更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -0,0 +1,467 @@
"""收货独立回归:真实内存查询/序列化,以及确认服务的确定性并发边界。
从 backend 目录运行(不连接业务数据库、不生成日志文件或 pyc):
PYTHONDONTWRITEBYTECODE=1 LOG_FILE_ENABLED=false <python> -B -m unittest \
tests.module_business.test_shipment_receipt_reads -v
SQLite 仅验证读取;确认测试的锁替身不代表 MySQL 的实际锁调度。
"""
import asyncio
import json
import unittest
from datetime import date, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from fastapi import FastAPI
from sqlalchemy import create_engine, event, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlalchemy.schema import CreateTable
from common.annotation.log_annotation import Log
from common.aspect.pre_auth import PreAuth
from common.context import RequestContext
from config.database import async_engine as business_engine
from config.get_db import get_db
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.log_service import LogQueueService
from module_admin.service.login_service import LoginService
from module_business.controller.shipment_receipt_controller import shipment_receipt_controller
from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.dependency.test_batch_dependency import TestBatchScopeResolver
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import (
BizCustomerSite, BizPallet, BizUserSite, BizUserWarehouse, BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord, BizPalletInventory, BizShipmentItem, BizShipmentOrder,
)
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel, ShipmentReceiptQueryModel
from module_business.service.shipment_receipt_service import ShipmentReceiptService
from module_business.service.test_batch_access_service import TestBatchAccessService
NOW = datetime(2026, 10, 8, 8)
def forbid_business_connection(*_args, **_kwargs):
raise AssertionError('收货测试禁止连接业务数据库')
def setUpModule():
event.listen(business_engine.sync_engine, 'do_connect', forbid_business_connection)
def tearDownModule():
event.remove(business_engine.sync_engine, 'do_connect', forbid_business_connection)
def user(user_id=7):
return CurrentUserModel(
permissions=['business:shipment:receive'], roles=[],
user=UserInfoModel(userId=user_id, userName=f'user-{user_id}'),
)
def batch_scope(batch_id=None):
if batch_id is None:
return TestBatchScope.formal()
return TestBatchScope(batch_id=batch_id, mode='TEST', status='ACTIVE', writable=True)
def order_values(shipment_id=12, site_id=9, batch_id=None, state='PENDING', status='CONFIRMED'):
return dict(
shipment_id=shipment_id, shipment_no=f'SH-{shipment_id}', source_warehouse_id=7,
target_site_id=site_id, test_batch_id=batch_id, status=status, receipt_state=state,
confirmed_at=NOW, confirmed_by=7,
)
def item_values(shipment_id, pallet_id, batch_id=None):
return dict(
shipment_item_id=shipment_id * 100 + pallet_id, shipment_id=shipment_id,
pallet_id=pallet_id, active_filling_id=100 + pallet_id, package_qty=100, test_batch_id=batch_id,
)
def pallet_values(pallet_id, batch_id=None):
return dict(
pallet_id=pallet_id, pallet_code=f'P-{pallet_id}', qr_token=f'QR-{pallet_id}',
capacity_packages=100, asset_status='0', test_batch_id=batch_id,
)
def filling_values(pallet_id, batch_id=None):
return dict(
filling_id=100 + pallet_id, filling_no=f'F-{pallet_id}', pallet_id=pallet_id,
warehouse_id=7, strain_id=1, strain_code_snapshot='S-1', strain_name_snapshot='原菌种',
batch_no=f'B-OLD-{pallet_id}', production_date=date(2026, 10, 1), expiry_date=date(2026, 11, 1),
package_spec='标准包', initial_packages=100, active_slot='ACTIVE',
operator_id=7, operated_at=NOW, idempotency_key=f'fill-{pallet_id}', request_hash='f' * 64,
test_batch_id=batch_id,
)
def inventory_values(pallet_id, site_id=9, batch_id=None):
return dict(
pallet_id=pallet_id, active_filling_id=100 + pallet_id, site_id=site_id,
warehouse_id=None, tunnel_id=None, trace_no=None, current_line_id=None,
flow_state='IN_TRANSIT', remaining_packages=100, version=3, test_batch_id=batch_id,
)
class MemorySession:
"""保留真实 SQLAlchemy ORM Row,仅适配 DAO 所需的异步 execute 接口。"""
def __init__(self, session):
self.session = session
async def execute(self, statement):
return self.session.execute(statement)
class TestShipmentReceiptReads(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.engine = create_engine('sqlite+pysqlite:///:memory:')
self.addCleanup(self.engine.dispose)
# 仅建读取涉及的表;外键/迁移契约由独立 SQL 测试负责。
models = (BizCustomerSite, BizUserSite, BizUserWarehouse, BizWarehouse, BizPallet,
BizFillingRecord, BizPalletInventory, BizShipmentOrder, BizShipmentItem)
with self.engine.begin() as connection:
for model in models:
connection.execute(CreateTable(model.__table__, include_foreign_key_constraints=[]))
for site_id, batch_id in ((9, None), (10, None), (19, 41), (29, 42)):
connection.execute(BizCustomerSite.__table__.insert(), dict(
site_id=site_id, customer_id=1, site_code=f'SITE-{site_id}',
site_name=f'现场{site_id}', status='0', test_batch_id=batch_id,
))
connection.execute(BizWarehouse.__table__.insert(), dict(
warehouse_id=7, warehouse_code='W-7', warehouse_name='原仓库', status='0',
))
for site_id in (9, 19, 29):
connection.execute(BizUserSite.__table__.insert(), dict(user_id=7, site_id=site_id))
connection.execute(BizUserWarehouse.__table__.insert(), dict(user_id=8, warehouse_id=7))
for pallet_id, site_id, batch_id in ((2, 9, None), (5, 9, None), (3, 10, None), (52, 19, 41), (62, 29, 42)):
connection.execute(BizPallet.__table__.insert(), pallet_values(pallet_id, batch_id))
connection.execute(BizFillingRecord.__table__.insert(), filling_values(pallet_id, batch_id))
connection.execute(BizPalletInventory.__table__.insert(), inventory_values(pallet_id, site_id, batch_id))
orders = [order_values(), order_values(13, 10), order_values(14, status='DRAFT'),
order_values(15, 19, 41), order_values(16, 29, 42),
order_values(17, state='RECEIVED'), order_values(18, state='LEGACY')]
for order in orders:
connection.execute(BizShipmentOrder.__table__.insert(), order)
for shipment_id, pallet_ids, batch_id in ((12, (2, 5), None), (13, (3,), None),
(15, (52,), 41), (16, (62,), 42),
(17, (2,), None), (18, (5,), None)):
for pallet_id in pallet_ids:
connection.execute(BizShipmentItem.__table__.insert(), item_values(shipment_id, pallet_id, batch_id))
self.session = Session(self.engine, autoflush=False)
self.addCleanup(self.session.close)
self.db = MemorySession(self.session)
self.user = user()
self.scope = batch_scope()
async def listing(self, **query):
return await ShipmentReceiptService.list_services(
self.db, ShipmentReceiptQueryModel(**query), self.user, self.scope,
)
async def detail(self, shipment_id=12):
return await ShipmentReceiptService.detail_services(self.db, shipment_id, self.user, self.scope)
async def scan(self, identifier='QR-2', shipment_id=12):
return await ShipmentReceiptService.scan_services(self.db, shipment_id, identifier, self.user, self.scope)
async def test_list_executes_real_query_and_pageutil_serialization(self):
page = await self.listing(pageSize=1)
payload = page.model_dump(by_alias=True, mode='json')
self.assertEqual(payload['total'], 3)
self.assertTrue(payload['hasNext'])
self.assertEqual(payload['rows'][0]['shipmentId'], 12)
self.assertEqual(payload['rows'][0]['receiptState'], 'PENDING')
self.assertNotIn('testBatchId', json.dumps(payload))
next_page = await self.listing(pageSize=1, pageNum=2)
self.assertEqual(next_page.rows[0].shipment_id, 17)
async def test_list_filters_original_route_receipt_state_and_confirmed_date(self):
page = await self.listing(shipmentNo='SH-12', sourceWarehouseId=7, targetSiteId=9,
receiptState='PENDING', beginTime='2026-10-08', endTime='2026-10-08')
self.assertEqual([row.shipment_id for row in page.rows], [12])
self.assertEqual((await self.listing(beginTime='2026-10-09')).total, 0)
async def test_list_keeps_formal_and_each_test_batch_separate(self):
for batch_id, expected in ((None, [12, 17, 18]), (41, [15]), (42, [16])):
with self.subTest(batch_id=batch_id):
self.scope = batch_scope(batch_id)
self.assertEqual([row.shipment_id for row in (await self.listing()).rows], expected)
async def test_warehouse_only_user_cannot_list_detail_or_scan_receipts(self):
self.user = user(8)
self.assertEqual((await self.listing()).total, 0)
for operation in (self.detail, self.scan):
with self.subTest(operation=operation.__name__), self.assertRaises(PermissionException):
await operation()
async def test_target_site_filter_cannot_expand_user_access(self):
with self.assertRaises(PermissionException):
await self.listing(targetSiteId=10)
with self.assertRaises(PermissionException):
await self.detail(13)
with self.assertRaises(PermissionException):
await self.scan('QR-3', 13)
async def test_detail_and_scan_cannot_read_other_batch_order(self):
with self.assertRaises(PermissionException):
await self.detail(15)
with self.assertRaises(PermissionException):
await self.scan('QR-52', 15)
self.scope = batch_scope(41)
self.assertEqual((await self.detail(15)).items[0].pallet_id, 52)
self.assertEqual((await self.scan('QR-52', 15)).pallet_id, 52)
with self.assertRaises(PermissionException):
await self.detail(16)
async def test_detail_reads_frozen_filling_after_pallet_refill(self):
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(
active_slot=None, closed_at=NOW,
))
replacement = filling_values(2)
replacement.update(filling_id=202, filling_no='F-NEW-2', batch_no='B-NEW', idempotency_key='new-fill')
self.session.execute(BizFillingRecord.__table__.insert(), replacement)
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(
active_filling_id=202, remaining_packages=20, flow_state='AT_SITE_PARTIAL',
))
item = (await self.detail(17)).items[0]
self.assertEqual((item.active_filling_id, item.package_qty, item.batch_no), (102, 100, 'B-OLD-2'))
self.assertEqual(item.production_date, date(2026, 10, 1))
self.assertEqual(item.expiry_date, date(2026, 11, 1))
async def test_legacy_detail_preserves_unknown_dates(self):
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 105).values(
production_date=None, expiry_date=None,
))
detail = await self.detail(18)
self.assertEqual(detail.receipt_state, 'LEGACY')
self.assertIsNone(detail.items[0].production_date)
self.assertIsNone(detail.items[0].expiry_date)
async def test_detail_rejects_original_filling_from_another_batch(self):
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(test_batch_id=41))
with self.assertRaises(ServiceException):
await self.detail()
async def test_detail_rejects_filling_belonging_to_another_pallet(self):
self.session.execute(update(BizShipmentItem).where(BizShipmentItem.shipment_item_id == 1202).values(active_filling_id=105))
with self.assertRaises(ServiceException):
await self.detail()
async def test_scan_accepts_printed_pallet_and_returns_canonical_identity(self):
for identifier in ('QR-2', ' P-2 '):
with self.subTest(identifier=identifier):
item = await self.scan(identifier)
self.assertEqual((item.pallet_id, item.qr_token, item.package_qty), (2, 'QR-2', 100))
async def test_scan_rejects_wrong_order_and_cross_batch_pallet(self):
for identifier in ('QR-3', 'QR-52', 'MISSING'):
with self.subTest(identifier=identifier), self.assertRaises(ServiceException):
await self.scan(identifier)
async def test_scan_rejects_changed_quantity_before_receiving(self):
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(remaining_packages=90))
with self.assertRaises(ServiceException):
await self.scan()
async def test_scan_rejects_received_and_legacy_orders(self):
for shipment_id in (17, 18):
with self.subTest(shipment_id=shipment_id), self.assertRaises(ServiceException):
await self.scan('QR-2', shipment_id)
async def test_controller_list_detail_scan_run_through_asgi(self):
app = FastAPI()
app.include_router(shipment_receipt_controller)
async def current_user():
RequestContext.set_current_user(self.user)
RequestContext.set_current_exclude_patterns([])
return self.user
async def current_db():
return self.db
async def current_scope():
return self.scope
app.dependency_overrides[get_db] = current_db
app.dependency_overrides[LoginService.get_current_user] = current_user
for route in shipment_receipt_controller.routes:
for dependency in route.dependant.dependencies:
if isinstance(dependency.call, PreAuth):
app.dependency_overrides[dependency.call] = current_user
if isinstance(dependency.call, TestBatchScopeResolver):
app.dependency_overrides[dependency.call] = current_scope
# 只替换登录来源、批次解析、日志队列和 IP 归属;接口权限校验仍真实执行。
with patch.object(Log, '_get_oper_location', new=AsyncMock(return_value='内存测试')), \
patch.object(LogQueueService, 'enqueue_operation_log', new=AsyncMock()):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://receipt.test') as client:
listing = await client.get('/business/shipment-receipt/list', params={'receiptState': 'PENDING'})
self.assertEqual(listing.status_code, 200)
self.assertEqual([r['shipmentId'] for r in listing.json()['rows']], [12])
detail = await client.get('/business/shipment-receipt/12')
self.assertEqual(detail.status_code, 200)
self.assertEqual(detail.json()['data']['items'][0]['batchNo'], 'B-OLD-2')
scanned = await client.get('/business/shipment-receipt/12/scan/P-2')
self.assertEqual(scanned.status_code, 200)
self.assertEqual(scanned.json()['data']['qrToken'], 'QR-2')
class TestShipmentReceiptConfirmBoundaries(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.user = user()
self.scope = batch_scope()
self.db = MagicMock(spec=AsyncSession)
self.order = BizShipmentOrder(**order_values())
self.order.items = [BizShipmentItem(**item_values(12, pallet_id)) for pallet_id in (2, 5)]
self.inventories = [BizPalletInventory(**inventory_values(pallet_id)) for pallet_id in (2, 5)]
self.events = []
self.batch_lock = self.patch(TestBatchAccessService, 'lock_active_batch', AsyncMock())
self.site_ids = self.patch(AccessDao, 'list_user_site_ids', AsyncMock(return_value=[9]))
self.site_access = self.patch(AccessDao, 'user_has_site', AsyncMock(return_value=True))
self.order_lock = self.patch(OperationDao, 'get_shipment_for_update', AsyncMock(return_value=self.order))
self.inventory_lock = self.patch(InventoryDao, 'get_inventories_for_update', AsyncMock(return_value=self.inventories))
self.patch(InventoryDao, 'get_pallets_by_ids', AsyncMock(return_value=[BizPallet(**pallet_values(i)) for i in (2, 5)]))
self.patch(MasterDao, 'get_site', AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)))
self.patch(InventoryDao, 'add_event', AsyncMock(side_effect=self.capture_event))
def patch(self, owner, name, replacement):
patcher = patch.object(owner, name, new=replacement)
result = patcher.start()
self.addCleanup(patcher.stop)
return result
async def capture_event(self, _db, _inventory, receipt_event, _scope):
self.events.append(receipt_event)
await asyncio.sleep(0)
return receipt_event
def command(self, ids=(5, 2), key='receipt-key'):
return ShipmentReceiptCommandModel(scannedPalletIds=list(ids), idempotencyKey=key)
async def confirm(self, command=None, db=None):
return await ShipmentReceiptService.confirm_services(
db if db is not None else self.db, 12,
command if command is not None else self.command(), self.user, self.scope,
)
async def test_permission_revoked_after_order_lock_prevents_any_mutation(self):
async def revoked(_db, _user_id, _site_id, *, for_update=False):
self.order_lock.assert_awaited_once()
self.assertTrue(for_update)
return False
self.site_access.side_effect = revoked
with self.assertRaises(PermissionException):
await self.confirm()
self.inventory_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
self.assertEqual(self.order.receipt_state, 'PENDING')
async def test_idempotent_replay_also_requires_current_target_permission(self):
await self.confirm()
self.site_access.return_value = False
with self.assertRaises(PermissionException):
await self.confirm()
self.assertEqual(len(self.events), 2)
self.assertEqual(self.db.commit.await_count, 1)
async def test_same_key_different_payload_is_rejected(self):
await self.confirm()
with self.assertRaises(ServiceException):
await self.confirm(self.command(ids=(2,)))
self.assertEqual(len(self.events), 2)
self.assertEqual(self.db.commit.await_count, 1)
async def test_same_key_permuted_set_replays_without_restoring_used_inventory(self):
await self.confirm()
self.inventories[0].remaining_packages = 80
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
await self.confirm(self.command(ids=(2, 5)))
self.assertEqual(self.inventories[0].remaining_packages, 80)
self.assertEqual(len(self.events), 2)
self.assertEqual(self.db.commit.await_count, 1)
async def test_closed_test_batch_is_rejected_before_order_lock(self):
self.scope = batch_scope(41)
self.batch_lock.side_effect = PermissionException(data='', message='测试批次已关闭')
with self.assertRaises(PermissionException):
await self.confirm()
self.order_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_cross_batch_order_is_rejected_even_if_dao_returns_it(self):
self.order.test_batch_id = 41
with self.assertRaises(ServiceException):
await self.confirm()
self.assertFalse(self.events)
self.db.commit.assert_not_awaited()
async def race(self, second_command):
"""模拟 DAO 的父单排他锁,第二个请求拿锁后必须重读已提交状态。"""
gate = asyncio.Lock()
attempted = []
async def lock_order(db, _shipment_id, _scope, **access):
self.assertEqual(access, {'warehouse_ids': None, 'site_ids': [9]})
attempted.append(db)
await gate.acquire()
db.owns_lock = True
return self.order
def connection():
db = MagicMock(spec=AsyncSession)
db.owns_lock = False
async def release():
if db.owns_lock:
db.owns_lock = False
gate.release()
db.commit = AsyncMock(side_effect=release)
db.rollback = AsyncMock(side_effect=release)
return db
first, second = connection(), connection()
self.order_lock.side_effect = lock_order
results = await asyncio.wait_for(asyncio.gather(
self.confirm(db=first), self.confirm(second_command, db=second), return_exceptions=True,
), timeout=2)
self.assertEqual(len(attempted), 2)
self.assertEqual(first.commit.await_count + second.commit.await_count, 1)
self.assertEqual(len(self.events), 2)
self.assertEqual([inventory.version for inventory in self.inventories], [4, 4])
self.assertTrue(all(inventory.remaining_packages == 100 for inventory in self.inventories))
return results
async def test_concurrent_identical_confirmations_append_events_once(self):
results = await self.race(self.command(ids=(2, 5)))
self.assertTrue(all(not isinstance(result, Exception) and result.is_success for result in results))
async def test_concurrent_same_key_different_payload_rejects_second_request(self):
results = await self.race(self.command(ids=(2,)))
self.assertTrue(results[0].is_success)
self.assertIsInstance(results[1], ServiceException)
async def test_concurrent_different_keys_do_not_confirm_twice(self):
results = await self.race(self.command(key='other-key'))
self.assertTrue(results[0].is_success)
self.assertIsInstance(results[1], ServiceException)
if __name__ == '__main__':
unittest.main()