fix(loading): remove ISO container validation across PDA fulfillment

This commit is contained in:
2026-10-23 11:12:36 +08:00
parent c38f1758c5
commit 9c19980736
22 changed files with 215 additions and 162 deletions
@@ -107,7 +107,7 @@ class ContainerControllerContractTest {
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
.contains("确认预装和创建PDA任务前仍校验ISO格式")
.contains("运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -73,7 +73,7 @@ class LoadingListControllerContractTest {
.contains("containerNo")
.contains("voyageNo")
.contains("首次补录真实集装箱号")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("不校验 ISO 6346 长度、结构、设备类别或校验位")
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm")
.contains("/loading-plans/{planId}/lifecycle")
@@ -1,23 +0,0 @@
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("校验位");
}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RealContainerNumberTest {
/** 录入只去空白转大写,ISO 长度、设备类别与校验位不构成门禁。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "MSCA6639870", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void normalizesWithoutIsoValidation(String containerNo) {
assertThat(RealContainerNumber.require(" \u00a0" + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
.isEqualTo(containerNo);
}
/** 空柜号不能进入实际作业,包含规范化后为空的 Unicode 空白。 */
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\u00a0\u3000"})
void rejectsMissingNumber(String containerNo) {
assertThatThrownBy(() -> RealContainerNumber.require(containerNo))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
}
}
@@ -297,21 +297,23 @@ class ContainerServiceTest {
verifyNoInteractions(oceanBookings);
}
@Test
/** 提柜补录使用相同的非 ISO 规则,并按版本原子保存。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
@DisplayName("提柜后按柜次版本原子补录真实柜号")
void assignsRealContainerNumberAfterPickup() {
void assignsRealContainerNumberAfterPickup(String containerNo) {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
ContainerBookingEntity assigned = booking("EDITING", 4);
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
assigned.setContainerNo(containerNo); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked, assigned);
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
when(repository.assignRealContainerNo(11L, containerNo, 3, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.assignRealContainerNo(
11L, " ms cu 663987 0 ", 3, 9L, "admin");
11L, " " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo(containerNo);
verify(repository).assignRealContainerNo(11L, containerNo, 3, 9L, "admin");
}
@Test
@@ -101,6 +101,23 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.eventVersion()).isEqualTo(3);
}
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
void publishesNonIsoContainerNumber(String containerNo) throws Exception {
Row value = row(11, 100, 101);
value.setContainerNo(containerNo);
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captured.capture());
EventEnvelope event = mapper.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.eventVersion()).isEqualTo(3);
assertThat(event.payload().path("containerNo").asText()).isEqualTo(containerNo);
}
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
Row value = row(11, 100, 101); value.setContainerNo(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
@@ -168,17 +168,20 @@ class LoadingListServiceTest {
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */
@Test
void stillRejectsNewNonIsoNumberInTransportCorrection() {
/** 运输纠错与关联入口一致,只规范化柜号并保留纠错原因。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void acceptsNonIsoNumberInTransportCorrection(String containerNo) {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("LOCAL-1");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.updateTransport(10L, 3, containerNo, null, 9L, "operator")).thenReturn(1);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "invalid-correction",
new TransportUpdateRequest("OTHER-2", null, 3, "纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
service.updateTransport(10L, 100L, 9L, "operator", "non-iso-correction",
new TransportUpdateRequest(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", null, 3, "纠错"));
verify(repository).updateTransport(10L, 3, containerNo, null, 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
@@ -235,7 +238,7 @@ class LoadingListServiceTest {
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" ", "023E", 3, "清空测试")))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
verify(repository, never()).updateTransport(eq(10L), eq(3),
org.mockito.ArgumentMatchers.nullable(String.class),
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
@@ -962,10 +962,10 @@ class LoadingPickServiceTest {
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
}
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */
/** 关联接受的非 ISO 柜号可直接确认预装,保留订舱放舱门禁。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) {
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void acceptsNonIsoNumberBeforeConfirmation(String containerNo) {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, containerNo, "EDITING", 3, 2, null);
var booking = new ContainerBookingEntity();
@@ -974,12 +974,6 @@ class LoadingPickServiceTest {
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(completionCommands, never()).insert(any());
booking.setContainerNo("MSCU6639870");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
@@ -990,27 +984,58 @@ class LoadingPickServiceTest {
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */
/** 非 ISO 柜号可以创建 PDA 任务,任务仍按柜次关联权威柜号。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) {
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(repository, never()).insert(any(LoadingPickItemEntity.class));
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void createsPdaTaskWithNonIsoNumber(String containerNo) {
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L);
var booking = new ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo(containerNo); booking.setCabinetNo("D2092");
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
doAnswer(invocation -> {
LoadingPickTaskEntity created = invocation.getArgument(0);
created.setId(20L);
return 1;
}).when(repository).insert(any(LoadingPickTaskEntity.class));
service.begin(10L, containerNo, 9L, "operator", List.of());
ArgumentCaptor<LoadingPickTaskEntity> captured = ArgumentCaptor.forClass(LoadingPickTaskEntity.class);
verify(repository).insert(captured.capture());
assertThat(captured.getValue().getContainerBookingId()).isEqualTo(40L);
assertThat(captured.getValue().getTaskStatus()).isEqualTo("LOADING");
}
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */
@Test
void completionStillRequiresIsoNumberForCrossServiceFacts() {
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871");
when(repository.taskForUpdate(20L)).thenReturn(task());
/** 去除 ISO 校验不应放开缺号任务。 */
@ParameterizedTest
@org.junit.jupiter.params.provider.NullAndEmptySource
@ValueSource(strings = {" ", "\u00a0\u3000"})
void rejectsMissingNumberBeforeCreatingPdaTask(String containerNo) {
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
.isInstanceOf(BizException.class).hasMessageContaining("录入真实集装箱号");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
}
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(completionCommands, never()).insert(any());
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
/** 非 ISO 柜号不会在完成阶段再次受阻,仍通过持久化命令结算。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void completionAcceptsNonIsoNumberForCrossServiceFacts(String containerNo) {
repository.activeBookingByTask(20L).setContainerNo(containerNo);
when(repository.taskForUpdate(20L)).thenReturn(task());
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
completing.setVersion(1);
when(repository.task(20L)).thenReturn(completing);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
Task result = service.complete(20L,
new CompleteLoadingRequest(0, "complete-non-iso", "checked"), 9L, "operator");
assertThat(result.taskStatus()).isEqualTo("COMPLETING");
verify(completionCommands).insert(any());
verify(repository).acceptCompletion(20L, 0);
}
@Test