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

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_shipment_for_update(AsyncSession, int) -> BizShipmentOrder | None
OperationDao.get_return_for_update(AsyncSession, int) -> BizReturnOrder | None OperationDao.get_return_for_update(AsyncSession, int) -> BizReturnOrder | None
OperationDao.get_active_filling_for_update(AsyncSession, int) -> BizFillingRecord | 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_shipment_by_confirm_idempotency_key(AsyncSession, str) -> BizShipmentOrder | None
OperationDao.get_filling_by_idempotency_key(AsyncSession, str) -> BizFillingRecord | None OperationDao.get_filling_by_idempotency_key(AsyncSession, str) -> BizFillingRecord | None
OperationDao.get_usage_by_idempotency_key(AsyncSession, str) -> BizUsageRecord | None OperationDao.get_usage_by_idempotency_key(AsyncSession, str) -> BizUsageRecord | None
@@ -370,12 +370,14 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
idempotency_key: str, idempotency_key: str,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None: ) -> BizInboundOrder | None:
return await cls._get_by_key( return await cls._get_by_key(
db, db,
BizInboundOrder, BizInboundOrder,
BizInboundOrder.confirm_idempotency_key, BizInboundOrder.confirm_idempotency_key,
idempotency_key, idempotency_key,
*cls._inbound_scope_conditions(warehouse_ids),
) )
@classmethod @classmethod
@@ -1,14 +1,14 @@
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from typing import Any 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser 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 ( from module_business.entity.do.master_do import (
BizCustomer, BizCustomer,
BizCustomerSite, BizCustomerSite,
@@ -86,6 +86,41 @@ class ReportDao:
] ]
return [or_(*conditions)] if conditions else [] 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 @staticmethod
async def _paginate( async def _paginate(
db: AsyncSession, db: AsyncSession,
@@ -433,12 +468,7 @@ class ReportDao:
before_line = aliased(BizProductionLine, name='before_line') before_line = aliased(BizProductionLine, name='before_line')
after_line = aliased(BizProductionLine, name='after_line') after_line = aliased(BizProductionLine, name='after_line')
operator_user = aliased(SysUser, name='operator_user') operator_user = aliased(SysUser, name='operator_user')
conditions = cls._or_scope_conditions( conditions = cls._pallet_trace_scope_conditions(warehouse_ids, site_ids)
(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 query_object.pallet_code: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.event_type: 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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='菌种编码')
strain_name: str | None = Field( strain_name: str | None = Field(
default=None, default=None,
@@ -104,7 +104,7 @@ class CustomerPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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( customer_code: str | None = Field(
default=None, default=None,
min_length=1, min_length=1,
@@ -171,7 +171,7 @@ class SitePageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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') 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_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='现场编码')
site_name: str | None = Field( 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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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') 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_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='产线编码')
line_name: str | None = Field( 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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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( warehouse_code: str | None = Field(
default=None, default=None,
min_length=1, min_length=1,
@@ -366,7 +366,7 @@ class InboundPageQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='入库单号') 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') warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID')
status: DocumentStatus | None = Field(default=None, description='单据状态') 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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='装填单号') 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') warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID')
strain_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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='发货单号') 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') source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID')
target_site_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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='使用记录号') 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') site_id: int | None = Field(default=None, gt=0, description='现场ID')
line_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) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='退回单号') 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') source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID')
target_warehouse_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='目标仓库已停用') raise ServiceException(message='目标仓库已停用')
return warehouse 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 @staticmethod
def _validate_pallet_pair(pallet: BizPallet, inventory: BizPalletInventory) -> None: def _validate_pallet_pair(pallet: BizPallet, inventory: BizPalletInventory) -> None:
if pallet.asset_status != '0': if pallet.asset_status != '0':
@@ -296,6 +354,13 @@ class InboundService:
) )
ordered_ids = sorted(page_object.pallet_ids) ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_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() current_time = datetime.now()
inbound = BizInboundOrder( inbound = BizInboundOrder(
@@ -348,6 +413,13 @@ class InboundService:
) )
ordered_ids = sorted(page_object.pallet_ids) ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_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() current_time = datetime.now()
inbound.warehouse_id = page_object.warehouse_id inbound.warehouse_id = page_object.warehouse_id
@@ -383,6 +455,7 @@ class InboundService:
ordered_ids = sorted(inbound_ids) ordered_ids = sorted(inbound_ids)
warehouse_ids = await cls._warehouse_scope(query_db, current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
locked_inbounds = []
for inbound_id in ordered_ids: for inbound_id in ordered_ids:
inbound = await OperationDao.get_inbound_for_update( inbound = await OperationDao.get_inbound_for_update(
query_db, query_db,
@@ -393,6 +466,16 @@ class InboundService:
cls._raise_inbound_not_accessible(current_user) cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value: if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许删除') 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: for inbound_id in ordered_ids:
await OperationDao.delete_inbound(query_db, inbound_id) await OperationDao.delete_inbound(query_db, inbound_id)
@@ -429,17 +512,14 @@ class InboundService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
) -> CrudResponseModel: ) -> CrudResponseModel:
try: try:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
recovered = await OperationDao.get_inbound_by_confirm_idempotency_key( recovered = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db, query_db,
idempotency_key, idempotency_key,
warehouse_ids=warehouse_ids,
) )
if recovered is None: if recovered is None:
raise ServiceException(message='确认入库失败,幂等冲突') raise ServiceException(message='确认入库失败,幂等冲突')
await AccessService.assert_warehouse_access(
query_db,
current_user,
recovered.warehouse_id,
)
if not cls._is_exact_confirmed( if not cls._is_exact_confirmed(
recovered, recovered,
inbound_id, inbound_id,
@@ -447,6 +527,26 @@ class InboundService:
request_hash, request_hash,
): ):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用') 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('确认成功') return cls._success('确认成功')
finally: finally:
await query_db.rollback() await query_db.rollback()
@@ -475,6 +575,7 @@ class InboundService:
existing = await OperationDao.get_inbound_by_confirm_idempotency_key( existing = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db, query_db,
idempotency_key, idempotency_key,
warehouse_ids=warehouse_ids,
) )
if existing is not None: if existing is not None:
await AccessService.assert_warehouse_access( await AccessService.assert_warehouse_access(
@@ -504,6 +605,12 @@ class InboundService:
idempotency_key, idempotency_key,
request_hash, request_hash,
): ):
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_inbound,
current_user,
require_active=False,
)
await query_db.rollback() await query_db.rollback()
return cls._success('确认成功') return cls._success('确认成功')
raise ServiceException(message='入库单已确认,幂等信息不匹配') raise ServiceException(message='入库单已确认,幂等信息不匹配')
@@ -521,6 +628,11 @@ class InboundService:
raise ServiceException(message='入库单托盘不能重复') raise ServiceException(message='入库单托盘不能重复')
ordered_ids = sorted(pallet_ids) ordered_ids = sorted(pallet_ids)
inventories, _ = await cls._lock_and_validate_pallets(query_db, ordered_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: for inventory in inventories:
PalletStateMachine.ensure_transition( PalletStateMachine.ensure_transition(
@@ -1026,7 +1026,7 @@ class ReturnService:
if scanned_ids != sorted(item_ids): if scanned_ids != sorted(item_ids):
raise ServiceException(message='确认扫码托盘集合与退回单明细不一致') 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( await cls._lock_and_reauthorize_confirm_warehouse(
query_db, query_db,
locked, locked,
@@ -1059,11 +1059,6 @@ class ReturnService:
before_remaining_packages = inventory.remaining_packages before_remaining_packages = inventory.remaining_packages
if inventory.remaining_packages > 0: if inventory.remaining_packages > 0:
target_state = PalletFlowState.FILLED_IN_WAREHOUSE.value 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: else:
target_state = PalletFlowState.EMPTY_IN_WAREHOUSE.value target_state = PalletFlowState.EMPTY_IN_WAREHOUSE.value
inventory.active_filling_id = None inventory.active_filling_id = None
@@ -148,7 +148,6 @@ class ShipmentService:
if ( if (
filling.filling_id != inventory.active_filling_id filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id or filling.pallet_id != pallet.pallet_id
or filling.warehouse_id != source_warehouse_id
or filling.active_slot != 'ACTIVE' or filling.active_slot != 'ACTIVE'
): ):
raise ServiceException(message='托盘活动装填周期无效') raise ServiceException(message='托盘活动装填周期无效')
@@ -378,7 +377,6 @@ class ShipmentService:
if ( if (
filling.filling_id != inventory.active_filling_id filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id or filling.pallet_id != pallet.pallet_id
or filling.warehouse_id != source_warehouse_id
or filling.active_slot != 'ACTIVE' or filling.active_slot != 'ACTIVE'
): ):
raise ServiceException(message='托盘活动装填周期无效') raise ServiceException(message='托盘活动装填周期无效')
@@ -424,11 +422,7 @@ class ShipmentService:
cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场') cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场')
if inventory.active_filling_id is None: if inventory.active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期') raise ServiceException(message='托盘不存在活动装填周期')
filling = await OperationDao.get_filling_by_id( filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id)
query_db,
inventory.active_filling_id,
warehouse_ids=[query_object.source_warehouse_id],
)
if filling is None: if filling is None:
raise ServiceException(message='托盘活动装填周期不存在') raise ServiceException(message='托盘活动装填周期不存在')
cls._validate_scan_pallet( cls._validate_scan_pallet(
@@ -400,6 +400,102 @@ create table if not exists biz_pallet_inventory (
-- ==================== DML:清理旧 seed 并写入托盘业务菜单 ==================== -- ==================== 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 delete from sys_role_menu
where menu_id in (4, 99, 118, 119, 1061, 1062, 1063, 1064); 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 delete from sys_dict_data
where dict_code between 33 and 69 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 delete from sys_dict_type
where dict_id = 12 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 delete from sys_menu
where menu_id = 99; where menu_id = 99;
@@ -178,7 +178,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
actions: list[str] = [] actions: list[str] = []
captured: dict[str, Any] = {} captured: dict[str, Any] = {}
async def access(*_args: Any) -> None: async def access(*_args: Any, **_kwargs: Any) -> None:
actions.append('access') actions.append('access')
async def lock_inventories(*_args: Any) -> list[BizPalletInventory]: 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) result = await InboundService.add_inbound_services(self.db, page, self.user)
self.assertEqual( self.assertEqual(
['access', 'inventory-lock', 'pallet-lock', 'add-order', 'replace-items', 'commit'], [
'access',
'inventory-lock',
'pallet-lock',
'access',
'add-order',
'replace-items',
'commit',
],
actions, actions,
) )
inventory_lock.assert_awaited_once_with(self.db, [2, 5]) inventory_lock.assert_awaited_once_with(self.db, [2, 5])
@@ -352,6 +360,43 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
pallet_lock.assert_not_awaited() pallet_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once() 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: async def test_edit_scopes_original_and_checks_target_access_then_replaces_items(self) -> None:
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2]) page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2])
inbound = make_inbound(warehouse_id=7, pallet_ids=(3,)) inbound = make_inbound(warehouse_id=7, pallet_ids=(3,))
@@ -390,7 +435,14 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
11, 11,
warehouse_ids=[2, 5, 7, 8, 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]) inventory_lock.assert_awaited_once_with(self.db, [2, 5])
pallet_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) self.assertEqual(8, inbound.warehouse_id)
@@ -402,6 +454,55 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_awaited_once() self.db.commit.assert_awaited_once()
self.assertTrue(result.is_success) 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: async def test_edit_rejects_confirmed_document_without_pallet_or_order_write(self) -> None:
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2]) page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2])
update = AsyncMock() update = AsyncMock()
@@ -484,6 +585,16 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
access = AsyncMock() access = AsyncMock()
with ( with (
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock)), 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(AccessService, 'assert_warehouse_access', new=access),
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)), patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)),
): ):
@@ -494,10 +605,58 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
actions, actions,
) )
self.default_scope.assert_awaited_once_with(self.db, 7) 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.assertTrue(result.is_success)
self.db.rollback.assert_not_awaited() 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: async def test_batch_delete_is_atomic_when_later_document_is_confirmed(self) -> None:
delete = AsyncMock() delete = AsyncMock()
records = { records = {
@@ -583,6 +742,11 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
'get_inbound_for_update', 'get_inbound_for_update',
new=AsyncMock(return_value=make_inbound(pallet_ids=(2,))), 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(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())), patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())),
self.assertRaises(ServiceException), self.assertRaises(ServiceException),
@@ -924,10 +1088,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
actions.append('detail') actions.append('detail')
return inbound return inbound
async def access(*_args: Any) -> None: async def access(*_args: Any, **_kwargs: Any) -> None:
actions.append('access') actions.append('access')
async def by_key(*_args: Any) -> None: async def by_key(*_args: Any, **_kwargs: Any) -> None:
actions.append('idempotency-read') actions.append('idempotency-read')
async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder: async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
@@ -942,6 +1106,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
actions.append('pallet-lock') actions.append('pallet-lock')
return pallets 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: def transition(current: PalletFlowState, target: PalletFlowState) -> None:
actions.append(f'transition-{current.value}-{target.value}') 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(OperationDao, 'update_inbound', new=AsyncMock(side_effect=update)),
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
patch.object(inbound_service_module, 'datetime'), patch.object(inbound_service_module, 'datetime'),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=lock_warehouse)),
) )
with ( with (
patches[0] as detail_read, patches[0] as detail_read,
patches[1] as access_probe, patches[1] as access_probe,
patches[2], patches[2] as key_read,
patches[3] as order_lock, patches[3] as order_lock,
patches[4] as inventory_lock, patches[4] as inventory_lock,
patches[5] as pallet_lock, patches[5] as pallet_lock,
@@ -997,6 +1166,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patches[7], patches[7],
patches[8], patches[8],
patches[9] as service_datetime, patches[9] as service_datetime,
patches[10] as warehouse_lock,
): ):
service_datetime.now.return_value = self.fixed_time service_datetime.now.return_value = self.fixed_time
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
@@ -1008,10 +1178,12 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
'events': events, 'events': events,
'actions': actions, 'actions': actions,
'detail_read': detail_read, 'detail_read': detail_read,
'key_read': key_read,
'access_probe': access_probe, 'access_probe': access_probe,
'order_lock': order_lock, 'order_lock': order_lock,
'inventory_lock': inventory_lock, 'inventory_lock': inventory_lock,
'pallet_lock': pallet_lock, 'pallet_lock': pallet_lock,
'warehouse_lock': warehouse_lock,
'ensure_transition': ensure_transition, 'ensure_transition': ensure_transition,
} }
@@ -1025,6 +1197,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
'order-lock', 'order-lock',
'inventory-lock', 'inventory-lock',
'pallet-lock', 'pallet-lock',
'warehouse-lock',
'access',
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE', 'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE', 'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
'update-order', 'update-order',
@@ -1036,8 +1210,14 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
) )
self.default_scope.assert_awaited_once_with(self.db, 7) self.default_scope.assert_awaited_once_with(self.db, 7)
captured['detail_read'].assert_awaited_once_with(self.db, 11, warehouse_ids=[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['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['inventory_lock'].assert_awaited_once_with(self.db, [2, 5])
captured['pallet_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( captured['ensure_transition'].assert_has_calls(
@@ -1085,6 +1265,52 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.db.rollback.assert_not_awaited() self.db.rollback.assert_not_awaited()
self.assertTrue(captured['result'].is_success) 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: async def test_confirm_rejects_incomplete_locked_sets_before_any_mutation(self) -> None:
inbound = make_inbound() inbound = make_inbound()
inventories = [make_inventory(2)] 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(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_inventories_for_update', new=AsyncMock(return_value=[inventory])),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), 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(PalletStateMachine, 'ensure_transition', side_effect=ValueError('invalid')),
patch.object(OperationDao, 'update_inbound', new=update), patch.object(OperationDao, 'update_inbound', new=update),
patch.object(InventoryDao, 'add_event', new=add_event), 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(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)) as lock,
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_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) result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
lock.assert_awaited_once_with(self.db, 11, warehouse_ids=[7]) 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() inventory_lock.assert_not_awaited()
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once() self.db.rollback.assert_awaited_once()
self.assertTrue(result.is_success) 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: async def test_same_key_for_different_inbound_is_rejected_without_lock_or_write(self) -> None:
target = make_inbound() target = make_inbound()
existing = 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(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=locked)),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), 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(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(OperationDao, 'update_inbound', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()),
): ):
@@ -1246,23 +1533,96 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
) )
key_reads = AsyncMock(side_effect=[None, recovered]) key_reads = AsyncMock(side_effect=[None, recovered])
self.db.commit.side_effect = integrity_error() self.db.commit.side_effect = integrity_error()
parent_lock = AsyncMock(side_effect=[inbound, recovered])
with ( with (
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access, 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_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_inventories_for_update', new=AsyncMock(return_value=[inventory])),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), 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(OperationDao, 'update_inbound', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()),
): ):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) 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) 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.assertEqual(2, self.db.rollback.await_count)
self.assertTrue(result.is_success) 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: async def test_integrity_conflict_with_mismatched_recovery_is_stable_failure(self) -> None:
inbound = make_inbound(pallet_ids=(2,)) inbound = make_inbound(pallet_ids=(2,))
inventory = make_inventory(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(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_inventories_for_update', new=AsyncMock(return_value=[inventory])),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])), 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(OperationDao, 'update_inbound', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()),
self.assertRaises(ServiceException), self.assertRaises(ServiceException),
@@ -125,7 +125,11 @@ class TestDaoPublicContract(unittest.TestCase):
'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'), 'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'),
'get_return_for_update': ('db', 'return_id', 'site_ids', 'warehouse_ids'), 'get_return_for_update': ('db', 'return_id', 'site_ids', 'warehouse_ids'),
'get_active_filling_for_update': ('db', 'filling_id'), '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': ( 'get_shipment_by_confirm_idempotency_key': (
'db', 'db',
'idempotency_key', 'idempotency_key',
@@ -641,6 +645,30 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.assertIn(f"{model.__tablename__}.{key_column} = 'same-key'", sql) self.assertIn(f"{model.__tablename__}.{key_column} = 'same-key'", sql)
self.assertNotIn('FOR UPDATE', 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: async def test_list_queries_apply_every_declared_filter_sort_and_pagination(self) -> None:
cases = ( cases = (
( (
@@ -607,6 +607,33 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
sql, 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 尚未实现') @unittest.skipUnless(ReportService is not None, 'ReportService 尚未实现')
class TestReportServices(unittest.IsolatedAsyncioTestCase): 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(PalletFlowState.FILLED_IN_WAREHOUSE.value, inventories[0].flow_state)
self.assertEqual(102, inventories[0].active_filling_id) self.assertEqual(102, inventories[0].active_filling_id)
self.assertEqual('ACTIVE', filling.active_slot) self.assertEqual('ACTIVE', filling.active_slot)
self.assertEqual(7, filling.warehouse_id) self.assertEqual(3, filling.warehouse_id)
update_filling.assert_awaited_once_with(self.db, filling) update_filling.assert_not_awaited()
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE.value, inventories[1].flow_state) self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE.value, inventories[1].flow_state)
self.assertIsNone(inventories[1].active_filling_id) self.assertIsNone(inventories[1].active_filling_id)
for inventory in inventories: for inventory in inventories:
@@ -552,10 +552,11 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9) query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9)
pallet = make_pallet(2) pallet = make_pallet(2)
inventory = make_inventory(2, remaining_packages=8) inventory = make_inventory(2, remaining_packages=8)
filling = make_filling(2) filling = make_filling(2, warehouse_id=3)
lock_inventory = AsyncMock() lock_inventory = AsyncMock()
lock_pallets = AsyncMock() lock_pallets = AsyncMock()
lock_filling = AsyncMock() lock_filling = AsyncMock()
get_filling = AsyncMock(return_value=filling)
with ( with (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_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(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)), 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_inventories_for_update', new=lock_inventory),
patch.object(InventoryDao, 'get_pallets_by_ids', new=lock_pallets), patch.object(InventoryDao, 'get_pallets_by_ids', new=lock_pallets),
patch.object(OperationDao, 'get_active_filling_for_update', new=lock_filling), 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(PalletFlowState.FILLED_IN_WAREHOUSE, result.flow_state)
self.assertEqual(8, result.remaining_packages) self.assertEqual(8, result.remaining_packages)
self.assertEqual(['shipment'], result.allowed_actions) self.assertEqual(['shipment'], result.allowed_actions)
get_filling.assert_awaited_once_with(self.db, inventory.active_filling_id)
lock_inventory.assert_not_awaited() lock_inventory.assert_not_awaited()
lock_pallets.assert_not_awaited() lock_pallets.assert_not_awaited()
lock_filling.assert_not_awaited() lock_filling.assert_not_awaited()
@@ -723,6 +725,34 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
self.user = make_user() self.user = make_user()
self.fixed_time = datetime(2026, 7, 26, 16, 30) 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 async def test_add_only_persists_pallet_ids_with_null_snapshots(self) -> None: # noqa: PLR0915
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[5, 2]) page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[5, 2])
inventories = [make_inventory(2), make_inventory(5)] 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.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') 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__': if __name__ == '__main__':
unittest.main() unittest.main()
@@ -829,10 +829,15 @@ class BusinessVoContractTest(unittest.TestCase):
): ):
model_class.model_validate({'endTime': '9999-12-31'}) model_class.model_validate({'endTime': '9999-12-31'})
def test_pallet_page_size_is_capped_at_100(self) -> None: def test_all_page_sizes_are_capped_at_100(self) -> None:
self.assertEqual(100, PalletPageQueryModel.model_validate({'pageSize': 100}).page_size) for model_class in PAGE_QUERY_MODELS:
with self.assertRaises(ValidationError): with self.subTest(model=model_class.__name__, page_size=100):
PalletPageQueryModel.model_validate({'pageSize': 101}) 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: def test_filling_accepts_camel_case_and_positive_packages(self) -> None:
model = FillingCreateModel.model_validate( model = FillingCreateModel.model_validate(