diff --git a/shierfeng-fastapi-backend/module_business/service/filling_service.py b/shierfeng-fastapi-backend/module_business/service/filling_service.py index db0088d..18cb0af 100644 --- a/shierfeng-fastapi-backend/module_business/service/filling_service.py +++ b/shierfeng-fastapi-backend/module_business/service/filling_service.py @@ -16,7 +16,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine -from module_business.domain.test_batch_scope import TestBatchScope +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.do.master_do import BizPallet, BizStrain from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory from module_business.entity.vo.master_vo import StrainPageQueryModel @@ -260,9 +260,11 @@ class FillingService: pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(pallet, test_scope, '托盘') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') if inventory.warehouse_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在仓库') await cls._assert_pallet_warehouse_access( @@ -351,6 +353,7 @@ class FillingService: warehouse_ids=[page_object.warehouse_id], ) if existing is not None: + assert_record_in_scope(existing, test_scope, '装填记录') await AccessService.assert_warehouse_access( query_db, current_user, @@ -375,6 +378,7 @@ class FillingService: ) if scanned_pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(scanned_pallet, test_scope, '托盘') inventory = await InventoryDao.get_inventory_for_update( query_db, @@ -383,6 +387,7 @@ class FillingService: ) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') if inventory.warehouse_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在仓库') await cls._assert_pallet_warehouse_access( @@ -398,6 +403,7 @@ class FillingService: if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != scanned_pallet.pallet_id: cls._raise_pallet_not_accessible(current_user) pallet = locked_pallets[0] + assert_record_in_scope(pallet, test_scope, '托盘') if inventory.active_filling_id is not None: concurrent = await OperationDao.get_active_filling_for_update( @@ -406,6 +412,7 @@ class FillingService: test_scope, ) if concurrent is not None and concurrent.idempotency_key == page_object.idempotency_key: + assert_record_in_scope(concurrent, test_scope, '活动装填') # 结束首次非锁定读取建立的 RR 快照,再按最新数据范围恢复并发成功结果。 await query_db.rollback() recovery_transaction_ended = True @@ -425,6 +432,7 @@ class FillingService: ) if warehouse is None: raise ServiceException(message='仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if warehouse.status != '0': raise ServiceException(message='仓库已停用') await cls._assert_pallet_warehouse_access( @@ -441,6 +449,7 @@ class FillingService: for_update=True, ) ) + assert_record_in_scope(strain, test_scope, '菌种') cls._validate_empty_warehouse_pallet( pallet, diff --git a/shierfeng-fastapi-backend/module_business/service/inbound_service.py b/shierfeng-fastapi-backend/module_business/service/inbound_service.py index bcb5ae0..a60533f 100644 --- a/shierfeng-fastapi-backend/module_business/service/inbound_service.py +++ b/shierfeng-fastapi-backend/module_business/service/inbound_service.py @@ -16,7 +16,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine -from module_business.domain.test_batch_scope import TestBatchScope +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizInboundItem, @@ -103,6 +103,7 @@ class InboundService: warehouse = await MasterDao.get_warehouse(query_db, warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='目标仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') return warehouse @@ -128,6 +129,8 @@ class InboundService: test_scope, for_update=True, ) + if warehouses[warehouse_id] is not None: + assert_record_in_scope(warehouses[warehouse_id], test_scope, '仓库') try: await AccessService.assert_warehouse_access( query_db, @@ -195,6 +198,10 @@ class InboundService: ordered_ids = sorted(pallet_ids) inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) + for inventory in inventories: + assert_record_in_scope(inventory, test_scope, '库存') + for pallet in pallets: + assert_record_in_scope(pallet, test_scope, '托盘') expected_ids = set(ordered_ids) inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories} pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets} @@ -327,15 +334,18 @@ class InboundService: warehouse = await MasterDao.get_warehouse(query_db, query_object.warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='目标仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: raise ServiceException(message='托盘不存在') + assert_record_in_scope(pallet, test_scope, '托盘') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: raise ServiceException(message='托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') cls._validate_pallet_pair(pallet, inventory) return PalletScanResponseModel( pallet_id=pallet.pallet_id, diff --git a/shierfeng-fastapi-backend/module_business/service/return_service.py b/shierfeng-fastapi-backend/module_business/service/return_service.py index 561d9b2..1d3706f 100644 --- a/shierfeng-fastapi-backend/module_business/service/return_service.py +++ b/shierfeng-fastapi-backend/module_business/service/return_service.py @@ -18,7 +18,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus from module_business.domain.pallet_state_machine import PalletStateMachine -from module_business.domain.test_batch_scope import TestBatchScope +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -256,6 +256,7 @@ class ReturnService: ) if return_order is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(return_order, test_scope, '退回单') return ReturnModel.model_validate(return_order) @classmethod @@ -346,9 +347,11 @@ class ReturnService: pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(pallet, test_scope, '托盘') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None or inventory.site_id is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在或不在客户现场') + assert_record_in_scope(inventory, test_scope, '库存') await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id) cls._validate_request_inventory(pallet, inventory, inventory.site_id) @@ -357,6 +360,7 @@ class ReturnService: filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') + assert_record_in_scope(filling, test_scope, '活动装填') cls._validate_active_filling(filling, inventory) return cls._scan_response(pallet, inventory, filling, ['request']) @@ -380,18 +384,22 @@ class ReturnService: ) if return_order is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(return_order, test_scope, '退回单') if return_order.status != ReturnStatus.REQUESTED.value: raise ServiceException(message='退回单当前状态不允许确认扫码') pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(pallet, test_scope, '托盘') matching_items = [item for item in return_order.items if item.pallet_id == pallet.pallet_id] if len(matching_items) != 1: cls._raise_pallet_not_accessible(current_user, '扫码托盘不属于该退回单') + assert_record_in_scope(matching_items[0], test_scope, '退回单明细') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') item = matching_items[0] cls._validate_confirm_snapshot(return_order, item, pallet, inventory) @@ -400,6 +408,7 @@ class ReturnService: filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') + assert_record_in_scope(filling, test_scope, '活动装填') cls._validate_active_filling(filling, inventory) return cls._scan_response(pallet, inventory, filling, ['confirm']) @@ -472,6 +481,8 @@ class ReturnService: cls._raise_pallet_not_accessible(current_user, '存在不存在或库存缺失的托盘') ordered_inventories = [inventory_by_id[pallet_id] for pallet_id in ordered_ids] + for inventory in ordered_inventories: + assert_record_in_scope(inventory, test_scope, '库存') if any(inventory.site_id != source_site_id for inventory in ordered_inventories): cls._raise_pallet_not_accessible(current_user, '同一退回单托盘必须属于同一现场') if any( @@ -485,6 +496,8 @@ class ReturnService: if set(pallet_by_id) != set(ordered_ids): cls._raise_pallet_not_accessible(current_user, '存在不存在或库存缺失的托盘') ordered_pallets = [pallet_by_id[pallet_id] for pallet_id in ordered_ids] + for pallet in ordered_pallets: + assert_record_in_scope(pallet, test_scope, '托盘') filling_by_id: dict[int, BizFillingRecord | None] = {} filling_ids = sorted( @@ -560,6 +573,7 @@ class ReturnService: ) if warehouse is None: raise ServiceException(message='目标仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') @@ -572,6 +586,7 @@ class ReturnService: ) if site is None: raise ServiceException(message='来源客户现场不存在') + assert_record_in_scope(site, test_scope, '现场') if site.status != '0': raise ServiceException(message='来源客户现场已停用') @@ -595,6 +610,7 @@ class ReturnService: cls._raise_return_not_accessible(current_user) if site is None: raise ServiceException(message='来源客户现场不存在') + assert_record_in_scope(site, test_scope, '现场') @classmethod async def _recover_request_integrity_error( @@ -620,6 +636,7 @@ class ReturnService: ) if recovered is None or not cls._is_exact_request_result(recovered, page_object, request_hash): raise ServiceException(message='申请退回失败,幂等冲突') + assert_record_in_scope(recovered, test_scope, '退回单') locked = await OperationDao.get_return_for_update( query_db, recovered.return_id, @@ -629,6 +646,7 @@ class ReturnService: ) if locked is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(locked, test_scope, '退回单') if not cls._is_exact_request_result( locked, page_object, @@ -689,6 +707,7 @@ class ReturnService: ) if locked_existing is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(locked_existing, test_scope, '退回单') if not cls._is_exact_request_result( locked_existing, page_object, @@ -721,6 +740,7 @@ class ReturnService: scope=test_scope, ) if fresh_existing is not None: + assert_record_in_scope(fresh_existing, test_scope, '退回单') if not cls._is_exact_request_result(fresh_existing, page_object, request_hash): raise ServiceException(message='申请退回失败,幂等键已被其他请求使用') fresh_existing_id = int(fresh_existing.return_id) @@ -863,6 +883,9 @@ class ReturnService: test_scope: TestBatchScope, ) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord]]: ordered_ids = sorted(item.pallet_id for item in items) + assert_record_in_scope(return_order, test_scope, '退回单') + for item in items: + assert_record_in_scope(item, test_scope, '退回单明细') inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories} @@ -876,6 +899,10 @@ class ReturnService: raise ServiceException(message='退回单存在不存在或库存缺失的托盘') ordered_inventories = [inventory_by_id[pallet_id] for pallet_id in ordered_ids] + for inventory in ordered_inventories: + assert_record_in_scope(inventory, test_scope, '库存') + for pallet in pallet_by_id.values(): + assert_record_in_scope(pallet, test_scope, '托盘') for pallet_id in ordered_ids: cls._validate_confirm_snapshot( return_order, @@ -896,6 +923,7 @@ class ReturnService: filling = await OperationDao.get_active_filling_for_update(query_db, filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') + assert_record_in_scope(filling, test_scope, '活动装填') filling_by_id[filling_id] = filling for inventory in ordered_inventories: if inventory.active_filling_id is not None: @@ -929,6 +957,7 @@ class ReturnService: cls._raise_return_not_accessible(current_user) if warehouse is None: raise ServiceException(message='目标仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if require_active and warehouse.status != '0': raise ServiceException(message='目标仓库已停用') @@ -955,6 +984,7 @@ class ReturnService: ) if route is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(route, test_scope, '退回单') recovered = await OperationDao.get_return_by_confirm_idempotency_key( query_db, page_object.idempotency_key, @@ -964,6 +994,7 @@ class ReturnService: ) if recovered is None: raise ServiceException(message='确认退回失败,幂等冲突') + assert_record_in_scope(recovered, test_scope, '退回单') if not cls._is_exact_confirm_result(recovered, return_id, page_object, request_hash): raise ServiceException(message='确认退回失败,幂等键已被其他请求使用') locked = await OperationDao.get_return_for_update( @@ -975,6 +1006,7 @@ class ReturnService: ) if locked is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(locked, test_scope, '退回单') if not cls._is_exact_confirm_result( locked, return_id, @@ -1019,6 +1051,7 @@ class ReturnService: ) if return_order is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(return_order, test_scope, '退回单') try: await AccessService.assert_warehouse_access( query_db, @@ -1041,6 +1074,8 @@ class ReturnService: request_hash, ): raise ServiceException(message='确认退回失败,幂等键已被其他请求使用') + if existing is not None: + assert_record_in_scope(existing, test_scope, '退回单') await query_db.rollback() await TestBatchAccessService.lock_active_batch(query_db, test_scope) @@ -1053,6 +1088,7 @@ class ReturnService: ) if locked is None: cls._raise_return_not_accessible(current_user) + assert_record_in_scope(locked, test_scope, '退回单') if locked.status == ReturnStatus.CONFIRMED.value: if cls._is_exact_confirm_result( locked, diff --git a/shierfeng-fastapi-backend/module_business/service/shipment_service.py b/shierfeng-fastapi-backend/module_business/service/shipment_service.py index e798d9d..6e9d00a 100644 --- a/shierfeng-fastapi-backend/module_business/service/shipment_service.py +++ b/shierfeng-fastapi-backend/module_business/service/shipment_service.py @@ -16,7 +16,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine -from module_business.domain.test_batch_scope import TestBatchScope +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -105,12 +105,14 @@ class ShipmentService: warehouse = await MasterDao.get_warehouse(query_db, source_warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='来源仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if warehouse.status != '0': raise ServiceException(message='来源仓库已停用') await AccessService.assert_site_access(query_db, current_user, target_site_id) site = await MasterDao.get_site(query_db, target_site_id, test_scope) if site is None: raise ServiceException(message='目标现场不存在') + assert_record_in_scope(site, test_scope, '现场') if site.status != '0': raise ServiceException(message='目标现场已停用') return warehouse, site @@ -166,6 +168,10 @@ class ShipmentService: ordered_ids = cls._validate_pallet_ids(pallet_ids) inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) + for inventory in inventories: + assert_record_in_scope(inventory, test_scope, '库存') + for pallet in pallets: + assert_record_in_scope(pallet, test_scope, '托盘') if ( len(inventories) != len(ordered_ids) or len(pallets) != len(ordered_ids) @@ -188,6 +194,7 @@ class ShipmentService: filling = await OperationDao.get_active_filling_for_update(query_db, filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') + assert_record_in_scope(filling, test_scope, '活动装填') fillings.append(filling) if len(fillings) != len(ordered_ids): raise ServiceException(message='托盘活动装填周期不完整') @@ -412,6 +419,7 @@ class ShipmentService: warehouse = await MasterDao.get_warehouse(query_db, query_object.source_warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='来源仓库不存在') + assert_record_in_scope(warehouse, test_scope, '仓库') if warehouse.status != '0': raise ServiceException(message='来源仓库已停用') await AccessService.assert_site_access( @@ -422,15 +430,18 @@ class ShipmentService: site = await MasterDao.get_site(query_db, query_object.target_site_id, test_scope) if site is None: raise ServiceException(message='目标现场不存在') + assert_record_in_scope(site, test_scope, '现场') if site.status != '0': raise ServiceException(message='目标现场已停用') pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(pallet, test_scope, '托盘') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') if inventory.pallet_id != pallet.pallet_id or inventory.warehouse_id != query_object.source_warehouse_id: cls._raise_pallet_not_accessible(current_user, '托盘不在来源仓库') if inventory.site_id is not None or inventory.current_line_id is not None: @@ -440,6 +451,7 @@ class ShipmentService: filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') + assert_record_in_scope(filling, test_scope, '活动装填') cls._validate_scan_pallet( pallet, inventory, @@ -629,7 +641,7 @@ class ShipmentService: ) @classmethod - async def _lock_and_authorize_routes( + async def _lock_and_authorize_routes( # noqa: PLR0912 cls, query_db: AsyncSession, current_user: CurrentUserModel, @@ -651,6 +663,8 @@ class ShipmentService: for_update=True, ) warehouses[warehouse_id] = warehouse + if warehouse is not None: + assert_record_in_scope(warehouse, test_scope, '仓库') try: await AccessService.assert_warehouse_access( query_db, @@ -671,6 +685,8 @@ class ShipmentService: for_update=True, ) sites[site_id] = site + if site is not None: + assert_record_in_scope(site, test_scope, '现场') try: await AccessService.assert_site_access( query_db, @@ -749,6 +765,7 @@ class ShipmentService: ) if current is None: cls._raise_shipment_not_accessible(current_user) + assert_record_in_scope(current, test_scope, '发货单') recovered = await OperationDao.get_shipment_by_confirm_idempotency_key( query_db, idempotency_key, @@ -758,6 +775,7 @@ class ShipmentService: ) if recovered is None: raise ServiceException(message='确认发货失败,幂等冲突') + assert_record_in_scope(recovered, test_scope, '发货单') if not cls._is_exact_confirmed( recovered, shipment_id, @@ -774,6 +792,7 @@ class ShipmentService: ) if locked_recovered is None: cls._raise_shipment_not_accessible(current_user) + assert_record_in_scope(locked_recovered, test_scope, '发货单') if not cls._is_exact_confirmed( locked_recovered, shipment_id, @@ -814,6 +833,7 @@ class ShipmentService: ) if shipment is None: cls._raise_shipment_not_accessible(current_user) + assert_record_in_scope(shipment, test_scope, '发货单') request_hash = cls._build_confirm_request_hash(shipment_id) idempotency_key = page_object.idempotency_key @@ -841,6 +861,7 @@ class ShipmentService: ) if locked_shipment is None: cls._raise_shipment_not_accessible(current_user) + assert_record_in_scope(locked_shipment, test_scope, '发货单') if locked_shipment.status == DocumentStatus.CONFIRMED.value: if cls._is_exact_confirmed( locked_shipment, diff --git a/shierfeng-fastapi-backend/module_business/service/usage_service.py b/shierfeng-fastapi-backend/module_business/service/usage_service.py index 8ae8dd6..493bfa7 100644 --- a/shierfeng-fastapi-backend/module_business/service/usage_service.py +++ b/shierfeng-fastapi-backend/module_business/service/usage_service.py @@ -18,7 +18,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine -from module_business.domain.test_batch_scope import TestBatchScope +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.do.operation_do import BizPalletEvent, BizUsageRecord from module_business.entity.vo.master_vo import LinePageQueryModel from module_business.entity.vo.operation_vo import ( @@ -165,6 +165,7 @@ class UsageService: cls._raise_usage_not_accessible(current_user) if site is None: raise ServiceException(message='客户现场不存在') + assert_record_in_scope(site, test_scope, '现场') @classmethod async def _recover_integrity_error( @@ -189,6 +190,7 @@ class UsageService: ) if recovered is None: raise ServiceException(message='使用失败,数据冲突') + assert_record_in_scope(recovered, test_scope, '使用记录') if recovered.site_id != expected_site_id or not cls._is_exact_idempotent_result( recovered, page_object, request_hash ): @@ -255,6 +257,7 @@ class UsageService: ) if usage is None: cls._raise_usage_not_accessible(current_user) + assert_record_in_scope(usage, test_scope, '使用记录') return UsageModel.model_validate(usage) @classmethod @@ -270,6 +273,7 @@ class UsageService: site = await MasterDao.get_site(query_db, site_id, test_scope) if site is None: raise ServiceException(message='客户现场不存在') + assert_record_in_scope(site, test_scope, '现场') if site.status != '0': raise ServiceException(message='客户现场已停用') lines = await MasterDao.list_line( @@ -301,9 +305,11 @@ class UsageService: pallet = await InventoryDao.get_pallet_by_qr_token(query_db, normalized_qr, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(pallet, test_scope, '托盘') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') if inventory.site_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场') await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id) @@ -328,6 +334,7 @@ class UsageService: filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') + assert_record_in_scope(filling, test_scope, '活动装填') if ( filling.filling_id != inventory.active_filling_id or filling.pallet_id != pallet.pallet_id @@ -369,6 +376,8 @@ class UsageService: preflight_site_ids = await cls._site_scope(query_db, current_user) # 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。 preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope) + if preflight_line is not None: + assert_record_in_scope(preflight_line, test_scope, '产线') preflight_site_id = preflight_line.site_id if preflight_line is not None else None existing = None if preflight_site_id is not None and ( @@ -381,6 +390,7 @@ class UsageService: site_ids=[preflight_site_id], ) if existing is not None: + assert_record_in_scope(existing, test_scope, '使用记录') if not cls._is_exact_idempotent_result(existing, page_object, request_hash): raise ServiceException(message='使用失败,幂等键已被其他请求使用') if preflight_site_ids is not None and existing.site_id not in preflight_site_ids: @@ -402,6 +412,7 @@ class UsageService: ) if scanned_pallet is None: cls._raise_pallet_not_accessible(current_user) + assert_record_in_scope(scanned_pallet, test_scope, '托盘') scanned_pallet_id = int(scanned_pallet.pallet_id) await query_db.rollback() await TestBatchAccessService.lock_active_batch(query_db, test_scope) @@ -413,6 +424,7 @@ class UsageService: ) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') + assert_record_in_scope(inventory, test_scope, '库存') if inventory.site_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场') expected_site_id = inventory.site_id @@ -429,6 +441,7 @@ class UsageService: if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id: cls._raise_pallet_not_accessible(current_user) pallet = locked_pallets[0] + assert_record_in_scope(pallet, test_scope, '托盘') filling = ( await OperationDao.get_active_filling_for_update( @@ -446,6 +459,7 @@ class UsageService: site_ids=[inventory.site_id], ) if locked_existing is not None: + assert_record_in_scope(locked_existing, test_scope, '使用记录') if locked_existing.site_id != inventory.site_id or not cls._is_exact_idempotent_result( locked_existing, page_object, @@ -466,6 +480,7 @@ class UsageService: raise ServiceException(message='托盘不存在活动装填周期') if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') + assert_record_in_scope(filling, test_scope, '活动装填') site = await MasterDao.get_site(query_db, inventory.site_id, test_scope, for_update=True) await cls._assert_pallet_site_access( @@ -476,11 +491,13 @@ class UsageService: ) if site is None: raise ServiceException(message='客户现场不存在') + assert_record_in_scope(site, test_scope, '现场') if site.status != '0': raise ServiceException(message='客户现场已停用') line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True) if line is None: raise ServiceException(message='产线不存在') + assert_record_in_scope(line, test_scope, '产线') if line.status != '0': raise ServiceException(message='产线已停用') if line.site_id != inventory.site_id: @@ -540,6 +557,7 @@ class UsageService: test_batch_id=test_scope.batch_id, ) usage = await OperationDao.add_usage_record(query_db, usage) + assert_record_in_scope(usage, test_scope, '使用记录') usage_id = usage.usage_id result = cls._success(usage) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py b/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py index 1ff45b6..83630dc 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py @@ -37,6 +37,8 @@ from module_business.entity.vo.operation_vo import ( ) from module_business.service.access_service import AccessService from module_business.service.filling_service import FillingService +from module_business.service.test_batch_access_service import TestBatchAccessService +from tests.module_business.test_batch_fixtures import make_test_scope def integrity_error() -> IntegrityError: @@ -78,6 +80,7 @@ def make_pallet( qr_token: str = 'qr-2', capacity_packages: int = 20, asset_status: str = '0', + test_batch_id: int | None = None, ) -> BizPallet: return BizPallet( pallet_id=pallet_id, @@ -85,6 +88,7 @@ def make_pallet( qr_token=qr_token, capacity_packages=capacity_packages, asset_status=asset_status, + test_batch_id=test_batch_id, ) @@ -99,6 +103,7 @@ def make_inventory( remaining_packages: int = 0, last_event_id: int | None = 40, version: int = 3, + test_batch_id: int | None = None, ) -> BizPalletInventory: return BizPalletInventory( pallet_id=pallet_id, @@ -111,6 +116,7 @@ def make_inventory( last_event_id=last_event_id, version=version, updated_at=datetime(2026, 7, 25, 9, 0), + test_batch_id=test_batch_id, ) @@ -120,12 +126,14 @@ def make_strain( status: str = '0', code: str = 'S-011', name: str = '香菇 11', + test_batch_id: int | None = None, ) -> BizStrain: return BizStrain( strain_id=strain_id, strain_code=code, strain_name=name, status=status, + test_batch_id=test_batch_id, ) @@ -135,6 +143,7 @@ def make_filling( warehouse_id: int = 7, idempotency_key: str = 'fill-key', request_hash: str | None = None, + test_batch_id: int | None = None, ) -> BizFillingRecord: page = make_page(warehouse_id=warehouse_id, idempotency_key=idempotency_key) return BizFillingRecord( @@ -157,6 +166,7 @@ def make_filling( create_time=datetime(2026, 7, 26, 10, 30), update_by='user-7', update_time=datetime(2026, 7, 26, 10, 30), + test_batch_id=test_batch_id, ) @@ -392,6 +402,24 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): await FillingService.scan_filling_pallet_services(self.db, 'qr-2', make_user(1), TestBatchScope.formal()) self.assertEqual('托盘库存不存在', raised.exception.message) + async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None: + scope = make_test_scope(4) + with ( + patch.object( + InventoryDao, + 'get_pallet_by_qr_token', + new=AsyncMock(return_value=make_pallet(test_batch_id=5)), + ), + patch.object( + MasterDao, + 'get_pallet_inventory', + new=AsyncMock(return_value=make_inventory(test_batch_id=4)), + ), + patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user, scope) + class TestFillPalletService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: @@ -401,7 +429,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): self.pallet = make_pallet() self.inventory = make_inventory() self.strain = make_strain() - self.warehouse = SimpleNamespace(warehouse_id=7, status='0') + self.warehouse = SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None) self.fixed_time = datetime(2026, 7, 26, 10, 30) async def test_fill_locks_inventory_then_pallet_and_writes_one_atomic_event( # noqa: PLR0915 @@ -549,6 +577,29 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): self.assertEqual('装填成功', result.message) self.assertEqual(31, result.result) + async def test_test_scope_write_rejects_cross_batch_pallet_and_rolls_back(self) -> None: + scope = make_test_scope(4) + add_filling = AsyncMock() + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock()), + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), + patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), + patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)), + patch.object( + InventoryDao, + 'get_pallet_by_qr_token', + new=AsyncMock(return_value=make_pallet(test_batch_id=5)), + ), + patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()), + patch.object(OperationDao, 'add_filling_record', new=add_filling), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await FillingService.fill_pallet_services(self.db, self.page, self.user, scope) + + add_filling.assert_not_awaited() + self.db.commit.assert_not_awaited() + self.db.rollback.assert_awaited_once() + async def _assert_fill_rejected( self, *, @@ -597,7 +648,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace: actions.append('warehouse-read') - return SimpleNamespace(warehouse_id=7, status='1') + return SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None) warehouse_read = AsyncMock(side_effect=lock_warehouse) strain_read = AsyncMock() 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 d10b31f..ad8d489 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py @@ -54,6 +54,7 @@ from module_business.entity.vo.operation_vo import ( from module_business.service import inbound_service as inbound_service_module from module_business.service.access_service import AccessService from module_business.service.inbound_service import InboundService +from tests.module_business.test_batch_fixtures import make_test_scope def integrity_error() -> IntegrityError: @@ -75,6 +76,7 @@ def make_pallet(pallet_id: int, *, asset_status: str = '0') -> BizPallet: qr_token=f'qr-{pallet_id}', capacity_packages=20, asset_status=asset_status, + test_batch_id=None, ) @@ -88,6 +90,7 @@ def make_inventory( active_filling_id: int | None = None, remaining_packages: int = 0, version: int = 3, + test_batch_id: int | None = None, ) -> BizPalletInventory: return BizPalletInventory( pallet_id=pallet_id, @@ -98,6 +101,7 @@ def make_inventory( active_filling_id=active_filling_id, remaining_packages=remaining_packages, version=version, + test_batch_id=test_batch_id, ) @@ -109,6 +113,7 @@ def make_inbound( status: str = 'DRAFT', idempotency_key: str | None = None, request_hash: str | None = None, + test_batch_id: int | None = None, ) -> BizInboundOrder: inbound = BizInboundOrder( inbound_id=inbound_id, @@ -117,12 +122,14 @@ def make_inbound( status=status, confirm_idempotency_key=idempotency_key, confirm_request_hash=request_hash, + test_batch_id=test_batch_id, ) inbound.items = [ BizInboundItem( inbound_item_id=index, inbound_id=inbound_id, pallet_id=pallet_id, + test_batch_id=test_batch_id, ) for index, pallet_id in enumerate(pallet_ids, start=1) ] @@ -228,7 +235,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)), patch.object( @@ -275,6 +282,32 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): self.assertTrue(result.is_success) self.db.rollback.assert_not_awaited() + async def test_add_rejects_cross_batch_locked_inventory_and_rolls_back(self) -> None: + inventory = make_inventory(2) + inventory.test_batch_id = None + pallet = make_pallet(2) + pallet.test_batch_id = 4 + with ( + patch.object(inbound_service_module.TestBatchAccessService, 'lock_active_batch', new=AsyncMock()), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=4)), + ), + patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), + patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])), + patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[pallet])), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await InboundService.add_inbound_services( + self.db, + InboundCreateModel(warehouseId=7, palletIds=[2]), + self.user, + make_test_scope(4), + ) + self.db.rollback.assert_awaited_once() + self.db.commit.assert_not_awaited() + async def test_add_rejects_invalid_pallet_before_any_order_or_event_write(self) -> None: page = InboundCreateModel(warehouseId=7, palletIds=[2, 5]) invalid_inventory = make_inventory(5, warehouse_id=7) @@ -285,7 +318,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object( @@ -316,7 +349,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): lock = AsyncMock() cases = ( (None, ServiceException), - (SimpleNamespace(warehouse_id=7, status='1'), ServiceException), + (SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None), ServiceException), ) for warehouse, exception_type in cases: with self.subTest(warehouse=warehouse): @@ -338,7 +371,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object( AccessService, @@ -386,7 +419,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): replace_items = AsyncMock() warehouse_mapping = AsyncMock(side_effect=warehouse_access) warehouse_read = AsyncMock( - return_value=SimpleNamespace(warehouse_id=7, status='0'), + return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None), ) with ( patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), @@ -425,7 +458,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=8, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=8, status='0', test_batch_id=None)), ), patch.object( InventoryDao, @@ -497,6 +530,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): side_effect=lambda _db, warehouse_id, _scope, **_kwargs: SimpleNamespace( warehouse_id=warehouse_id, status='0', + test_batch_id=None, ) ), ), @@ -655,7 +689,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object( @@ -680,7 +714,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())), @@ -830,9 +864,9 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None: warehouses = [ - SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0'), - SimpleNamespace(warehouse_id=3, warehouse_code='W3', warehouse_name='三仓', status='1'), - SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0'), + SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0', test_batch_id=None), + SimpleNamespace(warehouse_id=3, warehouse_code='W3', warehouse_name='三仓', status='1', test_batch_id=None), + SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0', test_batch_id=None), ] with ( patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)) as list_warehouse, @@ -849,8 +883,8 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): async def test_admin_warehouse_options_include_all_active_records(self) -> None: warehouses = [ - SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0'), - SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0'), + SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0', test_batch_id=None), + SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0', test_batch_id=None), ] scope = AsyncMock() with ( @@ -894,7 +928,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)) as get_pallet, patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)) as get_inventory, @@ -938,7 +972,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)), @@ -969,7 +1003,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): ) get_pallet.assert_not_awaited() - for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1')): + for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None)): with self.subTest(warehouse=warehouse): get_pallet.reset_mock() with ( @@ -1079,7 +1113,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace: actions.append('warehouse-lock') - return SimpleNamespace(warehouse_id=7, status='0') + return SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None) def transition(current: PalletFlowState, target: PalletFlowState) -> None: actions.append(f'transition-{current.value}-{target.value}') @@ -1282,7 +1316,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ) as warehouse_lock, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), patch.object(OperationDao, 'update_inbound', new=update), @@ -1343,7 +1377,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(PalletStateMachine, 'ensure_transition', side_effect=ValueError('invalid')), patch.object(OperationDao, 'update_inbound', new=update), @@ -1378,7 +1412,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ) as warehouse_lock, ): result = await InboundService.confirm_inbound_services( @@ -1439,7 +1473,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), ): @@ -1486,7 +1520,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None)), ) as warehouse_lock, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), self.assertRaises(PermissionException) as raised, @@ -1564,7 +1598,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), @@ -1600,7 +1634,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), @@ -1674,7 +1708,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), ): @@ -1737,7 +1771,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ) as warehouse_lock, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), @@ -1777,7 +1811,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_warehouse', - new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), + new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)), ), patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), 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 9eb5a0a..3ac208a 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py @@ -45,6 +45,7 @@ from module_business.entity.vo.operation_vo import ( ) from module_business.service.access_service import AccessService from module_business.service.test_batch_access_service import TestBatchAccessService +from tests.module_business.test_batch_fixtures import make_test_scope RETURN_SERVICE_SPEC = importlib.util.find_spec('module_business.service.return_service') RETURN_CONTROLLER_SPEC = importlib.util.find_spec('module_business.controller.return_controller') @@ -70,13 +71,20 @@ def make_user(user_id: int = 7) -> CurrentUserModel: ) -def make_pallet(pallet_id: int, *, capacity: int = 20, asset_status: str = '0') -> BizPallet: +def make_pallet( + pallet_id: int, + *, + capacity: int = 20, + asset_status: str = '0', + test_batch_id: int | None = None, +) -> BizPallet: return BizPallet( pallet_id=pallet_id, pallet_code=f'P-{pallet_id:03d}', qr_token=f'qr-{pallet_id}', capacity_packages=capacity, asset_status=asset_status, + test_batch_id=test_batch_id, ) @@ -90,6 +98,7 @@ def make_inventory( filling_id: int | None = -1, state: str = PalletFlowState.AT_SITE_PARTIAL.value, version: int = 3, + test_batch_id: int | None = None, ) -> BizPalletInventory: active_filling_id = (100 + pallet_id if remaining > 0 else None) if filling_id == -1 else filling_id return BizPalletInventory( @@ -101,10 +110,16 @@ def make_inventory( remaining_packages=remaining, flow_state=state, version=version, + test_batch_id=test_batch_id, ) -def make_filling(pallet_id: int, *, warehouse_id: int = 3) -> BizFillingRecord: +def make_filling( + pallet_id: int, + *, + warehouse_id: int = 3, + test_batch_id: int | None = None, +) -> BizFillingRecord: return BizFillingRecord( filling_id=100 + pallet_id, filling_no=f'FI-{pallet_id}', @@ -121,6 +136,7 @@ def make_filling(pallet_id: int, *, warehouse_id: int = 3) -> BizFillingRecord: request_hash='f' * 64, operator_id=7, operated_at=datetime(2026, 7, 1), + test_batch_id=test_batch_id, ) @@ -133,6 +149,7 @@ def make_return_order( request_hash: str = 'r' * 64, confirm_key: str | None = None, confirm_hash: str | None = None, + test_batch_id: int | None = None, ) -> BizReturnOrder: order = BizReturnOrder( return_id=return_id, @@ -146,6 +163,7 @@ def make_return_order( confirm_request_hash=confirm_hash, requested_by=8, requested_at=datetime(2026, 7, 25, 9), + test_batch_id=test_batch_id, ) order.items = [ BizReturnItem( @@ -154,6 +172,7 @@ def make_return_order( pallet_id=pallet_id, active_filling_id_snapshot=filling_id, remaining_packages_snapshot=remaining, + test_batch_id=test_batch_id, ) for index, (pallet_id, filling_id, remaining) in enumerate(snapshots, start=1) ] @@ -573,6 +592,35 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(8, actual.remaining_packages) self.assertEqual(['request'], actual.allowed_actions) + async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None: + scope = make_test_scope(4) + with ( + patch.object( + InventoryDao, + 'get_pallet_by_qr_token', + new=AsyncMock(return_value=make_pallet(2, test_batch_id=5)), + ), + patch.object( + MasterDao, + 'get_pallet_inventory', + new=AsyncMock(return_value=make_inventory(2, test_batch_id=4)), + ), + patch.object(AccessService, 'assert_site_access', new=AsyncMock()), + patch.object( + OperationDao, + 'get_filling_by_id', + new=AsyncMock(return_value=make_filling(2, test_batch_id=4)), + ), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await ReturnService.scan_return_pallet_services( + self.db, + 'qr-2', + ReturnScanQueryModel(mode='request'), + self.user, + scope, + ) + async def test_confirm_scan_is_scoped_to_target_warehouse_and_exact_item_snapshot(self) -> None: order = make_return_order(snapshots=((2, 102, 8),)) pallet = make_pallet(2) @@ -687,11 +735,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace: actions.append('warehouse-row') - return SimpleNamespace(status='0') + return SimpleNamespace(status='0', test_batch_id=None) async def lock_site(*_args: Any, **_kwargs: Any) -> SimpleNamespace: actions.append('site-row') - return SimpleNamespace(status='0') + return SimpleNamespace(status='0', test_batch_id=None) async def lock_site_mapping(*_args: Any, **_kwargs: Any) -> None: actions.append('site-mapping') @@ -772,8 +820,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): ), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(OperationDao, 'add_return_order', new=AsyncMock(return_value=probe)), patch.object(OperationDao, 'add_return_items', new=AsyncMock(side_effect=add_items)), patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), @@ -822,8 +878,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(return_value=filling)), patch.object(OperationDao, 'get_return_for_update', new=order_lock), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=4)), + ), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=4)), + ), patch.object(OperationDao, 'add_return_order', new=AsyncMock(side_effect=integrity_error())), ): result = await ReturnService.request_return_services(self.db, self.page, self.user, scope) @@ -886,7 +950,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): async def lock_site(*_args: Any, **_kwargs: Any) -> SimpleNamespace: actions.append('site-lock') - return SimpleNamespace(status='0') + return SimpleNamespace(status='0', test_batch_id=None) key_read = AsyncMock(side_effect=read_key) scope_read = AsyncMock(side_effect=read_scope) @@ -1010,7 +1074,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=relocated)), patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks), patch.object(OperationDao, 'get_active_filling_for_update', new=filling_locks), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order, patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event, ): @@ -1105,7 +1173,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 'get_active_filling_for_update', new=AsyncMock(return_value=make_filling(2)), ), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), ): result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) @@ -1192,7 +1264,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): new=AsyncMock(return_value=probe), ), patch.object(OperationDao, 'get_return_for_update', new=order_lock), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as inventory_locks, ): result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) @@ -1271,8 +1347,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 'get_active_filling_for_update', new=AsyncMock(return_value=make_filling(5)), ), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), ): try: await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) @@ -1480,7 +1564,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): ), patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks, ): result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) @@ -1567,7 +1655,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[10, 9])), patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read), patch.object(OperationDao, 'get_return_for_update', new=order_lock), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), ): result = await ReturnService._recover_request_integrity_error( @@ -1604,7 +1696,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=get_by_key), patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), ): result = await ReturnService.request_return_services(self.db, self.page, admin, TestBatchScope.formal()) @@ -1822,7 +1918,11 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): ), patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks, ): result = await ReturnService.confirm_return_services( @@ -1871,7 +1971,11 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): ), patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), ): result = await ReturnService.confirm_return_services(self.db, 13, self.page, admin, TestBatchScope.formal()) @@ -1916,7 +2020,11 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_return_by_id', new=route_read), patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=key_read), patch.object(OperationDao, 'get_return_for_update', new=order_lock), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), ): result = await ReturnService._recover_confirm_integrity_error( 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 5da12fd..8b516e6 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py @@ -55,6 +55,8 @@ from module_business.entity.vo.operation_vo import ( from module_business.service import shipment_service as shipment_service_module from module_business.service.access_service import AccessService from module_business.service.shipment_service import ShipmentService +from module_business.service.test_batch_access_service import TestBatchAccessService +from tests.module_business.test_batch_fixtures import make_test_scope def make_user(user_id: int = 7) -> CurrentUserModel: @@ -69,13 +71,20 @@ def integrity_error() -> IntegrityError: return IntegrityError('statement', {}, RuntimeError('constraint')) -def make_pallet(pallet_id: int, *, capacity: int = 20, asset_status: str = '0') -> BizPallet: +def make_pallet( + pallet_id: int, + *, + capacity: int = 20, + asset_status: str = '0', + test_batch_id: int | None = None, +) -> BizPallet: return BizPallet( pallet_id=pallet_id, pallet_code=f'P-{pallet_id:03d}', qr_token=f'qr-{pallet_id}', capacity_packages=capacity, asset_status=asset_status, + test_batch_id=test_batch_id, ) @@ -89,6 +98,7 @@ def make_inventory( remaining_packages: int = 8, flow_state: str = 'FILLED_IN_WAREHOUSE', version: int = 3, + test_batch_id: int | None = None, ) -> BizPalletInventory: return BizPalletInventory( pallet_id=pallet_id, @@ -99,10 +109,16 @@ def make_inventory( remaining_packages=remaining_packages, flow_state=flow_state, version=version, + test_batch_id=test_batch_id, ) -def make_filling(pallet_id: int, *, warehouse_id: int = 7) -> BizFillingRecord: +def make_filling( + pallet_id: int, + *, + warehouse_id: int = 7, + test_batch_id: int | None = None, +) -> BizFillingRecord: return BizFillingRecord( filling_id=100 + pallet_id, filling_no=f'FI-{pallet_id}', @@ -119,6 +135,7 @@ def make_filling(pallet_id: int, *, warehouse_id: int = 7) -> BizFillingRecord: request_hash='f' * 64, operator_id=7, operated_at=SimpleNamespace(), + test_batch_id=test_batch_id, ) @@ -131,6 +148,7 @@ def make_shipment( status: str = 'DRAFT', idempotency_key: str | None = None, request_hash: str | None = None, + test_batch_id: int | None = None, ) -> BizShipmentOrder: shipment = BizShipmentOrder( shipment_id=shipment_id, @@ -140,12 +158,14 @@ def make_shipment( status=status, confirm_idempotency_key=idempotency_key, confirm_request_hash=request_hash, + test_batch_id=test_batch_id, ) shipment.items = [ BizShipmentItem( shipment_item_id=index, shipment_id=shipment_id, pallet_id=pallet_id, + test_batch_id=test_batch_id, ) for index, pallet_id in enumerate(pallet_ids, start=1) ] @@ -756,6 +776,58 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): ) self.assertEqual('无权访问该托盘', raised.exception.message) + async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None: + scope = make_test_scope(4) + query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9) + with ( + patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), + patch.object(AccessService, 'assert_site_access', new=AsyncMock()), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock( + return_value=BizWarehouse( + warehouse_id=7, + warehouse_code='W-7', + warehouse_name='七号仓', + status='0', + test_batch_id=4, + ) + ), + ), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock( + return_value=BizCustomerSite( + site_id=9, + customer_id=1, + site_code='S-9', + site_name='九号现场', + status='0', + test_batch_id=4, + ) + ), + ), + patch.object( + InventoryDao, + 'get_pallet_by_qr_token', + new=AsyncMock(return_value=make_pallet(2, test_batch_id=5)), + ), + patch.object( + MasterDao, + 'get_pallet_inventory', + new=AsyncMock(return_value=make_inventory(2, test_batch_id=4)), + ), + patch.object( + OperationDao, + 'get_filling_by_id', + new=AsyncMock(return_value=make_filling(2, test_batch_id=4)), + ), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await ShipmentService.scan_shipment_pallet_services(self.db, 'qr-2', query, self.user, scope) + class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: @@ -792,6 +864,58 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual([pallet], pallets) self.assertEqual([origin_filling], fillings) + async def test_test_scope_write_rejects_cross_batch_inventory_and_rolls_back(self) -> None: + scope = make_test_scope(4) + page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[2]) + add_shipment = AsyncMock() + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock()), + patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), + patch.object(AccessService, 'assert_site_access', new=AsyncMock()), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock( + return_value=BizWarehouse( + warehouse_id=7, + warehouse_code='W-7', + warehouse_name='七号仓', + status='0', + test_batch_id=4, + ) + ), + ), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock( + return_value=BizCustomerSite( + site_id=9, + customer_id=1, + site_code='S-9', + site_name='九号现场', + status='0', + test_batch_id=4, + ) + ), + ), + patch.object( + InventoryDao, + 'get_inventories_for_update', + new=AsyncMock(return_value=[make_inventory(2, test_batch_id=5)]), + ), + patch.object( + InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2, test_batch_id=4)]) + ), + patch.object(OperationDao, 'add_shipment', new=add_shipment), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await ShipmentService.add_shipment_services(self.db, page, self.user, scope) + + add_shipment.assert_not_awaited() + self.db.commit.assert_not_awaited() + self.db.rollback.assert_awaited_once() + 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_usage_service.py b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py index e278504..6e4c77c 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py @@ -100,6 +100,7 @@ def make_pallet( qr_token: str = 'qr-2', capacity_packages: int = 20, asset_status: str = '0', + test_batch_id: int | None = None, ) -> BizPallet: return BizPallet( pallet_id=pallet_id, @@ -107,6 +108,7 @@ def make_pallet( qr_token=qr_token, capacity_packages=capacity_packages, asset_status=asset_status, + test_batch_id=test_batch_id, ) @@ -119,6 +121,7 @@ def make_inventory( remaining_packages: int = 8, flow_state: str = PalletFlowState.AT_SITE_UNUSED.value, version: int = 3, + test_batch_id: int | None = None, ) -> BizPalletInventory: return BizPalletInventory( pallet_id=pallet_id, @@ -131,6 +134,7 @@ def make_inventory( last_event_id=40, version=version, updated_at=datetime(2026, 7, 25, 9, 0), + test_batch_id=test_batch_id, ) @@ -139,6 +143,7 @@ def make_filling( *, pallet_id: int = 2, active_slot: str | None = 'ACTIVE', + test_batch_id: int | None = None, ) -> BizFillingRecord: return BizFillingRecord( filling_id=filling_id, @@ -157,16 +162,23 @@ def make_filling( request_hash='f' * 64, operator_id=7, operated_at=datetime(2026, 7, 25, 8, 0), + test_batch_id=test_batch_id, ) -def make_site(site_id: int = 9, *, status: str = '0') -> BizCustomerSite: +def make_site( + site_id: int = 9, + *, + status: str = '0', + test_batch_id: int | None = None, +) -> BizCustomerSite: return BizCustomerSite( site_id=site_id, customer_id=3, site_code=f'SITE-{site_id}', site_name=f'现场 {site_id}', status=status, + test_batch_id=test_batch_id, ) @@ -175,6 +187,7 @@ def make_line( *, site_id: int = 9, status: str = '0', + test_batch_id: int | None = None, ) -> BizProductionLine: return BizProductionLine( line_id=line_id, @@ -182,6 +195,7 @@ def make_line( line_code=f'L-{line_id}', line_name=f'产线 {line_id}', status=status, + test_batch_id=test_batch_id, ) @@ -192,6 +206,7 @@ def make_usage( line_id: int = 4, idempotency_key: str = 'usage-key', request_hash: str = 'a' * 64, + test_batch_id: int | None = None, ) -> BizUsageRecord: return BizUsageRecord( usage_id=usage_id, @@ -206,6 +221,7 @@ def make_usage( request_hash=request_hash, operator_id=7, occurred_at=datetime(2026, 7, 26, 11, 30), + test_batch_id=test_batch_id, ) @@ -519,6 +535,29 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): self.assertEqual(5, result.remaining_packages) self.assertEqual(['usage'], result.allowed_actions) + async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None: + scope = make_test_scope(4) + with ( + patch.object( + InventoryDao, + 'get_pallet_by_qr_token', + new=AsyncMock(return_value=make_pallet(test_batch_id=5)), + ), + patch.object( + MasterDao, + 'get_pallet_inventory', + new=AsyncMock(return_value=make_inventory(test_batch_id=4)), + ), + patch.object(AccessService, 'assert_site_access', new=AsyncMock()), + patch.object( + OperationDao, + 'get_filling_by_id', + new=AsyncMock(return_value=make_filling(test_batch_id=4)), + ), + self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), + ): + await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, scope) + async def test_scan_hides_missing_non_site_and_unauthorized_qr_for_normal_user(self) -> None: with ( patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), @@ -1683,6 +1722,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): self.expired = False self.pallet_id_reads = 0 self._pallet_id = pallet_id + self.test_batch_id = None @property def pallet_id(self) -> int: