"""隔离内存库验证真实 FIFO 查询,不依赖业务数据库。""" import unittest from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch from sqlalchemy import Column, MetaData, Table, create_engine from sqlalchemy.orm import Session from exceptions.exception import PermissionException from module_business.dao.usage_fifo_dao import UsageFifoDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory from module_business.service.access_service import AccessService from module_business.service.usage_fifo_service import UsageFifoService from module_business.service.usage_service import UsageService from module_business.dao.master_dao import MasterDao from types import SimpleNamespace from tests.module_business.test_batch_fixtures import make_test_scope class TestUsageFifo(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.engine = create_engine('sqlite:///:memory:') metadata = MetaData() self.tables = {} for model in (BizPallet, BizFillingRecord, BizPalletInventory): self.tables[model] = Table( model.__tablename__, metadata, *[Column(c.name, c.type, primary_key=c.primary_key) for c in model.__table__.columns], ) metadata.create_all(self.engine) self.session = Session(self.engine) self.db = MagicMock() self.db.execute = AsyncMock(side_effect=self.session.execute) def tearDown(self) -> None: self.session.close() self.engine.dispose() def add( self, pallet_id, arrived_at, *, site=9, strain=4, batch=None, remaining=100, state='AT_SITE_UNUSED', tunnel=None, asset='0', filling_batch=None, trace='TRACE-CURRENT', ) -> None: self.session.execute( self.tables[BizPallet].insert(), { 'pallet_id': pallet_id, 'pallet_code': f'P-{pallet_id}', 'qr_token': f'qr-{pallet_id}', 'test_batch_id': batch, 'asset_status': asset, 'capacity_packages': 100, }, ) self.session.execute( self.tables[BizFillingRecord].insert(), { 'filling_id': pallet_id, 'pallet_id': pallet_id, 'strain_id': strain, 'batch_no': f'B-{pallet_id}', 'test_batch_id': batch if filling_batch is None else filling_batch, 'active_slot': 'ACTIVE', 'closed_at': None, }, ) self.session.execute( self.tables[BizPalletInventory].insert(), { 'pallet_id': pallet_id, 'active_filling_id': pallet_id, 'site_id': site, 'warehouse_id': None, 'test_batch_id': batch, 'tunnel_id': tunnel, 'trace_no': trace if tunnel is not None else None, 'flow_state': state, 'remaining_packages': remaining, 'site_arrived_at': arrived_at, }, ) async def query(self, tunnel=None, scope=None): return await UsageFifoDao.recommendation( self.db, site_id=9, strain_id=4, tunnel_id=tunnel, trace_no='TRACE-CURRENT' if tunnel is not None else None, scope=scope or TestBatchScope.formal(), ) async def test_orders_by_real_receipt_time_not_pallet_id_and_supports_partial(self) -> None: self.add(1, datetime(2026, 10, 8)) self.add(50, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL') candidate, unknown = await self.query() self.assertEqual(candidate['pallet_id'], 50) self.assertEqual(candidate['remaining_packages'], 80) self.assertEqual(candidate['batch_no'], 'B-50') self.assertEqual(unknown, 0) async def test_excludes_unreceived_empty_returning_wrong_site_strain_and_test_scope(self) -> None: early = datetime(2026, 10, 1) for i, change in enumerate( [ {'state': 'IN_TRANSIT'}, {'remaining': 0}, {'state': 'RETURNING'}, {'site': 10}, {'strain': 8}, {'batch': 7}, {'asset': '1'}, {'filling_batch': 7}, ], 1, ): self.add(i, early, **change) self.add(99, datetime(2026, 10, 8)) candidate, _ = await self.query() self.assertEqual(candidate['pallet_id'], 99) test_candidate, _ = await self.query(scope=make_test_scope(7)) self.assertEqual(test_candidate['pallet_id'], 6) async def test_unknown_legacy_arrivals_are_reported_not_fabricated(self) -> None: self.add(1, None) self.add(2, datetime(2026, 10, 8)) candidate, unknown = await self.query() self.assertEqual(candidate['pallet_id'], 2) self.assertEqual(unknown, 1) async def test_reserved_other_tunnels_are_not_recommended(self) -> None: self.add(1, datetime(2026, 10, 1), tunnel=12) self.add(2, datetime(2026, 10, 2), tunnel=13) self.add(3, datetime(2026, 10, 3)) candidate, _ = await self.query() self.assertEqual(candidate['pallet_id'], 3) candidate, _ = await self.query(tunnel=13) self.assertEqual(candidate['pallet_id'], 2) async def test_no_stock_returns_no_recommendation(self) -> None: self.assertEqual(await self.query(), (None, 0)) async def test_old_tunnel_trace_is_not_recommended_or_counted_as_unknown(self) -> None: self.add(1, datetime(2026, 10, 1), tunnel=13, trace='TRACE-OLD') self.add(2, None, tunnel=13, trace='TRACE-OLD') self.add(3, datetime(2026, 10, 8), tunnel=13) candidate, unknown = await self.query(tunnel=13) self.assertEqual(candidate['pallet_id'], 3) self.assertEqual(unknown, 0) async def test_permission_failure_stops_before_candidate_query(self) -> None: with ( patch.object( UsageService, 'scan_usage_pallet_services', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问')), ), self.assertRaises(PermissionException), ): await UsageFifoService.recommendation(self.db, 'qr-other', MagicMock(), TestBatchScope.formal()) self.db.execute.assert_not_awaited() async def test_advice_uses_selected_tunnel_and_requires_reason_for_strictly_earlier(self) -> None: self.add(1, datetime(2026, 10, 1), tunnel=13) self.add(2, datetime(2026, 10, 8)) scanned = SimpleNamespace(pallet_id=2, site_id=9, strain_id=4, tunnel_id=None, trace_no=None) with ( patch.object(UsageService, 'scan_usage_pallet_services', new=AsyncMock(return_value=scanned)), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=SimpleNamespace( site_arrived_at=datetime(2026, 10, 8), test_batch_id=None))), patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=SimpleNamespace( site_id=9, status='0', current_trace_no='TRACE-CURRENT', test_batch_id=None))), ): result = await UsageFifoService.recommendation( self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13) self.assertEqual(result.recommended.pallet_id, 1) self.assertTrue(result.requires_skip_reason) async def test_queue_groups_by_strain_in_recommendation_order(self) -> None: # 同菌种按实际收货时间排序;已入隧和不可用的托盘不进入待使用队列 self.add(1, datetime(2026, 10, 8)) self.add(2, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL') self.add(3, datetime(2026, 10, 1), tunnel=13) self.add(4, None) self.add(5, datetime(2026, 10, 2), strain=8) self.add(6, datetime(2026, 10, 1), state='RETURNING') self.add(7, datetime(2026, 10, 1), site=10) groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal()) self.assertEqual([4, 8], [group['strain_id'] for group in groups]) main = groups[0] self.assertEqual([2, 1], [pallet['pallet_id'] for pallet in main['pallets']]) self.assertEqual((2, 1), (main['pallet_count'], main['unknown_arrival_count'])) self.assertEqual(80, main['pallets'][0]['remaining_packages']) self.assertEqual([5], [pallet['pallet_id'] for pallet in groups[1]['pallets']]) candidate, unknown = await self.query() self.assertEqual((main['pallets'][0]['pallet_id'], main['unknown_arrival_count']), (candidate['pallet_id'], unknown)) async def test_queue_limits_listed_pallets_but_counts_all_and_respects_test_scope(self) -> None: for pallet_id in range(1, 6): self.add(pallet_id, datetime(2026, 10, pallet_id)) self.add(9, datetime(2026, 9, 1), batch=7) groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal(), limit_per_strain=2) self.assertEqual(5, groups[0]['pallet_count']) self.assertEqual([1, 2], [pallet['pallet_id'] for pallet in groups[0]['pallets']]) test_groups = await UsageFifoDao.queue(self.db, site_id=9, scope=make_test_scope(7)) self.assertEqual([9], [pallet['pallet_id'] for pallet in test_groups[0]['pallets']]) async def test_queue_service_checks_site_access_before_query(self) -> None: with ( patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))), self.assertRaises(PermissionException), ): await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal()) self.db.execute.assert_not_awaited() self.add(1, datetime(2026, 10, 1)) with ( patch.object(AccessService, 'assert_site_access', new=AsyncMock()), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(site_id=9, test_batch_id=None))), ): result = await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal()) dumped = result.model_dump(by_alias=True) self.assertEqual(9, dumped['siteId']) self.assertEqual('P-1', dumped['strains'][0]['pallets'][0]['palletCode'])