更新登录页及优化相关功能
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
"""真实 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)
|
||||
Reference in New Issue
Block a user