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

This commit is contained in:
焦龙言
2026-10-08 13:17:59 +08:00
parent ecb254cb54
commit bcdb0524c2
139 changed files with 76688 additions and 811 deletions
@@ -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(