"""隔离内存库验证真实 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.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)