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

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -0,0 +1,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)