优化
This commit is contained in:
@@ -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";
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 海外仓卸柜完成事实;预计报空时间由头程按照片上传时间加两天计算。 */
|
||||
public record OverseasContainerUnloadedV1(
|
||||
String containerNo,
|
||||
Instant unloadingCompletedAt,
|
||||
Instant unloadingPhotoUploadedAt,
|
||||
Instant actualEmptyReportedAt
|
||||
) {
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:前缀';
|
||||
|
||||
+40
@@ -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<ContainerBookingEntity> 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, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -107,6 +107,24 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
|
||||
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
|
||||
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET unloading_completed_at=#{unloadingCompletedAt},
|
||||
unloading_photo_uploaded_at=#{photoUploadedAt}, estimated_empty_reported_at=#{estimatedEmptyReportedAt},
|
||||
actual_empty_reported_at=#{actualEmptyReportedAt}, empty_report_source_version=#{sourceVersion},
|
||||
updated_by=0, updated_by_name='OVERSEAS_EVENT', updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{id} AND record_status='ACTIVE'
|
||||
AND (empty_report_source_version IS NULL OR empty_report_source_version < #{sourceVersion})
|
||||
""")
|
||||
int applyEmptyReport(@Param("id") long id,
|
||||
@Param("unloadingCompletedAt") java.time.LocalDateTime unloadingCompletedAt,
|
||||
@Param("photoUploadedAt") java.time.LocalDateTime photoUploadedAt,
|
||||
@Param("estimatedEmptyReportedAt") java.time.LocalDateTime estimatedEmptyReportedAt,
|
||||
@Param("actualEmptyReportedAt") java.time.LocalDateTime actualEmptyReportedAt,
|
||||
@Param("sourceVersion") long sourceVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
|
||||
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerEventInboxMapper extends BaseMapper<ContainerEventInboxEntity> {
|
||||
}
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}
|
||||
|
||||
+108
@@ -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<ContainerEventInboxEntity> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+9
@@ -105,6 +105,15 @@ public class InboundPdaController {
|
||||
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
|
||||
}
|
||||
|
||||
@PostMapping("/unload-photos")
|
||||
public ApiResponse<PdaResponses.Task> 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) {
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
|
||||
LocalDateTime estimatedEmptyReturnAt) {}
|
||||
|
||||
public record Operation(String result, long allocationId, int requiredPackageCount,
|
||||
int countedPackageCount, int pickedPackageCount,
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+39
-1
@@ -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<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
|
||||
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|
||||
|| task.getEstimatedEmptyReturnAt() == null) {
|
||||
throw conflict("Upload unload photos before completing the PDA inbound task");
|
||||
}
|
||||
Map<Long, Integer> pickedByLine = new HashMap<>();
|
||||
List<InboundPlanPalletAllocation> 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<Long> 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<Long> unloadPhotoFileIds(InboundPdaTask task) {
|
||||
if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of();
|
||||
try {
|
||||
return objectMapper.readValue(task.getUnloadPhotoFileIds(), new TypeReference<List<Long>>() {});
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Cannot read unload photo file IDs", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal perPackageVolume(InboundPlanLine line) {
|
||||
|
||||
+68
@@ -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<Long> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<LastlegEventOutbox> {
|
||||
}
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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 '业务幂等键',
|
||||
|
||||
+48
@@ -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<InboundPdaOperation> 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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user