更新登录页及优化相关功能
This commit is contained in:
@@ -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': [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user