Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_fifo_skip_policy.py
T

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)