fix(firstleg): allow booking link before container pickup

This commit is contained in:
2026-09-01 15:06:03 +08:00
parent a10e32a012
commit 1e8f504f15
10 changed files with 124 additions and 15 deletions
@@ -62,8 +62,9 @@ class ContainerControllerContractTest {
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -81,7 +82,9 @@ class ContainerControllerContractTest {
assertThat(openapi)
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
.contains("Content-Disposition 与 Cache-Control no-store")
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
.contains("containerNo: {type: string, nullable: true")
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
@@ -75,14 +75,17 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
@DisplayName("真实柜号只允许订舱关联或提柜后原子补录")
void onlyBookingLinkOrPickupAssignmentWritesContainerNumber() 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());
String pickupSql = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("assignRealContainerNo", long.class, String.class, int.class,
long.class, String.class).getAnnotation(Update.class).value());
assertThat(draftSql)
.contains("capacity_cbm")
.contains("p.plan_status!='EDITING'")
@@ -92,6 +95,11 @@ class ContainerBookingMapperContractTest {
.doesNotContain(">");
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
"ocean_booking_id IS NULL");
assertThat(pickupSql)
.contains("container_no=#{containerNo}", "container_no IS NULL", "ocean_booking_id IS NOT NULL",
"ocean_booking_requirement_id IS NOT NULL", "version=#{version}", "fl_loading_pick_task",
"fl_loading_cabinet_evidence", "fl_container_document")
.doesNotContain("ocean_booking_id=#{");
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
.doesNotContain("updateTransport", "unlinkReleasedBooking");
}
@@ -105,6 +113,7 @@ class ContainerBookingMapperContractTest {
}) {
assertThat(source)
.contains("国内唯一权威真实集装箱号")
.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}$'");
@@ -212,6 +212,58 @@ class ContainerServiceTest {
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求")
void linksReleasedBookingWithoutRealContainerNumber() {
stubLinkContext();
ContainerBookingEntity container = repository.find(11L);
when(repository.find(11L)).thenReturn(container, container);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, null, 2));
assertThat(result.getContainerNo()).isNull();
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).containerNoExists(anyString(), anyLong());
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("提柜后按柜次版本原子补录真实柜号")
void assignsRealContainerNumberAfterPickup() {
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);
when(repository.find(11L)).thenReturn(linked, assigned);
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.assignRealContainerNo(
11L, " ms cu 663987 0 ", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
}
@Test
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
void rejectsDuplicateRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked);
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.assignRealContainerNo(
11L, "MSCU6639870", 3, 9L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {