修复:统一发货路由错误隐藏

This commit is contained in:
焦龙言
2026-07-26 20:26:59 +08:00
parent dc28cf6df0
commit 3076194328
2 changed files with 229 additions and 22 deletions
@@ -663,16 +663,15 @@ class ShipmentService:
hidden_site_ids: set[int],
require_active: bool,
) -> None:
warehouses: dict[int, Any] = {}
warehouse_permission_errors: dict[int, PermissionException] = {}
for warehouse_id in sorted(warehouse_ids):
warehouse = await MasterDao.get_warehouse(
query_db,
warehouse_id,
for_update=True,
)
if warehouse is None:
raise ServiceException(message='来源仓库不存在')
if require_active and warehouse.status != '0':
raise ServiceException(message='来源仓库已停用')
warehouses[warehouse_id] = warehouse
try:
await AccessService.assert_warehouse_access(
query_db,
@@ -680,21 +679,18 @@ class ShipmentService:
warehouse_id,
for_update=True,
)
except PermissionException:
if warehouse_id in hidden_warehouse_ids:
cls._raise_shipment_not_accessible(current_user)
raise
except PermissionException as exc:
warehouse_permission_errors[warehouse_id] = exc
sites: dict[int, Any] = {}
site_permission_errors: dict[int, PermissionException] = {}
for site_id in sorted(site_ids):
site = await MasterDao.get_site(
query_db,
site_id,
for_update=True,
)
if site is None:
raise ServiceException(message='目标现场不存在')
if require_active and site.status != '0':
raise ServiceException(message='目标现场已停用')
sites[site_id] = site
try:
await AccessService.assert_site_access(
query_db,
@@ -702,10 +698,33 @@ class ShipmentService:
site_id,
for_update=True,
)
except PermissionException:
if site_id in hidden_site_ids:
except PermissionException as exc:
site_permission_errors[site_id] = exc
if hidden_warehouse_ids & warehouse_permission_errors.keys() or (
hidden_site_ids & site_permission_errors.keys()
):
cls._raise_shipment_not_accessible(current_user)
raise
explicit_permission_errors = [
warehouse_permission_errors[warehouse_id] for warehouse_id in sorted(warehouse_permission_errors)
]
explicit_permission_errors.extend(site_permission_errors[site_id] for site_id in sorted(site_permission_errors))
if explicit_permission_errors:
raise explicit_permission_errors[0]
for warehouse_id in sorted(warehouses):
warehouse = warehouses[warehouse_id]
if warehouse is None:
raise ServiceException(message='来源仓库不存在')
if require_active and warehouse.status != '0':
raise ServiceException(message='来源仓库已停用')
for site_id in sorted(sites):
site = sites[site_id]
if site is None:
raise ServiceException(message='目标现场不存在')
if require_active and site.status != '0':
raise ServiceException(message='目标现场已停用')
@classmethod
async def _lock_and_reauthorize_route(
@@ -1072,6 +1072,138 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
db.commit.assert_not_awaited()
db.rollback.assert_awaited_once()
async def test_edit_hidden_current_route_denial_wins_after_all_stable_route_locks(self) -> None:
scenarios = (
('both-warehouses-revoked', {7, 8}, set(), set(), set()),
('new-warehouse-and-current-site-revoked', {7}, {10}, set(), set()),
('current-warehouse-disabled-and-revoked', {8}, set(), {8}, set()),
('current-site-disabled-and-revoked', set(), {10}, set(), {10}),
)
expected_locks = [
'warehouse-master-7',
'warehouse-mapping-7',
'warehouse-master-8',
'warehouse-mapping-8',
'site-master-9',
'site-mapping-9',
'site-master-10',
'site-mapping-10',
]
for (
scenario,
denied_warehouse_ids,
denied_site_ids,
disabled_warehouse_ids,
disabled_site_ids,
) in scenarios:
with self.subTest(scenario=scenario):
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(
source_warehouse_id=8,
target_site_id=10,
pallet_ids=(2,),
)
page = ShipmentUpdateModel(
shipmentId=12,
sourceWarehouseId=7,
targetSiteId=9,
palletIds=[2],
)
locks: list[str] = []
async def warehouse_access(
_db: AsyncSession,
_user: CurrentUserModel,
warehouse_id: int,
_denied_ids: set[int] = denied_warehouse_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> None:
if not kwargs.get('for_update'):
return
_locks.append(f'warehouse-mapping-{warehouse_id}')
if warehouse_id in _denied_ids:
raise PermissionException(data='', message='无权访问该仓库')
async def site_access(
_db: AsyncSession,
_user: CurrentUserModel,
site_id: int,
_denied_ids: set[int] = denied_site_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> None:
if not kwargs.get('for_update'):
return
_locks.append(f'site-mapping-{site_id}')
if site_id in _denied_ids:
raise PermissionException(data='', message='无权访问该客户现场')
async def get_warehouse(
_db: AsyncSession,
warehouse_id: int,
_disabled_ids: set[int] = disabled_warehouse_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> BizWarehouse:
if kwargs.get('for_update'):
_locks.append(f'warehouse-master-{warehouse_id}')
return BizWarehouse(
warehouse_id=warehouse_id,
warehouse_code=f'W-{warehouse_id}',
warehouse_name=f'{warehouse_id}号仓',
status='1' if kwargs.get('for_update') and warehouse_id in _disabled_ids else '0',
)
async def get_site(
_db: AsyncSession,
site_id: int,
_disabled_ids: set[int] = disabled_site_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> BizCustomerSite:
if kwargs.get('for_update'):
_locks.append(f'site-master-{site_id}')
return BizCustomerSite(
site_id=site_id,
customer_id=1,
site_code=f'S-{site_id}',
site_name=f'{site_id}号现场',
status='1' if kwargs.get('for_update') and site_id in _disabled_ids else '0',
)
update = AsyncMock()
replace_items = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
patch.object(
AccessService,
'assert_warehouse_access',
new=AsyncMock(side_effect=warehouse_access),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=site_access)),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=get_site)),
patch.object(
ShipmentService,
'_lock_and_validate_pallets',
new=AsyncMock(return_value=([], [], [])),
),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(OperationDao, 'replace_shipment_items', new=replace_items),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.edit_shipment_services(db, page, self.user)
self.assertEqual('无权访问该发货单', raised.exception.message)
self.assertEqual(expected_locks, locks)
update.assert_not_awaited()
replace_items.assert_not_awaited()
db.commit.assert_not_awaited()
db.rollback.assert_awaited_once()
async def test_edit_locks_old_and_new_routes_in_stable_grouped_order(self) -> None:
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(
@@ -1242,6 +1374,19 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
status='0',
)
async def get_site(
_db: AsyncSession,
site_id: int,
**_kwargs: Any,
) -> BizCustomerSite:
return BizCustomerSite(
site_id=site_id,
customer_id=1,
site_code=f'S-{site_id}',
site_name=f'{site_id}号现场',
status='0',
)
async def warehouse_access(
_db: AsyncSession,
_user: CurrentUserModel,
@@ -1253,12 +1398,16 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
delete = AsyncMock()
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
site_lock = AsyncMock(side_effect=get_site)
site_mapping = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(side_effect=lock_order)),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)),
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(OperationDao, 'delete_shipment', new=delete),
self.assertRaises(PermissionException) as raised,
):
@@ -1268,6 +1417,9 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([11, 12], parent_lock_order)
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))
self.assertEqual([9, 10], [call.args[1] for call in site_lock.await_args_list])
self.assertEqual([9, 10], [call.args[2] for call in site_mapping.await_args_list])
self.assertTrue(all(call.kwargs.get('for_update') for call in site_mapping.await_args_list))
delete.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
@@ -1762,7 +1914,16 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
inventory_lock = AsyncMock()
update = AsyncMock()
add_event = AsyncMock()
site_lock = AsyncMock()
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
site_mapping = AsyncMock()
warehouse_mapping = AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库'))
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
@@ -1789,6 +1950,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
self.assertRaises(PermissionException) as raised,
@@ -1803,7 +1965,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
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)
site_lock.assert_not_awaited()
site_lock.assert_awaited_once_with(self.db, 9, for_update=True)
site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
inventory_lock.assert_not_awaited()
update.assert_not_awaited()
add_event.assert_not_awaited()
@@ -1997,7 +2160,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
)
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)
site_lock.assert_not_awaited()
site_lock.assert_awaited_once_with(self.db, 9, for_update=True)
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
@@ -2007,6 +2170,16 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
update = AsyncMock()
add_event = AsyncMock()
mapping = AsyncMock()
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
site_mapping = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
@@ -2035,6 +2208,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
),
),
patch.object(AccessService, 'assert_warehouse_access', new=mapping),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
self.assertRaises(ServiceException) as raised,
@@ -2045,7 +2220,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual('DRAFT', shipment.status)
self.assertEqual('FILLED_IN_WAREHOUSE', inventory.flow_state)
self.assertEqual(4, inventory.version)
mapping.assert_not_awaited()
mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
site_lock.assert_awaited_once_with(self.db, 9, for_update=True)
site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
update.assert_not_awaited()
add_event.assert_not_awaited()
self.db.rollback.assert_awaited_once()
@@ -2121,7 +2298,16 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
update = AsyncMock()
add_event = AsyncMock()
transition = MagicMock()
site_lock = AsyncMock()
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
site_mapping = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
@@ -2157,6 +2343,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库')),
),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(PalletStateMachine, 'ensure_transition', new=transition),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
@@ -2170,7 +2357,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(7, inventory.warehouse_id)
self.assertIsNone(inventory.site_id)
self.assertEqual(4, inventory.version)
site_lock.assert_not_awaited()
site_lock.assert_awaited_once_with(self.db, 9, for_update=True)
site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
transition.assert_not_called()
update.assert_not_awaited()
add_event.assert_not_awaited()