feat: add dropship fulfillment producer chain

This commit is contained in:
2026-08-25 08:40:27 +08:00
parent c218bfad6b
commit 4839243f21
35 changed files with 389 additions and 53 deletions
@@ -583,6 +583,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱',
customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码',
shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID',
@@ -604,6 +605,26 @@ CREATE TABLE IF NOT EXISTS ord_order (
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
SET @order_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type'
);
PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql;
EXECUTE order_fulfillment_type_stmt;
DEALLOCATE PREPARE order_fulfillment_type_stmt;
UPDATE baiyue_order.ord_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE baiyue_order.ord_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
-- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。
SET @order_inbound_code_nullable_sql = IF(
EXISTS(
@@ -2531,6 +2552,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
@@ -2581,6 +2603,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
SET @inbound_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type'
);
PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql;
EXECUTE inbound_fulfillment_type_stmt;
DEALLOCATE PREPARE inbound_fulfillment_type_stmt;
UPDATE fl_inbound_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE fl_inbound_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
SET @inbound_global_code_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
@@ -4323,8 +4365,10 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id');
CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name');
CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code');
CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias');
@@ -59,6 +59,9 @@ public class InternalInboundOrderController {
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.inboundCode().length() > 40
|| command.fulfillmentType() == null
|| (!"STANDARD".equals(command.fulfillmentType())
&& !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))
|| command.totalPackageCount() != null && command.totalPackageCount() < 1) {
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
}
@@ -142,7 +145,10 @@ public class InternalInboundOrderController {
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0
|| command.warehouseName() == null || command.warehouseName().isBlank()
|| command.businessType() == null || command.businessType().isBlank()) {
|| command.businessType() == null || command.businessType().isBlank()
|| command.fulfillmentType() == null
|| (!"STANDARD".equals(command.fulfillmentType())
&& !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) {
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
}
}
@@ -17,7 +17,7 @@ public record InboundOrderSummary(
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String businessType, String fulfillmentType, String channelCode, String channelName,
String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
@@ -56,6 +56,7 @@ public record InboundOrderSummary(
order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getFulfillmentType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
@@ -33,7 +33,8 @@ public record OrderApprovedInboundCommand(
String expressTrackingNo,
Integer totalPackageCount,
String pickupMethod,
LocalDateTime requestedPickupAt
LocalDateTime requestedPickupAt,
String fulfillmentType
) {
public OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
@@ -48,7 +49,7 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, totalPackageCount, null, null);
expressTrackingNo, totalPackageCount, null, null, "STANDARD");
}
/** 兼容尚未携带订单总箱数的历史事件。 */
public OrderApprovedInboundCommand(
@@ -63,7 +64,7 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, null, null, null);
expressTrackingNo, null, null, null, "STANDARD");
}
/** 兼容未携带报价包税维度的历史调用方。 */
@@ -79,6 +80,6 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, null, null, null);
expressTrackingNo, null, null, null, "STANDARD");
}
}
@@ -38,6 +38,7 @@ public class InboundOrderEntity extends BaseEntity {
private Long deliveryWarehouseId;
private String deliveryWarehouseName;
private String businessType;
private String fulfillmentType;
private String channelCode;
private String channelName;
private Long carrierId;
@@ -38,7 +38,7 @@ public interface InboundSplitSettlementMapper {
INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status,
order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status,
expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name,
delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name,
delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,channel_code,channel_name,carrier_id,carrier_name,
tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id,
destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count,
actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg,
@@ -48,7 +48,7 @@ public interface InboundSplitSettlementMapper {
SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode},
CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot,
'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,
channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection,
destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason,
#{packageCount},#{packageCount},
@@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper {
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
o.order_id AS source_order_id, o.order_no AS source_order_no,
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
o.inbound_code, o.business_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
o.inbound_code, o.business_type, o.fulfillment_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
o.destination_name AS destination_warehouse_name,
i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
@@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper {
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
private String businessType; private String destinationType;
private String businessType; private String fulfillmentType; private String destinationType;
private String customerRemark; private String internalRemark; private String warehouseRemark;
}
@@ -68,6 +68,7 @@ public class InboundOrderCreationService {
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
inbound.setBusinessType(command.businessType());
inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType()));
inbound.setChannelCode(command.channelCode());
inbound.setChannelName(command.channelName());
inbound.setCarrierId(command.carrierId());
@@ -160,6 +161,7 @@ public class InboundOrderCreationService {
.set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName())
.set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt())
.set(InboundOrderEntity::getBusinessType, command.businessType())
.set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType()))
.set(InboundOrderEntity::getChannelCode, command.channelCode())
.set(InboundOrderEntity::getChannelName, command.channelName())
.set(InboundOrderEntity::getDestinationType, command.destinationType())
@@ -181,6 +183,7 @@ public class InboundOrderCreationService {
.set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE")
.set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED")
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
.set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType()))
.set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1");
if (orders.update(null, update) != 1) {
@@ -217,6 +220,7 @@ public class InboundOrderCreationService {
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
inbound.setBusinessType(command.businessType());
inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType()));
inbound.setChannelCode(command.channelCode());
inbound.setChannelName(command.channelName());
inbound.setCarrierId(command.carrierId());
@@ -241,6 +245,12 @@ public class InboundOrderCreationService {
return inbound;
}
private static String fulfillmentType(String value) {
if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value;
throw new BizException(ResultCode.BAD_REQUEST,
"履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP");
}
private void addHistory(long inboundId, long tenantId, String from, String to,
String action, LocalDateTime now) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
@@ -91,8 +91,9 @@ public class LoadingCompletedOutboxService {
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null,
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
.toList();
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(first.getBusinessType())
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
@@ -110,7 +111,7 @@ public class LoadingCompletedOutboxService {
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
Map<LabelPoolKey, List<Row>> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType()))
Map<LabelPoolKey, List<Row>> byInbound = rows.stream().filter(row -> !dropship(row.getFulfillmentType()))
.filter(row -> "OVERSEAS".equals(destinationType(List.of(row))))
.collect(java.util.stream.Collectors.groupingBy(
row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()),
@@ -168,6 +169,18 @@ public class LoadingCompletedOutboxService {
return values.iterator().next();
}
private String fulfillmentType(List<Row> rows) {
Set<String> values = rows.stream().map(Row::getFulfillmentType)
.map(value -> value == null || value.isBlank() ? "STANDARD" : value)
.collect(java.util.stream.Collectors.toSet());
if (values.size() != 1
|| (!values.contains("STANDARD") && !values.contains("ONE_CLICK_DROPSHIP"))) {
throw new BizException(ResultCode.CONFLICT,
"装柜入仓单履约类型必须唯一且为 STANDARD 或 ONE_CLICK_DROPSHIP");
}
return values.iterator().next();
}
private record LabelPoolKey(long accountId, long inboundId) { }
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
@@ -223,8 +236,7 @@ public class LoadingCompletedOutboxService {
}
private static boolean dropship(String value) {
return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value)
|| value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP"));
return "ONE_CLICK_DROPSHIP".equals(value);
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
@@ -77,6 +78,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
SET @inbound_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type'
);
PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql;
EXECUTE inbound_fulfillment_type_stmt;
DEALLOCATE PREPARE inbound_fulfillment_type_stmt;
UPDATE fl_inbound_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE fl_inbound_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
-- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。
SET @inbound_global_code_index_sql = IF(
NOT EXISTS(
@@ -1122,6 +1122,7 @@ components:
deliveryWarehouseId: {type: integer, format: int64, nullable: true}
deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
taxMode: {type: string, nullable: true}
@@ -1466,7 +1467,7 @@ components:
adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true}
OrderApprovedInboundCommand:
type: object
required: [tenantId, orderId, inboundCode, customerName, warehouseId]
required: [tenantId, orderId, inboundCode, customerName, warehouseId, fulfillmentType]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -1480,6 +1481,7 @@ components:
deliveryWarehouseName: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], description: 独立履约类型;不得复用计费 businessType}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照}
@@ -1810,6 +1812,7 @@ components:
deliveryWarehouseId: {type: string, nullable: true}
deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
@@ -37,12 +37,17 @@ class UnifiedInboundOpenApiContractTest {
assertThat(stringMap(inbound.get("properties"))).containsKeys(
"id", "tenantId", "orderId", "inboundCode", "processStatus", "version",
"customerId", "warehouseId", "deliveryWarehouseId", "totalPackageCount",
"businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType",
"businessType", "fulfillmentType", "channelCode", "pricingTaxMode", "containerMode", "destinationType",
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
"cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt",
"actualPortArrivalAt", "createdByName", "createdAt", "updatedAt");
Map<String, Object> approval = stringMap(schemas.get("OrderApprovedInboundCommand"));
assertThat(stringList(approval.get("required"))).contains("fulfillmentType");
Map<String, Object> fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType"));
assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP");
}
@SuppressWarnings("unchecked")
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FulfillmentTypeSqlContractTest {
@Test
void baselinesAndUnifiedMigrationPersistIndependentFulfillmentType() throws Exception {
String order = Files.readString(resolve("order/src/main/resources/db/order.sql"), StandardCharsets.UTF_8);
String inbound = Files.readString(resolve("firstleg/src/main/resources/db/phase2-inbound-schema.sql"), StandardCharsets.UTF_8);
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
assertFulfillmentMigration(order, "baiyue_order.ord_order");
assertFulfillmentMigration(inbound, "fl_inbound_order");
assertFulfillmentMigration(unified, "baiyue_order.ord_order");
assertFulfillmentMigration(unified, "fl_inbound_order");
assertThat(unified)
.contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')")
.contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')");
}
private static void assertFulfillmentMigration(String sql, String table) {
assertThat(sql)
.contains("fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'")
.contains("ADD COLUMN fulfillment_type VARCHAR(32) NULL")
.contains("SET fulfillment_type = 'STANDARD'")
.contains("MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'")
.contains(table);
}
private static Path resolve(String path) {
Path direct = Path.of(path);
return Files.exists(direct) ? direct : Path.of("..").resolve(path).normalize();
}
}
@@ -100,6 +100,7 @@ class PdaSplitSettlementSqlContractTest {
assertThat(clone)
.contains("#{childOrderId},#{childOrderNo}")
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END")
.contains("business_type,fulfillment_type")
.contains("actual_gross_weight_kg * #{packageCount}")
.contains("actual_net_weight_kg * #{packageCount}");
@@ -72,11 +72,35 @@ class InboundOrderCreationServiceTest {
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(inboundCaptor.getValue().getFulfillmentType()).isEqualTo("STANDARD");
assertThat(inboundCaptor.getValue().getTotalPackageCount()).isEqualTo(4);
assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId());
assertThat(replayedId).isEqualTo(900L);
}
@Test
void persistsDropshipFulfillmentTypeWithoutChangingPricingBusinessType() {
OrderApprovedInboundCommand base = approvedCommand("PCHK2608140001");
OrderApprovedInboundCommand command = new OrderApprovedInboundCommand(
base.tenantId(), base.orderId(), base.orderNo(), base.inboundCode(), base.customerId(),
base.customerName(), base.warehouseId(), base.warehouseName(), base.deliveryWarehouseId(),
base.deliveryWarehouseName(), base.expectedArrivalAt(), base.businessType(), base.channelCode(),
base.channelName(), base.carrierId(), base.carrierName(), base.taxMode(), base.pricingTaxMode(),
base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(),
base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(),
base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(),
"ONE_CLICK_DROPSHIP");
when(orders.selectOne(any(Wrapper.class))).thenReturn(null);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
service.createFromApprovedOrder(command);
ArgumentCaptor<InboundOrderEntity> captor = ArgumentCaptor.forClass(InboundOrderEntity.class);
verify(orders).insert(captor.capture());
assertThat(captor.getValue().getBusinessType()).isEqualTo("FBA");
assertThat(captor.getValue().getFulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP");
}
@Test
void shouldRejectReplayWithDifferentInboundCode() {
InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null);
@@ -146,9 +146,9 @@ class LoadingCompletedOutboxServiceTest {
}
@Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception {
Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP");
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.palletRows(90L)).thenReturn(List.of(
@@ -168,6 +168,35 @@ class LoadingCompletedOutboxServiceTest {
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@Test
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
Row value = row(11, 100, 101);
value.setBusinessType("ONE_CLICK_DROPSHIP");
value.setFulfillmentType("STANDARD");
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("FIRST_LEG");
assertThat(event.payload().at("/orders/0/pallets")).isEmpty();
}
@Test
void rejectsMixedFulfillmentTypesWithinOneInbound() {
Row first = row(11, 100, 101);
Row second = row(11, 100, 102);
second.setFulfillmentType("ONE_CLICK_DROPSHIP");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("履约类型必须唯一");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
@@ -176,6 +205,7 @@ class LoadingCompletedOutboxServiceTest {
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
r.setDestinationType("OVERSEAS");
r.setFulfillmentType("STANDARD");
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
}
@@ -58,8 +58,8 @@ class TaillegRealProducerAcceptanceTest {
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "LOADED", "ONT8");
seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9");
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "FBA_SEA", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9");
seedItem(990022321L, FIRST_INBOUND, 990022421L, 990022221L, MARKER + "-FIRST-IN", "SKU-REAL-A", 3, "ONT8");
seedItem(990022322L, DROPSHIP_INBOUND, 990022422L, 990022222L, MARKER + "-DROP-IN", "SKU-REAL-D", 2, "LAX9");
for (int i = 1; i <= 3; i++) seedLabel(990022540L + i, FIRST_INBOUND, 990022221L, 990022421L,
@@ -86,13 +86,15 @@ class TaillegRealProducerAcceptanceTest {
id, 990022901L, planItemId, inboundId, detailId, packages, packages);
}
private void seedInbound(long id, long orderId, String code, String type, String status, String destination) {
private void seedInbound(long id, long orderId, String code, String businessType, String fulfillmentType,
String status, String destination) {
jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," +
"order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_type,destination_warehouse_id," +
"order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,fulfillment_type,destination_type,destination_warehouse_id," +
"destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,'FBA',9001,?,?,?,0.4000,1,1)",
id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type,
destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3);
"VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,?,'FBA',9001,?,?,?,0.4000,1,1)",
id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", businessType,
fulfillmentType, destination, "FBA " + destination,
fulfillmentType.equals("ONE_CLICK_DROPSHIP") ? 2 : 3);
}
private void seedItem(long id, long inboundId, long detailId, long orderId, String inboundCode,
@@ -210,9 +210,11 @@ public class LoadingCompletedConsumer {
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId);
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
if ("FIRST_LEG".equals(orderType)) {
if ("FIRST_LEG".equals(orderType) || "ONE_CLICK_DROPSHIP".equals(orderType)) {
InboundPlanResponse plan = inboundPlans.convert(customerId, order.getId());
registerInTransit(event, payload, source, order, plan.planNo());
if ("FIRST_LEG".equals(orderType)) {
registerInTransit(event, payload, source, order, plan.planNo());
}
}
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
long warehouseId;
@@ -14,7 +14,7 @@ class LoadingCompletedConsumerTest {
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inboundPlans).convert(eq("100"),anyLong()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
@@ -45,7 +45,7 @@ public interface FirstlegInboundClient {
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo, Integer totalPackageCount,
String pickupMethod, LocalDateTime requestedPickupAt
String pickupMethod, LocalDateTime requestedPickupAt, String fulfillmentType
) {
}
}
@@ -73,8 +73,28 @@ public final class OrderRequests {
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId,
@Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40)
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode,
@Schema(description = "Fulfillment type; omitted means STANDARD / 履约类型;省略时为普通头程", allowableValues = {"STANDARD", "ONE_CLICK_DROPSHIP"}, defaultValue = "STANDARD")
@Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType
) {
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
LocalDateTime requestedPickupAt, Long receivingWarehouseId,
String receivingWarehouseName, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode,
String containerMode, Boolean customsInspection, String destinationType,
String amazonWarehouseCode, String shipmentId, String referenceId,
String inboundCode) {
this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, inboundCode, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
@@ -89,7 +109,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null);
shipmentId, referenceId, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -106,7 +126,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null);
shipmentId, referenceId, null, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -47,6 +47,7 @@ public class OrderEntity extends BaseEntity {
private String containerMode;
private Boolean customsInspection;
private String destinationType;
private String fulfillmentType;
private String amazonWarehouseCode;
private String shipmentId;
private String referenceId;
@@ -462,6 +462,7 @@ public class OrderApplicationService {
order.setPricingTaxMode(request.pricingTaxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
order.setFulfillmentType(fulfillmentType(request.fulfillmentType()));
order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null
? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode());
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
@@ -486,6 +487,13 @@ public class OrderApplicationService {
}
order.setInboundCode(normalized);
}
private static String fulfillmentType(String value) {
if (value == null) return "STANDARD";
if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value;
throw new BizException(ResultCode.VALIDATION_FAILED,
"履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP");
}
private boolean legacyManualMode() {
return inboundCodeModeProvider.legacyManual();
}
@@ -599,7 +607,8 @@ public class OrderApplicationService {
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount,
order.getPickupMethod(), order.getRequestedPickupAt());
order.getPickupMethod(), order.getRequestedPickupAt(),
order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable();
@@ -141,6 +141,7 @@ public class OrderFulfillmentSplitService {
child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId());
child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode());
child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType());
child.setFulfillmentType(root.getFulfillmentType());
child.setInboundCode(remainderInboundCode(root, sequence));
child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId);
if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败");
+21
View File
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID',
@@ -61,6 +62,26 @@ CREATE TABLE IF NOT EXISTS ord_order (
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
SET @order_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type'
);
PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql;
EXECUTE order_fulfillment_type_stmt;
DEALLOCATE PREPARE order_fulfillment_type_stmt;
UPDATE baiyue_order.ord_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE baiyue_order.ord_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
-- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。
SET @order_global_inbound_index_sql = IF(
NOT EXISTS(
@@ -646,6 +646,11 @@ components:
type: object
required: [pricingTaxMode, cargoes]
properties:
fulfillmentType:
type: string
enum: [STANDARD, ONE_CLICK_DROPSHIP]
default: STANDARD
description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType
inboundCode:
type: string
maxLength: 40
@@ -657,7 +662,8 @@ components:
minItems: 1
items: {$ref: '#/components/schemas/FirstLegOrderCargo'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;fulfillmentType
省略时固定为 STANDARD,一键代发必须显式传 ONE_CLICK_DROPSHIP,且不复用计费 businessType;pricingTaxMode
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
InboundCodePolicy:
@@ -37,6 +37,9 @@ class MerchantBatchDraftOpenApiContractTest {
.contains("merchant:order:create")
.contains("FirstLegBatchDraftResponse")
.contains("FirstLegBatchDraftResult")
.contains("fulfillmentType:")
.contains("enum: [STANDARD, ONE_CLICK_DROPSHIP]")
.contains("default: STANDARD")
.contains("inboundCode:")
.contains("errorCode:")
.contains("errorMessage:");
@@ -54,6 +54,25 @@ class OrderRequestsValidationTest {
assertThat(validator.validate(draft)).isEmpty();
}
@Test
void fulfillmentTypeIsOptionalButRejectsPricingBusinessValues() {
var standard = request(null, null, "TAX_INCLUDED");
assertThat(validator.validate(standard)).isEmpty();
var invalid = new OrderRequests.SaveRequest(
standard.customerId(), standard.customerName(), standard.transportMode(), standard.tradeTerm(),
standard.destinationCountryCode(), standard.destinationWarehouseId(), standard.destinationWarehouseName(),
standard.expectedShipDate(), standard.customerRemark(), standard.sender(), standard.receiver(),
standard.cargoes(), standard.pickupMethod(), standard.requestedPickupAt(), standard.receivingWarehouseId(),
standard.receivingWarehouseName(), standard.channelCode(), standard.channelName(), standard.carrierId(),
standard.carrierName(), standard.taxMode(), standard.pricingTaxMode(), standard.containerMode(),
standard.customsInspection(), standard.destinationType(), standard.amazonWarehouseCode(),
standard.shipmentId(), standard.referenceId(), standard.inboundCode(), "FBA_SEA");
assertThat(validator.validate(invalid))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("fulfillmentType"));
}
@Test
void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() {
var source = request(null, null, "TAX_INCLUDED");
@@ -60,7 +60,7 @@ class OrderApplicationServiceStateTest {
assertThat(orderId).isNotNull();
verify(eligibility, never()).requireApproved(anyLong());
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT
&& order.getInboundCode() == null));
&& order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType())));
}
@Test
@@ -372,6 +372,31 @@ class OrderApplicationServiceStateTest {
assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(command.totalPackageCount()).isEqualTo(4);
assertThat(command.fulfillmentType()).isEqualTo("STANDARD");
}
@Test
void explicitDropshipFulfillmentTypeIsPersistedAndSentIndependentlyFromPricingBusinessType() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest source = request();
OrderRequests.SaveRequest dropship = new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(),
source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP");
when(masterData.validate(10001L, dropship)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(10001L, 2L, dropship, "dropship-fulfillment");
verify(repository).insertOrder(argThat(order -> "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())
&& "OVERSEAS".equals(order.getDestinationType())));
}
@Test
+10 -6
View File
@@ -921,10 +921,10 @@ export class AcceptanceRun {
}
}
async runPhase4ProducerVariant(label, destinationType) {
async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') {
const matrixMarker = this.marker
const variantMarker = `${matrixMarker}-P4-${label}`
const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker']
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker']
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking']
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
present: Object.hasOwn(this.context, key), value: this.context[key],
@@ -935,6 +935,7 @@ export class AcceptanceRun {
let restoreFixture = null
this.marker = variantMarker
this.context.orderDestinationType = destinationType
this.context.orderFulfillmentType = fulfillmentType
this.context.phase4ProducerMatrixMarker = matrixMarker
try {
restoreFixture = await this.preparePhase3LoadingFixture()
@@ -958,19 +959,22 @@ export class AcceptanceRun {
async ensurePhase4PdaProducerMatrix() {
const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {}
if (Number(readiness().fbaContainerCount || 0) < 1) {
await this.runPhase4ProducerVariant('FBA-PRIMARY', 'FBA')
if (Number(readiness().dropshipContainerCount || 0) < 1) {
await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP')
}
if (Number(readiness().fbaContainerCount || 0) < 2) {
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA')
}
if (Number(readiness().overseasContainerCount || 0) < 1) {
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS')
}
if (Number(readiness().distinctContainerCount || 0) < 3) {
if (Number(readiness().distinctContainerCount || 0) < 4) {
await this.runPhase4ProducerVariant('SECONDARY', 'FBA')
}
const matrix = this.createdResources.phase4InboundProducerFixtures
this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null,
matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true,
`marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`,
`marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`,
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
}
@@ -250,6 +250,7 @@ function buildPayload(context, profile, master, productImageFileId) {
const referenceId = `REF-${context.marker}`.slice(0, 64)
const companyName = profile.tenantName || `订单验收客户-${context.marker}`
return {
fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(),
customerId: master.partner?.id ?? null,
customerName: master.partner?.partnerName ?? companyName,
transportMode: 'SEA', tradeTerm: 'FOB',
@@ -397,6 +398,7 @@ export async function run(context) {
if (!orderId) throw new Error('Client 订单创建失败,后续 Admin 链路停止')
context.createdResources.orders = {
marker: context.marker, tenantId, orderId, productImageFileId,
fulfillmentType: payload.fulfillmentType,
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
receivingWarehouseId: id(master.receiving.id),
destinationType: master.destinationType,
@@ -35,6 +35,7 @@ export function collectPhase4InboundProducerFixture(previous, producer) {
destinationType,
containers.filter((item) => item.destinationType === destinationType),
]))
const dropshipContainers = containers.filter((item) => item.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size
return {
marker: producer.marker,
@@ -43,12 +44,15 @@ export function collectPhase4InboundProducerFixture(previous, producer) {
readiness: {
fbaContainerCount: byDestinationType.FBA.length,
overseasContainerCount: byDestinationType.OVERSEAS.length,
dropshipContainerCount: dropshipContainers.length,
distinctContainerCount,
readyForPhase4Pda: byDestinationType.FBA.length >= 1
readyForPhase4Pda: byDestinationType.FBA.length >= 2
&& byDestinationType.OVERSEAS.length >= 1
&& distinctContainerCount >= 3,
&& dropshipContainers.length >= 1
&& distinctContainerCount >= 4,
},
requiredEntryOrchestration: [
'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA',
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
@@ -367,6 +371,7 @@ export async function run(context) {
sourceInboundId: inboundId,
cartonMark: fixture.cartonMark || null,
cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [],
fulfillmentType: fixture.fulfillmentType || 'STANDARD',
destinationType: fixture.destinationType || null,
destinationWarehouseId: id(fixture.destinationWarehouseId),
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
@@ -1215,6 +1215,7 @@ export async function run(context) {
cartonMark: source?.boxCode || source?.cartonMark || null,
cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || [])
.map((value) => String(value || '').trim()).filter(Boolean))],
fulfillmentType: source?.fulfillmentType || 'STANDARD',
destinationType: initial?.summary?.destinationType || source?.destinationType || null,
destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId),
destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null,
@@ -17,25 +17,32 @@ test('order fixture defaults to FBA and accepts only the explicit OVERSEAS varia
const orders = read('orders.mjs')
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
assert.match(orders, /destinationType: master\.destinationType/)
assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/)
assert.match(orders, /destinationType,\s*\n\s*\}\)/)
assert.doesNotMatch(orders, /const fbaResponse =/)
})
test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => {
test('producer collector requires dropship FBA, standard FBA, OVERSEAS and a fourth distinct container', () => {
const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' }
const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' }
const dropship = { ...common, eventKey: 'DROP-EVENT', destinationType: 'FBA',
fulfillmentType: 'ONE_CLICK_DROPSHIP', containerNo: 'DROP-001' }
const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA',
fulfillmentType: 'STANDARD', containerNo: 'FBA-001' }
const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' }
const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' }
const one = collectPhase4InboundProducerFixture(null, fba)
const two = collectPhase4InboundProducerFixture(one, overseasPrimary)
const ready = collectPhase4InboundProducerFixture(two, secondary)
const one = collectPhase4InboundProducerFixture(null, dropship)
const two = collectPhase4InboundProducerFixture(one, fba)
const three = collectPhase4InboundProducerFixture(two, overseasPrimary)
const ready = collectPhase4InboundProducerFixture(three, secondary)
assert.equal(one.readiness.readyForPhase4Pda, false)
assert.equal(two.readiness.readyForPhase4Pda, false)
assert.equal(three.readiness.readyForPhase4Pda, false)
assert.equal(ready.readiness.readyForPhase4Pda, true)
assert.equal(ready.readiness.fbaContainerCount, 2)
assert.equal(ready.readiness.fbaContainerCount, 3)
assert.equal(ready.readiness.overseasContainerCount, 1)
assert.equal(ready.readiness.distinctContainerCount, 3)
assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2')
assert.equal(ready.readiness.dropshipContainerCount, 1)
assert.equal(ready.readiness.distinctContainerCount, 4)
assert.equal(ready.byDestinationType.FBA[2].eventKey, 'FBA-EVENT-2')
})
test('same event key is idempotently replaced and another marker starts a new matrix', () => {
@@ -49,6 +56,7 @@ test('same event key is idempotently replaced and another marker starts a new ma
assert.equal(nextRun.containers.length, 1)
assert.equal(nextRun.marker, 'RUN-2')
assert.deepEqual(nextRun.requiredEntryOrchestration, [
'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA',
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
@@ -61,7 +69,14 @@ test('measurement and loading pass destination identity into the LOADED producer
const finalize = read('phase3-loading-finalize.mjs')
assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/)
assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/)
assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/)
assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/)
assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/)
assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/)
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
const entry = read('../admin-test-env-acceptance.mjs')
assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/)
assert.match(entry, /orderFulfillmentType/)
const pda = read('phase4-overseas-pda.mjs')
assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/)
})
@@ -413,7 +413,8 @@ export async function run(context) {
return
}
const matrix = context.createdResources?.phase4InboundProducerFixtures
const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
.filter((item) => nonBlank(item?.containerNo))
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) {