修复:收紧托盘业务权限与数据契约
This commit is contained in:
@@ -178,7 +178,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
actions: list[str] = []
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def access(*_args: Any) -> None:
|
||||
async def access(*_args: Any, **_kwargs: Any) -> None:
|
||||
actions.append('access')
|
||||
|
||||
async def lock_inventories(*_args: Any) -> list[BizPalletInventory]:
|
||||
@@ -231,7 +231,15 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
result = await InboundService.add_inbound_services(self.db, page, self.user)
|
||||
|
||||
self.assertEqual(
|
||||
['access', 'inventory-lock', 'pallet-lock', 'add-order', 'replace-items', 'commit'],
|
||||
[
|
||||
'access',
|
||||
'inventory-lock',
|
||||
'pallet-lock',
|
||||
'access',
|
||||
'add-order',
|
||||
'replace-items',
|
||||
'commit',
|
||||
],
|
||||
actions,
|
||||
)
|
||||
inventory_lock.assert_awaited_once_with(self.db, [2, 5])
|
||||
@@ -352,6 +360,43 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
pallet_lock.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_add_rechecks_revoked_mapping_after_pallet_locks_before_writing(self) -> None:
|
||||
page = InboundCreateModel(warehouseId=7, palletIds=[2])
|
||||
|
||||
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
||||
if kwargs.get('for_update'):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
add_order = AsyncMock(return_value=make_inbound(pallet_ids=(2,)))
|
||||
replace_items = AsyncMock()
|
||||
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
||||
warehouse_read = AsyncMock(
|
||||
return_value=SimpleNamespace(warehouse_id=7, status='0'),
|
||||
)
|
||||
with (
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
||||
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
|
||||
patch.object(
|
||||
InboundService,
|
||||
'_lock_and_validate_pallets',
|
||||
new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])),
|
||||
),
|
||||
patch.object(OperationDao, 'add_inbound', new=add_order),
|
||||
patch.object(OperationDao, 'replace_inbound_items', new=replace_items),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await InboundService.add_inbound_services(self.db, page, self.user)
|
||||
|
||||
self.assertEqual('无权访问该仓库', raised.exception.message)
|
||||
self.assertEqual(2, warehouse_read.await_count)
|
||||
warehouse_read.assert_awaited_with(self.db, 7, for_update=True)
|
||||
self.assertEqual(2, warehouse_mapping.await_count)
|
||||
warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True)
|
||||
add_order.assert_not_awaited()
|
||||
replace_items.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_edit_scopes_original_and_checks_target_access_then_replaces_items(self) -> None:
|
||||
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2])
|
||||
inbound = make_inbound(warehouse_id=7, pallet_ids=(3,))
|
||||
@@ -390,7 +435,14 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
11,
|
||||
warehouse_ids=[2, 5, 7, 8, 11],
|
||||
)
|
||||
access.assert_awaited_once_with(self.db, self.user, 8)
|
||||
self.assertEqual(
|
||||
[
|
||||
unittest.mock.call(self.db, self.user, 8),
|
||||
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
||||
unittest.mock.call(self.db, self.user, 8, for_update=True),
|
||||
],
|
||||
access.await_args_list,
|
||||
)
|
||||
inventory_lock.assert_awaited_once_with(self.db, [2, 5])
|
||||
pallet_lock.assert_awaited_once_with(self.db, [2, 5])
|
||||
self.assertEqual(8, inbound.warehouse_id)
|
||||
@@ -402,6 +454,55 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.commit.assert_awaited_once()
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_edit_hides_document_when_current_mapping_is_revoked_after_pallet_locks(self) -> None:
|
||||
current_warehouse_id = 7
|
||||
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2])
|
||||
inbound = make_inbound(warehouse_id=current_warehouse_id, pallet_ids=(2,))
|
||||
|
||||
async def warehouse_access(
|
||||
_db: AsyncSession,
|
||||
_user: CurrentUserModel,
|
||||
warehouse_id: int,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if warehouse_id == current_warehouse_id and kwargs.get('for_update'):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
update = AsyncMock()
|
||||
replace = AsyncMock()
|
||||
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(
|
||||
side_effect=lambda _db, warehouse_id, **_kwargs: SimpleNamespace(
|
||||
warehouse_id=warehouse_id,
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
InboundService,
|
||||
'_lock_and_validate_pallets',
|
||||
new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])),
|
||||
),
|
||||
patch.object(OperationDao, 'update_inbound', new=update),
|
||||
patch.object(OperationDao, 'replace_inbound_items', new=replace),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await InboundService.edit_inbound_services(self.db, page, self.user)
|
||||
|
||||
self.assertEqual('无权访问该入库单', raised.exception.message)
|
||||
warehouse_mapping.assert_any_await(self.db, self.user, 7, for_update=True)
|
||||
warehouse_mapping.assert_any_await(self.db, self.user, 8, for_update=True)
|
||||
update.assert_not_awaited()
|
||||
replace.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_edit_rejects_confirmed_document_without_pallet_or_order_write(self) -> None:
|
||||
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2])
|
||||
update = AsyncMock()
|
||||
@@ -484,6 +585,16 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
access = AsyncMock()
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock)),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(
|
||||
side_effect=lambda _db, warehouse_id, **_kwargs: SimpleNamespace(
|
||||
warehouse_id=warehouse_id,
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)),
|
||||
):
|
||||
@@ -494,10 +605,58 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
actions,
|
||||
)
|
||||
self.default_scope.assert_awaited_once_with(self.db, 7)
|
||||
access.assert_not_awaited()
|
||||
self.assertEqual([2, 5], [call.args[2] for call in access.await_args_list])
|
||||
self.assertTrue(all(call.kwargs.get('for_update') for call in access.await_args_list))
|
||||
self.assertTrue(result.is_success)
|
||||
self.db.rollback.assert_not_awaited()
|
||||
|
||||
async def test_batch_delete_rechecks_all_current_warehouses_before_first_delete(self) -> None:
|
||||
revoked_warehouse_id = 8
|
||||
documents = {
|
||||
11: make_inbound(inbound_id=11, warehouse_id=revoked_warehouse_id),
|
||||
12: make_inbound(inbound_id=12, warehouse_id=7),
|
||||
}
|
||||
|
||||
async def warehouse_access(
|
||||
_db: AsyncSession,
|
||||
_user: CurrentUserModel,
|
||||
warehouse_id: int,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if warehouse_id == revoked_warehouse_id and kwargs.get('for_update'):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
delete = AsyncMock()
|
||||
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
||||
with (
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_inbound_for_update',
|
||||
new=AsyncMock(side_effect=lambda _db, inbound_id, **_kwargs: documents[inbound_id]),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(
|
||||
side_effect=lambda _db, warehouse_id, **_kwargs: SimpleNamespace(
|
||||
warehouse_id=warehouse_id,
|
||||
status='0',
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
||||
patch.object(OperationDao, 'delete_inbound', new=delete),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await InboundService.delete_inbound_services(self.db, [12, 11], self.user)
|
||||
|
||||
self.assertEqual('无权访问该入库单', raised.exception.message)
|
||||
self.assertEqual([7, 8], [call.args[2] for call in warehouse_mapping.await_args_list])
|
||||
self.assertTrue(all(call.kwargs.get('for_update') for call in warehouse_mapping.await_args_list))
|
||||
delete.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_batch_delete_is_atomic_when_later_document_is_confirmed(self) -> None:
|
||||
delete = AsyncMock()
|
||||
records = {
|
||||
@@ -583,6 +742,11 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
||||
'get_inbound_for_update',
|
||||
new=AsyncMock(return_value=make_inbound(pallet_ids=(2,))),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())),
|
||||
self.assertRaises(ServiceException),
|
||||
@@ -924,10 +1088,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
actions.append('detail')
|
||||
return inbound
|
||||
|
||||
async def access(*_args: Any) -> None:
|
||||
async def access(*_args: Any, **_kwargs: Any) -> None:
|
||||
actions.append('access')
|
||||
|
||||
async def by_key(*_args: Any) -> None:
|
||||
async def by_key(*_args: Any, **_kwargs: Any) -> None:
|
||||
actions.append('idempotency-read')
|
||||
|
||||
async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
|
||||
@@ -942,6 +1106,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
actions.append('pallet-lock')
|
||||
return pallets
|
||||
|
||||
async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
||||
actions.append('warehouse-lock')
|
||||
return SimpleNamespace(warehouse_id=7, status='0')
|
||||
|
||||
def transition(current: PalletFlowState, target: PalletFlowState) -> None:
|
||||
actions.append(f'transition-{current.value}-{target.value}')
|
||||
|
||||
@@ -985,11 +1153,12 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(OperationDao, 'update_inbound', new=AsyncMock(side_effect=update)),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
|
||||
patch.object(inbound_service_module, 'datetime'),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=lock_warehouse)),
|
||||
)
|
||||
with (
|
||||
patches[0] as detail_read,
|
||||
patches[1] as access_probe,
|
||||
patches[2],
|
||||
patches[2] as key_read,
|
||||
patches[3] as order_lock,
|
||||
patches[4] as inventory_lock,
|
||||
patches[5] as pallet_lock,
|
||||
@@ -997,6 +1166,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
patches[7],
|
||||
patches[8],
|
||||
patches[9] as service_datetime,
|
||||
patches[10] as warehouse_lock,
|
||||
):
|
||||
service_datetime.now.return_value = self.fixed_time
|
||||
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
|
||||
@@ -1008,10 +1178,12 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
'events': events,
|
||||
'actions': actions,
|
||||
'detail_read': detail_read,
|
||||
'key_read': key_read,
|
||||
'access_probe': access_probe,
|
||||
'order_lock': order_lock,
|
||||
'inventory_lock': inventory_lock,
|
||||
'pallet_lock': pallet_lock,
|
||||
'warehouse_lock': warehouse_lock,
|
||||
'ensure_transition': ensure_transition,
|
||||
}
|
||||
|
||||
@@ -1025,6 +1197,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
'order-lock',
|
||||
'inventory-lock',
|
||||
'pallet-lock',
|
||||
'warehouse-lock',
|
||||
'access',
|
||||
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
|
||||
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
|
||||
'update-order',
|
||||
@@ -1036,8 +1210,14 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.default_scope.assert_awaited_once_with(self.db, 7)
|
||||
captured['detail_read'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
|
||||
captured['key_read'].assert_awaited_once_with(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
warehouse_ids=[7],
|
||||
)
|
||||
captured['order_lock'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
|
||||
captured['access_probe'].assert_not_awaited()
|
||||
captured['warehouse_lock'].assert_awaited_once_with(self.db, 7, for_update=True)
|
||||
captured['access_probe'].assert_awaited_once_with(self.db, self.user, 7, for_update=True)
|
||||
captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5])
|
||||
captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5])
|
||||
captured['ensure_transition'].assert_has_calls(
|
||||
@@ -1085,6 +1265,52 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.rollback.assert_not_awaited()
|
||||
self.assertTrue(captured['result'].is_success)
|
||||
|
||||
async def test_confirm_hides_document_when_mapping_is_revoked_after_pallet_locks(self) -> None:
|
||||
inbound = make_inbound(pallet_ids=(2,))
|
||||
inventory = make_inventory(2)
|
||||
|
||||
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
||||
if kwargs.get('for_update'):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
update = AsyncMock()
|
||||
add_event = AsyncMock()
|
||||
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_inbound_by_confirm_idempotency_key',
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(
|
||||
InboundService,
|
||||
'_lock_and_validate_pallets',
|
||||
new=AsyncMock(return_value=([inventory], [make_pallet(2)])),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
) as warehouse_lock,
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
||||
patch.object(OperationDao, 'update_inbound', new=update),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
|
||||
|
||||
self.assertEqual('无权访问该入库单', raised.exception.message)
|
||||
warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True)
|
||||
warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
|
||||
self.assertEqual('DRAFT', inbound.status)
|
||||
self.assertEqual('UNSTOCKED', inventory.flow_state)
|
||||
update.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_confirm_rejects_incomplete_locked_sets_before_any_mutation(self) -> None:
|
||||
inbound = make_inbound()
|
||||
inventories = [make_inventory(2)]
|
||||
@@ -1124,6 +1350,11 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(PalletStateMachine, 'ensure_transition', side_effect=ValueError('invalid')),
|
||||
patch.object(OperationDao, 'update_inbound', new=update),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
@@ -1154,16 +1385,67 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)) as lock,
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
) as warehouse_lock,
|
||||
):
|
||||
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
|
||||
|
||||
lock.assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True)
|
||||
self.assertEqual(
|
||||
[
|
||||
unittest.mock.call(self.db, self.user, 7),
|
||||
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
||||
],
|
||||
access.await_args_list,
|
||||
)
|
||||
inventory_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_same_key_retry_hides_document_when_current_mapping_is_revoked(self) -> None:
|
||||
confirmed = make_inbound(
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=self.request_hash,
|
||||
)
|
||||
|
||||
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
||||
if kwargs.get('for_update'):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
inventory_lock = AsyncMock()
|
||||
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=confirmed)),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_inbound_by_confirm_idempotency_key',
|
||||
new=AsyncMock(return_value=confirmed),
|
||||
),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1')),
|
||||
) as warehouse_lock,
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
|
||||
|
||||
self.assertEqual('无权访问该入库单', raised.exception.message)
|
||||
warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True)
|
||||
warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True)
|
||||
inventory_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_same_key_for_different_inbound_is_rejected_without_lock_or_write(self) -> None:
|
||||
target = make_inbound()
|
||||
existing = make_inbound(
|
||||
@@ -1225,6 +1507,11 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=locked)),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(5)])),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
||||
):
|
||||
@@ -1246,23 +1533,96 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
key_reads = AsyncMock(side_effect=[None, recovered])
|
||||
self.db.commit.side_effect = integrity_error()
|
||||
parent_lock = AsyncMock(side_effect=[inbound, recovered])
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access,
|
||||
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_reads),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
||||
):
|
||||
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
|
||||
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
self.assertEqual(2, parent_lock.await_count)
|
||||
self.assertEqual(
|
||||
[
|
||||
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
||||
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
||||
],
|
||||
access.await_args_list,
|
||||
)
|
||||
self.assertEqual(2, key_reads.await_count)
|
||||
key_reads.assert_has_awaits(
|
||||
[
|
||||
unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]),
|
||||
unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]),
|
||||
]
|
||||
)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_integrity_recovery_locks_parent_and_rechecks_revoked_current_mapping(self) -> None:
|
||||
inbound = make_inbound(pallet_ids=(2,))
|
||||
recovered = make_inbound(
|
||||
pallet_ids=(2,),
|
||||
status='CONFIRMED',
|
||||
idempotency_key='confirm-key',
|
||||
request_hash=self.request_hash,
|
||||
)
|
||||
locked_access_count = 0
|
||||
recovery_access_number = 2
|
||||
|
||||
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
||||
nonlocal locked_access_count
|
||||
if not kwargs.get('for_update'):
|
||||
return
|
||||
locked_access_count += 1
|
||||
if locked_access_count == recovery_access_number:
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
self.db.commit.side_effect = integrity_error()
|
||||
parent_lock = AsyncMock(side_effect=[inbound, recovered])
|
||||
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_inbound_by_confirm_idempotency_key',
|
||||
new=AsyncMock(side_effect=[None, recovered]),
|
||||
),
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
|
||||
patch.object(
|
||||
InboundService,
|
||||
'_lock_and_validate_pallets',
|
||||
new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])),
|
||||
),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
) as warehouse_lock,
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
||||
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
|
||||
|
||||
self.assertEqual('无权访问该入库单', raised.exception.message)
|
||||
self.assertEqual(2, parent_lock.await_count)
|
||||
parent_lock.assert_awaited_with(self.db, 11, warehouse_ids=[7])
|
||||
self.assertEqual(2, warehouse_lock.await_count)
|
||||
self.assertEqual(2, locked_access_count)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
|
||||
async def test_integrity_conflict_with_mismatched_recovery_is_stable_failure(self) -> None:
|
||||
inbound = make_inbound(pallet_ids=(2,))
|
||||
inventory = make_inventory(2)
|
||||
@@ -1284,6 +1644,11 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_warehouse',
|
||||
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
||||
),
|
||||
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
||||
self.assertRaises(ServiceException),
|
||||
|
||||
@@ -125,7 +125,11 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'),
|
||||
'get_return_for_update': ('db', 'return_id', 'site_ids', 'warehouse_ids'),
|
||||
'get_active_filling_for_update': ('db', 'filling_id'),
|
||||
'get_inbound_by_confirm_idempotency_key': ('db', 'idempotency_key'),
|
||||
'get_inbound_by_confirm_idempotency_key': (
|
||||
'db',
|
||||
'idempotency_key',
|
||||
'warehouse_ids',
|
||||
),
|
||||
'get_shipment_by_confirm_idempotency_key': (
|
||||
'db',
|
||||
'idempotency_key',
|
||||
@@ -641,6 +645,30 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn(f"{model.__tablename__}.{key_column} = 'same-key'", sql)
|
||||
self.assertNotIn('FOR UPDATE', sql)
|
||||
|
||||
async def test_inbound_confirm_key_pushes_warehouse_scope_into_sql(self) -> None:
|
||||
self.assertIn(
|
||||
'warehouse_ids',
|
||||
inspect.signature(OperationDao.get_inbound_by_confirm_idempotency_key).parameters,
|
||||
)
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = None
|
||||
self.db.execute.return_value = result
|
||||
|
||||
actual = await OperationDao.get_inbound_by_confirm_idempotency_key(
|
||||
self.db,
|
||||
'confirm-key',
|
||||
warehouse_ids=[7, 3, 7],
|
||||
)
|
||||
|
||||
self.assertIsNone(actual)
|
||||
statement = self.db.execute.await_args.args[0]
|
||||
for dialect in (mysql.dialect(), postgresql.dialect()):
|
||||
with self.subTest(dialect=dialect.name):
|
||||
sql = compile_sql(statement, dialect)
|
||||
self.assertIn("biz_inbound_order.confirm_idempotency_key = 'confirm-key'", sql)
|
||||
self.assertIn('biz_inbound_order.warehouse_id IN (3, 7)', sql)
|
||||
self.assertNotIn('FOR UPDATE', sql)
|
||||
|
||||
async def test_list_queries_apply_every_declared_filter_sort_and_pagination(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
|
||||
@@ -607,6 +607,33 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
|
||||
sql,
|
||||
)
|
||||
|
||||
async def test_trace_shipment_events_require_both_route_scopes(self) -> None:
|
||||
statement = await self._capture_statement(
|
||||
ReportDao.get_pallet_trace_list,
|
||||
PalletTraceReportQueryModel(),
|
||||
warehouse_ids=[8, 7],
|
||||
site_ids=[10, 9],
|
||||
)
|
||||
|
||||
for sql in self._assert_compiles_for_supported_databases(statement):
|
||||
self.assertIn(
|
||||
"biz_pallet_event.event_type = 'shipment_confirmed' "
|
||||
'and biz_pallet_event.before_warehouse_id in (7, 8) '
|
||||
'and biz_pallet_event.after_site_id in (9, 10)',
|
||||
sql,
|
||||
)
|
||||
self.assertIn(
|
||||
"biz_pallet_event.event_type != 'shipment_confirmed' and (",
|
||||
sql,
|
||||
)
|
||||
self.assertIn(
|
||||
'biz_pallet_event.before_warehouse_id in (7, 8) '
|
||||
'or biz_pallet_event.after_warehouse_id in (7, 8) '
|
||||
'or biz_pallet_event.before_site_id in (9, 10) '
|
||||
'or biz_pallet_event.after_site_id in (9, 10)',
|
||||
sql,
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(ReportService is not None, 'ReportService 尚未实现')
|
||||
class TestReportServices(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
@@ -1540,8 +1540,8 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, inventories[0].flow_state)
|
||||
self.assertEqual(102, inventories[0].active_filling_id)
|
||||
self.assertEqual('ACTIVE', filling.active_slot)
|
||||
self.assertEqual(7, filling.warehouse_id)
|
||||
update_filling.assert_awaited_once_with(self.db, filling)
|
||||
self.assertEqual(3, filling.warehouse_id)
|
||||
update_filling.assert_not_awaited()
|
||||
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE.value, inventories[1].flow_state)
|
||||
self.assertIsNone(inventories[1].active_filling_id)
|
||||
for inventory in inventories:
|
||||
|
||||
@@ -552,10 +552,11 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9)
|
||||
pallet = make_pallet(2)
|
||||
inventory = make_inventory(2, remaining_packages=8)
|
||||
filling = make_filling(2)
|
||||
filling = make_filling(2, warehouse_id=3)
|
||||
lock_inventory = AsyncMock()
|
||||
lock_pallets = AsyncMock()
|
||||
lock_filling = AsyncMock()
|
||||
get_filling = AsyncMock(return_value=filling)
|
||||
with (
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
@@ -586,7 +587,7 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
|
||||
patch.object(OperationDao, 'get_filling_by_id', new=AsyncMock(return_value=filling)),
|
||||
patch.object(OperationDao, 'get_filling_by_id', new=get_filling),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=lock_inventory),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=lock_pallets),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=lock_filling),
|
||||
@@ -605,6 +606,7 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE, result.flow_state)
|
||||
self.assertEqual(8, result.remaining_packages)
|
||||
self.assertEqual(['shipment'], result.allowed_actions)
|
||||
get_filling.assert_awaited_once_with(self.db, inventory.active_filling_id)
|
||||
lock_inventory.assert_not_awaited()
|
||||
lock_pallets.assert_not_awaited()
|
||||
lock_filling.assert_not_awaited()
|
||||
@@ -723,6 +725,34 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.user = make_user()
|
||||
self.fixed_time = datetime(2026, 7, 26, 16, 30)
|
||||
|
||||
async def test_residual_return_can_be_re_shipped_from_current_inventory_warehouse(self) -> None:
|
||||
inventory = make_inventory(2, warehouse_id=7, remaining_packages=6)
|
||||
pallet = make_pallet(2)
|
||||
origin_filling = make_filling(2, warehouse_id=3)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
InventoryDao,
|
||||
'get_inventories_for_update',
|
||||
new=AsyncMock(return_value=[inventory]),
|
||||
),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[pallet])),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_active_filling_for_update',
|
||||
new=AsyncMock(return_value=origin_filling),
|
||||
),
|
||||
):
|
||||
inventories, pallets, fillings = await ShipmentService._lock_and_validate_pallets(
|
||||
self.db,
|
||||
[2],
|
||||
7,
|
||||
)
|
||||
|
||||
self.assertEqual([inventory], inventories)
|
||||
self.assertEqual([pallet], pallets)
|
||||
self.assertEqual([origin_filling], fillings)
|
||||
|
||||
async def test_add_only_persists_pallet_ids_with_null_snapshots(self) -> None: # noqa: PLR0915
|
||||
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[5, 2])
|
||||
inventories = [make_inventory(2), make_inventory(5)]
|
||||
|
||||
@@ -1258,6 +1258,79 @@ class SqlContractTest(unittest.TestCase):
|
||||
self.assertLess(dict_type_cleanup.start(), external_link_cleanup.start())
|
||||
self.assertNotRegex(sql, r'\bdelete\s+from\s+(?:[a-z0-9_]+\.)?(?:biz|ai)_[a-z0-9_]+\b')
|
||||
|
||||
def test_patch_cleanup_fails_closed_before_deleting_legacy_seed_ids(self) -> None:
|
||||
sql = _strip_sql_comments(PATCH_PATH.read_text(encoding='utf-8')).replace('`', '').lower()
|
||||
guard_create = re.search(
|
||||
r'create\s+temporary\s+table\s+pallet_workflow_legacy_seed_guard\b',
|
||||
sql,
|
||||
)
|
||||
guard_insert = re.search(
|
||||
r'insert\s+into\s+pallet_workflow_legacy_seed_guard\b[\s\S]+?;',
|
||||
sql,
|
||||
)
|
||||
guard_drops = list(
|
||||
re.finditer(
|
||||
r'drop\s+temporary\s+table(?:\s+if\s+exists)?\s+pallet_workflow_legacy_seed_guard\b',
|
||||
sql,
|
||||
)
|
||||
)
|
||||
guard_drop = guard_drops[-1] if guard_drops else None
|
||||
role_cleanup = re.search(r'delete\s+from\s+sys_role_menu\b', sql)
|
||||
for statement in (guard_create, guard_insert, guard_drop, role_cleanup):
|
||||
self.assertIsNotNone(statement)
|
||||
self.assertLess(guard_create.start(), guard_insert.start())
|
||||
self.assertLess(guard_insert.start(), guard_drop.start())
|
||||
self.assertLess(guard_drop.start(), role_cleanup.start())
|
||||
|
||||
guard_sql = guard_insert.group()
|
||||
expected_menu_identities = (
|
||||
(4, 'ai 管理', 0, 'ai', None, '', 'm'),
|
||||
(99, '施尔丰仓库', 0, 'https://git.yujiangrubber.cn/niuniu/shierfeng', None, '', 'm'),
|
||||
(118, '模型管理', 4, 'model', 'ai/model/index', 'ai:model:list', 'c'),
|
||||
(119, 'ai 对话', 4, 'chat', 'ai/chat/index', 'ai:chat:list', 'c'),
|
||||
(1061, '模型查询', 118, '#', '', 'ai:model:query', 'f'),
|
||||
(1062, '模型新增', 118, '#', '', 'ai:model:add', 'f'),
|
||||
(1063, '模型修改', 118, '#', '', 'ai:model:edit', 'f'),
|
||||
(1064, '模型删除', 118, '#', '', 'ai:model:remove', 'f'),
|
||||
)
|
||||
for menu_id, name, parent_id, path, component, permission, menu_type in expected_menu_identities:
|
||||
with self.subTest(menu_id=menu_id):
|
||||
component_pattern = (
|
||||
r'component\s*<=>\s*null' if component is None else rf"component\s*<=>\s*'{re.escape(component)}'"
|
||||
)
|
||||
identity_pattern = (
|
||||
rf'menu_id\s*=\s*{menu_id}\b[\s\S]+?'
|
||||
rf"menu_name\s*<=>\s*'{re.escape(name)}'[\s\S]+?"
|
||||
rf'parent_id\s*<=>\s*{parent_id}\b[\s\S]+?'
|
||||
rf"path\s*<=>\s*'{re.escape(path)}'[\s\S]+?"
|
||||
rf'{component_pattern}[\s\S]+?'
|
||||
rf"perms\s*<=>\s*'{re.escape(permission)}'[\s\S]+?"
|
||||
rf"menu_type\s*<=>\s*'{menu_type}'"
|
||||
)
|
||||
self.assertRegex(guard_sql, identity_pattern)
|
||||
|
||||
self.assertRegex(
|
||||
guard_sql,
|
||||
r'from\s+sys_dict_data[\s\S]+?dict_code\s+between\s+33\s+and\s+69'
|
||||
r"[\s\S]+?dict_type\s*<=>\s*'ai_provider_type'",
|
||||
)
|
||||
self.assertRegex(
|
||||
guard_sql,
|
||||
r'from\s+sys_dict_type[\s\S]+?dict_id\s*=\s*12\b'
|
||||
r"[\s\S]+?dict_type\s*<=>\s*'ai_provider_type'",
|
||||
)
|
||||
|
||||
dict_data_cleanup = re.search(r'delete\s+from\s+sys_dict_data\s+where\s+[^;]+', sql)
|
||||
dict_type_cleanup = re.search(r'delete\s+from\s+sys_dict_type\s+where\s+[^;]+', sql)
|
||||
self.assertRegex(
|
||||
dict_data_cleanup.group(),
|
||||
r'dict_code\s+between\s+33\s+and\s+69\s+and\s+dict_type\s*=\s*\'ai_provider_type\'',
|
||||
)
|
||||
self.assertRegex(
|
||||
dict_type_cleanup.group(),
|
||||
r'dict_id\s*=\s*12\s+and\s+dict_type\s*=\s*\'ai_provider_type\'',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -829,10 +829,15 @@ class BusinessVoContractTest(unittest.TestCase):
|
||||
):
|
||||
model_class.model_validate({'endTime': '9999-12-31'})
|
||||
|
||||
def test_pallet_page_size_is_capped_at_100(self) -> None:
|
||||
self.assertEqual(100, PalletPageQueryModel.model_validate({'pageSize': 100}).page_size)
|
||||
with self.assertRaises(ValidationError):
|
||||
PalletPageQueryModel.model_validate({'pageSize': 101})
|
||||
def test_all_page_sizes_are_capped_at_100(self) -> None:
|
||||
for model_class in PAGE_QUERY_MODELS:
|
||||
with self.subTest(model=model_class.__name__, page_size=100):
|
||||
self.assertEqual(100, model_class.model_validate({'pageSize': 100}).page_size)
|
||||
with (
|
||||
self.subTest(model=model_class.__name__, page_size=101),
|
||||
self.assertRaises(ValidationError),
|
||||
):
|
||||
model_class.model_validate({'pageSize': 101})
|
||||
|
||||
def test_filling_accepts_camel_case_and_positive_packages(self) -> None:
|
||||
model = FillingCreateModel.model_validate(
|
||||
|
||||
Reference in New Issue
Block a user