import unittest from datetime import date, datetime, timedelta 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.filling_summary_dao import FillingSummaryDao from module_business.dao.operation_dao import OperationDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( FillingBatchDetailModel, FillingBatchDetailQueryModel, FillingBatchModel, FillingBatchPageQueryModel, FillingListItemModel, FillingOverviewModel, FillingPageQueryModel, ) from module_business.service.filling_service import FillingService 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 result.first.return_value = rows[0] if rows else None return result def result_scalar(value: Any) -> MagicMock: result = MagicMock() result.scalar_one.return_value = value return result def batch_row(**overrides: Any) -> SimpleNamespace: values = { 'strain_id': 3, 'batch_no': 'B261017-01', 'strain_code': 'A15', 'strain_name': '平菇 A15', 'production_date': date(2026, 10, 16), 'expiry_date': date(2026, 11, 30), 'pallet_count': 6, 'package_sum': 600, 'last_operated_at': datetime(2026, 10, 17, 9, 10), 'last_filling_id': 88, } values.update(overrides) return SimpleNamespace(**values) 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:filling:list'], roles=['common'], user=SimpleNamespace(user_id=5, user_name='ln', admin=False), ) class FillingListFilterTest(unittest.IsolatedAsyncioTestCase): async def _capture(self, query: FillingPageQueryModel) -> str: with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: await OperationDao.get_filling_list( MagicMock(spec=AsyncSession), query, warehouse_ids=[7], scope=make_test_scope(4), ) return compile_sql(paginate.await_args.args[1]) async def test_keyword_matches_filling_no_pallet_code_or_batch_within_scope(self) -> None: sql = await self._capture(FillingPageQueryModel(keyword='B2610')) for fragment in ( 'JOIN biz_pallet ON biz_pallet.pallet_id = biz_filling_record.pallet_id', "biz_filling_record.filling_no LIKE '%B2610%'", "biz_pallet.pallet_code LIKE '%B2610%'", "biz_filling_record.batch_no LIKE '%B2610%'", 'biz_filling_record.test_batch_id = 4', 'biz_filling_record.warehouse_id IN (7)', ): self.assertIn(fragment, sql) async def test_without_keyword_or_pallet_code_list_does_not_join_pallets(self) -> None: sql = await self._capture(FillingPageQueryModel(batchNo='B1')) self.assertNotIn('JOIN', sql) def test_keyword_rejects_blank_value(self) -> None: for model in (FillingPageQueryModel, FillingBatchPageQueryModel): with self.subTest(model=model.__name__), self.assertRaises(ValueError): model(keyword=' ') class FillingListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): async def test_empty_ids_skip_database(self) -> None: db = MagicMock(spec=AsyncSession) self.assertEqual({}, await FillingSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) db.execute.assert_not_called() async def test_names_are_joined_in_same_batch_scope(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10401', '成品一仓', '年糕')])) summaries = await FillingSummaryDao.get_list_summaries(db, [32, 31, 31], make_test_scope(4)) self.assertEqual( {'pallet_code': 'TP-10401', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}, summaries[31] ) self.assertEqual({'pallet_code': None, 'warehouse_name': None, 'operator_name': None}, summaries[32]) sql = compile_sql(db.execute.await_args.args[0]) for fragment in ( 'biz_filling_record.filling_id IN (31, 32)', 'biz_filling_record.test_batch_id = 4', 'biz_pallet.test_batch_id = 4', 'biz_warehouse.test_batch_id = 4', 'sys_user.user_id = biz_filling_record.operator_id', "sys_user.del_flag = '0'", ): self.assertIn(fragment, sql) class FillingBatchPageDaoTest(unittest.IsolatedAsyncioTestCase): async def test_groups_by_strain_and_batch_newest_first_within_scope(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock( side_effect=[ result_scalar(18), result_rows([batch_row(), batch_row(batch_no='B261016-01', last_filling_id=80)]), result_rows([(88, '1kg/包', '年糕'), (80, '1.2kg/包', None)]), ] ) query = FillingBatchPageQueryModel( pageNum=2, pageSize=5, keyword='A15', strainId=3, beginTime='2026-10-01', endTime='2026-10-17' ) batches, total = await FillingSummaryDao.get_batch_page(db, query, [7, 6], make_test_scope(4)) self.assertEqual(18, total) self.assertEqual(['B261017-01', 'B261016-01'], [batch['batch_no'] for batch in batches]) self.assertEqual(('1kg/包', '年糕'), (batches[0]['package_spec'], batches[0]['last_operator_name'])) self.assertEqual(('1.2kg/包', None), (batches[1]['package_spec'], batches[1]['last_operator_name'])) FillingBatchModel.model_validate(batches[0]) count_sql, page_sql, latest_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) for sql in (count_sql, page_sql): for fragment in ( 'GROUP BY biz_filling_record.strain_id, biz_filling_record.batch_no', 'biz_filling_record.test_batch_id = 4', 'biz_filling_record.warehouse_id IN (6, 7)', "biz_filling_record.batch_no LIKE '%A15%'", "biz_filling_record.strain_name_snapshot LIKE '%A15%'", 'biz_filling_record.strain_id = 3', "biz_filling_record.operated_at >= '2026-10-01 00:00:00'", "biz_filling_record.operated_at < '2026-10-18 00:00:00'", ): self.assertIn(fragment, sql) self.assertIn( 'ORDER BY max(biz_filling_record.operated_at) DESC, max(biz_filling_record.filling_id) DESC', page_sql ) self.assertIn('LIMIT 5, 5', page_sql) self.assertIn('biz_filling_record.filling_id IN (80, 88)', latest_sql) async def test_empty_page_skips_latest_record_lookup(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(side_effect=[result_scalar(0), result_rows([])]) batches, total = await FillingSummaryDao.get_batch_page( db, FillingBatchPageQueryModel(), [], TestBatchScope.formal() ) self.assertEqual(([], 0), (batches, total)) self.assertEqual(2, db.execute.await_count) self.assertIn('false', compile_sql(db.execute.await_args_list[0].args[0]).lower()) class FillingBatchDetailDaoTest(unittest.IsolatedAsyncioTestCase): async def test_missing_batch_returns_none_without_further_queries(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([])) self.assertIsNone(await FillingSummaryDao.get_batch_detail(db, 3, 'B1', [7], TestBatchScope.formal())) self.assertEqual(1, db.execute.await_count) async def test_distribution_uses_active_cycle_and_closed_cycles_count_as_consumed(self) -> None: db = MagicMock(spec=AsyncSession) recent = [ (90, 'FL-90', 501, 'TP-10501', '成品一仓', '李娜', datetime(2026, 10, 17, 9, 10), '1kg/包'), (89, 'FL-89', 500, None, None, None, datetime(2026, 10, 17, 9, 5), '0.8kg/包'), ] db.execute = AsyncMock( side_effect=[ result_rows([batch_row(pallet_count=30)]), result_rows([('AT_SITE_UNUSED', 15), (None, 2), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5)]), result_rows(recent), ] ) detail = await FillingSummaryDao.get_batch_detail(db, 3, 'B261017-01', [7], make_test_scope(4)) self.assertEqual( [('AT_SITE_UNUSED', 15), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5), ('CONSUMED', 2)], [(item['state'], item['pallet_count']) for item in detail['distribution']], ) self.assertEqual(['TP-10501', None], [item['pallet_code'] for item in detail['recent']]) # 包装规格与操作人取批次内最新一条记录 self.assertEqual(('1kg/包', '李娜'), (detail['package_spec'], detail['last_operator_name'])) FillingBatchDetailModel.model_validate(detail) header_sql, distribution_sql, recent_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) for sql in (header_sql, distribution_sql, recent_sql): for fragment in ( 'biz_filling_record.strain_id = 3', "biz_filling_record.batch_no = 'B261017-01'", 'biz_filling_record.warehouse_id IN (7)', 'biz_filling_record.test_batch_id = 4', ): self.assertIn(fragment, sql) self.assertIn('biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', distribution_sql) self.assertIn('biz_pallet_inventory.test_batch_id = 4', distribution_sql) self.assertIn('ORDER BY biz_filling_record.operated_at DESC, biz_filling_record.filling_id DESC', recent_sql) self.assertIn('LIMIT 10', recent_sql) class FillingOverviewDaoTest(unittest.IsolatedAsyncioTestCase): def _db(self) -> MagicMock: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock( side_effect=[ result_rows([(3, 'B261017-01', '平菇 A15', 'A15', 6, 600)]), result_rows( [ (3, 'B261017-01', '平菇 A15', 'A15', 6, 600), (3, 'B261016-01', '平菇 A15', 'A15', 10, 1000), (4, 'B261015-01', '白灵菇 B22', 'B22', 27, 2700), (5, 'B261010-01', None, 'S130', 18, 1800), (6, 'B261008-01', '金针菇 J61', 'J61', 8, 800), ] ), result_rows([(6, '周转仓', 10), (4, '成品一仓', 13)]), result_rows([('B260930-01', date(2026, 10, 28), 3), ('B260923-01', date(2026, 10, 25), 4)]), ] ) return db async def test_overview_totals_top_strains_empty_pallets_and_nearest_expiry(self) -> None: db = self._db() overview = await FillingSummaryDao.get_overview( db, [4, 6], make_test_scope(4), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14, ) self.assertEqual( (6, 600, 1), (overview['today_pallet_count'], overview['today_package_sum'], overview['today_batch_count']) ) self.assertEqual((69, 5), (overview['month_pallet_count'], overview['month_batch_count'])) self.assertEqual( [('白灵菇 B22', 27), ('S130', 18), ('平菇 A15', 16)], [(item['strain_name'], item['pallet_count']) for item in overview['month_top_strains']], ) self.assertEqual(23, overview['empty_pallet_count']) self.assertEqual([4, 6], [row['warehouse_id'] for row in overview['empty_by_warehouse']]) self.assertEqual((7, 2), (overview['near_expiry_pallet_count'], overview['near_expiry_batch_count'])) self.assertEqual( ('B260923-01', date(2026, 10, 25)), (overview['nearest_expiry_batch_no'], overview['nearest_expiry_date']) ) FillingOverviewModel.model_validate(overview) today_sql, month_sql, empty_sql, near_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) self.assertIn("biz_filling_record.operated_at >= '2026-10-17 00:00:00'", today_sql) self.assertIn("biz_filling_record.operated_at >= '2026-10-01 00:00:00'", month_sql) for sql in (today_sql, month_sql): self.assertIn('biz_filling_record.warehouse_id IN (4, 6)', sql) self.assertIn('biz_filling_record.test_batch_id = 4', sql) self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql) self.assertIn("biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", near_sql) self.assertIn("biz_filling_record.expiry_date <= '2026-10-31'", near_sql) self.assertIn('biz_filling_record.filling_id = biz_pallet_inventory.active_filling_id', near_sql) for sql in (empty_sql, near_sql): self.assertIn('biz_pallet_inventory.warehouse_id IN (4, 6)', sql) self.assertIn('biz_pallet_inventory.test_batch_id = 4', sql) async def test_user_without_warehouses_sees_nothing(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(side_effect=[result_rows([]), result_rows([]), result_rows([]), result_rows([])]) overview = await FillingSummaryDao.get_overview( db, [], TestBatchScope.formal(), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14 ) self.assertEqual(0, overview['month_pallet_count']) self.assertIsNone(overview['nearest_expiry_date']) FillingOverviewModel.model_validate(overview) for call in db.execute.await_args_list: self.assertIn('false', compile_sql(call.args[0]).lower()) class FillingServiceSummaryTest(unittest.IsolatedAsyncioTestCase): async def test_list_rows_carry_names_in_camel_case(self) -> None: record = { 'filling_id': 31, 'filling_no': 'FL-1', 'pallet_id': 501, 'warehouse_id': 4, 'strain_id': 3, 'strain_code_snapshot': 'A15', 'strain_name_snapshot': '平菇 A15', 'batch_no': 'B1', 'package_spec': '1kg/包', 'initial_packages': 100, 'idempotency_key': 'k', 'request_hash': 'h', 'operator_id': 2, 'operated_at': datetime(2026, 10, 17, 9, 10), } page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) summary = {31: {'pallet_code': 'TP-10501', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}} with ( patch.object(OperationDao, 'get_filling_list', new=AsyncMock(return_value=page)), patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, ): result = await FillingService.get_filling_list_services( MagicMock(spec=AsyncSession), FillingPageQueryModel(), admin_user(), TestBatchScope.formal() ) self.assertEqual([31], summaries.await_args.args[1]) self.assertIsInstance(result.rows[0], FillingListItemModel) dumped = result.rows[0].model_dump(by_alias=True) self.assertEqual( ('TP-10501', '成品一仓', '年糕'), (dumped['palletCode'], dumped['warehouseName'], dumped['operatorName']) ) async def test_batches_page_uses_warehouse_scope_and_reports_has_next(self) -> None: batch = {**vars(batch_row()), 'package_spec': '1kg/包', 'last_operator_name': '年糕'} query = FillingBatchPageQueryModel(pageNum=1, pageSize=1) with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 4, 7])), patch.object(FillingSummaryDao, 'get_batch_page', new=AsyncMock(return_value=([batch], 3))) as get_page, ): result = await FillingService.get_filling_batches_services( MagicMock(spec=AsyncSession), query, normal_user(), TestBatchScope.formal() ) self.assertEqual([4, 7], get_page.await_args.args[2]) self.assertEqual((3, True), (result.total, result.has_next)) row = result.rows[0].model_dump(by_alias=True) self.assertEqual(('B261017-01', 600, '年糕'), (row['batchNo'], row['packageSum'], row['lastOperatorName'])) self.assertNotIn('lastFillingId', row) async def test_missing_batch_is_hidden_for_normal_user_and_precise_for_admin(self) -> None: query = FillingBatchDetailQueryModel(strainId=3, batchNo='B1') cases = ( (normal_user(), PermissionException, '无权访问该生产批次'), (admin_user(), ServiceException, '生产批次不存在'), ) for user, error, message in cases: with ( self.subTest(admin=user.user.admin), patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), patch.object(FillingSummaryDao, 'get_batch_detail', new=AsyncMock(return_value=None)) as get_detail, self.assertRaises(error) as raised, ): await FillingService.get_filling_batch_detail_services( MagicMock(spec=AsyncSession), query, user, TestBatchScope.formal() ) self.assertEqual(message, raised.exception.message) self.assertEqual((3, 'B1'), get_detail.await_args.args[1:3]) async def test_overview_passes_today_month_and_near_expiry_bounds(self) -> None: overview = { 'today_pallet_count': 0, 'today_package_sum': 0, 'today_batch_count': 0, 'month_start': date(2026, 10, 1), 'month_pallet_count': 0, 'month_batch_count': 0, 'month_top_strains': [], 'empty_pallet_count': 0, 'empty_by_warehouse': [], 'near_expiry_days': 14, 'near_expiry_pallet_count': 0, 'near_expiry_batch_count': 0, 'nearest_expiry_batch_no': None, 'nearest_expiry_date': None, } with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), patch.object(FillingSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview, ): result = await FillingService.get_filling_overview_services( MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal() ) self.assertIsInstance(result, FillingOverviewModel) _, warehouse_ids, _, today_start, month_start, near_until, days = get_overview.await_args.args self.assertEqual([7], warehouse_ids) self.assertEqual((0, 0), (today_start.hour, today_start.minute)) self.assertEqual((1, today_start.month), (month_start.day, month_start.month)) self.assertEqual(today_start.date() + timedelta(days=14), near_until) self.assertEqual(14, days) if __name__ == '__main__': unittest.main()