From a870b920fe358ec13e793596b96305ea4b813b16 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 14:22:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/20261016-数据库变更汇总.md | 2 +- .../V8__chargeable_weight_confirmation.sql | 2 +- ...ansfer_measurement_and_compact_numbers.sql | 4 +- .../V31__align_physical_pallet_guards.sql | 49 +++++ .../controller/LastlegOrderController.java | 9 + .../service/DeliveryTransferService.java | 63 +++++- .../discard/dto/CargoDiscardResponses.java | 8 +- .../mapper/CargoDiscardDimensionMapper.java | 11 ++ .../mapper/CargoDiscardInventoryMapper.java | 8 +- .../discard/model/CargoDiscardQuantities.java | 9 +- .../model/CargoDiscardStockCartonRow.java | 1 + .../discard/pda/dto/PdaCargoDiscardDtos.java | 3 +- .../pda/service/PdaCargoDiscardService.java | 79 ++++---- .../CargoDiscardDimensionRepository.java | 21 ++ .../repository/CargoDiscardPdaRepository.java | 46 +++++ .../CargoDiscardQuantityRepository.java | 5 +- .../CargoDiscardStockRepository.java | 47 +++++ .../service/CargoDiscardOutboundService.java | 15 +- .../discard/service/CargoDiscardService.java | 18 +- .../CargoDiscardStatisticsService.java | 10 +- .../service/CargoDiscardStockService.java | 184 ++++++++++++++---- .../dispatch/mapper/DispatchBolMapper.java | 5 - .../DispatchPickOrderAttributionService.java | 4 +- .../port/DispatchSourceAllocation.java | 4 + .../service/DispatchItemSourceService.java | 12 +- .../service/DispatchPickingService.java | 2 +- .../lastleg/dto/LastlegOrderSummary.java | 4 +- .../fleet/pickup/FleetPickupMapper.java | 14 +- .../fleet/pickup/FleetPickupRepository.java | 5 +- .../fleet/pickup/FleetPickupService.java | 23 +-- .../fleet/pickup/FleetPickupSource.java | 20 ++ .../controller/InventoryQueryController.java | 10 + .../query/dto/InventoryPieceProjection.java | 14 ++ .../query/mapper/InventoryPieceMapper.java | 73 +++++++ .../query/mapper/InventoryQueryMapper.java | 4 +- .../repository/InventoryPieceRepository.java | 15 ++ .../query/service/InventoryPieceService.java | 71 +++++++ ...formPalletOperationRecoveryController.java | 24 +++ .../mapper/PalletOperationRecoveryMapper.java | 25 +++ .../mapper/PdaPalletOperationMapper.java | 10 +- .../PalletOperationRecoveryRepository.java | 24 +++ .../PalletOperationRecoveryService.java | 58 ++++++ .../service/PdaPalletOperationService.java | 44 ++++- .../lastleg/query/LastlegOrderFilter.java | 52 +++++ .../RelabelApplicationController.java | 10 + .../pda/service/PdaRelabelService.java | 9 +- .../repository/RelabelQuantityRepository.java | 31 +++ .../service/RelabelPalletDimensions.java | 65 +++++++ .../service/RelabelQuantityService.java | 24 ++- .../MerchantReturnOrderController.java | 7 + ...atformReturnOrderStatisticsController.java | 23 +++ .../dto/ReturnOrderStatistics.java | 13 ++ .../mapper/ReturnOrderStatisticsMapper.java | 43 ++++ .../ReturnOrderStatisticsRepository.java | 27 +++ .../service/ReturnOrderStatisticsService.java | 21 ++ .../lastleg/service/LastlegOrderService.java | 53 ++--- .../MerchantWarehouseTransferController.java | 8 + .../transfer/dto/TransferQuantityTotals.java | 8 + .../transfer/dto/TransferQuantityView.java | 6 + .../dto/WarehouseTransferRequests.java | 5 +- .../mapper/TransferQuantityMapper.java | 78 ++++++++ .../TransferQuantityRepository.java | 28 +++ .../service/TransferQuantityService.java | 87 +++++++++ .../service/WarehouseTransferService.java | 61 ++++-- .../openapi/cargo-discard-photos-v1.yaml | 3 +- .../openapi/cargo-discard-statistics-v1.yaml | 94 +++++++++ .../openapi/relabel-quantities-v1.yaml | 40 +++- .../openapi/return-order-statistics-v1.yaml | 60 ++++++ .../warehouse-transfer-quantities-v1.yaml | 120 ++++++++++++ .../service/RelabelOutboundPickingTest.java | 118 +++++++++++ .../CargoDiscardTransferCartonMapperTest.java | 50 ++++- .../CargoDiscardQueryBindingTest.java | 43 ++++ .../service/PdaCargoDiscardServiceTest.java | 39 +++- .../CargoDiscardDimensionRepositoryTest.java | 43 ++++ .../CargoDiscardQuantityRepositoryTest.java | 27 +++ .../CargoDiscardStatisticsServiceTest.java | 26 ++- .../service/CargoDiscardStockServiceTest.java | 92 ++++++++- .../DispatchAggregatePersistenceTest.java | 2 +- .../DispatchItemSourcePersistenceTest.java | 8 +- .../fleet/pickup/FleetPickupMapperTest.java | 21 ++ .../fleet/pickup/FleetPickupServiceTest.java | 24 +-- .../query/InventoryPieceServiceTest.java | 49 +++++ .../PalletMutationOrderMapperTest.java | 54 +++++ .../PalletOperationRecoveryServiceTest.java | 31 +++ ...daPalletDispatchFreezePersistenceTest.java | 27 ++- .../PdaPalletOperationServiceTest.java | 5 + .../pda/service/PdaRelabelServiceTest.java | 3 + .../service/RelabelQuantityServiceTest.java | 49 +++++ .../ReturnInventoryMovementSqlTest.java | 4 +- .../service/LastlegOrderServiceTest.java | 4 +- .../transfer/TransferQuantityMapperTest.java | 63 ++++++ ...rchantWarehouseTransferControllerTest.java | 9 +- .../service/TransferQuantityServiceTest.java | 57 ++++++ .../WarehouseTransferCartonCountTest.java | 1 - .../service/WarehouseTransferServiceTest.java | 7 +- 95 files changed, 2616 insertions(+), 275 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PalletOperationRecoveryRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/query/LastlegOrderFilter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletDimensions.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderStatisticsController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderStatistics.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderStatisticsMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java create mode 100644 lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java diff --git a/deploy/sql/20261016-数据库变更汇总.md b/deploy/sql/20261016-数据库变更汇总.md index eb2172ede..815838580 100644 --- a/deploy/sql/20261016-数据库变更汇总.md +++ b/deploy/sql/20261016-数据库变更汇总.md @@ -10,7 +10,7 @@ | warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 | | 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL | -已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。 +已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。由单 schema 执行器选定 `baiyue_lastleg`,文件内不切库,不访问其他服务数据库。2026-10-20 按尚未执行候选的边界移除内部切库语句,其余结构内容不变;本次未执行此候选。 ## 新表与字段 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql index 47620cd03..481e45442 100644 --- a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql @@ -1,5 +1,5 @@ -- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 -USE baiyue_firstleg; +-- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。 -- 客户独立确认计费重,不回填既有费用确认。 CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( id BIGINT NOT NULL COMMENT '确认记录ID', diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql index 54d74c3c1..19c90b39e 100644 --- a/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql @@ -2,7 +2,7 @@ -- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。 -- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。 SET NAMES utf8mb4; -USE baiyue_lastleg; +-- 由单 schema 执行器选择 baiyue_lastleg,不在迁移内部切库。 CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', @@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 -USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米''')); @@ -42,7 +41,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt; -- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。 -USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细''')); PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql new file mode 100644 index 000000000..90bb0919b --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql @@ -0,0 +1,49 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 排车物理托盘串行点、物理占用及逻辑占用关联结构补齐。 +-- Preconditions: MySQL 8;已选中目标schema;ll_dispatch_pallet_lock已有业务基线存在。 +-- Postconditions: 两张实托表和physical_lock_id列/idx_dispatch_logical_physical索引存在。 +-- 只增加缺失结构,不删除/回填/重算历史数据。重复执行已存在对象时跳过。 +-- 与当前lastleg.sql最终定义一致;已有异构对象须人工核对,不能以跳过证明一致。 + +CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间', + PRIMARY KEY (warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock ( + id BIGINT NOT NULL COMMENT '物理实托占用主键', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID', + active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空', + dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥', + draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥', + draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID', + operator_id VARCHAR(128) NULL COMMENT '占用操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态', + expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) NULL COMMENT '释放时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_physical_order (dispatch_order_id,status), + KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status), + CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)), + CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL)) +) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用'; + +-- 已只读确认本地缺失:客户逻辑锁到物理锁的可空关联列及查询索引。 +-- 仅补结构,不根据历史单据推测或回填关联。 +SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='physical_lock_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN physical_lock_id BIGINT NULL COMMENT ''实际物理实托占用ID,非物理来源为空'''); +PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl; +EXECUTE overseas_dimension_stmt; +DEALLOCATE PREPARE overseas_dimension_stmt; +SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND index_name='idx_dispatch_logical_physical'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD KEY idx_dispatch_logical_physical (physical_lock_id,status)'); +PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl; +EXECUTE overseas_dimension_stmt; +DEALLOCATE PREPARE overseas_dimension_stmt; +SET @overseas_dimension_ddl = NULL; + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 5255b933e..592a75301 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -91,6 +91,15 @@ public class LastlegOrderController { return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo)); } + /** 按主列表完整筛选读取仓储转运数量,保持同一平台客户边界和列表权限。 */ + @GetMapping("/api/platform/v1/last-leg/orders/warehouse-transfer-quantities") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + public ApiResponse transferQuantities( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) String customerId, + @RequestParam(required=false) String relabelNo, @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.transferQuantityTotals(platformScope(user,customerId),query,relabelNo)); + } + /** Compatibility overload used by non-Spring callers and existing contract tests. */ public ApiResponse> list( AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 35762deed..fb6f930eb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -597,12 +597,19 @@ public class DeliveryTransferService { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw conflict("换标单尚未完成客户确认或已不可出库"); } + if (relabelSnapshotMarks(item, selectedPosition.getId(), application.getRelabelNo()).isEmpty()) + throw conflict("换标出库指定位置或箱唛快照不完整,请核对原申请"); List sourceItems = relabelItems.selectList(new LambdaQueryWrapper() .eq(RelabelItem::getApplicationId, application.getId()) .eq(RelabelItem::getCustomerId, customerId) .eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId())); long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum(); - if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致"); + List sourceMarks = sourceItems.stream().map(RelabelItem::getCartonMark) + .filter(StringUtils::hasText).distinct().sorted().toList(); + Set sourceIds = sourceItems.stream().map(source -> String.valueOf(source.getId())).collect(Collectors.toSet()); + if (sourcePackages != request.packages() || !sourceMarks.equals(expectedMarks) + || !sourceIds.equals(relabelSnapshotItemIds(item))) + throw conflict("换标出库来源库存快照与任务箱数或箱唛不一致"); int releaseIndex = 0; for (RelabelItem source : sourceItems) { inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( @@ -720,6 +727,7 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + /** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */ @Transactional public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo, String palletNo, DeliveryRequests.PdaPalletPick request) { @@ -769,7 +777,7 @@ public class DeliveryTransferService { long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount()); if (remaining <= 0) continue; Map> positionsById = pdaPickPositions( - task.getCustomerId(), boundWarehouseId, item).stream().collect(Collectors.groupingBy( + task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()).stream().collect(Collectors.groupingBy( DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList())); for (Map.Entry> entry : positionsById.entrySet()) { if (remaining <= 0) break; @@ -1049,6 +1057,7 @@ public class DeliveryTransferService { .map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList(); } + /** PDA明细展示与整托拣货使用相同的客户、仓库及来源投影。 */ public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) { String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"), @@ -1061,7 +1070,7 @@ public class DeliveryTransferService { value(item.getPickedPackageCount()), item.getStatus(), ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item) - : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item), item.getReturnSkuQuantity())).toList(); + : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()), item.getReturnSkuQuantity())).toList(); return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } @@ -1119,13 +1128,28 @@ public class DeliveryTransferService { && value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount()))); } + /** 换标使用指定实物箱及仍归本单的冻结库存;普通出库继续按可用库存和SKU匹配。 */ private List pdaPickPositions(String customerId, String warehouseId, - OutboundPalletItem item) { + OutboundPalletItem item, String outboundType) { if (item.getWarehousePalletId() == null) return List.of(); List result = new ArrayList<>(); Set frozenMarks = new HashSet<>(Arrays.asList( Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { + if ("RELABEL_OUTBOUND".equals(outboundType)) { + List marks = relabelSnapshotMarks(item, position.getId(), null); + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (marks.isEmpty() || !customerId.equals(position.getCustomerId()) + || !warehouseId.equals(position.getOverseasWarehouseId()) + || !Objects.equals(item.getWarehousePalletId(), position.getPalletId()) + || balance == null || !customerId.equals(balance.getCustomerId()) + || !warehouseId.equals(balance.getOverseasWarehouseId()) + || !Objects.equals(item.getInboundCode(), balance.getInboundCode()) + || value(position.getHoldPackages()) < marks.size()) continue; + // 本单冻结箱先在写路径校验原申请和来源任务,再释放、分配、拣货、出库;不把其他可用箱混入。 + for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0)); + continue; + } long available = value(position.getAvailablePackages()); if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); @@ -1147,6 +1171,37 @@ public class DeliveryTransferService { return result; } + /** 仅接受完整的换标出库快照;空SKU不是通配符,必须精确匹配返工后冻结的位置和全部箱唛。 */ + private List relabelSnapshotMarks(OutboundPalletItem item, Long positionId, String expectedRelabelNo) { + try { + var refs = objectMapper.readTree(item.getOrderRefs()); + if (!"RELABEL_ORDER".equals(refs.path("sourceType").asText()) + || !StringUtils.hasText(refs.path("relabelNo").asText()) + || expectedRelabelNo != null && !expectedRelabelNo.equals(refs.path("relabelNo").asText()) + || positionId == null || !String.valueOf(positionId).equals(refs.path("sourceInventoryPositionId").asText()) + || !refs.path("items").isArray()) return List.of(); + var marks = new TreeSet(); var ids = new HashSet(); long packages = 0; + for (var line : refs.path("items")) { + String mark = line.path("cartonMark").asText().trim(), id = line.path("itemId").asText(); + long count = line.path("packages").asLong(); + if (!StringUtils.hasText(mark) || !StringUtils.hasText(id) || !ids.add(id) || !marks.add(mark) || count != 1) return List.of(); + packages += count; + } + var frozen = Arrays.stream(Objects.toString(item.getCartonMarks(), "").split(",")) + .map(String::trim).filter(StringUtils::hasText).collect(Collectors.toCollection(TreeSet::new)); + return packages == value(item.getPackageCount()) && marks.equals(frozen) ? List.copyOf(marks) : List.of(); + } catch (Exception ignored) { return List.of(); } + } + + /** 释放冻结前核对快照任务主键与本申请当前位置的真实任务集合完全相同。 */ + private Set relabelSnapshotItemIds(OutboundPalletItem item) { + try { + var ids = new HashSet(); + for (var line : objectMapper.readTree(item.getOrderRefs()).path("items")) ids.add(line.path("itemId").asText()); + return ids; + } catch (Exception ignored) { return Set.of(); } + } + private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item, InventoryBalance balance, String cartonMark, long available, long picked) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index c62094d6d..3b9ac1a1b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -29,12 +29,16 @@ public final class CargoDiscardResponses { } } public record Box(Long cartonId, String cartonMark, String palletNo, List skus) {} - public record CandidateSku(String skuCode, String productName, long availablePackages) {} + public record CandidateSku(String skuCode, String productName, long availablePackages, Long availablePieces) { + /** 无件数事实时保留未知,不按箱规推算。 */ + public CandidateSku(String skuCode, String productName, long availablePackages) { this(skuCode, productName, availablePackages, null); } + } public record Summary(Long id, String customerId, String discardNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, - String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt, + List skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java index 5f62485bc..972282487 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java @@ -19,4 +19,15 @@ public interface CargoDiscardDimensionMapper { WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId} """) List palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + /** 仅读取所选转运实收箱的真实件数,其他来源保留未知。 */ + @Select(""" + SELECT CASE WHEN l.source_type='TRANSFER' THEN c.sku_items_json ELSE NULL END + FROM ll_cargo_discard_item i + JOIN ll_cargo_discard_carton l ON l.customer_id=i.customer_id AND l.application_id=i.application_id + AND (l.item_id=i.id OR (i.source_type='INVENTORY' AND JSON_CONTAINS(i.carton_marks_json,JSON_QUOTE(l.carton_mark)))) + LEFT JOIN ll_warehouse_transfer_carton c ON c.id=l.carton_id AND c.customer_id=l.customer_id + WHERE i.customer_id=#{customerId} AND i.id=#{itemId} + """) + List selectedCartonPieces(@Param("customerId") String customerId, @Param("itemId") Long itemId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index ba4238a99..1b9cc17d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -25,16 +25,16 @@ public interface CargoDiscardInventoryMapper { @Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE") com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); - /** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */ + /** 按实收托盘关联已清点箱;混装物理余额允许箱内不同 SKU,仍严格限定客户、批次、仓和托盘。 */ @Select(""" - SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type + SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type,NULL sku_items_json FROM ll_inbound_plan_carton c JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'') UNION ALL - SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type + SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type,c.sku_items_json FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id @@ -44,7 +44,7 @@ public interface CargoDiscardInventoryMapper { JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id - AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'') + AND i.transfer_order_id=t.id AND (b.sku_code='MIXED' OR COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'')) WHERE p.customer_id=#{customerId} AND p.id=#{positionId} ORDER BY id """) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java index 7c4e06f51..1aa4f166c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java @@ -12,15 +12,22 @@ public final class CargoDiscardQuantities { /** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */ public static int boxes(List lines, boolean actual) { var returnBoxes = new HashSet(); + var inventoryBoxes = new HashSet(); int total = 0; for (var line : lines) { int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages(); if (line.getReturnCartonId() != null) { if (quantity > 0) returnBoxes.add(line.getReturnCartonId()); + } else if ("INVENTORY".equals(line.getSourceType()) && line.getCartonMarksJson() != null && quantity > 0) { + try { + var marks = new com.fasterxml.jackson.databind.ObjectMapper().readTree(line.getCartonMarksJson()); + if (!marks.isArray() || marks.size() != quantity) throw new IllegalStateException("废弃箱唛快照与数量不一致"); + for (var mark : marks) inventoryBoxes.add(line.getInboundCode() + ":" + mark.asText()); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw new IllegalStateException("废弃箱唛快照无法读取", e); } } else { total = Math.addExact(total, quantity); } } - return Math.addExact(total, returnBoxes.size()); + return Math.addExact(Math.addExact(total, returnBoxes.size()), inventoryBoxes.size()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java index 3a3f8d15e..d413b83bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java @@ -9,4 +9,5 @@ public class CargoDiscardStockCartonRow { private String cartonMark; private String skuCode; private String sourceType; + private String skuItemsJson; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index 7ac9e9f50..db4793900 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -21,7 +21,8 @@ public final class PdaCargoDiscardDtos { } /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, - Integer actualPackages, String status, String quantityUnit) { } + Integer actualPackages, String status, String quantityUnit, + boolean actualQuantityEditable) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, String palletNo, Long profileId, String status, List skus, String outboundNo, String scanMode, List cartonMarks) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index ad828118b..96d5ce1e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -1,17 +1,11 @@ package com.baiyue.lastleg.discard.pda.service; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; import com.baiyue.lastleg.discard.entity.*; -import com.baiyue.lastleg.discard.mapper.*; import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; import com.baiyue.lastleg.discard.service.CargoDiscardService; -import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; -import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; -import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -24,28 +18,24 @@ import java.util.stream.Collectors; @Service public class PdaCargoDiscardService { @Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService; - @Resource private CargoDiscardApplicationMapper applications; + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository repository; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService; - @Resource private CargoDiscardItemMapper items; - @Resource private CargoDiscardCartonMapper cartons; - @Resource private DropshipOrderProfileMapper profiles; @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardService discardService; - @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService; + /** 只读取绑定仓已审核申请的实际托盘任务。 */ public List tasks(PdaWarehouseIdentity identity) { - return applications.selectList(new LambdaQueryWrapper() - .eq(CargoDiscardApplication::getOverseasWarehouseId, String.valueOf(identity.overseasWarehouseId())) - .eq(CargoDiscardApplication::getStatus, CargoDiscardService.APPROVED) - .orderByAsc(CargoDiscardApplication::getCreatedAt)) + return repository.approved(String.valueOf(identity.overseasWarehouseId())) .stream().flatMap(a -> palletTasks(a).stream()).toList(); } + /** 扫码读取当前仓库唯一可操作任务。 */ public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) { return resolve(identity, palletNo, null); } + /** 扫码时限定已选择申请,防止同托盘多任务误操作。 */ public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) { var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim())) .filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList(); @@ -54,10 +44,11 @@ public class PdaCargoDiscardService { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务")); } + /** 仓库按无箱唛对应任务的实点数结算,其余任务继续核平所选箱。 */ @Transactional public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo, String palletNo, PdaCargoDiscardDtos.Confirm request) { - CargoDiscardApplication application = applications.findNoByWarehouse(discardNo, + CargoDiscardApplication application = repository.lockApplication(discardNo, String.valueOf(identity.overseasWarehouseId())); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus()) @@ -71,8 +62,12 @@ public class PdaCargoDiscardService { if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); } - if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages()))) - throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); + for (var line : task.skus()) { + int actual = actuals.get(line.itemId()); + if (actual < 0 || actual > line.plannedPackages()) throw conflict("实际废弃数量不能超过申请预留数量"); + if (!line.actualQuantityEditable() && actual != line.plannedPackages()) + throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); + } if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { var scanned = request.scannedCartonMarks() == null ? List.of() : request.scannedCartonMarks(); if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) @@ -80,7 +75,7 @@ public class PdaCargoDiscardService { } List palletItems = new ArrayList<>(); for (PdaCargoDiscardDtos.Sku line : task.skus()) { - CargoDiscardItem item = items.selectById(line.itemId()); + CargoDiscardItem item = repository.item(line.itemId()); if (item == null || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()) || !physicalCode(application, item).equals(task.palletNo())) @@ -90,14 +85,14 @@ public class PdaCargoDiscardService { throw conflict("已完成托盘的实际数量不能修改"); palletItems.add(item); } - if (terminal && items.listApplication(application.getCustomerId(), application.getId()).stream() + if (terminal && repository.items(application.getCustomerId(), application.getId()).stream() .anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus()) || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()))) throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); photoService.confirm(application, task.palletNo(), request.photoFileIds(), palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); - // A completed pallet is an immutable result, including the final pallet after application completion. + // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (terminal) throw conflict("废弃终态不能执行未完成托盘"); LocalDateTime now = LocalDateTime.now(); @@ -105,20 +100,25 @@ public class PdaCargoDiscardService { if ("COMPLETED".equals(item.getPdaStatus())) { continue; } - List links = cartons.listApplication(application.getCustomerId(), application.getId()).stream() + List links = repository.cartons(application.getCustomerId(), application.getId()).stream() .filter(c -> c.getItemId().equals(item.getId())).toList(); - adjustments.discardPalletItem(application.getCustomerId(), application, item, links, - actuals.get(item.getId()), identity.operatorKey(), now); + // 未知箱唛对应关系时只按当前仓位冻结桶结算,不猜测被废弃的具体箱唛。 + if ("INVENTORY".equals(item.getSourceType()) && stockService.actualQuantityEditable(item)) { + stockService.discard(application.getCustomerId(), identity.operatorKey(), application, item, actuals.get(item.getId())); + } else { + adjustments.discardPalletItem(application.getCustomerId(), application, item, links, + actuals.get(item.getId()), identity.operatorKey(), now); + } item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); - item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item); + item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item); } - task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(items::selectById) + task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item) .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); - if (items.listApplication(application.getCustomerId(), application.getId()).stream() + if (repository.items(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( - items.listApplication(application.getCustomerId(), application.getId()), true); + repository.items(application.getCustomerId(), application.getId()), true); application.setTotalPackages(actualTotal); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, @@ -130,6 +130,7 @@ public class PdaCargoDiscardService { } @Transactional + /** 当前申请行锁内保存现场照片,已完成托盘禁止补写。 */ public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo, long operatorUserId, org.springframework.web.multipart.MultipartFile file) { CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true); @@ -140,19 +141,22 @@ public class PdaCargoDiscardService { } @Transactional(readOnly = true) + /** 读取绑定仓当前托盘的照片。 */ public List photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) { return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false); } @Transactional(readOnly = true) + /** 下载当前申请和托盘拥有的照片。 */ public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto( PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) { return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false); } + /** 照片访问必须同时满足绑定仓和申请托盘范围。 */ private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) { var warehouseId = String.valueOf(identity.overseasWarehouseId()); - var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId); + var application = lock ? repository.lockApplication(discardNo, warehouseId) : repository.readApplication(discardNo, warehouseId); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim()))) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"); @@ -162,13 +166,13 @@ public class PdaCargoDiscardService { private static final String LOOSE = "散箱"; /** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */ private List palletTasks(CargoDiscardApplication application) { - var allItems = items.listApplication(application.getCustomerId(), application.getId()); + var allItems = repository.items(application.getCustomerId(), application.getId()); var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList())); return grouped.entrySet().stream().map(entry -> { CargoDiscardItem first = entry.getValue().get(0); var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(), - isReturnItem(i) ? "件" : "箱")).toList(); + isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList(); String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); // 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 @@ -180,6 +184,7 @@ public class PdaCargoDiscardService { status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks); }).toList(); } + /** 相关托盘只从客户库存事实读取,不从请求推断。 */ private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) { String code; if ("INVENTORY".equals(item.getSourceType())) { @@ -187,31 +192,35 @@ public class PdaCargoDiscardService { if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库"); code = position.getPalletNo(); } else if (isReturnItem(item)) { - var carton = returnCartons.selectById(item.getReturnCartonId()); + var carton = repository.returnCarton(item.getReturnCartonId()); if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在"); code = carton.getPalletCode(); } else { - var profile = profiles.selectById(item.getProfileId()); + var profile = repository.profile(item.getProfileId()); if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在"); code = profile.getPalletNo(); } return code == null || code.isBlank() ? LOOSE : code.trim(); } + /** 仅返回保存的真实箱唛,未知对应关系不补造箱码。 */ private List cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) { if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item); if (isReturnItem(item)) { - var carton = returnCartons.selectById(item.getReturnCartonId()); + var carton = repository.returnCarton(item.getReturnCartonId()); String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); return mark == null ? List.of() : List.of(mark); } - return cartons.listApplication(application.getCustomerId(), application.getId()).stream() + return repository.cartons(application.getCustomerId(), application.getId()).stream() .filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList(); } + /** 退货库存沿用独立按件结算规则。 */ private boolean isReturnItem(CargoDiscardItem item) { return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; } + /** 取首个有效业务展示值。 */ private static String firstNonBlank(String first, String second) { return first != null && !first.isBlank() ? first : second; } + /** 返回可恢复业务冲突。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java index b375f8202..8201293b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java @@ -12,8 +12,29 @@ import java.util.List; public class CargoDiscardDimensionRepository { @Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardDimensionMapper dimensions; + @Resource private com.fasterxml.jackson.databind.ObjectMapper objectMapper; /** 读取一票客户隔离明细。 */ public List items(String customer, Long application) { return items.listApplication(customer, application); } /** 读取一票托盘身份,保留无事实行。 */ public List pallets(String customer, Long application) { return dimensions.palletNos(customer, application); } + /** 完整箱级事实才可提供件数,缺箱或未知件数不得固定换算。 */ + public Long pieces(String customer, CargoDiscardItem item) { + var values = dimensions.selectedCartonPieces(customer, item.getId()); + if (values.isEmpty() || values.size() != item.getDiscardPackages()) return null; + long total = 0; + for (var value : values) { + if (value == null || value.isBlank()) return null; + try { + var contents = objectMapper.readTree(value); + if (!contents.isArray()) return null; + long selected = 0; + for (var content : contents) { + if (item.getSkuCode().equals(content.path("skuCode").asText())) selected += content.path("quantity").asLong(); + } + if (selected <= 0) return null; + total += selected; + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { return null; } + } + return total; + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java new file mode 100644 index 000000000..1d176def8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.mapper.*; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装 PDA 废弃任务读取和作业快照持久化,保留绑定仓与申请行锁。 */ +@Repository +public class CargoDiscardPdaRepository { + @Resource private CargoDiscardApplicationMapper applications; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper cartons; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private ReturnInventoryCartonMapper returnCartons; + + /** 按已验证绑定仓读取已审核作业,客户归属由申请事实保留。 */ + public List approved(String warehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .eq(CargoDiscardApplication::getOverseasWarehouseId, warehouseId) + .eq(CargoDiscardApplication::getStatus, "APPROVED") + .orderByAsc(CargoDiscardApplication::getCreatedAt)); + } + /** 写操作按申请串行,保证扣减、释放、实点结果及终态同事务。 */ + public CargoDiscardApplication lockApplication(String no, String warehouse) { return applications.findNoByWarehouse(no, warehouse); } + /** 照片只读入口仍限制绑定仓。 */ + public CargoDiscardApplication readApplication(String no, String warehouse) { return applications.readNoByWarehouse(no, warehouse); } + /** 按申请所属客户读取全部明细。 */ + public List items(String customer, Long application) { return items.listApplication(customer, application); } + /** 读取明细后由服务复验客户与申请。 */ + public CargoDiscardItem item(Long id) { return items.selectById(id); } + /** 保存一次实际作业结果。 */ + public void saveItem(CargoDiscardItem item) { items.updateById(item); } + /** 按客户和申请读取已选箱快照。 */ + public List cartons(String customer, Long application) { return cartons.listApplication(customer, application); } + /** 读取原托盘档案供服务核验归属。 */ + public DropshipOrderProfile profile(Long id) { return profiles.selectById(id); } + /** 读取退货箱档案供服务核验归属。 */ + public ReturnInventoryCarton returnCarton(Long id) { return returnCartons.selectById(id); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java index 64acc82c5..dbfc3265b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java @@ -11,9 +11,10 @@ import org.springframework.stereotype.Repository; public class CargoDiscardQuantityRepository { @Resource private CargoDiscardItemMapper items; - /** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */ + /** 完工后列表和详情按实际箱数展示,作业前保留申请预留数量。 */ public int boxes(CargoDiscardApplication application) { var lines = items.listApplication(application.getCustomerId(), application.getId()); - return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); + boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus()); + return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java new file mode 100644 index 000000000..a5ae4173a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.mapper.*; +import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */ +@Repository +public class CargoDiscardStockRepository { + @Resource private CargoDiscardInventoryMapper stock; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper links; + + /** 读取客户当前可用且没有转运占用的库存仓位。 */ + public List candidates(String customer, String keyword) { return stock.genericPositions(customer, keyword); } + /** 读取余额,服务继续校验其客户与仓库归属。 */ + public InventoryBalance balance(Long id) { return balances.selectById(id); } + /** 读取客户指定仓位。 */ + public InventoryPosition position(String customer, Long id) { return positions.findTenantPosition(customer, id); } + /** 锁定源仓位以串行创建预留。 */ + public InventoryPosition lockPosition(String customer, Long id) { return stock.lockGenericPosition(customer, id); } + /** 读取已有的真实箱唛与托盘对应事实。 */ + public List cartons(String customer, Long position) { return stock.genericCartons(customer, position); } + /** 排除须走一件代发逐箱预留的仓位。 */ + public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; } + /** 读取散箱实收流水中的箱码快照。 */ + public List looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); } + /** 保存废弃申请的库存来源明细。 */ + public void saveItem(CargoDiscardItem item) { items.insert(item); } + /** 保存申请所选实物箱快照。 */ + public void saveCarton(CargoDiscardCarton carton) { links.insert(carton); } + /** 读取申请全部所选箱,服务按当前明细筛选。 */ + public List selectedCartons(String customer, Long application) { return links.listApplication(customer, application); } + /** 读取同申请各 SKU 的仓位和箱唛快照,用于按物理仓位消除重复库存命令。 */ + public List applicationItems(String customer, Long application) { return items.listApplication(customer, application); } + /** 普通实收箱用原状态条件更新,防止并发占用。 */ + public int changeInboundCarton(String customer, Long id, String from, String to) { return stock.changeGenericCartonStatus(customer, id, from, to); } + /** 转运实收箱保留已清点状态语义。 */ + public int changeTransferCarton(String customer, Long id, String from, String to) { return stock.changeTransferCartonStatus(customer, id, from, to); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index 84d361e9f..d9ef4c468 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -58,6 +58,7 @@ public class CargoDiscardOutboundService { var snapshots = new java.util.ArrayList(); var palletCodes = new java.util.HashSet(); var returnBoxIds = new java.util.HashSet(); + var inventoryBoxMarks = new java.util.HashSet(); for (var line : lines) { boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType()); String palletNo; @@ -88,7 +89,7 @@ public class CargoDiscardOutboundService { snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId()); snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode()); snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks); - snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages()); + snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks)); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages()); snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at); @@ -99,11 +100,13 @@ public class CargoDiscardOutboundService { } /** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */ + /** 同步废弃实物出库投影,混装 SKU 明细不能重复累计物理箱。 */ public void sync(CargoDiscardApplication application) { OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application)); if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果"); var lines = items.listApplication(application.getCustomerId(), application.getId()); var returnBoxIds = new java.util.HashSet(); + var inventoryBoxMarks = new java.util.HashSet(); for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) { var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() .orElseThrow(() -> conflict("废弃出库明细关联失效")); @@ -114,11 +117,11 @@ public class CargoDiscardOutboundService { throw conflict("废弃明细关联退货箱不存在"); snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton)); } - int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); + int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks); snapshot.setPackageCount(boxes); if ("COMPLETED".equals(line.getPdaStatus())) { snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0) - : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); + : "INVENTORY".equals(line.getSourceType()) && !stockService.cartonMarks(line).isEmpty() ? boxes : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); snapshot.setStatus("PICKED"); } if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED"); @@ -134,5 +137,11 @@ public class CargoDiscardOutboundService { outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); } + /** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */ + private int physicalBoxes(com.baiyue.lastleg.discard.entity.CargoDiscardItem line, java.util.Set seen) { + if (!"INVENTORY".equals(line.getSourceType())) return line.getDiscardPackages(); + var marks = stockService.cartonMarks(line); + return marks.isEmpty() ? line.getDiscardPackages() : (int)marks.stream().filter(mark -> seen.add(line.getInboundCode() + ":" + mark)).count(); + } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 81640a372..4b9e3900b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -165,7 +165,10 @@ public class CargoDiscardService { if (input.cartonIds() != null) { if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致"); String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP"; - for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃"); + for (Long cartonId : input.cartonIds()) { + boolean added = requestedBoxes.add(source + ":" + cartonId); + if (!added && input.inventoryPositionId() == null) throw badRequest("同一个箱子不能重复废弃"); + } } int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0); if (sources != 1) { @@ -202,7 +205,7 @@ public class CargoDiscardService { throw conflict("One cargo discard application cannot span operating warehouses"); } // 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。 - if (!returnSource) total = Math.addExact(total, input.discardPackages()); + if (!returnSource && input.cartonIds() == null) total = Math.addExact(total, input.discardPackages()); } for (Long cartonId : returnCartonCache.keySet()) { @@ -214,6 +217,7 @@ public class CargoDiscardService { if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存"); } total = Math.addExact(total, returnCartonCache.size()); + total = Math.addExact(total, requestedBoxes.size()); LocalDateTime now = LocalDateTime.now(); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); @@ -236,9 +240,10 @@ public class CargoDiscardService { // 废弃申请独立承载废弃流程,不创建尾程订单。 Set reservedReturnCartons = new LinkedHashSet<>(); + stockService.reserveAll(customerId, operatorId, application, request.items(), now); for (CargoDiscardRequests.Item input : request.items()) { if (input.inventoryPositionId() != null) { - stockService.reserve(customerId, operatorId, application, input, now); + // 普通库存已按完整混装箱批量预留。 } else if (input.returnCartonId() != null) { reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now, reservedReturnCartons); @@ -513,10 +518,15 @@ public class CargoDiscardService { /** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */ private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map names) { int boxes = quantities.boxes(value); + var lines = items.listApplication(value.getCustomerId(), value.getId()); + var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList(); + int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false); + Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null) + ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null; return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes, value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), - value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt()); + value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual); } private void history(String customerId, Long applicationId, String from, String to, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java index 76508b8e8..7441a8de5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java @@ -32,8 +32,14 @@ public class CargoDiscardStatisticsService { for (var line : lines) { if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode()); if (line.getReturnCartonId() == null) { - piecesKnown = false; - if (line.getActualDiscardPackages() != null) actualPiecesKnown = false; + var selectedPieces = dimensions.pieces(application.getCustomerId(), line); + if (selectedPieces == null) piecesKnown = false; + else pieces += selectedPieces; + if (line.getActualDiscardPackages() != null) { + if (line.getActualDiscardPackages() == 0) { /* 实际未废弃商品为零,不推测箱规。 */ } + else if (selectedPieces != null && line.getActualDiscardPackages() == line.getDiscardPackages()) actualPieces += selectedPieces; + else actualPiecesKnown = false; + } } else { pieces += line.getDiscardPackages(); if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index 081158224..4ef53aa2f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -2,11 +2,9 @@ package com.baiyue.lastleg.discard.service; import com.baiyue.lastleg.discard.dto.*; import com.baiyue.lastleg.discard.entity.*; -import com.baiyue.lastleg.discard.mapper.*; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.*; -import com.baiyue.lastleg.inventory.mapper.*; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -16,129 +14,233 @@ import org.springframework.web.server.ResponseStatusException; import java.util.*; import java.time.LocalDateTime; -/** Stock eligibility is based on physical available positions, independent of the source document type. */ +/** 根据实际可用仓位校验废弃库存,精确选箱与无对应关系实点分别结算。 */ @Service public class CargoDiscardStockService { - @Resource private CargoDiscardInventoryMapper stock; - @Resource private InventoryPositionMapper positions; - @Resource private InventoryBalanceMapper balances; + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository; @Resource private InventoryCommandService commands; - @Resource private CargoDiscardItemMapper items; - @Resource private CargoDiscardCartonMapper links; @Resource private ObjectMapper objectMapper; + /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ public List candidates(String customer, String keyword) { - return stock.genericPositions(customer, keyword).stream().map(position -> { - var balance = balances.selectById(position.getBalanceId()); + return repository.candidates(customer, keyword).stream().map(position -> { + var balance = repository.balance(position.getBalanceId()); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); - var cartons = stock.genericCartons(customer, position.getId()); - boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank()) - && cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size(); + var cartons = repository.cartons(customer, position.getId()); + boolean complete = completeCartons(cartons, available); var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), - List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.of(); + contents(c, balance.getProductNameZh()))).toList() : List.of(); return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null, balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseName(), position.getCreatedAt(), available, - List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)), + complete ? summarizeSkus(cartons, balance) : List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)), position.getId(), position.getPalletNo(), boxes, complete, - complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃"); + complete ? null : "缺少完整箱唛与托盘对应记录,将预留所选库存,由仓库核对相关托盘并填写实际废弃箱数,未废弃部分释放"); }).filter(c -> c.availablePackages() > 0).toList(); } + /** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */ public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) { var position = position(customer, request.inventoryPositionId()); - var balance = balances.selectById(position.getBalanceId()); + var balance = repository.balance(position.getBalanceId()); if (balance == null || !customer.equals(balance.getCustomerId()) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) throw conflict("库存不属于当前客户或仓库"); - if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (request.cartonIds() != null) { if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) throw conflict("废弃箱子数量与所选箱子不一致或重复"); - var cartons = stock.genericCartons(customer, position.getId()); + var cartons = repository.cartons(customer, position.getId()); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); - } else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); - if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); + if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子"); + } else { + if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); + if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); + } + if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); return balance; } /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { - if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); + if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); var balance = validate(customer, request); + reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>()); + } + + /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ + public void reserveAll(String customer, String operator, CargoDiscardApplication application, List requests, LocalDateTime at) { + var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList(); + stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> { + if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在"); + }); + var balances = new HashMap(); + var cartons = new HashMap>(); + var selectedSkus = new HashMap>(); + for (var request : stocks) { + balances.put(request.inventoryPositionId(), validate(customer, request)); + var facts = repository.cartons(customer, request.inventoryPositionId()); + cartons.put(request.inventoryPositionId(), facts); + for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId())) + selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim()); + } + for (var request : stocks) for (var carton : cartons.get(request.inventoryPositionId())) { + if (request.cartonIds() != null && !request.cartonIds().contains(carton.getId())) continue; + var required = contents(carton, null).stream().map(CargoDiscardResponses.CandidateSku::skuCode).collect(java.util.stream.Collectors.toSet()); + if (required.size() > 1 && request.cartonIds() == null) throw conflict("混装箱必须选择具体箱子及箱内全部 SKU 库存"); + if (!selectedSkus.get(carton.getSourceType() + ":" + carton.getId()).containsAll(required)) throw conflict("混装箱必须选择箱内全部 SKU 库存"); + } + var lockedBoxes = new HashSet(); + var heldBoxes = new HashSet(); + for (var request : stocks) reserveValidated(customer, operator, application, request, at, + balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes); + } + + /** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */ + private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at, + InventoryBalance balance, List availableCartons, Set lockedBoxes, Set heldBoxes) { var position = position(customer, request.inventoryPositionId()); var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId()); line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId()); - line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh()); - line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at); - var availableCartons = stock.genericCartons(customer, position.getId()); + line.setInboundCode(balance.getInboundCode()); line.setSkuCode(request.skuCode().trim()); line.setProductName(sku(balance).equals(line.getSkuCode()) ? balance.getProductNameZh() : null); + line.setAvailablePackagesSnapshot(availableCartons.isEmpty() ? Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()))) : availableCartons.size()); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at); var selected = request.cartonIds() == null - ? (availableCartons.size() == request.discardPackages() ? availableCartons : List.of()) + ? (completeCartons(availableCartons, request.discardPackages()) ? availableCartons : List.of()) : availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList(); if (!selected.isEmpty()) { for (var carton : selected) { + if (!lockedBoxes.add(carton.getSourceType() + ":" + carton.getId())) continue; if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId()); - link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link); + link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); repository.saveCarton(link); } snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList()); line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages())); } else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) { - var marks = stock.looseCartonMarks(customer, position.getId()); + var marks = repository.looseMarks(customer, position.getId()); if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); snapshotMarks(line, marks); } - commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD")); - items.insert(line); + int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count(); + if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD")); + repository.saveItem(line); } + /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ + private List summarizeSkus(List cartons, InventoryBalance balance) { + var boxes = new LinkedHashMap(); var pieces = new HashMap(); var unknown = new HashSet(); + for (var carton : cartons) for (var content : contents(carton, null)) { + boxes.merge(content.skuCode(), 1L, Math::addExact); + if (content.availablePieces() == null) unknown.add(content.skuCode()); else pieces.merge(content.skuCode(), content.availablePieces(), Math::addExact); + } + return boxes.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), sku(balance).equals(e.getKey()) ? balance.getProductNameZh() : null, + e.getValue(), unknown.contains(e.getKey()) ? null : pieces.get(e.getKey()))).toList(); + } + + /** 箱数、箱唛和唯一性均完整时,才允许将库存对应到具体箱子。 */ + private boolean completeCartons(List cartons, long count) { + return cartons.size() == count && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank()) + && cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size(); + } + /** 转运实收 JSON 提供完整 SKU 与件数;无 JSON 的单 SKU 来源保留箱数单位。 */ + private List contents(com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton, String productName) { + if (carton.getSkuItemsJson() == null || carton.getSkuItemsJson().isBlank()) + return List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), productName, 1)); + try { + var values = objectMapper.readTree(carton.getSkuItemsJson()); + if (!values.isArray()) throw conflict("箱内 SKU 事实不完整"); + var quantities = new LinkedHashMap(); + for (var value : values) { + String code = value.path("skuCode").asText().trim(); long pieces = value.path("quantity").asLong(); + if (code.isEmpty() || pieces <= 0) throw conflict("箱内 SKU 事实不完整"); + quantities.merge(code, pieces, Math::addExact); + } + if (quantities.isEmpty()) throw conflict("箱内 SKU 事实不完整"); + return quantities.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), Objects.equals(e.getKey(), carton.getSkuCode()) ? productName : null, 1, e.getValue())).toList(); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("箱内 SKU 事实无法读取"); } + } + + /** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */ public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { changeSelectedCartons(customer, application, line, "AVAILABLE"); - commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE")); + int physical = physicalPackages(customer, application, line); + if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE")); } + /** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */ + @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { - if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出"); - changeSelectedCartons(customer, application, line, "DISCARDED"); - commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD")); + if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数"); + if (!actualQuantityEditable(line) && actual != line.getDiscardPackages()) + throw conflict("必须完成本次申请所选全部箱子,不能漏出"); + // 已选择具体箱子的作业仍按箱唛结算,不能按数量猜测哪些箱已被废弃。 + if (!actualQuantityEditable(line)) changeSelectedCartons(customer, application, line, "DISCARDED"); + int physical = actualQuantityEditable(line) ? actual : physicalPackages(customer, application, line); + if (physical > 0) commands.discardHeld(customer, operator, move(application, line, physical, "DISCARD")); + int remaining = line.getDiscardPackages() - actual; + if (remaining > 0) commands.release(customer, operator, move(application, line, remaining, "ACTUAL_RELEASE")); } - private int changeBox(String customer, Long cartonId, String source, String from, String to) { - if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to); - if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); - return stock.changeGenericCartonStatus(customer, cartonId, from, to); + /** 同仓位同箱只由首个 SKU 明细执行库存命令;其他 SKU 保存件数事实,不重复扣物理箱桶。 */ + private int physicalPackages(String customer, CargoDiscardApplication application, CargoDiscardItem line) { + var marks = cartonMarks(line); + if (marks.isEmpty()) return line.getDiscardPackages(); + var previous = new HashSet(); + for (var other : repository.applicationItems(customer, application.getId())) { + if (Objects.equals(other.getInventoryPositionId(), line.getInventoryPositionId()) && other.getId() != null && line.getId() != null && other.getId() < line.getId()) previous.addAll(cartonMarks(other)); + } + return (int)marks.stream().filter(mark -> !previous.contains(mark)).count(); } + + /** 只有未保存具体箱唛的普通仓位库存允许仓库按实点箱数完成。 */ + public boolean actualQuantityEditable(CargoDiscardItem line) { + return "INVENTORY".equals(line.getSourceType()) && line.getInventoryPositionId() != null + && cartonMarks(line).isEmpty() && !Boolean.TRUE.equals(line.getCartonScanRequired()); + } + + /** 按箱来源执行带原状态条件的变更。 */ + private int changeBox(String customer, Long cartonId, String source, String from, String to) { + if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to); + if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); + return repository.changeInboundCarton(customer, cartonId, from, to); + } + /** 保存本次选箱事实,后续作业不得按数量猜测箱唛。 */ private void snapshotMarks(CargoDiscardItem line, List marks) { try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); } catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); } } - /** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */ + /** 仅变更当前申请明细保存的箱子,转运箱释放后恢复已清点状态。 */ private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) { - for (var link : links.listApplication(customer, application.getId())) { + for (var link : repository.selectedCartons(customer, application.getId())) { if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1) throw conflict("预留箱子的库存状态发生变化"); } } + /** 库存命令使用申请和明细构成的稳定来源与幂等键。 */ private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) { String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId(); return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(), null, cycle, cycle + ":" + action, "货物废弃 " + action); } + /** 读取并核对客户指定库存仓位。 */ public InventoryPosition position(String customer, Long id) { - var position = positions.findTenantPosition(customer, id); + var position = repository.position(customer, id); if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户"); return position; } + /** 只解析申请保存的箱唛,缺失对应关系保留为空。 */ public List cartonMarks(CargoDiscardItem line) { if (line.getCartonMarksJson() == null) return List.of(); try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); } catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); } } + /** 无 SKU 来源保留未指定标记,不猜测商品。 */ private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } + /** 空库存桶按零处理。 */ private static long value(Long value) { return value == null ? 0 : value; } + /** 返回可恢复的业务冲突。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index ede9b6852..a150fdfd0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -5,11 +5,6 @@ import java.util.List; /** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { - /** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ - default List findByDispatchOrder(String customerId, Long dispatchOrderId) { - return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList(); - } - @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index 45b9a0687..97f1b33a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -20,7 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; -import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; +import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation; @Service public class DispatchPickOrderAttributionService { @@ -39,7 +39,7 @@ public class DispatchPickOrderAttributionService { /** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */ @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) - public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List allocations) { + public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List allocations) { long packages=0,quantity=0;var seen=new java.util.HashSet(); for(var allocation:allocations) { var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java new file mode 100644 index 000000000..1b79200f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 一次真实拣货消费的冻结来源额度;件数未知时保留空,不接受请求伪造来源身份。 */ +public record DispatchSourceAllocation(Long dispatchItemSourceId, long packages, Long productQuantity) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java index 9aeb1e526..5c58a2348 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.entity.*; import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation; import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; @@ -23,9 +24,6 @@ public class DispatchItemSourceService { /** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */ public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;} - /** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */ - public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { } - /** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */ @Transactional(propagation=Propagation.MANDATORY) public void capture(DispatchAggregate root,List all,List added,LocalDateTime at) { @@ -108,7 +106,7 @@ public class DispatchItemSourceService { /** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */ @Transactional(propagation=Propagation.MANDATORY) - public List allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List actualStock) { + public List allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List actualStock) { var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p)); var expected=new HashMap(); for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) { @@ -127,14 +125,14 @@ public class DispatchItemSourceService { for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);} if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数"); if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数"); - var result=new ArrayList();long knownQuantity=0; + var result=new ArrayList();long knownQuantity=0; for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);} if(rows.size()>1) { if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数"); - for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));} + for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new DispatchSourceAllocation(row.getId(),remaining.get(row.getId()),quantity));} } else { var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致"); - result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); + result.add(new DispatchSourceAllocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 4f5fc7da3..5e157632c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -65,7 +65,7 @@ public class DispatchPickingService { boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); - List relabelAllocations=List.of(); + List relabelAllocations=List.of(); if(nativeReturn) { attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index d640aad0b..1691d5933 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -75,7 +75,9 @@ public record LastlegOrderSummary( LocalDateTime updatedAt, @Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序") List relabelApplications, - BusinessSource businessSource + BusinessSource businessSource, + @Schema(description = "仓储转运专属预报、历史实收及当前箱托数量;其他业务为空") + com.baiyue.lastleg.transfer.dto.TransferQuantityView warehouseTransferQuantity ) { @Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立") public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java index 161a4281d..18d1c5813 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java @@ -1,13 +1,14 @@ package com.baiyue.lastleg.fleet.pickup; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import org.apache.ibatis.annotations.*; import java.util.List; /** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */ @Mapper public interface FleetPickupMapper extends BaseMapper { + String SOURCE_COLUMNS = "id,customer_id,transfer_no,inbound_code,lastleg_order_no,operating_warehouse_id," + + "delivery_address,pickup_contact_name,pickup_contact_phone,package_count,status,delivery_method"; String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id " + "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code " + "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) " @@ -21,14 +22,11 @@ public interface FleetPickupMapper extends BaseMapper { @Select("SELECT COUNT(*)" + ELIGIBLE) long countCandidates(@Param("keyword") String keyword); /** 锁定源订单,串行化补箱、清点与取货任务关联。 */ - @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") - WarehouseTransferOrder lockSource(@Param("customer") String customer,@Param("number") String number); + @Select("SELECT " + SOURCE_COLUMNS + " FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") + FleetPickupSource lockSource(@Param("customer") String customer,@Param("number") String number); /** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */ - @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}") - WarehouseTransferOrder source(@Param("customer") String customer,@Param("number") String number); - /** PDA 作业仍按已绑定海外仓锁定原单。 */ - @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouse} AND transfer_no=#{number} FOR UPDATE") - WarehouseTransferOrder lockPdaSource(@Param("warehouse") String warehouse,@Param("number") String number); + @Select("SELECT " + SOURCE_COLUMNS + " FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}") + FleetPickupSource source(@Param("customer") String customer,@Param("number") String number); /** 读取取货任务,原订单锁负责同票并发互斥。 */ @Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}") FleetPickupTask task(@Param("id") long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java index 70eb1ebd5..ceb88b69c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java @@ -2,7 +2,6 @@ package com.baiyue.lastleg.fleet.pickup; import com.baiyue.lastleg.fleet.trip.FleetTripEntity; import com.baiyue.lastleg.fleet.trip.FleetTripMapper; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import org.springframework.stereotype.Repository; import java.util.List; @@ -18,9 +17,7 @@ public class FleetPickupRepository { /** 查询匹配需求总数。 */ public long count(String keyword) { return mapper.countCandidates(keyword); } /** 读取或锁定客户原单。 */ - public WarehouseTransferOrder source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); } - /** 按 PDA 仓库范围锁定原单。 */ - public WarehouseTransferOrder pdaSource(String warehouse,String number) { return mapper.lockPdaSource(warehouse,number); } + public FleetPickupSource source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); } /** 读取原单唯一取货记录。 */ public FleetPickupTask task(long id) { return mapper.task(id); } /** 读取车次全部取货成员。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java index f454ebfb4..50002ec18 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -6,7 +6,6 @@ import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory; import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.HttpStatus; @@ -59,7 +58,7 @@ public class FleetPickupService { var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId) .thenComparing(FleetPickupModels.Source::transferNo)).toList(); if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择"); - var orders=new ArrayList(); + var orders=new ArrayList(); for(var source:sources) { var order=source(source.customerId(),source.transferNo(),true);eligible(order); // 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。 @@ -107,18 +106,10 @@ public class FleetPickupService { if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null; return project(source,repository.task(source.getId())); } - /** PDA 开始作业前校验已送仓,防止把司机取货冒充仓库实收。 */ - public WarehouseTransferOrder requireMeasuringSource(String warehouse,String number) { - var source=repository.pdaSource(warehouse,number); - if(source==null)throw missing("仓储转运单不存在"); - if(!"WAIT_PDA_RECEIVING".equals(source.getStatus()))throw conflict("当前转运单不允许测量"); - requireArrived(source); - return source; - } /** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */ - public void requireArrived(WarehouseTransferOrder source) { - if("WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) && source.getTransferNo().equals(source.getInboundCode())) { - var task=repository.task(source.getId()); + public void requireArrived(Long sourceId,String deliveryMethod,String transferNo,String inboundCode) { + if("WAREHOUSE_PICKUP".equals(deliveryMethod) && transferNo.equals(inboundCode)) { + var task=repository.task(sourceId); if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓"); } } @@ -198,7 +189,7 @@ public class FleetPickupService { var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId); } /** 组装运输进度,车次资源修改后自然回显最新分配。 */ - private FleetPickupModels.Task project(WarehouseTransferOrder source,FleetPickupTask task) { + private FleetPickupModels.Task project(FleetPickupSource source,FleetPickupTask task) { var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false); String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus(); return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(), @@ -215,11 +206,11 @@ public class FleetPickupService { if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task; } /** 按客户与单号读取原订单。 */ - private WarehouseTransferOrder source(String customer,String number,boolean lock) { + private FleetPickupSource source(String customer,String number,boolean lock) { var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source; } /** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */ - private static void eligible(WarehouseTransferOrder order) { + private static void eligible(FleetPickupSource order) { if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode()) || !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单"); if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java new file mode 100644 index 000000000..3fa120ccc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.pickup; + +import lombok.Data; + +/** 车队只读取货资格和展示所需来源摘要,不共享转运领域实体或写入原订单。 */ +@Data +public class FleetPickupSource { + private Long id; + private String customerId; + private String transferNo; + private String inboundCode; + private String lastlegOrderNo; + private String operatingWarehouseId; + private String deliveryAddress; + private String pickupContactName; + private String pickupContactPhone; + private Integer packageCount; + private String status; + private String deliveryMethod; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 05c721b56..37a5a5a17 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -35,6 +35,16 @@ public class InventoryQueryController { private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; @Resource private InventoryQueryService service; + @Resource private com.baiyue.lastleg.inventory.query.service.InventoryPieceService pieces; + + /** 完整筛选读取当前可用件数、箱内全部SKU及未知箱数,不改变原有箱数契约。 */ + @GetMapping(BASE_PATH + "/piece-projection") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + public ApiResponse pieceProjection( + @AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(pieces.project(platformScope(user,customerId),query)); + } @GetMapping(BASE_PATH + "/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java new file mode 100644 index 000000000..7ac33301c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inventory.query.dto; +import java.util.List; +/** 当前严格可用件数与完整混箱SKU内容;不完整映射保持未知,不覆盖原有箱数API。 */ +public record InventoryPieceProjection(Long availablePieceCount,long knownAvailablePieceCount,long unknownCartonCount, + long availableCartonCount,long availablePalletCount,List groups) { + /** 与通用库存按客户、仓、目的仓、记账SKU分组对齐,箱内SKU不重复累计物理箱。 */ + public record Group(String customerId,String warehouseId,String destinationCode,String anchorSkuCode, + Long availablePieceCount,long knownAvailablePieceCount,long unknownCartonCount,long availableCartonCount, + long availablePalletCount,List skuContents,List cartons) { } + /** 箱内实际SKU件数。 */ + public record Sku(String skuCode,long quantity) { } + /** 保留箱唛及所在仓位,混箱SKU共享同一物理箱。 */ + public record Carton(String cartonMark,Long inventoryPositionId,Long palletId,Long quantity,List items) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java new file mode 100644 index 000000000..2563379a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 从原箱清点事实及实际目的仓签收定位箱内SKU,不按首SKU或箱规换算件数。 */ +@Mapper +public interface InventoryPieceMapper { + String POSITION_CTE = "WITH balances AS (SELECT * FROM ll_inventory_balance " + InventoryQueryMapper.BALANCE_FILTER + """ + ), occupied AS ( + SELECT DISTINCT t.customer_id,t.operating_warehouse_id warehouse_id,t.inbound_code, + COALESCE(NULLIF(c.customer_carton_mark,''),c.carton_mark) carton_mark + FROM ll_warehouse_transfer_order t JOIN ll_warehouse_transfer_carton c + ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id + -- 出库已扣减来源箱,后续签收生成目的仓库存,不再把同一原箱当作待出库预留。 + WHERE t.transfer_no<>t.inbound_code AND t.status NOT IN + ('DRAFT','REJECTED','CANCELLED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED') + ), raw_positions AS (SELECT p.* FROM ll_inventory_position p JOIN balances b + ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.available_packages>0), + located AS ( + SELECT p.id position_id,c.id carton_id,c.carton_mark,c.counted_quantity,c.sku_items_json + FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN ll_warehouse_transfer_order t ON t.customer_id=b.customer_id AND t.inbound_order_id=b.inbound_order_id + AND t.operating_warehouse_id=b.overseas_warehouse_id AND t.transfer_no=t.inbound_code + JOIN ll_warehouse_transfer_pallet tp ON tp.transfer_order_id=t.id AND tp.customer_id=t.customer_id AND tp.warehouse_pallet_id=p.pallet_id + JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id + AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' + JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND (b.sku_code='MIXED' OR i.sku_code=b.sku_code) + UNION + SELECT p.id,c.id,c.carton_mark,c.counted_quantity,c.sku_items_json + FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN ll_warehouse_transfer_receipt r ON r.destination_inventory_position_id=p.id AND r.customer_id=p.customer_id + JOIN ll_warehouse_transfer_inventory_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id AND a.status='RECEIVED' + JOIN ll_inventory_position sp ON sp.id=a.inventory_position_id AND sp.customer_id=a.customer_id + JOIN ll_warehouse_transfer_order outgoing ON outgoing.id=a.transfer_order_id AND outgoing.customer_id=a.customer_id + JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=outgoing.id AND chosen.customer_id=outgoing.customer_id + JOIN ll_warehouse_transfer_order origin ON origin.customer_id=outgoing.customer_id AND origin.inbound_code=outgoing.inbound_code + AND origin.operating_warehouse_id=outgoing.operating_warehouse_id AND origin.transfer_no=origin.inbound_code + JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=origin.id AND c.customer_id=origin.customer_id + AND c.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND c.status='SHIPPED' + JOIN ll_warehouse_transfer_pallet tp ON tp.id=c.transfer_pallet_id AND tp.customer_id=c.customer_id AND tp.warehouse_pallet_id=sp.pallet_id + JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND (b.sku_code='MIXED' OR i.sku_code=b.sku_code) + ), reservations AS ( + SELECT p.id position_id,COUNT(DISTINCT l.carton_id) reserved_count + FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN located l ON l.position_id=p.id JOIN occupied o ON o.customer_id=b.customer_id + AND o.warehouse_id=b.overseas_warehouse_id AND o.inbound_code=b.inbound_code AND o.carton_mark=l.carton_mark + GROUP BY p.id + ), positions AS ( + SELECT p.*,GREATEST(0,p.available_packages-COALESCE(r.reserved_count,0)) free_packages + FROM raw_positions p LEFT JOIN reservations r ON r.position_id=p.id + ) + SELECT p.id positionId,p.balance_id balanceId,b.customer_id customerId,b.overseas_warehouse_id warehouseId, + b.destination_warehouse_code destinationCode,b.sku_code anchorSkuCode,p.pallet_id palletId, + p.free_packages availablePackages,l.carton_id cartonId,l.carton_mark cartonMark, + l.counted_quantity countedQuantity,l.sku_items_json skuItemsJson + FROM positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + LEFT JOIN located l ON l.position_id=p.id AND NOT EXISTS ( + SELECT 1 FROM occupied o WHERE o.customer_id=b.customer_id AND o.warehouse_id=b.overseas_warehouse_id + AND o.inbound_code=b.inbound_code AND o.carton_mark=l.carton_mark) + WHERE p.free_packages>0 + ORDER BY p.id,l.carton_id + """; + + /** 完整筛选取箱级投影;页码/每页大小不参与查询。 */ + @Select("") + List rows(@Param("customerId") String customerId,@Param("q") InventoryQueryRequests.BalanceQuery query); + + /** 每个当前可用仓位及其匹配箱事实;未知箱以空cartonId表达。 */ + record PieceRow(Long positionId,Long balanceId,String customerId,String warehouseId,String destinationCode, + String anchorSkuCode,Long palletId,long availablePackages,Long cartonId,String cartonMark,Long countedQuantity,String skuItemsJson) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 03702fad3..1aa57889d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -24,7 +24,7 @@ public interface InventoryQueryMapper { " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + - " AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND (sku_code LIKE CONCAT('%',#{q.skuCode},'%') OR EXISTS (SELECT 1 FROM ll_warehouse_transfer_order tq JOIN ll_warehouse_transfer_carton cq ON cq.transfer_order_id=tq.id AND cq.customer_id=tq.customer_id WHERE tq.customer_id=ll_inventory_balance.customer_id AND tq.inbound_order_id=ll_inventory_balance.inbound_order_id AND tq.operating_warehouse_id=ll_inventory_balance.overseas_warehouse_id AND tq.transfer_no=tq.inbound_code AND cq.status IN ('COUNTED','DISCARD_PENDING') AND EXISTS (SELECT 1 FROM ll_warehouse_transfer_item iq WHERE iq.id=cq.transfer_item_id AND iq.customer_id=cq.customer_id AND (ll_inventory_balance.sku_code='MIXED' OR iq.sku_code=ll_inventory_balance.sku_code)) AND JSON_SEARCH(cq.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL) OR EXISTS (SELECT 1 FROM ll_inventory_position dp JOIN ll_warehouse_transfer_receipt rr ON rr.destination_inventory_position_id=dp.id AND rr.customer_id=dp.customer_id JOIN ll_warehouse_transfer_inventory_allocation aa ON aa.id=rr.allocation_id AND aa.customer_id=rr.customer_id AND aa.status='RECEIVED' JOIN ll_inventory_position sp ON sp.id=aa.inventory_position_id AND sp.customer_id=aa.customer_id JOIN ll_warehouse_transfer_order ot ON ot.id=aa.transfer_order_id AND ot.customer_id=aa.customer_id JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=ot.id AND chosen.customer_id=ot.customer_id JOIN ll_warehouse_transfer_order origin ON origin.customer_id=ot.customer_id AND origin.inbound_code=ot.inbound_code AND origin.operating_warehouse_id=ot.operating_warehouse_id AND origin.transfer_no=origin.inbound_code JOIN ll_warehouse_transfer_carton cc ON cc.transfer_order_id=origin.id AND cc.customer_id=origin.customer_id AND cc.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND cc.status='SHIPPED' JOIN ll_warehouse_transfer_pallet pp ON pp.id=cc.transfer_pallet_id AND pp.customer_id=cc.customer_id AND pp.warehouse_pallet_id=sp.pallet_id JOIN ll_warehouse_transfer_item ii ON ii.id=cc.transfer_item_id AND ii.customer_id=cc.customer_id WHERE dp.balance_id=ll_inventory_balance.id AND dp.customer_id=ll_inventory_balance.customer_id AND dp.available_packages>0 AND (ll_inventory_balance.sku_code='MIXED' OR ii.sku_code=ll_inventory_balance.sku_code) AND JSON_SEARCH(cc.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL))" + " AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + @@ -48,7 +48,7 @@ public interface InventoryQueryMapper { " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + - " AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND (b.sku_code LIKE CONCAT('%',#{q.skuCode},'%') OR EXISTS (SELECT 1 FROM ll_warehouse_transfer_order tq JOIN ll_warehouse_transfer_carton cq ON cq.transfer_order_id=tq.id AND cq.customer_id=tq.customer_id WHERE tq.customer_id=b.customer_id AND tq.inbound_order_id=b.inbound_order_id AND tq.operating_warehouse_id=b.overseas_warehouse_id AND tq.transfer_no=tq.inbound_code AND cq.status IN ('COUNTED','DISCARD_PENDING') AND EXISTS (SELECT 1 FROM ll_warehouse_transfer_item iq WHERE iq.id=cq.transfer_item_id AND iq.customer_id=cq.customer_id AND (b.sku_code='MIXED' OR iq.sku_code=b.sku_code)) AND JSON_SEARCH(cq.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL) OR EXISTS (SELECT 1 FROM ll_inventory_position dp JOIN ll_warehouse_transfer_receipt rr ON rr.destination_inventory_position_id=dp.id AND rr.customer_id=dp.customer_id JOIN ll_warehouse_transfer_inventory_allocation aa ON aa.id=rr.allocation_id AND aa.customer_id=rr.customer_id AND aa.status='RECEIVED' JOIN ll_inventory_position sp ON sp.id=aa.inventory_position_id AND sp.customer_id=aa.customer_id JOIN ll_warehouse_transfer_order ot ON ot.id=aa.transfer_order_id AND ot.customer_id=aa.customer_id JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=ot.id AND chosen.customer_id=ot.customer_id JOIN ll_warehouse_transfer_order origin ON origin.customer_id=ot.customer_id AND origin.inbound_code=ot.inbound_code AND origin.operating_warehouse_id=ot.operating_warehouse_id AND origin.transfer_no=origin.inbound_code JOIN ll_warehouse_transfer_carton cc ON cc.transfer_order_id=origin.id AND cc.customer_id=origin.customer_id AND cc.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND cc.status='SHIPPED' JOIN ll_warehouse_transfer_pallet pp ON pp.id=cc.transfer_pallet_id AND pp.customer_id=cc.customer_id AND pp.warehouse_pallet_id=sp.pallet_id JOIN ll_warehouse_transfer_item ii ON ii.id=cc.transfer_item_id AND ii.customer_id=cc.customer_id WHERE dp.balance_id=b.id AND dp.customer_id=b.customer_id AND dp.available_packages>0 AND (b.sku_code='MIXED' OR ii.sku_code=b.sku_code) AND JSON_SEARCH(cc.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL))" + " AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java new file mode 100644 index 000000000..3cdef3329 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.repository; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; +/** 统一库存混箱事实查询入口。 */ +@Repository +public class InventoryPieceRepository { + @Resource private InventoryPieceMapper mapper; + /** 读取相同筛选范围的全部当前可用箱。 */ + public List rows(String customerId,InventoryQueryRequests.BalanceQuery query) { + return mapper.rows(customerId,query); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java new file mode 100644 index 000000000..c2877f4de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.inventory.query.service; +import com.baiyue.lastleg.inventory.query.dto.*; +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow; +import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import java.util.stream.Collectors; + +/** 用完整箱内清点投影当前可用件数,对缺失或不完整映射显式返回未知。 */ +@Service +public class InventoryPieceService { + @Resource private InventoryPieceRepository repository; + @Resource private ObjectMapper objectMapper; + /** 完整筛选的多分组汇总,同一物理托盘按客户及仓去重而非相减。 */ + @Transactional(readOnly=true) + public InventoryPieceProjection project(String customerId,InventoryQueryRequests.BalanceQuery query) { + var rows=repository.rows(customerId,query); + var groups=rows.stream().collect(Collectors.groupingBy(r -> Arrays.asList(r.customerId(),r.warehouseId(),r.destinationCode(),r.anchorSkuCode()),LinkedHashMap::new,Collectors.toList())); + var result=new ArrayList(); var pallets=new HashSet(); + for(var group:groups.values()) { + result.add(group(group)); + for(var row:group) if(row.palletId()!=null) pallets.add(row.customerId()+":"+row.warehouseId()+":"+row.palletId()); + } + long known=0,unknown=0,cartons=0; + for(var group:result) { known=Math.addExact(known,group.knownAvailablePieceCount()); unknown+=group.unknownCartonCount(); cartons+=group.availableCartonCount(); } + return new InventoryPieceProjection(unknown==0?known:null,known,unknown,cartons,pallets.size(),result); + } + + /** 每个仓位只有精确箱映射完整时才计入已知件数,不能从多余箱中截取或按比例推算。 */ + private InventoryPieceProjection.Group group(List rows) { + var positions=rows.stream().collect(Collectors.groupingBy(PieceRow::positionId,LinkedHashMap::new,Collectors.toList())); + var contents=new TreeMap(); var cartons=new ArrayList(); + var pallets=new HashSet(); long available=0,unknown=0,known=0; + for(var position:positions.values()) { + var first=position.get(0); available+=first.availablePackages(); if(first.palletId()!=null) pallets.add(first.palletId()); + var boxes=position.stream().filter(r -> r.cartonId()!=null).collect(Collectors.toMap(PieceRow::cartonId,r -> r,(a,b)->a,LinkedHashMap::new)); + var parsed=new ArrayList(); + for(var row:boxes.values()) parsed.add(carton(row)); + if(boxes.size()!=first.availablePackages() || parsed.stream().anyMatch(c -> c.quantity()==null)) { unknown+=first.availablePackages(); continue; } + for(var box:parsed) { + cartons.add(box); known=Math.addExact(known,box.quantity()); + for(var sku:box.items()) contents.merge(sku.skuCode(),sku.quantity(),Math::addExact); + } + } + var first=rows.get(0); + return new InventoryPieceProjection.Group(first.customerId(),first.warehouseId(),first.destinationCode(),first.anchorSkuCode(), + unknown==0?known:null,known,unknown,available,pallets.size(),contents.entrySet().stream().map(e -> new InventoryPieceProjection.Sku(e.getKey(),e.getValue())).toList(),cartons); + } + + /** 清点JSON与箱总件数必须一致;缺失信息保持未知,不拿首SKU替代混箱。 */ + private InventoryPieceProjection.Carton carton(PieceRow row) { + var items=new ArrayList(); Long quantity=null; + try { + var lines=objectMapper.readTree(row.skuItemsJson()); long sum=0; + if(lines.isArray() && !lines.isEmpty()) { + for(var line:lines) { + String sku=line.path("skuCode").asText(""); var count=line.path("quantity"); + if(sku.isBlank() || !count.asText().matches("[0-9]+")) throw new IllegalArgumentException("无效箱内SKU事实"); + long pieces=Long.parseLong(count.asText()); + if(pieces<=0) throw new IllegalArgumentException("箱内件数必须为正整数"); + items.add(new InventoryPieceProjection.Sku(sku,pieces)); sum=Math.addExact(sum,pieces); + } + if(row.countedQuantity()!=null && sum==row.countedQuantity()) quantity=sum; + } + } catch(Exception invalid) { items.clear(); } + return new InventoryPieceProjection.Carton(row.cartonMark(),row.positionId(),row.palletId(),quantity,items); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java new file mode 100644 index 000000000..6ea260164 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.palletops.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.palletops.service.PalletOperationRecoveryService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import jakarta.validation.Valid; + +/** 受库存管理权限保护的人工恢复入口,不允许PDA自行批准技术状态关闭。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/pallet-operation-recoveries") +@PreAuthorize("hasAuthority('lastleg:inventory:manage')") +public class PlatformPalletOperationRecoveryController { + private final PalletOperationRecoveryService service; + /** 注入恢复业务服务。 */ + public PlatformPalletOperationRecoveryController(PalletOperationRecoveryService service){this.service=service;} + /** 管理员读取原作业证据。 */ + @GetMapping("/{id}") public ApiResponse inspect(@PathVariable long id){return ApiResponse.success(service.inspect(id));} + /** 以认证管理员身份审计关闭。 */ + @PostMapping("/{id}/close-no-effect") + public ApiResponse close(@PathVariable long id,@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PalletOperationRecoveryService.Close request){return ApiResponse.success(service.close(id,String.valueOf(user.userId()),request));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java new file mode 100644 index 000000000..1b2518fff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.palletops.mapper; + +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; + +/** 恢复与远端意图使用同一行CAS,恢复关闭后旧执行线程不能继续发起远端写入。 */ +@Mapper +public interface PalletOperationRecoveryMapper { + /** 读取原作业技术状态。 */ + @Select("SELECT * FROM ll_pda_pallet_operation WHERE id=#{id}") + PdaPalletOperation find(@Param("id") long id); + /** 恢复审批与远端意图按原行串行。 */ + @Select("SELECT * FROM ll_pda_pallet_operation WHERE id=#{id} FOR UPDATE") + PdaPalletOperation lock(@Param("id") long id); + /** 恢复关闭后拒绝继续持久化远端意图。 */ + @Update("UPDATE ll_pda_pallet_operation SET remote_transitions_json=#{json},updated_at=#{at} WHERE id=#{id} AND status='PROCESSING'") + int journal(@Param("id") long id,@Param("json") String json,@Param("at") LocalDateTime at); + /** 条件关闭原作业并保存恢复审计。 */ + @Update("UPDATE ll_pda_pallet_operation SET status='RECOVERED_NO_EFFECT',result_json=#{audit},updated_at=#{at} WHERE id=#{id} AND status IN ('PROCESSING','FAILED_COMPENSATED')") + int close(@Param("id") long id,@Param("audit") String audit,@Param("at") LocalDateTime at); + /** 旧执行线程不能用失败结果覆盖已恢复或已完成终态。 */ + @Update("UPDATE ll_pda_pallet_operation SET status=#{status},last_error=#{error},updated_at=#{at} WHERE id=#{id} AND status NOT IN ('RECOVERED_NO_EFFECT','COMPLETED')") + int fail(@Param("id") long id,@Param("status") String status,@Param("error") String error,@Param("at") LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index 72d0d164f..a3bf3a31e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -53,11 +53,15 @@ public interface PdaPalletOperationMapper extends BaseMapper @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) List mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List applicationNos); - /** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */ + /** 收齐真实尾程订单;入仓快照ID经同客户来源分片解析,缺失关联保留空值以拒绝改绑。 */ @Select("") + ReturnOrderStatistics.Totals merchantTotals(@Param("customerId") String customerId, @Param("q") com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Query query); + @Select("") + List merchantDimensions(@Param("customerId") String customerId, @Param("q") com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Query query, @Param("size") long size, @Param("offset") long offset); + @Select("") + ReturnOrderStatistics.Totals platformTotals(@Param("ew") Wrapper wrapper); + @Select("") + List platformDimensions(@Param("ew") Wrapper wrapper, @Param("size") long size, @Param("offset") long offset); +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java new file mode 100644 index 000000000..af1321982 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.returnreceipt.repository; + +import com.baiyue.lastleg.returnreceipt.dto.*; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderStatisticsMapper; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.query.LastlegOrderFilter; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.springframework.stereotype.Repository; + +/** 列表统计只经本域Mapper读取正式订单与签收事实。 */ +@Repository +public class ReturnOrderStatisticsRepository { + private final ReturnOrderStatisticsMapper mapper; + /** 注入订单统计映射。 */ + public ReturnOrderStatisticsRepository(ReturnOrderStatisticsMapper mapper) { this.mapper=mapper; } + /** 客户范围来自认证身份,分页不进入总计查询。 */ + public ReturnOrderStatistics merchant(String customerId, ReturnOrderRequests.Query query) { + return new ReturnOrderStatistics(mapper.merchantTotals(customerId,query), + mapper.merchantDimensions(customerId,query,query.size(),Math.multiplyExact(query.page()-1,query.size()))); + } + /** 与平台尾程列表使用同一筛选构造器,只统计其中原生退货单。 */ + public ReturnOrderStatistics platform(LastlegOrderRequests.Query query, String relabelNo) { + var filter=LastlegOrderFilter.create(null,query,relabelNo); + return new ReturnOrderStatistics(mapper.platformTotals(filter), + mapper.platformDimensions(filter,query.size(),Math.multiplyExact(query.page()-1,query.size()))); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java new file mode 100644 index 000000000..04e333026 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baiyue.lastleg.returnreceipt.dto.*; +import com.baiyue.lastleg.returnreceipt.repository.ReturnOrderStatisticsRepository; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 汇总和当前页维度在同一只读快照内返回,避免签收并发时前后口径不一致。 */ +@Service +public class ReturnOrderStatisticsService { + private final ReturnOrderStatisticsRepository repository; + /** 注入只读统计仓储。 */ + public ReturnOrderStatisticsService(ReturnOrderStatisticsRepository repository) { this.repository=repository; } + /** 当前客户的总计与页内维度使用同一只读快照。 */ + @Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public ReturnOrderStatistics merchant(String customerId, ReturnOrderRequests.Query query) { return repository.merchant(customerId,query); } + /** 平台筛选范围中的退货总计与混合列表页内维度。 */ + @Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public ReturnOrderStatistics platform(LastlegOrderRequests.Query query, String relabelNo) { return repository.platform(query,relabelNo); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 6ae026a6d..6e556f464 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -86,6 +86,7 @@ public class LastlegOrderService { @Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ReturnOrderReferenceResolver returnOrderReferences; + @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; public PageResult page(String customerId, LastlegOrderRequests.Query query) { return page(customerId, query, null); @@ -102,11 +103,12 @@ public class LastlegOrderService { Map weights = orderWeights(page.getRecords()); Map> relabels = relabelSubOrders(page.getRecords()); Map businessSources = businessSources(page.getRecords()); + var quantities = transferQuantities.forOrders(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), - relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()))).toList(), + relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -363,7 +365,7 @@ public class LastlegOrderService { return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), - businessSources(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() + businessSources(List.of(order)).get(order.getId()), transferQuantities.forOrders(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -532,45 +534,18 @@ public class LastlegOrderService { return orders.size(); } + /** 与主列表复用同一筛选,汇总只纳入仓储转运且不使用分页参数。 */ + public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals transferQuantityTotals( + String customerId, LastlegOrderRequests.Query query, String relabelNo) { + return transferQuantities.totals(filter(customerId, query, relabelNo).eq(LastlegOrder::getOrderType,"WAREHOUSE_TRANSFER")); + } + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { return filter(customerId, query, null); } private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) { - if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); - } - String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); - String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null; - return new LambdaQueryWrapper() - .ne(LastlegOrder::getOrderType, "CARGO_DISCARD") - .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) - .and(StringUtils.hasText(query.orderNo()), nested -> nested - .like(LastlegOrder::getOrderNo, query.orderNo()) - .or() - .like(LastlegOrder::getSourceOrderNo, query.orderNo())) - .and(StringUtils.hasText(relabelNo), nested -> nested - .like(LastlegOrder::getRelabelOrderNo, relabelNo) - .or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + - "AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo)) - .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) - .apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_FILTER_SQL, query.businessType()) - .apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_FILTER_SQL, query.businessOrderNo()) - .apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo()) - .apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL, - query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0) - .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) - .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) - .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) - .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) - .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) - .apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse) - .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, - query.transferWarehouseCode()) - .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) - .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) - .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) - .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); + return com.baiyue.lastleg.query.LastlegOrderFilter.create(customerId, query, relabelNo); } private LastlegOrder findOwned(String customerId, long orderId) { @@ -647,7 +622,8 @@ public class LastlegOrderService { String containerNode, String dispatchStatus, LastlegOrderMapper.OrderWeightRow weight, List relabelApplications, - LastlegOrderSummary.BusinessSource businessSource) { + LastlegOrderSummary.BusinessSource businessSource, + com.baiyue.lastleg.transfer.dto.TransferQuantityView transferQuantity) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -665,7 +641,7 @@ public class LastlegOrderService { o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), - o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource); + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource, transferQuantity); } private static Integer packageDifference(LastlegOrder order) { @@ -722,3 +698,4 @@ public class LastlegOrderService { } } + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 35ba560dd..193265be0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -77,6 +77,14 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.submit(customer(user), transferNo, operator(user))); } + /** 客户当前筛选的全部转运数量,不受页码影响。 */ + @GetMapping("/quantities") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") + public ApiResponse quantities( + @AuthenticationPrincipal AuthenticatedUser user,@Valid WarehouseTransferRequests.Query query) { + return ApiResponse.success(service.quantityTotals(customer(user),query)); + } + @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java new file mode 100644 index 000000000..1eec2f53f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.transfer.dto; + +/** 全筛选结果汇总,不受分页影响;已知小计与未知票数同时返回。 */ +public record TransferQuantityTotals(long orderCount, Long declaredPieceCount, long knownDeclaredPieceCount, + long unknownDeclaredOrderCount, Long receivedPieceCount, long knownReceivedPieceCount, + long unknownReceivedOrderCount, long declaredCartonCount, long receivedCartonCount, + long receivedPalletCount, long availableCartonCount, long shippedCartonCount, + long remainingPalletCount, long shippedPalletCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java new file mode 100644 index 000000000..5776cc3d2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.transfer.dto; + +/** 分离客户预报、历史实收和当前箱状态;未知件数保持空值。 */ +public record TransferQuantityView(Long lastlegOrderId, Long declaredPieceCount, Long receivedPieceCount, + long declaredCartonCount, long receivedCartonCount, long receivedPalletCount, + long availableCartonCount, long shippedCartonCount, long remainingPalletCount, long shippedPalletCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 21f4fc5b5..f7b1a01dc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -212,7 +212,10 @@ public final class WarehouseTransferRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String transferNo, String inboundCode, String operatingWarehouseId, String fbaWarehouseCode, String status, Boolean needRelabel, - LocalDateTime createdFrom, LocalDateTime createdTo) { + LocalDateTime createdFrom, LocalDateTime createdTo, + LocalDateTime reviewedFrom, LocalDateTime reviewedTo, + LocalDateTime measuredFrom, LocalDateTime measuredTo, + LocalDateTime outboundFrom, LocalDateTime outboundTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java new file mode 100644 index 000000000..2c5fe9502 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.TransferQuantityView; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 使用主订单相同筛选读取转运数量事实,不把历史实收与剩余库存相减。 */ +@Mapper +public interface TransferQuantityMapper { + String SELECTED = "SELECT id,customer_id FROM ll_order ${ew.customSqlSegment}"; + String RECEIVED = "c.counted_at IS NOT NULL AND c.status IN ('COUNTED','DISCARD_PENDING','DISCARDED','SHIPPED')"; + String FACTS = """ + SELECT t.lastleg_order_id lastlegOrderId, + (SELECT CASE WHEN COUNT(*)=0 OR COUNT(*)<>COUNT(i.declared_quantity) THEN NULL ELSE SUM(i.declared_quantity) END + FROM ll_warehouse_transfer_item i WHERE i.customer_id=t.customer_id AND i.transfer_order_id=t.id) declaredPieceCount, + (SELECT CASE WHEN COUNT(*)=0 OR COUNT(*)<>COUNT(c.counted_quantity) THEN NULL ELSE SUM(c.counted_quantity) END + FROM ll_warehouse_transfer_carton c WHERE c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND + """ + RECEIVED + """ + ) receivedPieceCount, + t.package_count declaredCartonCount, + COALESCE(f.receivedCartonCount,0) receivedCartonCount, COALESCE(f.receivedPalletCount,0) receivedPalletCount, + COALESCE(f.availableCartonCount,0) availableCartonCount, COALESCE(f.shippedCartonCount,0) shippedCartonCount, + COALESCE(f.remainingPalletCount,0) remainingPalletCount, COALESCE(f.shippedPalletCount,0) shippedPalletCount + FROM ll_warehouse_transfer_order t JOIN ( + """ + SELECTED + """ + ) selected ON selected.id=t.lastleg_order_id AND selected.customer_id=t.customer_id + LEFT JOIN ( + SELECT c.customer_id,c.transfer_order_id, + """ + "SUM(" + RECEIVED + ") receivedCartonCount," + + "COUNT(DISTINCT CASE WHEN " + RECEIVED + " THEN p.warehouse_pallet_id END) receivedPalletCount," + """ + SUM(c.status='COUNTED') availableCartonCount, SUM(c.status='SHIPPED') shippedCartonCount, + COUNT(DISTINCT CASE WHEN c.status IN ('COUNTED','DISCARD_PENDING') THEN p.warehouse_pallet_id END) remainingPalletCount, + COUNT(DISTINCT CASE WHEN c.status='SHIPPED' THEN p.warehouse_pallet_id END) shippedPalletCount + FROM ll_warehouse_transfer_carton c LEFT JOIN ll_warehouse_transfer_pallet p + ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id + GROUP BY c.customer_id,c.transfer_order_id + ) f ON f.transfer_order_id=t.id AND f.customer_id=t.customer_id + """; + + /** 一次读取筛选范围或本页订单的预报及实收事实。 */ + @Select("") + List rows(@Param("ew") Wrapper filter); + + /** 按真实托盘身份在完整筛选集合去重,同托部分出库可同时属于实收、剩余、出库集合。 */ + @Select("") + List pallets(@Param("ew") Wrapper filter); + + /** 物理托盘事实,保留客户隔离。 */ + record PalletFact(String customerId, Long palletId, String status) { } + + /** 库存转运目的仓实收依正式签收凭据;件数只取精确选箱对应原清点事实。 */ + @Select("") + List movements(@Param("ew") Wrapper filter); + + /** 每个分配来源的实际出库与目的仓签收事实。 */ + record MovementFact(Long lastlegOrderId,String customerId,long cartonCount,Long sourcePalletId,String status, + Long receiptId,Long destinationPalletId,Long pieceCount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java new file mode 100644 index 000000000..4015236ee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.TransferQuantityView; +import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 转运数量只读持久化入口。 */ +@Repository +public class TransferQuantityRepository { + @Resource private TransferQuantityMapper mapper; + @Resource private com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper transfers; + /** 以客户列表相同筛选读取关联主订单,不读取分页参数。 */ + public List selectedOrderIds(Wrapper filter) { + return transfers.selectList(filter).stream() + .map(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getLastlegOrderId) + .filter(java.util.Objects::nonNull).distinct().toList(); + } + /** 读取完整筛选集合的事实,分页由主列表单独处理。 */ + public List rows(Wrapper filter) { return mapper.rows(filter); } + /** 读取完整筛选集合的物理托盘。 */ + public List pallets(Wrapper filter) { return mapper.pallets(filter); } + /** 读取库存转运实际出库及目的仓签收,按来源分配去重。 */ + public List movements(Wrapper filter) { return mapper.movements(filter); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java new file mode 100644 index 000000000..c37617cea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.*; +import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import java.util.function.ToLongFunction; +import java.util.stream.Collectors; + +/** 预报、实收、当前剩余分别汇总,空件数不会被零值掩盖。 */ +@Service +public class TransferQuantityService { + @Resource private TransferQuantityRepository repository; + + /** 客户列表共享完整筛选,主订单查询仍显式限定客户。 */ + @Transactional(readOnly=true) + public TransferQuantityTotals merchantTotals(Wrapper filter, String customerId) { + var ids = repository.selectedOrderIds(filter); + var orders = new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId); + if (ids.isEmpty()) orders.apply("1=0"); else orders.in(LastlegOrder::getId, ids); + return totals(orders); + } + + /** 主列表批量加载本页转运,其他业务不改变字段语义。 */ + public Map forOrders(List orders) { + var ids=orders.stream().filter(o -> "WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(LastlegOrder::getId).toList(); + if(ids.isEmpty()) return Map.of(); + var filter = new LambdaQueryWrapper().in(LastlegOrder::getId,ids); + return withMovements(repository.rows(filter),repository.movements(filter)) + .stream().collect(Collectors.toMap(TransferQuantityView::lastlegOrderId, v -> v)); + } + + /** 同一只读事务按完整筛选汇总,page/size不参与数量计算。 */ + @Transactional(readOnly=true) + public TransferQuantityTotals totals(Wrapper filter) { + var movements=repository.movements(filter); + var rows=withMovements(repository.rows(filter),movements); var pallets=repository.pallets(filter); + long unknownDeclared=rows.stream().filter(v -> v.declaredPieceCount()==null).count(); + long unknownReceived=rows.stream().filter(v -> v.receivedPieceCount()==null).count(); + long declared=sum(rows,v -> v.declaredPieceCount()==null?0:v.declaredPieceCount()); + long received=sum(rows,v -> v.receivedPieceCount()==null?0:v.receivedPieceCount()); + var all=new HashSet(); var remaining=new HashSet(); var shipped=new HashSet(); + for(var pallet:pallets) { + String key=pallet.customerId()+":"+pallet.palletId(); all.add(key); + if(Set.of("COUNTED","DISCARD_PENDING").contains(pallet.status())) remaining.add(key); + if("SHIPPED".equals(pallet.status())) shipped.add(key); + } + for (var move:movements) { + if (move.receiptId()!=null && move.destinationPalletId()!=null) all.add(move.customerId()+":"+move.destinationPalletId()); + if (Set.of("SHIPPED","RECEIVED").contains(move.status()) && move.sourcePalletId()!=null) shipped.add(move.customerId()+":"+move.sourcePalletId()); + } + return new TransferQuantityTotals(rows.size(),unknownDeclared==0?declared:null,declared,unknownDeclared, + unknownReceived==0?received:null,received,unknownReceived,sum(rows,TransferQuantityView::declaredCartonCount), + sum(rows,TransferQuantityView::receivedCartonCount),all.size(),sum(rows,TransferQuantityView::availableCartonCount), + sum(rows,TransferQuantityView::shippedCartonCount),remaining.size(),shipped.size()); + } + + /** 库存转运的实收来自目的仓凭据,原仓出库事实保留独立维度。 */ + private List withMovements(List rows, + List movements) { + var grouped=movements.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact::lastlegOrderId)); + return rows.stream().map(row -> { + var facts=grouped.get(row.lastlegOrderId()); if(facts==null) return row; + long received=0,pieces=0,shipped=0; boolean known=true; var targets=new HashSet(); var sources=new HashSet(); + for(var fact:facts) { + if(fact.receiptId()!=null) { + received=Math.addExact(received,fact.cartonCount()); + if(fact.pieceCount()==null) known=false; else pieces=Math.addExact(pieces,fact.pieceCount()); + if(fact.destinationPalletId()!=null) targets.add(fact.destinationPalletId()); + } + if(Set.of("SHIPPED","RECEIVED").contains(fact.status())) { shipped=Math.addExact(shipped,fact.cartonCount()); if(fact.sourcePalletId()!=null) sources.add(fact.sourcePalletId()); } + } + return new TransferQuantityView(row.lastlegOrderId(),row.declaredPieceCount(),received>0&&known?pieces:null, + row.declaredCartonCount(),received,targets.size(),0,shipped,0,sources.size()); + }).toList(); + } + + /** 使用精确整数累加,溢出不能静默返回错误汇总。 */ + private long sum(List rows,ToLongFunction measure) { + long value=0; for(var row:rows) value=Math.addExact(value,measure.applyAsLong(row)); return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index ff0b99069..8a790f977 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -92,6 +92,7 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { + @Resource private TransferQuantityService transferQuantityService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; @@ -551,19 +552,15 @@ public class WarehouseTransferService { transferCartons.updateById(carton); } } - @Transactional(readOnly = true) - public PageResult page(String customerId, WarehouseTransferRequests.Query query) { - return page(customerId, query, true); - } - - @Transactional(readOnly = true) - public PageResult page(String customerId, WarehouseTransferRequests.Query query, - boolean includeDrafts) { - if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { - throw badRequest("createdFrom must not be after createdTo"); - } + /** 列表和全筛选数量共享条件,避免翻页或状态分组后口径漂移。 */ + private LambdaQueryWrapper transferPageFilter(String customerId, + WarehouseTransferRequests.Query query, boolean includeDrafts) { + validateQuantityRange(query.createdFrom(), query.createdTo(), "创建"); + validateQuantityRange(query.reviewedFrom(), query.reviewedTo(), "审核"); + validateQuantityRange(query.measuredFrom(), query.measuredTo(), "清点完成"); + validateQuantityRange(query.outboundFrom(), query.outboundTo(), "出库"); List groupedStatuses = customerMainStatuses(query.status()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) @@ -577,7 +574,35 @@ public class WarehouseTransferService { .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) + .ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom()) + .le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo()) + .ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom()) + .le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo()) + .apply(query.measuredFrom() != null, "(SELECT MAX(qp.completed_at) FROM ll_warehouse_transfer_pallet qp WHERE qp.transfer_order_id=ll_warehouse_transfer_order.id AND qp.customer_id=ll_warehouse_transfer_order.customer_id) >= {0}", query.measuredFrom()) + .apply(query.measuredTo() != null, "(SELECT MAX(qp.completed_at) FROM ll_warehouse_transfer_pallet qp WHERE qp.transfer_order_id=ll_warehouse_transfer_order.id AND qp.customer_id=ll_warehouse_transfer_order.customer_id) <= {0}", query.measuredTo()) .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); + } + + /** 日期区间统一校验,列表与数量汇总均拒绝反向区间。 */ + private void validateQuantityRange(LocalDateTime from, LocalDateTime to, String name) { + if (from != null && to != null && from.isAfter(to)) throw badRequest(name + "开始时间不能晚于结束时间"); + } + + /** 客户侧完整筛选数量,不读取其他客户且不使用页码。 */ + @Transactional(readOnly = true) + public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals quantityTotals(String customerId, WarehouseTransferRequests.Query query) { + return transferQuantityService.merchantTotals(transferPageFilter(customerId, query, true), customerId); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query) { + return page(customerId, query, true); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query, + boolean includeDrafts) { + var wrapper = transferPageFilter(customerId, query, includeDrafts); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Map relabelApplicationsByNo = relabelApplications(result.getRecords()); Map measurementTimes = measurementTimes(result.getRecords()); @@ -961,7 +986,7 @@ public class WarehouseTransferService { WarehouseTransferRequests.CountCartons request) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); if (transfer == null) throw notFound("当前仓没有该转运单"); - if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); String customerId = transfer.getCustomerId(); List cartons = transferCartons.listOrder(customerId, transfer.getId()); // 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。 @@ -1096,7 +1121,11 @@ public class WarehouseTransferService { /** 上门取货必须先送仓;按 PDA 绑定仓锁定后继续使用原测量流程。 */ private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { - return pickupService.requireMeasuringSource(String.valueOf(warehouseId), transferNo); + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("仓储转运单不存在"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("当前转运单不允许测量"); + pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); + return transfer; } private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { @@ -1114,7 +1143,7 @@ public class WarehouseTransferService { WarehouseTransferRequests.Palletize request) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse"); } @@ -1683,7 +1712,7 @@ public class WarehouseTransferService { String transferNo, MultipartFile file) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { throw conflict("Measurement photos can only be uploaded before palletization completes"); } diff --git a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml index 07d151e04..83fd0bfba 100644 --- a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml @@ -86,6 +86,7 @@ paths: - $ref: '#/components/parameters/PalletNo' post: summary: Confirm actual disposal with owned pallet photos + description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。 x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR] requestBody: required: true @@ -103,7 +104,7 @@ paths: required: [itemId, actualPackages] properties: itemId: { type: string, pattern: '^[1-9][0-9]*$' } - actualPackages: { type: integer, minimum: 1, description: Must equal plannedPackages; must finish the application selection } + actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages } scannedCartonMarks: type: array uniqueItems: true diff --git a/lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml b/lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml new file mode 100644 index 000000000..456c15e53 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml @@ -0,0 +1,94 @@ +openapi: 3.0.3 +info: + title: 货物废弃全部筛选数量统计 + version: 1.1.0 +paths: + /api/platform/v1/last-leg/cargo-discards/statistics: + get: + summary: 按平台允许的客户范围汇总全部筛选结果 + description: 与废弃列表共用筛选条件,page和size不限制统计范围。未记录的实际数量、商品件数或托盘事实返回null。实际数取PDA确认事实,不从申请数或状态推算。 + parameters: + - {name: customerId, in: query, schema: {type: string}} + - {$ref: '#/components/parameters/DiscardNo'} + - {$ref: '#/components/parameters/InboundCode'} + - {$ref: '#/components/parameters/Warehouse'} + - {$ref: '#/components/parameters/Status'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + - {$ref: '#/components/parameters/CompletedFrom'} + - {$ref: '#/components/parameters/CompletedTo'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + responses: + '200': + description: 标准ApiResponse,data为全部筛选数量统计 + content: + application/json: + schema: {$ref: '#/components/schemas/Envelope'} + /api/merchant/v1/last-leg/cargo-discards/statistics: + get: + summary: 按当前认证客户汇总全部筛选结果 + description: 需要merchant:overseas-warehouse:discard:list权限;客户归属只取认证身份。完成时间筛选同时适用于列表。 + parameters: + - {$ref: '#/components/parameters/DiscardNo'} + - {$ref: '#/components/parameters/InboundCode'} + - {$ref: '#/components/parameters/Warehouse'} + - {$ref: '#/components/parameters/Status'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + - {$ref: '#/components/parameters/CompletedFrom'} + - {$ref: '#/components/parameters/CompletedTo'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + responses: + '200': + description: 标准ApiResponse,data为当前客户全部筛选统计 + content: + application/json: + schema: {$ref: '#/components/schemas/Envelope'} +components: + parameters: + DiscardNo: {name: discardNo, in: query, schema: {type: string}} + InboundCode: {name: inboundCode, in: query, schema: {type: string}} + Warehouse: {name: overseasWarehouseId, in: query, schema: {type: string}} + Status: {name: status, in: query, schema: {type: string}} + CreatedFrom: {name: createdFrom, in: query, schema: {type: string, format: date-time}} + CreatedTo: {name: createdTo, in: query, schema: {type: string, format: date-time}} + CompletedFrom: {name: completedFrom, in: query, description: 客户确认完成时间下界,包含边界, schema: {type: string, format: date-time}} + CompletedTo: {name: completedTo, in: query, description: 客户确认完成时间上界,包含边界, schema: {type: string, format: date-time}} + Page: {name: page, in: query, required: true, schema: {type: integer, minimum: 1}} + Size: {name: size, in: query, required: true, schema: {type: integer, minimum: 1, maximum: 200}} + schemas: + SummaryDimensions: + type: object + description: 平台及客户废弃列表records与详情summary均返回这些维度;totalPackages保留作业阶段展示箱数,申请箱数须读取requestedPackages。 + required: [skuCodes, skuCount, requestedPackages, actualPackages] + properties: + skuCodes: {type: array, items: {type: string}, description: 申请明细去重并排序后的全部SKU} + skuCount: {type: integer, description: 本单SKU种数} + requestedPackages: {type: integer, description: 申请物理箱数,同一混装箱跨SKU仅计一次} + actualPackages: {type: integer, nullable: true, description: PDA已确认实际物理箱数,未作业为null} + CandidateSku: + type: object + description: 库存仓位按物理箱计量,箱内全部SKU从真实实收JSON投影;完整混箱必须携带全部SKU明细,库存冻结和扣减按仓位及箱身份去重。 + properties: + skuCode: {type: string} + productName: {type: string, nullable: true} + availablePackages: {type: string, description: 包含本SKU的可选物理箱数} + availablePieces: {type: string, nullable: true, description: 真实箱内本SKU件数,缺失时null,不按箱规推算} + Envelope: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/Statistics'} + Statistics: + type: object + description: Long值遵循全局序列化配置,可能以十进制字符串返回;退货SKU按件汇总并按实物箱去重,转运件数只取所选箱实点SKU事实,其他缺失事实保留null。 + properties: + orderCount: {type: string, description: 筛选订单票数} + skuCount: {type: integer, description: 筛选内去重SKU种数} + requestedPackages: {type: string, description: 申请物理箱数} + actualPackages: {type: string, nullable: true, description: 已确认实际物理箱数} + requestedPieces: {type: string, nullable: true, description: 申请商品件数} + actualPieces: {type: string, nullable: true, description: 已确认实际商品件数} + palletCount: {type: integer, nullable: true, description: 客户和仓范围内去重真实来源托盘数,缺失或散箱返回null} diff --git a/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml b/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml index 4777b1eda..ff0b85426 100644 --- a/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml @@ -4,6 +4,17 @@ info: version: 1.0.0 description: 全部筛选结果汇总,不受 page/size 影响。件、箱、SKU分别统计;未知不能解释为零,禁止固定箱规换算。 paths: + /api/platform/v1/last-leg/relabel-applications/inventory-candidates: + get: + operationId: platformRelabelInventoryCandidates + description: 平台代客创建候选;权限platform:overseas-warehouse:relabel:create。客户和公共作业仓均必须显式选择,返回与客户创建端相同的InventoryCandidate结构。 + parameters: + - {name: customerId, in: query, required: true, schema: {type: string}} + - {name: operationWarehouseId, in: query, required: true, schema: {type: string}} + - {name: inboundCode, in: query, schema: {type: string}} + responses: + '200': + description: 标准ApiResponse,data为正式库存候选数组;普通库存包含inventoryBalanceId/inboundOrderId/positions,退货包含returnCartonId/cartonMark/cartonSkuItems/cartonAvailablePackages。所有Long ID为十进制字符串。 /api/platform/v1/last-leg/relabel-applications/quantity-summary: get: operationId: platformRelabelQuantitySummary @@ -61,12 +72,16 @@ components: type: object required: [orderCount, skuCount, knownLabelPieceCount, unknownPieceOrders, knownCartonCount, unknownCartonOrders, orders] properties: - orderCount: {type: integer, format: int64, minimum: 0} - skuCount: {type: integer, format: int64, minimum: 0, description: 全部筛选结果的不同旧SKU数} - knownLabelPieceCount: {type: integer, format: int64, minimum: 0} - unknownPieceOrders: {type: integer, format: int64, minimum: 0} - knownCartonCount: {type: integer, format: int64, minimum: 0} - unknownCartonOrders: {type: integer, format: int64, minimum: 0} + orderCount: {type: string, pattern: '^[0-9]+$'} + skuCount: {type: string, pattern: '^[0-9]+$', description: 全部筛选结果的不同旧SKU数} + knownLabelPieceCount: {type: string, pattern: '^[0-9]+$'} + unknownPieceOrders: {type: string, pattern: '^[0-9]+$'} + knownCartonCount: {type: string, pattern: '^[0-9]+$'} + unknownCartonOrders: {type: string, pattern: '^[0-9]+$'} + knownSourcePalletCount: {type: string, pattern: '^[0-9]+$', description: 已知完整原托快照按仓库和托盘码在全部筛选单据间去重} + unknownSourcePalletOrders: {type: string, pattern: '^[0-9]+$'} + knownTargetPalletCount: {type: string, pattern: '^[0-9]+$', description: 本轮完整目标快照按仓库和托盘码去重;返工旧轮目标不计} + unknownTargetPalletOrders: {type: string, pattern: '^[0-9]+$'} orders: type: object description: 以换标业务单号为键,覆盖全部筛选单据 @@ -74,10 +89,15 @@ components: Quantity: type: object properties: - labelPieceCount: {type: integer, format: int64, nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null} - cartonCount: {type: integer, format: int64, nullable: true, description: 箱级任务按箱唛去重;按件来源标识不作为箱数} + labelPieceCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null} + cartonCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 箱级任务按箱唛去重;按件任务仅在全部精确来源快照都是BOX时按来源主键去重,PALLET/PARCEL不冒充箱数} skuCount: {type: integer, minimum: 0} duplicateCarton: {type: boolean, description: 历史重复任务关联提示,不将同一来源不同SKU按件任务视为重复} quantityUnit: {type: string, enum: [PIECE, CARTON, UNKNOWN]} - sourcePalletCount: {type: integer, format: int64, nullable: true, description: 缺少不可变原托快照时为null} - targetPalletCount: {type: integer, format: int64, nullable: true, description: 未提供汇总,详情以本轮有效绑定历史为准} + sourcePalletCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 缺少不可变原托快照时为null} + targetPalletCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 每个任务本轮最后一次可信绑定的不同目标托盘数;未绑定完整为null} + sourceUnitCounts: + type: object + nullable: true + description: 首次认领时已清点且精确关联的退货来源主键按BOX/PARCEL/PALLET分类去重;任一来源缺失时为null,不按标识外形猜测 + additionalProperties: {type: string, pattern: '^[0-9]+$'} diff --git a/lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml b/lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml new file mode 100644 index 000000000..df36d8e24 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.3 +info: + title: 退货全筛选统计与托盘无效果恢复 + version: 1.0.0 +paths: + /api/merchant/v1/last-leg/return-orders/statistics: + get: + summary: 当前客户退货全筛选统计 + description: 使用与退货分页列表一致的 returnNo、status、skuCode、trackingNo 条件。客户由认证身份确定,忽略客户端归属输入。totals 不受 page/size 影响;pageDimensions 仅对应当前页。SKU 跨订单按去空白大写后的编码去重。件、箱、包裹、托独立统计;未提供的预报载体数不推算为零。需要 MERCHANT_RETURN_ORDER_LIST 对应正式权限。 + operationId: merchantReturnOrderStatistics + responses: + '200': + description: ApiResponse,data 为 totals 与 pageDimensions;失败按统一响应契约返回。 + /api/platform/v1/last-leg/return-orders/statistics: + get: + summary: 尾程筛选集合中原生退货订单的统计 + description: 需要 platform:overseas-warehouse:last-mile-order:list。完整复用尾程列表筛选;totals 只聚合其中有原生退货记录的订单。pageDimensions 对应混合尾程列表当前页中的退货行,不单独分页退货子集。实收载体来自签收事实,出库不扣减历史签收数量。 + operationId: platformReturnOrderStatistics + responses: + '200': + description: ApiResponse,data 为 totals 与 pageDimensions。 + /api/platform/v1/last-leg/pallet-operation-recoveries/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: integer, format: int64 } + get: + summary: 管理员只读核对原托盘作业 + description: 需要 lastleg:inventory:manage,返回原状态、请求哈希、远端意图、原错误和更新时间,不返回会话凭据。 + operationId: inspectPalletOperationRecovery + responses: + '200': { description: 原作业技术审计状态 } + /api/platform/v1/last-leg/pallet-operation-recoveries/{id}/close-no-effect: + parameters: + - in: path + name: id + required: true + schema: { type: integer, format: int64 } + post: + summary: 审计关闭无远端效果的超时换标组托 + description: 需要 lastleg:inventory:manage。仅 RELABEL_PALLET_DISASSEMBLE、PROCESSING 或 FAILED_COMPENSATED、超过15分钟、版本一致、无结果且无任何远端意图允许关闭。未知/已确认/损坏远端记录均拒绝。原行行锁与远端意图条件写互斥;旧线程失败写不能覆盖恢复终态。原请求哈希及原错误不变,恢复管理员、原因、幂等键、原状态和时间写入原 result_json。相同管理员/键/原因重放返回同一审计,不另造业务成功结果。无需新增表,不写库存或远端实托。 + operationId: closePalletOperationNoEffect + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [warehouseId, expectedUpdatedAt, idempotencyKey, reason] + properties: + warehouseId: { type: integer, format: int64, minimum: 1 } + expectedUpdatedAt: { type: string, format: date-time } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + reason: { type: string, minLength: 1, maxLength: 1000 } + responses: + '200': { description: ApiResponse 恢复审计或正式业务拒绝 } + '403': { description: 权限不足 } + '404': { description: 原作业不存在 } + '409': { description: 原状态、版本、归属或远端效果不满足恢复条件 } diff --git a/lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml b/lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml new file mode 100644 index 000000000..42036dae5 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml @@ -0,0 +1,120 @@ +openapi: 3.0.3 +info: + title: 仓储转运与混箱库存数量 + version: 1.0.0 + description: 件、箱、托分开记录。所有Long数量序列化为十进制字符串;未知件数保持null,空筛选返回零。同托部分出库可同时属于历史实收、剩余、出库集合,禁止托数相减。 +paths: + /api/platform/v1/last-leg/orders/warehouse-transfer-quantities: + get: + operationId: platformWarehouseTransferQuantityTotals + description: 权限platform:overseas-warehouse:last-mile-order:list,平台访问域与主订单列表一致。复用主订单全部Query筛选及relabelNo,额外限定WAREHOUSE_TRANSFER;page/size不参与汇总。 + responses: + '200': + description: ApiResponse.data为TransferQuantityTotals。 + content: + application/json: + schema: { $ref: '#/components/schemas/TotalsEnvelope' } + /api/merchant/v1/last-leg/warehouse-transfers/quantities: + get: + operationId: merchantWarehouseTransferQuantityTotals + description: 权限merchant:overseas-warehouse:transfer:list,客户范围只从会话获取。复用转运列表完整筛选,包括transferNo/inboundCode/operatingWarehouseId/fbaWarehouseCode/status/needRelabel和创建、审核、清点完成、出库日期区间;日期区间上界包含端点。page/size不参与汇总。 + responses: + '200': + description: ApiResponse.data为TransferQuantityTotals。 + content: + application/json: + schema: { $ref: '#/components/schemas/TotalsEnvelope' } + /api/platform/v1/last-leg/inventory/piece-projection: + get: + operationId: platformInventoryPieceProjection + description: 权限platform:overseas-warehouse:inventory:list,与库存列表相同平台客户访问域及BalanceQuery完整筛选,不分页。按原箱清点及正式目的仓签收凭据投影当前未占用可用库存。每仓位精确箱映射数量须等于available_packages,箱内SKU件数须等于箱总清点件数;缺失、不一致、无法追溯的非转运库存均为未知,不按箱规或首SKU推算。通用余额MIXED也逐箱读取全部SKU。SKU查询可命中混箱第二SKU。 + responses: + '200': + description: ApiResponse.data为InventoryPieceProjection。 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/InventoryPieceProjection' } +components: + schemas: + Count: + type: string + pattern: '^[0-9]+$' + NullableCount: + type: string + nullable: true + pattern: '^[0-9]+$' + TransferQuantityView: + type: object + description: 主订单列表与详情summary.warehouseTransferQuantity,仅转运订单提供。新到仓历史实收取counted_at非空且COUNTED/DISCARD_PENDING/DISCARDED/SHIPPED原箱;库存转运实收取正式目的仓签收凭据,件数取精确原箱清点。 + properties: + lastlegOrderId: { $ref: '#/components/schemas/Count' } + declaredPieceCount: { $ref: '#/components/schemas/NullableCount' } + receivedPieceCount: { $ref: '#/components/schemas/NullableCount' } + declaredCartonCount: { $ref: '#/components/schemas/Count' } + receivedCartonCount: { $ref: '#/components/schemas/Count' } + receivedPalletCount: { $ref: '#/components/schemas/Count' } + availableCartonCount: { type: string, description: 原箱当前COUNTED数,非扣除其他业务预留后的可用库存。真实可用数读取库存投影。 } + shippedCartonCount: { $ref: '#/components/schemas/Count' } + remainingPalletCount: { $ref: '#/components/schemas/Count' } + shippedPalletCount: { $ref: '#/components/schemas/Count' } + TransferQuantityTotals: + allOf: + - { $ref: '#/components/schemas/TransferQuantityView' } + - type: object + properties: + orderCount: { $ref: '#/components/schemas/Count' } + knownDeclaredPieceCount: { $ref: '#/components/schemas/Count' } + unknownDeclaredOrderCount: { $ref: '#/components/schemas/Count' } + knownReceivedPieceCount: { $ref: '#/components/schemas/Count' } + unknownReceivedOrderCount: { $ref: '#/components/schemas/Count' } + TotalsEnvelope: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/TransferQuantityTotals' } + Sku: + type: object + properties: + skuCode: { type: string } + quantity: { $ref: '#/components/schemas/Count' } + InventoryPieceProjection: + type: object + properties: + availablePieceCount: { $ref: '#/components/schemas/NullableCount' } + knownAvailablePieceCount: { $ref: '#/components/schemas/Count' } + unknownCartonCount: { $ref: '#/components/schemas/Count' } + availableCartonCount: { $ref: '#/components/schemas/Count' } + availablePalletCount: { $ref: '#/components/schemas/Count' } + groups: + type: array + items: + type: object + properties: + customerId: { type: string } + warehouseId: { type: string } + destinationCode: { type: string, nullable: true } + anchorSkuCode: { type: string, nullable: true, description: 原余额记账SKU,可能为MIXED。完整SKU在skuContents中。 } + availablePieceCount: { $ref: '#/components/schemas/NullableCount' } + knownAvailablePieceCount: { $ref: '#/components/schemas/Count' } + unknownCartonCount: { $ref: '#/components/schemas/Count' } + availableCartonCount: { $ref: '#/components/schemas/Count' } + availablePalletCount: { $ref: '#/components/schemas/Count' } + skuContents: + type: array + items: { $ref: '#/components/schemas/Sku' } + cartons: + type: array + items: + type: object + properties: + cartonMark: { type: string } + inventoryPositionId: { type: string } + palletId: { type: string, nullable: true } + quantity: { $ref: '#/components/schemas/NullableCount' } + items: + type: array + items: { $ref: '#/components/schemas/Sku' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java new file mode 100644 index 000000000..c0c0d96eb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class RelabelOutboundPickingTest { + private DeliveryTransferService service; + private InventoryCommandService commands; + private InventoryPosition position; + private InventoryBalance balance; + private OutboundPalletItem item; + private OutboundPdaPickMapper picks; + private OutboundPdaTaskMapper tasks; + private OutboundOrderMapper orders; + private OutboundPalletItemMapper pallets; + private InventoryPositionMapper positions; + private InventoryBalanceMapper balances; + private RelabelApplicationMapper applications; + private RelabelItemMapper items; + + /** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */ + @BeforeEach void prepare() { + service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class); + picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class); + orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class); + positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class); + applications=mock(RelabelApplicationMapper.class); items=mock(RelabelItemMapper.class); + Map.of("inventoryCommands",commands,"pdaPicks",picks,"pdaTasks",tasks,"outboundOrders",orders, + "outboundPallets",pallets,"inventoryPositions",positions,"inventoryBalances",balances, + "relabelApplications",applications,"relabelItems",items,"objectMapper",new ObjectMapper()) + .forEach((name,value)->ReflectionTestUtils.setField(service,name,value)); + var outbound=new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C"); + outbound.setOverseasWarehouseId("W"); outbound.setOutboundNo("OUT"); outbound.setOutboundType("RELABEL_OUTBOUND"); + var task=new OutboundPdaTask(); task.setId(2L); task.setCustomerId("C"); task.setOutboundOrderId(1L); + task.setTaskNo("TASK"); task.setStatus("PENDING"); + item=new OutboundPalletItem(); item.setId(3L); item.setCustomerId("C"); item.setOutboundOrderId(1L); + item.setWarehousePalletId(4L); item.setPalletNo("NEW-TARGET"); item.setInboundCode("IN"); + item.setPackageCount(2); item.setPickedPackageCount(0); item.setCartonMarks("BOX-A,BOX-B"); + item.setOrderRefs(""" + {"sourceType":"RELABEL_ORDER","relabelNo":"RL","sourceInventoryPositionId":9, + "items":[{"itemId":11,"cartonMark":"BOX-A","packages":1},{"itemId":12,"cartonMark":"BOX-B","packages":1}]} + """); + position=new InventoryPosition(); position.setId(9L); position.setCustomerId("C"); position.setBalanceId(8L); + position.setOverseasWarehouseId("W"); position.setPalletId(4L); position.setPalletNo("NEW-TARGET"); + position.setAvailablePackages(0L); position.setHoldPackages(2L); + balance=new InventoryBalance(); balance.setId(8L); balance.setCustomerId("C"); + balance.setOverseasWarehouseId("W"); balance.setInboundCode("IN"); balance.setSkuCode("MIXED"); + when(tasks.lockByNoAndWarehouse("TASK","W")).thenReturn(task); + when(tasks.findByNoAndWarehouse("TASK","W")).thenReturn(task); + when(orders.selectById(1L)).thenReturn(outbound); when(orders.lockByNo("C","OUT")).thenReturn(outbound); + when(pallets.findByOutbound("C",1L)).thenReturn(List.of(item)); when(pallets.selectById(3L)).thenReturn(item); + when(positions.listPallet("C",4L)).thenReturn(List.of(position)); when(positions.selectById(9L)).thenReturn(position); + when(balances.selectById(8L)).thenReturn(balance); + var app=new RelabelApplication(); app.setId(10L); app.setRelabelNo("RL"); app.setStatus("READY_FOR_OUTBOUND"); + when(applications.lockByOutbound("C","OUT")).thenReturn(app); + var first=new RelabelItem(); first.setId(11L); first.setCartonMark("BOX-A"); first.setNotifiedPackageCount(1); + var second=new RelabelItem(); second.setId(12L); second.setCartonMark("BOX-B"); second.setNotifiedPackageCount(1); + when(items.selectList(any())).thenReturn(List.of(first,second)); + } + + /** 指定箱从冻结到出库数量守恒;整托重扫不重复释放或出库。 */ + @Test void picksExactFrozenBoxesAndReplayDoesNotShipAgain() { + assertEquals(List.of("BOX-A","BOX-B"),service.pdaTaskDetail("W","TASK").items().get(0).positions() + .stream().map(value->value.cartonMark()).toList()); + service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")); + service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")); + var release=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands,times(2)).release(eq("C"),eq("U"),release.capture()); + assertEquals(2,release.getAllValues().stream().mapToLong(InventoryCommandRequests.Move::packages).sum()); + assertTrue(release.getAllValues().stream().allMatch(move->move.positionId()==9L&&"RELABEL:RL".equals(move.sourceEventId()))); + var shipped=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands).ship(eq("C"),eq("U"),shipped.capture()); + assertEquals(2,shipped.getValue().packages()); assertEquals(9L,shipped.getValue().positionId()); + verify(commands).allocate(eq("C"),eq("U"),any()); verify(commands).pick(eq("C"),eq("U"),any()); + verify(picks).insert(any(OutboundPdaPick.class)); assertEquals(2,item.getPickedPackageCount()); + } + + /** 其他位置、客户、仓、入仓或不足冻结均拒绝,不能用空SKU放宽范围。 */ + @ParameterizedTest @ValueSource(strings={"POSITION","CUSTOMER","WAREHOUSE","INBOUND","HOLD","SNAPSHOT"}) + void rejectsUnrelatedOrIncompleteSourcesWithoutInventoryEffects(String invalid) { + switch(invalid) { + case "POSITION" -> position.setId(99L); + case "CUSTOMER" -> balance.setCustomerId("OTHER"); + case "WAREHOUSE" -> position.setOverseasWarehouseId("OTHER"); + case "INBOUND" -> balance.setInboundCode("OTHER"); + case "HOLD" -> position.setHoldPackages(1L); + case "SNAPSHOT" -> item.setOrderRefs("{\"sourceType\":\"RELABEL_ORDER\"}"); + } + assertTrue(service.pdaTaskDetail("W","TASK").items().get(0).positions().isEmpty()); + assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"))); + verifyNoInteractions(commands,picks); + } + + /** 同箱唛也不能代替来源任务主键,损坏ID必须在释放冻结前拒绝。 */ + @Test void rejectsWrongSnapshotItemIdBeforeReleasingAnyStock() { + item.setOrderRefs(item.getOrderRefs().replace("\"itemId\":11", "\"itemId\":999")); + assertEquals(2,service.pdaTaskDetail("W","TASK").items().get(0).positions().size()); + assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"))); + verifyNoInteractions(commands); verify(picks,never()).insert(any(OutboundPdaPick.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java index 3df612690..095c47413 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java @@ -16,6 +16,50 @@ import static org.junit.jupiter.api.Assertions.*; /** 使用真实 SQL 核对转运实收箱与库存仓位的关联及废弃状态流转。 */ class CargoDiscardTransferCartonMapperTest { + /** MIXED 是整托物理余额标记;精确返回剩余实箱及真实 SKU,不能降级成整仓位或混入别的来源。 */ + @Test + void mixedPhysicalBalanceKeepsOnlyItsRemainingCountedCartons() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(source); + db.execute("CREATE TABLE ll_inbound_plan_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),received_at TIMESTAMP,destination_warehouse_code VARCHAR(20),carton_mark VARCHAR(80),sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20),sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,batch_no VARCHAR(80),overseas_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))"); + db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))"); + db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','MIXED')"); + db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'TRANSFER','W'),(42,'C',1,100,'OTHER-BATCH','W'),(43,'C',1,100,'TRANSFER','OTHER-WAREHOUSE')"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'TRANSFER','W'),(20,'C',10,'OTHER-ORDER','W')"); + db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(11,'C',10,100,'COMPLETED'),(12,'C',10,200,'COMPLETED'),(13,'C',10,100,'BOUND'),(14,'C',20,100,'COMPLETED')"); + db.update("INSERT INTO ll_warehouse_transfer_item VALUES(21,'C',10,'A'),(22,'C',10,'B'),(23,'C',20,'FOREIGN-ORDER')"); + db.update("INSERT INTO ll_warehouse_transfer_carton(id,customer_id,transfer_order_id,transfer_item_id,transfer_pallet_id,status,carton_mark,sku_items_json) VALUES " + + "(1,'C',10,21,11,'COUNTED','REMAINING-A','[{\"skuCode\":\"A\",\"quantity\":2}]')," + + "(2,'C',10,22,11,'DISPATCHED','MOVED-B','[{\"skuCode\":\"B\",\"quantity\":8}]')," + + "(3,'C',10,21,12,'COUNTED','OTHER-PALLET',NULL),(4,'OTHER',10,21,11,'COUNTED','OTHER-CUSTOMER',NULL)," + + "(5,'C',10,21,11,'EXPECTED','FORECAST',NULL),(6,'C',10,21,11,'DISCARD_PENDING','HELD',NULL)," + + "(7,'C',10,21,13,'COUNTED','NOT-STOCKED',NULL),(8,'C',20,23,14,'COUNTED','OTHER-ORDER',NULL)," + + "(9,'C',10,23,11,'COUNTED','ITEM-ORDER-MISMATCH',NULL)"); + var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source)); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(CargoDiscardInventoryMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper = session.getMapper(CargoDiscardInventoryMapper.class); + var rows = mapper.genericCartons("C", 41L); + assertEquals(List.of("REMAINING-A"), rows.stream().map(c -> c.getCartonMark()).toList()); + assertEquals("A", rows.get(0).getSkuCode()); + assertEquals("[{\"skuCode\":\"A\",\"quantity\":2}]", rows.get(0).getSkuItemsJson()); + assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty()); + assertTrue(mapper.genericCartons("C", 42L).isEmpty()); + assertTrue(mapper.genericCartons("C", 43L).isEmpty()); + db.update("UPDATE ll_warehouse_transfer_carton SET status='COUNTED',sku_items_json='[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":5}]' WHERE id=2"); + session.clearCache(); + assertEquals(List.of("REMAINING-A", "MOVED-B"), mapper.genericCartons("C", 41L).stream().map(c -> c.getCartonMark()).toList()); + assertEquals("B", mapper.genericCartons("C", 41L).get(1).getSkuCode()); + assertTrue(mapper.genericCartons("C", 41L).get(1).getSkuItemsJson().contains("\"quantity\":5")); + } + } + /** 同批多托盘、多 SKU 时仅返回本仓位实收箱,排除预报、短收、占用及其他客户的箱。 */ @Test void receivedBoxesFollowTheirActualPalletAndSku() { @@ -27,7 +71,7 @@ class CargoDiscardTransferCartonMapperTest { db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))"); db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))"); db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))"); - db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))"); db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','SKU'),(2,'C',10,'FBA','OTHER')"); db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'ZY-BYWL-261019194301','W'),(42,'C',1,200,'ZY-BYWL-261019194301','W'),(43,'C',2,100,'ZY-BYWL-261019194301','W')"); db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'ZY-BYWL-261019194301','W')"); @@ -48,6 +92,10 @@ class CargoDiscardTransferCartonMapperTest { assertEquals(List.of("OTHER-PALLET"), mapper.genericCartons("C", 42L).stream().map(c -> c.getCartonMark()).toList()); assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList()); assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty()); + db.update("UPDATE ll_warehouse_transfer_carton SET sku_items_json=? WHERE id=1", "[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"OTHER\",\"quantity\":7}]"); + session.clearCache(); + assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList()); + assertTrue(mapper.genericCartons("C", 41L).get(0).getSkuItemsJson().contains("OTHER")); assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING")); assertEquals(1, mapper.genericCartons("C", 41L).size()); assertEquals(0, mapper.changeTransferCartonStatus("OTHER", 2L, "DISCARD_PENDING", "COUNTED")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java new file mode 100644 index 000000000..2a8f67b0a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.bind.support.WebDataBinderFactory; +import java.time.LocalDateTime; +import java.util.Set; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 使用真实 MVC 参数绑定防止查询构造器重载导致列表和统计全部失败。 */ +class CargoDiscardQueryBindingTest { + /** 客户归属取认证身份,完成时间同时传入分页和全筛选统计。 */ + @Test void mvcBindsCompletionDatesForPageAndStatistics() throws Exception { + var service = mock(CargoDiscardService.class); + var controller = new MerchantCargoDiscardController(); + ReflectionTestUtils.setField(controller, "service", service); + var mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; } + @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest request, WebDataBinderFactory binder) { + return new AuthenticatedUser(1L, 100L, 100L, 9L, "fixture", "MERCHANT", "WEB", Set.of()); + } + }).build(); + for (String suffix : new String[]{"", "/statistics"}) { + mvc.perform(get("/api/merchant/v1/last-leg/cargo-discards" + suffix) + .param("page", "1").param("size", "1").param("customerId", "OTHER") + .param("completedFrom", "2099-01-01T00:00:00")).andExpect(status().isOk()); + } + var query = new CargoDiscardRequests.Query(1, 1, null, null, null, null, null, null, + LocalDateTime.of(2099, 1, 1, 0, 0), null); + verify(service).page("100", query); + verify(service).statistics("100", query); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index 1a310889a..7980ea411 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -42,15 +42,17 @@ class PdaCargoDiscardServiceTest { @BeforeEach void setup() { service = new PdaCargoDiscardService(); + var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository(); + ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class)); ReflectionTestUtils.setField(service, "photoService", photoService); - ReflectionTestUtils.setField(service, "applications", applications); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "cartons", cartons); - ReflectionTestUtils.setField(service, "profiles", profiles); + ReflectionTestUtils.setField(repository, "applications", applications); + ReflectionTestUtils.setField(repository, "items", items); + ReflectionTestUtils.setField(repository, "cartons", cartons); + ReflectionTestUtils.setField(repository, "profiles", profiles); ReflectionTestUtils.setField(service, "adjustments", adjustments); ReflectionTestUtils.setField(service, "discardService", discardService); - ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + ReflectionTestUtils.setField(repository, "returnCartons", returnCartons); application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1"); application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99"); application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00")); @@ -171,6 +173,33 @@ class PdaCargoDiscardServiceTest { private PdaCargoDiscardDtos.Confirm looseRequest(List marks, boolean bulk) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk); } + + /** 无箱唛对应的仓位允许实际少弃或未弃,完工后同载荷重放不再结算。 */ + @ParameterizedTest @ValueSource(ints = {0, 1, 2}) + void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) { + var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); + ReflectionTestUtils.setField(service, "stockService", stockService); + item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3); + var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); + position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1"); + when(stockService.position("CUSTOMER", 42L)).thenReturn(position); + when(stockService.cartonMarks(item)).thenReturn(List.of()); + when(stockService.actualQuantityEditable(item)).thenReturn(true); + when(applications.selectList(any())).thenReturn(List.of(application)); + var task = service.resolve(identity, "PALLET-1", "CD-1"); + assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode()); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4))); + verifyNoInteractions(photoService); + var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual)); + assertEquals(actual, completed.skus().get(0).actualPackages()); + assertEquals(actual, application.getTotalPackages()); + assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee()); + service.confirm(identity, "CD-1", "PALLET-1", request(actual)); + verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual); + verifyNoInteractions(adjustments); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3))); + verify(items, times(1)).updateById(item); + } @Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() { looseTask(); when(applications.selectList(any())).thenReturn(List.of(application)); var task = service.resolve(identity, "BOX-1", "CD-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java new file mode 100644 index 000000000..c9de169d0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardDimensionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实箱内混装SKU按各自件数统计,缺失事实不得用整箱件数替代。 */ +class CargoDiscardDimensionRepositoryTest { + private final CargoDiscardDimensionMapper mapper = mock(CargoDiscardDimensionMapper.class); + private final CargoDiscardDimensionRepository repository = new CargoDiscardDimensionRepository(); + /** 注入JSON解析和隔离Mapper。 */ + CargoDiscardDimensionRepositoryTest() { + ReflectionTestUtils.setField(repository, "dimensions", mapper); + ReflectionTestUtils.setField(repository, "objectMapper", new ObjectMapper()); + } + /** 一箱混装A五件和B七件,不能把十二件分别计给两个SKU。 */ + @Test void mixedCartonUsesMatchingSkuOnly() { + var line = line(1, "A"); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5},{\"skuCode\":\"B\",\"quantity\":7}]")); + assertEquals(5L, repository.pieces("C", line)); + line.setSkuCode("B"); assertEquals(7L, repository.pieces("C", line)); + line.setSkuCode("MISSING"); assertNull(repository.pieces("C", line)); + } + /** 两箱五件和七件累计十二件,箱数量不足或JSON损坏时保留未知。 */ + @Test void everySelectedCartonNeedsKnownPositiveContents() { + var line = line(2, "A"); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]", "[{\"skuCode\":\"A\",\"quantity\":7}]")); + assertEquals(12L, repository.pieces("C", line)); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]")); + assertNull(repository.pieces("C", line)); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[]", "broken")); + assertNull(repository.pieces("C", line)); + } + /** 指定申请箱数和当前SKU,不创建库存。 */ + private static CargoDiscardItem line(int boxes, String sku) { + var line = new CargoDiscardItem(); line.setId(1L); line.setDiscardPackages(boxes); line.setSkuCode(sku); return line; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java new file mode 100644 index 000000000..20dc378fa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class CargoDiscardQuantityRepositoryTest { + /** 完工前展示申请十箱,完工后费用及列表使用实弃六箱或零箱。 */ + @Test void completedSummaryUsesActualWithoutChangingRequestedSnapshot() { + var mapper = mock(CargoDiscardItemMapper.class); + var repository = new CargoDiscardQuantityRepository(); + ReflectionTestUtils.setField(repository, "items", mapper); + var application = new CargoDiscardApplication(); application.setCustomerId("C1"); application.setId(1L); + var line = new CargoDiscardItem(); line.setDiscardPackages(10); line.setActualDiscardPackages(6); + when(mapper.listApplication("C1", 1L)).thenReturn(List.of(line)); + application.setStatus("APPROVED"); assertEquals(10, repository.boxes(application)); + application.setStatus("PDA_COMPLETED"); assertEquals(6, repository.boxes(application)); + application.setStatus("COMPLETED"); assertEquals(6, repository.boxes(application)); + line.setActualDiscardPackages(0); assertEquals(0, repository.boxes(application)); + assertEquals(10, line.getDiscardPackages()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java index 6081879d2..a8a858bf3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java @@ -40,10 +40,11 @@ class CargoDiscardStatisticsServiceTest { @Test void unknownPiecesAndLoosePalletNeverBecomeZero() { when(queries.list("C", query)).thenReturn(List.of(application(1L))); when(dimensions.items("C", 1L)).thenReturn(List.of(item(null, "A", 2, 0))); + when(dimensions.pieces(eq("C"), any())).thenReturn(null); when(dimensions.pallets("C", 1L)).thenReturn(List.of("")); var result = service.statistics("C", query); assertEquals(2, result.requestedPackages()); assertEquals(0L, result.actualPackages()); - assertNull(result.requestedPieces()); assertNull(result.actualPieces()); assertNull(result.palletCount()); + assertNull(result.requestedPieces()); assertEquals(0L, result.actualPieces()); assertNull(result.palletCount()); } /** 空筛选有零票零申请箱,但不能伪造实际作业或商品、托盘事实。 */ @Test void emptySelectionHasNoInventedActualFacts() { @@ -53,6 +54,29 @@ class CargoDiscardStatisticsServiceTest { assertNull(result.actualPackages()); assertNull(result.requestedPieces()); assertNull(result.palletCount()); verifyNoInteractions(dimensions); } + /** 两箱实收五件与七件,实际确认后件数仍由箱级事实汇总。 */ + @Test void selectedTransferCartonsProvideRealPieces() { + var line = item(null, "TRANSFER-SKU", 2, 2); + when(queries.list("C", query)).thenReturn(List.of(application(1L))); + when(dimensions.items("C", 1L)).thenReturn(List.of(line)); + when(dimensions.pieces("C", line)).thenReturn(12L); + when(dimensions.pallets("C", 1L)).thenReturn(List.of("P")); + var result = service.statistics("C", query); + assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages()); + assertEquals(12L, result.requestedPieces()); assertEquals(12L, result.actualPieces()); + } + /** 创建一票同客户同仓的隔离单据。 */ + @Test void mixedTransferSkuLinesDeduplicatePhysicalBoxesWithoutLosingPieces() { + var a = item(null, "A", 2, 2); var b = item(null, "B", 2, 2); + for (var line : List.of(a, b)) { line.setSourceType("INVENTORY"); line.setInboundCode("ZY-MIX"); line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]"); } + when(queries.list("C", query)).thenReturn(List.of(application(1L))); + when(dimensions.items("C", 1L)).thenReturn(List.of(a, b)); + when(dimensions.pieces("C", a)).thenReturn(8L); when(dimensions.pieces("C", b)).thenReturn(18L); + when(dimensions.pallets("C", 1L)).thenReturn(List.of("P", "P")); + var result = service.statistics("C", query); + assertEquals(2, result.skuCount()); assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages()); + assertEquals(26L, result.requestedPieces()); assertEquals(26L, result.actualPieces()); assertEquals(1, result.palletCount()); + } /** 创建一票同客户同仓的隔离单据。 */ private static CargoDiscardApplication application(long id) { var a = new CargoDiscardApplication(); a.setId(id); a.setCustomerId("C"); a.setOverseasWarehouseId("W"); return a; } /** 明确退货件数和实际值,不引入固定箱规。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java index 777b3d501..8af617bac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java @@ -32,12 +32,14 @@ class CargoDiscardStockServiceTest { final CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); } @BeforeEach void setup() { - ReflectionTestUtils.setField(service, "stock", stock); - ReflectionTestUtils.setField(service, "positions", positions); - ReflectionTestUtils.setField(service, "balances", balances); + var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(repository, "stock", stock); + ReflectionTestUtils.setField(repository, "positions", positions); + ReflectionTestUtils.setField(repository, "balances", balances); ReflectionTestUtils.setField(service, "commands", commands); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "links", links); + ReflectionTestUtils.setField(repository, "items", items); + ReflectionTestUtils.setField(repository, "links", links); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L); @@ -83,6 +85,33 @@ class CargoDiscardStockServiceTest { var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow(); carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton; } + /** 同一混装实箱的两个 SKU 保留完整快照,物理仓位只冻结一次,实箱只锁定和保存一次,漏 SKU 时不得产生任何冻结。 */ + @Test void mixedTransferCartonRequiresAllSkusAndLocksPhysicalBoxOnce() { + position.setAvailablePackages(1L); balance.setAvailablePackages(1L); + var box = carton(1L, "MIXED-1"); box.setSourceType("TRANSFER"); + box.setSkuItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"SECOND\",\"quantity\":7}]"); + when(stock.genericCartons("C1", 42L)).thenReturn(List.of(box)); + when(stock.changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING")).thenReturn(1); + var a = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(1L)); + var b = new CargoDiscardRequests.Item(null, null, "SECOND", 1, 42L, List.of(1L)); + assertThrows(ResponseStatusException.class, () -> service.reserveAll("C1", "OP", application, List.of(a), LocalDateTime.now())); + verifyNoInteractions(commands, items, links); + service.reserveAll("C1", "OP", application, List.of(a, b), LocalDateTime.now()); + verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING"); + verify(links, times(1)).insert(any(CargoDiscardCarton.class)); + verify(commands, times(1)).hold(eq("C1"), eq("OP"), any()); + var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items, times(2)).insert(saved.capture()); + assertTrue(saved.getAllValues().stream().allMatch(i -> service.cartonMarks(i).equals(List.of("MIXED-1")))); + assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), false)); + when(items.listApplication("C1", 11L)).thenReturn(saved.getAllValues()); + var physical = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(physical.capture()); + when(links.listApplication("C1", 11L)).thenReturn(List.of(physical.getValue())); + when(stock.changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED")).thenReturn(1); + for (var line : saved.getAllValues()) { service.discard("C1", "OP", application, line, 1); line.setActualDiscardPackages(1); } + verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED"); + verify(commands, times(1)).discardHeld(eq("C1"), eq("OP"), any()); + assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), true)); + } @Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() { position.setAvailablePackages(3L); balance.setAvailablePackages(3L); when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3"))); @@ -133,4 +162,57 @@ class CargoDiscardStockServiceTest { verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); } + /** 即使有同数量箱记录,缺失箱唛时仍应展示相关托盘并允许仓库实点。 */ + @Test void incompleteCartonMarksUseActualQuantityWithoutGuessingBoxes() { + position.setAvailablePackages(1L); balance.setAvailablePackages(1L); + when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position)); + when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, null))); + var candidate = service.candidates("C1", "WT").get(0); + assertFalse(candidate.cartonSelectionSupported()); assertEquals("P-42", candidate.palletNo()); + service.reserve("C1", "OP", application, request(1), LocalDateTime.now()); + var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(saved.capture()); + assertTrue(service.actualQuantityEditable(saved.getValue())); + assertTrue(service.cartonMarks(saved.getValue()).isEmpty()); + verifyNoInteractions(links); + verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); + } + /** 预留十箱、实弃六箱,扣减与释放分别使用稳定幂等键。 */ + @Test void unboundPalletDiscardsActualAndReleasesUnfinishedReservation() { + var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY"); + line.setInventoryPositionId(42L); line.setDiscardPackages(10); + assertTrue(service.actualQuantityEditable(line)); + service.discard("C1", "OP", application, line, 6); + var deduction = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands).discardHeld(eq("C1"), eq("OP"), deduction.capture()); + verify(commands).release(eq("C1"), eq("OP"), release.capture()); + assertEquals(6, deduction.getValue().packages()); assertEquals(4, release.getValue().packages()); + assertEquals(deduction.getValue().sourceEventId(), release.getValue().sourceEventId()); + assertNotEquals(deduction.getValue().idempotencyKey(), release.getValue().idempotencyKey()); + verifyNoInteractions(stock, links); + } + + /** 实际未废弃时只释放全部预留,不产生零数量扣减。 */ + @Test void unboundPalletZeroDisposalReleasesAllHeldStock() { + var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY"); + line.setInventoryPositionId(42L); line.setDiscardPackages(10); + service.discard("C1", "OP", application, line, 0); + verify(commands, never()).discardHeld(anyString(), anyString(), any()); + var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands).release(eq("C1"), eq("OP"), release.capture()); + assertEquals(10, release.getValue().packages()); + } + + /** 已选具体箱子不能通过实际数量绕过核对,超量和负数同样不得写库存。 */ + @Test void exactBoxesAndInvalidActualsCannotUseQuantityOnlyCompletion() { + var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY"); + line.setInventoryPositionId(42L); line.setDiscardPackages(2); + assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, -1)); + assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 3)); + line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]"); + assertFalse(service.actualQuantityEditable(line)); + assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 1)); + verifyNoInteractions(commands, stock, links); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 3f4290dcf..29a1a83d0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest { /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ DriverEvidenceFixture() throws Exception { f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); - var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class)); + var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class)); org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers); dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java index 515f73550..4ac5c08d0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.dispatch.service; +import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation; + import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baiyue.common.core.exception.BizException; @@ -326,7 +328,7 @@ class DispatchItemSourcePersistenceTest { capture(); var allocated = allocate(first, 101, 3, 10, null, 6); assertEquals(2, allocated.size()); - assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum()); + assertEquals(3, allocated.stream().mapToLong(DispatchSourceAllocation::packages).sum()); assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum()); assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count()); assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6)); @@ -368,7 +370,7 @@ class DispatchItemSourcePersistenceTest { } /** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */ - private List allocate(DispatchItem item, long position, long packages, + private List allocate(DispatchItem item, long position, long packages, long quantity, String mark, long hold) { long balance = position == 102 ? 602 : 601; return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark, @@ -376,7 +378,7 @@ class DispatchItemSourcePersistenceTest { } /** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */ - private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) { + private void pick(long id, String customer, long fulfillment, DispatchSourceAllocation allocation) { jdbc.update(""" INSERT INTO ll_dispatch_pda_pick_order (id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java index 1a6a7af91..11db2b00a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java @@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.*; class FleetPickupMapperTest { JdbcTemplate db;FleetPickupMapper mapper; + /** 每个用例建立独立数据库,通过真实 Mapper 验证取货只读来源投影。 */ @BeforeEach void setup() throws Exception { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds); db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)"); @@ -23,6 +24,26 @@ class FleetPickupMapperTest { var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class); } + /** 释放隔离库,避免完整回归累积 H2 内存。 */ + @AfterEach void close() { if(db!=null)db.execute("SHUTDOWN"); } + /** 显式列投影保留车队全部资格和展示字段,不依赖转运实体映射。 */ + @Test void sourceProjectionPreservesPickupFactsAndCustomerScope() { + var source=mapper.lockSource("C1","WT-1"); + assertThat(source.getId()).isEqualTo(1L); + assertThat(source.getCustomerId()).isEqualTo("C1"); + assertThat(source.getTransferNo()).isEqualTo("WT-1"); + assertThat(source.getInboundCode()).isEqualTo("WT-1"); + assertThat(source.getLastlegOrderNo()).isEqualTo("LL-1"); + assertThat(source.getOperatingWarehouseId()).isEqualTo("100"); + assertThat(source.getDeliveryAddress()).isEqualTo("Ontario Test Address"); + assertThat(source.getPickupContactName()).isEqualTo("Contact"); + assertThat(source.getPickupContactPhone()).isEqualTo("0000000000"); + assertThat(source.getPackageCount()).isEqualTo(5); + assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(source.getDeliveryMethod()).isEqualTo("WAREHOUSE_PICKUP"); + assertThat(mapper.lockSource("C2","WT-1")).isNull(); + assertThat(mapper.source("C1","WT-1").getId()).isEqualTo(source.getId()); + } @Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() { assertThat(mapper.countCandidates(null)).isEqualTo(2); assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java index f48e8ff79..8fceea32d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -4,7 +4,6 @@ import com.baiyue.lastleg.fleet.driver.*; import com.baiyue.lastleg.fleet.reporting.*; import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.springframework.web.server.ResponseStatusException; @@ -16,7 +15,7 @@ import static org.mockito.ArgumentMatchers.*; class FleetPickupServiceTest { FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; - TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task; + TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task; final Map commands=new HashMap<>(); @BeforeEach void setup() { repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); @@ -30,15 +29,18 @@ class FleetPickupServiceTest { doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); } - @Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() { - when(repository.pdaSource("100","WT-1")).thenReturn(source); - assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓"); - task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source); - assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class); + /** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */ + @Test void pickupMustArriveBeforePda() { + assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓"); + task.setStatus("ARRIVED"); + assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).doesNotThrowAnyException(); + assertThatThrownBy(()->service.requireArrived(2L,"WAREHOUSE_PICKUP","WT-2","WT-2")).hasMessageContaining("先完成车辆取货送仓"); } + /** 客户自送和已有库存转运沿本域原测量资格,不要求不存在的取货任务。 */ @Test void selfDeliveryKeepsOriginalMeasurementPath() { - source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source); - assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong()); + assertThatCode(()->service.requireArrived(1L,"CUSTOMER_DELIVERY","WT-1","WT-1")).doesNotThrowAnyException(); + assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","ORIGINAL-INBOUND")).doesNotThrowAnyException(); + verify(repository,never()).task(anyLong()); } @Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() { var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); @@ -98,8 +100,8 @@ class FleetPickupServiceTest { verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); } /** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */ - static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) { - var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number); + static FleetPickupSource source(long id,String customer,String number,String warehouse) { + var row=new FleetPickupSource();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number); row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5); row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java new file mode 100644 index 000000000..115573d88 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.inventory.query; + +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow; +import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository; +import com.baiyue.lastleg.inventory.query.service.InventoryPieceService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InventoryPieceServiceTest { + /** 混箱全部SKU与数字字符串正确投影,多组共享物理托盘只累计一次。 */ + @Test void projectsAllSkusWithoutMultiplyingCartonsOrPallets() { + var repository=mock(InventoryPieceRepository.class); + when(repository.rows("A",null)).thenReturn(List.of( + row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":\"7\"}]"), + row(1,10,100,2,102,2L,"[{\"skuCode\":\"A\",\"quantity\":2}]"), + row(2,11,100,1,103,4L,"[{\"skuCode\":\"C\",\"quantity\":4}]"))); + var result=service(repository).project("A",null); + assertEquals(16L,result.availablePieceCount()); assertEquals(3,result.availableCartonCount()); assertEquals(1,result.availablePalletCount()); + assertEquals(2,result.groups().size()); assertEquals(2,result.groups().get(0).skuContents().size()); + assertEquals(7,result.groups().get(0).skuContents().get(1).quantity()); + } + + /** 箱映射不全、JSON缺失或件数冲突均保留未知,不拿已知部分冒充总数。 */ + @Test void incompleteAndInconsistentPositionsRemainUnknown() { + for (var rows:List.of( + List.of(row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":10}]")), + List.of(row(1,10,100,1,101,10L,null)), + List.of(row(1,10,100,1,101,10L,"[{\"skuCode\":\"A\",\"quantity\":9}]")))) { + var repository=mock(InventoryPieceRepository.class); when(repository.rows("A",null)).thenReturn(rows); + var result=service(repository).project("A",null); + assertNull(result.availablePieceCount()); assertEquals(0,result.knownAvailablePieceCount()); + assertEquals(rows.get(0).availablePackages(),result.unknownCartonCount()); assertTrue(result.groups().get(0).skuContents().isEmpty()); + } + } + + /** 手工注入只读仓储及JSON解析器,测试不依赖运行服务。 */ + private InventoryPieceService service(InventoryPieceRepository repository) { + var service=new InventoryPieceService(); ReflectionTestUtils.setField(service,"repository",repository); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper()); return service; + } + /** 保留每个仓位所属记账分组及真实箱身份。 */ + private PieceRow row(long position,long balance,long pallet,long boxes,long carton,Long quantity,String json) { + return new PieceRow(position,balance,"A","W","DEST",balance==10?"A":"C",pallet,boxes,carton,"BOX-"+carton,quantity,json); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java new file mode 100644 index 000000000..277f8ff70 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class PalletMutationOrderMapperTest { + /** 执行真实Mapper,证明入仓ID不同于尾程ID且同源分片跨客户不会串锁。 */ + @Test void resolvesSnapshotIdsToRealOrdersAndKeepsMissingSourcesFailClosed() { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new JdbcTemplate(ds); + try { + db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,relabel_no VARCHAR,lastleg_order_id BIGINT)"); + db.execute("CREATE TABLE ll_relabel_item(customer_id VARCHAR,application_id BIGINT,source_inbound_order_id BIGINT,return_carton_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,overseas_warehouse_id VARCHAR,pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_return_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,plan_id BIGINT,customer_id VARCHAR,line_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT)"); + db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR,source_return_no VARCHAR,overseas_warehouse_id VARCHAR,total_quantity BIGINT,pallet_code VARCHAR)"); + db.execute("CREATE TABLE ll_return_order(customer_id VARCHAR,return_no VARCHAR,lastleg_order_id BIGINT)"); + db.update("INSERT INTO ll_relabel_application VALUES(1,'C','RL',900)"); + db.update("INSERT INTO ll_relabel_item VALUES('C',1,100,NULL)"); + db.update("INSERT INTO ll_inbound_order VALUES(100,'C','WAREHOUSE_TRANSFER:ZY'),(101,'C','MISSING')"); + db.update("INSERT INTO ll_order VALUES(200,'C','WAREHOUSE_TRANSFER:ZY'),(201,'OTHER','WAREHOUSE_TRANSFER:ZY')"); + db.update("INSERT INTO ll_inventory_balance VALUES(10,'C',100),(11,'C',101)"); + db.update("INSERT INTO ll_inventory_position VALUES(20,'C',10,'WH',500),(21,'C',11,'WH',501)"); + var configuration=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + configuration.addMapper(PdaPalletOperationMapper.class); + try(var session=new SqlSessionFactoryBuilder().build(configuration).openSession()){ + var mapper=session.getMapper(PdaPalletOperationMapper.class); + var rows=mapper.mutationOrders("WH",List.of(500L),List.of("P500"),List.of("RL")); + assertThat(rows).extracting(PdaPalletOperationMapper.MutationOrder::orderId).containsExactlyInAnyOrder(900L,200L); + assertThat(rows).allSatisfy(r->assertThat(r.customerId()).isEqualTo("C")); + assertThat(mapper.mutationOrders("WH",List.of(501L),List.of("P501"),List.of())).singleElement().satisfies(r->{assertThat(r.customerId()).isEqualTo("C");assertThat(r.orderId()).isNull();}); + } + } finally { + // Mapper会话先关闭,再销毁本用例独有的H2数据库,不把内存库留给全量回归。 + db.execute("SHUTDOWN"); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java new file mode 100644 index 000000000..455d5b954 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class PalletOperationRecoveryServiceTest { + private final PalletOperationRecoveryRepository repository=mock(PalletOperationRecoveryRepository.class); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final PalletOperationRecoveryService service=new PalletOperationRecoveryService(repository,json); + private PdaPalletOperation row(){var r=new PdaPalletOperation();r.setId(9L);r.setOverseasWarehouseId(88L);r.setOperationType("RELABEL_PALLET_DISASSEMBLE");r.setStatus("PROCESSING");r.setUpdatedAt(LocalDateTime.now().minusHours(1));r.setRequestHash("original-hash");return r;} + private PalletOperationRecoveryService.Close request(PdaPalletOperation r){return new PalletOperationRecoveryService.Close(88L,r.getUpdatedAt(),"recovery-1","核实无远端意图且无业务提交");} + @Test void closesOnlyNoEffectAndReplaysImmutableAudit() throws Exception { + var row=row();row.setLastError("original failure");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true); + var result=service.close(9L,"admin",request(row));assertEquals("RECOVERED_NO_EFFECT",result.status());assertEquals("original failure",result.previousError());assertFalse(result.replayed()); + row.setStatus(result.status());row.setResultJson(json.writeValueAsString(result)); + var replay=service.close(9L,"admin",request(row));assertTrue(replay.replayed());assertEquals(result.closedAt(),replay.closedAt());verify(repository,times(1)).close(eq(9L),anyString(),any());assertEquals("original-hash",row.getRequestHash()); + assertThrows(ResponseStatusException.class,()->service.close(9L,"different-admin",request(row))); + } + @Test void unknownOrConfirmedRemoteAttemptsCannotBeClosed(){for(String journal:new String[]{"[{\"operationKey\":\"x\",\"receipt\":null}]","[{\"receipt\":{\"operationStatus\":\"APPLIED\"}}]","{}","corrupt"}){var row=row();row.setRemoteTransitionsJson(journal);when(repository.lock(9L)).thenReturn(row);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}verify(repository,never()).close(anyLong(),anyString(),any());} + @Test void activeChangedCompletedAndWrongWarehouseReject(){var row=row();when(repository.lock(9L)).thenReturn(row);var old=request(row);row.setUpdatedAt(LocalDateTime.now());assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",old));assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("COMPLETED");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("PROCESSING");row.setOverseasWarehouseId(99L);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));verify(repository,never()).close(anyLong(),anyString(),any());} + @Test void conditionalWriteLossIsNotSuccess(){var row=row();when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(false);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));} + /** 已补偿失败也须逐条确认无意图,审计保留真实前态。 */ + @Test void failedCompensatedRequiresTheSameNoRemoteProof(){var row=row();row.setStatus("FAILED_COMPENSATED");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true);assertEquals("FAILED_COMPENSATED",service.close(9L,"admin",request(row)).previousStatus());row.setRemoteTransitionsJson("[{\"receipt\":null}]");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java index 2e50caf6a..45a58d936 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java @@ -19,7 +19,9 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.mapper.PalletOperationRecoveryMapper; import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; @@ -36,6 +38,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; @@ -65,6 +68,13 @@ import static org.mockito.Mockito.*; /** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */ class PdaPalletDispatchFreezePersistenceTest { + private final List fixtures = new ArrayList<>(); + + /** 每个用例结束都销毁其独立H2库,避免DB_CLOSE_DELAY=-1在全套测试中累计保留。 */ + @AfterEach void closeFixtures() { + for (var fixture : fixtures) fixture.db.execute("SHUTDOWN"); + fixtures.clear(); + } private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0); private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L); @@ -343,7 +353,7 @@ class PdaPalletDispatchFreezePersistenceTest { } /** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */ - static class Fixture { + class Fixture { final JdbcTemplate db; final PdaPalletOperationService service = new PdaPalletOperationService(); final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class); @@ -366,9 +376,12 @@ class PdaPalletDispatchFreezePersistenceTest { } }; db = new JdbcTemplate(ds); + // SqlSessionTemplate每次调用自行释放会话;用例最后显式关闭仅本夹具拥有的数据库。 + fixtures.add(this); baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type); - createColumns("ll_order",List.of("id","customer_id","detained")); + createColumns("ll_order",List.of("id","customer_id","detained","source_inbound_id")); + createColumns("ll_inbound_order",List.of("id","customer_id","source_inbound_id")); createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id")); createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id")); createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id")); @@ -381,6 +394,7 @@ class PdaPalletDispatchFreezePersistenceTest { createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at")); createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status")); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(PalletOperationRecoveryMapper.class); for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); @@ -401,6 +415,7 @@ class PdaPalletDispatchFreezePersistenceTest { operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class)); var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory); ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced)); + ReflectionTestUtils.setField(service,"recoveryRepository",new PalletOperationRecoveryRepository(sessions.getMapper(PalletOperationRecoveryMapper.class))); ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical); ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class)); ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class)); @@ -480,8 +495,12 @@ class PdaPalletDispatchFreezePersistenceTest { /** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */ void position(long id,String customer,long balance,long pallet,long order,long held) { - db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); - db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order); + // 入仓快照和尾程订单是不同主键,经同客户的来源身份解析,禁止借相同ID掩盖映射错误。 + String source = "TEST-SOURCE-"+order; + long inbound = order+100000; + db.update("INSERT INTO ll_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",order,customer,source); + db.update("INSERT INTO ll_inbound_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",inbound,customer,source); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,inbound); String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER"; db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index ada74b54a..c92b53c33 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -62,6 +62,7 @@ class PdaPalletOperationServiceTest { } @Mock private PdaPalletOperationMapper mapper; @Mock private PdaPalletOperationRepository mutationRepository; + @Mock private com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository recoveryRepository; @Mock private PhysicalPalletSerialPort physicalPalletSerial; @Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Mock private InventoryPositionMapper positions; @@ -82,6 +83,8 @@ class PdaPalletOperationServiceTest { ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class)); ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository); + ReflectionTestUtils.setField(service, "recoveryRepository", recoveryRepository); + lenient().when(recoveryRepository.journal(anyLong(),anyString(),any())).thenReturn(true); ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial); // 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。 lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> { @@ -577,6 +580,8 @@ class PdaPalletOperationServiceTest { } @Test void unboundReturnRelabelSourceDoesNotUnboxNullPalletIdentity() { + ReflectionTestUtils.setField(service, "relabelWorkTasks", + mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class)); RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 0b27bde80..e023838a5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -76,6 +76,9 @@ class PdaRelabelServiceTest { @BeforeEach void setUp() { service = new PdaRelabelService(); + var quantityRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository.class); + lenient().when(quantityRepository.sourceSnapshot(any())).thenReturn(java.util.Map.of("itemBindings", List.of())); + ReflectionTestUtils.setField(service, "quantityRepository", quantityRepository); ReflectionTestUtils.setField(service, "skuConversions", skuConversions); ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements); ReflectionTestUtils.setField(service, "applications", applications); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java index f2411c501..083a21cc1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java @@ -12,6 +12,55 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class RelabelQuantityServiceTest { + /** 构造不可变历史供多轮和多来源回归使用。 */ + private com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory history(String action, String json) { + var event = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + event.setAction(action); event.setDetailJson(json); return event; + } + @Test void exactBindingsKeepOriginalAndReplaceTargetsAcrossRebindAndRework() { + var a = item("CARTON_AND_SKU", "A", 1); a.setId(1L); + var b = item("CARTON_AND_SKU", "B", 1); b.setId(2L); + var first = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P1\",\"targetPalletCode\":\"T1\"},{\"itemId\":\"2\",\"sourceKnown\":true,\"sourcePalletCode\":\"P2\",\"targetPalletCode\":\"T1\"}]}"); + var second = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"T1\",\"targetPalletCode\":\"T2\"}]}"); + var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, second), new ObjectMapper()); + assertEquals(java.util.Set.of("P1", "P2"), result.sources()); + assertEquals(java.util.Set.of("T1", "T2"), result.targets()); + result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, history("REWORK_ARCHIVE", "{}"), second), new ObjectMapper()); + assertEquals(java.util.Set.of("P1", "P2"), result.sources()); assertNull(result.targets()); + } + @Test void ambiguousLegacySourcesAndCurrentPositionsCannotInventOriginalPallets() { + var a = item("SKU_LABEL", "A", 1); a.setId(1L); a.setSourceInventoryPositionId(999L); + var result = RelabelPalletDimensions.measure(List.of(a), List.of(history("PALLET_BIND", "{\"cartonMarks\":[\"A\"],\"sourcePallets\":[\"P1\",\"P2\"],\"targetPallets\":[\"T1\"]}")), new ObjectMapper()); + assertNull(result.sources()); assertEquals(java.util.Set.of("T1"), result.targets()); + } + @Test void bulkSourceTypesUseExactIdsAndNeverTreatPalletOrParcelAsBoxes() { + var a = item("RETURN_BULK_SKU_LABEL", "M", 2); a.setId(1L); + var b = item("RETURN_BULK_SKU_LABEL", "M", 3); b.setId(2L); + var box = history("SOURCE_SNAPSHOT", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"},{\"itemId\":\"2\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"}]}"); + var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper()); + assertEquals(1L, result.cartons()); assertEquals(java.util.Map.of("BOX",1L), result.sourceUnits()); + for (var type : List.of("PALLET", "PARCEL")) { + box.setDetailJson(box.getDetailJson().replace("BOX", type)); + result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper()); + assertNull(result.cartons()); assertEquals(1L, result.sourceUnits().get(type)); + box.setDetailJson(box.getDetailJson().replace(type, "BOX")); + } + } + @Test void allFilterPalletTotalsDeduplicateSameWarehouseAcrossOrders() { + var service = service(); var repo = mock(RelabelQuantityRepository.class); + ReflectionTestUtils.setField(service,"repository",repo); + var a = new RelabelApplication(); a.setRelabelNo("A"); a.setOverseasWarehouseId("W"); + var b = new RelabelApplication(); b.setRelabelNo("B"); b.setOverseasWarehouseId("W"); + var item = item("SKU_LABEL", "A", 1); item.setId(1L); + var event = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\",\"targetPalletCode\":\"T\"}]}"); + var query = new RelabelRequests.Query(2,1,null,null,null,null,null,null,null); + when(repo.filtered(null,query)).thenReturn(List.of(a,b)); + when(repo.items(a)).thenReturn(List.of(item)); when(repo.items(b)).thenReturn(List.of(item)); + when(repo.history(a)).thenReturn(List.of(event)); when(repo.history(b)).thenReturn(List.of(event)); + var result = service.totals(null,query); + assertEquals(1L,result.knownSourcePalletCount()); assertEquals(1L,result.knownTargetPalletCount()); + assertEquals(0L,result.unknownSourcePalletOrders()); assertEquals(2,result.orderCount()); + } /** 以真实对象测试件数、箱数的不同业务口径。 */ private RelabelQuantityService service() { var service = new RelabelQuantityService(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java index 478c9da19..25f3efb88 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java @@ -54,7 +54,9 @@ class ReturnInventoryMovementSqlTest { var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method) .getBoundSql(params); String sql = boundSql.getSql(); - assertEquals(8, sql.split("UNION ALL", -1).length); + // 原有八种库存流水加上正式废弃扣减分支,完整保留投影和客户过滤合同。 + assertEquals(9, sql.split("UNION ALL", -1).length); + assertTrue(sql.contains("'DISCARD'")); assertTrue(sql.contains("ll_return_carton_stock_receipt")); assertTrue(!sql.contains("CONVERT(")); assertTrue(sql.contains("m.customerId=?")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index c5bacefba..61b4a81dd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -77,6 +77,7 @@ class LastlegOrderServiceTest { @Mock ReturnOrderReferenceResolver returnOrderReferences; @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; LastlegOrderService service; + @Mock com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Test void exposesWarehouseTransferNumberAsTailOrderNumber() { @@ -104,7 +105,7 @@ class LastlegOrderServiceTest { LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( service, "summary", order, List.of(), null, "UNSCHEDULED", - new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null); + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null, null); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); @@ -117,6 +118,7 @@ class LastlegOrderServiceTest { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); service = new LastlegOrderService(); + ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java new file mode 100644 index 000000000..a09d69a15 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.transfer; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper; +import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TransferQuantityMapperTest { + /** 使用真实MyBatis注册解析动态XML,避免仅javac放过未转义比较符。 */ + @Test void registersBothDynamicQuantityMappers() { + var configuration = new MybatisConfiguration(); + assertDoesNotThrow(() -> configuration.addMapper(TransferQuantityMapper.class)); + assertDoesNotThrow(() -> configuration.addMapper(InventoryPieceMapper.class)); + assertTrue(configuration.hasStatement(TransferQuantityMapper.class.getName() + ".rows")); + assertTrue(configuration.hasStatement(InventoryPieceMapper.class.getName() + ".rows")); + } + + /** 真正执行SQL核对未知件数、出库后历史实收、同托重叠及客户隔离。 */ + @Test void executesQuantityFactsWithoutLosingUnknownsOrShippedHistory() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:transfer-quantity-" + System.nanoTime() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection = ds.getConnection(); var sql = connection.createStatement()) { + sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,package_count BIGINT)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_item(customer_id VARCHAR,transfer_order_id BIGINT,declared_quantity BIGINT)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_pallet_id BIGINT,counted_at TIMESTAMP,status VARCHAR,counted_quantity BIGINT)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)"); + sql.execute("INSERT INTO ll_order VALUES(1,'A'),(2,'A'),(3,'B')"); + sql.execute("INSERT INTO ll_warehouse_transfer_order VALUES(11,'A',1,3),(12,'A',2,1),(13,'B',3,1)"); + sql.execute("INSERT INTO ll_warehouse_transfer_item VALUES('A',11,10),('A',11,NULL),('A',12,4),('B',13,999)"); + sql.execute("INSERT INTO ll_warehouse_transfer_pallet VALUES(21,'A',100),(22,'B',100)"); + sql.execute("INSERT INTO ll_warehouse_transfer_carton VALUES('A',11,21,CURRENT_TIMESTAMP,'COUNTED',5),('A',11,21,CURRENT_TIMESTAMP,'SHIPPED',7),('A',11,NULL,NULL,'NOT_RECEIVED',NULL),('A',12,NULL,CURRENT_TIMESTAMP,'DISCARDED',NULL),('B',13,22,CURRENT_TIMESTAMP,'COUNTED',999)"); + } + var configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); + configuration.addMapper(TransferQuantityMapper.class); + try (var session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession()) { + var mapper = session.getMapper(TransferQuantityMapper.class); + var filter = new QueryWrapper().eq("customer_id", "A").orderByAsc("id"); + var rows = mapper.rows(filter); + assertEquals(2, rows.size()); + var first = rows.get(0); + assertNull(first.declaredPieceCount()); + assertEquals(12L, first.receivedPieceCount()); + assertEquals(2L, first.receivedCartonCount()); + assertEquals(1L, first.receivedPalletCount()); + assertEquals(1L, first.remainingPalletCount()); + assertEquals(1L, first.shippedPalletCount()); + assertNull(rows.get(1).receivedPieceCount()); + var pallets = mapper.pallets(filter); + assertEquals(2, pallets.size()); + assertTrue(pallets.stream().allMatch(pallet -> pallet.customerId().equals("A"))); + assertTrue(mapper.rows(new QueryWrapper().eq("customer_id", "NONE")).isEmpty()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index bebe9059a..97abc0c3d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -18,7 +18,7 @@ class MerchantWarehouseTransferControllerTest { private final com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService = mock(com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService.class); private final MerchantWarehouseTransferController controller = controller(); private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantPageUsesOnlyAuthenticatedTenant() { @@ -26,6 +26,13 @@ class MerchantWarehouseTransferControllerTest { verify(service).page("100", query); } + /** 汇总只使用已认证客户,平台身份不可借客户入口读取数量。 */ + @Test void quantityTotalsUsesOnlyAuthenticatedCustomer() { + controller.quantities(user(100L,"MERCHANT"),query); + verify(service).quantityTotals("100",query); + assertThrows(ResponseStatusException.class,() -> controller.quantities(user(0L,"PLATFORM"),query)); + } + @Test void merchantDetailUsesOnlyAuthenticatedTenant() { controller.detail(user(100L, "MERCHANT"), "TR001"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java new file mode 100644 index 000000000..ccfd686c5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.TransferQuantityView; +import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.PalletFact; +import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TransferQuantityServiceTest { + /** 库存转运签收使用目的仓实收凭据,同时保留原仓已出库托盘。 */ + @Test void destinationReceiptUsesExactOriginalPiecesAndPhysicalTargetPallets() { + var repository=mock(TransferQuantityRepository.class); + var service=new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository); + var filter=new QueryWrapper().eq("id",3); + when(repository.rows(filter)).thenReturn(List.of(new TransferQuantityView(3L,14L,null,2,0,0,0,0,0,0))); + when(repository.movements(filter)).thenReturn(List.of( + new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,100L,"RECEIVED",1L,200L,10L), + new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,101L,"RECEIVED",2L,201L,4L))); + var result=service.totals(filter); + assertEquals(14L,result.receivedPieceCount()); assertEquals(2,result.receivedCartonCount()); + assertEquals(2,result.receivedPalletCount()); assertEquals(2,result.shippedPalletCount()); + assertEquals(2,result.shippedCartonCount()); + } + + /** 完整筛选总量保留未知,托盘按客户去重且剩余与出库集合允许重叠。 */ + @Test void keepsUnknownsAndDeduplicatesOverlappingPalletsAcrossCustomers() { + var repository = mock(TransferQuantityRepository.class); + var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository); + var filter = new QueryWrapper().eq("order_type","WAREHOUSE_TRANSFER"); + when(repository.rows(filter)).thenReturn(List.of( + new TransferQuantityView(1L,null,12L,3,2,1,1,1,1,1), + new TransferQuantityView(2L,4L,null,1,1,1,1,0,1,0))); + when(repository.pallets(filter)).thenReturn(List.of(new PalletFact("A",100L,"COUNTED"), + new PalletFact("A",100L,"SHIPPED"),new PalletFact("A",100L,"COUNTED"),new PalletFact("B",100L,"COUNTED"))); + var totals = service.totals(filter); + assertEquals(2,totals.orderCount()); assertNull(totals.declaredPieceCount()); assertEquals(4,totals.knownDeclaredPieceCount()); + assertEquals(1,totals.unknownDeclaredOrderCount()); assertNull(totals.receivedPieceCount()); + assertEquals(12,totals.knownReceivedPieceCount()); assertEquals(1,totals.unknownReceivedOrderCount()); + assertEquals(2,totals.receivedPalletCount()); assertEquals(2,totals.remainingPalletCount()); assertEquals(1,totals.shippedPalletCount()); + verify(repository).rows(filter); verify(repository).pallets(filter); + } + + /** 空筛选返回准确零值,非转运订单无需额外数量读取。 */ + @Test void emptyFilterIsZeroAndNonTransferPageSkipsRepository() { + var repository = mock(TransferQuantityRepository.class); + var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository); + var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL"); + assertTrue(service.forOrders(List.of(order)).isEmpty()); verifyNoInteractions(repository); + var totals = service.totals(new QueryWrapper().apply("1=0")); + assertEquals(0L,totals.declaredPieceCount()); assertEquals(0L,totals.receivedPieceCount()); assertEquals(0,totals.receivedPalletCount()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index e0de96786..1ce61ed87 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -27,7 +27,6 @@ class WarehouseTransferCartonCountTest { service = spy(new WarehouseTransferService()); ReflectionTestUtils.setField(service, "transfers", orders); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0))); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferItems", items); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index c3ec0bb4e..860ffb446 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -143,7 +143,7 @@ class WarehouseTransferServiceTest { when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null)); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null)); assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); assertEquals("COMPLETED", result.records().get(0).relabelStatus()); @@ -162,7 +162,7 @@ class WarehouseTransferServiceTest { when(transfers.selectPage(any(), any())).thenReturn(resultPage); var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null)); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null)); assertEquals(1, result.records().size()); assertNull(result.records().get(0).relabelStatus()); @@ -233,7 +233,6 @@ class WarehouseTransferServiceTest { }); ReflectionTestUtils.setField(service, "transfers", transfers); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0))); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferCartons", transferCartons); @@ -521,8 +520,6 @@ class WarehouseTransferServiceTest { transfer.setInboundCode("IN-1"); transfer.setNeedRelabel(true); transfer.setPackageCount(1); - var selected = new WarehouseTransferCarton(); - selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX"); when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);