feat: enforce canonical container identity across regions

This commit is contained in:
2026-08-31 12:13:20 +08:00
parent b386f35ab4
commit 942dbace19
78 changed files with 1203 additions and 730 deletions
@@ -0,0 +1,44 @@
package com.baiyue.common.event;
import java.util.Locale;
/** Canonical physical container number used by cross-region business facts. */
public final class ContainerNumbers {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private ContainerNumbers() { }
public static String normalizeRequired(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("containerNo is required");
}
StringBuilder compact = new StringBuilder(value.length());
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
String normalized = compact.toString().toUpperCase(Locale.ROOT);
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = normalized.charAt(index);
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
}
return normalized;
}
public static String requireCanonical(String value) {
String normalized = normalizeRequired(value);
if (!normalized.equals(value)) {
throw new IllegalArgumentException("containerNo must be normalized");
}
return normalized;
}
}
@@ -1,5 +1,6 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
String queryId, String queryId,
String customerId, String customerId,
String oceanBookingId, String oceanBookingId,
String containerBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String containerNo,
String standardNode, String standardNode,
int nodeSequence, int nodeSequence,
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|| containerNo == null || containerNo.isBlank()) { || containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空"); throw new IllegalArgumentException("船节点交接业务标识不能为空");
} }
containerNo = ContainerNumbers.requireCanonical(containerNo);
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) { || observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
@@ -1,14 +1,26 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */ /** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
public record FirstLegLoadingPlanCompletedV2( public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId, String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName, String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { List<Order> orders) {
public FirstLegLoadingPlanCompletedV2 {
if (loadingPlanId == null || loadingPlanId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()) {
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
orders = orders == null ? List.of() : List.copyOf(orders);
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
}
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId, public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) { String warehouseName, List<Order> orders) {
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
Instant unloadingPhotoUploadedAt, Instant unloadingPhotoUploadedAt,
Instant actualEmptyReportedAt Instant actualEmptyReportedAt
) { ) {
public OverseasContainerUnloadedV1 {
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
throw new IllegalArgumentException("sourceContainerBookingId is required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
}
} }
@@ -7,11 +7,11 @@ components:
schemas: schemas:
FirstLegLoadingPlanCompletedV2: FirstLegLoadingPlanCompletedV2:
type: object type: object
required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders] required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
properties: properties:
loadingPlanId: {type: string} loadingPlanId: {type: string}
containerBookingId: {type: string} sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
containerNo: {type: string} containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
cabinetNo: {type: string} cabinetNo: {type: string}
warehouseId: {type: string} warehouseId: {type: string}
warehouseName: {type: string} warehouseName: {type: string}
@@ -40,3 +40,29 @@ components:
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
pallets: {type: array, items: {type: object}} pallets: {type: array, items: {type: object}}
destinationType: {type: string, enum: [FBA, OVERSEAS]} destinationType: {type: string, enum: [FBA, OVERSEAS]}
FirstLegContainerShippingNodeRecordedV1:
type: object
required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
properties:
queryId: {type: string}
customerId: {type: string}
oceanBookingId: {type: string}
sourceContainerBookingId: {type: string, minLength: 1}
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
standardNode: {type: string}
nodeSequence: {type: integer}
observedAt: {type: string, format: date-time}
actualAt: {type: string, format: date-time}
providerCode: {type: string}
providerEventId: {type: string}
providerNodeCode: {type: string}
rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
OverseasContainerUnloadedV1:
type: object
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
properties:
sourceContainerBookingId: {type: string, minLength: 1}
containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it}
unloadingCompletedAt: {type: string, format: date-time}
unloadingPhotoUploadedAt: {type: string, format: date-time}
actualEmptyReportedAt: {type: [string, 'null'], format: date-time}
@@ -0,0 +1,20 @@
package com.baiyue.common.event;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerNumbersTest {
@Test
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
.hasMessageContaining("normalized");
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
.hasMessageContaining("check digit");
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
.hasMessageContaining("ISO 6346");
}
}
@@ -21,6 +21,18 @@ class FirstLegContainerShippingNodeRecordedV1Test {
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003"); assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
assertThat(restored.nodeSequence()).isEqualTo(80); assertThat(restored.nodeSequence()).isEqualTo(80);
var json = mapper.valueToTree(payload);
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
assertThat(json.has("containerBookingId")).isFalse();
}
@Test
void rejectsNonCanonicalPhysicalContainerNumber() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
.hasMessageContaining("normalized");
} }
@Test @Test
@@ -18,7 +18,7 @@ class FirstLegLoadingPlanCompletedV2Test {
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"), 12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS"); List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
var payload = new FirstLegLoadingPlanCompletedV2( var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order)); "2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
var json = new ObjectMapper().valueToTree(payload); var json = new ObjectMapper().valueToTree(payload);
@@ -26,6 +26,8 @@ class FirstLegLoadingPlanCompletedV2Test {
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue(); assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
assertThat(json.has("containerBookingId")).isFalse();
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3); assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12); assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120); assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
@@ -33,6 +35,19 @@ class FirstLegLoadingPlanCompletedV2Test {
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750"); assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
} }
@Test
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
.hasMessageContaining("normalized");
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
.hasMessageContaining("containerNo");
}
@Test @Test
void carriesTheThreeItemRemarks() throws Exception { void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1, var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
@@ -41,7 +56,7 @@ class FirstLegLoadingPlanCompletedV2Test {
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item), null, null, null, 1, 2, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of()); "11", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload); String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark"); assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText()) assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
@@ -57,7 +72,7 @@ class FirstLegLoadingPlanCompletedV2Test {
"2", "2", "3", "ORD", "3", "RK", null, null, null, "2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet)); 2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2( var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload); var json = new ObjectMapper().valueToTree(payload);
@@ -74,7 +89,7 @@ class FirstLegLoadingPlanCompletedV2Test {
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA"); 2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA"); assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
} }
@@ -89,7 +104,7 @@ class FirstLegLoadingPlanCompletedV2Test {
"11", "客户甲", "FIRST_LEG", List.of()); "11", "客户甲", "FIRST_LEG", List.of());
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2"); assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
@@ -118,5 +133,6 @@ class FirstLegLoadingPlanCompletedV2Test {
.contains("consumers must not query domestic IAM") .contains("consumers must not query domestic IAM")
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt") .contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder"); .contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number");
} }
} }
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OverseasContainerUnloadedV1Test { class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test @Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
String json = mapper.writeValueAsString(payload); String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt()); assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt()); assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
} }
@Test
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
.hasMessageContaining("normalized");
}
} }
@@ -4869,7 +4869,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking ( CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -4923,7 +4923,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')), CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
@@ -4981,6 +4983,11 @@ BEGIN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED')); CHECK (record_status IN ('ACTIVE','MERGED'));
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_container_real_unit_columns(); CALL phase3_container_real_unit_columns();
@@ -45,6 +45,11 @@ public class EventInboxService {
if (existing != null) { if (existing != null) {
return duplicateOrConflict(existing, envelope); return duplicateOrConflict(existing, envelope);
} }
String idempotencyKey = idempotencyKey(envelope);
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (sameBusinessEvent != null) {
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
}
BridgeInbox inbox = toInbox(envelope); BridgeInbox inbox = toInbox(envelope);
try { try {
inboxStore.insert(inbox); inboxStore.insert(inbox);
@@ -52,13 +57,25 @@ public class EventInboxService {
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (concurrent == null) { if (concurrent == null) {
throw exception; throw exception;
} }
return duplicateOrConflict(concurrent, envelope); return Objects.equals(concurrent.getEventId(), envelope.eventId())
? duplicateOrConflict(concurrent, envelope)
: duplicateBusinessEventOrConflict(concurrent, envelope);
} }
} }
private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameBusinessEvent(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移");
}
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt());
}
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameEnvelope(existing, envelope)) { if (!sameEnvelope(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
@@ -69,6 +86,13 @@ public class EventInboxService {
} }
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
return sameBusinessEvent(existing, envelope)
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
&& Objects.equals(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
}
private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) {
return existing.getPayloadHash() != null return existing.getPayloadHash() != null
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
&& Objects.equals(existing.getEventType(), envelope.eventType()) && Objects.equals(existing.getEventType(), envelope.eventType())
@@ -80,16 +104,13 @@ public class EventInboxService {
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId()) && Objects.equals(existing.getAggregateId(), envelope.aggregateId())
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) && Objects.equals(existing.getCorrelationId(), envelope.correlationId());
&& Objects.equals(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
} }
private BridgeInbox toInbox(EventEnvelope envelope) { private BridgeInbox toInbox(EventEnvelope envelope) {
BridgeInbox inbox = new BridgeInbox(); BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId()); inbox.setEventId(envelope.eventId());
inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId()); inbox.setIdempotencyKey(idempotencyKey(envelope));
inbox.setEventType(envelope.eventType()); inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion()); inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name()); inbox.setSourceRegion(envelope.sourceRegion().name());
@@ -117,4 +138,8 @@ public class EventInboxService {
private static Instant normalizeOccurredAt(Instant value) { private static Instant normalizeOccurredAt(Instant value) {
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
} }
private static String idempotencyKey(EventEnvelope envelope) {
return envelope.sourceRegion() + ":" + envelope.correlationId();
}
} }
@@ -7,5 +7,6 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
*/ */
public interface InboxStore { public interface InboxStore {
BridgeInbox findByEventId(String eventId); BridgeInbox findByEventId(String eventId);
BridgeInbox findByIdempotencyKey(String idempotencyKey);
void insert(BridgeInbox inbox); void insert(BridgeInbox inbox);
} }
@@ -4,6 +4,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
/** /**
* 基于 MyBatis-Plus 的入站幂等存储。 * 基于 MyBatis-Plus 的入站幂等存储。
@@ -17,6 +18,12 @@ public class MybatisInboxStore implements InboxStore {
return mapper.selectById(eventId); return mapper.selectById(eventId);
} }
@Override
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
}
@Override @Override
public void insert(BridgeInbox inbox) { public void insert(BridgeInbox inbox) {
mapper.insert(inbox); mapper.insert(inbox);
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
CREATE TABLE IF NOT EXISTS bridge_inbox ( CREATE TABLE IF NOT EXISTS bridge_inbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本', event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
@@ -106,6 +106,32 @@ class EventInboxServiceTest {
.hasMessageContaining("事件信封"); .hasMessageContaining("事件信封");
} }
@Test
void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope, envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(null);
when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing);
EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001",
envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"),
envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(),
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2",
envelope.correlationId(), null, envelope.payload(), envelope.payloadHash());
assertThat(service.receive(replay).result()).isEqualTo("重复");
com.fasterxml.jackson.databind.node.ObjectNode driftPayload =
(com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy();
driftPayload.put("containerNo", "DIFFERENT");
String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload));
EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002",
envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(),
envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(),
envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(),
null, driftPayload, driftHash);
assertThatThrownBy(() -> service.receive(drift))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test @Test
void 并发插入冲突后按既有记录判定为重复() { void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder")); EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
@@ -6,10 +6,8 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService; import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -96,30 +94,15 @@ public class PlatformContainerController {
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜") @BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜")
public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); } public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许") @Operation(summary="编辑货柜基础资料", description="仅待装柜或预装草稿时允许;不得写入真实集装箱号或内部柜次号")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id, @PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) { @Valid @RequestBody FullUpdateRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
} }
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), key, request));
}
@GetMapping("/{id}/clearance") @GetMapping("/{id}/clearance")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@Operation(summary = "查询货柜目的港清关资料") @Operation(summary = "查询货柜目的港清关资料")
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
/** ISO 6346 真实集装箱号的唯一规范化与校验入口。 */
public final class Iso6346ContainerNumber {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private Iso6346ContainerNumber() {
}
public static String require(String raw) {
String value = normalizeWhitespace(raw);
if (value == null || !value.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
throw invalid();
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = value.charAt(index);
int number = Character.isDigit(current)
? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
int expected = sum % 11 % 10;
if (value.charAt(10) - '0' != expected) throw invalid();
return value;
}
private static String normalizeWhitespace(String raw) {
if (raw == null) return null;
StringBuilder value = new StringBuilder(raw.length());
raw.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint))
.forEach(value::appendCodePoint);
String normalized = value.toString().toUpperCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static BizException invalid() {
return new BizException(ResultCode.BAD_REQUEST,
"真实集装箱号必须符合ISO 6346结构、设备类别和校验位");
}
}
@@ -46,7 +46,6 @@ public final class ContainerDtos {
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑") @Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest( public record SaveRequest(
@Size(max = 64) String containerNo,
@NotNull @Positive Long warehouseId, @NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName, @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo, @Size(max = 128) String billOfLadingNo,
@@ -80,19 +79,6 @@ public final class ContainerDtos {
@Valid @NotNull SaveRequest data @Valid @NotNull SaveRequest data
) {} ) {}
@Schema(name = "ContainerTransportUpdateRequest",
description = "拣货中阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version,
@NotBlank @Size(max = 500) String reason
) {
public TransportUpdateRequest(String containerNo, String voyageNo, Integer version) {
this(containerNo, voyageNo, version, "运输信息更新");
}
}
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成") @Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode, public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
@@ -104,7 +90,4 @@ public final class ContainerDtos {
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo, @NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {} @NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
} }
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -50,7 +51,7 @@ public final class LoadingListDtos {
@Size(max = 64) String containerNo, @Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo, @Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason @NotBlank @Size(max = 500) String operationReason
) { ) {
} }
@@ -1,25 +0,0 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
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_transport_history")
public class ContainerTransportHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long containerBookingId;
private String beforeContainerNo;
private String afterContainerNo;
private String beforeVoyageNo;
private String afterVoyageNo;
private String operationId;
private String requestHash;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -123,26 +123,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Param("actualEmptyReportedAt") java.time.LocalDateTime actualEmptyReportedAt, @Param("actualEmptyReportedAt") java.time.LocalDateTime actualEmptyReportedAt,
@Param("sourceVersion") long sourceVersion); @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,
bill_of_lading_no=NULL, vessel_name=NULL, voyage_no=NULL,
shipping_company_id=NULL, shipping_company_name=NULL,
departure_port_code=NULL, departure_port_name=NULL,
destination_port_code=NULL, destination_port_name=NULL,
estimated_sailing_at=NULL, estimated_port_arrival_at=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
""")
int unlinkReleasedBooking(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update(""" @Update("""
UPDATE fl_container_booking SET UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName}, warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo}, bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo},
container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId}, container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId},
@@ -169,22 +151,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_pick_task t
JOIN fl_loading_plan p ON p.id=t.loading_plan_id
WHERE p.container_booking_id=fl_container_booking.id))
""")
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update(""" @Update("""
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName}, UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},updated_by=#{operatorId}, voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},updated_by=#{operatorId},
@@ -1,11 +1,23 @@
package com.baiyue.firstleg.mapper; package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
@Mapper public interface ContainerOverviewMapper { @Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id); @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id); @Select("""
SELECT history.id,plan.container_booking_id containerBookingId,
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
JSON_UNQUOTE(JSON_EXTRACT(history.after_snapshot,'$.containerNo')) afterContainerNo,
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.voyageNo')) beforeVoyageNo,
JSON_UNQUOTE(JSON_EXTRACT(history.after_snapshot,'$.voyageNo')) afterVoyageNo,
history.operation_id operationId,history.operation_reason reason,
history.operator_id operatorId,history.operator_name operatorName,
history.occurred_at createdAt
FROM fl_loading_plan_history history
JOIN fl_loading_plan plan ON plan.id=history.loading_plan_id
WHERE plan.container_booking_id=#{id} AND history.action_type='UPDATE_PRELOAD_TRANSPORT'
ORDER BY history.occurred_at,history.id
""") List<TransportRow> transport(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id);
@@ -22,5 +34,8 @@ import java.util.List;
record ExceptionRow(Long id,String action,String reason,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt,String requestHash) {} record ExceptionRow(Long id,String action,String reason,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt,String requestHash) {}
record PickTaskRow(Long id,Long loadingPlanId,Long containerBookingId,String taskStatus,Integer version) {} record PickTaskRow(Long id,Long loadingPlanId,Long containerBookingId,String taskStatus,Integer version) {}
record TimelineRow(String eventType,String detail,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt) {} record TimelineRow(String eventType,String detail,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt) {}
record TransportRow(Long id,Long containerBookingId,String beforeContainerNo,String afterContainerNo,
String beforeVoyageNo,String afterVoyageNo,String operationId,String reason,
Long operatorId,String operatorName,java.time.LocalDateTime createdAt) {}
record PhotoRow(Long evidenceId,Long tenantId,Long inboundId,String evidenceType,Long fileId,String fileName,String status,java.time.LocalDateTime uploadedAt) {} record PhotoRow(Long evidenceId,Long tenantId,Long inboundId,String evidenceType,Long fileId,String fileName,String status,java.time.LocalDateTime uploadedAt) {}
} }
@@ -1,15 +0,0 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{bookingId} "
+ "AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1")
ContainerTransportHistoryEntity selectOperation(@Param("bookingId") long bookingId,
@Param("operatorId") long operatorId, @Param("operationId") String operationId);
}
@@ -3,7 +3,11 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.entity.InboundOutboxEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> { public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> {
@Select("SELECT * FROM fl_inbound_outbox WHERE event_key=#{eventKey} LIMIT 1")
InboundOutboxEntity selectByEventKey(@Param("eventKey") String eventKey);
} }
@@ -227,9 +227,18 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1 updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status IN ('EDITING', 'LOADING') WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL
AND (plan_status = 'LOADING' OR preload_pushed_at IS NOT NULL)
AND version = #{expectedVersion} AND version = #{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=fl_loading_plan.id)
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
WHERE task.loading_plan_id=fl_loading_plan.id AND task.task_status<>'LOADING')
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_item item ON item.pick_task_id=task.id
WHERE task.loading_plan_id=fl_loading_plan.id
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
""") """)
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion, int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@@ -240,15 +249,44 @@ public interface LoadingListQueryMapper {
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}, SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status IN ('EDITING', 'LOADING') WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL
AND (p.plan_status = 'LOADING' OR p.preload_pushed_at IS NOT NULL) AND #{containerNo} IS NOT NULL
AND (#{containerNo} IS NOT NULL OR NOT EXISTS ( AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id)) JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=p.id)
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
WHERE task.loading_plan_id=p.id AND task.task_status<>'LOADING')
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_item item ON item.pick_task_id=task.id
WHERE task.loading_plan_id=p.id
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
""") """)
int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo, int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName); @Param("operatorName") String operatorName);
@Select("""
SELECT CASE WHEN
EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=#{planId})
OR EXISTS (SELECT 1 FROM fl_loading_pick_task task
WHERE task.loading_plan_id=#{planId} AND task.task_status<>'LOADING')
OR EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_item item ON item.pick_task_id=task.id
WHERE task.loading_plan_id=#{planId}
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
OR EXISTS (SELECT 1 FROM fl_loading_evidence evidence
JOIN fl_loading_plan_item plan_item
ON plan_item.account_id=evidence.tenant_id AND plan_item.inbound_id=evidence.inbound_id
WHERE plan_item.loading_plan_id=#{planId} AND plan_item.deleted=0
AND evidence.status='ACTIVE')
THEN 1 ELSE 0 END
""")
int hasPdaWorkStarted(@Param("planId") long planId);
@Update(""" @Update("""
UPDATE fl_preload_plan UPDATE fl_preload_plan
SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName}, SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName},
@@ -24,7 +24,7 @@ public final class CarrierScheduleQueryDtos {
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion, public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId, @NotNull @Positive Long customerId,
@NotNull @Positive Long oceanBookingId, @NotNull @Positive Long oceanBookingId,
@Positive Long containerBookingId, @NotNull @Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode, @NotBlank @Size(max = 16) String carrierCode,
@NotBlank @Size(max = 32) String trackingType, @NotBlank @Size(max = 32) String trackingType,
@NotBlank @Size(max = 128) String trackingNo, @NotBlank @Size(max = 128) String trackingNo,
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
@@ -36,10 +37,16 @@ public class CarrierScheduleNodeOutboxService {
public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact, public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact,
String containerNo) { String containerNo) {
if (fact.getActualAt() == null) return; if (fact.getActualAt() == null) return;
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
if (!"CONTAINER_NO".equals(query.getTrackingType())
|| !normalizedContainerNo.equals(query.getTrackingNo())) {
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
}
ZoneId zone = ZoneId.of(scheduleZone); ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1( var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(), string(query.getContainerBookingId()), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
@@ -61,7 +68,15 @@ public class CarrierScheduleNodeOutboxService {
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
row.setCreatedAt(LocalDateTime.now()); row.setCreatedAt(LocalDateTime.now());
try { outbox.insert(row); } catch (DuplicateKeyException ignored) { } try {
outbox.insert(row);
} catch (DuplicateKeyException duplicate) {
InboundOutboxEntity existing = outbox.selectByEventKey(eventKey);
if (existing == null || existing.getPayloadHash() == null
|| !existing.getPayloadHash().equalsIgnoreCase(payloadHash)) {
throw new BizException(ResultCode.CONFLICT, "相同eventKey对应的船节点柜号或负载发生漂移");
}
}
} }
private String json(Object value) { private String json(Object value) {
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
@@ -55,7 +56,7 @@ import java.util.Set;
@Service @Service
public class CarrierScheduleQueryService { public class CarrierScheduleQueryService {
static final int MAX_SLOT_ATTEMPTS = 3; static final int MAX_SLOT_ATTEMPTS = 3;
private static final Set<String> TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); private static final Set<String> TRACKING_TYPES = Set.of("CONTAINER_NO");
@Resource private CarrierScheduleQueryRepository repository; @Resource private CarrierScheduleQueryRepository repository;
@Resource private PortScheduleRepository portSchedules; @Resource private PortScheduleRepository portSchedules;
@@ -91,21 +92,15 @@ public class CarrierScheduleQueryService {
long operatorId, String operatorName) { long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId, CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId); containerBookingId);
if (existing != null) return existing;
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId); validateLinks(customerId, oceanBookingId, containerBookingId);
String trackingType; String trackingType = "CONTAINER_NO";
String trackingNo; String trackingNo = normalizedContainerNo(container);
if (StringUtils.hasText(container.getContainerNo())) { if (existing != null) {
trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim(); requireFrozenTrackingIdentity(existing, trackingNo);
} else if (StringUtils.hasText(booking.getBillOfLadingNo())) { return existing;
trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim();
} else if (StringUtils.hasText(booking.getBookingNo())) {
trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim();
} else {
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
} }
PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo); PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo);
LocalDateTime now = now(); LocalDateTime now = now();
@@ -129,7 +124,10 @@ public class CarrierScheduleQueryService {
catch (DuplicateKeyException exception) { catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId); containerBookingId);
if (concurrent != null) return concurrent; if (concurrent != null) {
requireFrozenTrackingIdentity(concurrent, trackingNo);
return concurrent;
}
throw exception; throw exception;
} }
persistenceStage = "NODE_FACT"; persistenceStage = "NODE_FACT";
@@ -352,15 +350,21 @@ public class CarrierScheduleQueryService {
Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request)); Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request));
if (replay != null) return replay; if (replay != null) return replay;
validateLinks(request.customerId(), request.oceanBookingId(), request.containerBookingId()); validateLinks(request.customerId(), request.oceanBookingId(), request.containerBookingId());
ContainerBookingEntity container = portSchedules.containerBooking(request.containerBookingId());
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
String carrierCode = adapters.normalize(request.carrierCode()); String carrierCode = adapters.normalize(request.carrierCode());
String trackingType = normalizeTrackingType(request.trackingType()); String trackingType = normalizeTrackingType(request.trackingType());
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, request.trackingNo()); String trackingNo = normalizedContainerNo(container);
if (!trackingNo.equals(request.trackingNo())) {
throw badRequest("trackingNo必须与柜次真实集装箱号一致");
}
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
LocalDateTime now = now(); LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(request.customerId()); value.setOceanBookingId(request.oceanBookingId()); value.setCustomerId(request.customerId()); value.setOceanBookingId(request.oceanBookingId());
value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode); value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode);
value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim()); value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
value.setStatus("READY"); value.setTriggerSource("MANUAL"); value.setStatus("READY"); value.setTriggerSource("MANUAL");
value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0);
@@ -516,10 +520,25 @@ public class CarrierScheduleQueryService {
private String requireContainerNo(long containerBookingId) { private String requireContainerNo(long containerBookingId) {
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null || !StringUtils.hasText(container.getContainerNo())) { if (container == null) {
throw conflict("跨区船节点交接要求柜次具备柜号"); throw conflict("跨区船节点交接要求柜次具备柜号");
} }
return container.getContainerNo().trim().toUpperCase(Locale.ROOT); return normalizedContainerNo(container);
}
private static String normalizedContainerNo(ContainerBookingEntity container) {
try {
return ContainerNumbers.normalizeRequired(container.getContainerNo());
} catch (IllegalArgumentException exception) {
throw badRequest("船节点查询要求柜次具备规范化真实集装箱号");
}
}
private static void requireFrozenTrackingIdentity(CarrierScheduleQueryEntity query, String containerNo) {
if (!"CONTAINER_NO".equals(query.getTrackingType())
|| !containerNo.equals(query.getTrackingNo())) {
throw conflict("船节点任务柜号与当前柜次真实集装箱号发生漂移");
}
} }
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
@@ -598,7 +617,7 @@ public class CarrierScheduleQueryService {
private static String normalizeTrackingType(String value) { private static String normalizeTrackingType(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT); String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!TRACKING_TYPES.contains(normalized)) { if (!TRACKING_TYPES.contains(normalized)) {
throw badRequest("trackingType仅支持BOOKING_NO、BILL_OF_LADING或CONTAINER_NO"); throw badRequest("trackingType仅支持CONTAINER_NO");
} }
return normalized; return normalized;
} }
@@ -33,9 +33,6 @@ public class ContainerRepository {
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
} }
public void insert(ContainerBookingEntity value) { mapper.insert(value); } public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int unlinkReleasedBooking(long targetId, int version, long operatorId, String operatorName) {
return mapper.unlinkReleasedBooking(targetId, version, operatorId, operatorName);
}
public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo, public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo,
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking, com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking,
int version, long operatorId, String operatorName) { int version, long operatorId, String operatorName) {
@@ -49,10 +46,6 @@ public class ContainerRepository {
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; } public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; } public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; }
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
}
public int syncReleasedTransport(long oceanBookingId, String billOfLadingNo, String vesselName, public int syncReleasedTransport(long oceanBookingId, String billOfLadingNo, String vesselName,
String voyageNo, java.time.LocalDateTime estimatedSailingAt, String voyageNo, java.time.LocalDateTime estimatedSailingAt,
long operatorId, String operatorName) { long operatorId, String operatorName) {
@@ -86,6 +86,10 @@ public class LoadingListRepository {
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName); return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
} }
public boolean hasPdaWorkStarted(long planId) {
return mapper.hasPdaWorkStarted(planId) > 0;
}
public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion, public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion,
long operatorId, String operatorName) { long operatorId, String operatorName) {
return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion, return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion,
@@ -152,8 +152,8 @@ public class ContainerOverviewService {
private List<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) { private List<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) {
var result = new ArrayList<TimelineEntry>(); var result = new ArrayList<TimelineEntry>();
mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(), mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.reason(),
value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()))); value.operatorId(), value.operatorName(), value.createdAt())));
exceptionHistory.forEach(value -> result.add(new TimelineEntry(value.action(), value.reason(), exceptionHistory.forEach(value -> result.add(new TimelineEntry(value.action(), value.reason(),
value.operatorId(), value.operatorName(), value.occurredAt()))); value.operatorId(), value.operatorName(), value.occurredAt())));
if (taskId != null) mapper.pickTimeline(taskId).forEach(value -> result.add(new TimelineEntry( if (taskId != null) mapper.pickTimeline(taskId).forEach(value -> result.add(new TimelineEntry(
@@ -11,17 +11,11 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest; import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -29,30 +23,21 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
@Service @Service
public class ContainerService { public class ContainerService {
@Resource private ContainerRepository repository; @Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses; @Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickRepository loadingPickRepository;
@Resource private LoadingPickService loadingPickService;
@Resource private PriceBookQueryUseCase priceBooks; @Resource private PriceBookQueryUseCase priceBooks;
@Resource private OceanBookingRepository oceanBookings; @Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) { public PageResult<ContainerBookingEntity> page(Query query) {
@@ -91,56 +76,44 @@ public class ContainerService {
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId()); long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限"); if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在"); String containerNo = Iso6346ContainerNumber.require(request.containerNo());
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
} }
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, try {
request.expectedVersion(), operatorId, operatorName) != 1) { if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo,
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); booking, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
} }
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation); OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
return required(id); return required(id);
} }
@Transactional
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
if (repository.unlinkReleasedBooking(id, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
oceanBookings.deleteAllocation(id);
return required(id);
}
private void requireFirstLinkMutable(ContainerBookingEntity container) { private void requireFirstLinkMutable(ContainerBookingEntity container) {
SmartLoadingStatus value = status(container); SmartLoadingStatus value = status(container);
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING) if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|| trim(container.getContainerNo()) != null
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId()) || repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|| repository.hasDocuments(container.getId())) throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能关联订舱"); || repository.hasDocuments(container.getId())) throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能关联订舱");
} }
private void requireUnlinkMutable(ContainerBookingEntity container) {
if (status(container) != SmartLoadingStatus.WAIT_LOADING || repository.hasActiveLoadingItems(container.getId())
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|| repository.hasDocuments(container.getId())) throw conflict("已有排货、PDA、证据或单证事实,不能解除订舱关联");
}
@Transactional @Transactional
public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName, public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName,
FullUpdateRequest request) { FullUpdateRequest request) {
ContainerBookingEntity current = required(id); ContainerBookingEntity current = required(id);
requireVersion(current, request.version()); requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.WAIT_LOADING && status(current) != SmartLoadingStatus.EDITING) { if (status(current) != SmartLoadingStatus.WAIT_LOADING && status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); throw conflict("货柜已进入预装流程,基础资料不可完整编辑");
} }
validateSchedule(request.data()); validateSchedule(request.data());
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg()); ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany()); var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
ensureUnique(trim(request.data().containerNo()), current.getCabinetNo(), id);
copy(current, request.data()); copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId()); current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -153,98 +126,11 @@ public class ContainerService {
return required(id); return required(id);
} }
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) {
String operationId = transportOperationId(id, idempotencyKey);
String containerNo = trim(request.containerNo());
String voyageNo = trim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + id + '|' + request.version() + '|'
+ Objects.toString(containerNo, "") + '|' + Objects.toString(voyageNo, "") + '|'
+ request.reason().trim());
ContainerTransportHistoryEntity replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return required(id);
}
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return current;
}
if (Objects.equals(current.getContainerNo(), containerNo)
&& Objects.equals(current.getVoyageNo(), voyageNo)) {
return current;
}
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(id);
if (containerNo == null && plan != null && loadingPickRepository.taskByPlan(plan.getId()) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
}
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, voyageNo, request.version(),
operatorId, operatorName) != 1) {
ContainerTransportHistoryEntity concurrent = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent != null && requestHash.equals(concurrent.getRequestHash())) {
return required(id);
}
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity();
history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo());
history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo());
history.setAfterVoyageNo(voyageNo); history.setOperationId(operationId);
history.setRequestHash(requestHash); history.setReason(request.reason().trim());
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
try {
if (transportHistories != null) transportHistories.insert(history);
} catch (DuplicateKeyException exception) {
ContainerTransportHistoryEntity concurrent = transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent == null || !requestHash.equals(concurrent.getRequestHash())) {
throw conflict("Idempotency-Key已被并发用于不同运输字段请求");
}
}
ContainerBookingEntity updated = required(id);
loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName);
return updated;
}
private static String transportOperationId(long id, String value) {
String key = trim(value);
if (key == null) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空");
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key长度不能超过128");
return sha256("CONTAINER_TRANSPORT|" + id + '|' + key);
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256不可用", exception);
}
}
private ContainerBookingEntity required(long id) { private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id); ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value; return value;
} }
private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) {
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
}
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try { try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active( ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
@@ -279,7 +165,6 @@ public class ContainerService {
} }
private static void copy(ContainerBookingEntity v, SaveRequest r) { private static void copy(ContainerBookingEntity v, SaveRequest r) {
var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg());
v.setContainerNo(trim(r.containerNo()));
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName())); v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType()); v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType());
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
@@ -49,6 +50,7 @@ public class LoadingCompletedOutboxService {
public void publish(long planId) { public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId); List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateContainerIdentity(rows);
validateCustomerSnapshots(rows); validateCustomerSnapshots(rows);
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows); Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId); List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
@@ -67,7 +69,8 @@ public class LoadingCompletedOutboxService {
.map(value -> order(value, palletRows.stream() .map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()), string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()),
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders); iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload); JsonNode payloadNode = objectMapper.valueToTree(payload);
@@ -278,7 +281,36 @@ public class LoadingCompletedOutboxService {
value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion()); value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion());
value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope)); value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope));
value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now()); value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now());
try { outboxMapper.insert(value); } catch (DuplicateKeyException ignored) { } try {
outboxMapper.insert(value);
} catch (DuplicateKeyException duplicate) {
InboundOutboxEntity existing = outboxMapper.selectByEventKey(value.getEventKey());
if (existing == null || existing.getPayloadHash() == null
|| !existing.getPayloadHash().equalsIgnoreCase(value.getPayloadHash())) {
throw new BizException(ResultCode.CONFLICT, "相同eventKey对应的装柜完成事件柜号或负载发生漂移");
}
}
}
private static void validateContainerIdentity(List<Row> rows) {
Row first = rows.get(0);
String containerNo;
try {
containerNo = ContainerNumbers.requireCanonical(first.getContainerNo());
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.CONFLICT, "装柜完成前必须冻结真实集装箱号");
}
if (first.getContainerBookingId() == null) {
throw new BizException(ResultCode.CONFLICT, "装柜完成事件缺少来源柜次标识");
}
try {
boolean drift = rows.stream().anyMatch(row ->
!Objects.equals(first.getContainerBookingId(), row.getContainerBookingId())
|| !containerNo.equals(ContainerNumbers.requireCanonical(row.getContainerNo())));
if (drift) throw new BizException(ResultCode.CONFLICT, "装柜完成事件的柜次或集装箱号快照不一致");
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.CONFLICT, "装柜完成事件包含非规范真实集装箱号");
}
} }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
@@ -23,6 +23,8 @@ import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -62,6 +64,8 @@ public class LoadingListService {
@Resource @Resource
private LoadingPickRepository pickRepository; private LoadingPickRepository pickRepository;
@Resource @Resource
private ContainerRepository containerRepository;
@Resource
@Lazy @Lazy
private LoadingPickService loadingPickService; private LoadingPickService loadingPickService;
@Resource @Resource
@@ -397,41 +401,60 @@ public class LoadingListService {
resourceId = "#planId", description = "修改预装集装箱号与航次") resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName, public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) { String idempotencyKey, TransportUpdateRequest request) {
String containerNo = nullableTrim(request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
AggregateRow before = requirePlan(planId); AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId); requireWarehouse(before, warehouseId);
String reason = trim(request.operationReason());
if (!StringUtils.hasText(reason)) throw badRequest("纠错原因不能为空");
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId); HistoryRow replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
requireSameRequest(replay, requestHash); String replayContainerNo = targetContainerNo(before, request.containerNo());
String replayHash = transportHash(planId, request.expectedVersion(), replayContainerNo,
nullableTrim(request.voyageNo()), reason);
requireSameRequest(replay, replayHash);
return repository.preloadDetail(before); return repository.preloadDetail(before);
} }
requireLoadingMutable(before); requireTransportCorrectionMutable(before);
String containerNo = targetContainerNo(before, request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = transportHash(planId, request.expectedVersion(), containerNo, voyageNo, reason);
if (java.util.Objects.equals(before.getContainerNo(), containerNo) if (java.util.Objects.equals(before.getContainerNo(), containerNo)
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) { && java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before); return repository.preloadDetail(before);
} }
if (containerNo == null && pickRepository.taskByPlan(planId) != null) { if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号"); throw conflict("集装箱号已存在");
} }
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, try {
operatorId, trim(operatorName)) != 1) { if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
throw conflict("预装计划已被其他操作修改,请刷新后重试"); operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
} }
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT", repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)), before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
json(values("requestHash", requestHash, "status", before.getPlanStatus(), json(values("requestHash", requestHash, "status", before.getPlanStatus(),
"containerNo", containerNo, "voyageNo", voyageNo, "containerNo", containerNo, "voyageNo", voyageNo,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName), "version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason())); reason);
loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName); loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName);
return repository.preloadDetail(requirePlan(planId)); return repository.preloadDetail(requirePlan(planId));
} }
private static String targetContainerNo(AggregateRow before, String requested) {
return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo())
: Iso6346ContainerNumber.require(requested);
}
private static String transportHash(long planId, int version, String containerNo,
String voyageNo, String reason) {
return sha256("TRANSPORT|" + planId + '|' + version + '|' + containerNo + '|'
+ voyageNo + '|' + reason);
}
@Transactional @Transactional
@BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN", @BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "原子确认装柜计划全部预装票据") resourceId = "#planId", description = "原子确认装柜计划全部预装票据")
@@ -535,12 +558,16 @@ public class LoadingListService {
} }
} }
private static void requireLoadingMutable(AggregateRow row) { private void requireTransportCorrectionMutable(AggregateRow row) {
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) { if (!"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次"); throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
} }
if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) { if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
throw conflict("仅已推送的预装清单可维护运输字段"); if (!StringUtils.hasText(row.getContainerNo())) {
throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入");
}
if (repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
} }
} }
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking ( CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -95,7 +95,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')), CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
@@ -190,6 +192,11 @@ BEGIN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED')); CHECK (record_status IN ('ACTIVE','MERGED'));
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_container_real_unit_columns(); CALL phase3_container_real_unit_columns();
@@ -96,7 +96,7 @@ components:
type: string type: string
enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]
description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation. description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation.
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} TrackingType: {type: string, enum: [CONTAINER_NO], description: Only the normalized physical containerNo frozen by Firstleg is accepted; cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks.}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
StandardNode: StandardNode:
type: string type: string
@@ -104,15 +104,15 @@ components:
ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]}
CreateRequest: CreateRequest:
type: object type: object
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo]
properties: properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0} expectedVersion: {type: integer, minimum: 0, maximum: 0}
customerId: {type: integer, format: int64, minimum: 1} customerId: {type: integer, format: int64, minimum: 1}
oceanBookingId: {type: integer, format: int64, minimum: 1} oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true} containerBookingId: {type: integer, format: int64, minimum: 1}
carrierCode: {$ref: '#/components/schemas/CarrierCode'} carrierCode: {$ref: '#/components/schemas/CarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'} trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string, minLength: 1, maxLength: 128} trackingNo: {type: string, minLength: 1, maxLength: 128, description: Normalized physical containerNo; must equal the linked Firstleg container snapshot.}
remark: {type: string, nullable: true, maxLength: 1000} remark: {type: string, nullable: true, maxLength: 1000}
ManualResultRequest: ManualResultRequest:
type: object type: object
@@ -136,13 +136,13 @@ components:
reason: {type: string, minLength: 1, maxLength: 1000} reason: {type: string, minLength: 1, maxLength: 1000}
Task: Task:
type: object type: object
required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version] required: [id, queryNo, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version]
properties: properties:
id: {type: integer, format: int64} id: {type: integer, format: int64}
queryNo: {type: string} queryNo: {type: string}
customerId: {type: integer, format: int64} customerId: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64} oceanBookingId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64, nullable: true} containerBookingId: {type: integer, format: int64}
carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'} carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'} trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string} trackingNo: {type: string}
@@ -295,16 +295,10 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link: /api/platform/v1/containers/{id}/ocean-booking-link:
post: post:
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号 summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。 description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo,服务端规范化并校验ISO 6346结构、设备类别和校验位。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}} responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
delete:
summary: 解除内部模拟柜的真实订舱关联
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;原子清除当前柜订舱/运输快照和 allocation,不恢复或创建其他柜记录。任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}}
responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}}
/api/platform/v1/containers/{id}: /api/platform/v1/containers/{id}:
get: get:
summary: 查询平台货柜详情 summary: 查询平台货柜详情
@@ -315,8 +309,8 @@ paths:
'200': {description: 成功或货柜不存在} '200': {description: 成功或货柜不存在}
'403': {description: 无货柜查询权限} '403': {description: 无货柜查询权限}
put: put:
summary: 草稿阶段完整编辑货柜 summary: 草稿阶段编辑货柜基础资料
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING(预装柜中)时允许 description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
parameters: parameters:
- {$ref: '#/components/parameters/ContainerBookingId'} - {$ref: '#/components/parameters/ContainerBookingId'}
requestBody: requestBody:
@@ -327,21 +321,6 @@ paths:
responses: responses:
'200': {description: 成功或状态、版本、重复号冲突} '200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限} '403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/transport:
patch:
summary: 修改集装箱号和航次
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读;相同幂等键精确重放零新增历史,相同业务字段为无效变更且不递增版本、不写历史
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'}
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/clearance: /api/platform/v1/containers/{id}/clearance:
get: get:
summary: 查询货柜目的港清关资料 summary: 查询货柜目的港清关资料
@@ -1051,7 +1030,6 @@ components:
expectedArrivalDate: {type: string, format: date, nullable: true} expectedArrivalDate: {type: string, format: date, nullable: true}
feeReviewStatus: {type: string, maxLength: 32, nullable: true} feeReviewStatus: {type: string, maxLength: 32, nullable: true}
channelName: {type: string, maxLength: 128, nullable: true} channelName: {type: string, maxLength: 128, nullable: true}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64, nullable: true} cabinetNo: {type: string, maxLength: 64, nullable: true}
sailingDate: {type: string, format: date, nullable: true} sailingDate: {type: string, format: date, nullable: true}
loaded: {type: boolean, nullable: true} loaded: {type: boolean, nullable: true}
@@ -1652,14 +1630,6 @@ components:
properties: properties:
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ContainerSaveRequest'} data: {$ref: '#/components/schemas/ContainerSaveRequest'}
ContainerTransportUpdateRequest:
type: object
required: [version, reason]
properties:
containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空}
voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500}
SmartLoadingPushRequest: SmartLoadingPushRequest:
type: object type: object
required: [expectedVersion] required: [expectedVersion]
@@ -1960,14 +1930,8 @@ components:
properties: properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID} oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID} requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号} containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号;服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位}
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
ContainerOceanBookingUnlinkRequest:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
ContainerFeeAdjustRequest: ContainerFeeAdjustRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -34,7 +34,9 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}/transport: /api/platform/v1/first-leg/loading-plans/{planId}/transport:
patch: patch:
summary: 修改预装运输信息 summary: 修改预装运输信息
description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。 description: >-
唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许;
containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。
x-permission: platform:firstleg:preload:edit x-permission: platform:firstleg:preload:edit
parameters: parameters:
- {$ref: '#/components/parameters/PlanId'} - {$ref: '#/components/parameters/PlanId'}
@@ -280,7 +282,7 @@ components:
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
ConfirmPreloadRequest: ConfirmPreloadRequest:
type: object type: object
required: [expectedVersion] required: [expectedVersion, operationReason]
additionalProperties: false additionalProperties: false
properties: properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version}
@@ -385,7 +387,7 @@ components:
required: [expectedVersion] required: [expectedVersion]
additionalProperties: false additionalProperties: false
properties: properties:
containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空} containerNo: {type: string, maxLength: 64, nullable: true, description: 新真实集装箱号;null表示保持当前值,空白字符串非法,禁止清空}
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} operationReason: {type: string, minLength: 1, maxLength: 500, description: 必填纠错原因}
@@ -22,9 +22,7 @@ class ContainerControllerContractTest {
assertPermission("detail", "platform:firstleg:container:list"); assertPermission("detail", "platform:firstleg:container:list");
assertPermission("create", "platform:firstleg:container:create"); assertPermission("create", "platform:firstleg:container:create");
assertPermission("linkOceanBooking", "platform:firstleg:container:edit"); assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit");
assertPermission("updateDraft", "platform:firstleg:container:edit"); assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("uploadClearance", "platform:firstleg:customs:manage");
assertPermission("overview", "platform:firstleg:container:list"); assertPermission("overview", "platform:firstleg:container:list");
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list"); assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
@@ -60,14 +58,15 @@ class ContainerControllerContractTest {
openapi.indexOf(" /openapi/v1/inbound-measurements:")); openapi.indexOf(" /openapi/v1/inbound-measurements:"));
assertThat(section) assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:") .contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜") .contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId") .contains("RELEASED oceanBookingId")
.contains("未满配 requirementId") .contains("未满配 requirementId")
.contains("唯一 containerNo") .contains("唯一 containerNo")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜") .contains("校验ISO 6346结构、设备类别和校验位")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝") .doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
.doesNotContain("realContainerBookingId") .doesNotContain("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:") .contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data") .contains("multipart/form-data")
@@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -67,8 +68,9 @@ class LoadingListControllerContractTest {
.contains("beforeSnapshot") .contains("beforeSnapshot")
.contains("containerNo") .contains("containerNo")
.contains("voyageNo") .contains("voyageNo")
.contains("已推送且仍为 EDITING") .contains("唯一纠错入口")
.contains("不能清空已创建 PDA 任务的柜号") .contains("校验ISO 6346结构、设备类别和校验位")
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm") .contains("/loading-plans/{planId}/confirm")
.contains("confirmationStatus") .contains("confirmationStatus")
.contains("canConfirm") .contains("canConfirm")
@@ -94,6 +96,16 @@ class LoadingListControllerContractTest {
assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull();
} }
@Test
void transportCorrectionRequiresReasonAndKeepsNullContainerAsUnchanged() {
assertThat(component(TransportUpdateRequest.class, "operationReason").getAccessor()
.getAnnotation(NotBlank.class))
.isNotNull();
assertThat(component(TransportUpdateRequest.class, "containerNo").getAccessor()
.getAnnotation(NotBlank.class))
.isNull();
}
@Test @Test
void yamlParses() throws Exception { void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Iso6346ContainerNumberTest {
@Test
void normalizesAllowedWhitespaceAndLowercase() {
assertThat(Iso6346ContainerNumber.require(" ms cu 663987 0 "))
.isEqualTo("MSCU6639870");
}
@Test
void rejectsWrongEquipmentCategoryAndCheckDigit() {
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCA6639870"))
.isInstanceOf(BizException.class).hasMessageContaining("设备类别");
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCU6639871"))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
}
}
@@ -22,12 +22,15 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED") .contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED")
.contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD") .contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD")
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
.contains("enum: [CONTAINER_NO]")
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
.contains("latestActualNode:") .contains("latestActualNode:")
.contains("latestActualAt:") .contains("latestActualAt:")
.contains("manual actual node is equal to or earlier than latestActualNode") .contains("manual actual node is equal to or earlier than latestActualNode")
.contains("strictly greater sequence than latestActualNode") .contains("strictly greater sequence than latestActualNode")
.contains("internal execution fields are intentionally absent") .contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
.doesNotContain("/{queryId}/attempts:") .doesNotContain("/{queryId}/attempts:")
.doesNotContain("port-schedule-query:attempt"); .doesNotContain("port-schedule-query:attempt");
} }
@@ -19,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.springframework.dao.DuplicateKeyException;
class CarrierScheduleNodeOutboxServiceTest { class CarrierScheduleNodeOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
@@ -51,9 +54,35 @@ class CarrierScheduleNodeOutboxServiceTest {
assertThat(envelope.aggregateVersion()).isEqualTo(7); assertThat(envelope.aggregateVersion()).isEqualTo(7);
assertThat(envelope.handoverVersion()).isEqualTo(8); assertThat(envelope.handoverVersion()).isEqualTo(8);
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY"); assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY");
assertThat(envelope.payload().path("sourceContainerBookingId").asText())
.isEqualTo("2088000000000000003");
assertThat(envelope.payload().has("containerBookingId")).isFalse();
assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
} }
@Test
void rejectsNonCanonicalOrDriftingTrackingIdentity() {
CarrierScheduleQueryEntity query = query();
query.setTrackingType("CONTAINER_NO"); query.setTrackingNo("zcsu7881970");
assertThatThrownBy(() -> service.publish(query, fact(), "ZCSU7881970"))
.hasMessageContaining("不一致");
query.setTrackingNo("MATU4646247");
assertThatThrownBy(() -> service.publish(query, fact(), "ZCSU7881970"))
.hasMessageContaining("不一致");
}
@Test
void duplicateEventKeyWithDifferentPayloadFailsClosed() {
InboundOutboxEntity existing = new InboundOutboxEntity(); existing.setPayloadHash("different");
when(mapper.insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
when(mapper.selectByEventKey(org.mockito.ArgumentMatchers.anyString())).thenReturn(existing);
assertThatThrownBy(() -> service.publish(query(), fact(), "ZCSU7881970"))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test @Test
void estimatedOnlyFactNeverEntersCrossRegionOutbox() { void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
@@ -70,7 +99,8 @@ class CarrierScheduleNodeOutboxServiceTest {
fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM"); fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM");
fact.setProviderNodeCode("OUTBOUND_CONFIRM"); fact.setProviderNodeCode("OUTBOUND_CONFIRM");
service.publish(query, fact, "CSNU1234567"); query.setTrackingNo("MATU4646247");
service.publish(query, fact, "MATU4646247");
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture()); verify(mapper).insert(captor.capture());
@@ -84,6 +114,7 @@ class CarrierScheduleNodeOutboxServiceTest {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L); value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L);
value.setTrackingType("CONTAINER_NO"); value.setTrackingNo("ZCSU7881970");
value.setVersion(7); return value; value.setVersion(7); return value;
} }
@@ -76,13 +76,17 @@ class CarrierScheduleQueryServiceTest {
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() { void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity()); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
var result = service.create(7L, "admin", "carrier-create", var result = service.create(7L, "admin", "carrier-create",
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null)); new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "CONTAINER_NO", "ZCSU7881970", null));
assertThat(result.query().query().carrierCode()).isEqualTo("ZIM"); assertThat(result.query().query().carrierCode()).isEqualTo("ZIM");
assertThat(result.query().query().trackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.query().query().trackingNo()).isEqualTo("ZCSU7881970");
assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().expired()).isTrue(); assertThat(result.query().expired()).isTrue();
ArgumentCaptor<CarrierScheduleQueryEntity> created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class); ArgumentCaptor<CarrierScheduleQueryEntity> created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class);
@@ -90,12 +94,57 @@ class CarrierScheduleQueryServiceTest {
assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
} }
@Test
void outboundRequiresPhysicalContainerNumberAndNeverFallsBackToBookingOrBillOfLading() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setCarrierCode("ZIM"); booking.setBookingNo("OB-1"); booking.setBillOfLadingNo("BL-1");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity());
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"))
.isInstanceOf(BizException.class)
.hasMessageContaining("规范化真实集装箱号");
verify(repository, never()).insert(any(CarrierScheduleQueryEntity.class));
}
@Test
void manualCreateAlsoRejectsBookingOrBillOfLadingTrackingKeys() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
assertThatThrownBy(() -> service.create(7L, "admin", "carrier-create-bol",
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "BL-1", null)))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持CONTAINER_NO");
verify(repository, never()).insert(any(CarrierScheduleQueryEntity.class));
}
@Test
void outboundReplayFailsClosedWhenFrozenTrackingNumberDrifts() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
CarrierScheduleQueryEntity existing = new CarrierScheduleQueryEntity();
existing.setTrackingType("CONTAINER_NO"); existing.setTrackingNo("ZCSU0000000");
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(existing);
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"))
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
}
@Test @Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001"); booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567"); container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -121,7 +170,7 @@ class CarrierScheduleQueryServiceTest {
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& fact.getActualAt() != null), eq("CSNU1234567")); && fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction()) "AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null && history.getFromVersion() == null
@@ -136,7 +185,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567"); container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -176,7 +225,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567"); container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -190,7 +239,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class)); verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class));
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox, times(1)).publish(any(CarrierScheduleQueryEntity.class), verify(nodeOutbox, times(1)).publish(any(CarrierScheduleQueryEntity.class),
any(CarrierScheduleNodeFactEntity.class), eq("CSNU1234567")); any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
verify(repository, times(1)).insert(any(CarrierScheduleQueryHistoryEntity.class)); verify(repository, times(1)).insert(any(CarrierScheduleQueryHistoryEntity.class));
} }
@@ -199,7 +248,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567"); container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -234,7 +283,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567"); container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -10,6 +10,8 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Arrays; import java.util.Arrays;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -73,21 +75,39 @@ class ContainerBookingMapperContractTest {
} }
@Test @Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁") @DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
void guardsUpdatesInSql() { void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) String draftSql = String.join("\n", ContainerBookingMapper.class
.map(method -> method.getAnnotation(Update.class)) .getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class,
.filter(annotation -> annotation != null) long.class, String.class).getAnnotation(Update.class).value());
.flatMap(annotation -> Arrays.stream(annotation.value())) String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.reduce("", (left, right) -> left + "\n" + right); .filter(method -> method.getName().equals("linkReleasedBooking"))
assertThat(allSql) .findFirst().orElseThrow().getAnnotation(Update.class).value());
assertThat(draftSql)
.contains("capacity_cbm") .contains("capacity_cbm")
.contains("p.plan_status!='EDITING'") .contains("p.plan_status!='EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.contains("version=#{expectedVersion}") .contains("version=#{expectedVersion}")
.doesNotContain("container_no=", "cabinet_no=")
.doesNotContain("&lt;") .doesNotContain("&lt;")
.doesNotContain("&gt;"); .doesNotContain("&gt;");
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
"ocean_booking_id IS NULL");
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
.doesNotContain("updateTransport", "unlinkReleasedBooking");
}
@Test
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
for (String source : new String[] {
Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")),
Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))
}) {
assertThat(source)
.contains("国内唯一权威真实集装箱号")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("CONSTRAINT chk_container_booking_iso6346")
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
}
} }
} }
@@ -38,6 +38,15 @@ class ContainerOverviewSqlContractTest {
} }
} }
@Test void transportTimelineReadsTheSingleLoadingPlanCorrectionAudit() throws Exception {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("transport", long.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("fl_loading_plan_history", "action_type='UPDATE_PRELOAD_TRANSPORT'")
.contains("before_snapshot", "after_snapshot", "operation_reason")
.doesNotContain("fl_container_transport_history");
}
@Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception { @Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception {
String development = new ClassPathResource("db/phase3-smart-loading-schema.sql") String development = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
@@ -76,16 +76,17 @@ class LoadingListQueryMapperContractTest {
String advance = sql("advance", Update.class); String advance = sql("advance", Update.class);
assertThat(touch) assertThat(touch)
.contains("plan_status IN ('EDITING', 'LOADING')") .contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL") .contains("preload_pushed_at IS NOT NULL")
.contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'")
.contains("version = #{expectedVersion}") .contains("version = #{expectedVersion}")
.contains("version = version + 1"); .contains("version = version + 1");
assertThat(transport) assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("plan_status IN ('EDITING', 'LOADING')") .contains("p.plan_status='EDITING'")
.contains("preload_pushed_at IS NOT NULL") .contains("preload_pushed_at IS NOT NULL")
.contains("#{containerNo} IS NOT NULL") .contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task") .contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE") .doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item"); .doesNotContain("fl_loading_plan_item");
assertThat(advance) assertThat(advance)
@@ -177,7 +177,7 @@ class SmartLoadingSqlContractTest {
.contains("三期入库装柜角色授权未完整写入") .contains("三期入库装柜角色授权未完整写入")
.contains("permission.permission_code IN (") .contains("permission.permission_code IN (")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
.contains("permission.permission_code IN (\n 'firstleg:inbound:split'") .contains("'firstleg:inbound:split', 'firstleg:inbound:preload'")
.contains(")) <> 18 THEN"); .contains(")) <> 18 THEN");
} }
@@ -8,16 +8,8 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -49,14 +41,10 @@ import static org.mockito.Mockito.when;
class ContainerServiceTest { class ContainerServiceTest {
@MockBean private ContainerRepository repository; @MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses; @MockBean private WarehouseMasterDataClient warehouses;
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService;
@MockBean private PriceBookQueryUseCase priceBooks; @MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private OceanBookingRepository oceanBookings; @MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service; @Resource private ContainerService service;
@BeforeEach @BeforeEach
@@ -140,152 +128,12 @@ class ContainerServiceTest {
assertThatThrownBy(() -> service.updateDraft( assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request()))) 11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次"); .hasMessageContaining("基础资料不可完整编辑");
verify(repository, never()).updateDraft(any(), any(Integer.class), verify(repository, never()).updateDraft(any(), any(Integer.class),
any(Long.class), any(String.class)); any(Long.class), any(String.class));
} }
@Test
@DisplayName("预装柜中和预装柜完成只更新集装箱号与航次")
void updatesTransportWhitelistAfterPush() {
for (String status : new String[] {"LOADING"}) {
ContainerBookingEntity current = booking(status, 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
service.updateTransport(11L, 9L, "admin", "transport-1",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
}
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
verify(transportHistories).insert(history.capture());
assertThat(history.getValue().getBeforeContainerNo()).isEqualTo("MSCU1234567");
assertThat(history.getValue().getAfterVoyageNo()).isEqualTo("V002");
assertThat(history.getValue().getOperationId()).hasSize(64);
assertThat(history.getValue().getRequestHash()).hasSize(64);
}
@Test
@DisplayName("运输字段没有有效变化时不递增版本且不写历史")
void ignoresTransportNoOp() {
ContainerBookingEntity current = booking("LOADING", 2);
current.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current);
ContainerBookingEntity result = service.updateTransport(11L, 9L, "admin", "transport-noop",
new TransportUpdateRequest(" MSCU1234567 ", " V002 ", 2));
assertThat(result).isSameAs(current);
verify(repository, never()).updateTransport(anyLong(), any(), any(), anyInt(), anyLong(), any());
verify(transportHistories, never()).insert(any(ContainerTransportHistoryEntity.class));
verifyNoInteractions(loadingPickService);
}
@Test
@DisplayName("运输字段精确幂等重放零新增")
void replaysTransportWithoutSecondMutationOrHistory() {
ContainerBookingEntity current = booking("LOADING", 2);
ContainerBookingEntity updated = booking("LOADING", 3);
updated.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current, updated, updated);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
TransportUpdateRequest request = new TransportUpdateRequest("MSCU1234567", "V002", 2);
service.updateTransport(11L, 9L, "admin", "transport-replay", request);
verify(transportHistories).insert(history.capture());
when(transportHistories.selectOperation(eq(11L), eq(9L), anyString()))
.thenReturn(history.getValue());
ContainerBookingEntity replay = service.updateTransport(
11L, 9L, "admin", "transport-replay", request);
assertThat(replay.getVersion()).isEqualTo(3);
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
verify(transportHistories, org.mockito.Mockito.times(1))
.insert(any(ContainerTransportHistoryEntity.class));
}
@Test
@DisplayName("预装清单推送后再绑定集装箱号会创建PDA拣货任务")
void startsPdaTaskWhenContainerIsBoundAfterPreloadPush() {
ContainerBookingEntity current = booking("EDITING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
service.updateTransport(11L, 9L, "admin", "transport-2",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
}
@Test
@DisplayName("PDA任务创建后替换真实柜号不重同步任务明细")
void doesNotResynchronizeExistingPdaTaskWhenContainerNumberChanges() {
ContainerBookingEntity current = booking("EDITING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU7654321", "V002", 2, 9L, "admin"))
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
service.updateTransport(11L, 9L, "admin", "transport-3",
new TransportUpdateRequest("MSCU7654321", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
}
@Test
@DisplayName("PDA任务创建后禁止清空真实集装箱号")
void rejectsClearingContainerNumberAfterPdaTaskStarts() {
ContainerBookingEntity current = booking("LOADING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
when(repository.find(11L)).thenReturn(current);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-4",
new TransportUpdateRequest(null, "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test
@DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() {
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-5",
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("全部只读");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test @Test
@DisplayName("旧版本编辑请求触发乐观锁冲突") @DisplayName("旧版本编辑请求触发乐观锁冲突")
void rejectsStaleVersion() { void rejectsStaleVersion() {
@@ -339,6 +187,7 @@ class ContainerServiceTest {
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份") @DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksReleasedBookingAfterPreloading() { void linksReleasedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2); ContainerBookingEntity container = booking("EDITING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
OceanBookingEntity booking = new OceanBookingEntity(); OceanBookingEntity booking = new OceanBookingEntity();
@@ -349,20 +198,44 @@ class ContainerServiceTest {
when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)); new OceanBookingLinkRequest(21L, 31L, " ms cu 663987 0 ", 2));
assertThat(result).isSameAs(container); assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001"); assertThat(container.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).hasActiveLoadingItems(11L); verify(repository, never()).hasActiveLoadingItems(11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(booking), eq(2), eq(9L), eq("admin")); same(booking), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
} }
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
stubLinkContext();
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联规范化后按全局唯一真实柜号拒绝冲突")
void rejectsNormalizedContainerNumberConflict() {
stubLinkContext();
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
}
@Test @Test
@DisplayName("首次关联拒绝装柜仓库不一致") @DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() { void rejectsLinkWhenWarehouseDiffers() {
@@ -385,13 +258,14 @@ class ContainerServiceTest {
@DisplayName("未发布订舱不可关联") @DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() { void rejectsDraftBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
OceanBookingEntity booking = new OceanBookingEntity(); OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("DRAFT"); booking.setId(21L); booking.setStatus("DRAFT");
when(repository.find(11L)).thenReturn(container); when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.find(21L)).thenReturn(booking);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已发布"); .isInstanceOf(BizException.class).hasMessageContaining("已发布");
} }
@@ -399,6 +273,7 @@ class ContainerServiceTest {
@DisplayName("订舱箱型需求达到数量上限时拒绝关联") @DisplayName("订舱箱型需求达到数量上限时拒绝关联")
void rejectsRequirementOverAllocation() { void rejectsRequirementOverAllocation() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
OceanBookingEntity booking = new OceanBookingEntity(); OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
@@ -409,7 +284,7 @@ class ContainerServiceTest {
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("数量上限"); .isInstanceOf(BizException.class).hasMessageContaining("数量上限");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString()); any(), anyInt(), anyLong(), anyString());
@@ -418,6 +293,7 @@ class ContainerServiceTest {
private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel,
String requirementType) { String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setWarehouseId(101L); container.setChannelCode("SEA-US");
container.setContainerType("40HC"); container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity(); OceanBookingEntity oceanBooking = new OceanBookingEntity();
@@ -431,38 +307,38 @@ class ContainerServiceTest {
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString()); any(), anyInt(), anyLong(), anyString());
} }
@Test private void stubLinkContext() {
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") ContainerBookingEntity container = booking("WAIT_LOADING", 2);
void unlinksEmptySimulationDirectly() { container.setContainerNo(null);
ContainerBookingEntity linked = booking("WAIT_LOADING", 2); container.setWarehouseId(101L); container.setChannelCode("SEA-US");
linked.setOceanBookingId(21L); container.setContainerType("40HC");
ContainerBookingEntity detached = booking("WAIT_LOADING", 3); OceanBookingEntity oceanBooking = new OceanBookingEntity();
when(repository.find(11L)).thenReturn(linked, detached); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1); oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin", requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
new OceanBookingUnlinkRequest(2, "换绑")); when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
assertThat(result).isSameAs(detached); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin"); when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
verify(oceanBookings).deleteAllocation(11L);
} }
@Test @Test
@DisplayName("首次关联遇到有效装柜证据时拒绝") @DisplayName("首次关联遇到有效装柜证据时拒绝")
void rejectsLinkWhenEvidenceExists() { void rejectsLinkWhenEvidenceExists() {
ContainerBookingEntity container = booking("EDITING", 2); ContainerBookingEntity container = booking("EDITING", 2);
container.setContainerNo(null);
when(repository.find(11L)).thenReturn(container); when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true); when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据"); .isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings); verifyNoInteractions(oceanBookings);
} }
@@ -485,7 +361,7 @@ class ContainerServiceTest {
} }
private static SaveRequest request() { private static SaveRequest request() {
return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮", return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", "V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A", "ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
@@ -72,10 +72,36 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30"); assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91");
assertThat(first.payload().has("containerBookingId")).isFalse();
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2); assertThat(first.eventVersion()).isEqualTo(2);
} }
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
Row value = row(11, 100, 101); value.setContainerNo(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("真实集装箱号");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void rejectsNonCanonicalContainerNumberAndDuplicateEventKeyPayloadDrift() {
Row value = row(11, 100, 101); value.setContainerNo(" zcsu7881970 ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("真实集装箱号");
value.setContainerNo("ZCSU7881970");
InboundOutboxEntity existing = new InboundOutboxEntity(); existing.setPayloadHash("different");
org.mockito.Mockito.when(outbox.insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)))
.thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
org.mockito.Mockito.when(outbox.selectByEventKey(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(existing);
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() { @Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() {
Row value = row(11, 100, 101); Row value = row(11, 100, 101);
value.setSkuCode(null); value.setSkuCode(null);
@@ -295,7 +321,7 @@ class LoadingCompletedOutboxServiceTest {
private Row row(long account,long inbound,long detail) { private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); r.setAggregateVersion(8); r.setContainerNo("ZCSU7881970"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setWarehouseName("WH"); r.setCustomerName("客户-" + account); r.setWarehouseName("WH"); r.setCustomerName("客户-" + account);
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0)); r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30)); r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
@@ -19,6 +19,7 @@ import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@@ -36,6 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
@@ -53,6 +55,8 @@ class LoadingListServiceTest {
@MockBean @MockBean
private LoadingPickRepository pickRepository; private LoadingPickRepository pickRepository;
@MockBean @MockBean
private ContainerRepository containerRepository;
@MockBean
private LoadingPickService loadingPickService; private LoadingPickService loadingPickService;
@MockBean @MockBean
private LoadingDocumentFileClient documentFiles; private LoadingDocumentFileClient documentFiles;
@@ -80,40 +84,43 @@ class LoadingListServiceTest {
@Test @Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("LOADING", 3); AggregateRow before = row("EDITING", 3);
before.setContainerNo("OLD"); before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
before.setVoyageNo("001E"); before.setVoyageNo("001E");
AggregateRow after = row("LOADING", 4); AggregateRow after = row("EDITING", 4);
after.setContainerNo("CSLU1234567"); after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E"); after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator")) when(repository.updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator"))
.thenReturn(1); .thenReturn(1);
when(repository.preloadDetail(after)).thenReturn( when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "key-1", service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); new TransportUpdateRequest(" cs qu 305438 3 ", "023E", 3, "纠错箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); verify(repository).updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator"); verify(loadingPickService).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("补录箱号")); eq("operator"), eq("纠错箱号"));
} }
@Test @Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("LOADING", 3); AggregateRow before = row("EDITING", 3);
before.setContainerNo("CSLU1234567"); before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
before.setVoyageNo("023E"); before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(repository.preloadDetail(before)).thenReturn( when(repository.preloadDetail(before)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "noop-key", service.updateTransport(10L, 100L, 9L, "operator", "noop-key",
new TransportUpdateRequest(" CSLU1234567 ", " 023E ", 3, "重复保存")); new TransportUpdateRequest(" ms cu 663987 0 ", " 023E ", 3, "重复保存"));
verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(), verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(),
any(), any(), anyLong(), any()); any(), any(), anyLong(), any());
@@ -123,29 +130,32 @@ class LoadingListServiceTest {
} }
@Test @Test
void pushedEditingPreloadCanReceiveContainerNumber() { void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt()); after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSLU1234567"); after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", null, 9L, "operator")).thenReturn(1); when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
when(repository.preloadDetail(after)).thenReturn( when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "editing-key", service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号")); new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"));
verify(loadingPickService).beginIfReady(30L, 9L, "operator"); verify(loadingPickService).beginIfReady(30L, 9L, "operator");
} }
@Test @Test
void editingPlanCannotReceiveTransportBeforePush() { void editingPlanCannotReceiveTransportBeforePush() {
when(repository.aggregate(10L)).thenReturn(row("EDITING", 3)); AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key", assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"))) new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")))
.isInstanceOf(BizException.class).hasMessageContaining("已推送"); .isInstanceOf(BizException.class).hasMessageContaining("已推送");
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(),
@@ -159,7 +169,7 @@ class LoadingListServiceTest {
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest("CSLU1234567", "023E", 5, null))) new TransportUpdateRequest("CSQU3054383", "023E", 5, "完成后纠错")))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()) .extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT); .isEqualTo(ResultCode.CONFLICT);
@@ -167,22 +177,73 @@ class LoadingListServiceTest {
} }
@Test @Test
void shouldRejectClearingContainerNumberAfterPdaTaskStarts() { void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() {
AggregateRow before = row("LOADING", 3); AggregateRow before = row("EDITING", 3);
when(repository.history(anyString())).thenReturn(null); before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(pickRepository.taskByPlan(10L)).thenReturn(new com.baiyue.firstleg.entity.LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(null, "023E", 3, "清空测试"))) new TransportUpdateRequest(" ", "023E", 3, "清空测试")))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
.hasMessageContaining("不能清空真实集装箱号");
verify(repository, never()).updateTransport(eq(10L), eq(3), verify(repository, never()).updateTransport(eq(10L), eq(3),
org.mockito.ArgumentMatchers.nullable(String.class), org.mockito.ArgumentMatchers.nullable(String.class),
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L), org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
org.mockito.ArgumentMatchers.nullable(String.class)); org.mockito.ArgumentMatchers.nullable(String.class));
} }
@Test
void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "claimed-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "领取后纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("已领取");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
}
@Test
void loadingPlanAndMissingInitialOceanBookingNumberBothRejectCorrection() {
AggregateRow loading = row("LOADING", 3);
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
loading.setContainerNo("MSCU6639870");
AggregateRow missing = row("EDITING", 3);
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.aggregate(10L)).thenReturn(loading, missing);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "loading-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "作业后纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("PDA尚未开始");
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "首次补录")))
.isInstanceOf(BizException.class).hasMessageContaining("首次写入");
}
@Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "unique-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(false);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"))
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_container_no"));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "race-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "并发唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
}
@Test @Test
void shouldCompleteLoadingAfterAllPdaItemsPicked() { void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2); AggregateRow before = row("LOADING", 2);
@@ -220,23 +281,26 @@ class LoadingListServiceTest {
@Test @Test
void shouldReplaySameTransportRequestWithoutSecondMutation() { void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("LOADING", 3); AggregateRow before = row("EDITING", 3);
AggregateRow after = row("LOADING", 4); before.setPreloadPushedAt(java.time.LocalDateTime.now());
after.setContainerNo("CSLU1234567"); before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E"); after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator")) when(repository.updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator"))
.thenReturn(1); .thenReturn(1);
when(repository.preloadDetail(after)).thenReturn( when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null); TransportUpdateRequest request = new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错");
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request); service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L), eq("EDITING"), eq("EDITING"), anyString(), snapshot.capture(), eq(9L),
eq("operator"), eq(null)); eq("operator"), eq("纠错"));
HistoryRow replay = new HistoryRow(); HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(snapshot.getValue()); replay.setAfterSnapshot(snapshot.getValue());
when(repository.history(anyString())).thenReturn(replay); when(repository.history(anyString())).thenReturn(replay);
@@ -244,7 +308,24 @@ class LoadingListServiceTest {
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request); service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); verify(repository, times(1)).updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator");
}
@Test
void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reused-key",
new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
} }
@Test @Test
@@ -48,7 +48,7 @@ class OverseasContainerEventConsumerTest {
Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z");
consumer.consume(event("00000000-0000-4000-8000-000000000001", 7, consumer.consume(event("00000000-0000-4000-8000-000000000001", 7,
new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null))); new OverseasContainerUnloadedV1("1001", "ZCSU7881970", completedAt, uploadedAt, null)));
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), 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-20T02:30:00")),
@@ -62,7 +62,7 @@ class OverseasContainerEventConsumerTest {
@Test @Test
void repeatedEventWithSameHashDoesNotApplyAgain() { void repeatedEventWithSameHashDoesNotApplyAgain() {
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); 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); EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload);
ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); ContainerEventInboxEntity existing = new ContainerEventInboxEntity();
@@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest {
@Test @Test
void rejectsTamperedPayloadHash() { void rejectsTamperedPayloadHash() {
EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7, EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7,
new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"),
Instant.parse("2026-08-20T02:30:00Z"), null)); Instant.parse("2026-08-20T02:30:00Z"), null));
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
@@ -102,7 +102,7 @@ class OverseasContainerEventConsumerTest {
private ContainerBookingEntity container() { private ContainerBookingEntity container() {
ContainerBookingEntity value = new ContainerBookingEntity(); ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(1001L); value.setContainerNo("MSCU1234567"); value.setId(1001L); value.setContainerNo("ZCSU7881970");
return value; return value;
} }
} }
+5
View File
@@ -13,6 +13,11 @@
<name>integration-service</name> <name>integration-service</name>
<dependencies> <dependencies>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>common-event-contract</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.baiyue.shared</groupId> <groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-core</artifactId> <artifactId>baiyue-common-core</artifactId>
@@ -1,5 +1,6 @@
package com.baiyue.integration.service; package com.baiyue.integration.service;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
@@ -21,10 +22,20 @@ public class CarrierScheduleProviderService {
} }
public QueryResult query(QueryRequest request) { public QueryResult query(QueryRequest request) {
QueryResult result = adapters.stream().filter(adapter -> adapter.supports(request.carrierCode())).findFirst() if (!"CONTAINER_NO".equals(request.trackingType())) {
throw new IllegalArgumentException("trackingType仅支持CONTAINER_NO");
}
String containerNo;
try {
containerNo = ContainerNumbers.requireCanonical(request.trackingNo());
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("trackingNo必须是规范化真实集装箱号");
}
QueryRequest normalized = new QueryRequest(request.carrierCode(), "CONTAINER_NO", containerNo);
QueryResult result = adapters.stream().filter(adapter -> adapter.supports(normalized.carrierCode())).findFirst()
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) "没有支持该船司的轨迹供应商适配器:" + normalized.carrierCode()))
.query(request); .query(normalized);
if (result == null || result.observations() == null || result.exceptions() == null if (result == null || result.observations() == null || result.exceptions() == null
|| !OUTCOMES.contains(result.outcome())) { || !OUTCOMES.contains(result.outcome())) {
throw new IllegalStateException("轨迹供应商返回空结果"); throw new IllegalStateException("轨迹供应商返回空结果");
@@ -26,8 +26,8 @@ components:
required: [carrierCode, trackingType, trackingNo] required: [carrierCode, trackingType, trackingNo]
properties: properties:
carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
trackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} trackingType: {type: string, enum: [CONTAINER_NO]}
trackingNo: {type: string, minLength: 1, maxLength: 128} trackingNo: {type: string, minLength: 1, maxLength: 128, description: Uppercase normalized physical containerNo supplied by Firstleg; no cabinet/order/booking/BOL fallback.}
StandardNode: StandardNode:
type: string type: string
enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED] enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED]
@@ -18,13 +18,33 @@ class CarrierScheduleProviderServiceTest {
@Test @Test
void fallbackNeverPretendsThatWebsiteScrapingSucceeded() { void fallbackNeverPretendsThatWebsiteScrapingSucceeded() {
var service = new CarrierScheduleProviderService(List.of(new CompliantCarrierScheduleProviderAdapter())); var service = new CarrierScheduleProviderService(List.of(new CompliantCarrierScheduleProviderAdapter()));
QueryResult result = service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")); QueryResult result = service.query(new QueryRequest("COSCO", "CONTAINER_NO", "ZCSU7881970"));
assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED");
assertThat(result.observations()).isEmpty(); assertThat(result.observations()).isEmpty();
assertThat(result.exceptions()).isEmpty(); assertThat(result.exceptions()).isEmpty();
} }
@Test
void onlyDispatchesNormalizedContainerNumberTracking() {
CarrierScheduleProviderAdapter capturing = new CarrierScheduleProviderAdapter() {
@Override public boolean supports(String carrierCode) { return true; }
@Override public QueryResult query(QueryRequest request) {
assertThat(request.trackingType()).isEqualTo("CONTAINER_NO");
assertThat(request.trackingNo()).isEqualTo("ZCSU7881970");
return new QueryResult("TEST", "NO_CHANGE", List.of(), List.of(), null, null, LocalDateTime.now());
}
};
var service = new CarrierScheduleProviderService(List.of(capturing));
service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"));
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "zcsu7881970")))
.hasMessageContaining("规范化真实集装箱号");
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881971")))
.hasMessageContaining("规范化真实集装箱号");
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "BILL_OF_LADING", "BL-1")))
.hasMessageContaining("CONTAINER_NO");
}
@Test @Test
void rejectsProviderSpecificNodesThatWereNotNormalized() { void rejectsProviderSpecificNodesThatWereNotNormalized() {
CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() {
@@ -37,7 +57,7 @@ class CarrierScheduleProviderServiceTest {
} }
}; };
var service = new CarrierScheduleProviderService(List.of(invalid)); var service = new CarrierScheduleProviderService(List.of(invalid));
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "ZCSU7881970")))
.hasMessageContaining("无效标准节点"); .hasMessageContaining("无效标准节点");
} }
@@ -53,7 +73,7 @@ class CarrierScheduleProviderServiceTest {
} }
}; };
var service = new CarrierScheduleProviderService(List.of(invalid)); var service = new CarrierScheduleProviderService(List.of(invalid));
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "ZCSU7881970")))
.hasMessageContaining("无效异常事实"); .hasMessageContaining("无效异常事实");
} }
} }
@@ -12,9 +12,9 @@ import java.util.List;
@Mapper @Mapper
public interface InboundPlanOrderMapper { public interface InboundPlanOrderMapper {
record ContainerOrderRef(String customerId, Long orderId, String currentStatus) { } record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo) { }
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus " + @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo " +
"FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") "FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<ContainerOrderRef> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); List<ContainerOrderRef> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@@ -19,6 +19,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.common.event.ContainerNumbers;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -67,10 +68,10 @@ public class InboundPlanService {
if (orders == null || orders.isEmpty()) { if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
} }
String containerNo = trimToNull(orders.get(0).containerNo()); String containerNo = requiredContainerNo(orders.get(0).containerNo());
if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty");
InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId); InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId);
if (existing != null) { if (existing != null) {
requirePlanContainer(existing, containerNo);
return detail(customerId, existing); return detail(customerId, existing);
} }
validateOrders(orders); validateOrders(orders);
@@ -79,11 +80,7 @@ public class InboundPlanService {
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders) { List<SourceOrderSnapshot> orders) {
String containerNo = trimToNull(orders.get(0).containerNo()); String containerNo = requiredContainerNo(orders.get(0).containerNo());
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound order container number is empty");
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan(); InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId()); plan.setId(IdWorker.getId());
@@ -138,6 +135,14 @@ public class InboundPlanService {
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId); List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
String containerNo = null;
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
String current = requiredContainerNo(ref.containerNo());
if (containerNo == null) containerNo = current;
if (!Objects.equals(containerNo, current)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
}
}
List<InboundPlan> existingPlans = refs.stream() List<InboundPlan> existingPlans = refs.stream()
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
.toList(); .toList();
@@ -145,6 +150,7 @@ public class InboundPlanService {
if (existingCount == refs.size()) { if (existingCount == refs.size()) {
List<InboundPlanResponse> replay = new ArrayList<>(refs.size()); List<InboundPlanResponse> replay = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) { for (int index = 0; index < refs.size(); index++) {
requirePlanContainer(existingPlans.get(index), containerNo);
replay.add(detail(refs.get(index).customerId(), existingPlans.get(index))); replay.add(detail(refs.get(index).customerId(), existingPlans.get(index)));
} }
return List.copyOf(replay); return List.copyOf(replay);
@@ -158,15 +164,13 @@ public class InboundPlanService {
} }
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size()); List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
String containerNo = null;
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId());
if (orders == null || orders.isEmpty()) { if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在");
} }
validateOrders(orders); validateOrders(orders);
String currentContainerNo = trimToNull(orders.get(0).containerNo()); String currentContainerNo = requiredContainerNo(orders.get(0).containerNo());
if (containerNo == null) containerNo = currentContainerNo;
if (!Objects.equals(containerNo, currentContainerNo)) { if (!Objects.equals(containerNo, currentContainerNo)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
} }
@@ -181,6 +185,22 @@ public class InboundPlanService {
return List.copyOf(created); return List.copyOf(created);
} }
private String requiredContainerNo(String value) {
try {
return ContainerNumbers.requireCanonical(value);
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"来源柜真实集装箱号缺失或格式无效", exception);
}
}
private void requirePlanContainer(InboundPlan plan, String containerNo) {
String frozen = requiredContainerNo(plan.getContainerNo());
if (!Objects.equals(containerNo, frozen)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划柜号快照与来源柜不一致");
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
@@ -84,6 +84,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Param("sourceRegion") String sourceRegion, @Param("sourceRegion") String sourceRegion,
@Param("sourceInboundId") String sourceInboundId); @Param("sourceInboundId") String sourceInboundId);
@Select("SELECT container_no FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} " +
"ORDER BY customer_id,id FOR UPDATE")
List<String> lockContainerNumbers(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}") @Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}")
long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.LastlegOrderSummary;
@@ -59,7 +60,8 @@ public class LastlegOrderService {
} }
long total = orderMapper.countContainerProgress(query); long total = orderMapper.countContainerProgress(query);
List<ContainerPlanProgressSummary> records = total == 0 ? List.of() List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size()); : orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size())
.stream().map(this::requireFrozenContainer).toList();
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages); return new PageResult<>(records, query.page(), query.size(), total, pages);
} }
@@ -74,7 +76,18 @@ public class LastlegOrderService {
if (summary == null) { if (summary == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
} }
return new ContainerPlanProgressDetail(summary, orderMapper.findContainerProgressItems(bookingId)); return new ContainerPlanProgressDetail(requireFrozenContainer(summary),
orderMapper.findContainerProgressItems(bookingId));
}
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
try {
ContainerNumbers.requireCanonical(summary.containerNo());
return summary;
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"来源柜真实集装箱号缺失、格式无效或存在漂移", exception);
}
} }
public LastlegOrderDetailResponse detail(String customerId, long orderId) { public LastlegOrderDetailResponse detail(String customerId, long orderId) {
@@ -9,6 +9,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
@@ -54,14 +55,15 @@ public class LoadingCompletedConsumer {
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致");
FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope);
if(payload.containerNo()==null||payload.containerNo().isBlank()) String containerNo = ContainerNumbers.requireCanonical(payload.containerNo());
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload);
freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
if(existing!=null) { if(existing!=null) {
if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突");
return countOrders(envelope,payload.containerNo()); return countOrders(envelope,containerNo);
} }
LastlegEventInbox inbox=new LastlegEventInbox(); LastlegEventInbox inbox=new LastlegEventInbox();
@@ -74,21 +76,18 @@ public class LoadingCompletedConsumer {
|| !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("事件摘要冲突", exception); throw new IllegalArgumentException("事件摘要冲突", exception);
} }
return countOrders(envelope,payload.containerNo()); return countOrders(envelope,containerNo);
} }
// The loading-plan and container-shipping events are independent aggregate streams. // The loading-plan and container-shipping events are independent aggregate streams.
// Seeding the shipping version from the loading-plan version makes a valid first node // Seeding the shipping version from the loading-plan version makes a valid first node
// (aggregateVersion=0) look stale whenever the loading plan has a higher version. // (aggregateVersion=0) look stale whenever the loading plan has a higher version.
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
payload.containerNo(), 0, envelope.handoverVersion());
String orderNo = orderNoGenerator.next(); String orderNo = orderNoGenerator.next();
JsonNode rawOrders = envelope.payload().path("orders"); JsonNode rawOrders = envelope.payload().path("orders");
for (int index = 0; index < payload.orders().size(); index++) { for (int index = 0; index < payload.orders().size(); index++) {
var source = payload.orders().get(index); var source = payload.orders().get(index);
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
createOrder(envelope, payload, source, orderNo, rawSource); createOrder(envelope, payload, source, orderNo, rawSource, containerNo, sourceContainerBookingId);
} }
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
return payload.orders().size(); return payload.orders().size();
@@ -109,6 +108,34 @@ public class LoadingCompletedConsumer {
return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo));
} }
private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId,
String containerNo) {
shippingGates.initialize(sourceContainerBookingId, envelope.tenantId(), "",
containerNo, 0, envelope.handoverVersion());
ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId);
if (frozen == null) {
throw new IllegalStateException("来源柜真实集装箱号冻结失败");
}
if (!containerNo.equals(ContainerNumbers.requireCanonical(frozen.getContainerNo()))) {
throw new IllegalStateException("同一 sourceContainerBookingId 的真实集装箱号冲突");
}
for (String existing : orderMapper.lockContainerNumbers(sourceContainerBookingId)) {
if (!containerNo.equals(ContainerNumbers.requireCanonical(existing))) {
throw new IllegalStateException("同一 sourceContainerBookingId 的尾程订单柜号冲突");
}
}
}
private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) {
String value = envelope.eventVersion() == 2
? envelope.payload().path("sourceContainerBookingId").asText(null)
: payload.containerBookingId();
if (value == null || value.isBlank() || value.trim().length() > 128) {
throw new IllegalArgumentException("事件缺少稳定来源柜预订 ID sourceContainerBookingId");
}
return value.trim();
}
private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) { private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) {
for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) {
Set<String> orderCartonMarks = new HashSet<>(); Set<String> orderCartonMarks = new HashSet<>();
@@ -158,7 +185,8 @@ public class LoadingCompletedConsumer {
} }
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource,
String containerNo, String sourceContainerBookingId) {
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
if(existing!=null) { if(existing!=null) {
@@ -186,7 +214,7 @@ public class LoadingCompletedConsumer {
order.setCustomerName(source.customerName()); order.setCustomerName(source.customerName());
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
@@ -195,7 +223,7 @@ public class LoadingCompletedConsumer {
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanId(payload.loadingPlanId());
order.setSourceContainerBookingId(payload.containerBookingId()); order.setSourceContainerBookingId(sourceContainerBookingId);
order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
@@ -170,7 +170,7 @@ paths:
/api/platform/v1/last-leg/orders/container-summaries: /api/platform/v1/last-leg/orders/container-summaries:
get: get:
summary: 分页查询柜级入库计划进度 summary: 分页查询柜级入库计划进度
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -267,7 +267,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/convert-container: /api/platform/v1/last-leg/inbound-plans/convert-container:
post: post:
summary: 按整柜批量生成入库计划 summary: 按整柜批量生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等。 description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有计划的真实 containerNo 快照一致。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等;任何柜号漂移均冲突且不部分写入。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: requestBody:
required: true required: true
@@ -727,10 +727,10 @@ components:
items: { type: string, pattern: '^[1-9][0-9]*$' } items: { type: string, pattern: '^[1-9][0-9]*$' }
ContainerPlanProgressSummary: ContainerPlanProgressSummary:
type: object type: object
required: [sourceContainerBookingId, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady] required: [sourceContainerBookingId, containerNo, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
properties: properties:
sourceContainerBookingId: { type: string } sourceContainerBookingId: { type: string }
containerNo: { type: string, nullable: true } containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 }
totalOrderCount: { type: integer, format: int64 } totalOrderCount: { type: integer, format: int64 }
inboundCodeCount: { type: integer, format: int64 } inboundCodeCount: { type: integer, format: int64 }
missingInboundCodeCount: { type: integer, format: int64 } missingInboundCodeCount: { type: integer, format: int64 }
@@ -106,7 +106,7 @@ class InboundUnloadCompletedOutboxRelayTest {
} }
private EventEnvelope event() { private EventEnvelope event() {
var payload = objectMapper.valueToTree(new OverseasContainerUnloadedV1("1001", "MSCU1234567", var payload = objectMapper.valueToTree(new OverseasContainerUnloadedV1("1001", "MSCU6639870",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null));
return new EventEnvelope("00000000-0000-4000-8000-000000000009", return new EventEnvelope("00000000-0000-4000-8000-000000000009",
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"), EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"),
@@ -45,7 +45,7 @@ class InboundUnloadCompletedOutboxServiceTest {
Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z"); Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z");
Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z"); Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z");
service.publishContainerCompleted("100", "booking-1", "MSCU1234567", service.publishContainerCompleted("100", "booking-1", "MSCU6639870",
containerCompletedAt, photoUploadedAt, 7); containerCompletedAt, photoUploadedAt, 7);
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
@@ -64,7 +64,7 @@ class InboundUnloadCompletedOutboxServiceTest {
void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception { void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception {
InboundPlan plan = new InboundPlan(); InboundPlan plan = new InboundPlan();
plan.setSourceOrderId(11L); plan.setSourceOrderId(11L);
plan.setContainerNo("MSCU1234567"); plan.setContainerNo("MSCU6639870");
InboundPdaTask task = new InboundPdaTask(); InboundPdaTask task = new InboundPdaTask();
task.setCustomerId("100"); task.setCustomerId("100");
task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00")); task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00"));
@@ -87,11 +87,11 @@ class InboundUnloadCompletedOutboxServiceTest {
@Test @Test
void repeatedContainerCompletionDoesNotPublishAnotherEvent() { void repeatedContainerCompletionDoesNotPublishAnotherEvent() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567"); LastlegEventOutbox existing = existing("100", "booking-1", "MSCU6639870");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing); .thenReturn(existing);
service.publishContainerCompleted("100", "booking-1", "MSCU1234567", service.publishContainerCompleted("100", "booking-1", "MSCU6639870",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8); Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8);
verify(mapper, never()).insert(any(LastlegEventOutbox.class)); verify(mapper, never()).insert(any(LastlegEventOutbox.class));
@@ -99,11 +99,11 @@ class InboundUnloadCompletedOutboxServiceTest {
@Test @Test
void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() { void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() {
LastlegEventOutbox existing = existing("100", "booking-1", "MSCU1234567"); LastlegEventOutbox existing = existing("100", "booking-1", "MSCU6639870");
when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing); .thenReturn(existing);
assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "TGHU7654321", assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "ZCSU7881970",
Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8)) Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8))
.isInstanceOf(IllegalStateException.class) .isInstanceOf(IllegalStateException.class)
.hasMessageContaining("conflicts with another container"); .hasMessageContaining("conflicts with another container");
@@ -98,12 +98,12 @@ class InboundPlanServiceTest {
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any()); verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
verify(orderMapper).lockOrder("100", 2L); verify(orderMapper).lockOrder("100", 2L);
assertEquals(2L, result.sourceOrderId()); assertEquals(2L, result.sourceOrderId());
assertEquals("CONT-1", result.containerNo()); assertEquals("MSCU6639870", result.containerNo());
} }
@Test @Test
void convertsAConsistentDestinationAssignedOrder() { void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2", SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10, "DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10,
"2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0); "2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned)); when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned));
@@ -125,7 +125,7 @@ class InboundPlanServiceTest {
existing.setId(9L); existing.setId(9L);
existing.setPlanNo("LIP9"); existing.setPlanNo("LIP9");
existing.setCustomerId("100"); existing.setCustomerId("100");
existing.setContainerNo("CONT-1"); existing.setContainerNo("MSCU6639870");
existing.setStatus("PLANNED"); existing.setStatus("PLANNED");
existing.setLineCount(1); existing.setLineCount(1);
existing.setPalletCount(1); existing.setPalletCount(1);
@@ -153,7 +153,7 @@ class InboundPlanServiceTest {
@Test @Test
void rejectsMissingDestinationWarehouse() { void rejectsMissingDestinationWarehouse() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0); "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
@@ -183,7 +183,7 @@ class InboundPlanServiceTest {
@Test @Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "MSCU6639870", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
@@ -196,8 +196,8 @@ class InboundPlanServiceTest {
assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class) assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class)
.getAnnotation(Transactional.class)); .getAnnotation(Transactional.class));
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED"))); new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED", "MSCU6639870")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A"))); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A")));
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B"))); when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B")));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
@@ -217,10 +217,10 @@ class InboundPlanServiceTest {
@Test @Test
void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() { void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870"),
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2", when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0))); "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0)));
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
@@ -233,8 +233,8 @@ class InboundPlanServiceTest {
@Test @Test
void completeContainerReplayReturnsStableExistingPlansWithoutWriting() { void completeContainerReplayReturnsStableExistingPlansWithoutWriting() {
var refs = List.of( var refs = List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED")); new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED", "MSCU6639870"));
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs); when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs);
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2")); when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2"));
@@ -249,8 +249,8 @@ class InboundPlanServiceTest {
@Test @Test
void partialExistingPlansAreRejectedBeforeAnyWrite() { void partialExistingPlansAreRejectedBeforeAnyWrite() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870"),
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870")));
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
@@ -259,13 +259,46 @@ class InboundPlanServiceTest {
verify(planMapper, never()).insert(any(InboundPlan.class)); verify(planMapper, never()).insert(any(InboundPlan.class));
} }
@Test
void rejectsContainerDriftAcrossCustomersBeforeWritingAnyPlan() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "PENDING_ASSIGNMENT", "CSQU3054383")));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.convertContainer("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).lockOrder(any(), any());
}
@Test
void rejectsReplayWhenExistingPlanContainerSnapshotHasDrifted() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_PLANNED", "MSCU6639870")));
InboundPlan first = existingPlan("100", 1L, "LIP-1");
InboundPlan drifted = existingPlan("200", 2L, "LIP-2");
drifted.setContainerNo("CSQU3054383");
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(first);
when(planMapper.findBySourceOrder("200", 2L)).thenReturn(drifted);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.convertContainer("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
private InboundPlan existingPlan(String customerId, long orderId, String planNo) { private InboundPlan existingPlan(String customerId, long orderId, String planNo) {
InboundPlan plan = new InboundPlan(); InboundPlan plan = new InboundPlan();
plan.setId(orderId + 100); plan.setId(orderId + 100);
plan.setPlanNo(planNo); plan.setPlanNo(planNo);
plan.setCustomerId(customerId); plan.setCustomerId(customerId);
plan.setSourceOrderId(orderId); plan.setSourceOrderId(orderId);
plan.setContainerNo("CONT-1"); plan.setContainerNo("MSCU6639870");
plan.setStatus("PENDING_ASSIGNMENT"); plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(1); plan.setLineCount(1);
plan.setPalletCount(1); plan.setPalletCount(1);
@@ -274,12 +307,12 @@ class InboundPlanServiceTest {
} }
private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) { private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) {
return new SourceOrderSnapshot(id, customerId, "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, return new SourceOrderSnapshot(id, customerId, "MSCU6639870", inboundCode, "PENDING_ASSIGNMENT", null,
BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0); BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0);
} }
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, return new SourceOrderSnapshot(id, "100", "MSCU6639870", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
warehouse, warehouse, warehouse, 1, 0); warehouse, warehouse, warehouse, 1, 0);
} }
@@ -204,8 +204,21 @@ class LastlegOrderServiceTest {
verify(orders).findContainerProgressItems("BOOKING-1"); verify(orders).findContainerProgressItems("BOOKING-1");
} }
@Test
void containerProgressDetailFailsClosedWhenContainerNumberFactHasDrifted() {
var drifted = new ContainerPlanProgressSummary("BOOKING-1", null, 2, 2, 0,
2, 2, 0, LocalDateTime.of(2026, 10, 3, 9, 0), false, "柜号缺失或不一致");
when(orders.findContainerProgress("BOOKING-1")).thenReturn(drifted);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.containerProgressDetail("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(orders, never()).findContainerProgressItems(any());
}
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) { private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "CONT-1", 15, 15, 0, return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", 15, 15, 0,
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason); 15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
} }
@@ -2,12 +2,18 @@ package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest { class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),anyString(),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
@@ -18,7 +24,7 @@ class LoadingCompletedConsumerTest {
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
} }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
@@ -26,7 +32,7 @@ class LoadingCompletedConsumerTest {
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","MSCU6639870","CAB-MULTI","92","Warehouse",List.of(order)),2);
assertEquals(1,service.consume(event)); assertEquals(1,service.consume(event));
verify(orders,times(1)).insert(any(LastlegOrder.class)); verify(orders,times(1)).insert(any(LastlegOrder.class));
@@ -40,7 +46,7 @@ class LoadingCompletedConsumerTest {
} }
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
EventEnvelope source=event(); EventEnvelope source=event();
JsonNode node=source.payload().deepCopy(); JsonNode node=v2Payload(source);
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
@@ -62,7 +68,7 @@ class LoadingCompletedConsumerTest {
assertNotNull(cartonCaptor.getAllValues().get(0).getOrderId()); assertNotNull(cartonCaptor.getAllValues().get(0).getOrderId());
} }
@Test void rejectsV2EventWithoutSkuCartonMapping(){ @Test void rejectsV2EventWithoutSkuCartonMapping(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); EventEnvelope source=event(); JsonNode node=v2Payload(source);
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
@@ -74,7 +80,7 @@ class LoadingCompletedConsumerTest {
verify(orders,never()).insert(any(LastlegOrder.class)); verify(orders,never()).insert(any(LastlegOrder.class));
} }
@Test void legacyV2FbaWithCompleteFrozenCartonsPassesTheMappingGate(){ @Test void legacyV2FbaWithCompleteFrozenCartonsPassesTheMappingGate(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); EventEnvelope source=event(); JsonNode node=v2Payload(source);
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)) ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
.set("cartonMarks",json.valueToTree(List.of("ABE3-BOX-1","ABE3-BOX-2","ABE3-BOX-3","ABE3-BOX-4","ABE3-BOX-5"))); .set("cartonMarks",json.valueToTree(List.of("ABE3-BOX-1","ABE3-BOX-2","ABE3-BOX-3","ABE3-BOX-4","ABE3-BOX-5")));
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
@@ -87,7 +93,7 @@ class LoadingCompletedConsumerTest {
verify(cartons,never()).insert(any(InboundPlanCarton.class)); verify(cartons,never()).insert(any(InboundPlanCarton.class));
} }
@Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){ @Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); EventEnvelope source=event(); JsonNode node=v2Payload(source);
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
@@ -98,7 +104,7 @@ class LoadingCompletedConsumerTest {
verify(cartons,never()).insert(any(InboundPlanCarton.class)); verify(cartons,never()).insert(any(InboundPlanCarton.class));
} }
@Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); EventEnvelope source=event(); JsonNode node=v2Payload(source);
for(JsonNode order:node.path("orders")){ for(JsonNode order:node.path("orders")){
((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1"); ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1");
JsonNode item=order.path("items").get(0); JsonNode item=order.path("items").get(0);
@@ -113,6 +119,7 @@ class LoadingCompletedConsumerTest {
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); verify(cartons,times(8)).insert(any(InboundPlanCarton.class));
} }
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
} }
@@ -113,7 +113,7 @@ class ContainerShippingNodeConsumerTest {
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1"); gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("MSCU6639870");
gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version); gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version);
gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt, gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt,
java.time.ZoneOffset.UTC)); return gate; java.time.ZoneOffset.UTC)); return gate;
@@ -121,7 +121,7 @@ class ContainerShippingNodeConsumerTest {
private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) { private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) {
FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1( FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1(
"Q-1", "100", "OB-1", "CB-1", "CONT-1", node, sequence, actualAt, "Q-1", "100", "OB-1", "CB-1", "MSCU6639870", node, sequence, actualAt,
actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64)); actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64));
JsonNode payloadNode = json.valueToTree(payload); JsonNode payloadNode = json.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
@@ -7,6 +7,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function code(response) { return id(response?.body?.code) || 'missing' } function code(response) { return id(response?.body?.code) || 'missing' }
@@ -205,7 +206,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`) const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`)
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse, const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
evidence(detailResponse), OPTIONS) evidence(detailResponse), OPTIONS)
const containerNo = `CL${suffix}${index + 1}`.slice(0, 64) const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, { const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo, oceanBookingId, requirementId: id(requirement.id), containerNo,
expectedVersion: Number(detail?.version), expectedVersion: Number(detail?.version),
@@ -0,0 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
test('HTTP fixtures generate stable unique ISO 6346 container numbers', () => {
const first = iso6346ContainerNumber('fixture-a')
assert.match(first, /^[A-Z]{3}U[0-9]{7}$/)
assert.equal(first, iso6346ContainerNumber('fixture-a'))
assert.notEqual(first, iso6346ContainerNumber('fixture-b'))
})
@@ -0,0 +1,18 @@
import { createHash } from 'node:crypto'
const LETTER_VALUES = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
export function iso6346ContainerNumber(seed, ownerCode = 'TST') {
if (!/^[A-Z]{3}$/.test(ownerCode)) throw new Error('ownerCode must contain three uppercase letters')
const serial = String(createHash('sha256').update(String(seed)).digest().readUInt32BE(0) % 1_000_000)
.padStart(6, '0')
const body = `${ownerCode}U${serial}`
let sum = 0
for (let index = 0; index < body.length; index += 1) {
const code = body.charCodeAt(index)
const value = code >= 48 && code <= 57 ? code - 48 : LETTER_VALUES[code - 65]
sum += value * (2 ** index)
}
return `${body}${(sum % 11) % 10}`
}
@@ -19,7 +19,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /context\.request\('POST', CONTAINERS/) assert.match(source, /context\.request\('POST', CONTAINERS/)
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/) assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`') const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`')
const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`') const transportIndex = source.indexOf('ocean-booking-link')
assert.ok(transportIndex >= 0 && pushIndex > transportIndex) assert.ok(transportIndex >= 0 && pushIndex > transportIndex)
const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex) const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex)
const confirmIndex = source.indexOf('`${LOADING}/loading-plans/${planId}/confirm`', unconfirmedTaskIndex) const confirmIndex = source.indexOf('`${LOADING}/loading-plans/${planId}/confirm`', unconfirmedTaskIndex)
@@ -34,7 +34,8 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/) assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/)
assert.match(source, /计划确认同键同参精确重放/) assert.match(source, /计划确认同键同参精确重放/)
assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/) assert.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/)
assert.match(source, /iso6346ContainerNumber/)
assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/) assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/)
assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/) assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/)
assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/) assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/)
@@ -126,7 +126,7 @@ async function readInbound(context, fixture) {
async function createConfirmedContainer(context, source, label) { async function createConfirmedContainer(context, source, label) {
const value = suffix(context, label) const value = suffix(context, label)
const containerNo = `MAN-${value}`.slice(0, 64) const containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`)
const summary = source.detail.summary const summary = source.detail.summary
const agentResponse = await context.request('GET', const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
@@ -201,18 +201,7 @@ async function createPlanTask(context, label) {
expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`), expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`),
}) })
plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS) plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS)
const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`)
const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS)
const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64)
const transportResponse = await context.request('PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version),
reason: `${context.marker} 人工状态样本`,
})
const transported = context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS)
if (transported?.containerNo !== containerNo || id(transported?.id) !== containerId
|| transported?.cabinetNo !== container?.cabinetNo) {
throw new Error(`${label} 真实集装箱号未绑定到稳定柜次`)
}
const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`,
{ expectedVersion: Number(plan.version) }, `${label}-push`) { expectedVersion: Number(plan.version) }, `${label}-push`)
plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS) plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS)
@@ -484,3 +473,4 @@ export async function run(context, matrix = context.createdResources.manualState
await completeSimpleDocuments(context, completed, matrix) await completeSimpleDocuments(context, completed, matrix)
} }
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
@@ -2,6 +2,7 @@ import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
const MODULE = 'phase3-container-loading' const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -146,7 +147,7 @@ export async function run(context) {
if (!selectedChannel) return if (!selectedChannel) return
const channelName = selectedChannel.channelName const channelName = selectedChannel.channelName
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 } const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const realContainerNo = `TST-${suffix}-01`.slice(0, 64) const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const agentResponse = await context.request('GET', const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
@@ -472,12 +473,6 @@ export async function run(context) {
context.addStep(MODULE, '已消费订舱需求拒绝再次关联', duplicateLinkResponse, context.addStep(MODULE, '已消费订舱需求拒绝再次关联', duplicateLinkResponse,
failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS) failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS)
const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
expectedVersion: Number(linkedContainer.version), reason: context.marker,
})
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
const preloadPageResponse = await context.request('GET', const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`) `${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS) const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
@@ -576,22 +571,24 @@ export async function run(context) {
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照', const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照',
beforeReplacementSnapshotResponse, '', OPTIONS) beforeReplacementSnapshotResponse, '', OPTIONS)
const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64) const replacementContainerNo = iso6346ContainerNumber(`${context.marker}-replacement`)
const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { const replacementResponse = await request(context, 'PATCH',
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`, containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
}) expectedVersion: Number(confirmedPlan?.version), operationReason: `${context.marker}-replace-container-no`,
}, { 'Idempotency-Key': `${context.marker}-replace-container-no`.slice(0, 128) })
const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS) const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS)
context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse, context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse,
replaced?.containerNo === replacementContainerNo, replaced?.summary?.containerNo === replacementContainerNo,
`containerNo=${replaced?.containerNo || 'missing'}`, OPTIONS) `containerNo=${replaced?.summary?.containerNo || 'missing'}`, OPTIONS)
const clearContainerNoResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { const clearContainerNoResponse = await request(context, 'PATCH',
containerNo: null, voyageNo: `V-${suffix}`.slice(0, 64), `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`, containerNo: ' ', voyageNo: `V-${suffix}`.slice(0, 64),
}) expectedVersion: Number(replaced?.summary?.version), operationReason: `${context.marker}-clear-container-no`,
}, { 'Idempotency-Key': `${context.marker}-clear-container-no`.slice(0, 128) })
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse, context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
clearContainerNoResponse.http === 200 && ['A0409', '409'].includes(codeOf(clearContainerNoResponse)), clearContainerNoResponse.http === 200 && ['A0400', '400'].includes(codeOf(clearContainerNoResponse)),
`HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS) `HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS)
const replacedTaskResponse = await context.request('GET', const replacedTaskResponse = await context.request('GET',
@@ -644,6 +641,15 @@ export async function run(context) {
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion, claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) `status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
const claimedCorrectionResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: iso6346ContainerNumber(`${context.marker}-claimed-mutate`), voyageNo: 'CLAIMED',
expectedVersion: Number(claimedPlan?.version), operationReason: `${context.marker}-claimed-mutate`,
}, { 'Idempotency-Key': `${context.marker}-claimed-mutate`.slice(0, 128) })
context.addStep(MODULE, 'PDA任务领取后真实柜号纠错被拒绝', claimedCorrectionResponse,
claimedCorrectionResponse.http === 200 && ['A0409', '409'].includes(codeOf(claimedCorrectionResponse)),
`HTTP=${claimedCorrectionResponse.http}, code=${codeOf(claimedCorrectionResponse)}`, OPTIONS)
const inventoryResponse = await context.request('GET', const inventoryResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
@@ -810,7 +816,7 @@ export async function run(context) {
const immutableResponse = await request(context, 'PATCH', const immutableResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`,
{ containerNo: `MUTATE-${suffix}`.slice(0, 64), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker }, { containerNo: iso6346ContainerNumber(`${context.marker}-completed-mutate`), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker },
{ 'Idempotency-Key': `${context.marker}-completed-mutate`.slice(0, 128) }) { 'Idempotency-Key': `${context.marker}-completed-mutate`.slice(0, 128) })
context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse, context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse,
immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)), immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)),
@@ -79,6 +79,7 @@ test('truck attachment API and Admin upload-reference-delete flow are frozen', (
test('existing container number replacement keeps container plan and PDA identities stable', () => { test('existing container number replacement keeps container plan and PDA identities stable', () => {
assert.match(containerLoadingAcceptance, /replacementContainerNo/) assert.match(containerLoadingAcceptance, /replacementContainerNo/)
assert.match(containerLoadingAcceptance, /PDA任务领取后真实柜号纠错被拒绝/)
assert.match(containerLoadingAcceptance, /containerBookingId: containerId/) assert.match(containerLoadingAcceptance, /containerBookingId: containerId/)
assert.match(containerLoadingAcceptance, /loadingPlanId: planId/) assert.match(containerLoadingAcceptance, /loadingPlanId: planId/)
assert.match(containerLoadingAcceptance, /pdaTaskId: taskId/) assert.match(containerLoadingAcceptance, /pdaTaskId: taskId/)
@@ -169,7 +169,7 @@ export async function run(context) {
const base = `/api/platform/v1/inbounds/${inboundId}` const base = `/api/platform/v1/inbounds/${inboundId}`
const suffix = markerSuffix(context.marker) const suffix = markerSuffix(context.marker)
const warehouseId = id(initial?.summary?.warehouseId) const warehouseId = id(initial?.summary?.warehouseId)
const containerNo = `P3B-${suffix}`.slice(0, 64) const containerNo = iso6346ContainerNumber(`${context.marker}-phase3-loading`)
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading'
const supplierResponse = await context.request('GET', const supplierResponse = await context.request('GET',
@@ -1387,3 +1387,4 @@ export async function run(context) {
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW', marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
} }
} }
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
@@ -45,7 +45,7 @@ test('PDA opens only after linking a released requirement and uses stable plan i
const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1) const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1)
assert.ok(releaseIndex >= 0 && linkIndex > releaseIndex && pushIndex > linkIndex assert.ok(releaseIndex >= 0 && linkIndex > releaseIndex && pushIndex > linkIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex) && taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /const realContainerNo = `SPLIT-/) assert.match(source, /const realContainerNo = iso6346ContainerNumber/)
assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/) assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/)
assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/) assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/) assert.match(source, /if \(!transportStable\) return null/)
@@ -8,6 +8,7 @@ const SMART = '/api/platform/v1/first-leg/smart-loading'
const PDA = '/api/platform/v1/first-leg/pda-loading' const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -206,7 +207,7 @@ async function loadOneFragment(context, state) {
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS) `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
const realContainerNo = `SPLIT-${context.marker.replace(/\D/g, '').slice(-10)}-${sequence}`.slice(0, 64) const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`)
if (!requirement?.id) return null if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo, oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo,