修复:强化装填事务一致性
This commit is contained in:
@@ -332,10 +332,12 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
new=AsyncMock(return_value=make_inventory(flow_state=PalletFlowState.FILLED_IN_WAREHOUSE.value)),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
self.assertRaises(PermissionException),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user)
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
self.assertEqual('', raised.exception.data)
|
||||
self.assertEqual('无权访问该托盘', raised.exception.message)
|
||||
|
||||
async def test_scan_hides_nonexistent_pallet_from_normal_user_but_admin_gets_precise_error(self) -> None:
|
||||
for user, exception_type in ((self.user, PermissionException), (make_user(1), ServiceException)):
|
||||
@@ -381,7 +383,11 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
actions.append('pallet-lock')
|
||||
return [self.pallet]
|
||||
|
||||
async def get_strain(*_args: Any) -> BizStrain:
|
||||
async def get_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
||||
actions.append('warehouse-read')
|
||||
return self.warehouse
|
||||
|
||||
async def get_strain(*_args: Any, **_kwargs: Any) -> BizStrain:
|
||||
actions.append('strain-read')
|
||||
return self.strain
|
||||
|
||||
@@ -408,6 +414,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
captured['filling'].__dict__.pop('filling_id', None)
|
||||
|
||||
self.db.commit.side_effect = commit
|
||||
warehouse_read = AsyncMock(side_effect=get_warehouse)
|
||||
strain_read = AsyncMock(side_effect=get_strain)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
@@ -415,8 +423,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(side_effect=get_strain)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
|
||||
patch.object(MasterDao, 'get_strain', new=strain_read),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=add_filling)),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
|
||||
patch('module_business.service.filling_service.datetime') as mocked_datetime,
|
||||
@@ -425,9 +433,19 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual(
|
||||
['inventory-lock', 'pallet-lock', 'strain-read', 'filling-flush', 'event-flush', 'commit'],
|
||||
[
|
||||
'inventory-lock',
|
||||
'pallet-lock',
|
||||
'warehouse-read',
|
||||
'strain-read',
|
||||
'filling-flush',
|
||||
'event-flush',
|
||||
'commit',
|
||||
],
|
||||
actions,
|
||||
)
|
||||
warehouse_read.assert_awaited_once_with(self.db, 7, for_update=True)
|
||||
strain_read.assert_awaited_once_with(self.db, 11, for_update=True)
|
||||
filling = captured['filling']
|
||||
self.assertTrue(filling.filling_no.startswith('FL-'))
|
||||
self.assertEqual(2, filling.pallet_id)
|
||||
@@ -514,6 +532,49 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_fill_rejects_packages_over_persisted_pallet_capacity(self) -> None:
|
||||
await self._assert_fill_rejected(page=make_page(initial_packages=21))
|
||||
|
||||
async def test_fill_rejects_stopped_warehouse_only_after_core_locks(self) -> None:
|
||||
actions: list[str] = []
|
||||
|
||||
async def lock_inventory(*_args: Any) -> BizPalletInventory:
|
||||
actions.append('inventory-lock')
|
||||
return self.inventory
|
||||
|
||||
async def lock_pallets(*_args: Any) -> list[BizPallet]:
|
||||
actions.append('pallet-lock')
|
||||
return [self.pallet]
|
||||
|
||||
async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
||||
actions.append('warehouse-read')
|
||||
return SimpleNamespace(warehouse_id=7, status='1')
|
||||
|
||||
warehouse_read = AsyncMock(side_effect=lock_warehouse)
|
||||
strain_read = AsyncMock()
|
||||
add_filling = AsyncMock()
|
||||
add_event = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
|
||||
patch.object(MasterDao, 'get_strain', new=strain_read),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual('仓库已停用', raised.exception.message)
|
||||
self.assertEqual(['inventory-lock', 'pallet-lock', 'warehouse-read'], actions)
|
||||
warehouse_read.assert_awaited_once_with(self.db, 7, for_update=True)
|
||||
strain_read.assert_not_awaited()
|
||||
add_filling.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_fill_rejects_stopped_pallet_and_stopped_or_missing_strain(self) -> None:
|
||||
await self._assert_fill_rejected(pallet=make_pallet(asset_status='1'))
|
||||
self.db.reset_mock()
|
||||
@@ -556,6 +617,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
existing = make_filling()
|
||||
inventory_lock = AsyncMock()
|
||||
access = AsyncMock()
|
||||
warehouse_read = AsyncMock()
|
||||
strain_read = AsyncMock()
|
||||
|
||||
async def rollback() -> None:
|
||||
existing.__dict__.pop('filling_id', None)
|
||||
@@ -568,12 +631,16 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=existing)
|
||||
) as get_key,
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
|
||||
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
|
||||
patch.object(MasterDao, 'get_strain', new=strain_read),
|
||||
):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
get_key.assert_awaited_once_with(self.db, 'fill-key', warehouse_ids=[7])
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
inventory_lock.assert_not_awaited()
|
||||
warehouse_read.assert_not_awaited()
|
||||
strain_read.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.assertTrue(result.is_success)
|
||||
@@ -615,6 +682,9 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
access = AsyncMock(side_effect=assert_access)
|
||||
strain_read = AsyncMock()
|
||||
add_filling = AsyncMock()
|
||||
add_event = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
@@ -623,12 +693,20 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
self.assertRaises(PermissionException),
|
||||
patch.object(MasterDao, 'get_strain', new=strain_read),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual([7, foreign_warehouse_id], [call.args[2] for call in access.await_args_list])
|
||||
self.assertEqual('', raised.exception.data)
|
||||
self.assertEqual('无权访问该托盘', raised.exception.message)
|
||||
pallet_lock.assert_not_awaited()
|
||||
strain_read.assert_not_awaited()
|
||||
add_filling.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_concurrent_same_key_recovers_after_waiting_for_inventory_lock(self) -> None:
|
||||
@@ -655,6 +733,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
add_filling = AsyncMock()
|
||||
add_event = AsyncMock()
|
||||
warehouse_read = AsyncMock(return_value=self.warehouse)
|
||||
strain_read = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
@@ -667,7 +747,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallet)),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(side_effect=lock_active)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
|
||||
patch.object(MasterDao, 'get_strain', new=strain_read),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
):
|
||||
@@ -677,6 +758,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(2, get_key.await_count)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.assertEqual(31, result.result)
|
||||
warehouse_read.assert_not_awaited()
|
||||
strain_read.assert_not_awaited()
|
||||
add_filling.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
@@ -762,7 +845,30 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
access.assert_any_await(self.db, self.user, 7)
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_integrity_error_without_scoped_idempotent_result_is_hidden_and_never_succeeds(self) -> None:
|
||||
async def test_integrity_error_recovery_rejects_revoked_warehouse_access(self) -> None:
|
||||
warehouse_scope = AsyncMock(side_effect=[[7], []])
|
||||
key_reads = AsyncMock(return_value=None)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=key_reads),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=self.inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[self.pallet])),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=self.strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual('无权访问该仓库', raised.exception.message)
|
||||
self.assertEqual(2, warehouse_scope.await_count)
|
||||
self.assertEqual(1, key_reads.await_count)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_integrity_error_without_idempotent_result_is_reported_as_data_conflict(self) -> None:
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
@@ -777,9 +883,10 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=self.strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())),
|
||||
self.assertRaises(PermissionException),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
self.assertEqual('装填失败,数据冲突', raised.exception.message)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user