fix(dropship): project sealed carton identities into overseas receiving
This commit is contained in:
+9
-3
@@ -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]
|
||||
|
||||
+17
-1
@@ -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();
|
||||
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
|
||||
+38
@@ -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");
|
||||
|
||||
+56
-12
@@ -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);
|
||||
}
|
||||
|
||||
+19
@@ -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);
|
||||
|
||||
+71
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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\)/)
|
||||
|
||||
Reference in New Issue
Block a user