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;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
String queryId,
String customerId,
String oceanBookingId,
String containerBookingId,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo,
String standardNode,
int nodeSequence,
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|| containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
@@ -1,14 +1,26 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
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 estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
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,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
Instant unloadingPhotoUploadedAt,
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:
FirstLegLoadingPlanCompletedV2:
type: object
required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
properties:
loadingPlanId: {type: string}
containerBookingId: {type: string}
containerNo: {type: string}
sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
cabinetNo: {type: string}
warehouseId: {type: string}
warehouseName: {type: string}
@@ -40,3 +40,29 @@ components:
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
pallets: {type: array, items: {type: object}}
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.standardNode()).isEqualTo("PICKUP_READY");
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
@@ -18,7 +18,7 @@ class FirstLegLoadingPlanCompletedV2Test {
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
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));
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("/actualPortArrivalAt").isNull()).isTrue();
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/packageCount").asInt()).isEqualTo(12);
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");
}
@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
void carriesTheThreeItemRemarks() throws Exception {
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",
null, null, null, 1, 2, null, null, null, null, List.of(item),
"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);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
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, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
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);
@@ -74,7 +89,7 @@ class FirstLegLoadingPlanCompletedV2Test {
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
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");
}
@@ -89,7 +104,7 @@ class FirstLegLoadingPlanCompletedV2Test {
"11", "客户甲", "FIRST_LEG", List.of());
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/cartonMarks")).hasSize(2);
@@ -118,5 +133,6 @@ class FirstLegLoadingPlanCompletedV2Test {
.contains("consumers must not query domestic IAM")
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
.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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
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);
String json = mapper.writeValueAsString(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()).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");
}
}