"""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)