diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index fe72ab844..89f643dfc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -72,7 +72,7 @@ public class PlatformLoadingListController { return ApiResponse.success(service.lifecycle(planId, warehouseId)); } - @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。") + @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING、PDA尚未领取或产生物理作业事实时,可首次补录或纠正集装箱号并维护航次;已有柜号的纠正必须提供原因和版本,柜号不能清空。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse updateTransport( @@ -86,7 +86,7 @@ public class PlatformLoadingListController { planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } - @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") + @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") @PostMapping("/loading-plans/{planId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse confirmPreload( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 1ba217f2b..1e7ef50eb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -51,7 +51,7 @@ public final class LoadingListDtos { @Size(max = 64) String containerNo, @Size(max = 64) String voyageNo, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 500) String operationReason + @Size(max = 500) String operationReason ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index abfdaa551..1bb4f1a1c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -250,7 +250,6 @@ public interface LoadingListQueryMapper { 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='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) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 6afdf2102..27f5333d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -151,13 +151,12 @@ public class LoadingListRepository { java.util.ArrayList blockers = new java.util.ArrayList<>(); if (!pushed) blockers.add("PRELOAD_NOT_PUSHED"); if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING"); - if (!containerReady) blockers.add("CONTAINER_NO_MISSING"); if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; - boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released; + boolean prerequisitesReady = pushed && editing && linkCount > 0 && released; boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); String confirmationStatus = allConfirmed ? "CONFIRMED" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 86970d5fe..bf2838354 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -423,7 +423,6 @@ public class LoadingListService { 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) { @@ -434,15 +433,21 @@ public class LoadingListService { return repository.preloadDetail(before); } - requireTransportCorrectionMutable(before); + requireTransportMutable(before); String containerNo = targetContainerNo(before, request.containerNo()); String voyageNo = nullableTrim(request.voyageNo()); + boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo()) + && !java.util.Objects.equals(before.getContainerNo(), containerNo); + if (correctingContainerNo && !StringUtils.hasText(reason)) { + throw badRequest("纠正已有真实集装箱号时纠错原因不能为空"); + } 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 (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) { + if (containerNo != null + && containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) { throw conflict("集装箱号已存在"); } try { @@ -464,8 +469,9 @@ public class LoadingListService { } private static String targetContainerNo(AggregateRow before, String requested) { - return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo()) - : Iso6346ContainerNumber.require(requested); + if (requested != null) return Iso6346ContainerNumber.require(requested); + return StringUtils.hasText(before.getContainerNo()) + ? Iso6346ContainerNumber.require(before.getContainerNo()) : null; } private static String transportHash(long planId, int version, String containerNo, @@ -577,14 +583,11 @@ public class LoadingListService { } } - private void requireTransportCorrectionMutable(AggregateRow row) { + private void requireTransportMutable(AggregateRow row) { if (!"EDITING".equals(row.getPlanStatus())) { throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); } if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段"); - if (!StringUtils.hasText(row.getContainerNo())) { - throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入"); - } if (repository.hasPdaWorkStarted(row.getPlanId())) { throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index eb203f7dd..a285dabe2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -23,6 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource; import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory; import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask; +import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -113,11 +114,11 @@ public class LoadingPickService { @Value("${baiyue.security.internal-service-token:test-internal-service-token}") private String internalToken; - /** Opens the field task only after planning, transport identity and ocean release have converged. */ + /** Opens the field task after planning and ocean release; the real container number may be filled later. */ @Transactional public void beginIfReady(long containerBookingId, long operatorId, String operatorName) { var booking = smartLoadingRepository.booking(containerBookingId); - if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return; + if (booking == null) return; var plan = smartLoadingRepository.planByBooking(containerBookingId); if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; var links = loadingModelBridge.byLoadingPlan(plan.getId()); @@ -137,10 +138,6 @@ public class LoadingPickService { if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) { throw conflict("预装清单已确认或当前状态不允许确认"); } - var booking = smartLoadingRepository.booking(link.containerBookingId()); - if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { - throw conflict("确认预装前必须录入真实集装箱号"); - } var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId()); if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜"); var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); @@ -462,6 +459,7 @@ public class LoadingPickService { requireSameCompletionCommand(replay, taskId, requestHash, operatorId); if ("FAILED".equals(replay.getCommandStatus())) { LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId); + requireCompletionContainerNumber(taskId); if (!"COMPLETION_FAILED".equals(failed.getTaskStatus()) || !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) { throw conflict("装柜完成命令状态已变化,请刷新后重试"); @@ -485,6 +483,7 @@ public class LoadingPickService { if (!request.expectedTaskVersion().equals(task.getVersion())) { throw conflict("PDA任务版本已变化,请刷新后复核"); } + requireCompletionContainerNumber(taskId); if (!repository.allTaskItemsResolved(taskId)) { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } @@ -514,6 +513,18 @@ public class LoadingPickService { return view(requiredTask(taskId), null); } + private void requireCompletionContainerNumber(long taskId) { + ContainerBookingEntity booking = repository.activeBookingByTask(taskId); + if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { + throw conflict("PDA确认装柜完成前必须录入真实集装箱号"); + } + try { + Iso6346ContainerNumber.require(booking.getContainerNo()); + } catch (BizException exception) { + throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号"); + } + } + /** Called only by the completion worker, after its durable lease has committed. */ public void processCompletion(LoadingCompletionCommandEntity command, String worker) { long taskId = command.getPickTaskId(); diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 92937c0b0..139bfb523 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -49,8 +49,8 @@ paths: patch: summary: 修改预装运输信息 description: >- - 唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许; - containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。 + 仅已推送且仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时允许。可首次补录真实集装箱号;已有柜号的纠正必须提供原因和当前版本; + containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 x-permission: platform:firstleg:preload:edit parameters: - {$ref: '#/components/parameters/PlanId'} @@ -66,7 +66,7 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/confirm: post: summary: 原子确认整份预装计划 - description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 + description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED;真实集装箱号可在PDA确认装柜完成前补录。任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 x-permission: platform:firstleg:preload:edit parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: @@ -313,11 +313,11 @@ components: pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态} confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态} canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口} - readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;task 已创建后仍为 true} + readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;不依赖真实集装箱号,task 已创建后仍为 true} blockingReasons: type: array description: 阻止计划确认的稳定原因码 - 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]} + items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} ConfirmPreloadRequest: type: object required: [expectedVersion, operationReason] @@ -428,4 +428,4 @@ components: 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, minLength: 1, maxLength: 500, description: 必填纠错原因} + operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 3260d4bc5..c0461e7f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -100,10 +100,10 @@ class LoadingListControllerContractTest { } @Test - void transportCorrectionRequiresReasonAndKeepsNullContainerAsUnchanged() { + void transportCorrectionReasonIsRuntimeConditionalAndNullContainerKeepsCurrentValue() { assertThat(component(TransportUpdateRequest.class, "operationReason").getAccessor() .getAnnotation(NotBlank.class)) - .isNotNull(); + .isNull(); assertThat(component(TransportUpdateRequest.class, "containerNo").getAccessor() .getAnnotation(NotBlank.class)) .isNull(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index d8a657a27..ba1f02508 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -94,7 +94,7 @@ class LoadingListQueryMapperContractTest { .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("p.plan_status='EDITING'") .contains("preload_pushed_at IS NOT NULL") - .contains("#{containerNo} IS NOT NULL") + .doesNotContain("#{containerNo} IS NOT NULL") .contains("fl_loading_pick_task", "fl_loading_pick_assignment") .doesNotContain("DELETE") .doesNotContain("fl_loading_plan_item"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index c2d7bc9db..aa7a047c6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -72,4 +72,30 @@ class LoadingListRepositoryTest { assertThat(confirmed.planId()).isEqualTo(41L); assertThat(confirmed.containerBookingId()).isEqualTo(61L); } + + @Test + void missingContainerNumberDoesNotBlockPreloadConfirmationOrPdaReadiness() { + AggregateRow row = new AggregateRow(); + row.setPlanId(41L); + row.setContainerBookingId(61L); + row.setPlanNo("LP-41"); + row.setPlanStatus("EDITING"); + row.setVersion(1); + row.setPreloadPushedAt(java.time.LocalDateTime.now()); + row.setPreloadLinkCount(2L); + row.setConfirmedPreloadCount(0L); + row.setOceanBookingId(81L); + row.setOceanBookingStatus("RELEASED"); + when(mapper.selectItems(41L)).thenReturn(List.of()); + when(mapper.selectHistories(41L)).thenReturn(List.of()); + + var pending = repository.preloadDetail(row).summary().readiness(); + assertThat(pending.containerNoReady()).isFalse(); + assertThat(pending.canConfirm()).isTrue(); + assertThat(pending.blockingReasons()).doesNotContain("CONTAINER_NO_MISSING"); + + row.setConfirmedPreloadCount(2L); + var confirmed = repository.preloadDetail(row).summary().readiness(); + assertThat(confirmed.readyForPda()).isTrue(); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index db2e06853..31bf5cfcb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -208,20 +208,43 @@ class LoadingListServiceTest { } @Test - void loadingPlanAndMissingInitialOceanBookingNumberBothRejectCorrection() { + void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() { 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); + AggregateRow afterFill = row("EDITING", 4); + afterFill.setPreloadPushedAt(missing.getPreloadPushedAt()); + afterFill.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); + when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(afterFill)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); 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("首次写入"); + service.updateTransport(10L, 100L, 9L, "operator", "missing-key", + new TransportUpdateRequest("CSQU3054383", null, 3, null)); + + verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"); + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + } + + @Test + void correctingExistingContainerNumberStillRequiresReason() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); + when(repository.aggregate(10L)).thenReturn(before); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reason-key", + new TransportUpdateRequest("CSQU3054383", null, 3, null))) + .isInstanceOf(BizException.class).hasMessageContaining("纠错原因"); + + verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 86557fd2e..38c953f01 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -113,7 +113,7 @@ class LoadingPickServiceTest { }).when(completionTransaction).executeWithoutResult(any()); ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(99L); - booking.setContainerNo("CSLU1234567"); + booking.setContainerNo("MSCU6639870"); booking.setWarehouseId(101L); booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); @@ -124,9 +124,9 @@ class LoadingPickServiceTest { } @Test - void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() { + void opensTaskAfterReleasedBookingWithoutWaitingForRealContainerNumber() { var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - booking.setId(40L); booking.setContainerNo("MSCU-001"); + booking.setId(40L); var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); plan.setPreloadPushedAt(java.time.LocalDateTime.now()); @@ -136,7 +136,7 @@ class LoadingPickServiceTest { when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); when(smartLoadingRepository.plan(10L)).thenReturn(plan); when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( - new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, null, "EDITING", 1, 1, null))); when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); when(oceanBookingRepository.find(90L)).thenReturn(ocean); when(repository.taskByPlan(10L)).thenReturn(null); @@ -191,11 +191,11 @@ class LoadingPickServiceTest { } @Test - void planConfirmationGateRequiresRealContainerAndReleasedBooking() { + void planConfirmationGateRequiresReleasedBookingButNotRealContainerNumber() { Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 3, 2, null); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - booking.setId(40L); booking.setContainerNo("MSCU-001"); + booking.setId(40L); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); var ocean = new OceanBookingEntity(); @@ -209,17 +209,21 @@ class LoadingPickServiceTest { } @Test - void planConfirmationGateRejectsMissingRealContainerNumber() { + void planConfirmationGateAllowsMissingRealContainerNumber() { Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, null, "EDITING", 3, 2, null); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); booking.setId(40L); + var allocation = new OceanBookingContainerAllocationEntity(); + allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); + ocean.setId(90L); ocean.setStatus("RELEASED"); when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); - assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) - .isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号"); - verify(oceanBookingRepository, never()).allocationByContainer(anyLong()); + assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); } @Test @@ -775,6 +779,25 @@ class LoadingPickServiceTest { verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); } + @Test + void completionRejectsMissingRealContainerNumberBeforeCreatingCommand() { + LoadingPickTaskEntity loading = task(); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); + booking.setWarehouseId(101L); + booking.setRecordStatus("ACTIVE"); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.activeBookingByTask(20L)).thenReturn(booking); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-missing-container", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("确认装柜完成前"); + + verify(repository, never()).allTaskItemsResolved(20L); + verify(completionCommands, never()).insert(any()); + verify(repository, never()).acceptCompletion(anyLong(), anyInt()); + } + @Test void differentOperationIdIsRejectedImmediatelyWithConflict() { // different operationId is a canonical A0409 CONFLICT while the durable command is active.