import unittest from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch from sqlalchemy.ext.asyncio import AsyncSession from exceptions.exception import PermissionException, ServiceException from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomerSite from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel from module_business.service.shipment_receipt_service import ShipmentReceiptService from tests.module_business.test_shipment_service import make_inventory, make_pallet, make_shipment, make_user class TestShipmentReceipt(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() self.scope = TestBatchScope.formal() self.order = make_shipment(status='CONFIRMED', pallet_ids=(2, 5)) self.order.receipt_state = 'PENDING' self.order.confirmed_at = datetime(2026, 10, 8, 8) self.inventories = [ make_inventory(i, warehouse_id=None, site_id=9, flow_state='IN_TRANSIT', remaining_packages=100) for i in (2, 5) ] for item in self.order.items: item.active_filling_id = 100 + item.pallet_id item.package_qty = 100 self.events = [] self.site_access = AsyncMock(return_value=True) self.patches = [ patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(AccessDao, 'user_has_site', new=self.site_access), patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=self.order)), patch.object( MasterDao, 'get_site', new=AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)), ), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=self.inventories)), patch.object( InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(i, capacity=100) for i in (2, 5)]), ), patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=self.capture_event)), ] for patcher in self.patches: patcher.start() self.addCleanup(patcher.stop) async def capture_event(self, _db, inventory, event, _scope): self.events.append(event) return event def command(self, ids=None, key='receipt-key'): return ShipmentReceiptCommandModel(scannedPalletIds=ids if ids is not None else [5, 2], idempotencyKey=key) async def receive(self, command=None): return await ShipmentReceiptService.confirm_services( self.db, 12, command or self.command(), self.user, self.scope ) async def test_receipt_keeps_original_pallet_filling_and_quantity(self) -> None: await self.receive() self.assertEqual(self.order.receipt_state, 'RECEIVED') self.assertEqual(self.order.received_by, 7) self.assertIsInstance(self.order.received_at, datetime) self.assertEqual( [(i.pallet_id, i.active_filling_id, i.remaining_packages, i.flow_state) for i in self.inventories], [ (2, 102, 100, 'AT_SITE_UNUSED'), (5, 105, 100, 'AT_SITE_UNUSED'), ], ) self.assertTrue(all(i.site_arrived_at == self.order.received_at for i in self.inventories)) self.assertEqual( [(e.event_type, e.before_state, e.after_state) for e in self.events], [ ('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'), ('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'), ], ) self.db.commit.assert_awaited_once() async def test_retry_after_later_consumption_does_not_restore_quantity(self) -> None: await self.receive() self.inventories[0].remaining_packages = 80 self.inventories[0].flow_state = 'AT_SITE_PARTIAL' await self.receive(self.command(ids=[2, 5])) self.assertEqual(self.inventories[0].remaining_packages, 80) self.assertEqual(len(self.events), 2) self.db.commit.assert_awaited_once() async def test_received_order_rejects_different_confirmation_key(self) -> None: await self.receive() with self.assertRaises(ServiceException): await self.receive(self.command(key='different')) self.assertEqual(len(self.events), 2) async def test_missing_or_unexpected_scans_do_not_change_inventory(self) -> None: for ids in ([2], [2, 5, 6], [2, 6]): with self.subTest(ids=ids), self.assertRaises(ServiceException): await self.receive(self.command(ids=ids)) self.assertEqual(self.order.receipt_state, 'PENDING') self.assertFalse(self.events) self.db.commit.assert_not_awaited() async def test_target_site_access_is_required_even_with_warehouse_permissions(self) -> None: self.site_access.return_value = False with self.assertRaises(PermissionException): await self.receive() self.assertFalse(self.events) self.db.commit.assert_not_awaited() async def test_draft_and_legacy_cannot_be_received_as_new_shipments(self) -> None: for status, receipt_state in [('DRAFT', 'PENDING'), ('CONFIRMED', 'LEGACY')]: self.order.status = status self.order.receipt_state = receipt_state with self.subTest(status=status, receipt_state=receipt_state), self.assertRaises(ServiceException): await self.receive() self.assertFalse(self.events) async def test_changed_material_snapshot_is_rejected_before_any_mutation(self) -> None: self.inventories[1].active_filling_id = 999 with self.assertRaises(ServiceException): await self.receive() self.assertEqual(self.inventories[0].flow_state, 'IN_TRANSIT') self.assertFalse(self.events) async def test_cross_test_batch_inventory_is_rejected(self) -> None: self.inventories[1].test_batch_id = 3 with self.assertRaises(ServiceException): await self.receive() self.assertFalse(self.events)