156 lines
9.4 KiB
Python
156 lines
9.4 KiB
Python
"""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)
|