From c1f01e1e87489eafab9fcfaadab96ff949f2561a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 11:54:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-event-contract/AGENTS.md | 1 + .../com/baiyue/common/event/EventTypes.java | 1 + .../event/OverseasDeliveryCompletedV1.java | 19 +++ .../OverseasDeliveryCompletedV1Test.java | 26 +++ deploy/sql/头程数据库统一增量-MySQL8.sql | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 12 +- .../metrics/EventBridgeMetrics.java | 1 + firstleg/AGENTS.md | 1 + .../OrderFulfillmentCompletionClient.java | 21 +++ ...ernalOverseasContainerEventController.java | 6 +- .../mapper/ContainerBookingMapper.java | 4 +- .../firstleg/mapper/InboundOrderMapper.java | 23 +++ .../mapper/OceanBookingMapper.java | 6 +- .../repository/OceanBookingRepository.java | 4 +- .../service/OceanBookingService.java | 20 +-- .../repository/ContainerRepository.java | 2 +- .../firstleg/service/ContainerService.java | 2 +- .../OverseasDeliveryCompletedConsumer.java | 148 ++++++++++++++++++ .../db/phase3-ocean-booking-schema.sql | 4 +- .../resources/openapi/ocean-bookings-v1.yaml | 2 +- .../OceanBookingMapperContractTest.java | 4 +- .../service/OceanBookingServiceTest.java | 20 ++- .../ContainerBookingMapperContractTest.java | 1 + .../service/ContainerServiceTest.java | 12 +- ...OverseasDeliveryCompletedConsumerTest.java | 86 ++++++++++ lastleg-service/AGENTS.md | 7 +- .../controller/CustomerVisitController.java | 8 - .../service/CustomerVisitService.java | 38 +---- .../DeliveryTransferController.java | 2 +- .../delivery/dto/DeliveryResponses.java | 14 +- .../delivery/entity/DeliveryOrder.java | 4 + .../delivery/mapper/DeliveryOrderMapper.java | 5 +- .../delivery/model/DeliveryStatusPolicy.java | 6 +- .../DeliveryCompletedOutboxService.java | 95 +++++++++++ .../service/DeliveryDocumentService.java | 42 +++++ .../service/DeliveryTransferService.java | 117 ++++++++------ .../UnloadCompletedOutboxRepository.java | 1 + .../src/main/resources/db/lastleg.sql | 12 +- .../openapi/delivery-documents-v1.yaml | 2 +- .../openapi/overseas-business-v1.yaml | 26 ++- .../service/CustomerVisitServiceTest.java | 54 +------ .../DeliveryLongIdJsonContractTest.java | 2 +- .../delivery/DeliverySchemaContractTest.java | 7 +- .../model/DeliveryStatusPolicyTest.java | 5 +- .../DeliveryCompletedOutboxServiceTest.java | 59 +++++++ .../service/DeliveryDocumentServiceTest.java | 30 ++++ .../service/DeliveryTransferServiceTest.java | 53 +++++-- .../service/DispatchBolPrintServiceTest.java | 30 ++++ order/AGENTS.md | 1 + ...lOrderFulfillmentCompletionController.java | 36 +++++ .../com/baiyue/order/mapper/OrderMapper.java | 3 + .../OrderFulfillmentCompletionService.java | 76 +++++++++ .../src/main/resources/openapi/order-v1.yaml | 31 ++++ .../ocean-booking-fixture-contract.test.mjs | 3 +- .../ocean-booking-fixture.mjs | 3 + .../smart-loading-channel-group.mjs | 2 + 56 files changed, 973 insertions(+), 231 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java create mode 100644 order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index d3d7e8542..8e6c472e6 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -18,6 +18,7 @@ | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | +| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index a8886358c..17172f544 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -14,6 +14,7 @@ public final class EventTypes { "FirstLegContainerShippingNodeRecorded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; + public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java new file mode 100644 index 000000000..90d88f414 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java @@ -0,0 +1,19 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 海外派送单按目的 FBA 仓签收后,回传对应国内入仓单和订单的完成事实。 */ +public record OverseasDeliveryCompletedV1( + String deliveryOrderId, + String deliveryNo, + String lastlegOrderId, + String lastlegOrderNo, + String sourceInboundId, + String inboundCode, + String sourceOrderId, + String sourceOrderNo, + String rootOrderId, + String fbaWarehouseCode, + Instant completedAt +) { +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java new file mode 100644 index 000000000..7d96a07b2 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java @@ -0,0 +1,26 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasDeliveryCompletedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void roundTripsStableSourceIdentifiers() throws Exception { + var value = new OverseasDeliveryCompletedV1("31", "DL-31", "41", "LM-41", "51", + "NYEXX2609080022", "61", "FL-61", "61", "ACY2", + Instant.parse("2026-09-15T08:00:00Z")); + + var restored = mapper.readValue(mapper.writeValueAsBytes(value), OverseasDeliveryCompletedV1.class); + + assertThat(restored).isEqualTo(value); + assertThat(restored.sourceInboundId()).isEqualTo("51"); + assertThat(restored.sourceOrderId()).isEqualTo("61"); + } +} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index bf509dfe9..e752970bf 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16597,10 +16597,10 @@ BEGIN @phase3_channel_message = MESSAGE_TEXT; END; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN - ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name; + ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照' AFTER customer_name; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_name') THEN - ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code; + ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱计划物流渠道组名称快照' AFTER channel_code; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='warehouse_id') THEN ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d79920bab..285bdfb13 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -350,6 +350,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', + destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', @@ -382,7 +386,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key), KEY idx_ll_delivery_list (customer_id,status,created_at) ) ENGINE=InnoDB COMMENT='尾程派送单'; @@ -390,6 +394,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); +PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); +SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); +SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); +PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index d652670aa..1b97d2bdf 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -31,6 +31,7 @@ public class EventBridgeMetrics { EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 6b9efc776..7c48ac467 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -44,6 +44,7 @@ | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | +| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java new file mode 100644 index 000000000..8833d0115 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.time.Instant; + +@FeignClient(name = "order-service", contextId = "firstlegOrderFulfillmentCompletionClient", + url = "${baiyue.clients.order.url:}") +public interface OrderFulfillmentCompletionClient { + @PostMapping("/internal/order/v1/fulfillment-completions") + ApiResponse complete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Request request); + + record Request(String tenantId, String orderId, String orderNo, String inboundCode, + String deliveryNo, Instant completedAt, String eventId, String payloadHash) { } + record Result(Long orderId, String status, boolean replayed) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java index 095e376c8..039cce166 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -7,6 +7,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventTypes; import com.baiyue.firstleg.service.OverseasContainerEventConsumer; import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer; +import com.baiyue.firstleg.service.OverseasDeliveryCompletedConsumer; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; @@ -22,6 +23,7 @@ import java.security.MessageDigest; public class InternalOverseasContainerEventController { private final OverseasContainerEventConsumer consumer; private final OverseasInboundOrderDetainedConsumer detainedConsumer; + private final OverseasDeliveryCompletedConsumer deliveryCompletedConsumer; @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping("/internal/firstleg/v1/container-events") @@ -30,7 +32,9 @@ public class InternalOverseasContainerEventController { @RequestBody EventEnvelope event) { authenticate(token); Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - ? detainedConsumer.consume(event) : consumer.consume(event); + ? detainedConsumer.consume(event) + : EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) + ? deliveryCompletedConsumer.consume(event) : consumer.consume(event); return ApiResponse.success(result); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 16335a476..f503a3a7f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -11,8 +11,8 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface ContainerBookingMapper extends BaseMapper { - @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") - int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") + int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Update(""" UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId}, updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index ffb4f0d87..bd8784895 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -24,6 +24,29 @@ public interface InboundOrderMapper extends BaseMapper { @Param("detained") boolean detained, @Param("reason") String reason, @Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId); + @Update(""" + UPDATE fl_inbound_order + SET process_status='DELIVERED', updated_by=0, updated_at=#{completedAt}, version=version+1 + WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' + """) + int completeOverseasDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("completedAt") java.time.LocalDateTime completedAt); + + @Select(""" + SELECT COUNT(*) FROM fl_inbound_order + WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status!='DELIVERED' + """) + int countNotDeliveredByOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + + @Update(""" + UPDATE fl_inbound_order + SET order_status_snapshot='COMPLETED', updated_by=0, updated_at=#{completedAt}, version=version+1 + WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 + AND (order_status_snapshot IS NULL OR order_status_snapshot!='COMPLETED') + """) + int markOrderSnapshotCompleted(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("completedAt") java.time.LocalDateTime completedAt); + @Update(""" UPDATE fl_inbound_order SET hold_flag=#{holdFlag}, current_hold_reason=#{reason}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 63ce5cd76..479ee3d79 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -76,16 +76,14 @@ public interface OceanBookingMapper extends BaseMapper { AND allocated.requirement_id=requirement.id WHERE booking.status='RELEASED' AND booking.warehouse_id=#{query.warehouseId} - AND booking.channel_code IN - #{channelCode} + AND booking.channel_code=#{query.channelGroupCode} AND requirement.container_type=#{query.containerType} AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC """) Page selectLinkCandidates(Page page, - @Param("query") LinkCandidateQuery query, - @Param("channelCodes") java.util.Set channelCodes); + @Param("query") LinkCandidateQuery query); @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index b5f5bb663..e6c8f1013 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -33,8 +33,8 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } - public PageResult linkCandidates(LinkCandidateQuery query, java.util.Set channelCodes) { - return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes)); + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index d973a9a68..1c2225276 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -77,7 +77,7 @@ public class OceanBookingService { catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); } String containerType = ContainerSpecificationCatalog.require(query.containerType()).containerType(); return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), - group.code(), containerType, query.page(), query.size()), group.channelCodes()); + group.code(), containerType, query.page(), query.size())); } public List channels(long originWarehouseId) { @@ -239,16 +239,18 @@ public class OceanBookingService { || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); } - private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) { + private SmartLoadingChannelGroupCatalog.Definition requireActiveChannelGroup(Long originWarehouseId, String channelGroupCode) { if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); - String normalized = normalizeChannel(channelCode); + SmartLoadingChannelGroupCatalog.Definition group; + try { group = SmartLoadingChannelGroupCatalog.require(normalizeChannel(channelGroupCode)); } + catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); } Long versionId = priceBooks.activeVersionId(); if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); - ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream() - .filter(value -> normalized.equals(normalizeChannel(value.getChannelCode()))) - .findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); - if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效"); - return channelOption(row, java.util.Map.of()); + boolean active = priceBooks.channels(versionId, originWarehouseId).stream() + .map(ChannelRow::getChannelCode).filter(StringUtils::hasText).map(String::trim) + .anyMatch(group.channelCodes()::contains); + if (!active) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择"); + return group; } private static ChannelOption channelOption(ChannelRow row, java.util.Map> taxModes) { @@ -269,7 +271,7 @@ public class OceanBookingService { if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) { WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId()); warehouseName = warehouse.warehouseName(); - channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName(); + channelName = requireActiveChannelGroup(warehouse.id(), d.channelCode()).name(); } var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode()); var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index cfc60b9d2..0112dcba9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -37,7 +37,7 @@ public class ContainerRepository { public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo, com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking, int version, long operatorId, String operatorName) { - return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName); + return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName); } public int assignRealContainerNo(long id, String containerNo, int version, long operatorId, String operatorName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 57a358a62..4936a9cf3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -89,7 +89,7 @@ public class ContainerService { String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo); if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) - || !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode()) + || !java.util.Objects.equals(container.getChannelGroupCode(), booking.getChannelCode()) || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java new file mode 100644 index 000000000..6fa43e279 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -0,0 +1,148 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class OverseasDeliveryCompletedConsumer { + private final InboundOrderMapper inboundOrders; + private final InboundStatusHistoryMapper histories; + private final ContainerEventInboxMapper inbox; + private final OrderFulfillmentCompletionClient orderClient; + private final ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @Transactional + public InboundOrderEntity consume(EventEnvelope event) { + validate(event); + OverseasDeliveryCompletedV1 payload = payload(event); + long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效"); + long tenantId = positive(event.tenantId(), "客户 ID 无效"); + if (!Objects.equals(event.aggregateId(), payload.sourceInboundId())) { + throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致"); + } + ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { + throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + } + return required(inboundId, tenantId, payload); + } + ContainerEventInboxEntity inboxRow = inbox(event); + try { inbox.insert(inboxRow); } + catch (DuplicateKeyException duplicate) { + ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); + if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; + return required(inboundId, tenantId, payload); + } + InboundOrderEntity inbound = required(inboundId, tenantId, payload); + if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) { + throw new IllegalArgumentException("来源头程入仓单尚未装柜完成"); + } + if ("LOADED".equals(inbound.getProcessStatus())) { + LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC); + if (inboundOrders.completeOverseasDelivery(tenantId, inboundId, completedAt) != 1) { + throw new IllegalStateException("来源头程入仓单状态并发变化"); + } + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setInboundId(inboundId); + history.setFromStatus("LOADED"); history.setToStatus("DELIVERED"); + history.setAction("COMPLETE_DELIVERY"); + history.setReason("海外派送单 " + payload.deliveryNo() + " 已签收"); + history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE"); + history.setOccurredAt(completedAt); histories.insert(history); + } + long sourceOrderId = positive(payload.sourceOrderId(), "来源订单 ID 无效"); + if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) { + var response = orderClient.complete(internalToken, new OrderFulfillmentCompletionClient.Request( + event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(), + payload.deliveryNo(), payload.completedAt(), event.eventId(), event.payloadHash())); + if (response == null || !response.successful() || response.data() == null + || !"COMPLETED".equals(response.data().status())) { + throw new IllegalStateException("Order 服务未确认来源订单完成"); + } + inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId, + LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC)); + } + inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); + inbox.updateById(inboxRow); + return required(inboundId, tenantId, payload); + } + + private void validate(EventEnvelope event) { + event.requireValid(); + if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 + || event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN + || !"INBOUND_ORDER".equals(event.aggregateType())) { + throw new IllegalArgumentException("不支持的海外派送完成事件"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + } + } + + private OverseasDeliveryCompletedV1 payload(EventEnvelope event) { + try { + OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class); + if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo()) + || !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo()) + || !StringUtils.hasText(value.inboundCode())) throw new IllegalArgumentException("字段不完整"); + return value; + } catch (Exception exception) { + throw new IllegalArgumentException("海外派送完成事件负载无效", exception); + } + } + + private InboundOrderEntity required(long inboundId, long tenantId, OverseasDeliveryCompletedV1 payload) { + InboundOrderEntity inbound = inboundOrders.selectActiveByIdForUpdate(inboundId); + if (inbound == null || !Objects.equals(inbound.getTenantId(), tenantId) + || !Objects.equals(inbound.getInboundCode(), payload.inboundCode()) + || !Objects.equals(String.valueOf(inbound.getOrderId()), payload.sourceOrderId()) + || !Objects.equals(inbound.getOrderNo(), payload.sourceOrderNo()) + || (StringUtils.hasText(payload.fbaWarehouseCode()) && StringUtils.hasText(inbound.getDestinationCode()) + && !Objects.equals(inbound.getDestinationCode(), payload.fbaWarehouseCode()))) { + throw new IllegalArgumentException("事件与来源头程入仓单归属不一致"); + } + return inbound; + } + + private ContainerEventInboxEntity inbox(EventEnvelope event) { + ContainerEventInboxEntity row = new ContainerEventInboxEntity(); + row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion()); + row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion()); + row.setPayloadHash(event.payloadHash().toLowerCase(Locale.ROOT)); row.setPayloadJson(event.payload().toString()); + row.setStatus("RECEIVED"); row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC)); + return row; + } + + private static long positive(String value, String message) { + try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } + catch (RuntimeException ignored) { } + throw new IllegalArgumentException(message); + } +} diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 556af84e4..337b7aace 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -7,8 +7,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', - channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照', - channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', + channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照', + channel_name VARCHAR(128) NULL COMMENT '订舱计划物流渠道组名称快照', warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照', warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index d4d9b1962..45b4db532 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -165,7 +165,7 @@ components: properties: customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权} customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null} - channelCode: {type: string, nullable: true} + channelCode: {type: string, nullable: true, description: 固定计划物流渠道组编码} warehouseId: {type: integer, format: int64, nullable: true} billOfLadingNo: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 156feea9c..1a73ab477 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -49,12 +49,12 @@ class OceanBookingMapperContractTest { @Test void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", - com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class, java.util.Set.class) + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) .getAnnotation(Select.class).value()); assertThat(sql) .contains("booking.status='RELEASED'") .contains("booking.warehouse_id=#{query.warehouseId}") - .contains("(List.of(candidate), 1, 20, 1, 1)); + "客户A", "深圳中转仓", "美森正班"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, " MATSON_CLX ", " 40HQ ", null, null)); @@ -337,13 +337,11 @@ class OceanBookingServiceTest { assertThat(result.records()).containsExactly(candidate); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); - @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(java.util.Set.class); - verify(repository).linkCandidates(query.capture(), channels.capture()); + verify(repository).linkCandidates(query.capture()); assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX"); assertThat(query.getValue().containerType()).isEqualTo("40HQ"); assertThat(query.getValue().page()).isEqualTo(1L); assertThat(query.getValue().size()).isEqualTo(20L); - assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK"); verify(repository, never()).find(anyLong()); verify(repository, never()).requirements(anyLong()); verify(repository, never()).containers(anyLong()); @@ -351,7 +349,7 @@ class OceanBookingServiceTest { @Test void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { - when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); PageResult result = service.linkCandidates( new LinkCandidateQuery(701L, "MATSON_CLX", "45HQ", null, null)); @@ -359,7 +357,7 @@ class OceanBookingServiceTest { assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero(); assertThat(result.pages()).isZero(); - verify(repository, times(1)).linkCandidates(any(), anySet()); + verify(repository, times(1)).linkCandidates(any()); assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); } @@ -386,7 +384,7 @@ class OceanBookingServiceTest { private void stubMasterData() { ChannelRow row = new ChannelRow(); - row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运"); + row.setPriceBookVersionId(51L); row.setChannelCode("MATSON_CLX_TRUCK"); row.setChannelName("美森正班海卡"); row.setPriceModel("FBA_WAREHOUSE_RATE"); when(priceBooks.activeVersionId()).thenReturn(51L); when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row)); @@ -406,7 +404,7 @@ class OceanBookingServiceTest { } private static DraftData completeDraft() { - return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", + return new DraftData(100L, "客户", "MATSON_CLX", 701L, "BL-001", 71L, "船公司", "MATSON", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", "LAX", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, @@ -419,7 +417,7 @@ class OceanBookingServiceTest { private static OceanBookingEntity completeBooking(String status, int version) { OceanBookingEntity value = booking(status, version); - value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); + value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("MATSON_CLX"); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); value.setCarrierCode("MATSON"); value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index e339e34ab..18f379ad1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -64,6 +64,7 @@ class ContainerBookingMapperContractTest { .filter(candidate -> candidate.getName().equals("linkReleasedBooking")) .findFirst().orElseThrow(); String sql = String.join("\n", method.getAnnotation(Update.class).value()); + assertThat(sql).doesNotContain("channel_code=#{channelCode}", "channel_name=#{channelName}"); var parameterNames = Arrays.stream(method.getParameters()) .map(parameter -> parameter.getAnnotation(Param.class).value()) .toList(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index b5f2fbf24..65b078ae6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -207,7 +207,7 @@ class ContainerServiceTest { container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HQ"); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); - booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY"); + booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("19500.000")); when(repository.find(11L)).thenReturn(container, container); @@ -319,7 +319,7 @@ class ContainerServiceTest { second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); - oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); when(repository.find(11L)).thenReturn(first); @@ -362,19 +362,19 @@ class ContainerServiceTest { @Test @DisplayName("首次关联拒绝装柜仓库不一致") void rejectsLinkWhenWarehouseDiffers() { - assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HQ"); + assertThreeFieldMismatch(102L, "MATSON_CLX", "40HQ"); } @Test @DisplayName("首次关联拒绝物流渠道不一致") void rejectsLinkWhenChannelDiffers() { - assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HQ"); + assertThreeFieldMismatch(101L, "HEDE", "40HQ"); } @Test @DisplayName("首次关联拒绝柜型不一致") void rejectsLinkWhenContainerTypeDiffers() { - assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HQ"); + assertThreeFieldMismatch(101L, "MATSON_CLX", "45HQ"); } @Test @@ -443,7 +443,7 @@ class ContainerServiceTest { container.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); - oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); when(repository.find(11L)).thenReturn(container); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java new file mode 100644 index 000000000..8fa0a26ba --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasDeliveryCompletedConsumerTest { + @Test + void completesOnlyEventSourceInboundAndOrder() { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class); + ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer( + inbounds, histories, inbox, orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0); + when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success( + new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false))); + + consumer.consume(event(json)); + + verify(inbounds).completeOverseasDelivery(eq(88L), eq(101L), any()); + verify(orders).complete(eq("token"), argThat(request -> + "201".equals(request.orderId()) && "IN-1".equals(request.inboundCode()) + && "DL-ONT8".equals(request.deliveryNo()))); + verify(inbounds).markOrderSnapshotCompleted(eq(88L), eq(201L), any()); + verify(inbounds, never()).completeOverseasDelivery(eq(88L), eq(102L), any()); + } + + @Test + void leavesOrderOpenWhileAnotherSourceInboundIsNotDelivered() { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class); + ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer( + inbounds, histories, inbox, orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(1); + + consumer.consume(event(json)); + + verifyNoInteractions(orders); + verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any()); + } + + private EventEnvelope event(ObjectMapper json) { + Instant time = Instant.parse("2026-09-15T08:00:00Z"); + var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11", + "101", "IN-1", "201", "FL-201", "201", "ONT8", time); + var node = json.valueToTree(payload); + return new EventEnvelope("00000000-0000-4000-8000-000000000001", + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN, + "88", "INBOUND_ORDER", "101", 2L, 1L, + "00000000-0000-4000-8000-000000000099", "delivery-101", null, node, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 806724be0..d6a05edca 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -45,6 +45,7 @@ | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | | 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | +| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | @@ -56,8 +57,8 @@ | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 | -| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载,不增加派送完成门禁或签名识别 | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 | +| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | @@ -75,7 +76,7 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。 -- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成 `WAIT_OUTBOUND` 出库单与派送单,但必须早于发车;PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 +- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java index 1707f2fef..ba27ae938 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -64,14 +64,6 @@ public class CustomerVisitController { return ApiResponse.success(service.upload(requiredScope(user, customerId), user.userId(), visitId, file)); } - @PostMapping("/delivery-orders/{deliveryNo}/complete") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") - public ApiResponse completeDelivery( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, - @PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) { - return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request)); - } - @PutMapping("/delivery-orders/{deliveryNo}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") public ApiResponse updateDeliveryStatus( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java index bc1d68090..801207e6c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -76,39 +76,6 @@ public class CustomerVisitService { return detail(visit); } - @Transactional - public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo, - CustomerVisitRequests.DeliveryStatus request) { - if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) { - throw badRequest("派送完成接口只接受 DELIVERED 状态"); - } - DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); - if (delivery == null) throw notFound("派送单不存在"); - boolean replayed = "DELIVERED".equals(delivery.getStatus()); - if (!replayed) { - requireVersion(delivery, request.expectedVersion()); - requireDeliveryTransition(delivery.getStatus(), "DELIVERED"); - } - LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now(); - if (!replayed) { - delivery.setStatus("DELIVERED"); - delivery.setSignedAt(now); - delivery.setUpdatedAt(now); - delivery.setVersion(version(delivery) + 1); - deliveryOrders.updateById(delivery); - for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) { - order.setSubOrderStatus("DELIVERY:DELIVERED"); - order.setUpdatedAt(now); - lastMileOrders.updateById(order); - } - recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now); - } - CustomerVisit visit = ensureVisit(delivery, now); - return new CustomerVisitResponses.Completed(visit.getId(), deliveryNo, delivery.getStatus(), visit.getStatus(), replayed); - } - @Transactional public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId, String deliveryNo, @@ -117,7 +84,6 @@ public class CustomerVisitService { if (!DeliveryStatusPolicy.isEditableTarget(target)) { throw badRequest("不支持的派送状态"); } - if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request); DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); if (target.equals(delivery.getStatus())) { @@ -192,6 +158,10 @@ public class CustomerVisitService { uploaded.contentType(), uploaded.sizeBytes()); } + public void ensureForCompletedDelivery(DeliveryOrder delivery, LocalDateTime completedAt) { + ensureVisit(delivery, completedAt); + } + private CustomerVisit ensureVisit(DeliveryOrder delivery, LocalDateTime completedAt) { CustomerVisit existing = visits.findByDelivery(delivery.getCustomerId(), delivery.getId()); if (existing != null) return existing; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 3934c7380..c4cd9a2bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -25,7 +25,7 @@ public class DeliveryTransferController { @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") - @Operation(summary = "Create one pending outbound order and one pending delivery order") + @Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse") public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @Valid @RequestBody DeliveryRequests.Transfer request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 654827545..df4ae6169 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -7,9 +7,13 @@ import java.util.List; public final class DeliveryResponses { private DeliveryResponses() {} + public record TransferDelivery(String deliveryNo, String deliveryStatus, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int palletCount, int packageCount) {} + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, - String deliveryNo, String deliveryStatus, int palletCount, - int packageCount, boolean replayed) {} + int palletCount, int packageCount, + List deliveries, boolean replayed) {} public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, String status, int palletCount, int packageCount, LocalDateTime createdAt, String outboundType) {} @@ -25,7 +29,9 @@ public final class DeliveryResponses { LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, String remark, LocalDateTime createdAt, long version, List allowedActions, - String bolNo, DeliveryDocumentDtos.Summary documentSummary) {} + String bolNo, DeliveryDocumentDtos.Summary documentSummary, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, @@ -45,7 +51,7 @@ public final class DeliveryResponses { public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, String destinationWarehouseCode, String destinationWarehouseName, String skuCode, int packageCount, int pickedPackageCount, String status) {} - public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List pallets) {} + public record OutboundDetail(OutboundRow outbound, List deliveries, List pallets) {} public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, String palletNo, String inboundCode, String skuCode, String productName, long availablePackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 5bf10042a..54ff1a699 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -23,6 +23,10 @@ public class DeliveryOrder { private String overseasWarehouseId; private String overseasWarehouseName; private String customerName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String deliveryGroupKey; private String status; private String waybillNo; private String bolNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java index de1372e55..e3606c3af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; @Mapper public interface DeliveryOrderMapper extends BaseMapper { @@ -14,6 +15,6 @@ public interface DeliveryOrderMapper extends BaseMapper { DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); - @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") - DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id FOR UPDATE") + List lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java index 2ae76d3bb..1b6b01368 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -6,7 +6,7 @@ import java.util.Set; public final class DeliveryStatusPolicy { private static final Set EDITABLE_TARGETS = Set.of( - "WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"); + "WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED"); private static final Map> TRANSITIONS = Map.of( "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"), "DELIVERING", Set.of("DELIVERED", "REJECTED"), @@ -29,8 +29,8 @@ public final class DeliveryStatusPolicy { public static List allowedActions(String status) { return switch (status == null ? "" : status) { case "WAIT_DELIVERY" -> List.of( - "START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); - case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + "START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN"); case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); default -> List.of(); }; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java new file mode 100644 index 000000000..26a7db8fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 在派送完成事务内按来源入仓单冻结海外到国内的完成事件。 */ +@Service +public class DeliveryCompletedOutboxService { + @Resource private LastlegEventOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + + public void publish(DeliveryOrder delivery, List orders, Instant completedAt) { + Objects.requireNonNull(delivery, "delivery"); + Objects.requireNonNull(completedAt, "completedAt"); + Map bySourceInbound = new LinkedHashMap<>(); + for (LastlegOrder order : orders == null ? List.of() : orders) { + if (!StringUtils.hasText(order.getSourceInboundId())) continue; + requireText(order.getSourceOrderId(), "sourceOrderId"); + requireText(order.getInboundCode(), "inboundCode"); + requireText(order.getCustomerId(), "customerId"); + LastlegOrder existing = bySourceInbound.putIfAbsent(order.getSourceInboundId(), order); + if (existing != null && (!Objects.equals(existing.getSourceOrderId(), order.getSourceOrderId()) + || !Objects.equals(existing.getInboundCode(), order.getInboundCode()))) { + throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突"); + } + } + for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt); + } + + private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) { + long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion()); + String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo() + + ':' + order.getSourceInboundId(); + var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(), + String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(), + order.getSourceOrderId(), order.getSourceOrderNo(), order.getRootOrderId(), + order.getFbaWarehouseCode(), completedAt); + JsonNode node = objectMapper.valueToTree(payload); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN, + order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L, + EventIdentifiers.newUuid(), eventKey, + EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null, + node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))).requireValid(); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(IdWorker.getId()); row.setCustomerId(order.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(envelope.aggregateId()); + row.setEventType(envelope.eventType()); row.setEventVersion(envelope.eventVersion()); + row.setEventKey(eventKey); row.setPayloadJson(json(envelope)); row.setStatus("PENDING"); + row.setRetryCount(0); row.setCreatedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)); + try { + outbox.insert(row); + } catch (DuplicateKeyException duplicate) { + LastlegEventOutbox existing = outbox.findByEventKey(eventKey); + if (existing == null || !Objects.equals(existing.getAggregateId(), order.getSourceInboundId()) + || !Objects.equals(existing.getCustomerId(), order.getCustomerId())) throw duplicate; + } + } + + private static void requireText(String value, String field) { + if (!StringUtils.hasText(value)) throw new IllegalStateException(field + " is required"); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize delivery-completed event", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 5a5982605..9c31d7c67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -6,6 +6,10 @@ import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -17,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.security.*; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.*; @Service @@ -26,6 +31,9 @@ public class DeliveryDocumentService { @Resource private DeliveryOperationMapper operations; @Resource private DeliveryDocumentFileGateway files; @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; + @Resource private CustomerVisitService customerVisits; public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } @@ -69,7 +77,21 @@ public class DeliveryDocumentService { order.setDocumentsJson(write(categories)); order.setVersion(version(order) + 1); order.setUpdatedAt(now); + boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty(); + boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus()); + if (autoCompleted) { + if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) { + throw conflict("当前派送状态不允许通过签收文件完成"); + } + order.setStatus("DELIVERED"); + order.setSignedAt(now); + } orders.updateById(order); + if (autoCompleted) { + List completedOrders = completeRelatedOrders(order, now); + deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); + customerVisits.ensureForCompletedDelivery(order, now); + } Detail result = detail(order, false); DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); @@ -79,6 +101,7 @@ public class DeliveryDocumentService { operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); operation.setCreatedAt(now); operations.insert(operation); + if (autoCompleted) recordAutomaticCompletion(order, operatorId, now); return result; } public List history(String customerId, String deliveryNo) { @@ -110,6 +133,25 @@ public class DeliveryDocumentService { (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + private List completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) { + List completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); + for (LastlegOrder order : completedOrders) { + order.setSubOrderStatus("DELIVERY:DELIVERED"); + order.setUpdatedAt(now); + lastlegOrders.updateById(order); + } + return completedOrders; + } + private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); + operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); + operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); + operation.setResult("签收文件保存成功,派送单自动完成"); + operation.setCreatedAt(now); operations.insert(operation); + } private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 9cb5713d1..f6bba2b50 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -134,10 +134,10 @@ public class DeliveryTransferService { OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (replay != null) { if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); - return result(replay, requireDelivery(customerId, replay.getId()), true); + return result(replay, requireDeliveries(customerId, replay.getId()), true); } OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true); + if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true); if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { throw conflict("Dispatch cannot be transferred to delivery in its current status"); } @@ -167,11 +167,11 @@ public class DeliveryTransferService { // the document created by the winning transaction rather than receive a false failure. OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { - return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); } concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); if (concurrent != null) { - return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); } throw conflict("Dispatch was concurrently transferred to delivery"); } @@ -195,43 +195,61 @@ public class DeliveryTransferService { outboundPallets.insert(item); } - DeliveryOrder delivery = new DeliveryOrder(); - delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); - delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); - delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); - delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); + Map> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy( + this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List deliveries = new ArrayList<>(); + int groupIndex = 0; + for (Map.Entry> entry : destinationGroups.entrySet()) { + List groupItems = entry.getValue(); + DispatchItem destination = groupItems.get(0); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); + delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId())); + delivery.setDestinationWarehouseCode(firstText( + firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), + destination.getDestinationWarehouseId())); + delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); + delivery.setDeliveryGroupKey(entry.getKey()); + LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); + delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); + delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); + delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); + delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); + delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); + delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); + delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); + delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + delivery.setPalletCount(groupItems.size()); + delivery.setTotalVolumeCbm(groupItems.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setTotalWeightKg(totalWeight(customerId, groupItems)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); + delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); + bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + deliveries.add(delivery); + } + DeliveryOrder feeDelivery = deliveries.get(0); LastlegOrder firstOrder = firstTailOrder(customerId, items); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); - delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); - delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); - delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); - delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); - delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); - delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); - delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); - delivery.setTotalVolumeCbm(items.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); - delivery.setTotalWeightKg(totalWeight(customerId, items)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); - delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); - delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); List feeInputs = request.fees() == null ? List.of() : request.fees(); for (DeliveryRequests.FeeItem input : feeInputs) { feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()), - new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.getDeliveryNo(), + new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(), + "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(), firstOrder == null ? null : firstOrder.getId(), null, firstOrder == null ? null : firstOrder.getInboundCode(), null, firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), - firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(delivery.getOverseasWarehouseId()), + firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()), input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(), input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), input.periodStart(), input.periodEnd())); } - recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now); - bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); - return result(outbound, delivery, false); + return result(outbound, deliveries, false); } private void validateTransferFees(DeliveryRequests.Transfer request) { @@ -622,17 +640,19 @@ public class DeliveryTransferService { || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { throw conflict("Outbound order cannot be cancelled after execution has started"); } - DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); - if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus())) + List existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); LocalDateTime now = LocalDateTime.now(); - DeliveryOrder delivery = existingDelivery; - if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } + for (DeliveryOrder delivery : existingDeliveries) { + delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); + delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + } OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } - if (delivery != null) { + if (!existingDeliveries.isEmpty()) { for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); @@ -772,13 +792,13 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); String customerId = outbound.getCustomerId(); - DeliveryOrder delivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); - if (delivery == null && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) { + List deliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (deliveries.isEmpty() && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) { throw new IllegalStateException("Delivery document is missing for dispatch outbound order"); } List pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream() .map(this::outboundPalletRow).toList(); - return new DeliveryResponses.OutboundDetail(outboundRow(outbound), delivery == null ? null : deliveryRow(delivery), pallets); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveries.stream().map(this::deliveryRow).toList(), pallets); } public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { @@ -801,7 +821,8 @@ public class DeliveryTransferService { public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("Delivery order not found"); - List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()); + List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream() + .filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList(); List orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); Map ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream() .filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order)); @@ -843,14 +864,16 @@ public class DeliveryTransferService { } } - private DeliveryOrder requireDelivery(String customerId, Long outboundId) { - DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId); - if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order"); - return value; + private List requireDeliveries(String customerId, Long outboundId) { + List values = deliveryOrders.lockByOutbound(customerId, outboundId); + if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order"); + return values; } - private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) { + private DeliveryResponses.TransferResult result(OutboundOrder o, List deliveries, boolean replayed) { return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), - d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); + o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery( + d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), + d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { @@ -864,12 +887,18 @@ public class DeliveryTransferService { candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), value(candidate.getAvailablePackages())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private String deliveryGroupKey(DispatchItem item) { + String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()); + if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT); + if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); + throw conflict("Dispatch pallet is missing its destination FBA warehouse"); + } private String resolveLxCodes(String customerId, List items) { List inboundCodes = items.stream().map(DispatchItem::getInboundCode) .filter(StringUtils::hasText).map(String::trim).distinct().toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index c0cb73682..279b9e728 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -16,6 +16,7 @@ public class UnloadCompletedOutboxRepository { private static final List OVERSEAS_TO_CN_TYPES = List.of( EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.WALLET_COMMAND_REQUESTED); @Resource private LastlegEventOutboxMapper mapper; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 53c2562a2..2f62b71d0 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -257,6 +257,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', + destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', @@ -289,7 +293,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key), KEY idx_ll_delivery_list (customer_id,status,created_at) ) ENGINE=InnoDB COMMENT='尾程派送单'; @@ -297,6 +301,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); +PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); +SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); +SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); +PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index 9bad68cfb..70ce3be2c 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -5,7 +5,7 @@ info: description: | 所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 - 状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。 + RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 5ed12e06f..150518692 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -620,11 +620,11 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: post: summary: 排车转出库与派送单 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车重复调用返回全部既有单据。派送费用只发布一次,避免按拆分派送单重复计费。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } - responses: { '200': { description: 出库与派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘或幂等冲突 } } + responses: { '200': { description: 一张出库单与按目的FBA仓拆分的派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘、目的FBA仓或幂等冲突 } } /api/platform/v1/last-leg/outbound-orders: get: summary: 分页查询出库单 @@ -664,7 +664,7 @@ paths: description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: '出库单、出库托盘及关联派送单详情;一件代发等独立出库无派送单时 delivery 为 null,排车转派送必须存在派送单;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } + responses: { '200': { description: '出库单、出库托盘及关联 deliveries 派送单数组;一件代发等独立出库时数组为空,排车转派送按目的FBA仓返回一项或多项;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: post: summary: 取消未执行出库单 @@ -676,7 +676,7 @@ paths: /api/platform/v1/last-leg/delivery-orders: get: summary: 分页查询平台派送单 - description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。每行返回服务端计算的 allowedActions,页面不得自行推导状态门禁。 + description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。同一排车按目的FBA仓显示为不同派送单,每行返回目的仓快照和服务端计算的 allowedActions,页面不得自行推导状态门禁;完成动作不再由人工触发。 security: [{ bearerAuth: [] }] responses: { '200': { description: 派送单分页 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}: @@ -685,7 +685,7 @@ paths: description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 派送单详情 }, '404': { description: 派送单不存在 } } + responses: { '200': { description: 派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓 }, '404': { description: 派送单不存在 } } /api/platform/v1/last-leg/exception-orders: get: summary: 分页查询异常订单 @@ -762,18 +762,10 @@ paths: parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } } responses: { '200': { description: 已创建退回异常单 } } - /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete: - post: - summary: 标记派送完成并生成回访记录 - description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。 - security: [{ bearerAuth: [] }] - parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } - responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: put: summary: 更新派送状态 - description: 权限 platform:overseas-warehouse:delivery:status。只允许 WAIT_DELIVERY→DELIVERING/DELIVERED/REJECTED/CANCELLED、DELIVERING→DELIVERED/REJECTED;同状态幂等,终态不可倒退。 + description: 权限 platform:overseas-warehouse:delivery:status。人工只允许 WAIT_DELIVERY→DELIVERING/REJECTED/CANCELLED、DELIVERING→REJECTED;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } @@ -2179,13 +2171,13 @@ components: type: object required: [status, expectedVersion] properties: - status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] } + status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED, CANCELLED] } expectedVersion: { type: integer, format: int64, minimum: 0 } remark: { type: string, nullable: true, maxLength: 500 } DeliveryAllowedAction: type: string - enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] - description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。 + enum: [START_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] + description: START/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。 DeliveryMarkReturn: type: object required: [inboundOrderId, reason, returnDirection, items, idempotencyKey] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java index b93b9a0ce..b0241ac14 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -6,7 +6,6 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -56,22 +55,12 @@ class CustomerVisitServiceTest { } @Test - void replaysDeliveredToDeliveredWithoutChangingDeliveryOrAddingHistory() { - DeliveryOrder delivery = delivery("DELIVERED"); - CustomerVisit visit = new CustomerVisit(); - visit.setId(33L); - visit.setStatus("PENDING"); - when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); - when(visits.findByDelivery("1001", 11L)).thenReturn(visit); + void rejectsManualDeliveryCompletionBeforeLoadingTheOrder() { + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("不支持"); - var result = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null)); - - assertThat(result.replayed()).isTrue(); - assertThat(result.deliveryStatus()).isEqualTo("DELIVERED"); - assertThat(result.visitId()).isEqualTo(33L); - verify(deliveryOrders, never()).updateById(delivery); - verifyNoInteractions(deliveryOperations); + verifyNoInteractions(deliveryOrders, deliveryOperations); } @Test @@ -89,26 +78,7 @@ class CustomerVisitServiceTest { } @Test - void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() { - DeliveryOrder delivery = delivery("DELIVERING"); - delivery.setVersion(1L); - LastlegOrder tail = new LastlegOrder(); - tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED"); - CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); - when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); - when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail)); - when(visits.findByDelivery("1001", 11L)).thenReturn(visit); - - var result = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)); - - assertThat(result.replayed()).isFalse(); - assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED"); - assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); - } - - @Test - void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() { + void startMeansDeliveringWithoutSigningOrCreatingVisit() { DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L); when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); var started = service.updateDeliveryStatus("1001", "9001", "DL-1", @@ -118,17 +88,7 @@ class CustomerVisitServiceTest { assertThat(delivery.getDispatchedAt()).isNotNull(); assertThat(delivery.getSignedAt()).isNull(); verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class)); - CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); - when(visits.findByDelivery("1001", 11L)).thenReturn(visit); - var completed = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)); - assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED"); - assertThat(delivery.getSignedAt()).isNotNull(); - assertThat(delivery.getVersion()).isEqualTo(2); - assertThat(completed.visitId()).isEqualTo(33); - assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue(); - verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery); + verify(deliveryOrders).updateById(delivery); } private DeliveryOrder delivery(String status) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index 609952846..0af56242b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -67,6 +67,6 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null)); + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 80f810b7e..00d4b5cad 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -35,7 +35,9 @@ class DeliverySchemaContractTest { assertTrue(schema.contains("create table if not exists ll_delivery_order")); assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'")); assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)")); - assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)")); + assertTrue(schema.contains("unique key uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) not null")); + assertTrue(schema.contains("delivery_group_key varchar(255) not null")); } @Test @@ -53,7 +55,8 @@ class DeliverySchemaContractTest { assertTrue(createEnd > create); String definition = schema.substring(create, createEnd); for (String column : new String[] { - "overseas_warehouse_name", "customer_name", "waybill_no", "delivery_method", + "overseas_warehouse_name", "customer_name", "destination_warehouse_id", + "destination_warehouse_code", "destination_warehouse_name", "delivery_group_key", "waybill_no", "delivery_method", "carrier_name", "pickup_method", "loading_type", "vehicle_type", "vehicle_size", "vehicle_plate", "driver_name", "driver_phone", "appointment_account", "seller_name", "total_weight_kg", "total_volume_cbm", "planned_delivery_at", "dispatched_at", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java index 69dc21cf3..94c949b2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -8,10 +8,11 @@ class DeliveryStatusPolicyTest { @Test void exposesOnlyServerAuthorizedActionsForEachState() { assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) - .containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + .containsExactly("START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) - .containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + .containsExactly("REJECT_DELIVERY", "MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); + assertThat(DeliveryStatusPolicy.isEditableTarget("DELIVERED")).isFalse(); assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty(); assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java new file mode 100644 index 000000000..1251a4f31 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class DeliveryCompletedOutboxServiceTest { + @Test + void emitsOneEventPerSourceInboundAcrossFbaWarehouses() throws Exception { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", json); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(1L); delivery.setDeliveryNo("DL-1"); delivery.setVersion(2L); + + service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), + order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z")); + + ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper, times(2)).insert(rows.capture()); + List payloads = rows.getAllValues().stream().map(row -> { + try { + EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class); + return json.treeToValue(envelope.payload(), OverseasDeliveryCompletedV1.class); + } catch (Exception exception) { throw new RuntimeException(exception); } + }).toList(); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::sourceInboundId) + .containsExactly("101", "102"); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::fbaWarehouseCode) + .containsExactly("ONT8", "ACY2"); + } + + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { + LastlegOrder value = new LastlegOrder(); + value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); + value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); + value.setSourceOrderId(orderId); value.setSourceOrderNo("FL-" + orderId); + value.setRootOrderId(orderId); value.setFbaWarehouseCode(fba); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index c99dc56cb..ad3d0bcee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -5,6 +5,9 @@ import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,6 +29,9 @@ class DeliveryDocumentServiceTest { @Mock DeliveryDocumentRevisionMapper revisions; @Mock DeliveryOperationMapper operations; @Mock DeliveryDocumentFileGateway files; + @Mock LastlegOrderMapper lastlegOrders; + @Mock DeliveryCompletedOutboxService deliveryCompletedOutbox; + @Mock CustomerVisitService customerVisits; DeliveryDocumentService service; DeliveryOrder order; @BeforeEach void setUp() { @@ -35,6 +41,9 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"operations",operations); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders); + ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox); + ReflectionTestUtils.setField(service,"customerVisits",customerVisits); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); } @@ -121,6 +130,27 @@ class DeliveryDocumentServiceTest { assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3); assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED")); } + @Test void savingReceiptAutomaticallyCompletesOnlyThisDeliveryAndItsBoundOrders() { + order.setStatus("WAIT_DELIVERY"); + LastlegOrder related = new LastlegOrder(); related.setId(20L); related.setCustomerId("100"); + related.setDeliveryOrderNo("DL-1"); related.setSubOrderStatus("DELIVERY:WAIT_DELIVERY"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(lastlegOrders.selectList(any())).thenReturn(List.of(related)); + + service.save("100","DL-1","9",request(0,"receipt",List.of(new Binding(RECEIPT,List.of(13L))))); + + assertThat(order.getStatus()).isEqualTo("DELIVERED"); + assertThat(order.getSignedAt()).isNotNull(); + assertThat(related.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); + verify(lastlegOrders).updateById(related); + verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any()); + verify(customerVisits).ensureForCompletedDelivery(eq(order), any()); + ArgumentCaptor operation = ArgumentCaptor.forClass(DeliveryOperation.class); + verify(operations, times(2)).insert(operation.capture()); + assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) + .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); + } private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index c20e27749..78a6a9895 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -117,7 +117,7 @@ class DeliveryTransferServiceTest { } @Test - void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { + void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( @@ -132,7 +132,7 @@ class DeliveryTransferServiceTest { ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); ArgumentCaptor pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); verify(outboundOrders).insert(outbound.capture()); - verify(deliveryOrders).insert(delivery.capture()); + verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(delivery.capture()); verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); @@ -140,15 +140,18 @@ class DeliveryTransferServiceTest { assertEquals(20, outbound.getValue().getPackageCount()); assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}")); - assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); - assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); - assertTrue(delivery.getValue().getDeliveryNo().matches("DL-WH-\\d{8}-\\d{6}")); + assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId()))); + assertEquals(List.of("ONT8", "LAX9"), delivery.getAllValues().stream().map(DeliveryOrder::getDestinationWarehouseCode).toList()); + assertEquals(List.of(12, 8), delivery.getAllValues().stream().map(DeliveryOrder::getPackageCount).toList()); + assertTrue(delivery.getAllValues().stream().allMatch(value -> "WAIT_DELIVERY".equals(value.getStatus()))); assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() .map(OutboundPalletItem::getInboundCode).toList()); assertEquals(20, result.packageCount()); assertEquals(2, result.palletCount()); + assertEquals(2, result.deliveries().size()); + verify(feeSources, org.mockito.Mockito.times(1)).publishApproved(any(), any(), any()); assertTrue(!result.replayed()); } @@ -193,14 +196,14 @@ class DeliveryTransferServiceTest { DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest(" transfer-101 ", null)); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); - assertEquals("DEL-401", result.deliveryNo()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); verify(outboundOrders, never()).insert((OutboundOrder) any()); verify(outboundPallets, never()).insert((OutboundPalletItem) any()); verify(deliveryOrders, never()).insert((DeliveryOrder) any()); @@ -230,14 +233,14 @@ class DeliveryTransferServiceTest { item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null)); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); - assertEquals("DEL-401", result.deliveryNo()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); verify(outboundPallets, never()).insert((OutboundPalletItem) any()); verify(deliveryOrders, never()).insert((DeliveryOrder) any()); } @@ -292,7 +295,7 @@ class DeliveryTransferServiceTest { DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); assertEquals("OUTBOUNDED", detail.outbound().status()); - org.junit.jupiter.api.Assertions.assertNull(detail.delivery()); + org.junit.jupiter.api.Assertions.assertTrue(detail.deliveries().isEmpty()); assertEquals("IN-A", detail.pallets().get(0).inboundCode()); assertEquals(4, detail.pallets().get(0).packageCount()); } @@ -312,13 +315,13 @@ class DeliveryTransferServiceTest { OutboundPalletItem pallet = pallet(501L, "IP-ONT8-20260907-000001-P001", "ONT8", "Ontario", "IN-A", 12); pallet.setSkuCode("SKU-1"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet)); DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); assertEquals("OUT-301", detail.outbound().outboundNo()); - assertEquals("DEL-401", detail.delivery().deliveryNo()); + assertEquals("DEL-401", detail.deliveries().get(0).deliveryNo()); assertEquals(1, detail.pallets().size()); assertEquals("IP-ONT8-20260907-000001-P001", detail.pallets().get(0).palletNo()); assertEquals("IN-A", detail.pallets().get(0).inboundCode()); @@ -333,7 +336,7 @@ class DeliveryTransferServiceTest { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); @@ -361,13 +364,13 @@ class DeliveryTransferServiceTest { when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "merchant-key")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest(" merchant-key ", null)); assertTrue(result.replayed()); - assertEquals("DEL-401", result.deliveryNo()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); verify(outboundOrders, never()).insert((OutboundOrder) any()); } @@ -396,6 +399,24 @@ class DeliveryTransferServiceTest { verify(dispatchItems, never()).findByDispatchOrder(any(), any()); } + @Test + void deliveryDetailAndBolSourceRowsContainOnlyItsDestinationFbaWarehouse() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder ont8 = delivery(401L, outbound, "DEL-ONT8"); + DispatchItem ont8Item = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + ont8Item.setOrderId(901L); + DispatchItem lax9Item = item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); + lax9Item.setOrderId(902L); + when(deliveryOrders.findByNo("TENANT-A", "DEL-ONT8")).thenReturn(ont8); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(ont8Item, lax9Item)); + when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of()); + + DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8"); + + assertEquals("ONT8", detail.order().destinationWarehouseCode()); + verify(tailOrders).selectBatchIds(List.of(901L)); + } + @Test void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); @@ -838,6 +859,8 @@ class DeliveryTransferServiceTest { value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo()); value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo()); value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY"); + value.setDestinationWarehouseId("DEST-ONT8"); value.setDestinationWarehouseCode("ONT8"); + value.setDestinationWarehouseName("Ontario"); value.setDeliveryGroupKey("CODE:ONT8"); value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount()); value.setCreatedAt(LocalDateTime.now()); return value; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index bd39901b4..c0d40b167 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -75,4 +75,34 @@ class DispatchBolPrintServiceTest { assertThat(document.shipFromAddress()).isEqualTo( "10767 7th Street, Rancho Cucamonga, California 91730, US")); } + + @Test + void createsOneBolDocumentPerFbaWarehouse() { + String warehouseId = "193000000000200003"; + DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", + null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, + null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", + 2, 2, 2, 0, 0, 2, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), + List.of( + new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", + "NYEXX2609110214", null, null, 1), + new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", + "NYEXX2609110215", null, null, 1))); + var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) + .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); + when(print.generateDispatchBol(eq("internal-test-token"), eq("request-2"), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count", "2").body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + + service.generate(detail.dispatchNo(), "request-2"); + + ArgumentCaptor request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-2"), request.capture()); + assertThat(request.getValue().documents().stream() + .map(PrintPdfClient.Document::fbaWarehouseCode).toList()) + .containsExactly("ABE2", "ONT8"); + } } diff --git a/order/AGENTS.md b/order/AGENTS.md index c7699e277..045a0f685 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -19,6 +19,7 @@ | 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg | | 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | +| 海外派送完成内部回写 | `controller/InternalOrderFulfillmentCompletionController.java`、`service/OrderFulfillmentCompletionService.java`;只接受 Firstleg 内部服务身份和已校验的来源订单/入仓码快照,幂等将对应订单推进为 `COMPLETED` 并写状态历史 | | 商户订单列表、批量草稿与中文 XLSX 导入 | `service/MerchantInboundOrderListService.java`、`MerchantBatchDraftService.java`、`MerchantBatchDraftItemService.java`、`MerchantBatchImportService.java`;动态目录客户端 `client/MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`,客户地址由 `CustomerAddressService.java` 提供;接口见 `controller/MerchantFirstLegOrderController.java` 的 `/batch-imports/template`、`/batch-imports` | | 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` | | 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java new file mode 100644 index 000000000..d45535f0a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java @@ -0,0 +1,36 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.service.OrderFulfillmentCompletionService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequiredArgsConstructor +public class InternalOrderFulfillmentCompletionController { + private final OrderFulfillmentCompletionService service; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + + @PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + + @PostMapping("/internal/order/v1/fulfillment-completions") + public ApiResponse complete( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody OrderFulfillmentCompletionService.Request request) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + return ApiResponse.success(service.complete(request)); + } +} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java index e7c88dec6..2fccbe52a 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java @@ -12,6 +12,9 @@ import java.util.Map; */ @Mapper public interface OrderMapper extends BaseMapper{ + @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE") + OrderEntity lockOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select(""" SELECT COUNT(*) AS total_submitted_orders, COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart} diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java new file mode 100644 index 000000000..899b20ae4 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java @@ -0,0 +1,76 @@ +package com.baiyue.order.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.entity.OrderStatusHistoryEntity; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.mapper.OrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.Set; + +/** 接受国内 Firstleg 验证后的海外派送完成事实,推进对应订单。 */ +@Service +public class OrderFulfillmentCompletionService { + private static final Set COMPLETABLE = Set.of( + OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING); + @Resource private OrderMapper orders; + @Resource private OrderStatusHistoryMapper histories; + + @Transactional + public Result complete(Request request) { + long tenantId = positive(request.tenantId(), "客户 ID 无效"); + long orderId = positive(request.orderId(), "订单 ID 无效"); + OrderEntity order = orders.lockOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在"); + if (!Objects.equals(order.getOrderNo(), request.orderNo()) + || !Objects.equals(order.getInboundCode(), request.inboundCode())) { + throw new BizException(ResultCode.CONFLICT, "派送完成事件与来源订单标识不一致"); + } + if (order.getStatus() == OrderStatus.COMPLETED) { + return new Result(orderId, order.getStatus().name(), true); + } + if (!COMPLETABLE.contains(order.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus()); + } + OrderStatus from = order.getStatus(); + LocalDateTime completedAt = LocalDateTime.ofInstant(request.completedAt(), ZoneOffset.UTC); + int version = order.getVersion() == null ? 0 : order.getVersion(); + int updated = orders.update(null, new LambdaUpdateWrapper() + .eq(OrderEntity::getTenantId, tenantId).eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getVersion, version).eq(OrderEntity::getDeleted, false) + .set(OrderEntity::getStatus, OrderStatus.COMPLETED) + .set(OrderEntity::getCompletedAt, completedAt) + .set(OrderEntity::getUpdatedBy, 0L).set(OrderEntity::getUpdatedAt, completedAt) + .setSql("version=version+1")); + if (updated != 1) throw new BizException(ResultCode.CONFLICT, "来源订单版本已变化,请重试"); + OrderStatusHistoryEntity history = new OrderStatusHistoryEntity(); + history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setOrderId(orderId); + history.setFromStatus(from); history.setToStatus(OrderStatus.COMPLETED); + history.setAction("OVERSEAS_DELIVERY_COMPLETED"); + history.setReason("海外派送单 " + request.deliveryNo() + " 已签收"); + history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE"); + history.setOccurredAt(completedAt); histories.insert(history); + return new Result(orderId, OrderStatus.COMPLETED.name(), false); + } + + private static long positive(String value, String message) { + try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } + catch (RuntimeException ignored) { } + throw new BizException(ResultCode.BAD_REQUEST, message); + } + + public record Request(String tenantId, String orderId, String orderNo, String inboundCode, + String deliveryNo, java.time.Instant completedAt, String eventId, + String payloadHash) { } + public record Result(Long orderId, String status, boolean replayed) { } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b81801bef..22662a888 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,37 @@ info: security: - bearerAuth: [] paths: + /internal/order/v1/fulfillment-completions: + post: + security: [] + summary: Firstleg 回传海外派送完成并结束对应订单 + description: >- + 仅接受内部服务令牌。请求必须携带海外事件已验证的来源订单 ID、订单号、入仓码、 + FBA 派送单号和完成时间;只推进该派送单关联的订单,COMPLETED 重放返回成功。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, orderId, orderNo, inboundCode, deliveryNo, completedAt, eventId, payloadHash] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string} + inboundCode: {type: string} + deliveryNo: {type: string} + completedAt: {type: string, format: date-time} + eventId: {type: string, format: uuid} + payloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} + responses: + '200': {description: 对应订单已完成或幂等重放成功} + '403': {description: 内部服务令牌无效} /internal/order/v1/cargoes/{tenantId}/{orderId}: get: security: [] diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index ff5986b42..1492d7fcd 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test' import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingAgent, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') -const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' } +const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', warehouseId: '41', warehouseName: '深圳仓' } test('fixture resolves current-marker customer channel and warehouse without inventing data', async () => { const steps = [] @@ -22,6 +22,7 @@ test('fixture resolves current-marker customer channel and warehouse without inv test('shared DRAFT uses requirements and never registers a real container number', () => { const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master) assert.equal(draft.channelName, undefined) + assert.equal(draft.channelCode, 'MATSON_CLX') assert.equal(draft.warehouseName, undefined) assert.deepEqual(draft.containerRequirements, [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }]) assert.equal(Object.hasOwn(draft, 'containers'), false) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 81effcb90..642275274 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -1,3 +1,5 @@ +import { channelGroup } from './smart-loading-channel-group.mjs' + function text(value) { return value == null ? '' : String(value).trim() } const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels' @@ -73,6 +75,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { }] return { ...writableMaster, + channelCode: channelGroup(writableMaster.channelCode), billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`, carrierCode: labels.carrierCode || 'OTHER', shippingCompanyId: labels.shippingCompanyId ?? null, diff --git a/scripts/admin-test-env-modules/smart-loading-channel-group.mjs b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs index dacbfbdef..1da739ef1 100644 --- a/scripts/admin-test-env-modules/smart-loading-channel-group.mjs +++ b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs @@ -1,5 +1,7 @@ export function channelGroup(channelCode) { const value = String(channelCode || '').trim().toUpperCase() + if (['MATSON_CLX', 'MATSON_MAX', 'EXX', 'HEDE', 'OA_LA', 'OA_CHICAGO', + 'OA_HOUSTON', 'OA_SAVANNAH', 'OA_NEW_YORK_TRUCK'].includes(value)) return value if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX' if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX' if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX'