Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_material_flow_mysql.py
T

302 lines
19 KiB
Python

"""真实 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)