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

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
@@ -21,10 +21,14 @@ CONTROLLER_NAMES = (
'inbound',
'filling',
'shipment',
'shipment_receipt',
'usage',
'usage_fifo',
'return',
'report',
'test_batch',
'dashboard',
'tunnel',
)
TARGET_PREFIXES = (
'/master',
@@ -43,6 +47,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
'master:line:list',
'master:warehouse:list',
'master:pallet:list',
'master:tunnel:list',
'business:inbound:list',
'business:filling:list',
'business:shipment:list',
@@ -80,6 +85,10 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'master:pallet:add',
'master:pallet:edit',
'master:pallet:print',
'master:tunnel:query',
'master:tunnel:add',
'master:tunnel:edit',
'master:tunnel:remove',
'business:inbound:query',
'business:inbound:add',
'business:inbound:edit',
@@ -90,6 +99,7 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'business:shipment:add',
'business:shipment:edit',
'business:shipment:confirm',
'business:shipment:receive',
'business:usage:query',
'business:usage:add',
'business:return:query',
@@ -108,7 +118,10 @@ EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
EXPECTED_WEB_PERMISSIONS = frozenset(
permission
for permission in EXPECTED_PERMISSIONS
if not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt'
if (
(not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt')
and permission != 'master:tunnel:remove'
)
)
VUE_PAGE_PATHS = (
'src/views/master/strain/index.vue',
@@ -117,6 +130,7 @@ VUE_PAGE_PATHS = (
'src/views/master/line/index.vue',
'src/views/master/warehouse/index.vue',
'src/views/master/pallet/index.vue',
'src/views/master/tunnel/index.vue',
'src/views/business/inbound/index.vue',
'src/views/business/filling/index.vue',
'src/views/business/shipment/index.vue',
@@ -130,7 +144,10 @@ VUE_PAGE_PATHS = (
'src/views/report/palletTrace/index.vue',
)
PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report|system):[a-z]+:[a-z]+')
UNGUARDED_AUTHENTICATED_ROUTES = {('/system/test-batch/options', 'GET')}
UNGUARDED_AUTHENTICATED_ROUTES = {
('/system/test-batch/options', 'GET'),
('/business/dashboard/scan-exceptions', 'GET'),
}
REMOVED_DELETE_SYMBOLS = (
'delStrain',
'delCustomer',
@@ -170,6 +187,16 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
}
)
expected.update(
{
('/master/tunnel/list', 'GET'): 'master:tunnel:list',
('/master/tunnel/{tunnel_id}', 'GET'): 'master:tunnel:query',
('/master/tunnel', 'POST'): 'master:tunnel:add',
('/master/tunnel', 'PUT'): 'master:tunnel:edit',
('/master/tunnel/{tunnel_ids}', 'DELETE'): 'master:tunnel:remove',
}
)
expected.update(
{
('/master/site/{site_id}/users', 'GET'): 'master:site:assign',
@@ -195,12 +222,20 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/business/shipment/site-options', 'GET'): 'business:shipment:query',
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}/labels', 'GET'): 'business:shipment:query',
('/business/shipment-receipt/list', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}/scan/{identifier}', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive',
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
('/business/shipment/{shipment_id}/confirm', 'PUT'): 'business:shipment:confirm',
('/business/usage/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query',
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
('/business/usage', 'POST'): 'business:usage:add',
@@ -249,6 +284,7 @@ STATIC_READS_BEFORE_DETAIL = {
),
'/business/usage/{usage_id}': (
'/business/usage/line-options',
'/business/usage/tunnel-options',
'/business/usage/scan/{qr_token}',
),
'/business/return/{return_id}': (
@@ -264,6 +300,7 @@ EXACT_QUERY_PARAMETERS = {
('/business/inbound/scan/{qr_token}', 'GET'): {'warehouseId'},
('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'},
('/business/usage/line-options', 'GET'): {'siteId'},
('/business/usage/tunnel-options', 'GET'): {'siteId'},
('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'},
}
REQUIRED_QUERY_PARAMETERS = {
@@ -286,7 +323,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]:
def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values())
test_case.assertEqual(85, expected.total())
test_case.assertEqual(98, expected.total())
test_case.assertEqual(expected, Counter(literals))
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected))
@@ -409,7 +446,7 @@ class ApiPermissionContractTest(unittest.TestCase):
cls.app = create_app()
cls.schema = cls.app.openapi()
cls.target_routes = [
route for route in cls.app.routes if isinstance(route, APIRoute) and _is_target_path(route.path)
route for route in cls.app.routes if isinstance(route, APIRoute) and _is_target_path(route.path_format)
]
def test_target_path_selector_uses_namespace_boundaries(self) -> None:
@@ -438,11 +475,11 @@ class ApiPermissionContractTest(unittest.TestCase):
}
self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations)
self.assertEqual(86, len(actual_operations))
self.assertEqual(100, len(actual_operations))
openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/'))
app_ai_paths = sorted(
route.path
route.path_format
for route in self.app.routes
if getattr(route, 'path', None) == '/ai' or getattr(route, 'path', '').startswith('/ai/')
)
@@ -454,8 +491,8 @@ class ApiPermissionContractTest(unittest.TestCase):
operation_keys: list[tuple[str, str]] = []
for route in self.target_routes:
methods = route.methods & ROUTE_HTTP_METHODS
self.assertEqual(1, len(methods), route.path)
key = (route.path, next(iter(methods)))
self.assertEqual(1, len(methods), route.path_format)
key = (route.path_format, next(iter(methods)))
operation_keys.append(key)
auth_dependencies = _auth_dependencies(route)
@@ -491,19 +528,33 @@ class ApiPermissionContractTest(unittest.TestCase):
literals.append(node.args[0].value)
self.assertEqual([], malformed_calls)
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(48, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
self.assertEqual(67, len(EXPECTED_PERMISSIONS))
_assert_controller_permission_literals(self, literals)
self.assertEqual(
{'add', 'adopt', 'assign', 'close', 'confirm', 'edit', 'list', 'print', 'purge', 'query', 'request'},
{
'add',
'adopt',
'assign',
'close',
'confirm',
'edit',
'list',
'print',
'purge',
'query',
'remove',
'request',
'receive',
},
{permission.rsplit(':', maxsplit=1)[1] for permission in literals},
)
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
self.assertEqual(85, len(literals))
self.assertEqual(98, len(literals))
literals.append(literals[0])
with self.assertRaises(AssertionError):
@@ -521,8 +572,8 @@ class ApiPermissionContractTest(unittest.TestCase):
def test_vue_pages_have_exact_guarded_permission_contract(self) -> None:
actual_permissions = set()
self.assertEqual(17, len(VUE_PAGE_PATHS))
self.assertEqual(17, len(set(VUE_PAGE_PATHS)))
self.assertEqual(18, len(VUE_PAGE_PATHS))
self.assertEqual(18, len(set(VUE_PAGE_PATHS)))
for relative_path in VUE_PAGE_PATHS:
source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8')
guarded = _extract_guarded_vue_permissions(source)
@@ -663,7 +714,7 @@ class ApiPermissionContractTest(unittest.TestCase):
def test_static_scan_and_option_routes_precede_dynamic_details(self) -> None:
route_order = {
route.path: index
route.path_format: index
for index, route in enumerate(self.app.routes)
if isinstance(route, APIRoute) and 'GET' in route.methods
}
@@ -705,9 +756,9 @@ class ApiPermissionContractTest(unittest.TestCase):
actual_write_routes: dict[tuple[str, str], APIRoute] = {}
for route in self.target_routes:
for method in route.methods & WRITE_METHODS:
actual_write_routes[(route.path, method)] = route
actual_write_routes[(route.path_format, method)] = route
self.assertEqual(41, len(expected_write_permissions))
self.assertEqual(46, len(expected_write_permissions))
self.assertEqual(set(expected_write_permissions), set(actual_write_routes))
for key, expected_permission in expected_write_permissions.items():
with self.subTest(operation=key):
@@ -0,0 +1,155 @@
"""FIFO 跳过原因必须在真正写入处验证,并随业务事实留痕。"""
import unittest
from datetime import datetime
from unittest.mock import AsyncMock, patch
from pydantic import ValidationError
from sqlalchemy.exc import OperationalError
from exceptions.exception import ServiceException
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 TunnelEntryCreateModel, UsageCreateModel, UsageModel
from module_business.service.usage_service import UsageService
from tests.module_business import test_usage_service as fixtures
class TestFifoSkipPolicy(unittest.IsolatedAsyncioTestCase):
def setUp(self):
fixtures.TestRecordUsageCore.setUp(self)
self.inventory.site_arrived_at = datetime(2026, 10, 8, 10)
self.candidate = {
'pallet_id': 50, 'pallet_code': 'P-050', 'batch_no': 'B-EARLY',
'remaining_packages': 80, 'site_arrived_at': datetime(2026, 10, 7, 10),
}
_patch_record_path = fixtures.TestRecordUsageCore._patch_record_path
def command(self, reason=None):
values = self.page.model_dump(by_alias=True)
if reason is not None:
values['fifoSkipReason'] = reason
return UsageCreateModel(**values)
async def test_earlier_available_pallet_requires_reason_before_any_write(self):
stack, calls = self._patch_record_path()
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
with self.assertRaises(ServiceException) as caught:
await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
self.assertEqual(caught.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
self.assertEqual(self.inventory.remaining_packages, 8)
calls['add_usage'].assert_not_awaited()
calls['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
async def test_reason_and_recommendation_are_saved_with_usage_and_event(self):
command = self.command(' 工艺需要混批补充 ')
stack, calls = self._patch_record_path()
fifo = AsyncMock(return_value=(self.candidate, 0))
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
usage = calls['add_usage'].call_args.args[1]
event = calls['add_event'].call_args.args[2]
for record in (usage, event):
self.assertEqual(record.fifo_skip_reason, '工艺需要混批补充')
self.assertEqual(record.fifo_recommendation['palletCode'], 'P-050')
self.assertEqual(record.fifo_recommendation['batchNo'], 'B-EARLY')
self.assertEqual(record.fifo_recommendation['siteArrivedAt'], '2026-10-07T10:00:00')
self.assertTrue(fifo.call_args.kwargs['for_update'])
self.assertEqual(self.inventory.remaining_packages, 5)
self.assertEqual(UsageModel.model_validate(usage).fifo_skip_reason, '工艺需要混批补充')
async def test_equal_arrival_does_not_force_pallet_id_order(self):
self.candidate['site_arrived_at'] = self.inventory.site_arrived_at
stack, calls = self._patch_record_path()
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertIsNone(calls['add_usage'].call_args.args[1].fifo_skip_reason)
async def test_unknown_current_arrival_does_not_fabricate_order(self):
self.inventory.site_arrived_at = None
stack, _ = self._patch_record_path()
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 1))):
result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
async def test_idempotent_retry_never_rechecks_fifo_or_deducts_again(self):
command = self.command('工艺需要')
existing = fixtures.make_usage()
existing.request_hash = UsageService._build_request_hash(command)
stack, calls = self._patch_record_path(key_read=existing)
fifo = AsyncMock(side_effect=AssertionError('成功重试不能重做FIFO判断'))
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
result = await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
calls['add_usage'].assert_not_awaited()
fifo.assert_not_awaited()
def test_reason_changes_hash_but_absent_reason_keeps_legacy_hash(self):
original = UsageService._build_request_hash(self.page)
self.assertEqual(original, UsageService._build_request_hash(self.command(' ')))
self.assertNotEqual(original, UsageService._build_request_hash(self.command('工艺需要')))
self.assertNotEqual(UsageService._build_request_hash(self.command('工艺需要')),
UsageService._build_request_hash(self.command('质量检查')))
def test_entry_reason_is_validated_and_part_of_idempotent_identity(self):
fields = {'qrToken': 'qr-2', 'tunnelId': 9, 'idempotencyKey': 'entry-key'}
original = TunnelEntryCreateModel(**fields)
reason = TunnelEntryCreateModel(**fields, fifoSkipReason=' 工艺需要 ')
self.assertEqual(reason.fifo_skip_reason, '工艺需要')
self.assertNotEqual(UsageService._build_tunnel_entry_request_hash(original),
UsageService._build_tunnel_entry_request_hash(reason))
with self.assertRaises(ValidationError):
TunnelEntryCreateModel(**fields, fifoSkipReason='字' * 501)
async def test_entry_refuses_skip_before_inventory_binding(self):
self.inventory.tunnel_id = None
self.inventory.trace_no = None
self.tunnel.current_trace_no = 'TRACE-9'
command = TunnelEntryCreateModel(qrToken='qr-2', tunnelId=9, idempotencyKey='entry-key')
stack, calls = self._patch_record_path()
with stack, patch.object(fixtures.InventoryDao, 'get_event_by_idempotency_key', new=AsyncMock(return_value=None)), \
patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
with self.assertRaises(ServiceException) as caught:
await UsageService.enter_tunnel_services(self.db, command, self.user, TestBatchScope.formal())
self.assertEqual(caught.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
self.assertIsNone(self.inventory.tunnel_id)
calls['add_event'].assert_not_awaited()
async def test_fifo_lock_conflict_retries_whole_usage_with_original_command(self):
stack, calls = self._patch_record_path()
conflict = OperationalError('candidate lock', {}, RuntimeError(3572, 'NOWAIT'))
fifo = AsyncMock(side_effect=[conflict, (self.candidate, 0)])
command = self.command('工艺需要')
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
result = await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertEqual(calls['inventory_lock'].await_count, 2)
self.assertEqual(calls['add_usage'].await_count, 1)
self.assertEqual(calls['add_usage'].call_args.args[1].idempotency_key, 'usage-key')
self.assertEqual(self.inventory.remaining_packages, 5)
async def test_persistent_lock_conflict_is_bounded_and_other_db_errors_are_not_retried(self):
for code, attempts in ((3572, 3), (1213, 3), (1205, 3), (1054, 1), (2013, 1)):
with self.subTest(code=code):
stack, calls = self._patch_record_path()
fifo = AsyncMock(side_effect=OperationalError('candidate lock', {}, RuntimeError(code, 'test')))
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo), self.assertRaises(OperationalError):
await UsageService.record_usage_services(self.db, self.command('工艺需要'), self.user, TestBatchScope.formal())
self.assertEqual(fifo.await_count, attempts)
calls['add_usage'].assert_not_awaited()
async def test_fifo_lock_conflict_retries_entry_before_binding(self):
self.inventory.tunnel_id = None
self.inventory.trace_no = None
command = TunnelEntryCreateModel(qrToken='qr-2', tunnelId=9, idempotencyKey='entry-key', fifoSkipReason='工艺需要')
stack, calls = self._patch_record_path()
fifo = AsyncMock(side_effect=[OperationalError('candidate lock', {}, RuntimeError(3572, 'NOWAIT')), (self.candidate, 0)])
with stack, patch.object(fixtures.InventoryDao, 'get_event_by_idempotency_key', new=AsyncMock(return_value=None)), \
patch.object(UsageFifoDao, 'recommendation', new=fifo):
result = await UsageService.enter_tunnel_services(self.db, command, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertEqual(calls['inventory_lock'].await_count, 2)
self.assertEqual(calls['add_event'].await_count, 1)
@@ -2,7 +2,7 @@ import hashlib
import inspect
import json
import unittest
from datetime import datetime
from datetime import date, datetime
from types import SimpleNamespace
from typing import Any, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch
@@ -60,7 +60,7 @@ def make_page(
strain_id: int = 11,
batch_no: str = 'B-20260726',
package_spec: str = '20kg',
initial_packages: int = 18,
initial_packages: int = 100,
idempotency_key: str = 'fill-key',
) -> FillingCreateModel:
return FillingCreateModel(
@@ -68,6 +68,8 @@ def make_page(
warehouseId=warehouse_id,
strainId=strain_id,
batchNo=batch_no,
productionDate='2026-07-20',
expiryDate='2027-01-20',
packageSpec=package_spec,
initialPackages=initial_packages,
idempotencyKey=idempotency_key,
@@ -78,7 +80,7 @@ def make_pallet(
pallet_id: int = 2,
*,
qr_token: str = 'qr-2',
capacity_packages: int = 20,
capacity_packages: int = 100,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
@@ -200,7 +202,9 @@ class TestFillingPublicContract(unittest.TestCase):
expected_payload = json.dumps(
{
'batch_no': 'B-20260726',
'initial_packages': 18,
'production_date': '2026-07-20',
'expiry_date': '2027-01-20',
'initial_packages': 100,
'package_spec': '20kg',
'qr_token': 'qr-2',
'strain_id': 11,
@@ -364,7 +368,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited()
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE, result.flow_state)
self.assertEqual(['filling'], result.allowed_actions)
self.assertEqual(20, result.capacity_packages)
self.assertEqual(100, result.capacity_packages)
self.assertEqual(0, result.remaining_packages)
async def test_scan_checks_access_before_exposing_invalid_state(self) -> None:
@@ -534,8 +538,10 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertEqual('S-011', filling.strain_code_snapshot)
self.assertEqual('香菇 11', filling.strain_name_snapshot)
self.assertEqual('B-20260726', filling.batch_no)
self.assertEqual(date(2026, 7, 20), filling.production_date)
self.assertEqual(date(2027, 1, 20), filling.expiry_date)
self.assertEqual('20kg', filling.package_spec)
self.assertEqual(18, filling.initial_packages)
self.assertEqual(100, filling.initial_packages)
self.assertEqual('ACTIVE', filling.active_slot)
self.assertEqual('fill-key', filling.idempotency_key)
self.assertEqual(FillingService._build_request_hash(self.page), filling.request_hash)
@@ -547,7 +553,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.fixed_time, filling.update_time)
self.assertEqual(31, self.inventory.active_filling_id)
self.assertEqual(18, self.inventory.remaining_packages)
self.assertEqual(100, self.inventory.remaining_packages)
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, self.inventory.flow_state)
self.assertEqual(4, self.inventory.version)
self.assertEqual(self.fixed_time, self.inventory.updated_at)
@@ -566,7 +572,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(event.before_line_id)
self.assertIsNone(event.after_line_id)
self.assertEqual(0, event.before_remaining_packages)
self.assertEqual(18, event.after_remaining_packages)
self.assertEqual(100, event.after_remaining_packages)
self.assertEqual(7, event.operator_id)
self.assertEqual(self.fixed_time, event.occurred_at)
self.assertEqual('fill-key', event.idempotency_key)
@@ -632,8 +638,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_fill_rejects_packages_over_persisted_pallet_capacity(self) -> None:
await self._assert_fill_rejected(page=make_page(initial_packages=21))
async def test_fill_rejects_historical_nonstandard_pallet_capacity(self) -> None:
await self._assert_fill_rejected(pallet=make_pallet(capacity_packages=20))
async def test_fill_rejects_stopped_warehouse_only_after_core_locks(self) -> None:
actions: list[str] = []
@@ -1152,7 +1158,7 @@ class TestFillingController(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
(route.path_format, next(iter(route.methods))): route
for route in filling_controller_module.filling_controller.routes
if isinstance(route, APIRoute)
}
@@ -1189,7 +1195,7 @@ class TestFillingController(unittest.TestCase):
self.assertEqual(ResponseBaseModel, routes[('/business/filling', 'POST')].response_model)
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
paths = [route.path for route in filling_controller_module.filling_controller.routes]
paths = [route.path_format for route in filling_controller_module.filling_controller.routes]
detail_index = paths.index('/business/filling/{filling_id}')
self.assertLess(paths.index('/business/filling/strain-options'), detail_index)
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index)
@@ -1218,7 +1224,7 @@ class TestFillingController(unittest.TestCase):
set(actual.values()),
)
def test_all_routes_inject_current_user_and_only_post_has_log(self) -> None:
def test_all_routes_inject_current_user_and_expected_routes_have_log(self) -> None:
for key, route in self._routes().items():
self.assertTrue(
any(
@@ -1227,7 +1233,11 @@ class TestFillingController(unittest.TestCase):
),
key,
)
self.assertEqual(key == ('/business/filling', 'POST'), hasattr(route.endpoint, '__wrapped__'))
expected_logged = {
('/business/filling/scan/{qr_token}', 'GET'),
('/business/filling', 'POST'),
}
self.assertEqual(key in expected_logged, hasattr(route.endpoint, '__wrapped__'))
def test_controller_does_not_add_scan_export_or_draft_permissions(self) -> None:
source = inspect.getsource(filling_controller_module)
@@ -1889,7 +1889,7 @@ class TestInboundController(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
(route.path_format, next(iter(route.methods))): route
for route in inbound_controller_module.inbound_controller.routes
if isinstance(route, APIRoute)
}
@@ -1935,7 +1935,7 @@ class TestInboundController(unittest.TestCase):
self.assertEqual(ResponseBaseModel, routes[key].response_model)
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
paths = [route.path for route in inbound_controller_module.inbound_controller.routes]
paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes]
detail_index = paths.index('/business/inbound/{inbound_id}')
self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index)
self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index)
@@ -0,0 +1,60 @@
"""物料日期是发货来源数据,不能由装填操作时间推算。"""
import unittest
from datetime import date
from pydantic import ValidationError
from module_business.entity.vo.operation_vo import FillingCreateModel, FillingModel
from module_business.service.filling_service import FillingService
from tests.module_business.test_filling_service import make_filling
def command_data(**changes):
return {
'qrToken': 'qr-material',
'warehouseId': 7,
'strainId': 11,
'batchNo': 'B-20261001',
'packageSpec': '标准包',
'initialPackages': 100,
'idempotencyKey': 'material-date-key',
**changes,
}
class TestMaterialDates(unittest.TestCase):
def test_new_filling_requires_source_dates(self):
with self.assertRaises(ValidationError):
FillingCreateModel(**command_data())
def test_dates_round_trip_without_time_or_timezone_conversion(self):
command = FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2027-03-31'))
self.assertEqual(command.production_date, date(2026, 10, 1))
self.assertEqual(command.model_dump(mode='json', by_alias=True)['expiryDate'], '2027-03-31')
def test_expiry_cannot_precede_production(self):
with self.assertRaises(ValidationError) as raised:
FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2026-09-30'))
self.assertIn('有效期', str(raised.exception))
def test_invalid_calendar_dates_are_rejected(self):
with self.assertRaises(ValidationError):
FillingCreateModel(**command_data(productionDate='2026-02-30', expiryDate='2027-03-31'))
def test_changing_either_source_date_changes_idempotency_content(self):
original = FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2027-03-31'))
expected = FillingService._build_request_hash(original)
for field, value in [('productionDate', '2026-10-02'), ('expiryDate', '2027-04-01')]:
with self.subTest(field=field):
changed = original.model_dump(by_alias=True)
changed[field] = value
self.assertNotEqual(expected, FillingService._build_request_hash(FillingCreateModel(**changed)))
def test_historical_missing_dates_remain_unknown(self):
record = make_filling()
record.production_date = None
record.expiry_date = None
result = FillingModel.model_validate(record).model_dump(mode='json', by_alias=True)
self.assertIsNone(result['productionDate'])
self.assertIsNone(result['expiryDate'])
@@ -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)
@@ -1,6 +1,6 @@
import unittest
from sqlalchemy import CHAR, BigInteger, CheckConstraint, DateTime, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy import JSON, CHAR, BigInteger, CheckConstraint, Date, DateTime, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.dialects import mysql
from sqlalchemy.schema import CreateColumn, CreateTable
@@ -14,6 +14,7 @@ TEST_BATCH_OWNED_TABLES = {
'biz_customer',
'biz_customer_site',
'biz_production_line',
'biz_tunnel',
'biz_warehouse',
'biz_pallet',
'biz_inbound_order',
@@ -88,6 +89,20 @@ EXPECTED_COLUMNS = {
'update_time',
'remark',
},
'biz_tunnel': {
'tunnel_id',
'site_id',
'tunnel_code',
'tunnel_name',
'current_trace_no',
'current_trace_started_at',
'status',
'create_by',
'create_time',
'update_by',
'update_time',
'remark',
},
'biz_pallet': {
'pallet_id',
'pallet_code',
@@ -133,6 +148,8 @@ EXPECTED_COLUMNS = {
'strain_code_snapshot',
'strain_name_snapshot',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'initial_packages',
'active_slot',
@@ -178,6 +195,9 @@ EXPECTED_COLUMNS = {
'pallet_id',
'active_filling_id',
'site_id',
'tunnel_id',
'trace_no',
'batch_no_snapshot',
'line_id',
'used_packages',
'remaining_packages_after',
@@ -211,6 +231,9 @@ EXPECTED_COLUMNS = {
'return_id',
'pallet_id',
'active_filling_id_snapshot',
'tunnel_id_snapshot',
'trace_no_snapshot',
'batch_no_snapshot',
'remaining_packages_snapshot',
'create_by',
'create_time',
@@ -220,6 +243,8 @@ EXPECTED_COLUMNS = {
'active_filling_id',
'warehouse_id',
'site_id',
'tunnel_id',
'trace_no',
'current_line_id',
'flow_state',
'remaining_packages',
@@ -238,12 +263,16 @@ EXPECTED_COLUMNS = {
'after_state',
'before_warehouse_id',
'before_site_id',
'before_tunnel_id',
'before_line_id',
'after_warehouse_id',
'after_site_id',
'after_tunnel_id',
'after_line_id',
'before_remaining_packages',
'after_remaining_packages',
'trace_no',
'batch_no_snapshot',
'operator_id',
'occurred_at',
'idempotency_key',
@@ -263,6 +292,20 @@ EXPECTED_COLUMN_TYPES = {
'update_time': (DateTime, None),
'remark': (String, 500),
},
'biz_tunnel': {
'tunnel_id': (BigInteger, None),
'site_id': (BigInteger, None),
'tunnel_code': (String, 64),
'tunnel_name': (String, 100),
'current_trace_no': (String, 64),
'current_trace_started_at': (DateTime, None),
'status': (CHAR, 1),
'create_by': (String, 64),
'create_time': (DateTime, None),
'update_by': (String, 64),
'update_time': (DateTime, None),
'remark': (String, 500),
},
'biz_warehouse': {
'warehouse_id': (BigInteger, None),
'warehouse_code': (String, 64),
@@ -366,6 +409,8 @@ EXPECTED_COLUMN_TYPES = {
'strain_code_snapshot': (String, 64),
'strain_name_snapshot': (String, 100),
'batch_no': (String, 64),
'production_date': (Date, None),
'expiry_date': (Date, None),
'package_spec': (String, 64),
'initial_packages': (Integer, None),
'active_slot': (String, 16),
@@ -411,6 +456,9 @@ EXPECTED_COLUMN_TYPES = {
'pallet_id': (BigInteger, None),
'active_filling_id': (BigInteger, None),
'site_id': (BigInteger, None),
'tunnel_id': (BigInteger, None),
'trace_no': (String, 64),
'batch_no_snapshot': (String, 64),
'line_id': (BigInteger, None),
'used_packages': (Integer, None),
'remaining_packages_after': (Integer, None),
@@ -444,6 +492,9 @@ EXPECTED_COLUMN_TYPES = {
'return_id': (BigInteger, None),
'pallet_id': (BigInteger, None),
'active_filling_id_snapshot': (BigInteger, None),
'tunnel_id_snapshot': (BigInteger, None),
'trace_no_snapshot': (String, 64),
'batch_no_snapshot': (String, 64),
'remaining_packages_snapshot': (Integer, None),
'create_by': (String, 64),
'create_time': (DateTime, None),
@@ -453,6 +504,8 @@ EXPECTED_COLUMN_TYPES = {
'active_filling_id': (BigInteger, None),
'warehouse_id': (BigInteger, None),
'site_id': (BigInteger, None),
'tunnel_id': (BigInteger, None),
'trace_no': (String, 64),
'current_line_id': (BigInteger, None),
'flow_state': (String, 32),
'remaining_packages': (Integer, None),
@@ -471,12 +524,16 @@ EXPECTED_COLUMN_TYPES = {
'after_state': (String, 32),
'before_warehouse_id': (BigInteger, None),
'before_site_id': (BigInteger, None),
'before_tunnel_id': (BigInteger, None),
'before_line_id': (BigInteger, None),
'after_warehouse_id': (BigInteger, None),
'after_site_id': (BigInteger, None),
'after_tunnel_id': (BigInteger, None),
'after_line_id': (BigInteger, None),
'before_remaining_packages': (Integer, None),
'after_remaining_packages': (Integer, None),
'trace_no': (String, 64),
'batch_no_snapshot': (String, 64),
'operator_id': (BigInteger, None),
'occurred_at': (DateTime, None),
'idempotency_key': (String, 64),
@@ -496,6 +553,20 @@ EXPECTED_NULLABILITY = {
'update_time': True,
'remark': True,
},
'biz_tunnel': {
'tunnel_id': False,
'site_id': False,
'tunnel_code': False,
'tunnel_name': False,
'current_trace_no': True,
'current_trace_started_at': True,
'status': False,
'create_by': True,
'create_time': True,
'update_by': True,
'update_time': True,
'remark': True,
},
'biz_warehouse': {
'warehouse_id': False,
'warehouse_code': False,
@@ -599,6 +670,8 @@ EXPECTED_NULLABILITY = {
'strain_code_snapshot': False,
'strain_name_snapshot': False,
'batch_no': False,
'production_date': True,
'expiry_date': True,
'package_spec': False,
'initial_packages': False,
'active_slot': True,
@@ -644,7 +717,10 @@ EXPECTED_NULLABILITY = {
'pallet_id': False,
'active_filling_id': False,
'site_id': False,
'line_id': False,
'tunnel_id': True,
'trace_no': True,
'batch_no_snapshot': True,
'line_id': True,
'used_packages': False,
'remaining_packages_after': False,
'idempotency_key': False,
@@ -677,6 +753,9 @@ EXPECTED_NULLABILITY = {
'return_id': False,
'pallet_id': False,
'active_filling_id_snapshot': True,
'tunnel_id_snapshot': True,
'trace_no_snapshot': True,
'batch_no_snapshot': True,
'remaining_packages_snapshot': False,
'create_by': True,
'create_time': True,
@@ -686,6 +765,8 @@ EXPECTED_NULLABILITY = {
'active_filling_id': True,
'warehouse_id': True,
'site_id': True,
'tunnel_id': True,
'trace_no': True,
'current_line_id': True,
'flow_state': False,
'remaining_packages': False,
@@ -704,12 +785,16 @@ EXPECTED_NULLABILITY = {
'after_state': False,
'before_warehouse_id': True,
'before_site_id': True,
'before_tunnel_id': True,
'before_line_id': True,
'after_warehouse_id': True,
'after_site_id': True,
'after_tunnel_id': True,
'after_line_id': True,
'before_remaining_packages': False,
'after_remaining_packages': False,
'trace_no': True,
'batch_no_snapshot': True,
'operator_id': False,
'occurred_at': False,
'idempotency_key': False,
@@ -728,6 +813,8 @@ EXPECTED_CALLABLE_DATETIME_DEFAULTS = {
('biz_customer_site', 'update_time'),
('biz_production_line', 'create_time'),
('biz_production_line', 'update_time'),
('biz_tunnel', 'create_time'),
('biz_tunnel', 'update_time'),
('biz_pallet', 'create_time'),
('biz_pallet', 'update_time'),
('biz_user_warehouse', 'create_time'),
@@ -757,6 +844,8 @@ EXPECTED_EMPTY_AUDIT_SERVER_DEFAULTS = {
('biz_customer_site', 'update_by'),
('biz_production_line', 'create_by'),
('biz_production_line', 'update_by'),
('biz_tunnel', 'create_by'),
('biz_tunnel', 'update_by'),
('biz_pallet', 'create_by'),
('biz_pallet', 'update_by'),
('biz_user_warehouse', 'create_by'),
@@ -780,6 +869,7 @@ EXPECTED_PRIMARY_KEYS = {
'biz_customer': ('customer_id',),
'biz_customer_site': ('site_id',),
'biz_production_line': ('line_id',),
'biz_tunnel': ('tunnel_id',),
'biz_pallet': ('pallet_id',),
'biz_user_warehouse': ('user_id', 'warehouse_id'),
'biz_user_site': ('user_id', 'site_id'),
@@ -801,6 +891,9 @@ EXPECTED_FOREIGN_KEYS = {
'biz_customer': set(),
'biz_customer_site': {('customer_id', 'biz_customer.customer_id')},
'biz_production_line': {('site_id', 'biz_customer_site.site_id')},
'biz_tunnel': {
('site_id', 'biz_customer_site.site_id'),
},
'biz_pallet': set(),
'biz_user_warehouse': {
('user_id', 'sys_user.user_id'),
@@ -838,6 +931,7 @@ EXPECTED_FOREIGN_KEYS = {
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'),
('site_id', 'biz_customer_site.site_id'),
('tunnel_id', 'biz_tunnel.tunnel_id'),
('line_id', 'biz_production_line.line_id'),
('operator_id', 'sys_user.user_id'),
},
@@ -851,12 +945,14 @@ EXPECTED_FOREIGN_KEYS = {
('return_id', 'biz_return_order.return_id'),
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id_snapshot', 'biz_filling_record.filling_id'),
('tunnel_id_snapshot', 'biz_tunnel.tunnel_id'),
},
'biz_pallet_inventory': {
('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'),
('warehouse_id', 'biz_warehouse.warehouse_id'),
('site_id', 'biz_customer_site.site_id'),
('tunnel_id', 'biz_tunnel.tunnel_id'),
('current_line_id', 'biz_production_line.line_id'),
('last_event_id', 'biz_pallet_event.event_id'),
},
@@ -864,9 +960,11 @@ EXPECTED_FOREIGN_KEYS = {
('pallet_id', 'biz_pallet.pallet_id'),
('before_warehouse_id', 'biz_warehouse.warehouse_id'),
('before_site_id', 'biz_customer_site.site_id'),
('before_tunnel_id', 'biz_tunnel.tunnel_id'),
('before_line_id', 'biz_production_line.line_id'),
('after_warehouse_id', 'biz_warehouse.warehouse_id'),
('after_site_id', 'biz_customer_site.site_id'),
('after_tunnel_id', 'biz_tunnel.tunnel_id'),
('after_line_id', 'biz_production_line.line_id'),
('operator_id', 'sys_user.user_id'),
},
@@ -878,6 +976,7 @@ EXPECTED_UNIQUE_SETS = {
'biz_customer': {frozenset({'customer_code'})},
'biz_customer_site': {frozenset({'customer_id', 'site_code'})},
'biz_production_line': {frozenset({'site_id', 'line_code'})},
'biz_tunnel': {frozenset({'site_id', 'tunnel_code'})},
'biz_pallet': {frozenset({'pallet_code'}), frozenset({'qr_token'})},
'biz_user_warehouse': set(),
'biz_user_site': set(),
@@ -913,6 +1012,36 @@ EXPECTED_UNIQUE_SETS = {
},
}
# 收货补齐:与既有日期、隧道契约并存,不用当前时间填充历史收货记录。
EXPECTED_COLUMNS['biz_shipment_order'].update({
'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash',
})
EXPECTED_COLUMN_TYPES['biz_shipment_order'].update({
'receipt_state': (String, 16),
'received_by': (BigInteger, None),
'received_at': (DateTime, None),
'receipt_idempotency_key': (String, 64),
'receipt_request_hash': (String, 64),
})
EXPECTED_NULLABILITY['biz_shipment_order'].update({
'receipt_state': False,
'received_by': True,
'received_at': True,
'receipt_idempotency_key': True,
'receipt_request_hash': True,
})
EXPECTED_FOREIGN_KEYS['biz_shipment_order'].add(('received_by', 'sys_user.user_id'))
EXPECTED_UNIQUE_SETS['biz_shipment_order'].add(frozenset({'target_site_id', 'receipt_idempotency_key'}))
EXPECTED_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
EXPECTED_COLUMN_TYPES['biz_pallet_inventory']['site_arrived_at'] = (DateTime, None)
EXPECTED_NULLABILITY['biz_pallet_inventory']['site_arrived_at'] = True
for fifo_table in ('biz_usage_record', 'biz_pallet_event'):
EXPECTED_COLUMNS[fifo_table].update({'fifo_skip_reason', 'fifo_recommendation'})
EXPECTED_NULLABILITY[fifo_table].update({'fifo_skip_reason': True, 'fifo_recommendation': True})
EXPECTED_COLUMN_TYPES[fifo_table]['fifo_skip_reason'] = (String, 500)
EXPECTED_COLUMN_TYPES[fifo_table]['fifo_recommendation'] = (JSON, None)
for table_name in TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_COLUMN_TYPES[table_name]['test_batch_id'] = (BigInteger, None)
@@ -1064,7 +1193,7 @@ class BusinessModelContractTest(unittest.TestCase):
def test_batch_related_tables_compile_for_mysql(self) -> None:
self.assertIs(Base.metadata.tables['sys_user'], user_do.SysUser.__table__)
batch_tables = EXPECTED_TEST_BATCH_TABLES | TEST_BATCH_OWNED_TABLES
self.assertEqual(18, len(batch_tables))
self.assertEqual(19, len(batch_tables))
for table_name in batch_tables:
for dialect_name, dialect in (('mysql', mysql.dialect()),):
with self.subTest(table_name=table_name, dialect=dialect_name):
@@ -1078,6 +1207,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_customer',
'biz_customer_site',
'biz_production_line',
'biz_tunnel',
'biz_pallet',
'biz_user_warehouse',
'biz_user_site',
@@ -1102,6 +1232,7 @@ class BusinessModelContractTest(unittest.TestCase):
master_do.BizCustomer: 'biz_customer',
master_do.BizCustomerSite: 'biz_customer_site',
master_do.BizProductionLine: 'biz_production_line',
master_do.BizTunnel: 'biz_tunnel',
master_do.BizPallet: 'biz_pallet',
master_do.BizUserWarehouse: 'biz_user_warehouse',
master_do.BizUserSite: 'biz_user_site',
@@ -1258,6 +1389,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_customer': {'ck_biz_customer_status': "statusIN('0','1')"},
'biz_customer_site': {'ck_biz_customer_site_status': "statusIN('0','1')"},
'biz_production_line': {'ck_biz_production_line_status': "statusIN('0','1')"},
'biz_tunnel': {'ck_biz_tunnel_status': "statusIN('0','1')"},
'biz_pallet': {
'ck_biz_pallet_capacity_packages': 'capacity_packages>0',
'ck_biz_pallet_asset_status': "asset_statusIN('0','1')",
@@ -1267,10 +1399,12 @@ class BusinessModelContractTest(unittest.TestCase):
},
'biz_filling_record': {
'ck_biz_filling_record_initial_packages': 'initial_packages>0',
'ck_biz_filling_record_material_dates': '(production_dateISNULLANDexpiry_dateISNULL)OR(production_dateISNOTNULLANDexpiry_dateISNOTNULLANDexpiry_date>=production_date)',
'ck_biz_filling_record_active_slot': "active_slotISNULLORactive_slot='ACTIVE'",
},
'biz_shipment_order': {
'ck_biz_shipment_order_status': "statusIN('DRAFT','CONFIRMED')",
'ck_biz_shipment_order_receipt_state': "receipt_stateIN('PENDING','RECEIVED','LEGACY')",
},
'biz_shipment_item': {
'ck_biz_shipment_item_package_qty': 'package_qtyISNULLORpackage_qty>0',
@@ -1288,7 +1422,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_pallet_inventory': {
'ck_biz_pallet_inventory_flow_state': (
"flow_stateIN('UNSTOCKED','EMPTY_IN_WAREHOUSE','FILLED_IN_WAREHOUSE',"
"'AT_SITE_UNUSED','AT_SITE_PARTIAL','AT_SITE_EMPTY','RETURNING')"
"'IN_TRANSIT','AT_SITE_UNUSED','AT_SITE_PARTIAL','AT_SITE_EMPTY','RETURNING')"
),
'ck_biz_pallet_inventory_remaining_packages': 'remaining_packages>=0',
'ck_biz_pallet_inventory_version': 'version>=0',
@@ -1296,7 +1430,7 @@ class BusinessModelContractTest(unittest.TestCase):
},
'biz_pallet_event': {
'ck_biz_pallet_event_event_type': (
"event_typeIN('INBOUND_CONFIRMED','FILLING_CONFIRMED','SHIPMENT_CONFIRMED',"
"event_typeIN('INBOUND_CONFIRMED','FILLING_CONFIRMED','SHIPMENT_CONFIRMED','SHIPMENT_RECEIVED','TUNNEL_ENTERED',"
"'USAGE_RECORDED','RETURN_REQUESTED','RETURN_CONFIRMED')"
),
},
@@ -1312,6 +1446,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_customer': 'status',
'biz_customer_site': 'status',
'biz_production_line': 'status',
'biz_tunnel': 'status',
'biz_pallet': 'asset_status',
}
for table_name, column_name in char_statuses.items():
@@ -1375,15 +1510,19 @@ class BusinessModelContractTest(unittest.TestCase):
'active_filling_id',
'warehouse_id',
'site_id',
'tunnel_id',
'trace_no',
'current_line_id',
'last_event_id',
},
'biz_pallet_event': {
'before_warehouse_id',
'before_site_id',
'before_tunnel_id',
'before_line_id',
'after_warehouse_id',
'after_site_id',
'after_tunnel_id',
'after_line_id',
},
}
@@ -0,0 +1,77 @@
"""在隔离内存库执行真实解析查询,不接触业务数据库。"""
import unittest
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, ServiceException
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizPallet
from module_business.service.access_service import AccessService
from module_business.service.filling_service import FillingService
from tests.module_business.test_batch_fixtures import make_test_scope
from tests.module_business.test_filling_service import make_inventory, make_pallet, make_user
class TestPalletScanIdentity(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.engine = create_engine('sqlite:///:memory:')
metadata = MetaData()
# 本测试只执行托盘查询,不复制业务数据库或外键依赖。
self.table = Table('biz_pallet', metadata, *[
Column(column.name, column.type, primary_key=column.primary_key)
for column in BizPallet.__table__.columns
])
metadata.create_all(self.engine)
self.session = Session(self.engine)
self.db = MagicMock()
self.db.execute = AsyncMock(side_effect=self.session.execute)
self.session.execute(self.table.insert(), [
{'pallet_id': 1, 'pallet_code': '123', 'qr_token': 'qr-one', 'test_batch_id': None},
{'pallet_id': 2, 'pallet_code': '456', 'qr_token': 'qr-two', 'test_batch_id': 7},
])
def tearDown(self):
self.session.close()
self.engine.dispose()
async def test_printed_number_and_qrcode_resolve_same_pallet(self):
by_code = await InventoryDao.get_pallet_by_qr_token(self.db, '123', TestBatchScope.formal())
by_qr = await InventoryDao.get_pallet_by_qr_token(self.db, 'qr-one', TestBatchScope.formal())
self.assertIsNotNone(by_code)
self.assertEqual(by_code.pallet_id, by_qr.pallet_id)
self.assertEqual(by_code.qr_token, 'qr-one')
async def test_resolver_never_crosses_formal_and_test_scopes(self):
self.assertIsNone(await InventoryDao.get_pallet_by_qr_token(self.db, '456', TestBatchScope.formal()))
self.assertIsNone(await InventoryDao.get_pallet_by_qr_token(self.db, '123', make_test_scope(7)))
pallet = await InventoryDao.get_pallet_by_qr_token(self.db, '456', make_test_scope(7))
self.assertIsNotNone(pallet)
self.assertEqual(pallet.pallet_id, 2)
async def test_ambiguous_number_and_token_are_rejected(self):
self.session.execute(self.table.insert(), {'pallet_id': 3, 'pallet_code': '789', 'qr_token': '123', 'test_batch_id': None})
with self.assertRaises(ServiceException):
await InventoryDao.get_pallet_by_qr_token(self.db, '123', TestBatchScope.formal())
async def test_scan_returns_canonical_token_after_warehouse_authorization(self):
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory())),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
result = await FillingService.scan_filling_pallet_services(self.db, 'P-002', make_user(), TestBatchScope.formal())
self.assertEqual(result.qr_token, 'qr-2')
async def test_printed_number_does_not_bypass_warehouse_access(self):
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory())),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问'))),
self.assertRaises(PermissionException),
):
await FillingService.scan_filling_pallet_services(self.db, 'P-002', make_user(), TestBatchScope.formal())
@@ -60,7 +60,7 @@ def integrity_error() -> IntegrityError:
def make_pallet_record(
pallet_id: int,
capacity_packages: int = 20,
capacity_packages: int = 100,
test_batch_id: int | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
@@ -113,7 +113,7 @@ class TestPalletVoContract(unittest.TestCase):
BusinessUserOptionModel.model_validate(value)
def test_pallet_edit_rejects_code_and_qr_token(self) -> None:
base = {'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'}
base = {'palletId': 1, 'capacityPackages': 100, 'assetStatus': '0'}
for identity_field in ({'palletCode': 'P-001'}, {'qrToken': 'fixed-token'}):
with self.subTest(identity_field=identity_field), self.assertRaises(ValidationError):
PalletEditModel.model_validate({**base, **identity_field})
@@ -147,7 +147,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
pallet = BizPallet(
pallet_code='P-001',
qr_token='fixed-token',
capacity_packages=20,
capacity_packages=100,
asset_status='0',
)
inventory = BizPalletInventory(
@@ -179,7 +179,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('test_batch_id', compile_sql(statement.whereclause))
async def test_update_pallet_sql_never_contains_identity_fields(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=18, assetStatus='1', remark='停用')
edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='1', remark='停用')
updated_at = datetime(2026, 7, 26, 10, 30)
await MasterDao.update_pallet(
@@ -193,7 +193,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertIn('UPDATE biz_pallet', sql)
self.assertIn('capacity_packages=18', sql)
self.assertIn('capacity_packages=100', sql)
self.assertIn("asset_status='1'", sql)
self.assertIn("remark='停用'", sql)
self.assertIn("update_by='admin'", sql)
@@ -357,7 +357,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
self.db = MagicMock(spec=AsyncSession)
self.admin = make_admin()
self.scope = TestBatchScope.formal()
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=20, remark='测试托盘')
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=100, remark='测试托盘')
self.fixed_time = datetime(2026, 7, 26, 11, 30)
async def test_add_generates_server_token_and_initial_inventory_in_one_transaction(self) -> None:
@@ -393,7 +393,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
pallet = captured['pallet']
self.assertEqual('P-001', pallet.pallet_code)
self.assertEqual('server-fixed-token', pallet.qr_token)
self.assertEqual(20, pallet.capacity_packages)
self.assertEqual(100, pallet.capacity_packages)
self.assertEqual('0', pallet.asset_status)
self.assertEqual('admin', pallet.create_by)
self.assertEqual(self.fixed_time, pallet.create_time)
@@ -549,7 +549,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
self.assertEqual('托盘不存在', raised.exception.message)
async def test_edit_updates_only_mutable_fields_with_server_audit(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=12, assetStatus='1', remark='停用')
edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='1', remark='停用')
updated_at = datetime(2026, 7, 26, 12, 30)
update_pallet = AsyncMock()
lock_actions: list[str] = []
@@ -591,7 +591,11 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
edit = PalletEditModel(palletId=3, capacityPackages=11, assetStatus='0')
update_pallet = AsyncMock()
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
patch.object(
MasterDao,
'get_pallet',
new=AsyncMock(return_value=make_pallet_record(3, capacity_packages=11)),
),
patch.object(
MasterDao,
'get_pallet_inventory',
@@ -608,7 +612,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
self.db.rollback.assert_awaited_once()
async def test_edit_rejects_missing_inventory(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=20, assetStatus='0')
edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='0')
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)),
@@ -975,7 +979,7 @@ class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
self.db = MagicMock(spec=AsyncSession)
self.admin = make_admin()
self.scope = make_test_scope(9)
self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=20)
self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=100)
async def test_list_detail_and_qrcode_forward_scope_to_scoped_dao_reads(self) -> None:
query = PalletPageQueryModel()
@@ -1049,7 +1053,7 @@ class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
events.append('get-pallet')
return make_pallet_record(7, test_batch_id=9)
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0')
edit = PalletEditModel(palletId=7, capacityPackages=100, assetStatus='0')
update_pallet = AsyncMock()
with (
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)),
@@ -1205,7 +1209,7 @@ class TestPalletDaoBatchScope(unittest.IsolatedAsyncioTestCase):
self.assertIn('FOR UPDATE', sql)
async def test_pallet_update_is_scoped_and_never_updates_batch_id(self) -> None:
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0')
edit = PalletEditModel(palletId=7, capacityPackages=100, assetStatus='0')
await MasterDao.update_pallet(
self.db,
edit,
@@ -0,0 +1,116 @@
"""日期报表查询与响应回归,仅读取生成的查询,不连接数据库。"""
import unittest
from datetime import date
from unittest.mock import AsyncMock, patch
from common.vo import PageModel
from module_business.dao.report_dao import ReportDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportQueryModel,
PalletInventoryReportModel, PalletInventoryReportQueryModel,
PalletTraceReportModel, PalletTraceReportQueryModel,
ReturnReportModel, ReturnReportQueryModel,
)
from module_business.service.report_service import ReportService
from tests.module_business.test_report_service import make_user, report_row
from tests.module_business.test_report_tunnel import joins, sql
REPORTS = (
('pallet_inventory', PalletInventoryReportModel, PalletInventoryReportQueryModel, 'active_filling'),
('consumption_report', ConsumptionReportModel, ConsumptionReportQueryModel, 'biz_filling_record'),
('return_report', ReturnReportModel, ReturnReportQueryModel, 'return_filling'),
('pallet_trace', PalletTraceReportModel, PalletTraceReportQueryModel, 'trace_filling'),
)
class TestReportDates(unittest.IsolatedAsyncioTestCase):
async def statement(self, name, query, batch_id=None):
page = PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False)
with patch.object(ReportDao, '_paginate', AsyncMock(return_value=page)) as paginate:
await getattr(ReportDao, f'get_{name}_list')(
None, query, TestBatchScope(batch_id, 'TEST' if batch_id else 'FORMAL', None, False),
)
return paginate.await_args.args[1]
async def test_dates_are_selected_from_the_referenced_filling_not_operation_time(self):
for name, _, query_type, alias in REPORTS:
with self.subTest(report=name):
statement = await self.statement(name, query_type())
for field in ('production_date', 'expiry_date'):
self.assertIn(field, statement.selected_columns.keys())
self.assertEqual(f'{alias}.{field}', sql(statement.selected_columns[field].element))
if name != 'pallet_inventory':
self.assertNotIn('biz_pallet_inventory', sql(statement))
expected_join = {
'pallet_inventory': 'active_filling.filling_id = biz_pallet_inventory.active_filling_id',
'consumption_report': 'biz_filling_record.filling_id = biz_usage_record.active_filling_id',
'return_report': 'return_filling.filling_id = biz_return_item.active_filling_id_snapshot',
}.get(name)
if expected_join:
self.assertIn(expected_join, sql(statement))
async def test_trace_resolves_original_filling_by_event_source_and_same_pallet(self):
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel())
self.assertIn('production_date', statement.selected_columns.keys())
query = sql(statement)
for source, reference in (
('FILLING', 'biz_pallet_event.source_id'),
('USAGE', 'trace_usage.active_filling_id'),
('RETURN', 'trace_return_item.active_filling_id_snapshot'),
('SHIPMENT', 'trace_shipment_item.active_filling_id'),
):
self.assertIn(f"WHEN (biz_pallet_event.source_type = '{source}') THEN {reference}", query)
for alias, id_field in (('trace_usage', 'usage_id'), ('trace_shipment_item', 'shipment_id')):
self.assertIn(f'{alias}.{id_field} = biz_pallet_event.source_id', query)
self.assertIn(f'{alias}.pallet_id = biz_pallet_event.pallet_id', query)
self.assertIn('trace_filling.pallet_id = biz_pallet_event.pallet_id', query)
self.assertNotIn('biz_pallet_inventory', query)
async def test_tunnel_entry_resolves_prior_filling_event_not_later_refill(self):
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel())
query = sql(statement)
self.assertIn('SELECT prior_filling_event.source_id', query)
self.assertIn('prior_filling_event.pallet_id = biz_pallet_event.pallet_id', query)
self.assertIn('prior_filling_event.event_seq < biz_pallet_event.event_seq', query)
self.assertIn("prior_filling_event.event_type = 'FILLING_CONFIRMED'", query)
self.assertIn('ORDER BY prior_filling_event.event_seq DESC', query)
self.assertIn('LIMIT 1', query)
async def test_trace_date_joins_are_optional_and_batch_isolated(self):
for batch_id, predicate in ((None, 'IS NULL'), (41, '= 41')):
with self.subTest(batch_id=batch_id):
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel(), batch_id)
all_joins = {join.right.name: join for join in joins(statement)}
for alias, key in (
('trace_usage', 'usage_id'), ('trace_shipment_item', 'shipment_item_id'),
('trace_filling', 'filling_id'),
):
self.assertIn(alias, all_joins)
self.assertTrue(all_joins[alias].isouter)
self.assertIn(f'({alias}.{key} IS NULL OR {alias}.test_batch_id {predicate})', sql(statement))
self.assertIn(f'prior_filling_event.test_batch_id {predicate}', sql(statement))
async def test_response_preserves_original_date_and_null_as_iso_calendar_dates(self):
for name, model_type, query_type, _ in REPORTS:
for production, expiry, expected in (
(date(2025, 12, 31), date(2026, 12, 31), ('2025-12-31', '2026-12-31')),
(None, None, (None, None)),
):
with self.subTest(report=name, production=production):
row = dict(report_row(model_type), production_date=production, expiry_date=expiry)
page = PageModel(rows=[row], total=1, pageNum=1, pageSize=10, hasNext=False)
with patch.object(ReportDao, f'get_{name}_list', AsyncMock(return_value=page)):
result = await getattr(ReportService, f'get_{name}_list_services')(
None, query_type(), make_user(1), TestBatchScope.formal(),
)
actual = result.rows[0].model_dump(mode='json', by_alias=True)
self.assertIn('productionDate', actual)
self.assertIn('expiryDate', actual)
self.assertEqual(expected, (actual['productionDate'], actual['expiryDate']))
if __name__ == '__main__':
unittest.main()
@@ -1026,6 +1026,13 @@ class TestReportControllerContract(unittest.TestCase):
'eventType',
},
}
for path in (
'/report/pallet-inventory/list', '/report/consumption/list',
'/report/return/list', '/report/pallet-trace/list',
):
expected_parameters[path] |= {'tunnelId', 'tunnelCode', 'tunnelName', 'traceNo'}
for path in ('/report/return/list', '/report/pallet-trace/list'):
expected_parameters[path].add('batchNo')
for path, parameters in expected_parameters.items():
with self.subTest(path=path):
operation_parameters = schema['paths'][path]['get']['parameters']
@@ -0,0 +1,164 @@
"""报表隧道回归:检查真实生成的查询与服务输出,全程不连接数据库。"""
import unittest
from unittest.mock import AsyncMock, patch
from common.vo import PageModel
from pydantic import ValidationError
from sqlalchemy.dialects import mysql
from sqlalchemy.sql.selectable import Join
from module_business.dao.report_dao import ReportDao
from module_business.domain.enums import PalletEventType
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportQueryModel,
PalletInventoryReportQueryModel, PalletTraceReportModel,
PalletTraceReportQueryModel, ReturnReportQueryModel,
)
from module_business.service.report_service import ReportService
from tests.module_business.test_report_service import make_user, report_row
def sql(expression):
return ' '.join(str(expression.compile(
dialect=mysql.dialect(), compile_kwargs={'literal_binds': True},
)).split())
def joins(statement):
def walk(node):
if isinstance(node, Join):
yield from walk(node.left)
yield node
return list(walk(statement.get_final_froms()[0]))
class TestReportTunnelQueries(unittest.IsolatedAsyncioTestCase):
async def statement(self, method, query, scope=None, **access):
# 只替换分页执行边界,保留 DAO 的真实 SQLAlchemy 查询生成。
paginate = AsyncMock(return_value=PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False))
with patch.object(ReportDao, '_paginate', paginate):
await method(None, query, scope or TestBatchScope.formal(), **access)
return paginate.await_args.args[1]
async def test_usage_without_line_is_not_removed_by_inner_join(self):
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel())
line_join = next(join for join in joins(statement) if join.right.name == 'biz_production_line')
self.assertTrue(line_join.isouter, 'line_id=NULL 的使用记录会被 INNER JOIN 丢弃')
async def test_optional_line_rejects_existing_cross_batch_association(self):
for batch_id, condition in ((None, 'IS NULL'), (41, '= 41')):
with self.subTest(batch_id=batch_id):
scope = TestBatchScope(batch_id=batch_id, mode='TEST' if batch_id else 'FORMAL', status=None, writable=False)
statement = await self.statement(
ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(), scope, site_ids=[9],
)
where = sql(statement.whereclause)
self.assertIn(f'(biz_production_line.line_id IS NULL OR biz_production_line.test_batch_id {condition})', where)
self.assertIn(f'biz_usage_record.test_batch_id {condition}', where)
self.assertIn('biz_usage_record.site_id IN (9)', where)
async def test_four_reports_filter_tunnel_and_trace_without_requiring_pallet(self):
for method, query_type, trace_column in (
(ReportDao.get_pallet_inventory_list, PalletInventoryReportQueryModel, 'biz_pallet_inventory.trace_no'),
(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel, 'biz_usage_record.trace_no'),
(ReportDao.get_return_report_list, ReturnReportQueryModel, 'biz_return_item.trace_no_snapshot'),
(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel, 'coalesce(biz_pallet_event.trace_no, trace_return_item.trace_no_snapshot)'),
):
with self.subTest(report=method.__name__):
query = query_type(tunnelId=17, tunnelCode='T-01', tunnelName='一号隧道', traceNo='TR-MIX')
statement = await self.statement(method, query)
where = sql(statement.whereclause)
self.assertIn(f"{trace_column} = 'TR-MIX'", where)
self.assertIn("tunnel_code LIKE '%%T-01%%'", where)
self.assertIn("tunnel_name LIKE '%%一号隧道%%'", where)
self.assertIn('tunnel_id', where)
self.assertIn('= 17', where)
self.assertNotIn('pallet_code LIKE', where)
self.assertTrue({'tunnel_id', 'tunnel_code', 'tunnel_name', 'trace_no'}.issubset(statement.selected_columns.keys()))
for join in joins(statement):
if 'tunnel' in join.right.name:
self.assertTrue(join.isouter)
self.assertIn(f'({join.right.name}.tunnel_id IS NULL OR {join.right.name}.test_batch_id IS NULL)', where)
async def test_usage_batch_filter_and_display_prefer_record_snapshot(self):
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(batchNo='B-OLD'))
self.assertEqual(
'coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no)',
sql(statement.selected_columns.batch_no.element),
)
self.assertIn("coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no) LIKE '%%B-OLD%%'", sql(statement.whereclause))
async def test_return_reads_frozen_tunnel_trace_and_batch_after_inventory_changes(self):
statement = await self.statement(ReportDao.get_return_report_list, ReturnReportQueryModel(batchNo='B-OLD'))
self.assertIn('tunnel_id', statement.selected_columns.keys())
self.assertEqual('biz_return_item.tunnel_id_snapshot', sql(statement.selected_columns.get('tunnel_id')).split(' AS ')[0])
self.assertEqual('biz_return_item.trace_no_snapshot', sql(statement.selected_columns.get('trace_no')).split(' AS ')[0])
self.assertIn('coalesce(biz_return_item.batch_no_snapshot, return_filling.batch_no)', sql(statement))
self.assertIn('return_filling.filling_id = biz_return_item.active_filling_id_snapshot', sql(statement))
self.assertIn('(return_filling.filling_id IS NULL OR return_filling.test_batch_id IS NULL)', sql(statement.whereclause))
self.assertNotIn('biz_pallet_inventory', sql(statement))
async def test_trace_filters_both_historical_tunnels_and_event_batch(self):
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel(tunnelId=17, batchNo='B-OLD'))
where = sql(statement.whereclause)
self.assertIn('(coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot) = 17 OR biz_pallet_event.after_tunnel_id = 17)', where)
self.assertIn("coalesce(biz_pallet_event.batch_no_snapshot, trace_return_item.batch_no_snapshot) LIKE '%%B-OLD%%'", where)
self.assertIn('biz_pallet_event.batch_no_snapshot', sql(statement))
self.assertNotIn('biz_pallet_inventory', sql(statement))
async def test_return_event_missing_tunnel_reads_same_pallet_return_snapshot(self):
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel())
self.assertEqual(
'coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot)',
sql(statement.selected_columns.before_tunnel_id.element),
)
query = sql(statement)
self.assertIn("biz_pallet_event.source_type = 'RETURN'", query)
self.assertIn('trace_return_item.return_id = biz_pallet_event.source_id', query)
self.assertIn('trace_return_item.pallet_id = biz_pallet_event.pallet_id', query)
self.assertIn('(trace_return_item.return_item_id IS NULL OR trace_return_item.test_batch_id IS NULL)', query)
class TestReportTunnelOutput(unittest.IsolatedAsyncioTestCase):
async def test_consumption_service_keeps_line_less_mixed_pallet_details(self):
rows = [dict(report_row(ConsumptionReportModel), usage_id=i, pallet_id=i,
pallet_code=f'P-{i}', line_id=None, line_name=None, tunnel_id=17,
tunnel_code='T-01', tunnel_name='一号隧道', trace_no='TR-MIX',
batch_no=batch, batch_no_snapshot=batch, used_packages=qty)
for i, batch, qty in ((1, 'B-A', 30), (2, 'B-B', 20))]
page = PageModel(rows=rows, total=2, pageNum=1, pageSize=10, hasNext=False)
with patch.object(ReportDao, 'get_consumption_report_list', AsyncMock(return_value=page)):
try:
result = await ReportService.get_consumption_report_list_services(
None, ConsumptionReportQueryModel(traceNo='TR-MIX'), make_user(1), TestBatchScope.formal(),
)
except ValidationError as exc:
self.fail(f'合法的无产线使用记录被响应模型拒绝:{exc}')
actual = [row.model_dump(by_alias=True) for row in result.rows]
self.assertEqual([30, 20], [row['usedPackages'] for row in actual])
self.assertEqual(['B-A', 'B-B'], [row['batchNoSnapshot'] for row in actual])
self.assertEqual(['TR-MIX', 'TR-MIX'], [row['traceNo'] for row in actual])
self.assertEqual([None, None], [row['lineId'] for row in actual])
def test_trace_quantities_separate_usage_and_confirmed_return(self):
for event, before, after, used, returned in (
('TUNNEL_ENTERED', 100, 100, 0, 0),
('USAGE_RECORDED', 100, 70, 30, 0),
('USAGE_RECORDED', 100, 80, 20, 0),
('RETURN_REQUESTED', 70, 70, 0, 0),
('RETURN_CONFIRMED', 70, 70, 0, 70),
('RETURN_CONFIRMED', 0, 0, 0, 0),
):
with self.subTest(event=event, before=before):
model = PalletTraceReportModel.model_validate(dict(
report_row(PalletTraceReportModel), event_type=PalletEventType(event),
before_remaining_packages=before, after_remaining_packages=after,
)).model_dump(by_alias=True)
self.assertEqual(used, model.get('usedPackages'))
self.assertEqual(returned, model.get('returnedPackages'))
if __name__ == '__main__':
unittest.main()
@@ -429,7 +429,7 @@ class ReturnControllerContractRedTest(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
(route.path_format, next(iter(route.methods))): route
for route in return_controller_module.return_controller.routes
if isinstance(route, APIRoute)
}
@@ -468,7 +468,7 @@ class ReturnControllerContractRedTest(unittest.TestCase):
def test_static_routes_permissions_current_user_and_write_logs(self) -> None:
routes = self._routes()
paths = [route.path for route in return_controller_module.return_controller.routes]
paths = [route.path_format for route in return_controller_module.return_controller.routes]
detail_index = paths.index('/business/return/{return_id}')
for static_path in (
'/business/return/list',
@@ -1731,6 +1731,14 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
pallets = [make_pallet(2), make_pallet(5)]
filling = make_filling(2, warehouse_id=3)
events: list[BizPalletEvent] = []
for inventory in inventories:
inventory.tunnel_id = 31
inventory.trace_no = 'TRACE-ORIGINAL'
inventory.site_arrived_at = datetime(2026, 7, 20)
for item in order.items:
item.tunnel_id_snapshot = 31
item.trace_no_snapshot = 'TRACE-ORIGINAL'
tunnel = SimpleNamespace(tunnel_id=31, site_id=9, current_trace_no='TRACE-ORIGINAL', test_batch_id=None)
async def add_event(
_db: AsyncSession,
@@ -1771,6 +1779,8 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
),
),
patch.object(OperationDao, 'update_return_order', new=AsyncMock()),
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=tunnel)),
patch.object(MasterDao, 'count_tunnel_trace_members', new=AsyncMock(return_value=0)),
patch.object(OperationDao, 'update_filling_record', new=AsyncMock()) as update_filling,
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
patch.object(return_service_module, 'datetime') as service_datetime,
@@ -1804,6 +1814,9 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([PalletEventType.RETURN_CONFIRMED.value] * 2, [event.event_type for event in events])
self.assertEqual([7, 7], [event.after_warehouse_id for event in events])
self.assertEqual([None, None], [event.after_site_id for event in events])
self.assertEqual([31, 31], [event.before_tunnel_id for event in events])
self.assertEqual(['TRACE-ORIGINAL', 'TRACE-ORIGINAL'], [event.trace_no for event in events])
self.assertTrue(all(inventory.site_arrived_at is None for inventory in inventories))
self.db.commit.assert_awaited_once()
async def test_confirm_resolves_authorized_return_route_before_key_lookup(self) -> None:
@@ -0,0 +1,27 @@
import unittest
from starlette.routing import Match
from module_business.controller.filling_controller import filling_controller
from module_business.controller.inbound_controller import inbound_controller
from module_business.controller.return_controller import return_controller
from module_business.controller.shipment_controller import shipment_controller
from module_business.controller.usage_controller import usage_controller
class TestScanPathIdentity(unittest.TestCase):
def test_scan_paths_preserve_printed_pallet_number_with_slashes(self) -> None:
for router in (
inbound_controller,
filling_controller,
shipment_controller,
usage_controller,
return_controller,
):
route = next(route for route in router.routes if '/scan/' in route.path)
# ASGI path 已由HTTP层解码,查询和片段字符不能再被当成URL语法。
path = route.path.split('/scan/')[0] + '/scan/托盘/2026?批#1'
with self.subTest(path=path):
match, child = route.matches({'type': 'http', 'method': 'GET', 'path': path, 'root_path': ''})
self.assertEqual(match, Match.FULL)
self.assertEqual(child['path_params']['qr_token'], '托盘/2026?批#1')
@@ -0,0 +1,244 @@
"""标签只读回归:执行真实服务/DAO,以记录型会话替代数据库连接。"""
import base64
import importlib
import importlib.util
import json
import unittest
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi import FastAPI, Request
from sqlalchemy.dialects import mysql
from common.aspect.interface_auth import CheckUserInterfaceAuth
from exceptions.exception import PermissionException, ServiceException
from module_business.controller import shipment_controller as shipment_controller_module
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 BizCustomer, BizCustomerSite, BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizShipmentItem, BizShipmentOrder
from module_business.service.pallet_service import PalletService
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
LabelRecords = tuple[BizShipmentOrder, BizShipmentItem, BizPallet, BizFillingRecord, BizCustomerSite, BizCustomer]
def sql(statement: Any) -> str:
return ' '.join(str(statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})).split())
class ReadOnlySession:
"""仅支持 SELECT,不提供写入、提交或真实连接能力。"""
def __init__(self, *results: Any) -> None:
self.results = list(results)
self.statements = []
async def execute(self, statement: Any) -> MagicMock:
assert statement.is_select, '标签不得产生业务写入'
self.statements.append(sql(statement))
assert self.results, '出现非预期查询'
value = self.results.pop(0)
result = MagicMock()
result.scalar_one_or_none.return_value = value
result.scalars.return_value.all.return_value = value
result.all.return_value = value
return result
class ShipmentLabelsTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
spec = importlib.util.find_spec('module_business.service.shipment_label_service')
self.assertIsNotNone(spec, '缺少可执行的发货标签服务')
self.service = importlib.import_module('module_business.service.shipment_label_service').ShipmentLabelService
def records(self, status: str = 'CONFIRMED', batch_id: int | None = None, dates: bool = True) -> LabelRecords:
order = BizShipmentOrder(shipment_id=12, shipment_no='原发货-12', source_warehouse_id=7,
target_site_id=9, status=status, test_batch_id=batch_id)
item = BizShipmentItem(shipment_item_id=1, shipment_id=12, pallet_id=5,
active_filling_id=105, package_qty=37, test_batch_id=batch_id)
pallet = BizPallet(pallet_id=5, pallet_code='原托盘-005', qr_token='fixed-qr-005',
asset_status='0', capacity_packages=100, test_batch_id=batch_id)
filling = BizFillingRecord(filling_id=105, pallet_id=5, strain_id=3,
strain_name_snapshot='<img src=x onerror=alert(1)>',
strain_code_snapshot='S03', batch_no='原生产批号-03',
active_slot='ACTIVE' if status == 'DRAFT' else None,
production_date=date(2026, 9, 1) if dates else None,
expiry_date=date(2027, 9, 1) if dates else None,
test_batch_id=batch_id)
site = BizCustomerSite(site_id=9, customer_id=4, site_name='原现场', test_batch_id=batch_id)
customer = BizCustomer(customer_id=4, customer_name='客户 <script>攻击</script>', test_batch_id=batch_id)
order.items = [item]
return order, item, pallet, filling, site, customer
def session(self, records: LabelRecords, *, admin: bool = True, qty: int | None = None) -> ReadOnlySession:
order, item, pallet, filling, site, customer = records
current_qty = 62 if qty is None else qty
detail_items = [(item, current_qty)] if order.status == 'DRAFT' else [item]
rows = [(item, pallet, filling, current_qty if order.status == 'DRAFT' else item.package_qty)]
values = [order, detail_items, site, customer, rows]
return ReadOnlySession(*([] if admin else [[7], [9]]), *values)
async def test_confirmed_uses_original_filling_and_package_snapshot_without_inventory(self) -> None:
records = self.records()
db = self.session(records)
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
self.assertEqual(1, len(labels))
payload = labels[0].model_dump(mode='json', by_alias=True)
self.assertEqual('原托盘-005', payload['palletCode'])
self.assertEqual('原发货-12', payload['shipmentNo'])
self.assertEqual('原生产批号-03', payload['batchNo'])
self.assertEqual('客户 <script>攻击</script>', payload['customerName'])
self.assertEqual('原现场', payload['siteName'])
self.assertEqual('<img src=x onerror=alert(1)>', payload['strainName'])
self.assertEqual(37, payload['packageQty'])
self.assertEqual('2026-09-01', payload['productionDate'])
self.assertEqual('2027-09-01', payload['expiryDate'])
prefix, encoded = payload['qrCodeDataUrl'].split(',', 1)
self.assertEqual('data:image/png;base64', prefix)
self.assertEqual(PalletService.render_qr_png('fixed-qr-005'), base64.b64decode(encoded))
self.assertNotIn('biz_pallet_inventory', ' '.join(db.statements))
self.assertIn('biz_shipment_item.active_filling_id = biz_filling_record.filling_id', db.statements[-1])
self.assertNotIn("active_slot = 'ACTIVE'", db.statements[-1])
self.assertEqual([], db.results)
async def test_draft_uses_current_quantity_and_active_filling_with_null_dates(self) -> None:
records = self.records('DRAFT', dates=False)
records[1].active_filling_id = None
records[1].package_qty = None
db = self.session(records)
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
self.assertEqual(62, labels[0].package_qty)
self.assertIsNone(labels[0].production_date)
self.assertIsNone(labels[0].expiry_date)
statement = db.statements[-1]
for fragment in (
'biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id',
'biz_pallet_inventory.warehouse_id = 7',
"biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'",
'biz_pallet_inventory.site_id IS NULL',
'biz_pallet_inventory.current_line_id IS NULL',
'biz_pallet_inventory.remaining_packages > 0',
"biz_filling_record.active_slot = 'ACTIVE'",
):
self.assertIn(fragment, statement)
async def test_both_business_scopes_and_each_related_batch_are_in_real_queries(self) -> None:
for scope in (TestBatchScope.formal(), make_test_scope(31)):
for status in ('DRAFT', 'CONFIRMED'):
with self.subTest(scope=scope, status=status):
db = self.session(self.records(status, scope.batch_id), admin=False)
await self.service.get_labels_services(db, 12, make_current_user(), scope)
self.assertIn('source_warehouse_id IN (7)', db.statements[2])
self.assertIn('target_site_id IN (9)', db.statements[2])
combined = ' '.join(db.statements)
tables = ['biz_shipment_order', 'biz_shipment_item', 'biz_pallet',
'biz_filling_record', 'biz_customer_site', 'biz_customer']
if status == 'DRAFT':
tables.append('biz_pallet_inventory')
for table in tables:
predicate = 'IS NULL' if scope.batch_id is None else '= 31'
self.assertIn(f'{table}.test_batch_id {predicate}', combined)
self.assertNotIn('FOR UPDATE', combined)
async def test_inaccessible_order_stops_before_related_queries_or_qr_rendering(self) -> None:
for warehouses, sites in (([], [9]), ([7], []), ([8], [10])):
with self.subTest(warehouses=warehouses, sites=sites):
db = ReadOnlySession(warehouses, sites, None)
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未授权生成二维码')),
self.assertRaises(PermissionException),
):
await self.service.get_labels_services(db, 12, make_current_user(), TestBatchScope.formal())
self.assertEqual(3, len(db.statements))
async def test_cross_batch_relations_are_rejected_before_qr_generation(self) -> None:
for index in range(6):
with self.subTest(record=index):
records = self.records()
records[index].test_batch_id = 31
db = self.session(records)
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('跨批次生成二维码')),
self.assertRaises(ServiceException),
):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_incomplete_label_does_not_print_partial_or_fall_back_to_current_inventory(self) -> None:
for status in ('DRAFT', 'CONFIRMED'):
for missing in ('pallet', 'filling', 'quantity', 'snapshot'):
with self.subTest(status=status, missing=missing):
records = self.records(status)
if missing == 'snapshot':
records[3].pallet_id = 99
db = self.session(records)
row = list(db.results[-1][0])
if missing != 'snapshot':
row[{'pallet': 1, 'filling': 2, 'quantity': 3}[missing]] = None
db.results[-1] = [tuple(row)]
with self.assertRaises(ServiceException):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_missing_later_pallet_prevents_any_qr_generation(self) -> None:
records = self.records()
db = self.session(records)
second = BizShipmentItem(shipment_item_id=2, shipment_id=12, pallet_id=6,
active_filling_id=106, package_qty=20, test_batch_id=None)
db.results[1].append(second)
db.results[-1].append((second, None, None, 20))
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未完成校验已生成二维码')),
self.assertRaises(ServiceException),
):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_empty_shipment_is_not_printable(self) -> None:
records = self.records()
db = self.session(records)
db.results[1] = []
db.results[-1] = []
with self.assertRaises(ServiceException):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_route_serializes_label_dates_and_prevents_cross_scope_browser_cache(self) -> None:
db = self.session(self.records())
response = await shipment_controller_module.get_business_shipment_labels(
Request({'type': 'http'}), 12, db, make_admin(), TestBatchScope.formal(),
)
payload = json.loads(response.body)
self.assertEqual(200, response.status_code)
self.assertEqual('2026-09-01', payload['data'][0]['productionDate'])
self.assertEqual('CONFIRMED', payload['data'][0]['shipmentStatus'])
self.assertTrue(payload['data'][0]['qrCodeDataUrl'].startswith('data:image/png;base64,'))
self.assertEqual('no-store', response.headers.get('Cache-Control'))
async def test_draft_rejects_disabled_closed_or_excess_quantity(self) -> None:
for field in ('asset_status', 'active_slot', 'capacity_packages'):
with self.subTest(field=field):
records = self.records('DRAFT')
target = records[3] if field == 'active_slot' else records[2]
setattr(target, field, {'asset_status': '1', 'active_slot': None, 'capacity_packages': 20}[field])
with self.assertRaises(ServiceException):
await self.service.get_labels_services(
self.session(records), 12, make_admin(), TestBatchScope.formal(),
)
class ShipmentLabelRouteTest(unittest.TestCase):
def test_label_route_reuses_query_permission_and_read_scope(self) -> None:
app = FastAPI()
app.include_router(shipment_controller_module.shipment_controller)
routes = [route for route in app.routes if route.path == '/business/shipment/{shipment_id}/labels']
self.assertEqual(1, len(routes), '缺少标签 GET 路由')
route = routes[0]
self.assertEqual({'GET'}, route.methods)
checks = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, CheckUserInterfaceAuth)]
self.assertEqual(['business:shipment:query'], [check.perm for check in checks])
scopes = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, TestBatchScopeResolver)]
self.assertEqual([False], [scope.write for scope in scopes])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,140 @@
import unittest
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import PermissionException, ServiceException
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.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomerSite
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel
from module_business.service.shipment_receipt_service import ShipmentReceiptService
from tests.module_business.test_shipment_service import make_inventory, make_pallet, make_shipment, make_user
class TestShipmentReceipt(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
self.scope = TestBatchScope.formal()
self.order = make_shipment(status='CONFIRMED', pallet_ids=(2, 5))
self.order.receipt_state = 'PENDING'
self.order.confirmed_at = datetime(2026, 10, 8, 8)
self.inventories = [
make_inventory(i, warehouse_id=None, site_id=9, flow_state='IN_TRANSIT', remaining_packages=100)
for i in (2, 5)
]
for item in self.order.items:
item.active_filling_id = 100 + item.pallet_id
item.package_qty = 100
self.events = []
self.site_access = AsyncMock(return_value=True)
self.patches = [
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(AccessDao, 'user_has_site', new=self.site_access),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=self.order)),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=self.inventories)),
patch.object(
InventoryDao,
'get_pallets_by_ids',
new=AsyncMock(return_value=[make_pallet(i, capacity=100) for i in (2, 5)]),
),
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=self.capture_event)),
]
for patcher in self.patches:
patcher.start()
self.addCleanup(patcher.stop)
async def capture_event(self, _db, inventory, event, _scope):
self.events.append(event)
return event
def command(self, ids=None, key='receipt-key'):
return ShipmentReceiptCommandModel(scannedPalletIds=ids if ids is not None else [5, 2], idempotencyKey=key)
async def receive(self, command=None):
return await ShipmentReceiptService.confirm_services(
self.db, 12, command or self.command(), self.user, self.scope
)
async def test_receipt_keeps_original_pallet_filling_and_quantity(self) -> None:
await self.receive()
self.assertEqual(self.order.receipt_state, 'RECEIVED')
self.assertEqual(self.order.received_by, 7)
self.assertIsInstance(self.order.received_at, datetime)
self.assertEqual(
[(i.pallet_id, i.active_filling_id, i.remaining_packages, i.flow_state) for i in self.inventories],
[
(2, 102, 100, 'AT_SITE_UNUSED'),
(5, 105, 100, 'AT_SITE_UNUSED'),
],
)
self.assertTrue(all(i.site_arrived_at == self.order.received_at for i in self.inventories))
self.assertEqual(
[(e.event_type, e.before_state, e.after_state) for e in self.events],
[
('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'),
('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'),
],
)
self.db.commit.assert_awaited_once()
async def test_retry_after_later_consumption_does_not_restore_quantity(self) -> None:
await self.receive()
self.inventories[0].remaining_packages = 80
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
await self.receive(self.command(ids=[2, 5]))
self.assertEqual(self.inventories[0].remaining_packages, 80)
self.assertEqual(len(self.events), 2)
self.db.commit.assert_awaited_once()
async def test_received_order_rejects_different_confirmation_key(self) -> None:
await self.receive()
with self.assertRaises(ServiceException):
await self.receive(self.command(key='different'))
self.assertEqual(len(self.events), 2)
async def test_missing_or_unexpected_scans_do_not_change_inventory(self) -> None:
for ids in ([2], [2, 5, 6], [2, 6]):
with self.subTest(ids=ids), self.assertRaises(ServiceException):
await self.receive(self.command(ids=ids))
self.assertEqual(self.order.receipt_state, 'PENDING')
self.assertFalse(self.events)
self.db.commit.assert_not_awaited()
async def test_target_site_access_is_required_even_with_warehouse_permissions(self) -> None:
self.site_access.return_value = False
with self.assertRaises(PermissionException):
await self.receive()
self.assertFalse(self.events)
self.db.commit.assert_not_awaited()
async def test_draft_and_legacy_cannot_be_received_as_new_shipments(self) -> None:
for status, receipt_state in [('DRAFT', 'PENDING'), ('CONFIRMED', 'LEGACY')]:
self.order.status = status
self.order.receipt_state = receipt_state
with self.subTest(status=status, receipt_state=receipt_state), self.assertRaises(ServiceException):
await self.receive()
self.assertFalse(self.events)
async def test_changed_material_snapshot_is_rejected_before_any_mutation(self) -> None:
self.inventories[1].active_filling_id = 999
with self.assertRaises(ServiceException):
await self.receive()
self.assertEqual(self.inventories[0].flow_state, 'IN_TRANSIT')
self.assertFalse(self.events)
async def test_cross_test_batch_inventory_is_rejected(self) -> None:
self.inventories[1].test_batch_id = 3
with self.assertRaises(ServiceException):
await self.receive()
self.assertFalse(self.events)
@@ -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()
@@ -52,6 +52,7 @@ from module_business.entity.vo.operation_vo import (
ShipmentScanQueryModel,
ShipmentUpdateModel,
)
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
from module_business.service import shipment_service as shipment_service_module
from module_business.service.access_service import AccessService
from module_business.service.shipment_service import ShipmentService
@@ -395,12 +396,12 @@ class ShipmentControllerContractTest(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
(route.path_format, next(iter(route.methods))): route
for route in shipment_controller_module.shipment_controller.routes
if isinstance(route, APIRoute)
}
def test_openapi_has_nine_operations_aliases_and_response_models(self) -> None:
def test_openapi_has_ten_operations_aliases_and_response_models(self) -> None:
app = FastAPI()
app.include_router(shipment_controller_module.shipment_controller)
schema = app.openapi()
@@ -410,6 +411,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
('/business/shipment/site-options', 'get'),
('/business/shipment/scan/{qr_token}', 'get'),
('/business/shipment/{shipment_id}', 'get'),
('/business/shipment/{shipment_id}/labels', 'get'),
('/business/shipment', 'post'),
('/business/shipment', 'put'),
('/business/shipment/{shipment_ids}', 'delete'),
@@ -437,6 +439,10 @@ class ShipmentControllerContractTest(unittest.TestCase):
DataResponseModel[ShipmentModel],
routes[('/business/shipment/{shipment_id}', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[list[ShipmentLabelModel]],
routes[('/business/shipment/{shipment_id}/labels', 'GET')].response_model,
)
for key in (
('/business/shipment', 'POST'),
('/business/shipment', 'PUT'),
@@ -446,7 +452,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
self.assertEqual(ResponseBaseModel, routes[key].response_model)
def test_static_options_and_scan_precede_dynamic_detail(self) -> None:
paths = [route.path for route in shipment_controller_module.shipment_controller.routes]
paths = [route.path_format for route in shipment_controller_module.shipment_controller.routes]
detail_index = paths.index('/business/shipment/{shipment_id}')
for static_path in (
'/business/shipment/warehouse-options',
@@ -462,6 +468,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
('/business/shipment/site-options', 'GET'): 'business:shipment:query',
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}/labels', 'GET'): 'business:shipment:query',
('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
@@ -1822,8 +1829,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
'warehouse-mapping-lock',
'site-lock',
'site-mapping-lock',
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED',
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED',
'transition-FILLED_IN_WAREHOUSE-IN_TRANSIT',
'transition-FILLED_IN_WAREHOUSE-IN_TRANSIT',
'update-order',
'event-2',
'event-5',
@@ -1843,6 +1850,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.request_hash, shipment.confirm_request_hash)
self.assertEqual(7, shipment.confirmed_by)
self.assertEqual(self.fixed_time, shipment.confirmed_at)
self.assertEqual('PENDING', shipment.receipt_state)
self.assertIsNone(shipment.received_by)
self.assertIsNone(shipment.received_at)
snapshots = {item.pallet_id: (item.active_filling_id, item.package_qty) for item in shipment.items}
self.assertEqual({2: (102, 8), 5: (105, 13)}, snapshots)
for inventory, original_version, remaining in zip(
@@ -1851,7 +1861,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
(8, 13),
strict=True,
):
self.assertEqual('AT_SITE_UNUSED', inventory.flow_state)
self.assertEqual('IN_TRANSIT', inventory.flow_state)
self.assertIsNone(inventory.site_arrived_at)
self.assertIsNone(inventory.warehouse_id)
self.assertEqual(9, inventory.site_id)
self.assertIsNone(inventory.current_line_id)
@@ -1865,7 +1876,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual('SHIPMENT', event.source_type)
self.assertEqual(12, event.source_id)
self.assertEqual('FILLED_IN_WAREHOUSE', event.before_state)
self.assertEqual('AT_SITE_UNUSED', event.after_state)
self.assertEqual('IN_TRANSIT', event.after_state)
self.assertEqual(7, event.before_warehouse_id)
self.assertIsNone(event.before_site_id)
self.assertIsNone(event.after_warehouse_id)
@@ -10,6 +10,7 @@ PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql'
TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql'
SHIPMENT_RECEIPT_PATCH_PATH = SQL_ROOT / 'patches/20261008_shipment_receipt_mysql.sql'
SYS_MENU_VALUE_COUNT = 20
SYS_MENU_PERMS_INDEX = 13
SYS_MENU_ICON_INDEX = 14
@@ -374,8 +375,79 @@ LEGACY_EXPECTED_UNIQUE_KEYS = {table: set(keys) for table, keys in EXPECTED_UNIQ
LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()}
LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()}
EXPECTED_COLUMNS['biz_filling_record'].update({'production_date', 'expiry_date'})
EXPECTED_CHECKS['biz_filling_record'].add(
'(production_dateisnullandexpiry_dateisnull)or(production_dateisnotnullandexpiry_dateisnotnullandexpiry_date>=production_date)'
)
CURRENT_TEST_BATCH_OWNED_TABLES = TEST_BATCH_OWNED_TABLES | {'biz_tunnel'}
EXPECTED_TABLES.add('biz_tunnel')
EXPECTED_COLUMNS.update(
{
'biz_tunnel': {
'tunnel_id',
'site_id',
'tunnel_code',
'tunnel_name',
'current_trace_no',
'current_trace_started_at',
'status',
}
| AUDIT_COLUMNS,
'biz_usage_record': EXPECTED_COLUMNS['biz_usage_record']
| {'tunnel_id', 'trace_no', 'batch_no_snapshot'},
'biz_return_item': EXPECTED_COLUMNS['biz_return_item']
| {'tunnel_id_snapshot', 'trace_no_snapshot', 'batch_no_snapshot'},
'biz_pallet_inventory': EXPECTED_COLUMNS['biz_pallet_inventory'] | {'tunnel_id', 'trace_no'},
'biz_pallet_event': EXPECTED_COLUMNS['biz_pallet_event']
| {'before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot'},
}
)
EXPECTED_DATETIME_COLUMNS['biz_tunnel'] = {'current_trace_started_at', 'create_time', 'update_time'}
EXPECTED_PRIMARY_KEYS['biz_tunnel'] = ('tunnel_id',)
EXPECTED_UNIQUE_KEYS['biz_tunnel'] = {('site_id', 'tunnel_code')}
EXPECTED_FOREIGN_KEYS.update(
{
'biz_tunnel': {('site_id', 'biz_customer_site.site_id')},
'biz_usage_record': EXPECTED_FOREIGN_KEYS['biz_usage_record']
| {('tunnel_id', 'biz_tunnel.tunnel_id')},
'biz_return_item': EXPECTED_FOREIGN_KEYS['biz_return_item']
| {('tunnel_id_snapshot', 'biz_tunnel.tunnel_id')},
'biz_pallet_inventory': EXPECTED_FOREIGN_KEYS['biz_pallet_inventory']
| {('tunnel_id', 'biz_tunnel.tunnel_id')},
'biz_pallet_event': EXPECTED_FOREIGN_KEYS['biz_pallet_event']
| {
('before_tunnel_id', 'biz_tunnel.tunnel_id'),
('after_tunnel_id', 'biz_tunnel.tunnel_id'),
},
}
)
EXPECTED_CHECKS['biz_tunnel'] = {"statusin('0','1')"}
EXPECTED_CHECKS['biz_pallet_event'] = {
"event_typein('inbound_confirmed','filling_confirmed','shipment_confirmed','shipment_received','tunnel_entered','usage_recorded',"
"'return_requested','return_confirmed')"
}
# 新契约必须位于 LEGACY_EXPECTED_* 的独立副本之后,旧补丁保持原始结构。
for fifo_table in ('biz_usage_record', 'biz_pallet_event'):
EXPECTED_COLUMNS[fifo_table].update({'fifo_skip_reason', 'fifo_recommendation'})
EXPECTED_COLUMNS['biz_shipment_order'].update({
'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash',
})
EXPECTED_DATETIME_COLUMNS['biz_shipment_order'].add('received_at')
EXPECTED_UNIQUE_KEYS['biz_shipment_order'].add(('target_site_id', 'receipt_idempotency_key'))
EXPECTED_FOREIGN_KEYS['biz_shipment_order'].add(('received_by', 'sys_user.user_id'))
EXPECTED_CHECKS['biz_shipment_order'].add("receipt_statein('pending','received','legacy')")
EXPECTED_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
EXPECTED_DATETIME_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
EXPECTED_CHECKS['biz_pallet_inventory'] = {
check.replace("'filled_in_warehouse','at_site_unused'", "'filled_in_warehouse','in_transit','at_site_unused'")
for check in EXPECTED_CHECKS['biz_pallet_inventory']
}
EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES
for table_name in TEST_BATCH_OWNED_TABLES:
for table_name in CURRENT_TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id'))
@@ -452,6 +524,7 @@ EXPECTED_PAGES = {
505: ('产线资料', 121, 4, 'line', 'master/line/index', 'master:line:list'),
506: ('仓库资料', 121, 5, 'warehouse', 'master/warehouse/index', 'master:warehouse:list'),
507: ('托盘资料', 121, 6, 'pallet', 'master/pallet/index', 'master:pallet:list'),
520: ('隧道资料', 121, 7, 'tunnel', 'master/tunnel/index', 'master:tunnel:list'),
508: ('仓库入库', 122, 1, 'inbound', 'business/inbound/index', 'business:inbound:list'),
509: ('工厂装填', 122, 2, 'filling', 'business/filling/index', 'business:filling:list'),
510: ('发货出库', 122, 3, 'shipment', 'business/shipment/index', 'business:shipment:list'),
@@ -494,6 +567,9 @@ BUTTON_SPECS = """
1088 507 2 托盘新增 master:pallet:add
1089 507 3 托盘修改 master:pallet:edit
1091 507 5 托盘打印 master:pallet:print
1116 520 1 隧道查询 master:tunnel:query
1117 520 2 隧道新增 master:tunnel:add
1118 520 3 隧道修改 master:tunnel:edit
1092 508 1 入库查询 business:inbound:query
1093 508 2 入库新增 business:inbound:add
1094 508 3 入库修改 business:inbound:edit
@@ -532,6 +608,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
'master:line:list',
'master:warehouse:list',
'master:pallet:list',
'master:tunnel:list',
'business:inbound:list',
'business:filling:list',
'business:shipment:list',
@@ -568,6 +645,9 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'master:pallet:add',
'master:pallet:edit',
'master:pallet:print',
'master:tunnel:query',
'master:tunnel:add',
'master:tunnel:edit',
'business:inbound:query',
'business:inbound:add',
'business:inbound:edit',
@@ -604,6 +684,7 @@ BUSINESS_MENU_SPECS = """
505|121|产线资料|line||4|master:line:list|master/line/index
506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index
507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index
520|121|隧道资料|tunnel||7|master:tunnel:list|master/tunnel/index
508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index
509|122|工厂装填|filling||2|business:filling:list|business/filling/index
510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index
@@ -636,6 +717,9 @@ BUSINESS_MENU_SPECS = """
1088|507|托盘新增|||2|master:pallet:add|
1089|507|托盘修改|||3|master:pallet:edit|
1091|507|托盘打印|||5|master:pallet:print|
1116|520|隧道查询|||1|master:tunnel:query|
1117|520|隧道新增|||2|master:tunnel:add|
1118|520|隧道修改|||3|master:tunnel:edit|
1092|508|入库查询|||1|business:inbound:query|
1093|508|入库新增|||2|business:inbound:add|
1094|508|入库修改|||3|business:inbound:edit|
@@ -699,9 +783,11 @@ LEGACY_EXPECTED_BUSINESS_MENU_FIELDS.update(
for menu_id, (name, parent_id, order_num, permission) in LEGACY_REMOVED_BUTTON_SPECS.items()
}
)
LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list'}
LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list', 'master:tunnel:list'}
LEGACY_EXPECTED_BUTTON_PERMISSIONS = EXPECTED_BUTTON_PERMISSIONS - {
permission for permission in EXPECTED_BUTTON_PERMISSIONS if permission.startswith('system:testbatch:')
permission
for permission in EXPECTED_BUTTON_PERMISSIONS
if permission.startswith(('system:testbatch:', 'master:tunnel:'))
} | {spec[3] for spec in LEGACY_REMOVED_BUTTON_SPECS.values()}
LEGACY_EXPECTED_PERMISSIONS = LEGACY_EXPECTED_PAGE_PERMISSIONS | LEGACY_EXPECTED_BUTTON_PERMISSIONS
LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
@@ -709,6 +795,11 @@ LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
123: (1, '报表查询', 'businessReport', '', 12, '', None),
}
# 同样在菜单旧快照生成后加入收货权限,避免改变历史迁移的权限清单。
EXPECTED_BUTTONS[1119] = ('收货核对', 510, 6, 'business:shipment:receive')
EXPECTED_BUSINESS_MENU_FIELDS[1119] = (510, '收货核对', '', '', 6, 'business:shipment:receive', '')
EXPECTED_BUTTON_PERMISSIONS |= {'business:shipment:receive'}
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
121: ('基础资料维护', 0, 1),
122: ('业务管理', 0, 2),
@@ -734,7 +825,7 @@ MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
BUSINESS_MENU_IDS = (
set(range(121, 124))
| set(range(502, 519))
| {519}
| {519, 520, 1119}
| set(range(1065, 1068))
| set(range(1069, 1072))
| set(range(1073, 1076))
@@ -750,7 +841,7 @@ BUSINESS_MENU_IDS = (
| set(range(1099, 1102))
| {1103}
| set(range(1104, 1109))
| set(range(1109, 1116))
| set(range(1109, 1119))
)
TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115}
MYSQL_MENU_STABLE_COLUMNS = (
@@ -1033,11 +1124,12 @@ def _assert_test_batch_sql_contract(
create_batch = normalized.index('create table biz_test_batch (')
create_batch_user = normalized.index('create table biz_test_batch_user (')
owned_marker = 'alter table' if is_patch else 'create table'
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in TEST_BATCH_OWNED_TABLES)
owned_tables = TEST_BATCH_OWNED_TABLES if is_patch else CURRENT_TEST_BATCH_OWNED_TABLES
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in owned_tables)
test_case.assertLess(create_batch, create_batch_user)
test_case.assertLess(create_batch_user, first_owned)
for table_name in TEST_BATCH_OWNED_TABLES:
for table_name in owned_tables:
with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name):
if is_patch:
test_case.assertRegex(
@@ -1319,6 +1411,103 @@ def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql
return pre_guard, post_guard
class ShipmentReceiptSqlContractTest(unittest.TestCase):
def patch_sql(self) -> str:
self.assertTrue(SHIPMENT_RECEIPT_PATCH_PATH.is_file(), '缺少收货单次迁移脚本')
return SHIPMENT_RECEIPT_PATCH_PATH.read_text(encoding='utf-8')
def test_patch_additions_match_initializer_columns_and_constraints(self) -> None:
def normalize(value: str) -> str:
return re.sub(r'\b(bigint)\(20\)', r'\1', value).split(' comment ')[0]
raw = _strip_sql_comments(self.patch_sql())
initial = _create_table_bodies((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8'))
alters = re.findall(r'\balter\s+table\s+(\w+)\s+(.*?);', raw, re.I | re.S)
self.assertEqual(['biz_shipment_order', 'biz_pallet_inventory', 'biz_pallet_event'], [table for table, _ in alters])
expected_columns = {
'biz_shipment_order': {'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash'},
'biz_pallet_inventory': {'site_arrived_at'},
'biz_pallet_event': set(),
}
for table, body in alters:
with self.subTest(table=table):
additions = ','.join(
re.sub(r'^add\s+(?:column\s+)?', '', clause.strip(), flags=re.I)
for clause in _split_values(body) if clause.strip().lower().startswith('add ')
)
columns, _, uniques, foreign_keys, checks = _table_contract(additions)
self.assertEqual(expected_columns[table], columns)
self.assertEqual(
{('target_site_id', 'receipt_idempotency_key')} if table == 'biz_shipment_order' else set(), uniques,
)
self.assertEqual({('received_by', 'sys_user.user_id')} if table == 'biz_shipment_order' else set(), foreign_keys)
expected_checks = (
{"receipt_statein('pending','received','legacy')"} if table == 'biz_shipment_order'
else {check for check in EXPECTED_CHECKS[table] if check.startswith(('flow_statein', 'event_typein'))}
)
self.assertEqual(expected_checks, checks)
initializer_columns = _column_definitions(initial[table])
for column, definition in _column_definitions(additions).items():
normalized = _normalized_sql_statement(re.sub(r'\s+after\s+\w+\s*$', '', definition, flags=re.I))
# 类型、可空性和默认值均应与初始化定义一致,注释不参与比较。
self.assertEqual(normalize(_normalized_sql_statement(initializer_columns[column])), normalize(normalized))
def test_patch_only_marks_confirmed_legacy_and_does_not_touch_inventory_or_receipt_time(self) -> None:
raw = _strip_sql_comments(self.patch_sql())
updates = re.findall(r'\bupdate\s+([a-z_]+)\s+set\s+(.*?);', raw, re.I | re.S)
self.assertEqual(1, len(updates))
self.assertEqual('biz_shipment_order', updates[0][0].lower())
self.assertEqual("receipt_state = 'legacy' where status = 'confirmed'", _normalized_sql_statement(updates[0][1]))
inserts = re.findall(r'\binsert\s+into\s+(\w+)', raw, re.I)
self.assertEqual(['sys_menu'], inserts)
self.assertNotRegex(raw.lower(), r'\b(?:delete\s+from|replace\s+into|truncate\s+table)\b')
self.assertNotRegex(raw.lower(), r'\b(?:received_at|site_arrived_at)\s*=')
def test_patch_guards_conflicting_menu_ids_and_permissions_before_any_alter(self) -> None:
raw = _strip_sql_comments(self.patch_sql()).lower()
guard = raw[:raw.index('alter table')]
for expression in (
r'from\s+sys_menu\s+where\s+menu_id\s*=\s*1119\s+or\s+perms\s*=\s*\'business:shipment:receive\'',
r'from\s+sys_role_menu\s+where\s+menu_id\s*=\s*1119',
r'menu_id\s*=\s*510\s+and\s+perms\s*=\s*\'business:shipment:list\'',
):
self.assertRegex(guard, expression)
self.assertIn("signal sqlstate '45000'", guard)
self.assertIn('resignal', guard)
rows = _insert_rows(raw, 'sys_menu')
self.assertEqual(1, len(rows))
self.assertEqual(SYS_MENU_VALUE_COUNT, len(rows[0]))
self.assertEqual(['1119', '收货核对', '510', '6'], rows[0][:4])
self.assertEqual('business:shipment:receive', rows[0][SYS_MENU_PERMS_INDEX])
self.assertNotRegex(raw, r'\b(?:insert\s+into|update|delete\s+from)\s+sys_role_menu\b')
def test_patch_requires_prerequisites_and_rejects_repeated_or_partial_execution(self) -> None:
raw = self.patch_sql()
for phrase in ('MySQL 8', '停写', '备份', '单次', 'DDL', '隐式提交', '20261005_tunnel_material_flow_mysql.sql', '20261008_material_dates_mysql.sql'):
self.assertIn(phrase, raw)
guard = _strip_sql_comments(raw).lower().split('alter table', maxsplit=1)[0]
self.assertIn('version()', guard)
self.assertIn('information_schema.columns', guard)
for field in ('production_date', 'expiry_date', 'tunnel_id', 'trace_no', 'receipt_state', 'received_by',
'received_at', 'receipt_idempotency_key', 'receipt_request_hash', 'site_arrived_at'):
self.assertIn(f"'{field}'", guard)
self.assertIn('information_schema.check_constraints', guard)
self.assertIn("'ck_biz_filling_record_material_dates'", guard)
self.assertIn("'ck_biz_pallet_event_event_type'", guard)
self.assertIn('tunnel_entered', guard)
self.assertLess(raw.lower().rindex('alter table'), raw.lower().index('start transaction'))
def test_current_expectations_leave_legacy_schema_and_menu_snapshots_unchanged(self) -> None:
self.assertNotIn('receipt_state', LEGACY_EXPECTED_COLUMNS['biz_shipment_order'])
self.assertNotIn('site_arrived_at', LEGACY_EXPECTED_COLUMNS['biz_pallet_inventory'])
self.assertNotIn(('target_site_id', 'receipt_idempotency_key'), LEGACY_EXPECTED_UNIQUE_KEYS['biz_shipment_order'])
self.assertNotIn(('received_by', 'sys_user.user_id'), LEGACY_EXPECTED_FOREIGN_KEYS['biz_shipment_order'])
self.assertNotIn('in_transit', ' '.join(LEGACY_EXPECTED_CHECKS['biz_pallet_inventory']))
self.assertNotIn('shipment_received', ' '.join(LEGACY_EXPECTED_CHECKS['biz_pallet_event']))
self.assertNotIn('business:shipment:receive', LEGACY_EXPECTED_PERMISSIONS)
self.assertNotIn(1119, LEGACY_EXPECTED_BUSINESS_MENU_FIELDS)
class SqlContractTest(unittest.TestCase):
def test_test_batch_initializers_and_patches_match_contract(self) -> None:
scripts = (
@@ -1407,7 +1596,7 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(EXPECTED_CHECKS[table], checks)
def test_mysql_business_datetimes_use_native_datetime_columns(self) -> None:
self.assertEqual(40, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
self.assertEqual(45, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
invalid_columns = []
for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items():
@@ -1425,11 +1614,11 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual([], sorted(invalid_columns))
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(47, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS))
self.assertEqual(66, len(EXPECTED_PERMISSIONS))
self.assertEqual(69, len(EXPECTED_BUSINESS_MENU_FIELDS))
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
@@ -13,10 +13,18 @@ from module_business.domain.pallet_state_machine import (
class PalletStateMachineTest(unittest.TestCase):
def test_dispatch_cannot_skip_receiving_confirmation(self) -> None:
with self.assertRaises(InvalidPalletTransition):
PalletStateMachine.ensure_transition(
PalletFlowState.FILLED_IN_WAREHOUSE,
PalletFlowState.AT_SITE_UNUSED,
)
ALLOWED_TRANSITIONS = (
(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE),
(PalletFlowState.EMPTY_IN_WAREHOUSE, PalletFlowState.FILLED_IN_WAREHOUSE),
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.AT_SITE_UNUSED),
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.IN_TRANSIT),
(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING),
@@ -75,6 +83,7 @@ class PalletEnumTest(unittest.TestCase):
'UNSTOCKED',
'EMPTY_IN_WAREHOUSE',
'FILLED_IN_WAREHOUSE',
'IN_TRANSIT',
'AT_SITE_UNUSED',
'AT_SITE_PARTIAL',
'AT_SITE_EMPTY',
@@ -92,6 +101,8 @@ class PalletEnumTest(unittest.TestCase):
'INBOUND_CONFIRMED',
'FILLING_CONFIRMED',
'SHIPMENT_CONFIRMED',
'SHIPMENT_RECEIVED',
'TUNNEL_ENTERED',
'USAGE_RECORDED',
'RETURN_REQUESTED',
'RETURN_CONFIRMED',
@@ -35,6 +35,7 @@ TABLE_LABELS = {
'biz_customer': '客户',
'biz_customer_site': '客户现场',
'biz_production_line': '生产线',
'biz_tunnel': '隧道',
'biz_warehouse': '仓库',
'biz_pallet': '托盘',
'biz_inbound_order': '入库单',
@@ -187,6 +188,8 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
active_filling_id=21,
warehouse_id=None,
site_id=3,
tunnel_id=None,
trace_no=None,
current_line_id=None,
last_event_id=41,
)
@@ -198,9 +201,11 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
source_id=30,
before_warehouse_id=2,
before_site_id=None,
before_tunnel_id=None,
before_line_id=None,
after_warehouse_id=None,
after_site_id=3,
after_tunnel_id=None,
after_line_id=None,
)
self.db = GraphSession(self.rows)
@@ -41,6 +41,7 @@ EXPECTED_DELETE_ORDER = [
'biz_pallet_event',
'biz_filling_record',
'biz_production_line',
'biz_tunnel',
'biz_customer_site',
'biz_customer',
'biz_warehouse',
@@ -0,0 +1,121 @@
import unittest
import hashlib
import json
from pydantic import ValidationError
from module_business.domain.enums import PalletEventType
from module_business.entity.do.master_do import BizTunnel
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory, BizReturnItem, BizUsageRecord
from module_business.entity.vo.master_vo import PalletCreateModel, TunnelModel, TunnelPageQueryModel
from module_business.entity.vo.operation_vo import FillingCreateModel, TunnelEntryCreateModel, UsageCreateModel
from module_business.service.usage_service import UsageService
class TestTunnelFlowContract(unittest.TestCase):
def test_tunnel_master_data_belongs_to_site_and_has_scoped_code(self) -> None:
self.assertEqual(BizTunnel.__tablename__, 'biz_tunnel')
self.assertIn('site_id', BizTunnel.__table__.c)
self.assertIn('tunnel_code', BizTunnel.__table__.c)
self.assertIn('current_trace_no', BizTunnel.__table__.c)
self.assertEqual(
{constraint.name for constraint in BizTunnel.__table__.constraints if constraint.name},
{
'uq_biz_tunnel_site_code',
'ck_biz_tunnel_status',
},
)
def test_tunnel_master_vo_supports_list_and_write_contract(self) -> None:
tunnel = TunnelModel(
siteId=9,
tunnelCode='T-01',
tunnelName='一号隧道',
)
self.assertEqual(tunnel.model_dump(by_alias=True)['tunnelCode'], 'T-01')
query = TunnelPageQueryModel(siteId=9, tunnelCode='T-01')
self.assertEqual(query.site_id, 9)
def test_usage_requires_tunnel_but_keeps_line_optional(self) -> None:
usage = UsageCreateModel(
qrToken='qr-1',
tunnelId=9,
usedPackages=20,
idempotencyKey='usage-key',
)
self.assertIsNone(usage.line_id)
with self.assertRaises(ValidationError):
UsageCreateModel(
qrToken='qr-1',
usedPackages=20,
idempotencyKey='usage-key',
)
def test_tunnel_entry_is_idempotent_command(self) -> None:
command = TunnelEntryCreateModel(
qrToken='qr-1',
tunnelId=9,
idempotencyKey='entry-key',
)
self.assertEqual(command.tunnel_id, 9)
def test_flow_records_keep_tunnel_and_trace_snapshots(self) -> None:
self.assertIn('tunnel_id', BizPalletInventory.__table__.c)
self.assertIn('trace_no', BizPalletInventory.__table__.c)
self.assertIn('tunnel_id', BizUsageRecord.__table__.c)
self.assertIn('trace_no', BizUsageRecord.__table__.c)
self.assertIn('batch_no_snapshot', BizUsageRecord.__table__.c)
self.assertIn('tunnel_id_snapshot', BizReturnItem.__table__.c)
self.assertIn('trace_no_snapshot', BizReturnItem.__table__.c)
self.assertIn('before_tunnel_id', BizPalletEvent.__table__.c)
self.assertIn('after_tunnel_id', BizPalletEvent.__table__.c)
self.assertIn('trace_no', BizPalletEvent.__table__.c)
self.assertEqual(PalletEventType.TUNNEL_ENTERED.value, 'TUNNEL_ENTERED')
def test_new_pallet_and_filling_are_fixed_to_one_hundred_packages(self) -> None:
PalletCreateModel(palletCode='P-001', capacityPackages=100)
FillingCreateModel(
qrToken='qr-1',
warehouseId=1,
strainId=1,
batchNo='B-1',
productionDate='2026-10-01',
expiryDate='2027-04-01',
packageSpec='标准包',
initialPackages=100,
idempotencyKey='fill-key',
)
with self.assertRaises(ValidationError):
PalletCreateModel(palletCode='P-002', capacityPackages=20)
with self.assertRaises(ValidationError):
FillingCreateModel(
qrToken='qr-1',
warehouseId=1,
strainId=1,
batchNo='B-1',
packageSpec='标准包',
initialPackages=20,
idempotencyKey='fill-key',
)
def test_usage_idempotency_hash_includes_tunnel(self) -> None:
command = UsageCreateModel(
qrToken=' qr-1 ',
tunnelId=9,
lineId=None,
usedPackages=20,
idempotencyKey='usage-key',
)
expected = hashlib.sha256(
json.dumps(
{
'qr_token': 'qr-1',
'tunnel_id': 9,
'line_id': None,
'used_packages': 20,
},
sort_keys=True,
separators=(',', ':'),
).encode()
).hexdigest()
self.assertEqual(expected, UsageService._build_request_hash(command))
@@ -0,0 +1,190 @@
"""隔离内存库验证真实 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)
@@ -32,7 +32,7 @@ from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import PalletEventType, PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletEvent,
@@ -43,6 +43,7 @@ from module_business.entity.vo.master_vo import LinePageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
PalletScanResponseModel,
TunnelEntrySuccessResponseModel,
UsageCreateModel,
UsageModel,
UsagePageQueryModel,
@@ -82,12 +83,14 @@ def integrity_error() -> IntegrityError:
def make_page(
*,
qr_token: str = 'qr-2',
tunnel_id: int = 9,
line_id: int = 4,
used_packages: int = 3,
idempotency_key: str = 'usage-key',
) -> UsageCreateModel:
return UsageCreateModel(
qrToken=qr_token,
tunnelId=tunnel_id,
lineId=line_id,
usedPackages=used_packages,
idempotencyKey=idempotency_key,
@@ -98,7 +101,7 @@ def make_pallet(
pallet_id: int = 2,
*,
qr_token: str = 'qr-2',
capacity_packages: int = 20,
capacity_packages: int = 100,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
@@ -116,6 +119,8 @@ def make_inventory(
pallet_id: int = 2,
*,
site_id: int | None = 9,
tunnel_id: int | None = 9,
trace_no: str | None = 'TRACE-9',
current_line_id: int | None = None,
active_filling_id: int | None = 31,
remaining_packages: int = 8,
@@ -128,6 +133,8 @@ def make_inventory(
active_filling_id=active_filling_id,
warehouse_id=None,
site_id=site_id,
tunnel_id=tunnel_id,
trace_no=trace_no,
current_line_id=current_line_id,
remaining_packages=remaining_packages,
flow_state=flow_state,
@@ -155,7 +162,7 @@ def make_filling(
strain_name_snapshot='香菇 11',
batch_no='B-20260726',
package_spec='20kg',
initial_packages=20,
initial_packages=100,
active_slot=active_slot,
closed_at=None,
idempotency_key='fill-key',
@@ -182,6 +189,25 @@ def make_site(
)
def make_tunnel(
tunnel_id: int = 9,
*,
site_id: int = 9,
status: str = '0',
current_trace_no: str | None = 'TRACE-9',
test_batch_id: int | None = None,
) -> BizTunnel:
return BizTunnel(
tunnel_id=tunnel_id,
site_id=site_id,
tunnel_code=f'T-{tunnel_id}',
tunnel_name=f'隧道 {tunnel_id}',
current_trace_no=current_trace_no,
status=status,
test_batch_id=test_batch_id,
)
def make_line(
line_id: int = 4,
*,
@@ -214,6 +240,9 @@ def make_usage(
pallet_id=2,
active_filling_id=31,
site_id=site_id,
tunnel_id=9,
trace_no='TRACE-9',
batch_no_snapshot='B-20260726',
line_id=line_id,
used_packages=3,
remaining_packages_after=5,
@@ -246,7 +275,9 @@ class TestUsagePublicContract(unittest.TestCase):
'get_usage_list_services',
'usage_detail_services',
'get_line_options_services',
'get_tunnel_options_services',
'scan_usage_pallet_services',
'enter_tunnel_services',
'record_usage_services',
):
with self.subTest(method=method_name):
@@ -257,7 +288,9 @@ class TestUsagePublicContract(unittest.TestCase):
'get_usage_list_services',
'usage_detail_services',
'get_line_options_services',
'get_tunnel_options_services',
'scan_usage_pallet_services',
'enter_tunnel_services',
'record_usage_services',
}
self.assertEqual(expected, {name for name in expected if hasattr(UsageService, name)})
@@ -276,7 +309,7 @@ class TestUsagePublicContract(unittest.TestCase):
page = make_page(qr_token=' qr-2 ', idempotency_key='key-a')
expected = hashlib.sha256(
json.dumps(
{'line_id': 4, 'qr_token': 'qr-2', 'used_packages': 3},
{'line_id': 4, 'qr_token': 'qr-2', 'tunnel_id': 9, 'used_packages': 3},
sort_keys=True,
separators=(',', ':'),
).encode()
@@ -511,6 +544,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=make_tunnel())),
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(OperationDao, 'get_filling_by_id', new=get_filling),
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
@@ -601,18 +635,20 @@ class TestUsageControllerContract(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
(route.path_format, next(iter(route.methods))): route
for route in usage_controller.routes
if isinstance(route, APIRoute)
}
def test_openapi_has_exact_five_operations_aliases_and_response_models(self) -> None:
def test_openapi_has_exact_seven_operations_aliases_and_response_models(self) -> None:
app = FastAPI()
app.include_router(usage_controller)
schema = app.openapi()
expected = {
('/business/usage/list', 'get'),
('/business/usage/line-options', 'get'),
('/business/usage/tunnel-options', 'get'),
('/business/usage/enter-tunnel', 'post'),
('/business/usage/scan/{qr_token}', 'get'),
('/business/usage/{usage_id}', 'get'),
('/business/usage', 'post'),
@@ -622,6 +658,13 @@ class TestUsageControllerContract(unittest.TestCase):
{'siteId'},
{parameter['name'] for parameter in schema['paths']['/business/usage/line-options']['get']['parameters']},
)
self.assertEqual(
{'siteId'},
{
parameter['name']
for parameter in schema['paths']['/business/usage/tunnel-options']['get']['parameters']
},
)
self.assertEqual(
{'qr_token'},
{
@@ -636,6 +679,14 @@ class TestUsageControllerContract(unittest.TestCase):
DataResponseModel[list[BusinessOptionModel]],
routes[('/business/usage/line-options', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[list[BusinessOptionModel]],
routes[('/business/usage/tunnel-options', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[TunnelEntrySuccessResponseModel],
routes[('/business/usage/enter-tunnel', 'POST')].response_model,
)
self.assertEqual(
DataResponseModel[PalletScanResponseModel],
routes[('/business/usage/scan/{qr_token}', 'GET')].response_model,
@@ -662,15 +713,19 @@ class TestUsageControllerContract(unittest.TestCase):
)
def test_static_routes_precede_dynamic_detail(self) -> None:
paths = [route.path for route in usage_controller.routes]
paths = [route.path_format for route in usage_controller.routes]
detail_index = paths.index('/business/usage/{usage_id}')
self.assertLess(paths.index('/business/usage/line-options'), detail_index)
self.assertLess(paths.index('/business/usage/tunnel-options'), detail_index)
self.assertLess(paths.index('/business/usage/enter-tunnel'), detail_index)
self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index)
def test_routes_have_exact_three_permissions_current_user_and_only_post_log(self) -> None:
expected = {
('/business/usage/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query',
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
('/business/usage', 'POST'): 'business:usage:add',
@@ -691,7 +746,12 @@ class TestUsageControllerContract(unittest.TestCase):
),
key,
)
self.assertEqual(key == ('/business/usage', 'POST'), hasattr(route.endpoint, '__wrapped__'))
expected_logged = {
('/business/usage/enter-tunnel', 'POST'),
('/business/usage/scan/{qr_token}', 'GET'),
('/business/usage', 'POST'),
}
self.assertEqual(key in expected_logged, hasattr(route.endpoint, '__wrapped__'))
actual[key] = permissions[0].perm
self.assertEqual(expected, actual)
@@ -763,6 +823,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
self.inventory = make_inventory()
self.filling = make_filling()
self.site = make_site()
self.tunnel = make_tunnel()
self.line = make_line()
self.fixed_time = datetime(2026, 7, 26, 11, 30)
@@ -808,6 +869,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
pallet_lock = AsyncMock(return_value=[selected_pallet])
filling_lock = AsyncMock(return_value=selected_filling)
site_lock = AsyncMock(return_value=selected_site)
tunnel_read = AsyncMock(return_value=self.tunnel)
preflight_line_read = AsyncMock(return_value=selected_line)
line_lock = AsyncMock(return_value=selected_line)
@@ -830,6 +892,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
stack.enter_context(patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock))
stack.enter_context(patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock))
stack.enter_context(patch.object(MasterDao, 'get_site', new=site_lock))
stack.enter_context(patch.object(MasterDao, 'get_tunnel', new=tunnel_read))
stack.enter_context(patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=read_line)))
stack.enter_context(patch.object(OperationDao, 'add_usage_record', new=usage_write))
stack.enter_context(patch.object(OperationDao, 'update_filling_record', new=filling_write))
@@ -845,6 +908,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
'pallet_lock': pallet_lock,
'filling_lock': filling_lock,
'site_lock': site_lock,
'tunnel_read': tunnel_read,
'preflight_line_read': preflight_line_read,
'line_lock': line_lock,
'add_usage': usage_write,
@@ -955,6 +1019,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(side_effect=lock_filling)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)),
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=self.tunnel)),
patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=lock_line)),
patch.object(OperationDao, 'add_usage_record', new=AsyncMock(side_effect=add_usage)),
patch.object(OperationDao, 'update_filling_record', new=update_filling),
@@ -1158,6 +1223,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
make_inventory(remaining_packages=8),
UsageCreateModel.model_construct(
qr_token='qr-2',
tunnel_id=9,
line_id=4,
used_packages=True,
idempotency_key='usage-key',
@@ -1268,6 +1334,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
async def test_record_hides_missing_and_non_site_qr_but_admin_gets_precise_error(self) -> None:
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=self.tunnel)),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
@@ -1279,6 +1346,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
self.db.reset_mock()
with (
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=self.tunnel)),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
@@ -1308,6 +1376,14 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
self.core.db = self.db
self.core.user = self.user
self.core.page = self.page
self.tunnel = self.core.tunnel
self._tunnel_patcher = patch.object(
MasterDao,
'get_tunnel',
new=AsyncMock(return_value=self.tunnel),
)
self._tunnel_patcher.start()
self.addCleanup(self._tunnel_patcher.stop)
def _exact_usage(self, *, request_hash: str | None = None) -> BizUsageRecord:
return make_usage(
@@ -184,13 +184,15 @@ OPERATION_MODEL_FIELDS = {
'warehouse_id',
'strain_id',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'initial_packages',
'idempotency_key',
},
ShipmentCreateModel: {'source_warehouse_id', 'target_site_id', 'pallet_ids'},
ShipmentUpdateModel: {'shipment_id', 'source_warehouse_id', 'target_site_id', 'pallet_ids'},
UsageCreateModel: {'qr_token', 'line_id', 'used_packages', 'idempotency_key'},
UsageCreateModel: {'qr_token', 'tunnel_id', 'line_id', 'used_packages', 'idempotency_key', 'fifo_skip_reason'},
ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'},
ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'},
InboundItemModel: {'inbound_item_id', 'inbound_id', 'pallet_id', 'create_by', 'create_time'},
@@ -219,6 +221,8 @@ OPERATION_MODEL_FIELDS = {
'strain_code_snapshot',
'strain_name_snapshot',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'initial_packages',
'active_slot',
@@ -253,6 +257,9 @@ OPERATION_MODEL_FIELDS = {
'confirm_request_hash',
'confirmed_by',
'confirmed_at',
'receipt_state',
'received_by',
'received_at',
'create_by',
'create_time',
'update_by',
@@ -262,10 +269,14 @@ OPERATION_MODEL_FIELDS = {
},
UsageModel: {
'usage_id',
'fifo_skip_reason',
'usage_no',
'pallet_id',
'active_filling_id',
'site_id',
'tunnel_id',
'trace_no',
'batch_no_snapshot',
'line_id',
'used_packages',
'remaining_packages_after',
@@ -279,6 +290,9 @@ OPERATION_MODEL_FIELDS = {
'return_id',
'pallet_id',
'active_filling_id_snapshot',
'tunnel_id_snapshot',
'trace_no_snapshot',
'batch_no_snapshot',
'remaining_packages_snapshot',
'create_by',
'create_time',
@@ -339,6 +353,8 @@ OPERATION_MODEL_FIELDS = {
'page_size',
'usage_no',
'site_id',
'tunnel_id',
'trace_no',
'line_id',
'pallet_code',
'begin_time',
@@ -361,15 +377,20 @@ OPERATION_MODEL_FIELDS = {
PalletScanResponseModel: {
'pallet_id',
'pallet_code',
'qr_token',
'flow_state',
'warehouse_id',
'site_id',
'tunnel_id',
'trace_no',
'current_line_id',
'active_filling_id',
'strain_id',
'strain_code',
'strain_name',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'capacity_packages',
'remaining_packages',
@@ -379,6 +400,10 @@ OPERATION_MODEL_FIELDS = {
REPORT_MODEL_FIELDS = {
PalletInventoryReportQueryModel: {
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'page_num',
'page_size',
'begin_time',
@@ -392,6 +417,12 @@ REPORT_MODEL_FIELDS = {
'batch_no',
},
PalletInventoryReportModel: {
'production_date',
'expiry_date',
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'pallet_id',
'pallet_code',
'flow_state',
@@ -463,6 +494,10 @@ REPORT_MODEL_FIELDS = {
'package_qty',
},
ConsumptionReportQueryModel: {
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'page_num',
'page_size',
'begin_time',
@@ -475,6 +510,13 @@ REPORT_MODEL_FIELDS = {
'batch_no',
},
ConsumptionReportModel: {
'production_date',
'expiry_date',
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'batch_no_snapshot',
'usage_id',
'usage_no',
'occurred_at',
@@ -496,6 +538,11 @@ REPORT_MODEL_FIELDS = {
'operator_name',
},
ReturnReportQueryModel: {
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'batch_no',
'page_num',
'page_size',
'begin_time',
@@ -507,6 +554,14 @@ REPORT_MODEL_FIELDS = {
'status',
},
ReturnReportModel: {
'production_date',
'expiry_date',
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'batch_no_snapshot',
'batch_no',
'return_id',
'return_no',
'status',
@@ -522,6 +577,11 @@ REPORT_MODEL_FIELDS = {
'remaining_packages_snapshot',
},
PalletTraceReportQueryModel: {
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'batch_no',
'page_num',
'page_size',
'begin_time',
@@ -530,6 +590,19 @@ REPORT_MODEL_FIELDS = {
'event_type',
},
PalletTraceReportModel: {
'production_date',
'expiry_date',
'tunnel_id',
'tunnel_code',
'tunnel_name',
'trace_no',
'batch_no_snapshot',
'before_tunnel_id',
'before_tunnel_code',
'before_tunnel_name',
'after_tunnel_id',
'after_tunnel_code',
'after_tunnel_name',
'event_id',
'event_seq',
'event_type',
@@ -702,7 +775,7 @@ PACKAGE_MODEL_PAYLOADS = {
'pallet_id': 1,
'pallet_code': 'PLT-001',
'qr_token': 'qr-12345678',
'capacity_packages': 20,
'capacity_packages': 100,
'asset_status': '0',
},
FillingModel: {
@@ -715,7 +788,7 @@ PACKAGE_MODEL_PAYLOADS = {
'strain_name_snapshot': '菌种一号',
'batch_no': 'B20260724',
'package_spec': '20kg/包',
'initial_packages': 20,
'initial_packages': 100,
'idempotency_key': 'fill-key-1',
'request_hash': 'fill-hash-1',
'operator_id': 1,
@@ -734,6 +807,9 @@ PACKAGE_MODEL_PAYLOADS = {
'pallet_id': 1,
'active_filling_id': 1,
'site_id': 1,
'tunnel_id': 9,
'trace_no': 'TRACE-9',
'batch_no_snapshot': 'B20260724',
'line_id': 1,
'used_packages': 1,
'remaining_packages_after': 19,
@@ -746,13 +822,16 @@ PACKAGE_MODEL_PAYLOADS = {
'return_item_id': 1,
'return_id': 1,
'pallet_id': 1,
'tunnel_id_snapshot': 9,
'trace_no_snapshot': 'TRACE-9',
'batch_no_snapshot': 'B20260724',
'remaining_packages_snapshot': 0,
},
PalletScanResponseModel: {
'pallet_id': 1,
'pallet_code': 'PLT-001',
'flow_state': 'AT_SITE_PARTIAL',
'capacity_packages': 20,
'capacity_packages': 100,
'remaining_packages': 10,
'allowed_actions': [],
},
@@ -760,7 +839,7 @@ PACKAGE_MODEL_PAYLOADS = {
'pallet_id': 1,
'pallet_code': 'PLT-001',
'flow_state': 'FILLED_IN_WAREHOUSE',
'initial_packages': 20,
'initial_packages': 100,
'remaining_packages': 20,
'updated_at': '2026-07-24T10:00:00',
},
@@ -776,7 +855,7 @@ PACKAGE_MODEL_PAYLOADS = {
'strain_name_snapshot': '菌种一号',
'batch_no': 'B20260724',
'package_spec': '20kg/包',
'initial_packages': 20,
'initial_packages': 100,
'operator_id': 1,
},
ShipmentReportModel: {
@@ -862,10 +941,10 @@ class BusinessVoContractTest(unittest.TestCase):
),
(LineModel, {'siteId': 1, 'lineCode': 'L-1', 'lineName': '产线'}),
(WarehouseModel, {'warehouseCode': 'W-1', 'warehouseName': '仓库'}),
(PalletCreateModel, {'palletCode': 'P-1', 'capacityPackages': 20}),
(PalletCreateModel, {'palletCode': 'P-1', 'capacityPackages': 100}),
(
PalletEditModel,
{'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'},
{'palletId': 1, 'capacityPackages': 100, 'assetStatus': '0'},
),
)
for model_class, payload in payloads:
@@ -979,20 +1058,23 @@ class BusinessVoContractTest(unittest.TestCase):
'warehouseId': 1,
'strainId': 2,
'batchNo': 'B20260724',
'productionDate': '2026-07-20',
'expiryDate': '2027-01-20',
'packageSpec': '20kg/包',
'initialPackages': 10,
'initialPackages': 100,
'idempotencyKey': 'fill-key-1',
}
)
self.assertEqual(10, model.initial_packages)
self.assertEqual(10, model.model_dump(by_alias=True)['initialPackages'])
self.assertEqual(100, model.initial_packages)
self.assertEqual(100, model.model_dump(by_alias=True)['initialPackages'])
def test_usage_rejects_zero_packages(self) -> None:
with self.assertRaises(ValidationError):
UsageCreateModel.model_validate(
{
'qrToken': 'qr-12345678',
'tunnelId': 9,
'lineId': 3,
'usedPackages': 0,
'idempotencyKey': 'usage-key-1',
@@ -1003,9 +1085,10 @@ class BusinessVoContractTest(unittest.TestCase):
for used_packages in (1.5, '1', True):
with self.subTest(used_packages=used_packages), self.assertRaises(ValidationError):
UsageCreateModel.model_validate(
{
'qrToken': 'qr-12345678',
'lineId': 3,
{
'qrToken': 'qr-12345678',
'tunnelId': 9,
'lineId': 3,
'usedPackages': used_packages,
'idempotencyKey': 'usage-key-1',
}
@@ -1133,7 +1216,7 @@ class BusinessVoContractTest(unittest.TestCase):
def test_pallet_edit_forbids_identity_fields(self) -> None:
valid_payload = {
'palletId': 1,
'capacityPackages': 20,
'capacityPackages': 100,
'assetStatus': '0',
'remark': None,
}
@@ -1169,7 +1252,7 @@ class BusinessVoContractTest(unittest.TestCase):
'strainId': 2,
'batchNo': ' ',
'packageSpec': '20kg/包',
'initialPackages': 10,
'initialPackages': 100,
'idempotencyKey': 'fill-key-1',
}
)
@@ -1190,7 +1273,7 @@ class BusinessVoContractTest(unittest.TestCase):
'palletId': 1,
'palletCode': 'PLT-001',
'flowState': 'AT_SITE_PARTIAL',
'capacityPackages': 20,
'capacityPackages': 100,
'remainingPackages': -1,
'allowedActions': [],
}