"""真实 MySQL 验收:仅允许显式指定的隔离演练库,绝不写开发/生产库。 运行前将备份恢复并迁移到 shierfeng_rehearsal_20261008_* 库,再设置 SHIERFENG_MYSQL_FLOW_TEST_DATABASE。测试只新建独立测试批次,保留现场用于复核。 """ import asyncio import os import re import secrets import unittest from datetime import date, datetime, timedelta from unittest.mock import patch from sqlalchemy import func, select, update from sqlalchemy.engine import URL from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from config.env import DataBaseConfig from exceptions.exception import PermissionException, ServiceException from module_admin.entity.do import user_do # noqa: F401 from module_business.entity.do.master_do import ( BizCustomer, BizCustomerSite, BizPallet, BizStrain, BizTunnel, BizWarehouse, ) from module_business.entity.do.operation_do import ( BizPalletEvent, BizPalletInventory, BizReturnOrder, BizShipmentOrder, BizUsageRecord, ) from module_business.entity.do.test_batch_do import BizTestBatch from module_business.dao.inventory_dao import InventoryDao from module_business.dao.usage_fifo_dao import UsageFifoDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( ConfirmCommandModel, FillingCreateModel, ReturnConfirmModel, ReturnRequestModel, ShipmentCreateModel, TunnelEntryCreateModel, UsageCreateModel, ) from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel from module_business.entity.vo.report_vo import ( ConsumptionReportQueryModel, PalletInventoryReportQueryModel, PalletTraceReportQueryModel, ReturnReportQueryModel, ) from module_business.service.filling_service import FillingService from module_business.service.return_service import ReturnService from module_business.service.report_service import ReportService from module_business.service.shipment_label_service import ShipmentLabelService from module_business.service.shipment_receipt_service import ShipmentReceiptService from module_business.service.shipment_service import ShipmentService from module_business.service.usage_fifo_service import UsageFifoService from module_business.service.usage_service import UsageService from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope @unittest.skipUnless(os.environ.get('SHIERFENG_MYSQL_FLOW_TEST_DATABASE'), '需显式指定隔离MySQL演练库') class TestMaterialFlowMysql(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): database = os.environ['SHIERFENG_MYSQL_FLOW_TEST_DATABASE'] if not re.fullmatch(r'shierfeng_rehearsal_20261008_[a-zA-Z0-9_]+', database): raise RuntimeError('只允许隔离演练库,拒绝写开发/生产数据库') if DataBaseConfig.db_host not in ('127.0.0.1', 'localhost'): raise RuntimeError('只允许本机MySQL') self.engine = create_async_engine(URL.create( 'mysql+asyncmy', username=DataBaseConfig.db_username, password=DataBaseConfig.db_password, host=DataBaseConfig.db_host, port=DataBaseConfig.db_port, database=database, query={'charset': 'utf8mb4'}, ), echo=False) self.sessions = async_sessionmaker(self.engine, autoflush=False) self.admin = make_admin() self.tag = 'FIFO-' + secrets.token_hex(6) async with self.sessions() as db: batch = BizTestBatch(batch_code=self.tag, batch_name='迁移隔离验收', status='ACTIVE') db.add(batch) await db.flush() self.scope = make_test_scope(batch.batch_id) self.batch_id = batch.batch_id warehouse = BizWarehouse(warehouse_code=self.tag, warehouse_name='验收仓库', test_batch_id=self.batch_id) customer = BizCustomer(customer_code=self.tag, customer_name='验收客户', test_batch_id=self.batch_id) strain = BizStrain(strain_code=self.tag, strain_name='验收品种', test_batch_id=self.batch_id) db.add_all([warehouse, customer, strain]) await db.flush() site = BizCustomerSite(customer_id=customer.customer_id, site_code=self.tag, site_name='验收现场', test_batch_id=self.batch_id) db.add(site) await db.flush() tunnel = BizTunnel(site_id=site.site_id, tunnel_code=self.tag, tunnel_name='验收隧道', test_batch_id=self.batch_id) db.add(tunnel) self.pallets = [] for index in (1, 2): pallet = BizPallet(pallet_code=f'{self.tag}-{index}', qr_token=f'{self.tag}/qr/{index}', capacity_packages=100, asset_status='0', test_batch_id=self.batch_id) db.add(pallet) await db.flush() db.add(BizPalletInventory(pallet_id=pallet.pallet_id, warehouse_id=warehouse.warehouse_id, flow_state='EMPTY_IN_WAREHOUSE', remaining_packages=0, version=0, updated_at=datetime.now(), test_batch_id=self.batch_id)) self.pallets.append((pallet.pallet_id, pallet.pallet_code, pallet.qr_token)) await db.flush() self.warehouse_id, self.site_id = warehouse.warehouse_id, site.site_id self.strain_id, self.tunnel_id = strain.strain_id, tunnel.tunnel_id await db.commit() async def asyncTearDown(self): await self.engine.dispose() async def call(self, method, *args, user=None): async with self.sessions() as db: return await method(db, *args, user or self.admin, self.scope) async def scalar(self, statement): async with self.sessions() as db: return await db.scalar(statement) async def test_full_receipt_fifo_mix_return_and_concurrent_retry(self): p1, p2 = self.pallets for index, pallet in enumerate(self.pallets, 1): await self.call(FillingService.fill_pallet_services, FillingCreateModel( qrToken=pallet[2], warehouseId=self.warehouse_id, strainId=self.strain_id, batchNo=f'{self.tag}-B{index}', productionDate='2026-10-01', expiryDate='2027-04-01', packageSpec='标准包', initialPackages=100, idempotencyKey=f'{self.tag}-fill-{index}', )) await self.call(ShipmentService.add_shipment_services, ShipmentCreateModel( sourceWarehouseId=self.warehouse_id, targetSiteId=self.site_id, palletIds=[p1[0], p2[0]], )) shipment_id = await self.scalar(select(BizShipmentOrder.shipment_id).where( BizShipmentOrder.test_batch_id == self.batch_id)) await self.call(ShipmentService.confirm_shipment_services, shipment_id, ConfirmCommandModel(idempotencyKey=self.tag + '-ship')) labels = await self.call(ShipmentLabelService.get_labels_services, shipment_id) self.assertEqual({l.pallet_code for l in labels}, {p1[1], p2[1]}) self.assertTrue(all(l.production_date == date(2026, 10, 1) and l.package_qty == 100 for l in labels)) entry = TunnelEntryCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry2') with self.assertRaises(ServiceException): await self.call(UsageService.enter_tunnel_services, entry) # 在途不允许使用 receipt = ShipmentReceiptCommandModel(scannedPalletIds=[p1[0], p2[0]], idempotencyKey=self.tag+'-receive') with self.assertRaises(PermissionException): await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt, user=make_current_user(7)) await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt) # 两次实际到货的排序边界夹具,仅改本演练批次,不造开发库历史时间。 async with self.sessions() as db: inventory = await db.get(BizPalletInventory, p1[0]) inventory.site_arrived_at -= timedelta(days=1) await db.commit() async with self.sessions() as db: advice = await UsageFifoService.recommendation( db, p2[1], self.admin, self.scope, tunnel_id=self.tunnel_id) self.assertTrue(advice.requires_skip_reason) self.assertEqual(advice.recommended.pallet_id, p1[0]) with self.assertRaises(ServiceException) as error: await self.call(UsageService.enter_tunnel_services, entry) self.assertEqual(error.exception.data, 'FIFO_SKIP_REASON_REQUIRED') self.assertIsNone(await self.scalar(select(BizPalletInventory.tunnel_id).where(BizPalletInventory.pallet_id==p2[0]))) entry = entry.model_copy(update={'fifo_skip_reason': '工艺混批'}) await self.call(UsageService.enter_tunnel_services, entry) await self.call(UsageService.enter_tunnel_services, entry) # 已提交入隧幂等分支也不得触发过期加载 usage = UsageCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, usedPackages=20, idempotencyKey=self.tag+'-usage2') with self.assertRaises(ServiceException) as error: await self.call(UsageService.record_usage_services, usage) self.assertEqual(error.exception.data, 'FIFO_SKIP_REASON_REQUIRED') self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==p2[0])),100) usage = usage.model_copy(update={'fifo_skip_reason': '工艺混批'}) # 独立MySQL连接同时重放相同业务命令,只能产生一条事实和一次扣减。 results = await asyncio.wait_for(asyncio.gather(*( self.call(UsageService.record_usage_services, usage) for _ in range(3) )), timeout=20) self.assertTrue(all(result.result.remaining_packages == 80 for result in results)) self.assertEqual(await self.scalar(select(func.count()).select_from(BizUsageRecord).where(BizUsageRecord.test_batch_id==self.batch_id)),1) record = await self.scalar(select(BizUsageRecord).where(BizUsageRecord.test_batch_id==self.batch_id)) self.assertEqual(record.fifo_skip_reason, '工艺混批') self.assertEqual(record.fifo_recommendation['palletCode'], p1[1]) with self.assertRaises(ServiceException): await self.call(UsageService.record_usage_services, usage.model_copy(update={'fifo_skip_reason':'更换原因'})) await self.call(UsageService.enter_tunnel_services, TunnelEntryCreateModel( qrToken=p1[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry1')) await self.call(UsageService.record_usage_services, UsageCreateModel( qrToken=p1[2], tunnelId=self.tunnel_id, usedPackages=20, idempotencyKey=self.tag+'-usage1')) traces = [await self.scalar(select(BizPalletInventory.trace_no).where(BizPalletInventory.pallet_id==p[0])) for p in self.pallets] self.assertTrue(traces[0] and traces[0] == traces[1]) await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt) self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==p2[0])),80) await self.call(ReturnService.request_return_services, ReturnRequestModel( sourceSiteId=self.site_id, targetWarehouseId=self.warehouse_id, palletIds=[p2[0]], idempotencyKey=self.tag+'-return')) return_id = await self.scalar(select(BizReturnOrder.return_id).where(BizReturnOrder.test_batch_id==self.batch_id)) await self.call(ReturnService.confirm_return_services, return_id, ReturnConfirmModel( scannedPalletIds=[p2[0]], idempotencyKey=self.tag+'-return-confirm')) returned = await self.scalar(select(BizPalletInventory).where(BizPalletInventory.pallet_id==p2[0])) self.assertEqual((returned.remaining_packages,returned.warehouse_id,returned.flow_state), (80,self.warehouse_id,'FILLED_IN_WAREHOUSE')) self.assertIsNone(returned.site_arrived_at) self.assertEqual(await self.scalar(select(func.count()).select_from(BizPallet).where(BizPallet.test_batch_id==self.batch_id)),2) labels_after = await self.call(ShipmentLabelService.get_labels_services, shipment_id) self.assertEqual({(l.pallet_code,l.batch_no,l.package_qty) for l in labels_after}, {(p1[1],self.tag+'-B1',100),(p2[1],self.tag+'-B2',100)}) events = await self.scalar(select(func.count()).select_from(BizPalletEvent).where( BizPalletEvent.test_batch_id==self.batch_id, BizPalletEvent.fifo_skip_reason=='工艺混批')) self.assertEqual(events,2) consumption = await self.call(ReportService.get_consumption_report_list_services, ConsumptionReportQueryModel(tunnelId=self.tunnel_id)) self.assertEqual(consumption.total, 2) self.assertEqual(sum(row.used_packages for row in consumption.rows), 40) self.assertTrue(all(row.line_id is None and row.trace_no == traces[0] for row in consumption.rows)) self.assertEqual({row.batch_no for row in consumption.rows}, {self.tag+'-B1', self.tag+'-B2'}) returned_report = await self.call(ReportService.get_return_report_list_services, ReturnReportQueryModel(tunnelId=self.tunnel_id)) self.assertEqual(returned_report.total, 1) self.assertEqual(returned_report.rows[0].remaining_packages_snapshot, 80) inventory_report = await self.call(ReportService.get_pallet_inventory_list_services, PalletInventoryReportQueryModel()) self.assertEqual(inventory_report.total, 2) self.assertTrue(all(row.remaining_packages == 80 and row.production_date == date(2026, 10, 1) for row in inventory_report.rows)) trace_report = await self.call(ReportService.get_pallet_trace_list_services, PalletTraceReportQueryModel(palletCode=p2[1], pageSize=100)) entered = [row for row in trace_report.rows if row.event_type.value == 'TUNNEL_ENTERED'] self.assertEqual(len(entered), 1) self.assertEqual(entered[0].production_date, date(2026, 10, 1)) async def test_formal_two_pallet_lock_inversion_retries_without_wait_cycle(self): # 仅在隔离演练库将本方法新建的夹具设为正式模式;排除测试批次总锁掩盖并发问题。 async with self.sessions() as db: for model in (BizPalletInventory, BizPallet, BizTunnel, BizCustomerSite, BizCustomer, BizWarehouse, BizStrain): await db.execute(update(model).where(model.test_batch_id == self.batch_id).values(test_batch_id=None)) await db.commit() self.scope = TestBatchScope.formal() p1, p2 = self.pallets for index, pallet in enumerate(self.pallets): await self.call(FillingService.fill_pallet_services, FillingCreateModel( qrToken=pallet[2], warehouseId=self.warehouse_id, strainId=self.strain_id, batchNo=f'{self.tag}-B{index}', productionDate='2026-10-01', expiryDate='2027-04-01', packageSpec='标准包', initialPackages=100, idempotencyKey=f'{self.tag}-fill-{index}', )) # 已收货夹具,仍使用真实表、约束与后续全部真实SQL。 async with self.sessions() as db: for index, pallet in enumerate(self.pallets): inventory = await db.get(BizPalletInventory, pallet[0]) inventory.site_id, inventory.warehouse_id = self.site_id, None inventory.site_arrived_at = datetime(2026, 10, 6 + index) inventory.flow_state = 'AT_SITE_UNUSED' await db.commit() async def overlap(method, earlier, later): late_at_fifo, early_has_inventory = asyncio.Event(), asyncio.Event() real_fifo = UsageFifoDao.recommendation real_inventory = InventoryDao.get_inventory_for_update late_calls = 0 observations = [] inventory_attempts = [] async def scheduled_fifo(*args, **kwargs): nonlocal late_calls if asyncio.current_task().get_name() == 'fifo-later': late_calls += 1 if late_calls == 1: late_at_fifo.set() await early_has_inventory.wait() try: result = await real_fifo(*args, **kwargs) observations.append((asyncio.current_task().get_name(), 'candidate', result[0])) return result except Exception as error: observations.append((asyncio.current_task().get_name(), type(error).__name__, str(error))) raise async def scheduled_inventory(db, pallet_id, scope): inventory_attempts.append((asyncio.current_task().get_name(), pallet_id)) result = await real_inventory(db, pallet_id, scope) if asyncio.current_task().get_name() == 'fifo-earlier': early_has_inventory.set() return result with patch.object(UsageFifoDao, 'recommendation', new=scheduled_fifo), \ patch.object(InventoryDao, 'get_inventory_for_update', new=scheduled_inventory): late = asyncio.create_task(self.call(method, later), name='fifo-later') early = None try: await asyncio.wait_for(late_at_fifo.wait(), 5) early = asyncio.create_task(self.call(method, earlier), name='fifo-earlier') results = await asyncio.wait_for(asyncio.gather(late, early), 5) self.assertTrue(all(result.is_success for result in results)) # InnoDB 可以选择任一请求回滚;不能把“必须晚到请求重试”当业务约束。 self.assertGreaterEqual(len(inventory_attempts), 3, (observations, inventory_attempts)) for name in ('fifo-later', 'fifo-earlier'): self.assertLessEqual(sum(task_name == name for task_name, _ in inventory_attempts), 3) finally: for task in (late, early): if task is not None and not task.done(): task.cancel() await asyncio.gather(*(task for task in (late, early) if task is not None), return_exceptions=True) await overlap(UsageService.enter_tunnel_services, TunnelEntryCreateModel(qrToken=p1[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry1'), TunnelEntryCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry2',fifoSkipReason='工艺混批')) await overlap(UsageService.record_usage_services, UsageCreateModel(qrToken=p1[2], tunnelId=self.tunnel_id, usedPackages=20,idempotencyKey=self.tag+'-usage1'), UsageCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, usedPackages=20,idempotencyKey=self.tag+'-usage2',fifoSkipReason='工艺混批')) for pallet in self.pallets: self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==pallet[0])),80) self.assertEqual(await self.scalar(select(func.count()).select_from(BizUsageRecord).where(BizUsageRecord.pallet_id==pallet[0])),1)