import io import unittest from datetime import date, datetime from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from openpyxl import Workbook from sqlalchemy.dialects import mysql from sqlalchemy.ext.asyncio import AsyncSession from common.vo import PageModel from exceptions.exception import ServiceException from module_business.controller import line_controller, site_controller, tunnel_controller from module_business.dao.master_dao import MasterDao from module_business.dao.master_summary_dao import MasterSummaryDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.master_summary_vo import ( CustomerDetailModel, MasterImportPreviewModel, PalletDetailModel, PalletListItemModel, WarehouseListItemModel, ) from module_business.entity.vo.master_vo import ( CustomerModel, PalletModel, PalletPageQueryModel, WarehouseModel, ) from module_business.service.master_excel_service import MasterExcelService from module_business.service.master_summary_service import MasterSummaryService from tests.module_business.test_batch_fixtures import make_admin, make_test_scope 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 result_rows(rows: list[Any]) -> MagicMock: result = MagicMock() result.all.return_value = rows return result class _Upload: def __init__(self, content: bytes) -> None: self.content = content async def read(self) -> bytes: return self.content async def close(self) -> None: return None def workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes: workbook = Workbook() worksheet = workbook.active worksheet.append(headers) for row in rows: worksheet.append(row) buffer = io.BytesIO() workbook.save(buffer) return buffer.getvalue() SUMMARY_GETTERS = ( 'get_strain_summaries', 'get_customer_summaries', 'get_site_summaries', 'get_line_summaries', 'get_tunnel_summaries', 'get_warehouse_summaries', 'get_pallet_summaries', ) class MasterSummaryDaoTest(unittest.IsolatedAsyncioTestCase): async def test_empty_ids_skip_database(self) -> None: for getter in SUMMARY_GETTERS: db = MagicMock(spec=AsyncSession) with self.subTest(getter=getter): self.assertEqual({}, await getattr(MasterSummaryDao, getter)(db, [], TestBatchScope.formal())) db.execute.assert_not_called() async def test_every_summary_query_is_bound_to_current_batch(self) -> None: for getter in SUMMARY_GETTERS: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([])) with self.subTest(getter=getter): summaries = await getattr(MasterSummaryDao, getter)(db, [3, 3, 1], make_test_scope(7)) self.assertEqual([1, 3], sorted(summaries)) self.assertGreater(db.execute.await_count, 0) for call in db.execute.await_args_list: sql = compile_sql(call.args[0]) self.assertIn('test_batch_id = 7', sql) self.assertNotIn('test_batch_id IS NULL', sql) async def test_customer_site_tree_queries_are_bound_to_current_batch(self) -> None: site = SimpleNamespace(site_id=5, site_code='S-01', site_name='南厂', address=None, status='0') db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(side_effect=[result_rows([site])] + [result_rows([])] * 4) tree = await MasterSummaryDao.get_customer_site_tree(db, 2, make_test_scope(7)) self.assertEqual(5, db.execute.await_count) self.assertEqual([{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'address': None, 'status': '0', 'lines': [], 'tunnels': [], 'users': []}], tree) for call in db.execute.await_args_list: self.assertIn('test_batch_id = 7', compile_sql(call.args[0])) async def test_customer_without_sites_stops_after_first_query(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([])) self.assertEqual([], await MasterSummaryDao.get_customer_site_tree(db, 2, TestBatchScope.formal())) self.assertEqual(1, db.execute.await_count) async def test_pallet_summary_merges_state_cycles_and_return_target(self) -> None: shipped_at = datetime(2026, 10, 1, 9, 0) db = MagicMock(spec=AsyncSession) db.execute = AsyncMock( side_effect=[ result_rows( [ (11, 'RETURNING', 0, None, '金禾菌业', '南厂', None, None, None, None, None), (12, 'FILLED_IN_WAREHOUSE', 100, '成品一仓', None, None, None, None, '香菇', 'B-1', date(2026, 10, 30)), ] ), result_rows([(11, 3, shipped_at, shipped_at), (12, 0, None, shipped_at)]), result_rows([(11, '周转仓')]), ] ) summaries = await MasterSummaryDao.get_pallet_summaries(db, [12, 11], TestBatchScope.formal()) self.assertEqual('RETURNING', summaries[11]['flow_state']) self.assertEqual(3, summaries[11]['cycle_count']) self.assertEqual('周转仓', summaries[11]['return_warehouse_name']) self.assertEqual('成品一仓', summaries[12]['warehouse_name']) self.assertEqual('香菇', summaries[12]['strain_name']) self.assertNotIn('return_warehouse_name', summaries[12]) sql = compile_sql(db.execute.await_args_list[2].args[0]) self.assertIn("biz_return_order.status = 'REQUESTED'", sql) class ParentOptionTest(unittest.IsolatedAsyncioTestCase): async def test_customer_options_only_list_active_customers_in_batch(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([(2, 'C-01', '金禾菌业')])) options = await MasterSummaryDao.list_customer_options(db, make_test_scope(7)) self.assertEqual([{'id': 2, 'code': 'C-01', 'name': '金禾菌业'}], options) sql = compile_sql(db.execute.await_args.args[0]) self.assertIn("biz_customer.status = '0'", sql) self.assertIn('biz_customer.test_batch_id = 7', sql) self.assertIn('ORDER BY biz_customer.customer_code', sql) async def test_site_options_require_active_site_and_active_customer(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([(5, 'S-01', '南厂', '金禾菌业')])) options = await MasterSummaryDao.list_site_options(db, TestBatchScope.formal()) self.assertEqual([{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}], options) sql = compile_sql(db.execute.await_args.args[0]) self.assertIn("biz_customer_site.status = '0'", sql) self.assertIn("biz_customer.status = '0'", sql) self.assertIn('biz_customer_site.test_batch_id IS NULL', sql) self.assertIn('biz_customer.test_batch_id IS NULL', sql) async def test_service_wraps_options_in_camel_case_models(self) -> None: rows = [{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}] with patch.object(MasterSummaryDao, 'list_site_options', new=AsyncMock(return_value=rows)): options = await MasterSummaryService.site_options(MagicMock(spec=AsyncSession), TestBatchScope.formal()) self.assertEqual({'id': 5, 'code': 'S-01', 'name': '南厂', 'parentName': '金禾菌业'}, options[0].model_dump(by_alias=True)) def test_option_routes_are_registered_before_detail_routes(self) -> None: for module, path, permission in ( (site_controller, '/master/site/customer-options', 'master:site:list'), (line_controller, '/master/line/site-options', 'master:line:list'), (tunnel_controller, '/master/tunnel/site-options', 'master:tunnel:list'), ): resource = path.split('/')[2] option_router = getattr(module, f'{resource}_option_controller') main_router = getattr(module, f'{resource}_controller') with self.subTest(path=path): self.assertLess(option_router.order_num, main_router.order_num) (route,) = [route for route in option_router.routes if getattr(route, 'path', '') == path] self.assertEqual({'GET'}, route.methods) self.assertEqual( [permission], [dep.dependency.perm for dep in route.dependencies if hasattr(dep.dependency, 'perm')], ) class PalletListFilterTest(unittest.IsolatedAsyncioTestCase): async def _capture(self, query: PalletPageQueryModel, scope: TestBatchScope) -> str: with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: await MasterDao.list_pallet(MagicMock(spec=AsyncSession), query, scope, is_page=True) return compile_sql(paginate.await_args.args[1]) async def test_flow_group_filters_by_inventory_state_in_same_batch(self) -> None: sql = await self._capture(PalletPageQueryModel(flowGroup='site'), make_test_scope(7)) self.assertIn('biz_pallet.pallet_id IN (SELECT biz_pallet_inventory.pallet_id', sql) self.assertIn("'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY'", sql) self.assertIn('biz_pallet_inventory.test_batch_id = 7', sql) self.assertNotIn('NOT IN', sql) async def test_unstocked_group_includes_pallets_without_inventory(self) -> None: sql = await self._capture(PalletPageQueryModel(flowGroup='unstocked'), TestBatchScope.formal()) self.assertIn("biz_pallet_inventory.flow_state IN ('UNSTOCKED')", sql) self.assertIn('biz_pallet.pallet_id NOT IN (SELECT biz_pallet_inventory.pallet_id', sql) async def test_code_search_also_matches_scanned_qr_token(self) -> None: sql = await self._capture(PalletPageQueryModel(palletCode='tok-1'), TestBatchScope.formal()) self.assertIn("biz_pallet.pallet_code LIKE '%tok-1%' OR biz_pallet.qr_token = 'tok-1'", sql) def test_flow_group_rejects_unknown_value(self) -> None: with self.assertRaises(ValueError): PalletPageQueryModel(flowGroup='lost') class MasterSummaryServiceTest(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.scope = TestBatchScope.formal() async def test_enrich_page_merges_summaries_into_list_rows(self) -> None: page = PageModel[Any]( rows=[ WarehouseModel(warehouseId=1, warehouseCode='W-01', warehouseName='成品一仓'), WarehouseModel(warehouseId=2, warehouseCode='W-02', warehouseName='周转仓'), ], pageNum=1, pageSize=10, total=2, hasNext=False, ) loader = AsyncMock(return_value={1: {'filled_pallet_count': 4, 'users': [{'user_id': 3, 'user_name': 'li'}]}}) with patch.object(MasterSummaryDao, 'get_warehouse_summaries', new=loader), patch.dict( 'module_business.service.master_summary_service._ENRICHERS', {'warehouse': ('warehouse_id', loader, WarehouseListItemModel)}, ): result = await MasterSummaryService.enrich_page(self.db, 'warehouse', page, self.scope) loader.assert_awaited_once_with(self.db, [1, 2], self.scope) self.assertEqual(2, result.total) self.assertIsInstance(result.rows[0], WarehouseListItemModel) self.assertEqual(4, result.rows[0].filled_pallet_count) self.assertEqual('li', result.rows[0].users[0].user_name) self.assertEqual(0, result.rows[1].filled_pallet_count) dumped = result.model_dump(by_alias=True)['rows'][0] self.assertEqual(4, dumped['filledPalletCount']) self.assertEqual('成品一仓', dumped['warehouseName']) async def test_customer_detail_carries_site_tree(self) -> None: tree = [{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'status': '0', 'unused_pallet_count': 2}] with patch.object(MasterSummaryDao, 'get_customer_site_tree', new=AsyncMock(return_value=tree)) as loader: detail = await MasterSummaryService.customer_detail( self.db, CustomerModel(customerId=2, customerCode='C-01', customerName='金禾菌业'), self.scope, ) loader.assert_awaited_once_with(self.db, 2, self.scope) self.assertIsInstance(detail, CustomerDetailModel) self.assertEqual('C-01', detail.customer_code) self.assertEqual(2, detail.sites[0].unused_pallet_count) async def test_pallet_detail_adds_state_and_recent_events(self) -> None: pallet = PalletModel(palletId=9, palletCode='P-9', qrToken='tok', capacityPackages=100, assetStatus='0') event = { 'event_id': 1, 'event_type': 'USAGE_RECORDED', 'occurred_at': datetime(2026, 10, 16, 9, 12), 'after_state': 'AT_SITE_PARTIAL', 'before_remaining_packages': 50, 'after_remaining_packages': 38, } with patch.object( MasterSummaryDao, 'get_pallet_summaries', new=AsyncMock(return_value={9: {'flow_state': 'AT_SITE_PARTIAL', 'remaining_packages': 38}}), ), patch.object(MasterSummaryDao, 'get_pallet_recent_events', new=AsyncMock(return_value=[event])): detail = await MasterSummaryService.pallet_detail(self.db, pallet, self.scope) self.assertIsInstance(detail, PalletDetailModel) self.assertIsInstance(detail, PalletListItemModel) self.assertEqual('tok', detail.qr_token) self.assertEqual(38, detail.remaining_packages) self.assertEqual('USAGE_RECORDED', detail.recent_events[0].event_type) class MasterImportPreviewTest(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = AsyncMock(spec=AsyncSession) self.db.execute = AsyncMock() self.headers = ['菌种编码', '菌种名称', '状态', '备注'] async def _preview(self, rows: list[list[object]], *, update_support: bool = False) -> MasterImportPreviewModel: return await MasterExcelService.preview_import( self.db, 'strain', _Upload(workbook_bytes(self.headers, rows)), # type: ignore[arg-type] update_support, make_admin(), TestBatchScope.formal(), ) async def test_preview_reports_each_row_and_never_commits(self) -> None: existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None) async def find(_db: object, code: str) -> object: return existing if code == 'S001' else None with patch.object(MasterDao, 'find_strain_by_code', new=find), patch.object( MasterDao, 'get_strain', new=AsyncMock(return_value=existing) ), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain, patch.object( MasterDao, 'update_strain', new=AsyncMock() ): preview = await self._preview( [['S001', '香菇', '正常', ''], ['S002', '平菇', '正常', ''], ['S003', '金针菇', '启用', '']] ) self.assertEqual(['菌种编码', '菌种名称', '状态', '备注'], preview.headers) self.assertEqual(3, preview.total_rows) self.assertEqual((1, 0, 1, 1), (preview.add_count, preview.update_count, preview.skip_count, preview.error_count)) self.assertEqual(['skip', 'add', 'error'], [row.outcome for row in preview.rows]) self.assertEqual([2, 3, 4], [row.excel_row for row in preview.rows]) self.assertIn('状态只能填写正常或停用', preview.rows[2].message) self.assertEqual(['S002', '平菇', '正常', ''], preview.rows[1].values) add_strain.assert_awaited_once() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_preview_with_update_support_marks_existing_rows_as_update(self) -> None: existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None) with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object( MasterDao, 'get_strain', new=AsyncMock(return_value=existing) ), patch.object(MasterDao, 'update_strain', new=AsyncMock()): preview = await self._preview([['S001', '新香菇', '停用', '']], update_support=True) self.assertEqual('update', preview.rows[0].outcome) self.assertTrue(preview.update_support) self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_whole_file_errors_are_raised_before_any_row_is_checked(self) -> None: with self.assertRaises(ServiceException) as raised: await self._preview([['S001', '香菇', '正常', ''], ['S001', '香菇', '正常', '']]) self.assertIn('重复编码', raised.exception.message) self.db.execute.assert_not_awaited() self.db.commit.assert_not_awaited() if __name__ == '__main__': unittest.main()