import unittest from datetime import datetime from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from sqlalchemy.dialects import mysql from sqlalchemy.ext.asyncio import AsyncSession from common.vo import PageModel from exceptions.exception import PermissionException, ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.dao.usage_summary_dao import UsageSummaryDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( UsageListItemModel, UsagePageQueryModel, UsageSiteBoardModel, UsageSiteOptionModel, ) from module_business.service.usage_service import UsageService from utils.page_util import PageUtil def compile_sql(statement: Any) -> str: compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) return ' '.join(str(compiled).split()).replace('%%', '%') def make_test_scope(batch_id: int) -> TestBatchScope: return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True) def result_rows(rows: list[Any]) -> MagicMock: result = MagicMock() result.all.return_value = rows return result def result_one(row: tuple[Any, ...]) -> MagicMock: result = MagicMock() result.one.return_value = row return result def result_scalar(value: Any) -> MagicMock: result = MagicMock() result.scalar_one_or_none.return_value = value return result def admin_user() -> CurrentUserModel: return CurrentUserModel.model_construct( permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True) ) def normal_user() -> CurrentUserModel: return CurrentUserModel.model_construct( permissions=['business:usage:list'], roles=['common'], user=SimpleNamespace(user_id=5, user_name='zq', admin=False), ) class UsageListFilterTest(unittest.IsolatedAsyncioTestCase): async def _capture(self, query: UsagePageQueryModel) -> str: with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: await OperationDao.get_usage_list( MagicMock(spec=AsyncSession), query, site_ids=[9], scope=make_test_scope(4) ) return compile_sql(paginate.await_args.args[1]) async def test_keyword_matches_usage_no_or_pallet_code_and_skip_filter_requires_reason(self) -> None: sql = await self._capture(UsagePageQueryModel(keyword='TP-104', fifoSkipped=True)) for fragment in ( 'JOIN biz_pallet ON biz_pallet.pallet_id = biz_usage_record.pallet_id', "biz_usage_record.usage_no LIKE '%TP-104%'", "biz_pallet.pallet_code LIKE '%TP-104%'", 'biz_usage_record.fifo_skip_reason IS NOT NULL', 'biz_usage_record.site_id IN (9)', 'biz_usage_record.test_batch_id = 4', ): self.assertIn(fragment, sql) async def test_plain_list_does_not_join_or_filter_skips(self) -> None: sql = await self._capture(UsagePageQueryModel(fifoSkipped=False)) self.assertNotIn('JOIN', sql) self.assertNotIn('fifo_skip_reason IS NOT NULL', sql) def test_keyword_rejects_blank_value(self) -> None: with self.assertRaises(ValueError): UsagePageQueryModel(keyword=' ') class UsageListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): async def test_empty_ids_skip_database(self) -> None: db = MagicMock(spec=AsyncSession) self.assertEqual({}, await UsageSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) db.execute.assert_not_called() async def test_names_and_skipped_pallet_come_from_same_scope_and_snapshot(self) -> None: db = MagicMock(spec=AsyncSession) snapshot = {'palletId': 100, 'palletCode': 'TP-10495', 'siteArrivedAt': '2026-09-10T08:30:00'} db.execute = AsyncMock( return_value=result_rows( [ (71, 'TP-10501', '南厂', 'JH-S-T03', '3 号隧道', '一号线', '赵强', snapshot), (72, 'TP-10502', '南厂', None, None, None, None, None), (73, 'TP-10503', '南厂', None, None, None, None, {'palletCode': ' '}), ] ) ) summaries = await UsageSummaryDao.get_list_summaries(db, [73, 72, 71], make_test_scope(4)) self.assertEqual( { 'pallet_code': 'TP-10501', 'site_name': '南厂', 'tunnel_code': 'JH-S-T03', 'tunnel_name': '3 号隧道', 'line_name': '一号线', 'operator_name': '赵强', 'skipped_pallet_code': 'TP-10495', }, summaries[71], ) self.assertIsNone(summaries[72]['skipped_pallet_code']) self.assertIsNone(summaries[73]['skipped_pallet_code']) sql = compile_sql(db.execute.await_args.args[0]) for fragment in ( 'biz_usage_record.usage_id IN (71, 72, 73)', 'biz_usage_record.test_batch_id = 4', 'biz_pallet.test_batch_id = 4', 'biz_customer_site.test_batch_id = 4', 'biz_tunnel.test_batch_id = 4', 'biz_production_line.test_batch_id = 4', "sys_user.del_flag = '0'", ): self.assertIn(fragment, sql) self.assertNotIn('trace_no', sql) class UsageSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase): async def test_user_sites_are_enabled_and_carry_counts(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([(4, 'JH-S', '南厂', '金禾菌业', 4, None)])) options = await UsageSummaryDao.get_site_options(db, [5, 4, 4], make_test_scope(4)) self.assertEqual( [ { 'id': 4, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业', 'tunnel_count': 4, 'line_count': 0, } ], options, ) sql = compile_sql(db.execute.await_args.args[0]) for fragment in ( 'biz_customer_site.site_id IN (4, 5)', "biz_customer_site.status = '0'", 'biz_customer_site.test_batch_id = 4', "biz_tunnel.status = '0'", 'biz_tunnel.test_batch_id = 4', "biz_production_line.status = '0'", ): self.assertIn(fragment, sql) async def test_without_sites_matches_nothing_and_admin_is_unfiltered(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([])) await UsageSummaryDao.get_site_options(db, [], TestBatchScope.formal()) self.assertIn('false', compile_sql(db.execute.await_args.args[0]).lower()) await UsageSummaryDao.get_site_options(db, None, TestBatchScope.formal()) self.assertNotIn('biz_customer_site.site_id IN', compile_sql(db.execute.await_args.args[0])) class UsageSiteBoardDaoTest(unittest.IsolatedAsyncioTestCase): async def test_board_combines_stock_today_usage_tunnels_and_skips(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock( side_effect=[ result_scalar('金禾菌业'), result_rows([('AT_SITE_UNUSED', 22, 2200), ('AT_SITE_PARTIAL', 4, 205), ('AT_SITE_EMPTY', 8, 0)]), result_rows([(4, '一号线', 3, 120), (None, None, 1, 20), (5, '二号线', 2, 200)]), result_one((1, 3)), result_rows( [ (2, 'JH-S-T01', '1 号隧道', True, datetime(2026, 9, 7, 7, 59)), (3, 'JH-S-T02', '2 号隧道', False, datetime(2026, 9, 1)), ] ), result_rows([(2, 1, 75)]), result_rows([(2, datetime(2026, 10, 17, 10, 55), '一号线')]), result_rows([(4, 'JH-S-L1', '一号线'), (5, 'JH-S-L2', '二号线')]), ] ) site = SimpleNamespace(site_id=4, site_code='JH-S', site_name='南厂', customer_id=3) board = await UsageSummaryDao.get_site_board( db, site, make_test_scope(4), datetime(2026, 10, 18), datetime(2026, 10, 12) ) self.assertEqual( (26, 2405, 22, 4, 8), ( board['pallet_count'], board['package_sum'], board['unused_pallet_count'], board['partial_pallet_count'], board['empty_pallet_count'], ), ) self.assertEqual((6, 340), (board['today_usage_count'], board['today_used_packages'])) self.assertEqual([200, 120, 20], [row['used_packages'] for row in board['today_by_line']]) self.assertEqual((2, 1), (board['tunnel_count'], board['active_tunnel_count'])) self.assertEqual((1, 3), (board['today_fifo_skip_count'], board['week_fifo_skip_count'])) first, idle = board['tunnels'] self.assertEqual( (1, 75, '一号线'), (first['pallet_count'], first['remaining_packages'], first['last_line_name']) ) self.assertEqual( (False, 0, None, None), (idle['in_use'], idle['pallet_count'], idle['started_at'], idle['last_used_at']) ) self.assertNotIn('trace_no', board['tunnels'][0]) UsageSiteBoardModel.model_validate(board) statements = [compile_sql(call.args[0]) for call in db.execute.await_args_list] _, stock_sql, today_sql, skip_sql, tunnel_sql, load_sql, last_sql, line_sql = statements self.assertIn('biz_pallet_inventory.site_id = 4', stock_sql) self.assertIn('biz_pallet_inventory.warehouse_id IS NULL', stock_sql) self.assertIn("biz_usage_record.occurred_at >= '2026-10-18 00:00:00'", today_sql) self.assertIn("biz_usage_record.occurred_at >= '2026-10-12 00:00:00'", skip_sql) self.assertIn('biz_usage_record.fifo_skip_reason IS NOT NULL', skip_sql) # 只暴露隧道是否在用,不返回内部溯源号的值 self.assertIn('biz_tunnel.current_trace_no IS NOT NULL AS in_use', tunnel_sql) self.assertIn("biz_tunnel.status = '0'", tunnel_sql) self.assertIn('biz_pallet_inventory.remaining_packages > 0', load_sql) self.assertIn('max(biz_usage_record.usage_id)', last_sql) for sql in statements[1:]: self.assertRegex(sql, r'test_batch_id = 4') self.assertIn("biz_production_line.status = '0'", line_sql) class UsageServiceSummaryTest(unittest.IsolatedAsyncioTestCase): async def test_list_rows_carry_names_in_camel_case(self) -> None: record = { 'usage_id': 71, 'usage_no': 'US-1', 'pallet_id': 501, 'active_filling_id': 9, 'site_id': 4, 'tunnel_id': 2, 'used_packages': 20, 'remaining_packages_after': 80, 'idempotency_key': 'k', 'request_hash': 'h', 'operator_id': 2, 'occurred_at': datetime(2026, 10, 17, 10, 55), 'fifo_skip_reason': '建议托盘外包装破损', } page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) summary = { 71: { 'pallet_code': 'TP-10501', 'site_name': '南厂', 'tunnel_code': 'JH-S-T01', 'tunnel_name': '1 号隧道', 'line_name': '一号线', 'operator_name': '赵强', 'skipped_pallet_code': 'TP-10495', } } with ( patch.object(OperationDao, 'get_usage_list', new=AsyncMock(return_value=page)), patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, ): result = await UsageService.get_usage_list_services( MagicMock(spec=AsyncSession), UsagePageQueryModel(), admin_user(), TestBatchScope.formal() ) self.assertEqual([71], summaries.await_args.args[1]) self.assertIsInstance(result.rows[0], UsageListItemModel) dumped = result.rows[0].model_dump(by_alias=True) self.assertEqual( ('TP-10501', '1 号隧道', '一号线', 'TP-10495'), (dumped['palletCode'], dumped['tunnelName'], dumped['lineName'], dumped['skippedPalletCode']), ) async def test_site_options_use_site_scope(self) -> None: rows = [ {'id': 4, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业', 'tunnel_count': 4, 'line_count': 3} ] with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[5, 4, 4])), patch.object(UsageSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options, ): result = await UsageService.get_site_options_services( MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal() ) self.assertEqual([4, 5], get_options.await_args.args[1]) self.assertIsInstance(result[0], UsageSiteOptionModel) self.assertEqual(4, result[0].model_dump(by_alias=True)['tunnelCount']) async def test_board_requires_site_access_before_reading(self) -> None: get_board = AsyncMock() with ( patch.object(AccessDao, 'user_has_site', new=AsyncMock(return_value=False)), patch.object(UsageSummaryDao, 'get_site_board', new=get_board), self.assertRaises(PermissionException), ): await UsageService.get_site_board_services( MagicMock(spec=AsyncSession), 7, normal_user(), TestBatchScope.formal() ) get_board.assert_not_awaited() async def test_board_rejects_missing_site_and_passes_today_and_week_bounds(self) -> None: with ( patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): await UsageService.get_site_board_services( MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal() ) self.assertEqual('客户现场不存在', raised.exception.message) site = SimpleNamespace(site_id=4, test_batch_id=None) board = { 'site_id': 4, 'pallet_count': 0, 'package_sum': 0, 'unused_pallet_count': 0, 'partial_pallet_count': 0, 'empty_pallet_count': 0, 'today_usage_count': 0, 'today_used_packages': 0, 'tunnel_count': 0, 'active_tunnel_count': 0, 'today_fifo_skip_count': 0, 'week_fifo_skip_count': 0, } with ( patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=site)), patch.object(UsageSummaryDao, 'get_site_board', new=AsyncMock(return_value=board)) as get_board, ): result = await UsageService.get_site_board_services( MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal() ) self.assertIsInstance(result, UsageSiteBoardModel) today_start, week_start = get_board.await_args.args[3:5] self.assertEqual((0, 0), (today_start.hour, today_start.minute)) self.assertEqual(6, (today_start - week_start).days) if __name__ == '__main__': unittest.main()