diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/ContainerNumbers.java b/common-event-contract/src/main/java/com/baiyue/common/event/ContainerNumbers.java new file mode 100644 index 000000000..2358f022f --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/ContainerNumbers.java @@ -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; + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java index a9a537c41..d3921bd60 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -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("船节点交接只接受带实际时间的标准节点事实"); diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 261939432..b83991f26 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -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 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 orders) { diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java index a919d58c5..3f1994896 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -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); + } } diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml index 39b7d97f6..ba458d494 100644 --- a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -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} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/ContainerNumbersTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/ContainerNumbersTest.java new file mode 100644 index 000000000..70b709ba5 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/ContainerNumbersTest.java @@ -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"); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java index faaa767cb..807c0829f 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -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 diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index d90ea1c4e..3864bd3d1 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -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"); } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java index 0942ac072..bfbbc938f 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -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"); + } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 04b8b29cd..b3d451168 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4869,7 +4869,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence ( CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', 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 '平台唯一内部柜号', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', 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_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_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='平台货柜预订资料;预装状态唯一来源为装柜计划'; 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 CHECK (record_status IN ('ACTIVE','MERGED')); 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$$ DELIMITER ; CALL phase3_container_real_unit_columns(); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index f904c7984..c130ae023 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -45,6 +45,11 @@ public class EventInboxService { if (existing != null) { return duplicateOrConflict(existing, envelope); } + String idempotencyKey = idempotencyKey(envelope); + BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey); + if (sameBusinessEvent != null) { + return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope); + } BridgeInbox inbox = toInbox(envelope); try { inboxStore.insert(inbox); @@ -52,13 +57,25 @@ public class EventInboxService { return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); } catch (DuplicateKeyException exception) { BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); + if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey); if (concurrent == null) { 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) { if (!sameEnvelope(existing, envelope)) { metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); @@ -69,6 +86,13 @@ public class EventInboxService { } 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 && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) && Objects.equals(existing.getEventType(), envelope.eventType()) @@ -80,16 +104,13 @@ public class EventInboxService { && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) - && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) - && Objects.equals(existing.getTraceId(), envelope.traceId()) - && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) - && Objects.equals(existing.getCausationId(), envelope.causationId()); + && Objects.equals(existing.getCorrelationId(), envelope.correlationId()); } private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); - inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId()); + inbox.setIdempotencyKey(idempotencyKey(envelope)); inbox.setEventType(envelope.eventType()); inbox.setEventVersion(envelope.eventVersion()); inbox.setSourceRegion(envelope.sourceRegion().name()); @@ -117,4 +138,8 @@ public class EventInboxService { private static Instant normalizeOccurredAt(Instant value) { return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); } + + private static String idempotencyKey(EventEnvelope envelope) { + return envelope.sourceRegion() + ":" + envelope.correlationId(); + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java index e96519c13..b542caebb 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java @@ -7,5 +7,6 @@ import com.baiyue.eventbridge.domain.BridgeInbox; */ public interface InboxStore { BridgeInbox findByEventId(String eventId); + BridgeInbox findByIdempotencyKey(String idempotencyKey); void insert(BridgeInbox inbox); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java index d68c0f983..fedcdf41c 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java @@ -4,6 +4,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; /** * 基于 MyBatis-Plus 的入站幂等存储。 @@ -17,6 +18,12 @@ public class MybatisInboxStore implements InboxStore { return mapper.selectById(eventId); } + @Override + public BridgeInbox findByIdempotencyKey(String idempotencyKey) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(BridgeInbox::getIdempotencyKey, idempotencyKey)); + } + @Override public void insert(BridgeInbox inbox) { mapper.insert(inbox); diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql index 4bbfe6bd4..300f674ec 100644 --- a/event-bridge-service/src/main/resources/db/event-bridge.sql +++ b/event-bridge-service/src/main/resources/db/event-bridge.sql @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( CREATE TABLE IF NOT EXISTS bridge_inbox ( 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_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 6789ed520..4bd350a39 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -106,6 +106,32 @@ class EventInboxServiceTest { .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 void 并发插入冲突后按既有记录判定为重复() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index dc5c82047..dd10cd1a4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -6,10 +6,8 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; 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.OceanBookingLinkRequest; -import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.service.ContainerService; import com.baiyue.firstleg.service.FirstlegScopeGuard; @@ -96,30 +94,15 @@ public class PlatformContainerController { @PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜") public ApiResponse 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 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')") @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") - @Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许") + @Operation(summary="编辑货柜基础资料", description="仅待装柜或预装草稿时允许;不得写入真实集装箱号或内部柜次号") public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody FullUpdateRequest request) { FirstlegScopeGuard.platform(user); 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 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") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @Operation(summary = "查询货柜目的港清关资料") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/Iso6346ContainerNumber.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/Iso6346ContainerNumber.java new file mode 100644 index 000000000..f314180eb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/Iso6346ContainerNumber.java @@ -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结构、设备类别和校验位"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index f18cd0fb2..600024f29 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -46,7 +46,6 @@ public final class ContainerDtos { @Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑") public record SaveRequest( - @Size(max = 64) String containerNo, @NotNull @Positive Long warehouseId, @Size(max = 128) String warehouseName, @Size(max = 128) String billOfLadingNo, @@ -80,19 +79,6 @@ public final class ContainerDtos { @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 = "新增内部模拟柜;内部柜号由服务端生成") public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode, @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 @PositiveOrZero Integer expectedVersion) {} - @Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联") - public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion, - @NotBlank @Size(max = 500) String reason) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index e3b9327f9..2916068de 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -50,7 +51,7 @@ public final class LoadingListDtos { @Size(max = 64) String containerNo, @Size(max = 64) String voyageNo, @NotNull @Min(0) Integer expectedVersion, - @Size(max = 500) String operationReason + @NotBlank @Size(max = 500) String operationReason ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java deleted file mode 100644 index 4b98dfa1f..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java +++ /dev/null @@ -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; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index e99d1dd72..2a4978b09 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -123,26 +123,8 @@ public interface ContainerBookingMapper extends BaseMapper documents(@Param("id") long id); - @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List 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 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 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); @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 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 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java deleted file mode 100644 index 380fefbf7..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java +++ /dev/null @@ -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 { - @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); -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java index e65c1342d..ff4332aa3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java @@ -3,7 +3,11 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.InboundOutboxEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; @Mapper public interface InboundOutboxMapper extends BaseMapper { + @Select("SELECT * FROM fl_inbound_outbox WHERE event_key=#{eventKey} LIMIT 1") + InboundOutboxEntity selectByEventKey(@Param("eventKey") String eventKey); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 2306dafaa..af02c7b95 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -227,9 +227,18 @@ public interface LoadingListQueryMapper { UPDATE fl_loading_plan SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 - WHERE id = #{planId} AND plan_status IN ('EDITING', 'LOADING') - AND (plan_status = 'LOADING' OR preload_pushed_at IS NOT NULL) + WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL 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, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @@ -240,15 +249,44 @@ public interface LoadingListQueryMapper { SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}, b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 - WHERE p.id = #{planId} AND p.plan_status IN ('EDITING', 'LOADING') - AND (p.plan_status = 'LOADING' OR p.preload_pushed_at IS NOT NULL) - AND (#{containerNo} IS NOT NULL OR NOT EXISTS ( - SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id)) + WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL + AND #{containerNo} IS NOT NULL + 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=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, @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, @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 fl_preload_plan SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 4c5a90237..6ff80c834 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -24,7 +24,7 @@ public final class CarrierScheduleQueryDtos { public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion, @NotNull @Positive Long customerId, @NotNull @Positive Long oceanBookingId, - @Positive Long containerBookingId, + @NotNull @Positive Long containerBookingId, @NotBlank @Size(max = 16) String carrierCode, @NotBlank @Size(max = 32) String trackingType, @NotBlank @Size(max = 128) String trackingNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java index 5f949eece..55fab5082 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.ContainerNumbers; import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -36,10 +37,16 @@ public class CarrierScheduleNodeOutboxService { public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact, String containerNo) { 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); var payload = new FirstLegContainerShippingNodeRecordedV1( 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.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); @@ -61,7 +68,15 @@ public class CarrierScheduleNodeOutboxService { row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 417ab2d60..9a0bc0916 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.ContainerNumbers; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; @@ -55,7 +56,7 @@ import java.util.Set; @Service public class CarrierScheduleQueryService { static final int MAX_SLOT_ATTEMPTS = 3; - private static final Set TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); + private static final Set TRACKING_TYPES = Set.of("CONTAINER_NO"); @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @@ -91,21 +92,15 @@ public class CarrierScheduleQueryService { long operatorId, String operatorName) { CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId, containerBookingId); - if (existing != null) return existing; OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); validateLinks(customerId, oceanBookingId, containerBookingId); - String trackingType; - String trackingNo; - if (StringUtils.hasText(container.getContainerNo())) { - trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim(); - } else if (StringUtils.hasText(booking.getBillOfLadingNo())) { - trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim(); - } else if (StringUtils.hasText(booking.getBookingNo())) { - trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim(); - } else { - throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); + String trackingType = "CONTAINER_NO"; + String trackingNo = normalizedContainerNo(container); + if (existing != null) { + requireFrozenTrackingIdentity(existing, trackingNo); + return existing; } PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo); LocalDateTime now = now(); @@ -129,7 +124,10 @@ public class CarrierScheduleQueryService { catch (DuplicateKeyException exception) { CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, containerBookingId); - if (concurrent != null) return concurrent; + if (concurrent != null) { + requireFrozenTrackingIdentity(concurrent, trackingNo); + return concurrent; + } throw exception; } persistenceStage = "NODE_FACT"; @@ -352,15 +350,21 @@ public class CarrierScheduleQueryService { Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request)); if (replay != null) return replay; 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 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(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); value.setCustomerId(request.customerId()); value.setOceanBookingId(request.oceanBookingId()); 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.setStatus("READY"); value.setTriggerSource("MANUAL"); value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); @@ -516,10 +520,25 @@ public class CarrierScheduleQueryService { private String requireContainerNo(long containerBookingId) { ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); - if (container == null || !StringUtils.hasText(container.getContainerNo())) { + if (container == null) { 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) { @@ -598,7 +617,7 @@ public class CarrierScheduleQueryService { private static String normalizeTrackingType(String value) { String normalized = value.trim().toUpperCase(Locale.ROOT); if (!TRACKING_TYPES.contains(normalized)) { - throw badRequest("trackingType仅支持BOOKING_NO、BILL_OF_LADING或CONTAINER_NO"); + throw badRequest("trackingType仅支持CONTAINER_NO"); } return normalized; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 900dcb1b9..4afe52209 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -33,9 +33,6 @@ public class ContainerRepository { .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; } 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, com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking, 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 hasEvidence(long id) { return mapper.hasEvidence(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, String voyageNo, java.time.LocalDateTime estimatedSailingAt, long operatorId, String operatorName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index c4c32a067..6afdf2102 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -86,6 +86,10 @@ public class LoadingListRepository { 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, long operatorId, String operatorName) { return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 612018986..f511c0ca7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -152,8 +152,8 @@ public class ContainerOverviewService { private List timeline(long id, Long taskId, List exceptionHistory) { var result = new ArrayList(); - mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(), - value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()))); + mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.reason(), + value.operatorId(), value.operatorName(), value.createdAt()))); exceptionHistory.forEach(value -> result.add(new TimelineEntry(value.action(), value.reason(), value.operatorId(), value.operatorName(), value.occurredAt()))); if (taskId != null) mapper.pickTimeline(taskId).forEach(value -> result.add(new TimelineEntry( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index f93e8e578..921917055 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -11,17 +11,11 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.Query; 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.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.LoadingPlanEntity; -import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; -import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; 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.port.in.PriceBookQueryUseCase; 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.OceanBookingContainerAllocationEntity; import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Objects; @Service public class ContainerService { @Resource private ContainerRepository repository; @Resource private WarehouseMasterDataClient warehouses; - @Resource private SmartLoadingRepository smartLoadingRepository; - @Resource private LoadingPickRepository loadingPickRepository; - @Resource private LoadingPickService loadingPickService; @Resource private PriceBookQueryUseCase priceBooks; @Resource private OceanBookingRepository oceanBookings; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private FirstlegDocumentNumberGenerator documentNumbers; - @Autowired(required = false) private ContainerTransportHistoryMapper transportHistories; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; public PageResult page(Query query) { @@ -91,56 +76,44 @@ public class ContainerService { if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId()); 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()) || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); } - if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, - request.expectedVersion(), operatorId, operatorName) != 1) { - throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); + try { + if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo, + 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); 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) { SmartLoadingStatus value = status(container); if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING) + || trim(container.getContainerNo()) != null || repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId()) || 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 public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName, FullUpdateRequest request) { ContainerBookingEntity current = required(id); requireVersion(current, request.version()); if (status(current) != SmartLoadingStatus.WAIT_LOADING && status(current) != SmartLoadingStatus.EDITING) { - throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); + throw conflict("货柜已进入预装流程,基础资料不可完整编辑"); } validateSchedule(request.data()); ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany()); - ensureUnique(trim(request.data().containerNo()), current.getCabinetNo(), id); copy(current, request.data()); current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId()); @@ -153,98 +126,11 @@ public class ContainerService { 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) { ContainerBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); 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) { try { ApiResponse response = warehouses.active( @@ -279,7 +165,6 @@ public class ContainerService { } private static void copy(ContainerBookingEntity v, SaveRequest r) { 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.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 96083caea..93a24bab2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.ContainerNumbers; import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -49,6 +50,7 @@ public class LoadingCompletedOutboxService { public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + validateContainerIdentity(rows); validateCustomerSnapshots(rows); Map> cartonMarks = cartonLabelPools(rows); List palletRows = snapshotMapper.palletRows(planId); @@ -67,7 +69,8 @@ public class LoadingCompletedOutboxService { .map(value -> order(value, palletRows.stream() .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); 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()), iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders); JsonNode payloadNode = objectMapper.valueToTree(payload); @@ -278,7 +281,36 @@ public class LoadingCompletedOutboxService { value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion()); value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope)); 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 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 33b8e3be1..58575e296 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -23,6 +23,8 @@ import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.repository.LoadingListRepository; 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.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,6 +64,8 @@ public class LoadingListService { @Resource private LoadingPickRepository pickRepository; @Resource + private ContainerRepository containerRepository; + @Resource @Lazy private LoadingPickService loadingPickService; @Resource @@ -397,41 +401,60 @@ public class LoadingListService { resourceId = "#planId", description = "修改预装集装箱号与航次") public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName, 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); 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); 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); } - 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) && java.util.Objects.equals(before.getVoyageNo(), voyageNo)) { return repository.preloadDetail(before); } - if (containerNo == null && pickRepository.taskByPlan(planId) != null) { - throw conflict("PDA拣货任务已创建,不能清空真实集装箱号"); + if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) { + throw conflict("集装箱号已存在"); } - if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, - operatorId, trim(operatorName)) != 1) { - throw conflict("预装计划已被其他操作修改,请刷新后重试"); + try { + if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试"); + } + } catch (DuplicateKeyException exception) { + throw conflict("集装箱号已存在"); } repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT", before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)), json(values("requestHash", requestHash, "status", before.getPlanStatus(), "containerNo", containerNo, "voyageNo", voyageNo, "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), - trim(request.operationReason())); + reason); loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName); 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 @BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "原子确认装柜计划全部预装票据") @@ -535,12 +558,16 @@ public class LoadingListService { } } - private static void requireLoadingMutable(AggregateRow row) { - if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) { - throw conflict("仅预装柜中或拣货中可修改集装箱号与航次"); + private void requireTransportCorrectionMutable(AggregateRow row) { + if (!"EDITING".equals(row.getPlanStatus())) { + throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); } - if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) { - throw conflict("仅已推送的预装清单可维护运输字段"); + if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段"); + if (!StringUtils.hasText(row.getContainerNo())) { + throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入"); + } + if (repository.hasPdaWorkStarted(row.getPlanId())) { + throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); } } diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 8ea74f0f6..432df921d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence ( CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', 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 '平台唯一内部柜号', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', 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_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_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='平台货柜预订资料;预装状态唯一来源为装柜计划'; 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 CHECK (record_status IN ('ACTIVE','MERGED')); 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$$ DELIMITER ; CALL phase3_container_real_unit_columns(); diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 5fac3399f..bbc2d4d3f 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -96,7 +96,7 @@ components: type: string 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. - 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]} StandardNode: type: string @@ -104,15 +104,15 @@ components: ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} CreateRequest: type: object - required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] + required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo] properties: expectedVersion: {type: integer, minimum: 0, maximum: 0} customerId: {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'} 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} ManualResultRequest: type: object @@ -136,13 +136,13 @@ components: reason: {type: string, minLength: 1, maxLength: 1000} Task: 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: id: {type: integer, format: int64} queryNo: {type: string} customerId: {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'} trackingType: {$ref: '#/components/schemas/TrackingType'} trackingNo: {type: string} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 137ba397b..c3e059a39 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -295,16 +295,10 @@ paths: /api/platform/v1/containers/{id}/ocean-booking-link: post: 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'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}} 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}: get: summary: 查询平台货柜详情 @@ -315,8 +309,8 @@ paths: '200': {description: 成功或货柜不存在} '403': {description: 无货柜查询权限} put: - summary: 草稿阶段完整编辑货柜 - description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING(预装柜中)时允许 + summary: 草稿阶段编辑货柜基础资料 + description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口 parameters: - {$ref: '#/components/parameters/ContainerBookingId'} requestBody: @@ -327,21 +321,6 @@ paths: responses: '200': {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: get: summary: 查询货柜目的港清关资料 @@ -1051,7 +1030,6 @@ components: expectedArrivalDate: {type: string, format: date, nullable: true} feeReviewStatus: {type: string, maxLength: 32, nullable: true} channelName: {type: string, maxLength: 128, nullable: true} - containerNo: {type: string, maxLength: 64, nullable: true} cabinetNo: {type: string, maxLength: 64, nullable: true} sailingDate: {type: string, format: date, nullable: true} loaded: {type: boolean, nullable: true} @@ -1652,14 +1630,6 @@ components: properties: version: {type: integer, minimum: 0} 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: type: object required: [expectedVersion] @@ -1960,14 +1930,8 @@ components: properties: oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱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} - ContainerOceanBookingUnlinkRequest: - type: object - required: [expectedVersion, reason] - properties: - expectedVersion: {type: integer, minimum: 0} - reason: {type: string, minLength: 1, maxLength: 500} ContainerFeeAdjustRequest: type: object additionalProperties: false diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 79b0ff218..f824a711d 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -34,7 +34,9 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/transport: patch: summary: 修改预装运输信息 - description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。 + description: >- + 唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许; + containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。 x-permission: platform:firstleg:preload:edit parameters: - {$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]} ConfirmPreloadRequest: type: object - required: [expectedVersion] + required: [expectedVersion, operationReason] additionalProperties: false properties: expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version} @@ -385,7 +387,7 @@ components: required: [expectedVersion] additionalProperties: false 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: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} - operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} + operationReason: {type: string, minLength: 1, maxLength: 500, description: 必填纠错原因} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 68241a4d2..b295a3d27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -22,9 +22,7 @@ class ContainerControllerContractTest { assertPermission("detail", "platform:firstleg:container:list"); assertPermission("create", "platform:firstleg:container:create"); assertPermission("linkOceanBooking", "platform:firstleg:container:edit"); - assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit"); assertPermission("updateDraft", "platform:firstleg:container:edit"); - assertPermission("updateTransport", "platform:firstleg:container:edit"); assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("overview", "platform:firstleg:container:list"); assertPermission("downloadPdaPhoto", "platform:firstleg:container:list"); @@ -60,14 +58,15 @@ class ContainerControllerContractTest { openapi.indexOf(" /openapi/v1/inbound-measurements:")); assertThat(section) - .contains("/api/platform/v1/containers/{id}/transport:") .contains("/api/platform/v1/containers/{id}/ocean-booking-link:") .contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜") .contains("RELEASED oceanBookingId") .contains("未满配 requirementId") .contains("唯一 containerNo") - .contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜") - .contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝") + .contains("校验ISO 6346结构、设备类别和校验位") + .doesNotContain("/api/platform/v1/containers/{id}/transport:") + .doesNotContain("ContainerOceanBookingUnlinkRequest") + .doesNotContain("ContainerTransportUpdateRequest") .doesNotContain("realContainerBookingId") .contains("/api/platform/v1/containers/{id}/clearance/document/upload:") .contains("multipart/form-data") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 5af98fc5b..3a05df888 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; import static org.assertj.core.api.Assertions.assertThat; @@ -67,8 +68,9 @@ class LoadingListControllerContractTest { .contains("beforeSnapshot") .contains("containerNo") .contains("voyageNo") - .contains("已推送且仍为 EDITING") - .contains("不能清空已创建 PDA 任务的柜号") + .contains("唯一纠错入口") + .contains("校验ISO 6346结构、设备类别和校验位") + .contains("PDA任务尚未领取且无任何物理作业事实") .contains("/loading-plans/{planId}/confirm") .contains("confirmationStatus") .contains("canConfirm") @@ -94,6 +96,16 @@ class LoadingListControllerContractTest { 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 void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/Iso6346ContainerNumberTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/Iso6346ContainerNumberTest.java new file mode 100644 index 000000000..e65663a22 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/domain/Iso6346ContainerNumberTest.java @@ -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("校验位"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 9c3fccd91..581673ddc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -22,12 +22,15 @@ class CarrierScheduleQueryOpenApiContractTest { .contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED") .contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD") .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") + .contains("enum: [CONTAINER_NO]") + .contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks") .contains("latestActualNode:") .contains("latestActualAt:") .contains("manual actual node is equal to or earlier than latestActualNode") .contains("strictly greater sequence than latestActualNode") .contains("internal execution fields are intentionally absent") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") + .doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]") .doesNotContain("/{queryId}/attempts:") .doesNotContain("port-schedule-query:attempt"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java index 0f5bea2a6..ee3d3041a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -19,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; 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 { private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); @@ -51,9 +54,35 @@ class CarrierScheduleNodeOutboxServiceTest { assertThat(envelope.aggregateVersion()).isEqualTo(7); assertThat(envelope.handoverVersion()).isEqualTo(8); 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"); } + @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 void estimatedOnlyFactNeverEntersCrossRegionOutbox() { CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); @@ -70,7 +99,8 @@ class CarrierScheduleNodeOutboxServiceTest { fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM"); fact.setProviderNodeCode("OUTBOUND_CONFIRM"); - service.publish(query, fact, "CSNU1234567"); + query.setTrackingNo("MATU4646247"); + service.publish(query, fact, "MATU4646247"); ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); verify(mapper).insert(captor.capture()); @@ -84,6 +114,7 @@ class CarrierScheduleNodeOutboxServiceTest { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L); + value.setTrackingType("CONTAINER_NO"); value.setTrackingNo("ZCSU7881970"); value.setVersion(7); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index cb7c0eaa5..8ad349e24 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -76,13 +76,17 @@ class CarrierScheduleQueryServiceTest { void createsTaskOnlyForBookingCustomerAndAllocatedContainer() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); 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()); 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().trackingType()).isEqualTo("CONTAINER_NO"); + assertThat(result.query().query().trackingNo()).isEqualTo("ZCSU7881970"); assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().expired()).isTrue(); ArgumentCaptor created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class); @@ -90,12 +94,57 @@ class CarrierScheduleQueryServiceTest { 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 void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - container.setContainerNo("CSNU1234567"); + container.setContainerNo("ZCSU7881970"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); @@ -121,7 +170,7 @@ class CarrierScheduleQueryServiceTest { && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) - && fact.getActualAt() != null), eq("CSNU1234567")); + && fact.getActualAt() != null), eq("ZCSU7881970")); verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> "AUTO_CREATE".equals(history.getAction()) && history.getFromVersion() == null @@ -136,7 +185,7 @@ class CarrierScheduleQueryServiceTest { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - container.setContainerNo("CSNU1234567"); + container.setContainerNo("ZCSU7881970"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); @@ -176,7 +225,7 @@ class CarrierScheduleQueryServiceTest { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - container.setContainerNo("CSNU1234567"); + container.setContainerNo("ZCSU7881970"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); 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)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.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)); } @@ -199,7 +248,7 @@ class CarrierScheduleQueryServiceTest { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - container.setContainerNo("CSNU1234567"); + container.setContainerNo("ZCSU7881970"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); @@ -234,7 +283,7 @@ class CarrierScheduleQueryServiceTest { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - container.setContainerNo("CSNU1234567"); + container.setContainerNo("ZCSU7881970"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index e64149613..57024deee 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -73,21 +75,39 @@ class ContainerBookingMapperContractTest { } @Test - @DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁") - void guardsUpdatesInSql() { - String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) - .map(method -> method.getAnnotation(Update.class)) - .filter(annotation -> annotation != null) - .flatMap(annotation -> Arrays.stream(annotation.value())) - .reduce("", (left, right) -> left + "\n" + right); - assertThat(allSql) + @DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口") + void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception { + String draftSql = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class, + long.class, String.class).getAnnotation(Update.class).value()); + String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("linkReleasedBooking")) + .findFirst().orElseThrow().getAnnotation(Update.class).value()); + assertThat(draftSql) .contains("capacity_cbm") .contains("p.plan_status!='EDITING'") - .contains("p.plan_status='COMPLETED'") - .contains("#{containerNo} IS NOT NULL") - .contains("fl_loading_pick_task") .contains("version=#{expectedVersion}") + .doesNotContain("container_no=", "cabinet_no=") .doesNotContain("<") .doesNotContain(">"); + 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}$'"); + } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index a05276a87..d472982e2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -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 { String development = new ClassPathResource("db/phase3-smart-loading-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 616093bcb..82c9101d6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -76,16 +76,17 @@ class LoadingListQueryMapperContractTest { String advance = sql("advance", Update.class); assertThat(touch) - .contains("plan_status IN ('EDITING', 'LOADING')") + .contains("plan_status = 'EDITING'") .contains("preload_pushed_at IS NOT NULL") + .contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'") .contains("version = #{expectedVersion}") .contains("version = version + 1"); assertThat(transport) .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("#{containerNo} IS NOT NULL") - .contains("fl_loading_pick_task") + .contains("fl_loading_pick_task", "fl_loading_pick_assignment") .doesNotContain("DELETE") .doesNotContain("fl_loading_plan_item"); assertThat(advance) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index ff0f5eacb..cd495d25e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -177,7 +177,7 @@ class SmartLoadingSqlContractTest { .contains("三期入库装柜角色授权未完整写入") .contains("permission.permission_code IN (") .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"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index f0902129a..b147b3c3a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -8,16 +8,8 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; 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.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.LoadingPickRepository; -import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; @@ -49,14 +41,10 @@ import static org.mockito.Mockito.when; class ContainerServiceTest { @MockBean private ContainerRepository repository; @MockBean private WarehouseMasterDataClient warehouses; - @MockBean private SmartLoadingRepository smartLoadingRepository; - @MockBean private LoadingPickRepository loadingPickRepository; - @MockBean private LoadingPickService loadingPickService; @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private OceanBookingRepository oceanBookings; @MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; - @MockBean private ContainerTransportHistoryMapper transportHistories; @Resource private ContainerService service; @BeforeEach @@ -140,152 +128,12 @@ class ContainerServiceTest { assertThatThrownBy(() -> service.updateDraft( 11L, 9L, "admin", new FullUpdateRequest(2, request()))) .isInstanceOf(BizException.class) - .hasMessageContaining("只能修改集装箱号和航次"); + .hasMessageContaining("基础资料不可完整编辑"); verify(repository, never()).updateDraft(any(), any(Integer.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 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 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 @DisplayName("旧版本编辑请求触发乐观锁冲突") void rejectsStaleVersion() { @@ -339,6 +187,7 @@ class ContainerServiceTest { @DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份") void linksReleasedBookingAfterPreloading() { ContainerBookingEntity container = booking("EDITING", 2); + container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); @@ -349,20 +198,44 @@ class ContainerServiceTest { when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); 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); 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(container.getCabinetNo()).isEqualTo("柜-001"); 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")); 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 @DisplayName("首次关联拒绝装柜仓库不一致") void rejectsLinkWhenWarehouseDiffers() { @@ -385,13 +258,14 @@ class ContainerServiceTest { @DisplayName("未发布订舱不可关联") void rejectsDraftBooking() { ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("DRAFT"); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(booking); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", - new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) .isInstanceOf(BizException.class).hasMessageContaining("已发布"); } @@ -399,6 +273,7 @@ class ContainerServiceTest { @DisplayName("订舱箱型需求达到数量上限时拒绝关联") void rejectsRequirementOverAllocation() { ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("RELEASED"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); @@ -409,7 +284,7 @@ class ContainerServiceTest { when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", - new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) .isInstanceOf(BizException.class).hasMessageContaining("数量上限"); verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), any(), anyInt(), anyLong(), anyString()); @@ -418,6 +293,7 @@ class ContainerServiceTest { private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, String requirementType) { ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); @@ -431,38 +307,38 @@ class ContainerServiceTest { when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", - new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), any(), anyInt(), anyLong(), anyString()); } - @Test - @DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") - void unlinksEmptySimulationDirectly() { - ContainerBookingEntity linked = booking("WAIT_LOADING", 2); - linked.setOceanBookingId(21L); - ContainerBookingEntity detached = booking("WAIT_LOADING", 3); - when(repository.find(11L)).thenReturn(linked, detached); - when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1); - - ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin", - new OceanBookingUnlinkRequest(2, "换绑")); - - assertThat(result).isSameAs(detached); - verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin"); - verify(oceanBookings).deleteAllocation(11L); + private void stubLinkContext() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setContainerType("40HC"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); } @Test @DisplayName("首次关联遇到有效装柜证据时拒绝") void rejectsLinkWhenEvidenceExists() { ContainerBookingEntity container = booking("EDITING", 2); + container.setContainerNo(null); when(repository.find(11L)).thenReturn(container); when(repository.hasEvidence(11L)).thenReturn(true); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", - new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) .isInstanceOf(BizException.class).hasMessageContaining("证据"); verifyNoInteractions(oceanBookings); } @@ -485,7 +361,7 @@ class ContainerServiceTest { } private static SaveRequest request() { - return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮", + return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮", "V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", "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, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 2fe47c380..034e20e3c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -72,10 +72,36 @@ class LoadingCompletedOutboxServiceTest { 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("/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(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() { Row value = row(11, 100, 101); value.setSkuCode(null); @@ -295,7 +321,7 @@ class LoadingCompletedOutboxServiceTest { private Row row(long account,long inbound,long detail) { 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.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0)); r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index fdaecbac9..bc047c0d3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -19,6 +19,7 @@ import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.ContainerRepository; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; 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.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; @@ -53,6 +55,8 @@ class LoadingListServiceTest { @MockBean private LoadingPickRepository pickRepository; @MockBean + private ContainerRepository containerRepository; + @MockBean private LoadingPickService loadingPickService; @MockBean private LoadingDocumentFileClient documentFiles; @@ -80,40 +84,43 @@ class LoadingListServiceTest { @Test void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { - AggregateRow before = row("LOADING", 3); - before.setContainerNo("OLD"); + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); before.setVoyageNo("001E"); - AggregateRow after = row("LOADING", 4); - after.setContainerNo("CSLU1234567"); + AggregateRow after = row("EDITING", 4); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + after.setContainerNo("CSQU3054383"); after.setVoyageNo("023E"); when(repository.history(anyString())).thenReturn(null); 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); when(repository.preloadDetail(after)).thenReturn( new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); 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(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), - eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), - eq("operator"), eq("补录箱号")); + eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), + eq("operator"), eq("纠错箱号")); } @Test void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { - AggregateRow before = row("LOADING", 3); - before.setContainerNo("CSLU1234567"); + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); before.setVoyageNo("023E"); when(repository.aggregate(10L)).thenReturn(before); when(repository.preloadDetail(before)).thenReturn( new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); 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(), any(), any(), anyLong(), any()); @@ -123,29 +130,32 @@ class LoadingListServiceTest { } @Test - void pushedEditingPreloadCanReceiveContainerNumber() { + void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() { AggregateRow before = row("EDITING", 3); before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); AggregateRow after = row("EDITING", 4); after.setPreloadPushedAt(before.getPreloadPushedAt()); - after.setContainerNo("CSLU1234567"); + after.setContainerNo("CSQU3054383"); 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( new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); service.updateTransport(10L, 100L, 9L, "operator", "editing-key", - new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号")); + new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")); verify(loadingPickService).beginIfReady(30L, 9L, "operator"); } @Test 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", - new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"))) + new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"))) .isInstanceOf(BizException.class).hasMessageContaining("已推送"); verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), @@ -159,7 +169,7 @@ class LoadingListServiceTest { when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", - new TransportUpdateRequest("CSLU1234567", "023E", 5, null))) + new TransportUpdateRequest("CSQU3054383", "023E", 5, "完成后纠错"))) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -167,22 +177,73 @@ class LoadingListServiceTest { } @Test - void shouldRejectClearingContainerNumberAfterPdaTaskStarts() { - AggregateRow before = row("LOADING", 3); - when(repository.history(anyString())).thenReturn(null); + void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); 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", - new TransportUpdateRequest(null, "023E", 3, "清空测试"))) - .isInstanceOf(BizException.class) - .hasMessageContaining("不能清空真实集装箱号"); + new TransportUpdateRequest(" ", "023E", 3, "清空测试"))) + .isInstanceOf(BizException.class).hasMessageContaining("ISO 6346"); verify(repository, never()).updateTransport(eq(10L), eq(3), org.mockito.ArgumentMatchers.nullable(String.class), org.mockito.ArgumentMatchers.nullable(String.class), eq(9L), 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 void shouldCompleteLoadingAfterAllPdaItemsPicked() { AggregateRow before = row("LOADING", 2); @@ -220,23 +281,26 @@ class LoadingListServiceTest { @Test void shouldReplaySameTransportRequestWithoutSecondMutation() { - AggregateRow before = row("LOADING", 3); - AggregateRow after = row("LOADING", 4); - after.setContainerNo("CSLU1234567"); + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); + AggregateRow after = row("EDITING", 4); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + after.setContainerNo("CSQU3054383"); after.setVoyageNo("023E"); when(repository.history(anyString())).thenReturn(null); 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); when(repository.preloadDetail(after)).thenReturn( new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); ArgumentCaptor 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); verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), - eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L), - eq("operator"), eq(null)); + eq("EDITING"), eq("EDITING"), anyString(), snapshot.capture(), eq(9L), + eq("operator"), eq("纠错")); HistoryRow replay = new HistoryRow(); replay.setAfterSnapshot(snapshot.getValue()); when(repository.history(anyString())).thenReturn(replay); @@ -244,7 +308,24 @@ class LoadingListServiceTest { 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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index 037490b0e..938211c4f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -48,7 +48,7 @@ class OverseasContainerEventConsumerTest { Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); 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")), eq(LocalDateTime.parse("2026-08-20T02:30:00")), @@ -62,7 +62,7 @@ class OverseasContainerEventConsumerTest { @Test 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); EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); @@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest { @Test void rejectsTamperedPayloadHash() { 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)); EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), @@ -102,7 +102,7 @@ class OverseasContainerEventConsumerTest { private ContainerBookingEntity container() { ContainerBookingEntity value = new ContainerBookingEntity(); - value.setId(1001L); value.setContainerNo("MSCU1234567"); + value.setId(1001L); value.setContainerNo("ZCSU7881970"); return value; } } diff --git a/integration-service/pom.xml b/integration-service/pom.xml index c167a47db..9bd856074 100644 --- a/integration-service/pom.xml +++ b/integration-service/pom.xml @@ -13,6 +13,11 @@ integration-service + + com.baiyue.shared + common-event-contract + ${project.version} + com.baiyue.shared baiyue-common-core diff --git a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java index 316bbf195..187e357dc 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -1,5 +1,6 @@ package com.baiyue.integration.service; +import com.baiyue.common.event.ContainerNumbers; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; @@ -21,10 +22,20 @@ public class CarrierScheduleProviderService { } 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( - "没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) - .query(request); + "没有支持该船司的轨迹供应商适配器:" + normalized.carrierCode())) + .query(normalized); if (result == null || result.observations() == null || result.exceptions() == null || !OUTCOMES.contains(result.outcome())) { throw new IllegalStateException("轨迹供应商返回空结果"); diff --git a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml index b376f8cea..dcbda342d 100644 --- a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml +++ b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml @@ -26,8 +26,8 @@ components: required: [carrierCode, trackingType, trackingNo] properties: carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} - trackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} - trackingNo: {type: string, minLength: 1, maxLength: 128} + trackingType: {type: string, enum: [CONTAINER_NO]} + trackingNo: {type: string, minLength: 1, maxLength: 128, description: Uppercase normalized physical containerNo supplied by Firstleg; no cabinet/order/booking/BOL fallback.} StandardNode: 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] diff --git a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java index e14c800f3..1b6355b3a 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -18,13 +18,33 @@ class CarrierScheduleProviderServiceTest { @Test void fallbackNeverPretendsThatWebsiteScrapingSucceeded() { 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.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); assertThat(result.observations()).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 void rejectsProviderSpecificNodesThatWereNotNormalized() { CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { @@ -37,7 +57,7 @@ class CarrierScheduleProviderServiceTest { } }; 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("无效标准节点"); } @@ -53,7 +73,7 @@ class CarrierScheduleProviderServiceTest { } }; 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("无效异常事实"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 544252195..793fba684 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -12,9 +12,9 @@ import java.util.List; @Mapper 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") List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 9181793b8..64dfab200 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -19,6 +19,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.common.event.ContainerNumbers; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -67,10 +68,10 @@ public class InboundPlanService { if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); } - String containerNo = trimToNull(orders.get(0).containerNo()); - if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty"); + String containerNo = requiredContainerNo(orders.get(0).containerNo()); InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId); if (existing != null) { + requirePlanContainer(existing, containerNo); return detail(customerId, existing); } validateOrders(orders); @@ -79,11 +80,7 @@ public class InboundPlanService { private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders) { - String containerNo = trimToNull(orders.get(0).containerNo()); - if (containerNo == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Inbound order container number is empty"); - } + String containerNo = requiredContainerNo(orders.get(0).containerNo()); LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); plan.setId(IdWorker.getId()); @@ -138,6 +135,14 @@ public class InboundPlanService { if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); List refs = orderMapper.lockContainerOrders(bookingId); 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 existingPlans = refs.stream() .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) .toList(); @@ -145,6 +150,7 @@ public class InboundPlanService { if (existingCount == refs.size()) { List replay = new ArrayList<>(refs.size()); for (int index = 0; index < refs.size(); index++) { + requirePlanContainer(existingPlans.get(index), containerNo); replay.add(detail(refs.get(index).customerId(), existingPlans.get(index))); } return List.copyOf(replay); @@ -158,15 +164,13 @@ public class InboundPlanService { } List> snapshots = new ArrayList<>(refs.size()); - String containerNo = null; for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { List orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); } validateOrders(orders); - String currentContainerNo = trimToNull(orders.get(0).containerNo()); - if (containerNo == null) containerNo = currentContainerNo; + String currentContainerNo = requiredContainerNo(orders.get(0).containerNo()); if (!Objects.equals(containerNo, currentContainerNo)) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); } @@ -181,6 +185,22 @@ public class InboundPlanService { 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) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 9a7915a2e..28f6e1884 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -84,6 +84,11 @@ public interface LastlegOrderMapper extends BaseMapper { @Param("sourceRegion") String sourceRegion, @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 lockContainerNumbers( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + @Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}") long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 5a4a91e64..71686ae8e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; @@ -59,7 +60,8 @@ public class LastlegOrderService { } long total = orderMapper.countContainerProgress(query); List 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(); return new PageResult<>(records, query.page(), query.size(), total, pages); } @@ -74,7 +76,18 @@ public class LastlegOrderService { if (summary == null) { 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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index ffb17be0a..b38ab8e20 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -9,6 +9,7 @@ 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.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -54,14 +55,15 @@ public class LoadingCompletedConsumer { if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); - if(payload.containerNo()==null||payload.containerNo().isBlank()) - throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); + String containerNo = ContainerNumbers.requireCanonical(payload.containerNo()); + String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); + freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); - return countOrders(envelope,payload.containerNo()); + return countOrders(envelope,containerNo); } LastlegEventInbox inbox=new LastlegEventInbox(); @@ -74,21 +76,18 @@ public class LoadingCompletedConsumer { || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("事件摘要冲突", exception); } - return countOrders(envelope,payload.containerNo()); + return countOrders(envelope,containerNo); } // 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 // (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(); JsonNode rawOrders = envelope.payload().path("orders"); for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); 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); return payload.orders().size(); @@ -109,6 +108,34 @@ public class LoadingCompletedConsumer { 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) { for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { Set orderCartonMarks = new HashSet<>(); @@ -158,7 +185,8 @@ public class LoadingCompletedConsumer { } 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)); LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); if(existing!=null) { @@ -186,7 +214,7 @@ public class LoadingCompletedConsumer { order.setCustomerName(source.customerName()); order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); 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.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); @@ -195,7 +223,7 @@ public class LoadingCompletedConsumer { .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); order.setSourceLoadingPlanId(payload.loadingPlanId()); - order.setSourceContainerBookingId(payload.containerBookingId()); + order.setSourceContainerBookingId(sourceContainerBookingId); order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 695f6d2c3..4749a8111 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -170,7 +170,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries: get: 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: [] }] parameters: - { 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: post: summary: 按整柜批量生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等。 + description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有计划的真实 containerNo 快照一致。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等;任何柜号漂移均冲突且不部分写入。 security: [{ bearerAuth: [] }] requestBody: required: true @@ -727,10 +727,10 @@ components: items: { type: string, pattern: '^[1-9][0-9]*$' } ContainerPlanProgressSummary: type: object - required: [sourceContainerBookingId, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady] + required: [sourceContainerBookingId, containerNo, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady] properties: 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 } inboundCodeCount: { type: integer, format: int64 } missingInboundCodeCount: { type: integer, format: int64 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index dc52dd01d..472331057 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -106,7 +106,7 @@ class InboundUnloadCompletedOutboxRelayTest { } 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)); return new EventEnvelope("00000000-0000-4000-8000-000000000009", EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java index 4712eba02..89851cfbd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -45,7 +45,7 @@ class InboundUnloadCompletedOutboxServiceTest { Instant photoUploadedAt = Instant.parse("2026-08-24T08:00: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); ArgumentCaptor captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); @@ -64,7 +64,7 @@ class InboundUnloadCompletedOutboxServiceTest { void legacySinglePlanEntryUsesTaskCompletionTime() throws Exception { InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); - plan.setContainerNo("MSCU1234567"); + plan.setContainerNo("MSCU6639870"); InboundPdaTask task = new InboundPdaTask(); task.setCustomerId("100"); task.setCompletedAt(LocalDateTime.parse("2026-08-24T09:30:00")); @@ -87,11 +87,11 @@ class InboundUnloadCompletedOutboxServiceTest { @Test 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")) .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); verify(mapper, never()).insert(any(LastlegEventOutbox.class)); @@ -99,11 +99,11 @@ class InboundUnloadCompletedOutboxServiceTest { @Test 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")) .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)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("conflicts with another container"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 76e3dcdd3..13396760c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -98,12 +98,12 @@ class InboundPlanServiceTest { verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any()); verify(orderMapper).lockOrder("100", 2L); assertEquals(2L, result.sourceOrderId()); - assertEquals("CONT-1", result.containerNo()); + assertEquals("MSCU6639870", result.containerNo()); } @Test 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, "2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0); when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned)); @@ -125,7 +125,7 @@ class InboundPlanServiceTest { existing.setId(9L); existing.setPlanNo("LIP9"); existing.setCustomerId("100"); - existing.setContainerNo("CONT-1"); + existing.setContainerNo("MSCU6639870"); existing.setStatus("PLANNED"); existing.setLineCount(1); existing.setPalletCount(1); @@ -153,7 +153,7 @@ class InboundPlanServiceTest { @Test 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); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); @@ -183,7 +183,7 @@ class InboundPlanServiceTest { @Test 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); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); @@ -196,8 +196,8 @@ class InboundPlanServiceTest { assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class) .getAnnotation(Transactional.class)); when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( - new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), - new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED"))); + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870"), + 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("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B"))); when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) @@ -217,10 +217,10 @@ class InboundPlanServiceTest { @Test void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() { when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( - new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), - new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870"), + 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", 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))); assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); @@ -233,8 +233,8 @@ class InboundPlanServiceTest { @Test void completeContainerReplayReturnsStableExistingPlansWithoutWriting() { var refs = List.of( - new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), - new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED")); + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED", "MSCU6639870")); when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs); when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2")); @@ -249,8 +249,8 @@ class InboundPlanServiceTest { @Test void partialExistingPlansAreRejectedBeforeAnyWrite() { when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( - new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), - new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870"))); when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); @@ -259,13 +259,46 @@ class InboundPlanServiceTest { 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) { InboundPlan plan = new InboundPlan(); plan.setId(orderId + 100); plan.setPlanNo(planNo); plan.setCustomerId(customerId); plan.setSourceOrderId(orderId); - plan.setContainerNo("CONT-1"); + plan.setContainerNo("MSCU6639870"); plan.setStatus("PENDING_ASSIGNMENT"); plan.setLineCount(1); plan.setPalletCount(1); @@ -274,12 +307,12 @@ class InboundPlanServiceTest { } 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); } 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(), warehouse, warehouse, warehouse, 1, 0); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 29d95a056..1dea430bd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -204,8 +204,21 @@ class LastlegOrderServiceTest { 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) { - 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); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 3d1d0feda..0a986e943 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { + private final Map 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; - @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)); } - @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 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 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 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")); } + @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 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 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 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 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 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 loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); } + @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 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 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()); } @@ -18,7 +24,7 @@ class LoadingCompletedConsumerTest { 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 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 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 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(){ 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")); @@ -26,7 +32,7 @@ class LoadingCompletedConsumerTest { 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")))); 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)); verify(orders,times(1)).insert(any(LastlegOrder.class)); @@ -40,7 +46,7 @@ class LoadingCompletedConsumerTest { } @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ 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(1)).put("destinationType","OVERSEAS"); ((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()); } @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(1)).put("destinationType","OVERSEAS"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); @@ -74,7 +80,7 @@ class LoadingCompletedConsumerTest { verify(orders,never()).insert(any(LastlegOrder.class)); } @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)) .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)) @@ -87,7 +93,7 @@ class LoadingCompletedConsumerTest { verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @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(1)).put("destinationType","FBA"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); @@ -98,7 +104,7 @@ class LoadingCompletedConsumerTest { verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ - EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + EventEnvelope source=event(); JsonNode node=v2Payload(source); for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1"); JsonNode item=order.path("items").get(0); @@ -113,6 +119,7 @@ class LoadingCompletedConsumerTest { assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); 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); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 30bec281f..aff149eb8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -113,7 +113,7 @@ class ContainerShippingNodeConsumerTest { private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { 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.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt, 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) { 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)); JsonNode payloadNode = json.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index acd738a66..5808234ef 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -7,6 +7,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading' import { oceanBookingDraft } from './ocean-booking-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 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 detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse, 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`, { oceanBookingId, requirementId: id(requirement.id), containerNo, expectedVersion: Number(detail?.version), diff --git a/scripts/admin-test-env-modules/iso6346-container-number-contract.test.mjs b/scripts/admin-test-env-modules/iso6346-container-number-contract.test.mjs new file mode 100644 index 000000000..5c64dc7d1 --- /dev/null +++ b/scripts/admin-test-env-modules/iso6346-container-number-contract.test.mjs @@ -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')) +}) diff --git a/scripts/admin-test-env-modules/iso6346-container-number.mjs b/scripts/admin-test-env-modules/iso6346-container-number.mjs new file mode 100644 index 000000000..bb7cf9c61 --- /dev/null +++ b/scripts/admin-test-env-modules/iso6346-container-number.mjs @@ -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}` +} diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 864e09dd3..0f3a60065 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -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, /summary\?\.processStatus === 'WAIT_SPLIT'/) 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) const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex) 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, /计划确认同键同参精确重放/) 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, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/) assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 031a93a37..7f63314c8 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -126,7 +126,7 @@ async function readInbound(context, fixture) { async function createConfirmedContainer(context, source, 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 agentResponse = await context.request('GET', '/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`), }) plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS) - const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) - 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 containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`) const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, { expectedVersion: Number(plan.version) }, `${label}-push`) 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) } +import { iso6346ContainerNumber } from './iso6346-container-number.mjs' diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index ca3317374..d9f01d17b 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -2,6 +2,7 @@ import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' +import { iso6346ContainerNumber } from './iso6346-container-number.mjs' const MODULE = 'phase3-container-loading' const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } @@ -146,7 +147,7 @@ export async function run(context) { if (!selectedChannel) return const channelName = selectedChannel.channelName 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 agentResponse = await context.request('GET', '/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, 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', `${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`) const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS) @@ -576,22 +571,24 @@ export async function run(context) { `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照', beforeReplacementSnapshotResponse, '', OPTIONS) - const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64) - const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { - containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), - version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`, - }) + const replacementContainerNo = iso6346ContainerNumber(`${context.marker}-replacement`) + const replacementResponse = await request(context, 'PATCH', + `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { + 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) context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse, - replaced?.containerNo === replacementContainerNo, - `containerNo=${replaced?.containerNo || 'missing'}`, OPTIONS) + replaced?.summary?.containerNo === replacementContainerNo, + `containerNo=${replaced?.summary?.containerNo || 'missing'}`, OPTIONS) - const clearContainerNoResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { - containerNo: null, voyageNo: `V-${suffix}`.slice(0, 64), - version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`, - }) + const clearContainerNoResponse = await request(context, 'PATCH', + `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { + 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, - clearContainerNoResponse.http === 200 && ['A0409', '409'].includes(codeOf(clearContainerNoResponse)), + clearContainerNoResponse.http === 200 && ['A0400', '400'].includes(codeOf(clearContainerNoResponse)), `HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS) const replacedTaskResponse = await context.request('GET', @@ -644,6 +641,15 @@ export async function run(context) { claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion, `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', `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) @@ -810,7 +816,7 @@ export async function run(context) { const immutableResponse = await request(context, 'PATCH', `${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) }) context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse, immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)), diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 3f4f68b1e..5f37dcf5b 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -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', () => { assert.match(containerLoadingAcceptance, /replacementContainerNo/) + assert.match(containerLoadingAcceptance, /PDA任务领取后真实柜号纠错被拒绝/) assert.match(containerLoadingAcceptance, /containerBookingId: containerId/) assert.match(containerLoadingAcceptance, /loadingPlanId: planId/) assert.match(containerLoadingAcceptance, /pdaTaskId: taskId/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index d37f5024e..98e34c0a5 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -169,7 +169,7 @@ export async function run(context) { const base = `/api/platform/v1/inbounds/${inboundId}` const suffix = markerSuffix(context.marker) 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 pdaBase = '/api/platform/v1/first-leg/pda-loading' 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', } } +import { iso6346ContainerNumber } from './iso6346-container-number.mjs' diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index a1a8565c3..04278288b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.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) assert.ok(releaseIndex >= 0 && linkIndex > releaseIndex && pushIndex > linkIndex && 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, /linked\?\.cabinetNo === simulation\.cabinetNo/) assert.match(source, /if \(!transportStable\) return null/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index eaddf6079..19732cb6a 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -8,6 +8,7 @@ const SMART = '/api/platform/v1/first-leg/smart-loading' const PDA = '/api/platform/v1/first-leg/pda-loading' import { oceanBookingDraft } from './ocean-booking-fixture.mjs' +import { iso6346ContainerNumber } from './iso6346-container-number.mjs' function id(value) { return value == null ? '' : String(value) } 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), `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS) 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 const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo,