feat: enforce canonical container identity across regions
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Canonical physical container number used by cross-region business facts. */
|
||||
public final class ContainerNumbers {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private ContainerNumbers() { }
|
||||
|
||||
public static String normalizeRequired(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
}
|
||||
StringBuilder compact = new StringBuilder(value.length());
|
||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = normalized.charAt(index);
|
||||
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String requireCanonical(String value) {
|
||||
String normalized = normalizeRequired(value);
|
||||
if (!normalized.equals(value)) {
|
||||
throw new IllegalArgumentException("containerNo must be normalized");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
||||
String queryId,
|
||||
String customerId,
|
||||
String oceanBookingId,
|
||||
String containerBookingId,
|
||||
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||
String containerNo,
|
||||
String standardNode,
|
||||
int nodeSequence,
|
||||
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
||||
|| containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||
|| observedAt == null || actualAt == null) {
|
||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||
|
||||
+13
-1
@@ -1,14 +1,26 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV2(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String loadingPlanId, String planNo,
|
||||
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||
List<Order> orders) {
|
||||
public FirstLegLoadingPlanCompletedV2 {
|
||||
if (loadingPlanId == null || loadingPlanId.isBlank()
|
||||
|| containerBookingId == null || containerBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
orders = orders == null ? List.of() : List.copyOf(orders);
|
||||
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
|
||||
}
|
||||
|
||||
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId,
|
||||
String warehouseName, List<Order> orders) {
|
||||
|
||||
+6
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
|
||||
Instant unloadingPhotoUploadedAt,
|
||||
Instant actualEmptyReportedAt
|
||||
) {
|
||||
public OverseasContainerUnloadedV1 {
|
||||
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("sourceContainerBookingId is required");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ components:
|
||||
schemas:
|
||||
FirstLegLoadingPlanCompletedV2:
|
||||
type: object
|
||||
required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
|
||||
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
|
||||
properties:
|
||||
loadingPlanId: {type: string}
|
||||
containerBookingId: {type: string}
|
||||
containerNo: {type: string}
|
||||
sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
|
||||
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
|
||||
cabinetNo: {type: string}
|
||||
warehouseId: {type: string}
|
||||
warehouseName: {type: string}
|
||||
@@ -40,3 +40,29 @@ components:
|
||||
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
|
||||
pallets: {type: array, items: {type: object}}
|
||||
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||
FirstLegContainerShippingNodeRecordedV1:
|
||||
type: object
|
||||
required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
|
||||
properties:
|
||||
queryId: {type: string}
|
||||
customerId: {type: string}
|
||||
oceanBookingId: {type: string}
|
||||
sourceContainerBookingId: {type: string, minLength: 1}
|
||||
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
|
||||
standardNode: {type: string}
|
||||
nodeSequence: {type: integer}
|
||||
observedAt: {type: string, format: date-time}
|
||||
actualAt: {type: string, format: date-time}
|
||||
providerCode: {type: string}
|
||||
providerEventId: {type: string}
|
||||
providerNodeCode: {type: string}
|
||||
rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
|
||||
OverseasContainerUnloadedV1:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
|
||||
properties:
|
||||
sourceContainerBookingId: {type: string, minLength: 1}
|
||||
containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it}
|
||||
unloadingCompletedAt: {type: string, format: date-time}
|
||||
unloadingPhotoUploadedAt: {type: string, format: date-time}
|
||||
actualEmptyReportedAt: {type: [string, 'null'], format: date-time}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerNumbersTest {
|
||||
@Test
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||
.hasMessageContaining("check digit");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||
.hasMessageContaining("ISO 6346");
|
||||
}
|
||||
}
|
||||
+12
@@ -21,6 +21,18 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
||||
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||
var json = mapper.valueToTree(payload);
|
||||
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
|
||||
assertThat(json.has("containerBookingId")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCanonicalPhysicalContainerNumber() {
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
"1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
|
||||
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+21
-5
@@ -18,7 +18,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
|
||||
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH",
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
|
||||
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(payload);
|
||||
@@ -26,6 +26,8 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
|
||||
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
|
||||
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
|
||||
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
|
||||
assertThat(json.has("containerBookingId")).isFalse();
|
||||
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
|
||||
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
|
||||
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
|
||||
@@ -33,6 +35,19 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
|
||||
.hasMessageContaining("containerNo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheThreeItemRemarks() throws Exception {
|
||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
||||
@@ -41,7 +56,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 1, 2, null, null, null, null, List.of(item),
|
||||
"11", "客户甲", "FIRST_LEG", List.of());
|
||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||
String json = new ObjectMapper().writeValueAsString(payload);
|
||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
||||
@@ -57,7 +72,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(payload);
|
||||
|
||||
@@ -74,7 +89,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||
}
|
||||
@@ -89,7 +104,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
"11", "客户甲", "FIRST_LEG", List.of());
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||
@@ -118,5 +133,6 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
.contains("consumers must not query domestic IAM")
|
||||
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
|
||||
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
|
||||
assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class OverseasContainerUnloadedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
@Test
|
||||
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
||||
var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
|
||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
|
||||
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
|
||||
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+31
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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<BridgeInbox>()
|
||||
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insert(BridgeInbox inbox) {
|
||||
mapper.insert(inbox);
|
||||
|
||||
@@ -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 '来源区域',
|
||||
|
||||
+26
@@ -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"));
|
||||
|
||||
+1
-18
@@ -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<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
|
||||
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
|
||||
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
|
||||
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
|
||||
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
|
||||
@Operation(summary="编辑货柜基础资料", description="仅待装柜或预装草稿时允许;不得写入真实集装箱号或内部柜次号")
|
||||
public ApiResponse<ContainerBookingEntity> 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<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody TransportUpdateRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "查询货柜目的港清关资料")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** ISO 6346 真实集装箱号的唯一规范化与校验入口。 */
|
||||
public final class Iso6346ContainerNumber {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private Iso6346ContainerNumber() {
|
||||
}
|
||||
|
||||
public static String require(String raw) {
|
||||
String value = normalizeWhitespace(raw);
|
||||
if (value == null || !value.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw invalid();
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = value.charAt(index);
|
||||
int number = Character.isDigit(current)
|
||||
? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
int expected = sum % 11 % 10;
|
||||
if (value.charAt(10) - '0' != expected) throw invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String normalizeWhitespace(String raw) {
|
||||
if (raw == null) return null;
|
||||
StringBuilder value = new StringBuilder(raw.length());
|
||||
raw.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint))
|
||||
.forEach(value::appendCodePoint);
|
||||
String normalized = value.toString().toUpperCase(Locale.ROOT);
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static BizException invalid() {
|
||||
return new BizException(ResultCode.BAD_REQUEST,
|
||||
"真实集装箱号必须符合ISO 6346结构、设备类别和校验位");
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@ public final class ContainerDtos {
|
||||
|
||||
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter
|
||||
@TableName("fl_container_transport_history")
|
||||
public class ContainerTransportHistoryEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long containerBookingId;
|
||||
private String beforeContainerNo;
|
||||
private String afterContainerNo;
|
||||
private String beforeVoyageNo;
|
||||
private String afterVoyageNo;
|
||||
private String operationId;
|
||||
private String requestHash;
|
||||
private String reason;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -123,26 +123,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Param("actualEmptyReportedAt") java.time.LocalDateTime actualEmptyReportedAt,
|
||||
@Param("sourceVersion") long sourceVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
|
||||
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
|
||||
bill_of_lading_no=NULL, vessel_name=NULL, voyage_no=NULL,
|
||||
shipping_company_id=NULL, shipping_company_name=NULL,
|
||||
departure_port_code=NULL, departure_port_name=NULL,
|
||||
destination_port_code=NULL, destination_port_name=NULL,
|
||||
estimated_sailing_at=NULL, estimated_port_arrival_at=NULL,
|
||||
updated_by=#{operatorId}, updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
|
||||
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
|
||||
""")
|
||||
int unlinkReleasedBooking(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET
|
||||
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
|
||||
warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName},
|
||||
bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo},
|
||||
container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId},
|
||||
@@ -169,22 +151,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
|
||||
updated_by=#{operatorId}, updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{id} AND version=#{expectedVersion}
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
|
||||
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
|
||||
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_task t
|
||||
JOIN fl_loading_plan p ON p.id=t.loading_plan_id
|
||||
WHERE p.container_booking_id=fl_container_booking.id))
|
||||
""")
|
||||
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
|
||||
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
|
||||
voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},updated_by=#{operatorId},
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
@Mapper public interface ContainerOverviewMapper {
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
|
||||
@Select("""
|
||||
SELECT history.id,plan.container_booking_id containerBookingId,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(history.after_snapshot,'$.containerNo')) afterContainerNo,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.voyageNo')) beforeVoyageNo,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(history.after_snapshot,'$.voyageNo')) afterVoyageNo,
|
||||
history.operation_id operationId,history.operation_reason reason,
|
||||
history.operator_id operatorId,history.operator_name operatorName,
|
||||
history.occurred_at createdAt
|
||||
FROM fl_loading_plan_history history
|
||||
JOIN fl_loading_plan plan ON plan.id=history.loading_plan_id
|
||||
WHERE plan.container_booking_id=#{id} AND history.action_type='UPDATE_PRELOAD_TRANSPORT'
|
||||
ORDER BY history.occurred_at,history.id
|
||||
""") List<TransportRow> transport(@Param("id") long id);
|
||||
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
|
||||
@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) {}
|
||||
}
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {
|
||||
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{bookingId} "
|
||||
+ "AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1")
|
||||
ContainerTransportHistoryEntity selectOperation(@Param("bookingId") long bookingId,
|
||||
@Param("operatorId") long operatorId, @Param("operationId") String operationId);
|
||||
}
|
||||
@@ -3,7 +3,11 @@ package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.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<InboundOutboxEntity> {
|
||||
@Select("SELECT * FROM fl_inbound_outbox WHERE event_key=#{eventKey} LIMIT 1")
|
||||
InboundOutboxEntity selectByEventKey(@Param("eventKey") String eventKey);
|
||||
}
|
||||
|
||||
@@ -227,9 +227,18 @@ public interface LoadingListQueryMapper {
|
||||
UPDATE fl_loading_plan
|
||||
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},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+17
-2
@@ -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) {
|
||||
|
||||
+37
-18
@@ -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<String> TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO");
|
||||
private static final Set<String> TRACKING_TYPES = Set.of("CONTAINER_NO");
|
||||
|
||||
@Resource private CarrierScheduleQueryRepository repository;
|
||||
@Resource private 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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -152,8 +152,8 @@ public class ContainerOverviewService {
|
||||
|
||||
private List<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) {
|
||||
var result = new ArrayList<TimelineEntry>();
|
||||
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(
|
||||
|
||||
@@ -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<ContainerBookingEntity> 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) {
|
||||
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<WarehouseMasterDataClient.Detail> 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());
|
||||
|
||||
+34
-2
@@ -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<Row> rows = snapshotMapper.rows(planId);
|
||||
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
||||
validateContainerIdentity(rows);
|
||||
validateCustomerSnapshots(rows);
|
||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||
List<PalletRow> 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<Row> rows) {
|
||||
Row first = rows.get(0);
|
||||
String containerNo;
|
||||
try {
|
||||
containerNo = ContainerNumbers.requireCanonical(first.getContainerNo());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜完成前必须冻结真实集装箱号");
|
||||
}
|
||||
if (first.getContainerBookingId() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜完成事件缺少来源柜次标识");
|
||||
}
|
||||
try {
|
||||
boolean drift = rows.stream().anyMatch(row ->
|
||||
!Objects.equals(first.getContainerBookingId(), row.getContainerBookingId())
|
||||
|| !containerNo.equals(ContainerNumbers.requireCanonical(row.getContainerNo())));
|
||||
if (drift) throw new BizException(ResultCode.CONFLICT, "装柜完成事件的柜次或集装箱号快照不一致");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜完成事件包含非规范真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
|
||||
|
||||
@@ -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("集装箱号已存在");
|
||||
}
|
||||
try {
|
||||
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
|
||||
operatorId, trim(operatorName)) != 1) {
|
||||
throw conflict("预装计划已被其他操作修改,请刷新后重试");
|
||||
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任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 必填纠错原因}
|
||||
|
||||
+4
-5
@@ -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")
|
||||
|
||||
+14
-2
@@ -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")
|
||||
|
||||
@@ -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("校验位");
|
||||
}
|
||||
}
|
||||
+3
@@ -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");
|
||||
}
|
||||
|
||||
+32
-1
@@ -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<InboundOutboxEntity> 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;
|
||||
}
|
||||
|
||||
|
||||
+58
-9
@@ -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<CarrierScheduleQueryEntity> 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());
|
||||
|
||||
+31
-11
@@ -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}$'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ContainerTransportHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
|
||||
verify(transportHistories).insert(history.capture());
|
||||
assertThat(history.getValue().getBeforeContainerNo()).isEqualTo("MSCU1234567");
|
||||
assertThat(history.getValue().getAfterVoyageNo()).isEqualTo("V002");
|
||||
assertThat(history.getValue().getOperationId()).hasSize(64);
|
||||
assertThat(history.getValue().getRequestHash()).hasSize(64);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运输字段没有有效变化时不递增版本且不写历史")
|
||||
void ignoresTransportNoOp() {
|
||||
ContainerBookingEntity current = booking("LOADING", 2);
|
||||
current.setVoyageNo("V002");
|
||||
when(repository.find(11L)).thenReturn(current);
|
||||
|
||||
ContainerBookingEntity result = service.updateTransport(11L, 9L, "admin", "transport-noop",
|
||||
new TransportUpdateRequest(" MSCU1234567 ", " V002 ", 2));
|
||||
|
||||
assertThat(result).isSameAs(current);
|
||||
verify(repository, never()).updateTransport(anyLong(), any(), any(), anyInt(), anyLong(), any());
|
||||
verify(transportHistories, never()).insert(any(ContainerTransportHistoryEntity.class));
|
||||
verifyNoInteractions(loadingPickService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运输字段精确幂等重放零新增")
|
||||
void replaysTransportWithoutSecondMutationOrHistory() {
|
||||
ContainerBookingEntity current = booking("LOADING", 2);
|
||||
ContainerBookingEntity updated = booking("LOADING", 3);
|
||||
updated.setVoyageNo("V002");
|
||||
when(repository.find(11L)).thenReturn(current, updated, updated);
|
||||
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
|
||||
.thenReturn(1);
|
||||
ArgumentCaptor<ContainerTransportHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
|
||||
|
||||
TransportUpdateRequest request = new TransportUpdateRequest("MSCU1234567", "V002", 2);
|
||||
service.updateTransport(11L, 9L, "admin", "transport-replay", request);
|
||||
verify(transportHistories).insert(history.capture());
|
||||
when(transportHistories.selectOperation(eq(11L), eq(9L), anyString()))
|
||||
.thenReturn(history.getValue());
|
||||
|
||||
ContainerBookingEntity replay = service.updateTransport(
|
||||
11L, 9L, "admin", "transport-replay", request);
|
||||
|
||||
assertThat(replay.getVersion()).isEqualTo(3);
|
||||
verify(repository, org.mockito.Mockito.times(1))
|
||||
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
|
||||
verify(transportHistories, org.mockito.Mockito.times(1))
|
||||
.insert(any(ContainerTransportHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("预装清单推送后再绑定集装箱号会创建PDA拣货任务")
|
||||
void startsPdaTaskWhenContainerIsBoundAfterPreloadPush() {
|
||||
ContainerBookingEntity current = booking("EDITING", 2);
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setId(31L);
|
||||
plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
|
||||
when(repository.find(11L)).thenReturn(current);
|
||||
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
|
||||
.thenReturn(1);
|
||||
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
|
||||
|
||||
service.updateTransport(11L, 9L, "admin", "transport-2",
|
||||
new TransportUpdateRequest("MSCU1234567", "V002", 2));
|
||||
|
||||
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDA任务创建后替换真实柜号不重同步任务明细")
|
||||
void doesNotResynchronizeExistingPdaTaskWhenContainerNumberChanges() {
|
||||
ContainerBookingEntity current = booking("EDITING", 2);
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setId(31L);
|
||||
plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
|
||||
when(repository.find(11L)).thenReturn(current);
|
||||
when(repository.updateTransport(11L, "MSCU7654321", "V002", 2, 9L, "admin"))
|
||||
.thenReturn(1);
|
||||
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
|
||||
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
|
||||
|
||||
service.updateTransport(11L, 9L, "admin", "transport-3",
|
||||
new TransportUpdateRequest("MSCU7654321", "V002", 2));
|
||||
|
||||
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDA任务创建后禁止清空真实集装箱号")
|
||||
void rejectsClearingContainerNumberAfterPdaTaskStarts() {
|
||||
ContainerBookingEntity current = booking("LOADING", 2);
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setId(31L);
|
||||
when(repository.find(11L)).thenReturn(current);
|
||||
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
|
||||
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-4",
|
||||
new TransportUpdateRequest(null, "V002", 2)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不能清空真实集装箱号");
|
||||
|
||||
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
|
||||
any(Integer.class), any(Long.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已确认货柜全部只读")
|
||||
void rejectsConfirmedUpdate() {
|
||||
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-5",
|
||||
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("全部只读");
|
||||
|
||||
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
|
||||
any(Integer.class), any(Long.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@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,
|
||||
|
||||
+27
-1
@@ -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));
|
||||
|
||||
@@ -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<String> snapshot = ArgumentCaptor.forClass(String.class);
|
||||
|
||||
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
|
||||
TransportUpdateRequest request = new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错");
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
|
||||
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
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
<name>integration-service</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>common-event-contract</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-core</artifactId>
|
||||
|
||||
+14
-3
@@ -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("轨迹供应商返回空结果");
|
||||
|
||||
@@ -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]
|
||||
|
||||
+23
-3
@@ -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("无效异常事实");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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<ContainerOrderRef> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
|
||||
+30
-10
@@ -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<SourceOrderSnapshot> 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<InboundPlanOrderMapper.ContainerOrderRef> 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<InboundPlan> existingPlans = refs.stream()
|
||||
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
|
||||
.toList();
|
||||
@@ -145,6 +150,7 @@ public class InboundPlanService {
|
||||
if (existingCount == refs.size()) {
|
||||
List<InboundPlanResponse> 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<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
|
||||
String containerNo = null;
|
||||
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
|
||||
List<SourceOrderSnapshot> 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) {
|
||||
|
||||
@@ -84,6 +84,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
@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<String> 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);
|
||||
|
||||
|
||||
@@ -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<ContainerPlanProgressSummary> records = total == 0 ? List.of()
|
||||
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size());
|
||||
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size())
|
||||
.stream().map(this::requireFrozenContainer).toList();
|
||||
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
|
||||
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) {
|
||||
|
||||
+39
-11
@@ -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<String> 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());
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+6
-6
@@ -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<LastlegEventOutbox> 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");
|
||||
|
||||
+50
-17
@@ -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);
|
||||
}
|
||||
|
||||
+14
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+19
-12
@@ -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<String,String> frozenContainerNos=new HashMap<>();
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@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<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
|
||||
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
|
||||
@Test void 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<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
|
||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@Test void 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<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
|
||||
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); }
|
||||
}
|
||||
|
||||
+2
-2
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
|
||||
test('HTTP fixtures generate stable unique ISO 6346 container numbers', () => {
|
||||
const first = iso6346ContainerNumber('fixture-a')
|
||||
assert.match(first, /^[A-Z]{3}U[0-9]{7}$/)
|
||||
assert.equal(first, iso6346ContainerNumber('fixture-a'))
|
||||
assert.notEqual(first, iso6346ContainerNumber('fixture-b'))
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const LETTER_VALUES = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
|
||||
export function iso6346ContainerNumber(seed, ownerCode = 'TST') {
|
||||
if (!/^[A-Z]{3}$/.test(ownerCode)) throw new Error('ownerCode must contain three uppercase letters')
|
||||
const serial = String(createHash('sha256').update(String(seed)).digest().readUInt32BE(0) % 1_000_000)
|
||||
.padStart(6, '0')
|
||||
const body = `${ownerCode}U${serial}`
|
||||
let sum = 0
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
const code = body.charCodeAt(index)
|
||||
const value = code >= 48 && code <= 57 ? code - 48 : LETTER_VALUES[code - 65]
|
||||
sum += value * (2 ** index)
|
||||
}
|
||||
return `${body}${(sum % 11) % 10}`
|
||||
}
|
||||
@@ -19,7 +19,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
|
||||
assert.match(source, /context\.request\('POST', CONTAINERS/)
|
||||
assert.match(source, /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\}`/)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`, {
|
||||
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),
|
||||
version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`,
|
||||
})
|
||||
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)),
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user