From 748855793d3b07a07efb8416646d7feb269de1cf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 13:20:11 +0800 Subject: [PATCH] fix(dropship): project sealed carton identities into overseas receiving --- .../event/FirstLegLoadingPlanCompletedV2.java | 12 +- .../openapi/cross-region-events-v1.yaml | 20 +- .../FirstLegLoadingPlanCompletedV2Test.java | 18 +- deploy/sql/海外区域统一增量-MySQL8.sql | 212 ++++++++++++++++++ .../LoadingCompletedOutboxService.java | 4 +- .../LoadingCompletedOutboxServiceTest.java | 38 ++++ .../service/LoadingCompletedConsumer.java | 68 +++++- ...ReceivingContainerServiceBehaviorTest.java | 19 ++ .../service/LoadingCompletedConsumerTest.java | 71 ++++++ scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 2 + ...und-measurement-box-code-contract.test.mjs | 3 +- .../inbound-measurement.mjs | 13 +- .../phase3-loading-finalize.mjs | 2 + .../admin-test-env-modules/phase3-loading.mjs | 10 + ...-phase4-producer-fixture-contract.test.mjs | 4 +- .../phase4-overseas-pda-contract.test.mjs | 65 +++++- .../phase4-overseas-pda.mjs | 25 ++- ...verseas-acceptance-entry-contract.test.mjs | 2 +- 19 files changed, 558 insertions(+), 33 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 2c28423eb..905c6050d 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 @@ -144,8 +144,14 @@ public record FirstLegLoadingPlanCompletedV2( /** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */ public record PalletSnapshot(String palletNo, List skuPackages) {} - /** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ - public record SkuPackageSnapshot(String skuCode, String productName, + /** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */ + public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName, int packageCount, int productQuantity, - List cartonMarks) {} + List cartonMarks) { + public SkuPackageSnapshot { + if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) { + throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot"); + } + } + } } 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 ba458d494..ce70219d5 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 @@ -38,7 +38,7 @@ components: volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable} items: {type: array, items: {type: object}} orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} - pallets: {type: array, items: {type: object}} + pallets: {type: array, items: {$ref: '#/components/schemas/FirstLegPalletSnapshotV2'}} destinationType: {type: string, enum: [FBA, OVERSEAS]} FirstLegContainerShippingNodeRecordedV1: type: object @@ -57,6 +57,24 @@ components: providerEventId: {type: string} providerNodeCode: {type: string} rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} + FirstLegPalletSnapshotV2: + type: object + required: [palletNo, skuPackages] + properties: + palletNo: {type: string, minLength: 1} + skuPackages: + type: array + items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'} + FirstLegSkuPackageSnapshotV2: + type: object + required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks] + 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} + productName: {type: [string, 'null']} + packageCount: {type: integer, minimum: 1} + productQuantity: {type: integer, minimum: 1} + cartonMarks: {type: array, items: {type: string}, minItems: 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 7b6f937f5..54c4d1b57 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,6 +11,21 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class FirstLegLoadingPlanCompletedV2Test { + @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")); + 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"))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId"); + } + assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}", + FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class)).hasMessageContaining("sourceInboundDetailId"); + } + @Test void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() { var order = new FirstLegLoadingPlanCompletedV2.Order( @@ -66,7 +81,7 @@ class FirstLegLoadingPlanCompletedV2Test { @Test void carriesDropshipPalletAndCartonSnapshots() throws Exception { var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot( - "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002")); + "101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002")); var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku)); var order = new FirstLegLoadingPlanCompletedV2.Order( "2", "2", "3", "ORD", "3", "RK", null, null, null, @@ -80,6 +95,7 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲"); assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(json.at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()).isEqualTo("101"); } @Test diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8d578d88e..85e86aa10 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1993,6 +1993,71 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- 换标总资料、派送资料与退货当前结构;仅结构定义,不回填业务记录。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_return_order ( + + id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要换标', + relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID', + relabel_no VARCHAR(64) NULL COMMENT '绑定换标单号', + relabel_status VARCHAR(32) NULL COMMENT '换标状态', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) +) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总'; +CREATE TABLE IF NOT EXISTS ll_return_order_item ( + id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) +) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细'; +CREATE TABLE IF NOT EXISTS ll_return_receipt ( + id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + carton_mark VARCHAR(128) NULL COMMENT '签收生成或绑定的退货箱号', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU')) +) ENGINE=InnoDB COMMENT='PDA退货实收签收事实'; +CREATE TABLE IF NOT EXISTS ll_return_receipt_item ( + id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量', + PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0) +) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细'; +CREATE TABLE IF NOT EXISTS ll_return_receipt_batch ( + id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at) +) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( + id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', + source_return_no VARCHAR(2000) NULL COMMENT '来源退货单号', + fba_carton_mark VARCHAR(255) NULL COMMENT 'FBA箱唛', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID', + relabel_no VARCHAR(64) NULL COMMENT '绑定换标单号', + relabel_status VARCHAR(32) NULL COMMENT '换标状态', + relabel_completed_at DATETIME(3) NULL COMMENT '换标完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED')) +) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( + id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_balance ( + id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0) +) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_operation ( + id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id) +) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND column_type='varchar(128)' AND is_nullable='NO'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''海外仓资源服务返回的真实托盘码''')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready; DELIMITER $$ CREATE PROCEDURE assert_tail_order_commands_ready() @@ -2017,6 +2082,153 @@ BEGIN AND table_name='ll_order_manual_node' AND index_name='uk_ll_order_manual_node' AND non_unique=0) <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令幂等或人工节点唯一约束未完整部署'; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_document_revision') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_delivery_document_revision'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_order'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_order_item'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_receipt'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_receipt_item'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_batch') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_receipt_batch'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_inventory_carton'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_inventory_carton_item'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_balance') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_inventory_balance'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_operation') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少表 ll_return_inventory_operation'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_order' AND column_name='bol_no' AND column_type='varchar(128)') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_delivery_order.bol_no'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_order' AND column_name='documents_json' AND column_type='json') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_delivery_order.documents_json'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='total_file_id' AND column_type='varchar(128)') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_relabel_application.total_file_id'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='total_file_name' AND column_type='varchar(255)') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_relabel_application.total_file_name'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='total_file_url' AND column_type='varchar(1000)') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_relabel_application.total_file_url'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND column_type='varchar(128)' AND is_nullable='NO') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入仓计划托盘码长度未就绪'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_document_revision' AND index_name='uk_delivery_document_command') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_delivery_document_command'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_document_revision' AND index_name='uk_delivery_document_version') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_delivery_document_version'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND index_name='uk_ll_return_order_customer_no') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_order_customer_no'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND index_name='uk_ll_return_order_customer_key') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_order_customer_key'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND index_name='idx_ll_return_order_warehouse_status') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_order_warehouse_status'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_order' AND constraint_name='chk_ll_return_order_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_order_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_item_order_sku'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_item_sku'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_order_item' AND constraint_name='chk_ll_return_item_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_item_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt' AND index_name='uk_ll_return_receipt_no') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_receipt_no'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt' AND index_name='uk_ll_return_receipt_batch') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_receipt_batch'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt' AND index_name='uk_ll_return_receipt_operator_key') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_receipt_operator_key'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt' AND index_name='idx_ll_return_receipt_order') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_receipt_order'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_receipt_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_receipt_bound'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_item' AND index_name='idx_ll_return_receipt_item_receipt') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_receipt_item_receipt'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_item' AND index_name='idx_ll_return_receipt_item_order_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_receipt_item_order_item'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_receipt_item' AND constraint_name='chk_ll_return_receipt_item_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_receipt_item_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_batch' AND index_name='uk_ll_return_batch_no') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_batch_no'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_batch' AND index_name='uk_ll_return_batch_receipt') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_batch_receipt'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_receipt_batch' AND index_name='idx_ll_return_batch_billing') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_batch_billing'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_mark') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_inventory_carton_mark'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_inventory_carton_receipt'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_inventory_carton_customer') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_inventory_carton_customer'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_inventory_carton_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_inventory_carton_status'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton_item' AND index_name='uk_ll_return_inventory_carton_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_inventory_carton_item'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton_item' AND index_name='idx_ll_return_inventory_carton_item_customer') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_inventory_carton_item_customer'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton_item' AND constraint_name='chk_ll_return_inventory_carton_item_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_inventory_carton_item_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_balance' AND index_name='uk_ll_return_inventory_balance') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_inventory_balance'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_balance' AND constraint_name='chk_ll_return_inventory_balance_quantity' AND constraint_type='CHECK') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少检查约束 chk_ll_return_inventory_balance_quantity'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_operation' AND index_name='uk_ll_return_inventory_operation_operator_key') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_ll_return_inventory_operation_operator_key'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_inventory_operation' AND index_name='idx_ll_return_inventory_operation_result') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 idx_ll_return_inventory_operation_result'; + END IF; END$$ DELIMITER ; CALL assert_tail_order_commands_ready(); 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 78d3eb0f2..b787c70c8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -285,7 +285,7 @@ public class LoadingCompletedOutboxService { 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.skuCode(), + 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); @@ -325,7 +325,7 @@ public class LoadingCompletedOutboxService { int quantity = detail.getProductQuantity() == null ? marks.size() : (int) Math.round((double) detail.getProductQuantity() * marks.size() / Math.max(1, detail.getPackageCount())); - skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSkuCode(), + skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(), detail.getProductNameZh(), marks.size(), quantity, marks)); actualPackages += marks.size(); } 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 dd2013b77..4edbe21e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -273,6 +273,44 @@ class LoadingCompletedOutboxServiceTest { .doesNotContain("pallet.pallet_code"); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void freezesRepeatedSkuCartonsBySourceDetailForBothPalletSources(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"); + second.setSkuCode("SAME-SKU"); second.setProductQuantity(20); + when(snapshots.rows(90L)).thenReturn(List.of(second, first)); + if (actualBinding) { + when(snapshots.workPalletCount(90L)).thenReturn(1); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102"), + actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101"))); + } else { + 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"))); + } + service.publish(90L); + service.publish(90L); + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getAllValues().get(0).getPayloadJson(), EventEnvelope.class); + var frozen = event.payload().at("/orders/0/pallets/0/skuPackages"); + assertThat(frozen).hasSize(2); + for (var sku : frozen) { + String detail = sku.path("sourceInboundDetailId").asText(); + assertThat(detail).isIn("101", "102"); + assertThat(sku.path("skuCode").asText()).isEqualTo("SAME-SKU"); + 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(frozen.get(0).path("sourceInboundDetailId")) + .isNotEqualTo(frozen.get(1).path("sourceInboundDetailId")); + assertThat(captor.getAllValues().get(0).getPayloadHash()).isEqualTo(captor.getAllValues().get(1).getPayloadHash()); + } + @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); 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 47aadc593..7bb8815c3 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 @@ -20,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Isolation; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import java.math.BigDecimal; +import java.util.Map; import java.time.LocalDateTime; import java.util.Objects; import java.util.ArrayList; @@ -62,7 +63,7 @@ public class LoadingCompletedConsumer { String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId()); - if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); + if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload, envelope.payload()); freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { @@ -102,8 +103,8 @@ public class LoadingCompletedConsumer { try { ObjectMapper compatibleMapper = objectMapper.copy() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - // V2 only adds immutable item remarks; the current tail-leg snapshot model - // intentionally ignores those fields until its schema exposes them. + // The internal order model keeps the shared fields. V2 pallet source-detail + // identity is validated separately from the original immutable payload. return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); } catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } @@ -168,8 +169,9 @@ public class LoadingCompletedConsumer { return normalized; } - private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) { - for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { + private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload, JsonNode rawPayload) { + for (int index = 0; index < payload.orders().size(); index++) { + FirstLegLoadingPlanCompletedV1.Order order = payload.orders().get(index); Set orderCartonMarks = new HashSet<>(); if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) { for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) { @@ -177,6 +179,7 @@ public class LoadingCompletedConsumer { requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks); } } + dropshipReceivingLabels(order, rawPayload.path("orders").get(index)); continue; } String destinationType = destinationType(payload, order); @@ -273,6 +276,8 @@ public class LoadingCompletedConsumer { } createInboundOrderSnapshot(event, payload, source, order, orderHash); + Map> dropshipLabels = "ONE_CLICK_DROPSHIP".equals(orderType) + && "OVERSEAS".equals(destinationType) ? dropshipReceivingLabels(source, rawSource) : Map.of(); for(var item:source.items()) { LastlegOrderDetail detail=new LastlegOrderDetail(); detail.setCustomerId(customerId); @@ -283,7 +288,9 @@ public class LoadingCompletedConsumer { detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); - createCartonLabels(customerId, order.getId(), destinationType, item); + createCartonLabels(customerId, order.getId(), destinationType, item, + "ONE_CLICK_DROPSHIP".equals(orderType) ? dropshipLabels.getOrDefault(item.sourceInboundDetailId(), List.of()) + : item.cartonMarks()); } LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); @@ -353,10 +360,38 @@ public class LoadingCompletedConsumer { } } + private Map> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { + Map itemsByDetail = new java.util.LinkedHashMap<>(); + for (var item : source.items()) { + if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank() + || itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) { + throw new IllegalArgumentException("一件代发来源明细标识缺失或重复"); + } + } + Map> marksByDetail = new java.util.LinkedHashMap<>(); + for (JsonNode pallet : rawSource.path("pallets")) { + for (JsonNode sku : pallet.path("skuPackages")) { + String detailId = sku.path("sourceInboundDetailId").asText(null); + var item = itemsByDetail.get(detailId); + if (item == null || !Objects.equals(item.skuCode(), sku.path("skuCode").asText(null))) { + throw new IllegalArgumentException("一件代发托盘来源明细缺失或与 SKU 不一致"); + } + List marks = marksByDetail.computeIfAbsent(detailId, ignored -> new java.util.ArrayList<>()); + sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); + } + } + for (var item : source.items()) { + if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) { + throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致"); + } + } + return marksByDetail; + } + private void createCartonLabels(String customerId, Long orderId, String destinationType, - FirstLegLoadingPlanCompletedV1.Item item) { + FirstLegLoadingPlanCompletedV1.Item item, List sourceMarks) { if (!"OVERSEAS".equals(destinationType)) return; - List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); + List marks = sourceMarks == null ? List.of() : sourceMarks; if (marks.isEmpty()) return; if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); @@ -409,10 +444,19 @@ public class LoadingCompletedConsumer { private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { if (source.pallets() != null && !source.pallets().isEmpty()) { - return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), - pallet.skuPackages().stream().map(sku -> new DropshipRequests.SkuPackages( - sku.skuCode(), sku.productName(), sku.packageCount(), sku.productQuantity(), - sku.cartonMarks())).toList())).toList(); + return source.pallets().stream().map(pallet -> { + 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)); + }); + } + return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values())); + }).toList(); } return legacyPallets(rawSource); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 197463131..eec9810df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -724,6 +724,25 @@ class ReceivingContainerServiceBehaviorTest { .containsExactly("SKU-A", "SKU-B", "SKU-C"); } + @Test + void overseasDropshipResolvesOnlyFrozenCartonsAndNeverCreatesLabelsWhileResolving() { + stubCurrent("OVERSEAS", "CONT-A"); + LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectById(1L)).thenReturn(dropship); + InboundPlanCarton frozen = carton(1L, "IN-1#2-1"); + frozen.setCustomerId("100"); frozen.setSourceInboundDetailId("d-1"); frozen.setSkuCode("SKU-1"); + when(cartons.selectOne(any())).thenReturn(frozen); + var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1")); + assertThat(resolved.inboundCode()).isEqualTo("IN-1"); + assertThat(resolved.destinationType()).isEqualTo("OVERSEAS"); + verify(cartons, never()).insert(any(InboundPlanCarton.class)); + verifyNoInteractions(warehouse); + when(cartons.selectOne(any())).thenReturn(null); + assertThatThrownBy(() -> service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-9"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("404"); + verify(cartons, never()).insert(any(InboundPlanCarton.class)); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); 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 54f964f57..500d07774 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 @@ -128,6 +128,77 @@ class LoadingCompletedConsumerTest { ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); + } + @Test void projectsOverseasDropshipFrozenCartonsIntoReceivingLabelPool() { + service.consume(overseasDropshipEvent(false)); + var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class); + verify(cartons,times(5)).insert(captured.capture()); + assertEquals(List.of("RK1#5-1","RK1#5-2","RK1#5-3","RK1#5-4","RK1#5-5"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton::getCartonMark).toList()); + for(var carton:captured.getAllValues()) { + assertEquals("100",carton.getCustomerId()); assertEquals("d-1",carton.getSourceInboundDetailId()); + assertEquals("SKU-1",carton.getSkuCode()); assertEquals("UNASSIGNED",carton.getStatus()); + assertNull(carton.getPlanId()); assertNull(carton.getLineId()); + } + verify(dropshipOrders).receive(any()); + } + @Test void rejectsAmbiguousDropshipSkuSourceBeforeWritingReceivingLabels() { + var failure=assertThrows(IllegalArgumentException.class,()->service.consume(overseasDropshipEvent(true))); + assertTrue(failure.getMessage().contains("来源明细")); + verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); + verify(orders,never()).insert(any(LastlegOrder.class)); + } + @Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() { + EventEnvelope original=overseasDropshipEvent(false); + var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy(); + var order=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0); + var second=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("items").get(0).deepCopy(); + second.put("sourceInboundDetailId","d-2"); + ((com.fasterxml.jackson.databind.node.ArrayNode)order.path("items")).add(second); + order.put("packageCount",10); + var skus=(com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets").get(0).path("skuPackages"); + var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)skus.get(0).deepCopy(); + secondSku.put("sourceInboundDetailId","d-2"); + var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n); + skus.add(secondSku); + service.consume(rehash(original,node)); + var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class); + verify(cartons,times(10)).insert(captured.capture()); + assertEquals(5,captured.getAllValues().stream().filter(c->"d-1".equals(c.getSourceInboundDetailId())).count()); + assertEquals(5,captured.getAllValues().stream().filter(c->"d-2".equals(c.getSourceInboundDetailId())).count()); + assertTrue(captured.getAllValues().stream().filter(c->c.getCartonMark().contains("#10-")).allMatch(c->"d-2".equals(c.getSourceInboundDetailId()))); + var received=ArgumentCaptor.forClass(DropshipRequests.Receive.class); + verify(dropshipOrders).receive(received.capture()); + var inventoryRows=received.getValue().pallets().get(0).skuPackages(); + assertEquals(1,inventoryRows.size()); assertEquals(10,inventoryRows.get(0).packageCount()); + assertEquals(100,inventoryRows.get(0).productQuantity()); assertEquals(10,inventoryRows.get(0).cartonMarks().size()); + } + @Test void rejectsMissingOrForeignPalletSourceBeforeAnyOrderWrite() { + for(String detailId:List.of("", "foreign-detail")) { + EventEnvelope original=overseasDropshipEvent(false); + var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("pallets").get(0).path("skuPackages").get(0)).put("sourceInboundDetailId",detailId); + assertThrows(IllegalArgumentException.class,()->service.consume(rehash(original,node))); + } + verify(orders,never()).insert(any(LastlegOrder.class)); + verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); + } + private EventEnvelope rehash(EventEnvelope original,JsonNode node) { + return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + } + private EventEnvelope overseasDropshipEvent(boolean duplicateSku) { + EventEnvelope original=event(); + var node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(original); + node.put("warehouseId","92"); + var sourceOrder=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0); + sourceOrder.put("orderType","ONE_CLICK_DROPSHIP").put("destinationType","OVERSEAS"); + var sku=json.createObjectNode().put("sourceInboundDetailId","d-1").put("skuCode","SKU-1").put("productName","产品").put("packageCount",5).put("productQuantity",50); + var marks=sku.putArray("cartonMarks"); for(int n=1;n<=5;n++)marks.add("RK1#5-"+n); + var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku); + sourceOrder.putArray("pallets").add(pallet); + if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy()); + node.putArray("orders").add(sourceOrder); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash); } 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; } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 59132f1e7..f66b5682a 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1380,6 +1380,7 @@ export class AcceptanceRun { orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, boxCode: fixture.boxCode, + sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', skuCodes: Array.isArray(fixtureOrder.skuCodes) ? fixtureOrder.skuCodes : [], skuLines: Array.isArray(fixtureOrder.skuLines) ? fixtureOrder.skuLines : [], @@ -1573,7 +1574,7 @@ export class AcceptanceRun { await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false) } if (Number(readiness().overseasContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true) + await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true) } if (Number(readiness().distinctContainerCount || 0) < 4) { await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 796372af5..11d777acc 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -41,6 +41,8 @@ export async function run(context) { 一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。 +该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。 + - `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。 - `requestBinary(method, path)`:文件下载,不把二进制写入报告。 - `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 465745c45..40c1a906d 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -175,7 +175,8 @@ async function exerciseSystemPalletScenario(fault = '') { throw new Error(`Unexpected scenario request: ${method} ${path}`) }, } - await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } }) + const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } }) + assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2']) return checks } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index cba78235a..5862bf9c2 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -140,6 +140,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) } let palletNo = '' let countedCartons = 0 + const sealedCartonMarks = [] let pallet = null const palletSnapshot = (value) => JSON.stringify({ @@ -190,7 +191,10 @@ export async function completeDropshipPalletization(context, { inboundId, inboun && Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity) && counted?.carton?.discrepancyStatus === 'MATCH', `cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS) - if (counted?.carton?.boxCode === resolved?.boxCode) countedCartons += 1 + if (counted?.carton?.boxCode === resolved?.boxCode) { + countedCartons += 1 + sealedCartonMarks.push(resolved.boxCode) + } if (index === 0) { context.assert(MODULE, '先扫箱模式将已清点箱暂存为待组托', countResponse, @@ -253,7 +257,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse, sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed), `taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS) - return sealed + return { ...sealed, sealedCartonMarks } } async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) { @@ -1062,10 +1066,12 @@ export async function run(context) { && closeTo(finalMeasurement?.volumeWeightKg, volumeWeight), `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) + let sealedCartonMarks = [] if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { - await completeDropshipPalletization(context, { + const sealed = await completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement, }) + sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) finalResponse = completed.response finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, @@ -1087,6 +1093,7 @@ export async function run(context) { OPTIONS) context.createdResources.inboundMeasurement = { + sealedCartonMarks, marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 8d705c2d0..ff95e185d 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -504,6 +504,8 @@ export async function run(context) { sourceOrderId: orderId, sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, + inboundCode: fixture.inboundCode || null, + sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [], skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [], diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 6288d55f0..f15c690e0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1605,6 +1605,8 @@ export async function run(context) { `inboundStatus=${stagedDetail?.summary?.processStatus || 'missing'}, taskStatus=${stagedTask?.taskStatus || 'missing'}, workflow=${stagedTask?.workflowStatus || 'missing'}, planStatus=${stagedPlan?.status || 'missing'}`, OPTIONS) context.createdResources.phase3Loading = { + inboundCode: initial?.summary?.inboundCode || source?.inboundCode, + sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [], tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, cartonMark: source?.boxCode || source?.cartonMark || null, cartonMarks: stableLoadedCartonMarks(stagedTask?.items), @@ -1633,5 +1635,13 @@ export async function run(context) { pdaTaskVersion: Number(stagedTask?.version), marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW', } + if (dropshipWholePallet) { + const marks = context.createdResources.phase3Loading.sealedCartonMarks + const loadedCount = (stagedTask?.items || []).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0) + context.assert(MODULE, '封托清点原始箱码覆盖一件代发全部实装箱数', stagedTaskResponse, + marks.length > 0 && new Set(marks).size === marks.length && marks.length === loadedCount, + `sealed=${marks.length}, loaded=${loadedCount}`, OPTIONS) + context.createdResources.phase3Loading.cartonMarks = marks + } } import { iso6346ContainerNumber } from './iso6346-container-number.mjs' diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 04309fb17..faed3eb0e 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -110,13 +110,13 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(loading, /一件代发整托生产者不执行可拆分动态增量场景/) assert.match(loading, /!dropshipWholePallet && Array\.isArray\(incrementalFixture\?\.skuCodes\)/) assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) - assert.match(pda, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks\)/) + assert.match(pda, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks, preferredProducer\)/) assert.match(pda, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/) }) test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { const entry = read('../admin-test-env-acceptance.mjs') - assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) const start = entry.indexOf('if (pickupRequired && producerKey)') diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 814394d36..a8051009c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -4,11 +4,74 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { expandOverseasCartonCandidates, run } from './phase4-overseas-pda.mjs' +import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') +test('dropship original counted cartons must resolve exactly before any receiving or inventory checks', async () => { + const inbounds = [{ inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS', totalPackages: 2 }] + const producer = { fulfillmentType: 'ONE_CLICK_DROPSHIP', inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS', + sealedCartonMarks: ['REAL-INBOUND#2-2', 'REAL-INBOUND#2-1'] } + for (const fault of [null, 'missing-carton', 'wrong-carton', 'wrong-inbound', 'wrong-destination']) { + const calls = []; const checks = [] + const context = { + async requestOverseas(method, requestPath, body) { + calls.push({ method, requestPath, body }) + const data = { cartonMark: body.cartonMark, inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS' } + if (fault === 'wrong-carton') data.cartonMark = 'ANOTHER-CARTON' + if (fault === 'wrong-inbound') data.inboundCode = 'ANOTHER-INBOUND' + if (fault === 'wrong-destination') data.destinationType = 'FBA' + return { http: 200, body: { code: fault === 'missing-carton' ? 'A0404' : '200', + message: 'Carton mark was not found', data }, requestId: 'request', traceId: 'trace' } + }, + assert(module, name, response, passed) { checks.push({ name, passed }) }, + addStep(module, name, response, passed) { checks.push({ name, passed }) }, + addCoverageGap() {}, + } + const result = await resolveCartonFixtures(context, 'token', inbounds, ['OLD-CANDIDATE#20-1'], producer) + assert.ok(calls.every(call => call.method === 'POST' && call.requestPath.endsWith('/current/cartons/resolve'))) + assert.ok(calls.every(call => producer.sealedCartonMarks.includes(call.body.cartonMark))) + if (fault) { + assert.equal(result, null, fault) + assert.ok(checks.some(check => !check.passed), fault) + assert.ok(!checks.some(check => check.name.includes('排除未实装箱码候选')), fault) + } else { + assert.deepEqual(result.map(item => item.cartonMark), producer.sealedCartonMarks) + assert.equal(calls.length, 2) + assert.ok(checks.every(check => check.passed)) + } + } + for (const sealedCartonMarks of [undefined, [], ['REAL-INBOUND#2-1'], ['REAL-INBOUND#2-1', 'REAL-INBOUND#2-1']]) { + const checks = [] + const result = await resolveCartonFixtures({ assert(m,n,r,passed) { checks.push(passed) }, + async requestOverseas() { assert.fail('incomplete physical fixture must not issue HTTP') } }, + 'token', inbounds, producer.sealedCartonMarks, { ...producer, sealedCartonMarks }) + assert.equal(result, null) + assert.deepEqual(checks, [false]) + } +}) + +test('current loading event binds each pallet carton group to an explicit source detail', () => { + const model = fs.readFileSync(path.join(here, '../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java'), 'utf8') + const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8') + assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/) + assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/) + assert.match(producer, /SkuPackageSnapshot\(detail\.detailId\(\)\.toString\(\), detail\.skuCode\(\)/) + assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/) + const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8') + const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8') + const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8') + assert.match(measurement, /sealedCartonMarks\.push\(resolved\.boxCode\)/) + assert.match(loading, /phase3Loading\.cartonMarks = marks/) + assert.match(finalize, /sealedCartonMarks: Array\.isArray\(fixture\.sealedCartonMarks\)/) + const entry = fs.readFileSync(path.join(here, '../admin-test-env-acceptance.mjs'), 'utf8') + assert.match(entry, /sealedCartonMarks: Array\.isArray\(fixture\.sealedCartonMarks\)/) + assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(source, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks, preferredProducer\)/) + assert.ok(source.indexOf('const cartonFixtures = await resolveCartonFixtures') < source.indexOf('const completed = await pda')) +}) + test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => { assert.match(source, /export async function run\(context\)/) assert.match(source, /requestOverseas\(/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b2036d8d0..40cd6f37d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -412,12 +412,25 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer return ready } -async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) { +export async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = [], dropshipProducer = null) { + const strictDropship = dropshipProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP' + if (strictDropship) { + const sealed = dropshipProducer.sealedCartonMarks + const inbound = inbounds.find((item) => item.inboundCode === dropshipProducer.inboundCode) + const valid = Array.isArray(sealed) && sealed.length > 0 && sealed.every(nonBlank) + && new Set(sealed).size === sealed.length && ['FBA', 'OVERSEAS'].includes(inbound?.destinationType) + && inbound.destinationType === dropshipProducer.destinationType + && sealed.length === Number(inbound.totalPackages) + context.assert(MODULE, '国内清点封托原箱码完整传递到当前海外收货柜', null, valid, + `sealed=${sealed?.length ?? 'missing'}, expected=${inbound?.totalPackages ?? 'missing'}`, OPTIONS) + if (!valid) return null + producerCartonMarks = sealed + } const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) .map(id).filter(nonBlank) - const fbaCartons = inbounds.filter((item) => item.destinationType === 'FBA') + const fbaCartons = (strictDropship ? [] : inbounds.filter((item) => item.destinationType === 'FBA')) .map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`) - const overseasCartons = expandOverseasCartonCandidates(inbounds, manifestCartons) + const overseasCartons = strictDropship ? manifestCartons : expandOverseasCartonCandidates(inbounds, manifestCartons) const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])] if (!cartonCandidates.length) { blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码') @@ -427,7 +440,7 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar for (const [index, cartonMark] of cartonCandidates.entries()) { const response = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) - if (overseasCartons.includes(cartonMark) + if (!strictDropship && overseasCartons.includes(cartonMark) && a0404Message(response, /^Carton mark was not found$/)) { context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true, `cartonMark=${cartonMark}; ${trace(response)}`, OPTIONS) @@ -440,6 +453,8 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar && matches.length === 1 && destinations.size === 1 && ['FBA', 'OVERSEAS'].includes(resolved?.destinationType) && destinations.has(resolved?.destinationType) + && (!strictDropship || (resolved?.cartonMark === cartonMark + && resolved?.inboundCode === dropshipProducer.inboundCode && resolved?.destinationType === dropshipProducer.destinationType)) context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response, unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS) if (!unique) { @@ -626,7 +641,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe await probeCartonResolveNegatives(context, token, current.data) const producerCartonMarks = preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] - const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks) + const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer) if (!cartonFixtures) return const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入', '/api/pda/v1/last-leg/receiving-containers/current') diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 2177ff3bc..a91807ed5 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -158,7 +158,7 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV } assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) - assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) assert.match(source, /runUntilPickupReady\(this\)/)