feat: enforce canonical container identity across regions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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("船节点交接只接受带实际时间的标准节点事实");
|
||||
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
+6
@@ -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");
|
||||
}
|
||||
}
|
||||
+12
@@ -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
|
||||
|
||||
+21
-5
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user