修复:收紧托盘业务权限与数据契约

This commit is contained in:
焦龙言
2026-07-27 17:40:24 +08:00
parent 9b1a807098
commit 4a321098f8
16 changed files with 823 additions and 59 deletions
@@ -1057,7 +1057,7 @@ OperationDao.get_inbound_for_update(AsyncSession, int) -> BizInboundOrder | None
OperationDao.get_shipment_for_update(AsyncSession, int) -> BizShipmentOrder | None
OperationDao.get_return_for_update(AsyncSession, int) -> BizReturnOrder | None
OperationDao.get_active_filling_for_update(AsyncSession, int) -> BizFillingRecord | None
OperationDao.get_inbound_by_confirm_idempotency_key(AsyncSession, str) -> BizInboundOrder | None
OperationDao.get_inbound_by_confirm_idempotency_key(AsyncSession, str, list[int] | None = None) -> BizInboundOrder | None
OperationDao.get_shipment_by_confirm_idempotency_key(AsyncSession, str) -> BizShipmentOrder | None
OperationDao.get_filling_by_idempotency_key(AsyncSession, str) -> BizFillingRecord | None
OperationDao.get_usage_by_idempotency_key(AsyncSession, str) -> BizUsageRecord | None
@@ -370,12 +370,14 @@ class OperationDao:
cls,
db: AsyncSession,
idempotency_key: str,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None:
return await cls._get_by_key(
db,
BizInboundOrder,
BizInboundOrder.confirm_idempotency_key,
idempotency_key,
*cls._inbound_scope_conditions(warehouse_ids),
)
@classmethod
@@ -1,14 +1,14 @@
from datetime import datetime, time, timedelta
from typing import Any
from sqlalchemy import ColumnElement, Select, false, or_, select
from sqlalchemy import ColumnElement, Select, and_, false, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import DocumentStatus
from module_business.domain.enums import DocumentStatus, PalletEventType
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
@@ -86,6 +86,41 @@ class ReportDao:
]
return [or_(*conditions)] if conditions else []
@classmethod
def _pallet_trace_scope_conditions(
cls,
warehouse_ids: list[int] | None,
site_ids: list[int] | None,
) -> list[ColumnElement[bool]]:
participant_scopes = [
condition
for column, scope_ids in (
(BizPalletEvent.before_warehouse_id, warehouse_ids),
(BizPalletEvent.after_warehouse_id, warehouse_ids),
(BizPalletEvent.before_site_id, site_ids),
(BizPalletEvent.after_site_id, site_ids),
)
if (condition := cls._scope_condition(column, scope_ids)) is not None
]
if not participant_scopes:
return []
shipment_scopes = [
condition
for column, scope_ids in (
(BizPalletEvent.before_warehouse_id, warehouse_ids),
(BizPalletEvent.after_site_id, site_ids),
)
if (condition := cls._scope_condition(column, scope_ids)) is not None
]
shipment_type = PalletEventType.SHIPMENT_CONFIRMED.value
return [
or_(
and_(BizPalletEvent.event_type == shipment_type, *shipment_scopes),
and_(BizPalletEvent.event_type != shipment_type, or_(*participant_scopes)),
)
]
@staticmethod
async def _paginate(
db: AsyncSession,
@@ -433,12 +468,7 @@ class ReportDao:
before_line = aliased(BizProductionLine, name='before_line')
after_line = aliased(BizProductionLine, name='after_line')
operator_user = aliased(SysUser, name='operator_user')
conditions = cls._or_scope_conditions(
(BizPalletEvent.before_warehouse_id, warehouse_ids),
(BizPalletEvent.after_warehouse_id, warehouse_ids),
(BizPalletEvent.before_site_id, site_ids),
(BizPalletEvent.after_site_id, site_ids),
)
conditions = cls._pallet_trace_scope_conditions(warehouse_ids, site_ids)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.event_type:
@@ -45,7 +45,7 @@ class StrainPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
strain_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='菌种编码')
strain_name: str | None = Field(
default=None,
@@ -104,7 +104,7 @@ class CustomerPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
customer_code: str | None = Field(
default=None,
min_length=1,
@@ -171,7 +171,7 @@ class SitePageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
customer_id: int | None = Field(default=None, gt=0, description='客户ID')
site_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='现场编码')
site_name: str | None = Field(
@@ -232,7 +232,7 @@ class LinePageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
site_id: int | None = Field(default=None, gt=0, description='现场ID')
line_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='产线编码')
line_name: str | None = Field(
@@ -293,7 +293,7 @@ class WarehousePageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
warehouse_code: str | None = Field(
default=None,
min_length=1,
@@ -366,7 +366,7 @@ class InboundPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
inbound_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='入库单号')
warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID')
status: DocumentStatus | None = Field(default=None, description='单据状态')
@@ -382,7 +382,7 @@ class FillingPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号')
warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID')
strain_id: int | None = Field(default=None, gt=0, description='菌种ID')
@@ -400,7 +400,7 @@ class ShipmentPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号')
source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID')
target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID')
@@ -417,7 +417,7 @@ class UsagePageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号')
site_id: int | None = Field(default=None, gt=0, description='现场ID')
line_id: int | None = Field(default=None, gt=0, description='产线ID')
@@ -434,7 +434,7 @@ class ReturnPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, description='每页记录数')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号')
source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID')
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
@@ -104,6 +104,64 @@ class InboundService:
raise ServiceException(message='目标仓库已停用')
return warehouse
@classmethod
async def _lock_and_authorize_warehouses(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
*,
warehouse_ids: set[int],
hidden_warehouse_ids: set[int],
require_active: bool,
) -> None:
"""锁定仓库及授权映射,并基于锁后最新状态重新校验写权限。"""
warehouses: dict[int, Any] = {}
permission_errors: dict[int, PermissionException] = {}
for warehouse_id in sorted(warehouse_ids):
warehouses[warehouse_id] = await MasterDao.get_warehouse(
query_db,
warehouse_id,
for_update=True,
)
try:
await AccessService.assert_warehouse_access(
query_db,
current_user,
warehouse_id,
for_update=True,
)
except PermissionException as exc:
permission_errors[warehouse_id] = exc
if hidden_warehouse_ids & permission_errors.keys():
cls._raise_inbound_not_accessible(current_user)
if permission_errors:
raise permission_errors[min(permission_errors)]
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='目标仓库已停用')
@classmethod
async def _lock_and_reauthorize_warehouse(
cls,
query_db: AsyncSession,
inbound: BizInboundOrder,
current_user: CurrentUserModel,
*,
require_active: bool = True,
) -> None:
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids={inbound.warehouse_id},
hidden_warehouse_ids={inbound.warehouse_id},
require_active=require_active,
)
@staticmethod
def _validate_pallet_pair(pallet: BizPallet, inventory: BizPalletInventory) -> None:
if pallet.asset_status != '0':
@@ -296,6 +354,13 @@ class InboundService:
)
ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_ids)
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids={page_object.warehouse_id},
hidden_warehouse_ids=set(),
require_active=True,
)
current_time = datetime.now()
inbound = BizInboundOrder(
@@ -348,6 +413,13 @@ class InboundService:
)
ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_ids)
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids={inbound.warehouse_id, page_object.warehouse_id},
hidden_warehouse_ids={inbound.warehouse_id},
require_active=True,
)
current_time = datetime.now()
inbound.warehouse_id = page_object.warehouse_id
@@ -383,6 +455,7 @@ class InboundService:
ordered_ids = sorted(inbound_ids)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
locked_inbounds = []
for inbound_id in ordered_ids:
inbound = await OperationDao.get_inbound_for_update(
query_db,
@@ -393,6 +466,16 @@ class InboundService:
cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许删除')
locked_inbounds.append(inbound)
locked_warehouse_ids = {inbound.warehouse_id for inbound in locked_inbounds}
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids=locked_warehouse_ids,
hidden_warehouse_ids=locked_warehouse_ids,
require_active=False,
)
for inbound_id in ordered_ids:
await OperationDao.delete_inbound(query_db, inbound_id)
@@ -429,17 +512,14 @@ class InboundService:
current_user: CurrentUserModel,
) -> CrudResponseModel:
try:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
recovered = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db,
idempotency_key,
warehouse_ids=warehouse_ids,
)
if recovered is None:
raise ServiceException(message='确认入库失败,幂等冲突')
await AccessService.assert_warehouse_access(
query_db,
current_user,
recovered.warehouse_id,
)
if not cls._is_exact_confirmed(
recovered,
inbound_id,
@@ -447,6 +527,26 @@ class InboundService:
request_hash,
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
locked_recovered = await OperationDao.get_inbound_for_update(
query_db,
inbound_id,
warehouse_ids=warehouse_ids,
)
if locked_recovered is None:
cls._raise_inbound_not_accessible(current_user)
if not cls._is_exact_confirmed(
locked_recovered,
inbound_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_recovered,
current_user,
require_active=False,
)
return cls._success('确认成功')
finally:
await query_db.rollback()
@@ -475,6 +575,7 @@ class InboundService:
existing = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db,
idempotency_key,
warehouse_ids=warehouse_ids,
)
if existing is not None:
await AccessService.assert_warehouse_access(
@@ -504,6 +605,12 @@ class InboundService:
idempotency_key,
request_hash,
):
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_inbound,
current_user,
require_active=False,
)
await query_db.rollback()
return cls._success('确认成功')
raise ServiceException(message='入库单已确认,幂等信息不匹配')
@@ -521,6 +628,11 @@ class InboundService:
raise ServiceException(message='入库单托盘不能重复')
ordered_ids = sorted(pallet_ids)
inventories, _ = await cls._lock_and_validate_pallets(query_db, ordered_ids)
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_inbound,
current_user,
)
for inventory in inventories:
PalletStateMachine.ensure_transition(
@@ -1026,7 +1026,7 @@ class ReturnService:
if scanned_ids != sorted(item_ids):
raise ServiceException(message='确认扫码托盘集合与退回单明细不一致')
inventories, filling_by_id = await cls._lock_confirm_pallets(query_db, locked, items)
inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items)
await cls._lock_and_reauthorize_confirm_warehouse(
query_db,
locked,
@@ -1059,11 +1059,6 @@ class ReturnService:
before_remaining_packages = inventory.remaining_packages
if inventory.remaining_packages > 0:
target_state = PalletFlowState.FILLED_IN_WAREHOUSE.value
filling = filling_by_id[inventory.active_filling_id]
filling.warehouse_id = locked.target_warehouse_id
filling.update_by = user_name
filling.update_time = current_time
await OperationDao.update_filling_record(query_db, filling)
else:
target_state = PalletFlowState.EMPTY_IN_WAREHOUSE.value
inventory.active_filling_id = None
@@ -148,7 +148,6 @@ class ShipmentService:
if (
filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id
or filling.warehouse_id != source_warehouse_id
or filling.active_slot != 'ACTIVE'
):
raise ServiceException(message='托盘活动装填周期无效')
@@ -378,7 +377,6 @@ class ShipmentService:
if (
filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id
or filling.warehouse_id != source_warehouse_id
or filling.active_slot != 'ACTIVE'
):
raise ServiceException(message='托盘活动装填周期无效')
@@ -424,11 +422,7 @@ class ShipmentService:
cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场')
if inventory.active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期')
filling = await OperationDao.get_filling_by_id(
query_db,
inventory.active_filling_id,
warehouse_ids=[query_object.source_warehouse_id],
)
filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在')
cls._validate_scan_pallet(
@@ -400,6 +400,102 @@ create table if not exists biz_pallet_inventory (
-- ==================== DML:清理旧 seed 并写入托盘业务菜单 ====================
-- 删除前校验固定 ID 仍属于原始 AI/外链 seed;任一 ID 被自定义数据复用时,
-- 临时表唯一键冲突会在所有永久 DML 之前中止补丁,避免误删角色、菜单或字典数据。
drop temporary table if exists pallet_workflow_legacy_seed_guard;
create temporary table pallet_workflow_legacy_seed_guard (
guard_token varchar(64) not null,
primary key (guard_token)
);
insert into pallet_workflow_legacy_seed_guard (guard_token)
select guard_token
from (
select 'legacy-seed-conflict' as guard_token
union all
select if(
not exists (
select 1
from sys_menu
where menu_id in (4, 99, 118, 119, 1061, 1062, 1063, 1064)
and not (
(menu_id = 4
and menu_name <=> 'AI 管理'
and parent_id <=> 0
and path <=> 'ai'
and component <=> null
and perms <=> ''
and menu_type <=> 'M')
or (menu_id = 99
and menu_name <=> '施尔丰仓库'
and parent_id <=> 0
and path <=> 'https://git.yujiangrubber.cn/niuniu/ShiErFeng'
and component <=> null
and perms <=> ''
and menu_type <=> 'M')
or (menu_id = 118
and menu_name <=> '模型管理'
and parent_id <=> 4
and path <=> 'model'
and component <=> 'ai/model/index'
and perms <=> 'ai:model:list'
and menu_type <=> 'C')
or (menu_id = 119
and menu_name <=> 'AI 对话'
and parent_id <=> 4
and path <=> 'chat'
and component <=> 'ai/chat/index'
and perms <=> 'ai:chat:list'
and menu_type <=> 'C')
or (menu_id = 1061
and menu_name <=> '模型查询'
and parent_id <=> 118
and path <=> '#'
and component <=> ''
and perms <=> 'ai:model:query'
and menu_type <=> 'F')
or (menu_id = 1062
and menu_name <=> '模型新增'
and parent_id <=> 118
and path <=> '#'
and component <=> ''
and perms <=> 'ai:model:add'
and menu_type <=> 'F')
or (menu_id = 1063
and menu_name <=> '模型修改'
and parent_id <=> 118
and path <=> '#'
and component <=> ''
and perms <=> 'ai:model:edit'
and menu_type <=> 'F')
or (menu_id = 1064
and menu_name <=> '模型删除'
and parent_id <=> 118
and path <=> '#'
and component <=> ''
and perms <=> 'ai:model:remove'
and menu_type <=> 'F')
)
)
and not exists (
select 1
from sys_dict_data
where dict_code between 33 and 69
and not (dict_type <=> 'ai_provider_type')
)
and not exists (
select 1
from sys_dict_type
where dict_id = 12
and not (dict_type <=> 'ai_provider_type')
),
'legacy-seed-verified',
'legacy-seed-conflict'
) as guard_token
) as legacy_seed_guard_rows;
drop temporary table pallet_workflow_legacy_seed_guard;
-- 先解除所有待清理菜单的历史角色关联。
delete from sys_role_menu
where menu_id in (4, 99, 118, 119, 1061, 1062, 1063, 1064);
@@ -410,11 +506,18 @@ where menu_id in (4, 118, 119, 1061, 1062, 1063, 1064);
delete from sys_dict_data
where dict_code between 33 and 69
or dict_type = 'ai_provider_type';
and dict_type = 'ai_provider_type';
-- 同时清理后续可能追加、但仍明确属于 AI provider 字典的扩展项。
delete from sys_dict_data
where dict_type = 'ai_provider_type';
delete from sys_dict_type
where dict_id = 12
or dict_type = 'ai_provider_type';
and dict_type = 'ai_provider_type';
delete from sys_dict_type
where dict_type = 'ai_provider_type';
delete from sys_menu
where menu_id = 99;
@@ -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(