From b55b049e777332abb289c704794fad7a90838ea6 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 15:20:05 +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 --- .../com/baiyue/common/event/EventTypes.java | 1 + .../event/OverseasContainerUnloadedV1.java | 12 ++ .../OverseasContainerUnloadedV1Test.java | 24 ++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 33 ++++++ ...ernalOverseasContainerEventController.java | 40 +++++++ .../entity/ContainerBookingEntity.java | 5 + .../entity/ContainerEventInboxEntity.java | 24 ++++ .../mapper/ContainerBookingMapper.java | 18 +++ .../mapper/ContainerEventInboxMapper.java | 9 ++ .../OverseasContainerEventConsumer.java | 109 ++++++++++++++++++ .../db/phase3-smart-loading-schema.sql | 45 ++++++++ .../main/resources/openapi/firstleg-v1.yaml | 5 + .../OverseasContainerEventConsumerTest.java | 108 +++++++++++++++++ .../lastleg/entity/LastlegEventOutbox.java | 28 +++++ .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../pda/controller/InboundPdaController.java | 9 ++ .../inboundplan/pda/dto/PdaRequests.java | 6 + .../inboundplan/pda/dto/PdaResponses.java | 4 +- .../pda/entity/InboundPdaTask.java | 3 + .../service/InboundPdaWorkflowService.java | 40 ++++++- .../InboundUnloadCompletedOutboxService.java | 68 +++++++++++ .../mapper/LastlegEventOutboxMapper.java | 9 ++ .../service/LoadingCompletedConsumer.java | 3 +- .../src/main/resources/db/lastleg.sql | 27 +++++ .../InboundPdaWorkflowServiceTest.java | 48 ++++++++ .../inbound-measurement.mjs | 12 +- scripts/admin-test-env-modules/orders.mjs | 2 +- .../phase3-container-loading.mjs | 11 +- .../admin-test-env-modules/phase3-loading.mjs | 9 ++ scripts/db/incremental.sql | 26 +++++ 30 files changed, 727 insertions(+), 12 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerEventInboxMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java 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 ccee66d60..7c23de9c9 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 @@ -10,6 +10,7 @@ public final class EventTypes { public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java new file mode 100644 index 000000000..abf2ce607 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -0,0 +1,12 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 海外仓卸柜完成事实;预计报空时间由头程按照片上传时间加两天计算。 */ +public record OverseasContainerUnloadedV1( + String containerNo, + Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, + Instant actualEmptyReportedAt +) { +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java new file mode 100644 index 000000000..dc847949b --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -0,0 +1,24 @@ +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 OverseasContainerUnloadedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { + var payload = new OverseasContainerUnloadedV1("MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); + + String json = mapper.writeValueAsString(payload); + + assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); + assertThat(json).contains("MSCU1234567", "unloadingPhotoUploadedAt"); + } +} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c9b82ae1b..a4849b700 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15663,6 +15663,39 @@ CALL assert_iam_security_event_schema(); DROP PROCEDURE IF EXISTS assert_iam_security_event_schema; USE baiyue_firstleg; +DROP PROCEDURE IF EXISTS add_container_empty_report_fields; +DELIMITER $$ +CREATE PROCEDURE add_container_empty_report_fields() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; +END$$ +DELIMITER ; +CALL add_container_empty_report_fields(); +DROP PROCEDURE IF EXISTS add_container_empty_report_fields; + +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), KEY idx_container_event_aggregate (aggregate_id,aggregate_version) +) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox'; + -- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java new file mode 100644 index 000000000..54f1a67be --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.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.common.event.EventEnvelope; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.service.OverseasContainerEventConsumer; +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 InternalOverseasContainerEventController { + private final OverseasContainerEventConsumer consumer; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @PostMapping("/internal/firstleg/v1/container-events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + authenticate(token); + return ApiResponse.success(consumer.consume(event)); + } + + private void authenticate(String token) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 0d1ec3a42..59922f76e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -54,6 +54,11 @@ public class ContainerBookingEntity { private LocalDateTime actualPortArrivalAt; private LocalDateTime estimatedWarehouseArrivalAt; private LocalDateTime actualWarehouseArrivalAt; + private LocalDateTime unloadingCompletedAt; + private LocalDateTime unloadingPhotoUploadedAt; + private LocalDateTime estimatedEmptyReportedAt; + private LocalDateTime actualEmptyReportedAt; + private Long emptyReportSourceVersion; private Long createdBy; private String createdByName; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java new file mode 100644 index 000000000..3fc1cec69 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_container_event_inbox") +public class ContainerEventInboxEntity { + @TableId private String eventId; + private String eventType; + private Integer eventVersion; + private String aggregateId; + private Long aggregateVersion; + private String payloadHash; + private String payloadJson; + private String status; + private LocalDateTime receivedAt; + private LocalDateTime processedAt; +} 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 1ae3ac1da..da7d5450e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -107,6 +107,24 @@ public interface ContainerBookingMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java new file mode 100644 index 000000000..67748dd32 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -0,0 +1,109 @@ +package com.baiyue.firstleg.service; + +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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +@RequiredArgsConstructor +public class OverseasContainerEventConsumer { + private final ContainerBookingMapper containers; + private final ContainerEventInboxMapper inbox; + private final ObjectMapper objectMapper; + + @Transactional + public ContainerBookingEntity consume(EventEnvelope event) { + validateEnvelope(event); + OverseasContainerUnloadedV1 payload = payload(event); + if (payload.containerNo() == null || payload.containerNo().isBlank() + || !payload.containerNo().equals(event.aggregateId())) { + throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致"); + } + if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) { + throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空"); + } + if (payload.actualEmptyReportedAt() != null + && payload.actualEmptyReportedAt().isBefore(payload.unloadingPhotoUploadedAt())) { + throw new IllegalArgumentException("实际报空时间不能早于卸柜照片上传时间"); + } + + ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { + throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + } + return requiredContainer(payload.containerNo()); + } + ContainerEventInboxEntity row = inbox(event); + try { + inbox.insert(row); + } catch (DuplicateKeyException duplicate) { + ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); + if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; + return requiredContainer(payload.containerNo()); + } + + ContainerBookingEntity container = requiredContainer(payload.containerNo()); + LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); + containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, + uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + row.setStatus("PROCESSED"); + row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); + inbox.updateById(row); + return requiredContainer(payload.containerNo()); + } + + private void validateEnvelope(EventEnvelope event) { + event.requireValid(); + if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) { + throw new IllegalArgumentException("不支持的海外货柜事件"); + } + if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN + || !"CONTAINER_BOOKING".equals(event.aggregateType())) { + throw new IllegalArgumentException("海外货柜事件区域或聚合类型无效"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + } + } + + private OverseasContainerUnloadedV1 payload(EventEnvelope event) { + try { return objectMapper.treeToValue(event.payload(), OverseasContainerUnloadedV1.class); } + catch (Exception exception) { throw new IllegalArgumentException("海外货柜事件负载无效", exception); } + } + + 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(java.util.Locale.ROOT)); + row.setPayloadJson(event.payload().toString()); row.setStatus("RECEIVED"); + row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC)); + return row; + } + + private ContainerBookingEntity requiredContainer(String containerNo) { + ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo); + if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用"); + return value; + } + + private LocalDateTime utc(java.time.Instant value) { + return value == null ? null : LocalDateTime.ofInstant(value, ZoneOffset.UTC); + } +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 00e4d9dfd..5086fa25d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -72,6 +72,11 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)', + unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)', + estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)', + actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)', + empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -93,6 +98,21 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', + aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', + payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', + processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), + KEY idx_container_event_aggregate (aggregate_id, aggregate_version) +) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox'; + DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; DELIMITER $$ CREATE PROCEDURE phase3_container_real_unit_columns() @@ -112,6 +132,31 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL + COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL + COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL + COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL + COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL + COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 49f3f525c..6ce610218 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1525,6 +1525,11 @@ components: actualPortArrivalAt: {type: string, format: date-time, nullable: true} estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间} + unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间} + estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计报空时间,由卸柜照片上传成功时间加两天计算} + actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间} + emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]} exceptionActive: {type: boolean, description: 最新异常历史动作是否为 MARK} destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java new file mode 100644 index 000000000..390c16439 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -0,0 +1,108 @@ +package com.baiyue.firstleg.service; + +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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OverseasContainerEventConsumerTest { + @Mock private ContainerBookingMapper containers; + @Mock private ContainerEventInboxMapper inbox; + private ObjectMapper objectMapper; + private OverseasContainerEventConsumer consumer; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + consumer = new OverseasContainerEventConsumer(containers, inbox, objectMapper); + } + + @Test + void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() { + ContainerBookingEntity container = container(); + when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container); + Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); + Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); + + consumer.consume(event("00000000-0000-4000-8000-000000000001", 7, + new OverseasContainerUnloadedV1("MSCU1234567", completedAt, uploadedAt, null))); + + verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), + eq(LocalDateTime.parse("2026-08-20T02:30:00")), + eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class); + verify(inbox).insert(captor.capture()); + assertThat(captor.getValue().getAggregateId()).isEqualTo("1001"); + assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED"); + verify(inbox).updateById(captor.getValue()); + } + + @Test + void repeatedEventWithSameHashDoesNotApplyAgain() { + OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); + EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); + ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); + existing.setPayloadHash(event.payloadHash()); + when(inbox.selectById(event.eventId())).thenReturn(existing); + when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container()); + + consumer.consume(event); + + verify(containers, never()).applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong()); + verify(inbox, never()).insert(any(ContainerEventInboxEntity.class)); + } + + @Test + void rejectsTamperedPayloadHash() { + EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7, + new OverseasContainerUnloadedV1("MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), + Instant.parse("2026-08-20T02:30:00Z"), null)); + EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), + valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), + valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), + valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> consumer.consume(tampered)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("payloadHash"); + verifyNoInteractions(containers, inbox); + } + + private EventEnvelope event(String eventId, long aggregateVersion, OverseasContainerUnloadedV1 payload) { + var node = objectMapper.valueToTree(payload); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, + Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", + "CONTAINER_BOOKING", payload.containerNo(), aggregateVersion, null, + "trace-12345678", "correlation-12345678", null, node, hash); + } + + private ContainerBookingEntity container() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(1001L); value.setContainerNo("MSCU1234567"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java new file mode 100644 index 000000000..a1179f95d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_event_outbox") +public class LastlegEventOutbox { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String aggregateType; + private String aggregateId; + private String eventType; + private Integer eventVersion; + private String eventKey; + private String payloadJson; + private String status; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private LocalDateTime publishedAt; + private String lastError; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 4337ed4e0..903ab3afb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -56,6 +56,7 @@ public class LastlegOrder { private String warehouseRemark; private String sourceEventId; private String sourceLoadingPlanId; + private String sourceContainerBookingId; private Long sourceLoadingPlanVersion; private Long sourceHandoverVersion; private String sourcePayloadHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index b4a508bca..9cc116ea1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -105,6 +105,15 @@ public class InboundPdaController { return ApiResponse.success(service.complete(scope(user, customerId), planNo)); } + @PostMapping("/unload-photos") + public ApiResponse uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.UnloadPhotos request) { + requireOperate(user); + return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java index 685ccf911..018e04692 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -1,8 +1,11 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; +import java.util.List; + public final class PdaRequests { private PdaRequests() {} @@ -22,4 +25,7 @@ public final class PdaRequests { public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record UnloadPhotos(@NotEmpty List<@Positive Long> fileIds, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 7a95472ca..0c6a2bb40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -8,7 +8,9 @@ public final class PdaResponses { private PdaResponses() {} public record Task(long taskId, String taskNo, String planNo, String status, - LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {} + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt) {} public record Operation(String result, long allocationId, int requiredPackageCount, int countedPackageCount, int pickedPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java index 753cd2fe2..42dad8e55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java @@ -21,6 +21,9 @@ public class InboundPdaTask { private LocalDateTime pushedAt; private LocalDateTime startedAt; private LocalDateTime completedAt; + private String unloadPhotoFileIds; + private LocalDateTime unloadPhotoUploadedAt; + private LocalDateTime estimatedEmptyReturnAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; private Integer version; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index b09a218c7..d77ec080a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.core.type.TypeReference; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +45,7 @@ public class InboundPdaWorkflowService { @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; + @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -372,6 +374,11 @@ public class InboundPdaWorkflowService { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); if (task == null) throw conflict("Push the inbound plan to PDA before operating"); if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + List unloadPhotoFileIds = unloadPhotoFileIds(task); + if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null + || task.getEstimatedEmptyReturnAt() == null) { + throw conflict("Upload unload photos before completing the PDA inbound task"); + } Map pickedByLine = new HashMap<>(); List allocations = repository.allocations(plan.getId()); for (InboundPlanPalletAllocation allocation : allocations) { @@ -402,9 +409,30 @@ public class InboundPdaWorkflowService { plan.setStatus("COMPLETED"); plan.setUpdatedAt(now); repository.update(plan); + unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); return task(task, planNo); } + @Transactional + public PdaResponses.Task uploadUnloadPhotos(String customerId, String planNo, PdaRequests.UnloadPhotos request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + List fileIds = request.fileIds().stream().distinct().sorted().toList(); + if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + LocalDateTime uploadedAt = LocalDateTime.now(); + task.setUnloadPhotoFileIds(write(fileIds)); + task.setUnloadPhotoUploadedAt(uploadedAt); + task.setEstimatedEmptyReturnAt(uploadedAt.plusDays(2)); + task.setUpdatedAt(uploadedAt); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + PdaResponses.Task result = task(task, planNo); + saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result); + return result; + } + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, int packageCount, BigDecimal movedVolume) { source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); @@ -582,7 +610,17 @@ public class InboundPdaWorkflowService { private PdaResponses.Task task(InboundPdaTask task, String planNo) { return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), - task.getPushedAt(), task.getStartedAt(), task.getCompletedAt()); + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt()); + } + + private List unloadPhotoFileIds(InboundPdaTask task) { + if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of(); + try { + return objectMapper.readValue(task.getUnloadPhotoFileIds(), new TypeReference>() {}); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read unload photo file IDs", exception); + } } private BigDecimal perPackageVolume(InboundPlanLine line) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java new file mode 100644 index 000000000..5a502dab5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.inboundplan.pda.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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +/** Writes the overseas-to-CN handover to the local transactional outbox. */ +@Service +public class InboundUnloadCompletedOutboxService { + @Resource private LastlegEventOutboxMapper mapper; + @Resource private LastlegOrderMapper orders; + @Resource private ObjectMapper objectMapper; + + public void publish(InboundPlan plan, InboundPdaTask task, List photoFileIds) { + LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId()); + if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null + || sourceOrder.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); + var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(), + plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null); + JsonNode payloadNode = objectMapper.valueToTree(payload); + long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion()); + String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); + String traceId = EventIdentifiers.newUuid(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, + 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", + sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); + + LastlegEventOutbox event = new LastlegEventOutbox(); + event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId()); + event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); + event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); + event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); + event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + mapper.insert(event); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize unload-completed event", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java new file mode 100644 index 000000000..c7871d177 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegEventOutboxMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 3d076a2b3..cf841602a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -120,7 +120,8 @@ public class LoadingCompletedConsumer { order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); - order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId()); + order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); try { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 67935ab04..8e3682ab8 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', source_handover_version BIGINT COMMENT '交接版本', source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -163,6 +164,8 @@ CREATE TABLE IF NOT EXISTS ll_order_status_history ( -- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; ALTER TABLE ll_order COMMENT='尾程订单主表'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -237,6 +240,7 @@ ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -410,6 +414,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( -- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。 ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt; ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', @@ -491,12 +497,33 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + unload_photo_file_ids JSON NULL COMMENT '卸柜照片文件ID列表', unload_photo_uploaded_at DATETIME(3) NULL COMMENT '卸柜照片上传成功时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (customer_id, plan_id), UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) ) ENGINE=InnoDB COMMENT='入仓PDA作业任务'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 950a00202..6caff0876 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -22,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -42,6 +43,7 @@ class InboundPdaWorkflowServiceTest { @Mock PdaWorkflowRepository repository; @Mock WarehouseResourceGateway warehouse; @Mock InboundStockReceiptService stockReceipts; + @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -61,6 +63,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -300,6 +303,47 @@ class InboundPdaWorkflowServiceTest { verify(repository, never()).update(plan); } + @Test + void completionRequiresSuccessfullyUploadedUnloadPhotos() { + activeTask(); + task.setUnloadPhotoFileIds(null); + task.setUnloadPhotoUploadedAt(null); + task.setEstimatedEmptyReturnAt(null); + + assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1")); + + verify(repository, never()).allocations(plan.getId()); + verify(unloadCompletedOutbox, never()).publish(any(), any(), any()); + } + + @Test + void uploadUnloadPhotosRecordsEstimatedEmptyReturnTwoDaysLaterAndIsIdempotent() { + activeTask(); + PdaRequests.UnloadPhotos request = new PdaRequests.UnloadPhotos(List.of(103L, 101L), + "unload-photo-1", "7", "worker"); + + var result = service.uploadUnloadPhotos("100", "LIP1", request); + + assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); + assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt()); + verify(repository).update(task); + ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(operation.capture()); + when(repository.operation("100", "unload-photo-1")).thenReturn(operation.getValue()); + + var replay = service.uploadUnloadPhotos("100", "LIP1", request); + assertEquals(result, replay); + verify(repository, times(1)).update(task); + } + + @Test + void uploadUnloadPhotosRejectsDuplicateFileIds() { + activeTask(); + assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1", + new PdaRequests.UnloadPhotos(List.of(101L, 101L), "unload-photo-duplicate", "7", "worker"))); + verify(repository, never()).update(task); + } + @Test void completesWhenMergedAllocationsPreserveExactLineTotals() { activeTask(); @@ -316,6 +360,7 @@ class InboundPdaWorkflowServiceTest { verify(repository).update(plan); verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), eq(List.of(target, moved)), any(), any()); + verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L))); } @Test @@ -387,6 +432,9 @@ class InboundPdaWorkflowServiceTest { value.setCustomerId("100"); value.setTaskNo("PDA2"); value.setStatus("PDA_PENDING"); + value.setUnloadPhotoFileIds("[101]"); + value.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); + value.setEstimatedEmptyReturnAt(LocalDateTime.of(2026, 8, 22, 10, 0)); value.setVersion(0); return value; } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 32e0da358..c19b7765f 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1,10 +1,12 @@ -import { readFile } from 'node:fs/promises' - const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const WEIGHING_IMAGE_COUNT = 1 const FIXTURE_PALLET_CAPACITY_CBM = 1.2 +const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, + 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, + 8, 215, 99, 248, 207, 192, 240, 31, 0, 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, + 73, 69, 78, 68, 174, 66, 96, 130]) function asId(value) { return value == null ? '' : String(value) } function asNumber(value) { return Number(value) } @@ -77,10 +79,9 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa } async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) { - const image = await readFile(new URL('../../../apps/pda-uniapp/static/c7.png', import.meta.url)) const uploadResponse = await context.requestMultipart('POST', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { - content: image, type: 'image/png', name: `weight-${inboundId}.png`, + content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}.png`, }) const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, `inboundId=${inboundId}`, OPTIONS) @@ -153,8 +154,7 @@ function findIsolatedInbound(context) { async function multipartRequest(context, path, data) { const form = new FormData() form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })) - const image = await readFile(new URL('../../../apps/pda-uniapp/static/c7.png', import.meta.url)) - form.append('image', new Blob([image], { type: 'image/png' }), 'carton-evidence.png') + form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png') try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index b0bf0ce9e..a7a7c3fd8 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -17,7 +17,7 @@ const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) => function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) } -function success(response) { return response.http === 200 && String(response.body?.code) === '200' } +function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 188cbf5ae..8c24db7ff 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -144,6 +144,13 @@ export async function run(context) { OPTIONS) if (!selectedChannel) return const channelName = selectedChannel.channelName + const supplierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS) + const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name) + context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, + Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) + if (!supplier?.id) return const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 } const realContainerNo = `TST-${suffix}-01`.slice(0, 64) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) @@ -151,8 +158,8 @@ export async function run(context) { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), - shippingCompanyId: null, shippingCompanyName: null, - bookingAgentId: null, bookingAgentName: null, + shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name, + bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c1dc96751..9f8be083d 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -123,11 +123,20 @@ export async function run(context) { const containerNo = `P3B-${suffix}`.slice(0, 64) const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading' + const supplierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS) + const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name) + context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, + Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) + if (!supplier?.id) return const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), + shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name, + bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 8253ca30c..298df8d32 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3551,3 +3551,29 @@ PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE P -- ============================================================================ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); PREPARE ll_order_sub_status_026 FROM @ddl; EXECUTE ll_order_sub_status_026; DEALLOCATE PREPARE ll_order_sub_status_026; + +-- ============================================================================ +-- 2026-08-20 / 027 +-- One tail-leg inbound plan per source tail-leg order; one container may carry many plans. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NULL COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE ll_inbound_plan_source_order_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_027; + +-- Legacy plans were created per container and cannot be inferred back to a source order. +UPDATE ll_inbound_plan SET source_order_id=NULL WHERE source_order_id=0; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'), + 'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1')); +PREPARE ll_inbound_plan_drop_container_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_drop_container_unique_027; DEALLOCATE PREPARE ll_inbound_plan_drop_container_unique_027; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_source_order'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); +PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027;