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 module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.inbound_summary_dao import InboundSummaryDao 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 ( InboundListItemModel, InboundOverviewModel, InboundPageQueryModel, ) from module_business.service.inbound_service import InboundService 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[tuple[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.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) ) class InboundListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): async def test_empty_ids_skip_database(self) -> None: db = MagicMock(spec=AsyncSession) self.assertEqual({}, await InboundSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) db.execute.assert_not_called() async def test_aggregates_names_and_pallet_counts_within_batch_scope(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock( side_effect=[ result_rows([(12, '成品一仓', '李娜', '王强'), (13, None, None, None)]), result_rows([(12, 18)]), ] ) summaries = await InboundSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7)) self.assertEqual( {'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': '王强', 'pallet_count': 18}, summaries[12], ) # 没有明细的单据托盘数为 0,名称缺失保持为空 self.assertEqual( {'warehouse_name': None, 'creator_name': None, 'confirmer_name': None, 'pallet_count': 0}, summaries[13], ) name_sql = compile_sql(db.execute.await_args_list[0].args[0]) count_sql = compile_sql(db.execute.await_args_list[1].args[0]) for fragment in ( 'biz_inbound_order.inbound_id IN (12, 13)', 'biz_inbound_order.test_batch_id = 7', 'biz_warehouse.test_batch_id = 7', "creator.del_flag = '0'", 'confirmer.user_id = biz_inbound_order.confirmed_by', ): self.assertIn(fragment, name_sql) self.assertIn('biz_inbound_item.inbound_id IN (12, 13)', count_sql) self.assertIn('biz_inbound_item.test_batch_id = 7', count_sql) async def test_item_identities_join_pallet_and_inventory_in_same_scope(self) -> None: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10501', 'EMPTY_IN_WAREHOUSE'), (32, None, None)])) identities = await InboundSummaryDao.get_item_identities(db, 12, make_test_scope(7)) self.assertEqual({'pallet_code': 'TP-10501', 'flow_state': 'EMPTY_IN_WAREHOUSE'}, identities[31]) self.assertEqual({'pallet_code': None, 'flow_state': None}, identities[32]) sql = compile_sql(db.execute.await_args.args[0]) for fragment in ( 'biz_inbound_item.inbound_id = 12', 'biz_inbound_item.test_batch_id = 7', 'biz_pallet.test_batch_id = 7', 'biz_pallet_inventory.test_batch_id = 7', ): self.assertIn(fragment, sql) class InboundOverviewDaoTest(unittest.IsolatedAsyncioTestCase): def _db(self) -> MagicMock: db = MagicMock(spec=AsyncSession) db.execute = AsyncMock( side_effect=[ result_one((3, 26, datetime(2026, 10, 14, 9, 30))), result_one((28, 1240)), result_scalar(54), result_rows([(2, '成品二仓', 126), (1, '成品一仓', 168), (3, None, 78)]), ] ) return db async def test_overview_counts_and_sorts_empty_pallets_by_warehouse(self) -> None: db = self._db() overview = await InboundSummaryDao.get_overview(db, [1, 2, 3], make_test_scope(7), datetime(2026, 10, 1)) self.assertEqual(3, overview['draft_count']) self.assertEqual(26, overview['draft_pallet_count']) self.assertEqual(datetime(2026, 10, 14, 9, 30), overview['oldest_draft_at']) self.assertEqual(28, overview['month_confirmed_count']) self.assertEqual(1240, overview['month_confirmed_pallet_count']) self.assertEqual(54, overview['unstocked_pallet_count']) self.assertEqual(372, overview['empty_pallet_count']) self.assertEqual([1, 2, 3], [row['warehouse_id'] for row in overview['empty_by_warehouse']]) InboundOverviewModel.model_validate(overview) draft_sql, month_sql, unstocked_sql, empty_sql = ( compile_sql(call.args[0]) for call in db.execute.await_args_list ) for sql in (draft_sql, month_sql): self.assertIn('biz_inbound_order.test_batch_id = 7', sql) self.assertIn('biz_inbound_order.warehouse_id IN (1, 2, 3)', sql) self.assertIn('biz_inbound_item.test_batch_id = 7', sql) self.assertIn("biz_inbound_order.status = 'DRAFT'", draft_sql) self.assertIn("biz_inbound_order.status = 'CONFIRMED'", month_sql) self.assertIn("biz_inbound_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql) # 未入库托盘没有仓库归属,只受测试批次范围与资产状态约束 self.assertIn("biz_pallet_inventory.flow_state = 'UNSTOCKED'", unstocked_sql) self.assertIn("biz_pallet.asset_status = '0'", unstocked_sql) self.assertIn('biz_pallet_inventory.test_batch_id = 7', unstocked_sql) self.assertNotIn('warehouse_id IN', unstocked_sql) self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql) self.assertIn('biz_pallet_inventory.warehouse_id IN (1, 2, 3)', empty_sql) self.assertIn('biz_warehouse.test_batch_id = 7', empty_sql) async def test_user_without_warehouses_sees_no_orders_or_empty_pallets(self) -> None: db = self._db() await InboundSummaryDao.get_overview(db, [], TestBatchScope.formal(), datetime(2026, 10, 1)) draft_sql, month_sql, _unstocked_sql, empty_sql = ( compile_sql(call.args[0]) for call in db.execute.await_args_list ) for sql in (draft_sql, month_sql, empty_sql): self.assertIn('false', sql.lower()) async def test_admin_overview_has_no_warehouse_filter(self) -> None: db = self._db() await InboundSummaryDao.get_overview(db, None, TestBatchScope.formal(), datetime(2026, 10, 1)) for call in db.execute.await_args_list: self.assertNotIn('warehouse_id IN', compile_sql(call.args[0])) class InboundServiceSummaryTest(unittest.IsolatedAsyncioTestCase): async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None: record = {'inbound_id': 12, 'inbound_no': 'IN-1', 'warehouse_id': 7, 'status': 'DRAFT'} page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) summary = { 12: {'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': None, 'pallet_count': 18} } with ( patch.object(OperationDao, 'get_inbound_list', new=AsyncMock(return_value=page)), patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, ): result = await InboundService.get_inbound_list_services( MagicMock(spec=AsyncSession), InboundPageQueryModel(), admin_user(), TestBatchScope.formal() ) self.assertEqual([12], summaries.await_args.args[1]) row = result.rows[0] self.assertIsInstance(row, InboundListItemModel) dumped = row.model_dump(by_alias=True) self.assertEqual('成品一仓', dumped['warehouseName']) self.assertEqual('李娜', dumped['creatorName']) self.assertEqual(18, dumped['palletCount']) async def test_detail_items_carry_pallet_code_and_current_state(self) -> None: inbound = { 'inbound_id': 12, 'inbound_no': 'IN-1', 'warehouse_id': 7, 'status': 'CONFIRMED', 'items': [{'inbound_item_id': 31, 'inbound_id': 12, 'pallet_id': 501}], } identities = {31: {'pallet_code': 'TP-10501', 'flow_state': 'FILLED_IN_WAREHOUSE'}} with ( patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)), ): result = await InboundService.inbound_detail_services( MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal() ) item = result.model_dump(by_alias=True)['items'][0] self.assertEqual('TP-10501', item['palletCode']) self.assertEqual('FILLED_IN_WAREHOUSE', item['flowState']) async def test_overview_uses_warehouse_scope_and_month_start(self) -> None: user = CurrentUserModel.model_construct( permissions=['business:inbound:list'], roles=['common'], user=SimpleNamespace(user_id=5, user_name='ln', admin=False), ) overview = { 'draft_count': 0, 'draft_pallet_count': 0, 'oldest_draft_at': None, 'month_start': datetime(2026, 10, 1).date(), 'month_confirmed_count': 0, 'month_confirmed_pallet_count': 0, 'unstocked_pallet_count': 0, 'empty_pallet_count': 0, 'empty_by_warehouse': [], } with ( patch.object(InboundService, '_warehouse_scope', new=AsyncMock(return_value=[7])), patch.object(InboundSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview, ): result = await InboundService.get_inbound_overview_services( MagicMock(spec=AsyncSession), user, TestBatchScope.formal() ) self.assertIsInstance(result, InboundOverviewModel) self.assertEqual([7], get_overview.await_args.args[1]) month_start = get_overview.await_args.args[3] self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute)) if __name__ == '__main__': unittest.main()