fix(dropship): project sealed carton identities into overseas receiving

This commit is contained in:
2026-09-09 13:20:11 +08:00
parent 90973532f0
commit 748855793d
19 changed files with 558 additions and 33 deletions
@@ -144,8 +144,14 @@ public record FirstLegLoadingPlanCompletedV2(
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> 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<String> cartonMarks) {}
List<String> cartonMarks) {
public SkuPackageSnapshot {
if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) {
throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot");
}
}
}
}
@@ -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]
@@ -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
@@ -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();
@@ -285,7 +285,7 @@ public class LoadingCompletedOutboxService {
int markEnd = Math.min(detail.marks().size(), markStart + count);
List<String> 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();
}
@@ -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<InboundOutboxEntity> 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");
@@ -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<String> 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<String, List<String>> 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<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
Map<String, FirstLegLoadingPlanCompletedV1.Item> 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<String, List<String>> 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<String> 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<String> sourceMarks) {
if (!"OVERSEAS".equals(destinationType)) return;
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
List<String> 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<DropshipRequests.Pallet> 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<String, DropshipRequests.SkuPackages> 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<String> 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);
}
@@ -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);
@@ -128,6 +128,77 @@ class LoadingCompletedConsumerTest {
ArgumentCaptor<LastlegOrder> 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; }
+2 -1
View File
@@ -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)
+2
View File
@@ -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。
@@ -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
}
@@ -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,
@@ -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 : [],
@@ -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'
@@ -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)')
@@ -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\(/)
@@ -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')
@@ -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\)/)