From 4f13ee2ec307b09138968d6f73a0ca313d33e028 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 20:55:09 +0800 Subject: [PATCH] fix(dropship): preserve carton quantities and shared pallet outbound ownership --- .../event/FirstLegLoadingPlanCompletedV2.java | 11 ++- .../openapi/cross-region-events-v1.yaml | 6 +- .../FirstLegLoadingPlanCompletedV2Test.java | 22 ++++- deploy/sql/海外区域统一增量-MySQL8.sql | 3 +- .../LoadingCompletedOutboxService.java | 73 ++++---------- .../LoadingCompletedOutboxServiceTest.java | 19 +++- .../service/DeliveryTransferService.java | 29 +++++- .../dropship/dto/DropshipRequests.java | 3 +- .../dropship/entity/DropshipCarton.java | 1 + .../mapper/DropshipOrderProfileMapper.java | 2 +- .../mapper/DropshipSkuInventoryMapper.java | 4 +- .../service/DropshipOrderService.java | 7 ++ .../service/LoadingCompletedConsumer.java | 41 +++++--- .../src/main/resources/db/lastleg.sql | 3 +- .../openapi/lastleg-internal-dropship-v1.yaml | 6 +- .../openapi/overseas-business-v1.yaml | 2 +- .../delivery/DeliverySchemaContractTest.java | 2 +- .../service/DeliveryTransferServiceTest.java | 98 ++++++++++++++++++- .../DropshipOrderControllerContractTest.java | 4 +- .../service/DropshipOrderServiceTest.java | 6 +- .../service/LoadingCompletedConsumerTest.java | 25 ++++- scripts/admin-test-env-modules/README.md | 2 +- .../dropship-pallet-matrix-contract.test.mjs | 11 +++ .../dropship-pallet-matrix.mjs | 21 +++- 24 files changed, 302 insertions(+), 99 deletions(-) diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 8c64ee40f..ad7df7775 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -157,11 +157,20 @@ public record FirstLegLoadingPlanCompletedV2( /** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */ public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName, int packageCount, int productQuantity, - List cartonMarks) { + List cartonMarks, java.util.Map cartonQuantities) { public SkuPackageSnapshot { if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) { throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot"); } + if (cartonMarks == null || cartonQuantities == null || cartonMarks.size() != packageCount + || new java.util.HashSet<>(cartonMarks).size() != packageCount + || !cartonQuantities.keySet().equals(new java.util.HashSet<>(cartonMarks)) + || cartonQuantities.values().stream().anyMatch(quantity -> quantity == null || quantity <= 0) + || cartonQuantities.values().stream().mapToLong(Integer::longValue).sum() != productQuantity) { + throw new IllegalArgumentException("cartonQuantities must exactly cover cartonMarks and productQuantity"); + } + cartonMarks = List.copyOf(cartonMarks); + cartonQuantities = java.util.Map.copyOf(cartonQuantities); } } } diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml index 510436c55..081b3e385 100644 --- a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -66,7 +66,7 @@ components: items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'} FirstLegSkuPackageSnapshotV2: type: object - required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks] + required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks, cartonQuantities] properties: sourceInboundDetailId: {type: string, minLength: 1, description: Exact source inbound detail identity matching the order item; never infer from SKU because multiple details may share a SKU} skuCode: {type: string} @@ -74,6 +74,10 @@ components: packageCount: {type: integer, minimum: 1} productQuantity: {type: integer, minimum: 1} cartonMarks: {type: array, items: {type: string}, minItems: 1} + cartonQuantities: + type: object + description: 箱唛到逐箱实收件数的完整映射;键与 cartonMarks 相同,件数合计等于 productQuantity + additionalProperties: {type: integer, minimum: 1} OverseasContainerUnloadedV1: type: object required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt] diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 54c4d1b57..8e6375eed 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -11,15 +11,29 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class FirstLegLoadingPlanCompletedV2Test { + @Test + void exactCartonQuantitiesRejectMissingMarksAndIncorrectTotals() { + var marks = List.of("BOX-A", "BOX-B"); + var snapshot = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 7, "BOX-B", 13)); + assertThat(snapshot.cartonQuantities().get("BOX-B")).isEqualTo(13); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 20))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 7, "BOX-B", 12))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 0, "BOX-B", 20))).isInstanceOf(IllegalArgumentException.class); + } + @Test void palletSkuRequiresExplicitSourceDetailIncludingJsonDeserialization() throws Exception { var mapper = new ObjectMapper(); - var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101")); - var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102")); + var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10)); + var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"), java.util.Map.of("BOX-102", 20)); assertThat(mapper.readValue(mapper.writeValueAsString(first), FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class)) .isEqualTo(first).isNotEqualTo(second); for (String invalid : new String[]{null, "", " "}) { - assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101"))) + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10))) .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId"); } assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}", @@ -81,7 +95,7 @@ class FirstLegLoadingPlanCompletedV2Test { @Test void carriesDropshipPalletAndCartonSnapshots() throws Exception { var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot( - "101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002")); + "101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"), java.util.Map.of("BOX-001", 7, "BOX-002", 13)); var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku)); var order = new FirstLegLoadingPlanCompletedV2.Order( "2", "2", "3", "ORD", "3", "RK", null, null, null, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f2fbcdb7d..97a4ce48d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1528,7 +1528,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), + UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) ) ENGINE=InnoDB COMMENT='出库托盘明细'; @@ -1968,6 +1968,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( CREATE TABLE IF NOT EXISTS ll_dropship_carton ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + product_quantity BIGINT NOT NULL COMMENT '该箱实收商品件数', carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', version BIGINT NOT NULL DEFAULT 0 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_ll_dropship_carton(customer_id,order_id,carton_mark), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 3e5b242a1..1dd920e0b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -293,61 +293,28 @@ public class LoadingCompletedOutboxService { private List palletSnapshots(List values, List source, Map manifests) { - if (source.isEmpty()) return List.of(); - if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) { - return actualPalletSnapshots(values, source, manifests); + if (source.isEmpty() || source.stream().anyMatch(item -> !Boolean.TRUE.equals(item.getActualBinding()))) { + throw new BizException(ResultCode.CONFLICT, "一件代发必须提供真实逐箱实收托盘快照"); } - Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( - p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), - LinkedHashMap::new, java.util.stream.Collectors.toList())); - int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount) - .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); - if (loadedPackages != sumInt(values, Row::getPackageCount)) { - throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致"); - } - Map details = new LinkedHashMap<>(); - values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row -> - details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(), - row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>()))); - source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> { - DetailSnapshot detail = details.get(item.getSourceInboundDetailId()); - if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode()); - }); - Map remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap( - DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new)); - Map remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap( - DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new)); - List result = new ArrayList<>(); - for (List palletRows : byPallet.values()) { - PalletRow first = palletRows.get(0); - int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount(); - List sku = new ArrayList<>(); - for (DetailSnapshot detail : details.values()) { - int remaining = remainingPackages.getOrDefault(detail.detailId(), 0); - if (capacity <= 0 || remaining <= 0) continue; - int count = Math.min(capacity, remaining); - int totalQuantity = detail.productQuantity() == null ? count - : (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount())); - int markStart = remainingMarks.getOrDefault(detail.detailId(), 0); - int markEnd = Math.min(detail.marks().size(), markStart + count); - List marks = new ArrayList<>(detail.marks().subList(markStart, markEnd)); - if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致"); - sku.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.detailId().toString(), detail.skuCode(), - detail.productName(), count, totalQuantity, marks)); - remainingPackages.put(detail.detailId(), remaining - count); - remainingMarks.put(detail.detailId(), markEnd); - capacity -= count; - } - if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot( - first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku)); - } - if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) { - throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细"); - } - return result; + return actualPalletSnapshots(values, source, manifests); } /** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */ + private Map cartonQuantities(List rows, List marks) { + Map quantities = new LinkedHashMap<>(); + for (PalletRow row : rows) { + if (!marks.contains(row.getBoxCode())) continue; + Integer quantity = row.getActualSkuQuantity(); + if (quantity == null || quantity <= 0 || quantities.putIfAbsent(row.getBoxCode(), quantity) != null) { + throw new BizException(ResultCode.CONFLICT, "一件代发箱唛缺少有效的逐箱实点 SKU 数量"); + } + } + if (quantities.size() != marks.size()) { + throw new BizException(ResultCode.CONFLICT, "一件代发逐箱实点 SKU 数量未覆盖全部箱唛"); + } + return quantities; + } + private List actualPalletSnapshots( List values, List source, Map manifests) { Map details = values.stream().collect(java.util.stream.Collectors.toMap( @@ -373,7 +340,7 @@ public class LoadingCompletedOutboxService { .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量"); skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(), - detail.getProductNameZh(), marks.size(), quantity, marks)); + detail.getProductNameZh(), marks.size(), quantity, marks, cartonQuantities(detailRows.getValue(), marks))); actualPackages += marks.size(); } PalletManifest manifest = manifests.get(pallet.getKey()); @@ -415,8 +382,6 @@ public class LoadingCompletedOutboxService { return "ONE_CLICK_DROPSHIP".equals(value); } - private record DetailSnapshot(Long detailId, String skuCode, String productName, - Integer packageCount, Integer productQuantity, List marks) {} private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {} private void insert(EventEnvelope envelope) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index b74f3e744..aee65a76c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -290,7 +290,7 @@ class LoadingCompletedOutboxServiceTest { @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) - void freezesRepeatedSkuCartonsBySourceDetailForBothPalletSources(boolean actualBinding) throws Exception { + void freezesActualSkuCartonsAndRejectsInferredPalletSources(boolean actualBinding) throws Exception { Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); first.setSkuCode("SAME-SKU"); first.setProductQuantity(10); Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); @@ -305,6 +305,10 @@ class LoadingCompletedOutboxServiceTest { when(snapshots.palletRows(90L)).thenReturn(List.of( pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"), pallet(11,100,1,"DP-260909-0001",2,101,"SAME-SKU",1,10,"BOX-101"))); + assertThatThrownBy(() -> service.publish(90L)).isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("真实逐箱实收"); + verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + return; } service.publish(90L); service.publish(90L); @@ -320,6 +324,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(sku.path("cartonMarks")).hasSize(1); assertThat(sku.path("cartonMarks").get(0).asText()).isEqualTo("BOX-" + detail); assertThat(sku.path("productQuantity").asInt()).isEqualTo("101".equals(detail) ? 10 : 20); + assertThat(sku.path("cartonQuantities").path("BOX-" + detail).asInt()).isEqualTo("101".equals(detail) ? 10 : 20); } assertThat(frozen.get(0).path("sourceInboundDetailId")) .isNotEqualTo(frozen.get(1).path("sourceInboundDetailId")); @@ -332,10 +337,11 @@ class LoadingCompletedOutboxServiceTest { Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); when(snapshots.rows(90L)).thenReturn(List.of(first, second)); - when(snapshots.palletRows(90L)).thenReturn(List.of( - pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"), - pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"), - pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003"))); + when(snapshots.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-001",7), + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-002",13), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-003",5))); service.publish(90L); @@ -346,6 +352,8 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11"); assertThat(event.payload().at("/orders/0/pallets")).hasSize(2); assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-001").asInt()).isEqualTo(7); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-002").asInt()).isEqualTo(13); assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } @@ -463,6 +471,7 @@ class LoadingCompletedOutboxServiceTest { String sku,String boxCode,int actualSkuQuantity) { PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode); row.setActualBinding(true); + row.setPalletId(Integer.toUnsignedLong(palletNo.hashCode()) + 1L); row.setActualSkuQuantity(actualSkuQuantity); return row; } 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 792671322..94c7f9ff1 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 @@ -265,13 +265,21 @@ public class DeliveryTransferService { outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); + Set physicalPalletIds = new HashSet<>(); for (DeliveryRequests.OneClickItem input : request.items()) { InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (balance == null || !customerId.equals(balance.getCustomerId()) || !StringUtils.hasText(balance.getInboundCode())) { + throw conflict("一件代发出库库存缺少所属入仓码"); + } OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setInboundCode(balance.getInboundCode()); + physicalPalletIds.add(position.getPalletId()); item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now); outboundPallets.insert(item); } + outbound.setPalletCount(physicalPalletIds.size()); outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound); OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); @@ -476,6 +484,11 @@ public class DeliveryTransferService { if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) { throw conflict("Scanned inventory position does not belong to the bound warehouse"); } + InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId()); + if (selectedBalance == null || !customerId.equals(selectedBalance.getCustomerId()) + || !StringUtils.hasText(item.getInboundCode()) || !item.getInboundCode().equals(selectedBalance.getInboundCode())) { + throw conflict("所选库存不属于出库明细的入仓码"); + } DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); List cartons = StringUtils.hasText(item.getCartonMarks()) @@ -488,7 +501,12 @@ public class DeliveryTransferService { inventoryCommands.allocate(customerId, operatorId, move); inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null)); inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null)); - if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages()) != 1) throw conflict("Insufficient one-click dropship SKU inventory"); + long productQuantity = 0; + for (DropshipCarton carton : cartons) { + if (carton.getProductQuantity() == null || carton.getProductQuantity() <= 0) throw conflict("出库箱唛缺少逐箱实收件数"); + productQuantity = Math.addExact(productQuantity, carton.getProductQuantity()); + } + if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages(), productQuantity) != 1) throw conflict("Insufficient one-click dropship SKU inventory"); for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); } OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item); @@ -630,6 +648,8 @@ public class DeliveryTransferService { long available = value(position.getAvailablePackages()); if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (StringUtils.hasText(item.getInboundCode()) && (balance == null + || !item.getInboundCode().equals(balance.getInboundCode()))) continue; DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId()); List marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId()) .stream().filter(carton -> "AVAILABLE".equals(carton.getStatus())) @@ -698,10 +718,13 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); String customerId = outbound.getCustomerId(); - DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); + DeliveryOrder delivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (delivery == null && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) { + throw new IllegalStateException("Delivery document is missing for dispatch outbound order"); + } List pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream() .map(this::outboundPalletRow).toList(); - return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), delivery == null ? null : deliveryRow(delivery), pallets); } public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java index 98054de3f..cd3f0110f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -15,5 +15,6 @@ public final class DropshipRequests { @NotBlank @Size(max = 64) String manifestHash) {} public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, @Positive long productQuantity, - @NotEmpty List<@NotBlank String> cartonMarks) {} + @NotEmpty List<@NotBlank String> cartonMarks, + @NotEmpty java.util.Map<@NotBlank String, @Positive Long> cartonQuantities) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java index 782f59b26..1180c36c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java @@ -14,6 +14,7 @@ public class DropshipCarton { private String palletNo; private String skuCode; private String cartonMark; + private Long productQuantity; private Integer cartonSequence; private String status; private String outboundOrderNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index d881788e1..1653db542 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -33,7 +33,7 @@ public interface DropshipOrderProfileMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java index 9c21b3a0f..65ad92d87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -14,8 +14,8 @@ import java.util.List; public interface DropshipSkuInventoryMapper extends BaseMapper { @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); - @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages}") - int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},product_quantity=product_quantity-#{quantity},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages} AND product_quantity>=#{quantity}") + int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages, @Param("quantity") long quantity); @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count+#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku}") int restore(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java index f36ed59ff..81d83bb37 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -83,6 +83,7 @@ public class DropshipOrderService { carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId()); carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null); carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence); + carton.setProductQuantity(input.cartonQuantities().get(mark)); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now); cartons.insert(carton); ledger.insert(stockInFact(request, order, carton, now)); @@ -102,6 +103,12 @@ public class DropshipOrderService { if (item.packageCount() != item.cartonMarks().size()) { throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode()); } + if (item.cartonQuantities() == null + || !item.cartonQuantities().keySet().equals(new java.util.HashSet<>(item.cartonMarks())) + || item.cartonQuantities().values().stream().anyMatch(value -> value == null || value <= 0) + || item.cartonQuantities().values().stream().mapToLong(Long::longValue).sum() != item.productQuantity()) { + throw conflict("逐箱实收件数必须完整且与 SKU 汇总件数一致"); + } if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode()); for (String mark : item.cartonMarks()) { if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 4c745c739..ea2da7a9d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -507,18 +507,24 @@ public class LoadingCompletedConsumer { JsonNode rawPallets = rawSource.path("pallets"); for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) { FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex); - Map inventorySkus = new java.util.LinkedHashMap<>(); - for (var sku : pallet.skuPackages()) { - inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(), - sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks()), (left, right) -> { - List marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks()); - return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(), - Math.addExact(left.packageCount(), right.packageCount()), - Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks)); - }); - } JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size() ? rawPallets.get(palletIndex) : objectMapper.createObjectNode(); + Map inventorySkus = new java.util.LinkedHashMap<>(); + for (int skuIndex = 0; skuIndex < pallet.skuPackages().size(); skuIndex++) { + var sku = pallet.skuPackages().get(skuIndex); + inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(), + sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks(), + cartonQuantities(rawPallet.path("skuPackages").path(skuIndex))), (left, right) -> { + List marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks()); + Map quantities = new java.util.LinkedHashMap<>(left.cartonQuantities()); + right.cartonQuantities().forEach((mark, quantity) -> { + if (quantities.putIfAbsent(mark, quantity) != null) throw new IllegalArgumentException("Duplicate carton quantity: " + mark); + }); + return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(), + Math.addExact(left.packageCount(), right.packageCount()), + Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks), Map.copyOf(quantities)); + }); + } result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()), positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"), positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet))); @@ -567,6 +573,19 @@ public class LoadingCompletedConsumer { return value.toLowerCase(java.util.Locale.ROOT); } + private Map cartonQuantities(JsonNode sku) { + JsonNode node = sku.path("cartonQuantities"); + if (!node.isObject()) throw new IllegalArgumentException("One-click dropship cartonQuantities is required"); + Map result = new java.util.LinkedHashMap<>(); + node.fields().forEachRemaining(entry -> { + if (!entry.getValue().isIntegralNumber() || !entry.getValue().canConvertToLong() || entry.getValue().longValue() <= 0) { + throw new IllegalArgumentException("Invalid carton quantity: " + entry.getKey()); + } + result.put(entry.getKey(), entry.getValue().longValue()); + }); + return Map.copyOf(result); + } + private List skuPackages(JsonNode source) { List result = new ArrayList<>(); JsonNode nodes = source.path("skuPackages"); @@ -585,7 +604,7 @@ public class LoadingCompletedConsumer { } if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) { String productName = item.path("productName").asText(item.path("productNameZh").asText(null)); - result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks)); + result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks, cartonQuantities(item))); } } return result; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e2e8353e2..450a185cc 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1435,7 +1435,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), + UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) ) ENGINE=InnoDB COMMENT='出库托盘明细'; @@ -1877,6 +1877,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( CREATE TABLE IF NOT EXISTS ll_dropship_carton ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + product_quantity BIGINT NOT NULL COMMENT '该箱实收商品件数', carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', version BIGINT NOT NULL DEFAULT 0 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_ll_dropship_carton(customer_id,order_id,carton_mark), diff --git a/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml b/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml index 6500963fc..348ca8973 100644 --- a/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml @@ -57,12 +57,16 @@ components: items: { $ref: '#/components/schemas/DropshipSkuPackages' } DropshipSkuPackages: type: object - required: [skuCode, packageCount, productQuantity, cartonMarks] + required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities] properties: skuCode: { type: string, minLength: 1 } productName: { type: string, nullable: true } packageCount: { type: integer, format: int64, minimum: 1 } productQuantity: { type: integer, format: int64, minimum: 1 } + cartonQuantities: + type: object + description: 箱唛到逐箱实收商品件数的完整映射,键必须与 cartonMarks 一致,值合计必须等于 productQuantity + additionalProperties: { type: integer, format: int64, minimum: 1 } cartonMarks: type: array minItems: 1 diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 378ee173a..f741a809f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -664,7 +664,7 @@ paths: description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: '出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } + responses: { '200': { description: '出库单、出库托盘及关联派送单详情;一件代发等独立出库无派送单时 delivery 为 null,排车转派送必须存在派送单;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: post: summary: 取消未执行出库单 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 1c69473d7..05895baa9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -27,7 +27,7 @@ class DeliverySchemaContractTest { assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)")); assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)")); assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); - assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)")); + assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code)")); assertTrue(schema.contains("destination_warehouse_code varchar(128) null")); assertTrue(schema.contains("order_refs varchar(2000) null")); assertTrue(schema.contains("inbound_code varchar(128) null")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 57412e348..53dc27116 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -279,6 +279,31 @@ class DeliveryTransferServiceTest { verify(outboundOrders, never()).insert((OutboundOrder) any()); } + @Test + void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301"); + outbound.setOutboundType("ONE_CLICK_DROPSHIP"); + outbound.setStatus("OUTBOUNDED"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of( + pallet(501L, "DP-260914-0001", "ONT8", "Ontario", "IN-A", 4))); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); + + assertEquals("OUTBOUNDED", detail.outbound().status()); + org.junit.jupiter.api.Assertions.assertNull(detail.delivery()); + assertEquals("IN-A", detail.pallets().get(0).inboundCode()); + assertEquals(4, detail.pallets().get(0).packageCount()); + } + + @Test + void dispatchOutboundStillRequiresItsDeliveryDocument() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); + outbound.setOutboundType("DISPATCH_DELIVERY"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + assertThrows(IllegalStateException.class, () -> service.outboundDetail("OUT-301")); + } + @Test void outboundDetailIncludesPalletCodesForDisplay() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); @@ -406,6 +431,33 @@ class DeliveryTransferServiceTest { verify(outboundPallets, never()).findByOutbound(any(), any()); } + @Test + void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() { + for (long i = 1; i <= 2; i++) { + when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 900L + i, "SKU-1")) + .thenReturn(dropshipCandidate(900L + i, 1000L + i, "WH-1", "DP-SHARED", "SKU-1", 1L)); + when(dropshipCartons.lockAvailable("TENANT-A", 1000L + i, "SKU-1", 1L)) + .thenReturn(List.of(new DropshipCarton())); + InventoryPosition position = new InventoryPosition(); + position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i); + position.setPalletId(501L); position.setPalletNo("DP-SHARED"); + InventoryBalance balance = new InventoryBalance(); + balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i); + when(inventoryPositions.selectById(900L + i)).thenReturn(position); + when(inventoryBalances.selectById(800L + i)).thenReturn(balance); + } + service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 1L, "SKU-1"), + new DeliveryRequests.OneClickItem(902L, 1L, "SKU-1")), null, "shared-pallet")); + ArgumentCaptor items = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(items.capture()); + assertEquals(List.of("IN-1", "IN-2"), items.getAllValues().stream().map(OutboundPalletItem::getInboundCode).toList()); + ArgumentCaptor order = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).updateById(order.capture()); + assertEquals(1, order.getValue().getPalletCount()); + assertEquals(2, order.getValue().getPackageCount()); + } + @Test void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L); @@ -539,17 +591,26 @@ class DeliveryTransferServiceTest { DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A"); DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B"); + InventoryPosition otherInbound = new InventoryPosition(); + otherInbound.setId(902L); otherInbound.setCustomerId("TENANT-A"); otherInbound.setBalanceId(802L); + otherInbound.setOverseasWarehouseId("WH-1"); otherInbound.setPalletId(501L); + otherInbound.setAvailablePackages(2L); + InventoryBalance otherBalance = new InventoryBalance(); + otherBalance.setId(802L); otherBalance.setCustomerId("TENANT-A"); otherBalance.setInboundCode("IN-B"); + otherBalance.setSkuCode("SKU-1"); when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); when(outboundOrders.selectById(301L)).thenReturn(outbound); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); - when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position)); + when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, otherInbound)); when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(inventoryBalances.selectById(802L)).thenReturn(otherBalance); when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second)); DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701"); assertEquals(2, detail.items().get(0).positions().size()); + verify(dropshipProfiles, never()).findPosition("TENANT-A", 902L); assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream() .map(DeliveryResponses.PdaPickPosition::cartonMark).toList()); DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0); @@ -588,6 +649,41 @@ class DeliveryTransferServiceTest { assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark()); } + @Test + void dropshipPartialPicksDeductExactCartonProductQuantities() { + var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class); + ReflectionTestUtils.setField(service, "inventoryCommands", commands); + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "quantity-test"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2); + item.setSkuCode("SKU-1"); + InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A"); + position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1"); + InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L); + DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundPallets.selectById(item.getId())).thenReturn(item); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "SKU-1", 1L)).thenReturn(List.of(first), List.of(second)); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7")); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13")); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L); + assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus()); + assertEquals(2, item.getPickedPackageCount()); + } + @Test void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java index e1600578a..85d128f4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java @@ -59,7 +59,7 @@ class DropshipOrderControllerContractTest { "name: X-Internal-Service-Token", "required: [customerId, orderId, warehouseId, idempotencyKey, pallets]", "required: [palletNo, skuPackages]", - "required: [skuCode, packageCount, productQuantity, cartonMarks]", + "required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]", "data: { type: integer, format: int32, minimum: 0 }"); assertThat(yaml.indexOf("name: X-Internal-Service-Token")) .isLessThan(yaml.indexOf("requestBody:")); @@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest { return new DropshipRequests.Receive("100", 10L, 20L, "event-1", List.of(new DropshipRequests.Pallet("PALLET-1", List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2, - List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64)))); + List.of("CARTON-1"), java.util.Map.of("CARTON-1", 2L))), 1, 1, 1, "0".repeat(64)))); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java index c196f09b4..1c312387a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -52,10 +52,10 @@ class DropshipOrderServiceTest { List.of( new DropshipRequests.Pallet("SOURCE-P-1", List.of( new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20, - List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)), + List.of("MARK-1", "MARK-2"), java.util.Map.of("MARK-1", 7L, "MARK-2", 13L))), 1, 2, 1, "0".repeat(64)), new DropshipRequests.Pallet("SOURCE-P-2", List.of( new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10, - List.of("MARK-3"))), 1, 1, 1, "1".repeat(64)))); + List.of("MARK-3"), java.util.Map.of("MARK-3", 10L))), 1, 1, 1, "1".repeat(64)))); assertThat(service.receive(request)).isEqualTo(3); verifyNoInteractions(warehouse); @@ -76,6 +76,8 @@ class DropshipOrderServiceTest { .containsOnlyNulls(); ArgumentCaptor cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class); verify(cartons, times(3)).insert(cartonCaptor.capture()); + assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getProductQuantity) + .containsExactly(7L, 13L, 10L); assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus) .containsOnly("PENDING_INBOUND"); ArgumentCaptor ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index df85a8cc4..44483efcb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -141,6 +141,18 @@ class LoadingCompletedConsumerTest { } verify(dropshipOrders).receive(any()); } + @Test void preservesNonUniformCartonQuantitiesAcrossTheEventBoundary() { + EventEnvelope original=overseasDropshipEvent(false); + var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy(); + var sku=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("pallets").get(0).path("skuPackages").get(0); + var quantities=sku.putObject("cartonQuantities"); + quantities.put("RK1#5-1",6).put("RK1#5-2",7).put("RK1#5-3",8).put("RK1#5-4",9).put("RK1#5-5",20); + service.consume(rehash(original,node)); + var captured=ArgumentCaptor.forClass(DropshipRequests.Receive.class); + verify(dropshipOrders).receive(captured.capture()); + assertEquals(Map.of("RK1#5-1",6L,"RK1#5-2",7L,"RK1#5-3",8L,"RK1#5-4",9L,"RK1#5-5",20L), + captured.getValue().pallets().get(0).skuPackages().get(0).cartonQuantities()); + } @Test void rejectsAmbiguousDropshipSkuSourceBeforeWritingReceivingLabels() { var failure=assertThrows(IllegalArgumentException.class,()->service.consume(overseasDropshipEvent(true))); assertTrue(failure.getMessage().contains("来源明细")); @@ -159,6 +171,7 @@ class LoadingCompletedConsumerTest { var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0); var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n); ((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet); + addManifest(secondPallet, 5); service.consume(rehash(original,node)); @@ -230,5 +243,15 @@ class LoadingCompletedConsumerTest { private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } - private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); } + private void addManifest(JsonNode pallet,int packageCount){ + var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; + object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); + // These fixtures explicitly represent uniform cartons; production must never infer this ratio. + for(JsonNode sku:object.path("skuPackages")) { + int count=sku.path("packageCount").asInt(), quantity=sku.path("productQuantity").asInt(); + assertTrue(count>0 && quantity%count==0); + var quantities=((com.fasterxml.jackson.databind.node.ObjectNode)sku).putObject("cartonQuantities"); + for(JsonNode mark:sku.path("cartonMarks")) quantities.put(mark.asText(),quantity/count); + } + } } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d312002fd..e44e2811d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -37,7 +37,7 @@ export async function run(context) { ## 可用上下文 -`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账;finally 停用本轮操作员。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;尚未包含专用出库验收。 +`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 - `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。 diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index c98d7e34b..5648b8a69 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -2,6 +2,17 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { sameOutboundTask } from './dropship-pallet-matrix.mjs' + +test('outbound replay preserves business identity and database millisecond timestamps', () => { + const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' } + assert.equal(sameOutboundTask(task, { ...task, pushedAt: '2026-09-14T20:24:45.92' }), true) + assert.equal(sameOutboundTask({ ...task, pushedAt: '2026-09-14T20:24:45.9999' }, + { ...task, pushedAt: '2026-09-14T20:24:46' }), true) + for (const key of ['id', 'taskNo', 'outboundNo', 'status', 'pushedAt']) { + assert.equal(sameOutboundTask(task, { ...task, [key]: 'changed' }), false) + } +}) const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index f07bc043f..1f939fefe 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -16,6 +16,16 @@ const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +export function sameOutboundTask(left, right) { + const databaseTime = (value) => { + const match = typeof value === 'string' && value.match(/^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/) + return match ? Date.parse(match[1]) + Math.round(Number(`0.${match[2] || '0'}`) * 1000) : NaN + } + return ['id', 'taskNo', 'outboundNo', 'status'].every((key) => left?.[key] === right?.[key]) + && ['pushedAt', 'completedAt'].every((key) => left?.[key] == null && right?.[key] == null + || Number.isFinite(databaseTime(left?.[key])) && databaseTime(left[key]) === databaseTime(right?.[key])) +} + // Domestic producer stage: these retained loaded fixtures must subsequently be verified // through overseas receipt and dedicated dropship outbound, not generic dispatch. export async function run(context) { @@ -415,12 +425,13 @@ export async function run(context) { const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) for (const record of group) record.outbound = { task, selected, completed: false } const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) - check(`${label}专用出库重放不重复建单`, null, JSON.stringify(replay) === JSON.stringify(task)) + check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task)) const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}` const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true) check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length) for (const [index, candidate] of selected.entries()) { - const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode) + const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode + && row.inboundCode === candidate.inboundCode) check(`${label}PDA拣货来源仓位和入仓码一致`, null, Boolean(item) && item.positions?.some((position) => String(position.inventoryPositionId) === String(candidate.inventoryPositionId) && position.inboundCode === candidate.inboundCode)) @@ -429,16 +440,18 @@ export async function run(context) { cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` } const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true) const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true) - check(`${label}PDA出库拣货重放不重复扣减`, null, JSON.stringify(repeated) === JSON.stringify(picked)) + check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked)) } const completion = { idempotencyKey: `${context.marker}-outbound-complete` } const completed = await call('PDA完成专用出库', 'POST', `${pdaPath}/complete`, completion, true) const repeated = await call('PDA专用出库完成重放', 'POST', `${pdaPath}/complete`, completion, true) check(`${label}专用出库任务完成且重放稳定`, null, - completed.status === 'COMPLETED' && JSON.stringify(repeated) === JSON.stringify(completed)) + completed.status === 'COMPLETED' && sameOutboundTask(repeated, completed), + JSON.stringify({ completed, repeated })) const outbound = await call('回查专用出库单终态', 'GET', `${base}/${task.outboundNo}`) check(`${label}专用出库单真实完成且箱数核平`, null, outbound.outbound?.status === 'OUTBOUNDED' && outbound.outbound?.outboundType === 'ONE_CLICK_DROPSHIP' + && Number(outbound.outbound.palletCount) === new Set(selected.map((item) => item.palletNo)).size && Number(outbound.outbound.packageCount) === inputs.reduce((sum, item) => sum + item.packages, 0) && outbound.pallets.every((item) => Number(item.packageCount) === Number(item.pickedPackageCount))) const remaining = await call('回查专用出库可用库存扣减', 'GET', candidatePath)