From 4a321098f8661e186e7d86283a5bd84542552c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Mon, 27 Jul 2026 17:40:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=89=98=E7=9B=98=E4=B8=9A=E5=8A=A1=E6=9D=83=E9=99=90=E4=B8=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-24-pallet-workflow-system.md | 2 +- .../module_business/dao/operation_dao.py | 2 + .../module_business/dao/report_dao.py | 46 ++- .../module_business/entity/vo/master_vo.py | 10 +- .../module_business/entity/vo/operation_vo.py | 10 +- .../service/inbound_service.py | 122 +++++- .../module_business/service/return_service.py | 7 +- .../service/shipment_service.py | 8 +- .../20260724_pallet_workflow_mysql.sql | 107 ++++- .../module_business/test_inbound_service.py | 387 +++++++++++++++++- .../module_business/test_inventory_dao.py | 30 +- .../module_business/test_report_service.py | 27 ++ .../module_business/test_return_service.py | 4 +- .../module_business/test_shipment_service.py | 34 +- .../module_business/test_sql_contract.py | 73 ++++ .../tests/module_business/test_vo_contract.py | 13 +- 16 files changed, 823 insertions(+), 59 deletions(-) diff --git a/docs/superpowers/plans/2026-07-24-pallet-workflow-system.md b/docs/superpowers/plans/2026-07-24-pallet-workflow-system.md index d78f75f..a7bcdd0 100644 --- a/docs/superpowers/plans/2026-07-24-pallet-workflow-system.md +++ b/docs/superpowers/plans/2026-07-24-pallet-workflow-system.md @@ -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 diff --git a/shierfeng-fastapi-backend/module_business/dao/operation_dao.py b/shierfeng-fastapi-backend/module_business/dao/operation_dao.py index 3271eaa..7ec6335 100644 --- a/shierfeng-fastapi-backend/module_business/dao/operation_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/operation_dao.py @@ -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 diff --git a/shierfeng-fastapi-backend/module_business/dao/report_dao.py b/shierfeng-fastapi-backend/module_business/dao/report_dao.py index 2d3a609..dcfe85c 100644 --- a/shierfeng-fastapi-backend/module_business/dao/report_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/report_dao.py @@ -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: diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py index 996f39d..fb1cb64 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py @@ -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, diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py index 0ce5b29..6b29364 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py @@ -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') diff --git a/shierfeng-fastapi-backend/module_business/service/inbound_service.py b/shierfeng-fastapi-backend/module_business/service/inbound_service.py index 31a9a5e..5e49d41 100644 --- a/shierfeng-fastapi-backend/module_business/service/inbound_service.py +++ b/shierfeng-fastapi-backend/module_business/service/inbound_service.py @@ -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( diff --git a/shierfeng-fastapi-backend/module_business/service/return_service.py b/shierfeng-fastapi-backend/module_business/service/return_service.py index ca62470..def43a4 100644 --- a/shierfeng-fastapi-backend/module_business/service/return_service.py +++ b/shierfeng-fastapi-backend/module_business/service/return_service.py @@ -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 diff --git a/shierfeng-fastapi-backend/module_business/service/shipment_service.py b/shierfeng-fastapi-backend/module_business/service/shipment_service.py index b6d4e50..5d11f0c 100644 --- a/shierfeng-fastapi-backend/module_business/service/shipment_service.py +++ b/shierfeng-fastapi-backend/module_business/service/shipment_service.py @@ -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( diff --git a/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql b/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql index 4fe36ee..bdd0bac 100644 --- a/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql +++ b/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql @@ -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; diff --git a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py index d64b1b9..7773d63 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py @@ -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), diff --git a/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py b/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py index d245d30..8ee2471 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py @@ -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 = ( ( diff --git a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py index 61ec386..11d4269 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py @@ -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): diff --git a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py index 73f57b5..1b69190 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py @@ -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: diff --git a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py index 1f8cdb4..5270f7a 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py @@ -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)] diff --git a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py index 01514a5..1e9c89e 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -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() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py index 7371eca..08eb29f 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py @@ -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(