From 4839243f217342eb89e4cf859749e21d3a3e698c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:40:27 +0800 Subject: [PATCH] feat: add dropship fulfillment producer chain --- deploy/sql/头程数据库统一增量-MySQL8.sql | 44 +++++++++++++++++++ .../InternalInboundOrderController.java | 8 +++- .../firstleg/dto/InboundOrderSummary.java | 3 +- .../dto/OrderApprovedInboundCommand.java | 9 ++-- .../firstleg/entity/InboundOrderEntity.java | 1 + .../mapper/InboundSplitSettlementMapper.java | 4 +- .../mapper/LoadingCompletedEventMapper.java | 4 +- .../service/InboundOrderCreationService.java | 10 +++++ .../LoadingCompletedOutboxService.java | 22 +++++++--- .../resources/db/phase2-inbound-schema.sql | 21 +++++++++ .../main/resources/openapi/firstleg-v1.yaml | 5 ++- .../UnifiedInboundOpenApiContractTest.java | 7 ++- .../FulfillmentTypeSqlContractTest.java | 40 +++++++++++++++++ .../PdaSplitSettlementSqlContractTest.java | 1 + .../InboundOrderCreationServiceTest.java | 24 ++++++++++ .../LoadingCompletedOutboxServiceTest.java | 34 +++++++++++++- .../TaillegRealProducerAcceptanceTest.java | 16 ++++--- .../service/LoadingCompletedConsumer.java | 6 ++- .../service/LoadingCompletedConsumerTest.java | 2 +- .../order/client/FirstlegInboundClient.java | 2 +- .../com/baiyue/order/dto/OrderRequests.java | 26 +++++++++-- .../com/baiyue/order/entity/OrderEntity.java | 1 + .../service/OrderApplicationService.java | 11 ++++- .../service/OrderFulfillmentSplitService.java | 1 + order/src/main/resources/db/order.sql | 21 +++++++++ .../src/main/resources/openapi/order-v1.yaml | 8 +++- ...MerchantBatchDraftOpenApiContractTest.java | 3 ++ .../dto/OrderRequestsValidationTest.java | 19 ++++++++ .../OrderApplicationServiceStateTest.java | 27 +++++++++++- scripts/admin-test-env-acceptance.mjs | 16 ++++--- scripts/admin-test-env-modules/orders.mjs | 2 + .../phase3-loading-finalize.mjs | 9 +++- .../admin-test-env-modules/phase3-loading.mjs | 1 + ...-phase4-producer-fixture-contract.test.mjs | 31 +++++++++---- .../phase4-overseas-pda.mjs | 3 +- 35 files changed, 389 insertions(+), 53 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a6bd2d6c0..483eb69ce 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 651269bd1..4e299343f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -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, "草稿预入库快照不完整"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f5a98f09a..e9071df30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -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(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 92665d4bd..ece4cea5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -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"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index 43290b179..75f165e1b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 647c9c080..ed0190ff2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -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}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index c11e662f4..8675643a5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 193940b28..ac82be20b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -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(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index f5879a22c..d2c706779 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -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 pallets = dropship(first.getBusinessType()) + String fulfillmentType = fulfillmentType(sorted); + String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List 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> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); - Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + Map> 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 rows) { + Set 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 palletSnapshots(List values, List 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, diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index df2b3f30c..902aede82 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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( diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e893811e8..f38dfe72d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index d18b7a6e3..8665093a8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -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 approval = stringMap(schemas.get("OrderApprovedInboundCommand")); + assertThat(stringList(approval.get("required"))).contains("fulfillmentType"); + Map fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType")); + assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java new file mode 100644 index 000000000..ca27c12fb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java @@ -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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 5d0c3b22a..636087f87 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -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}"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 9f6c94123..a9583fa1b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -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 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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 90ea941cb..461aca213 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -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 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; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index df13829c5..e593f1aa2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index c0709152b..bbb89c9ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -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; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index dc66ad190..2646b549d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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 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 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")); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index de4b011fb..e61647e36 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -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 ) { } } diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 28db86533..6786d6578 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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 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 和名称必须同时填写或同时留空") diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 8a1e97d44..0c8fceec5 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -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; diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 832ad9e86..9f2f9f841 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index 524868b10..e5fffe608 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -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, "余量子订单创建失败"); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index d61276e08..27d732b9a 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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( diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 9891be3ce..b015fdb8b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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: diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java index 7329bc794..e685ef60d 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java @@ -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:"); diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index e269eacce..f58d48fe2 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -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"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 636f7649a..be1b0dce3 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index d230f0a72..8f0951ff7 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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 }) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index ebce176a2..186aae83b 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index aa7bc88c8..345454daa 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 38c2da961..f1cf90df1 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 25c535b2a..9a7234e26 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -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'\)/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d24debae7..6aae9e42f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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))) {