From a35794e9182bf94bb06fb1fe01bb90973cb90dfc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 17:04:00 +0800 Subject: [PATCH] feat: close preload confirmation and loading flow --- .../PlatformLoadingListController.java | 17 +- .../baiyue/firstleg/dto/LoadingListDtos.java | 38 +- .../mapper/LoadingModelBridgeMapper.java | 9 +- .../service/LoadingModelBridgeService.java | 17 + .../mapper/LoadingListQueryMapper.java | 56 ++- .../repository/LoadingListRepository.java | 43 ++- .../firstleg/service/LoadingListService.java | 64 ++++ .../firstleg/service/LoadingPickService.java | 29 ++ .../firstleg/service/SmartLoadingService.java | 4 - .../PlatformSplitPreloadController.java | 27 -- .../firstleg/split/dto/SplitRequests.java | 2 - .../split/mapper/PreloadPlanMapper.java | 8 - .../split/mapper/SplitInboundMapper.java | 7 - .../split/repository/SplitRepository.java | 10 - .../split/service/SplitPreloadService.java | 83 ----- .../main/resources/openapi/firstleg-v1.yaml | 5 +- .../resources/openapi/loading-lists-v1.yaml | 69 +++- .../phase3-loading-model-bridge-v1.yaml | 2 +- .../LoadingListControllerContractTest.java | 24 +- ...ormSmartLoadingControllerContractTest.java | 8 +- .../LoadingModelBridgeServiceTest.java | 13 + .../LoadingListQueryMapperContractTest.java | 21 +- .../repository/LoadingListRepositoryTest.java | 11 + .../service/LoadingListServiceTest.java | 139 +++++++ .../service/LoadingPickServiceTest.java | 96 +++++ .../service/SmartLoadingServiceTest.java | 27 +- ...ormSplitPreloadControllerContractTest.java | 10 +- .../service/SplitPreloadServiceTest.java | 54 --- scripts/admin-test-env-modules/README.md | 16 + .../phase3-container-loading.mjs | 237 +++++++++--- ...se3-container-regression-contract.test.mjs | 23 +- .../admin-test-env-modules/phase3-loading.mjs | 345 ++++++++++-------- .../phase3-producer-chain-contract.test.mjs | 115 +++--- .../full-regression-entry-contract.test.mjs | 77 +++- 34 files changed, 1194 insertions(+), 512 deletions(-) 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 f7f6788a0..153ae3d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -9,6 +9,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; @@ -66,7 +68,7 @@ public class PlatformLoadingListController { return ApiResponse.success(service.preloadDetail(planId, warehouseId)); } - @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") + @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse updateTransport( @@ -80,6 +82,19 @@ public class PlatformLoadingListController { planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } + @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") + @PostMapping("/loading-plans/{planId}/confirm") + @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") + public ApiResponse confirmPreload( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ConfirmPreloadRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.confirmPreload( + planId, user.userId(), user.username(), idempotencyKey, request)); + } + @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @GetMapping("/loading-lists") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") 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 6b68abf4a..5fcaf16ae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -83,12 +83,43 @@ public final class LoadingListDtos { public record Customer(Long accountId, String customerName) { } + public record PreloadReadiness( + boolean preloadPushed, + boolean containerNoReady, + int preloadLinkCount, + int confirmedPreloadCount, + Long oceanBookingId, + String oceanBookingStatus, + Long pdaTaskId, + String pdaTaskStatus, + String confirmationStatus, + boolean canConfirm, + boolean readyForPda, + List blockingReasons + ) { + public PreloadReadiness { + blockingReasons = blockingReasons == null ? List.of() : List.copyOf(blockingReasons); + } + } + + public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion) { + } + + public record PreloadConfirmationResponse( + Long planId, + Long containerBookingId, + String confirmationStatus, + Integer version, + PreloadReadiness readiness + ) { + } + public record PreloadSummary( Long planId, Long containerBookingId, String preloadNo, String containerNo, - String internalContainerNo, + String cabinetNo, List customers, Long warehouseId, String warehouseName, @@ -101,7 +132,8 @@ public final class LoadingListDtos { BigDecimal totalVolumeCbm, BigDecimal totalGrossWeightKg, LocalDateTime createdAt, - Integer version + Integer version, + PreloadReadiness readiness ) { public PreloadSummary { customers = customers == null ? List.of() : List.copyOf(customers); @@ -115,7 +147,7 @@ public final class LoadingListDtos { Long warehouseId, String warehouseName, String containerNo, - String internalContainerNo, + String cabinetNo, String status, long totalPackageCount, BigDecimal totalGrossWeightKg, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index 5d2b40a23..9a2fd94a2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); } public List byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); } + public List requireAllocatedLinksForConfirmation(long loadingPlanId) { + var loadingPlan = repository.loadingPlan(loadingPlanId); + if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); + if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) { + throw conflict("仅已推送且仍为EDITING的预装计划可以确认"); + } + List scopes = repository.allocatedScopes(loadingPlanId); + if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物"); + List links = repository.byLoadingPlan(loadingPlanId); + boolean complete = scopes.stream().allMatch(scope -> links.stream().anyMatch(link -> + Objects.equals(link.tenantId(), scope.tenantId()) + && Objects.equals(link.inboundId(), scope.inboundId()) + && link.allocatedPackageCount() != null && link.allocatedPackageCount() > 0)); + if (!complete) throw conflict("装柜计划的预装关联不完整"); + return links; + } + public void requireStarted(long tenantId, long inboundId) { List links = requireLinks(tenantId, inboundId); if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0 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 e3b5d241d..c641efbb5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -21,9 +21,24 @@ import java.util.List; public interface LoadingListQueryMapper { String AGGREGATE_COLUMNS = """ p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, - p.created_by, p.created_by_name, p.created_at, p.version, + p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, b.vessel_name, b.voyage_no, b.container_type, + (SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link + WHERE preload_link.loading_plan_id = p.id) AS preload_link_count, + (SELECT COUNT(*) FROM fl_preload_loading_plan_link confirmed_link + JOIN fl_preload_plan confirmed_preload ON confirmed_preload.id = confirmed_link.preload_plan_id + WHERE confirmed_link.loading_plan_id = p.id + AND confirmed_preload.status = 'CONFIRMED') AS confirmed_preload_count, + (SELECT allocation.ocean_booking_id FROM fl_ocean_booking_container_allocation allocation + WHERE allocation.container_booking_id = p.container_booking_id LIMIT 1) AS ocean_booking_id, + (SELECT ocean.status FROM fl_ocean_booking_container_allocation allocation + JOIN fl_ocean_booking ocean ON ocean.id = allocation.ocean_booking_id + WHERE allocation.container_booking_id = p.container_booking_id LIMIT 1) AS ocean_booking_status, + (SELECT pick_task.id FROM fl_loading_pick_task pick_task + WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_id, + (SELECT pick_task.task_status FROM fl_loading_pick_task pick_task + WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_status, COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count, COALESCE(SUM(i.package_count), 0) AS total_package_count, COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg, @@ -34,7 +49,7 @@ public interface LoadingListQueryMapper { String AGGREGATE_GROUP = """ p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, - p.created_by, p.created_by_name, p.created_at, p.version, + p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, b.vessel_name, b.voyage_no, b.container_type """; @@ -202,7 +217,8 @@ 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 = 'LOADING' + WHERE id = #{planId} AND plan_status IN ('EDITING', 'LOADING') + AND (plan_status = 'LOADING' OR preload_pushed_at IS NOT NULL) AND version = #{expectedVersion} """) int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion, @@ -214,7 +230,8 @@ 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 = 'LOADING' + 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)) """) @@ -222,6 +239,30 @@ public interface LoadingListQueryMapper { @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_preload_plan + SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName}, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{preloadPlanId} AND status = #{fromStatus} AND version = #{expectedVersion} + """) + int confirmLinkedPreload(@Param("preloadPlanId") long preloadPlanId, + @Param("fromStatus") String fromStatus, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update(""" + 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 = 'EDITING' AND preload_pushed_at IS NOT NULL + AND version = #{expectedVersion} + """) + int touchForPreloadConfirmation(@Param("planId") long planId, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + @Update(""" UPDATE fl_loading_plan SET plan_status = #{toStatus}, @@ -268,6 +309,7 @@ public interface LoadingListQueryMapper { private String createdByName; private LocalDateTime createdAt; private Integer version; + private LocalDateTime preloadPushedAt; private String containerNo; private String cabinetNo; private Long warehouseId; @@ -275,6 +317,12 @@ public interface LoadingListQueryMapper { private String vesselName; private String voyageNo; private String containerType; + private Long preloadLinkCount; + private Long confirmedPreloadCount; + private Long oceanBookingId; + private String oceanBookingStatus; + private Long pdaTaskId; + private String pdaTaskStatus; private Long skuCount; private Long totalPackageCount; private BigDecimal totalGrossWeightKg; 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 d190c79ba..945252748 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.Item; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; @@ -85,6 +86,17 @@ public class LoadingListRepository { return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName); } + public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion, + long operatorId, String operatorName) { + return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion, + operatorId, operatorName); + } + + public int touchForPreloadConfirmation(long planId, int expectedVersion, + long operatorId, String operatorName) { + return mapper.touchForPreloadConfirmation(planId, expectedVersion, operatorId, operatorName); + } + public int advance(long planId, String fromStatus, String toStatus, int expectedVersion, long operatorId, String operatorName) { return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName); @@ -116,7 +128,36 @@ public class LoadingListRepository { customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(), row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()), value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()), - decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion()); + decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion(), readiness(row)); + } + + private PreloadReadiness readiness(AggregateRow row) { + boolean pushed = row.getPreloadPushedAt() != null; + boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo()); + int linkCount = Math.toIntExact(value(row.getPreloadLinkCount())); + int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount())); + boolean oceanLinked = row.getOceanBookingId() != null; + boolean released = "RELEASED".equals(row.getOceanBookingStatus()); + boolean editing = "EDITING".equals(row.getPlanStatus()); + boolean taskCreated = row.getPdaTaskId() != null; + 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 canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; + if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); + String confirmationStatus = allConfirmed ? "CONFIRMED" + : confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING"; + return new PreloadReadiness(pushed, containerReady, linkCount, confirmedCount, + row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(), + row.getPdaTaskStatus(), confirmationStatus, canConfirm, + prerequisitesReady && allConfirmed, blockers); } private ConfirmedSummary confirmed(AggregateRow row) { 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 11881909a..40916e5ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -10,6 +10,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; @@ -18,6 +20,7 @@ import com.baiyue.firstleg.client.LoadingDocumentFileClient; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; 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.fasterxml.jackson.core.JsonProcessingException; @@ -28,6 +31,7 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @@ -38,6 +42,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -57,6 +62,9 @@ public class LoadingListService { @Resource private LoadingPickRepository pickRepository; @Resource + @Lazy + private LoadingPickService loadingPickService; + @Resource private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource private ObjectMapper objectMapper; @@ -420,9 +428,51 @@ public class LoadingListService { "containerNo", containerNo, "voyageNo", voyageNo, "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), trim(request.operationReason())); + loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName); return repository.preloadDetail(requirePlan(planId)); } + @Transactional + @BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "原子确认装柜计划全部预装票据") + public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName, + String idempotencyKey, + ConfirmPreloadRequest request) { + String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion()); + String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey); + HistoryRow replay = repository.history(operationId); + if (replay != null) return confirmationReplay(replay, requestHash); + + AggregateRow before = requirePlan(planId); + List links = loadingPickService.requirePreloadConfirmationReady(planId); + if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划已被其他操作修改,请刷新后重试"); + } + Map preloads = new LinkedHashMap<>(); + links.forEach(link -> preloads.putIfAbsent(link.preloadPlanId(), link)); + for (Link link : preloads.values()) { + if (repository.confirmLinkedPreload(link.preloadPlanId(), link.preloadStatus(), + link.preloadVersion(), operatorId, trim(operatorName)) != 1) { + throw conflict("关联预装票据已被其他操作修改,请刷新后重试"); + } + } + links.stream().map(Link::containerBookingId).filter(java.util.Objects::nonNull).distinct() + .forEach(containerBookingId -> loadingPickService.beginIfReady( + containerBookingId, operatorId, operatorName)); + + AggregateRow after = requirePlan(planId); + PreloadDetail detail = repository.preloadDetail(after); + PreloadConfirmationResponse response = new PreloadConfirmationResponse(planId, + after.getContainerBookingId(), detail.summary().readiness().confirmationStatus(), + after.getVersion(), detail.summary().readiness()); + repository.appendHistory(planId, operationId, "CONFIRM_PRELOAD", + before.getPlanStatus(), after.getPlanStatus(), json(snapshot(before)), + json(values("requestHash", requestHash, "response", response)), + operatorId, trim(operatorName), null); + return response; + } + /** * PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。 */ @@ -481,6 +531,9 @@ public class LoadingListService { if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) { throw conflict("仅预装柜中或拣货中可修改集装箱号与航次"); } + if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) { + throw conflict("仅已推送的预装清单可维护运输字段"); + } } private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { @@ -507,6 +560,17 @@ public class LoadingListService { } } + private PreloadConfirmationResponse confirmationReplay(HistoryRow replay, String requestHash) { + requireSameRequest(replay, requestHash); + try { + var response = objectMapper.readTree(replay.getAfterSnapshot()).get("response"); + if (response == null) throw new IllegalStateException("预装确认幂等响应缺失"); + return objectMapper.treeToValue(response, PreloadConfirmationResponse.class); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法读取预装确认幂等响应", exception); + } + } + private Map snapshot(AggregateRow row) { return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(), "voyageNo", row.getVoyageNo(), "version", row.getVersion()); 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 daa6ea89b..6fd3650bb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -32,6 +32,8 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -71,6 +73,8 @@ public class LoadingPickService { @Resource private OceanBookingRepository oceanBookingRepository; @Resource + private LoadingModelBridgeService loadingModelBridge; + @Resource private LoadingListService loadingListService; @Resource private InboundStatusHistoryMapper inboundHistories; @@ -88,6 +92,8 @@ public class LoadingPickService { if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return; var plan = smartLoadingRepository.planByBooking(containerBookingId); if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; + var links = loadingModelBridge.byLoadingPlan(plan.getId()); + if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); if (allocation == null) return; var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); @@ -97,6 +103,29 @@ public class LoadingPickService { smartLoadingRepository.items(plan.getId())); } + public List requirePreloadConfirmationReady(long loadingPlanId) { + List links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId); + for (Link link : links) { + 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()); + if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) { + throw conflict("确认预装前关联订舱必须已放舱"); + } + if (repository.taskByPlan(link.loadingPlanId()) != null) { + throw conflict("PDA任务已创建,不能重复确认预装"); + } + } + return links; + } + @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, List planItems) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 40341327f..5b3b00d26 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -236,9 +236,6 @@ public class SmartLoadingService { String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion()); LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - if (!StringUtils.hasText(booking.getContainerNo())) { - throw validation("录入真实柜号后才能推送预装清单并开启PDA作业"); - } if (key.equals(plan.getPushIdempotencyKey())) { requirePushReplay(planId, key, requestHash); return view(plan, booking); @@ -261,7 +258,6 @@ public class SmartLoadingService { } LoadingPlanEntity pushed = repository.plan(planId); loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key); - loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName); return view(pushed, requireBooking(pushed.getContainerBookingId())); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java b/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java index 1864e0339..f8e802674 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadController.java @@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.service.FirstlegScopeGuard; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse; import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; @@ -44,32 +43,6 @@ public class PlatformSplitPreloadController { return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request)); } - @Operation(summary = "提交预装清单确认") - @PostMapping("/{inboundId}/preload/confirming") - @PreAuthorize("hasAuthority('firstleg:inbound:preload')") - @BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId", - description = "提交预装清单确认") - public ApiResponse confirming( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, - @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ConfirmPreloadRequest request) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request)); - } - - @Operation(summary = "确认预装清单并进入装柜中") - @PostMapping("/{inboundId}/preload/confirm") - @PreAuthorize("hasAuthority('firstleg:inbound:preload')") - @BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId", - description = "确认预装清单") - public ApiResponse confirm( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, - @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ConfirmPreloadRequest request) { - FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request)); - } - @Operation(summary = "查询预装清单") @GetMapping("/{inboundId}/preload") @PreAuthorize("hasAuthority('firstleg:inbound:preload-view')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java index 2caf3352b..0343cf03e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/dto/SplitRequests.java @@ -18,8 +18,6 @@ public final class SplitRequests { List<@Valid PreloadItem> items, @Size(max = 1000) String remark) {} - public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {} - public record PreloadItem( @Size(max = 64) String containerCode, @Size(max = 128) String cargoReference, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java index efe37bd23..c43d5921a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/PreloadPlanMapper.java @@ -16,12 +16,4 @@ public interface PreloadPlanMapper extends BaseMapper { @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion); - @Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, " - + "updated_by_name=#{operatorName}, version=version+1 " - + "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} " - + "AND version=#{expectedVersion}") - int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, - @Param("expectedVersion") int expectedVersion); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java index c608e8c06..2b797ea91 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java @@ -3,7 +3,6 @@ package com.baiyue.firstleg.split.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; @Mapper public interface SplitInboundMapper { @@ -11,11 +10,5 @@ public interface SplitInboundMapper { + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); - @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " - + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " - + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}") - int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); - record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java index fc0d5a2bf..0e6097f6e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/repository/SplitRepository.java @@ -20,10 +20,6 @@ public class SplitRepository { return inbounds.find(tenantId, inboundId); } - public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { - return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion); - } - public PreloadPlanEntity findPlan(long tenantId, long inboundId) { return plans.selectOne(new LambdaQueryWrapper() .eq(PreloadPlanEntity::getTenantId, tenantId) @@ -39,12 +35,6 @@ public class SplitRepository { return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion); } - public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus, - long operatorId, String operatorName, int expectedVersion) { - return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus, - operatorId, operatorName, expectedVersion); - } - public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId, String operation, String key) { return operations.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 820e5b29e..70146cf38 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -2,11 +2,7 @@ package com.baiyue.firstleg.split.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; -import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; -import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.split.domain.PreloadStatus; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse; @@ -24,19 +20,14 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.dao.DuplicateKeyException; -import java.time.LocalDateTime; import java.util.List; /** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */ @Service public class SplitPreloadService { private static final String SMART_SPLIT = "SMART_SPLIT"; - private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING"; - private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD"; @Resource private SplitRepository repository; - @Resource private LoadingModelBridgeService loadingModelBridge; - @Resource private InboundStatusHistoryMapper histories; @Resource private ObjectMapper objectMapper; @Transactional @@ -101,67 +92,6 @@ public class SplitPreloadService { } } - @Transactional - public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId, - String operatorName, String key, - ConfirmPreloadRequest request) { - SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key); - if (replay != null) return replay; - InboundSnapshot inbound = requireInbound(tenantId, inboundId); - boolean loading = "LOADING".equals(inbound.processStatus()); - if (loading) { - // Starting a real loading task advances the inbound before its preload - // confirmation is submitted; retain the linked-container hard gate. - loadingModelBridge.requireStarted(tenantId, inboundId); - } else { - requireWaitingSplit(inbound, "仅待分柜订单可以提交预装清单确认"); - } - PreloadPlanEntity plan = requirePlan(tenantId, inboundId); - if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { - return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", response(plan))); - } - if (!PreloadStatus.PENDING.equals(plan.getStatus()) - || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, - PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) { - throw concurrent(); - } - return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", - response(repository.findPlan(tenantId, inboundId)))); - } - - @Transactional - public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId, - String operatorName, String key, - ConfirmPreloadRequest request) { - SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key); - if (replay != null) return replay; - InboundSnapshot inbound = requireInbound(tenantId, inboundId); - if ("LOADING".equals(inbound.processStatus())) { - loadingModelBridge.requireStarted(tenantId, inboundId); - return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, - new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); - } - requireWaitingSplit(inbound, "仅待分柜订单可以确认预装清单"); - PreloadPlanEntity plan = requirePlan(tenantId, inboundId); - if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); - } - loadingModelBridge.requireStarted(tenantId, inboundId); - if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED, - operatorId, operatorName, request.expectedVersion()) != 1) { - throw concurrent(); - } - if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) { - throw concurrent(); - } - addHistory(tenantId, inboundId, inbound.processStatus(), "LOADING", CONFIRM_PRELOAD, - operatorId, operatorName); - return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, - new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); - } - public PreloadPlanResponse detail(long tenantId, long inboundId) { requireInbound(tenantId, inboundId); return response(requirePlan(tenantId, inboundId)); @@ -209,19 +139,6 @@ public class SplitPreloadService { return "WAIT_SPLIT".equals(status); } - private void addHistory(long tenantId, long inboundId, String from, String to, String action, - long operatorId, String operatorName) { - InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); - history.setTenantId(tenantId); - history.setInboundId(inboundId); - history.setFromStatus(from); - history.setToStatus(to); - history.setAction(action); - history.setOperatorId(operatorId); - history.setOperatorName(operatorName); - histories.insert(history); - } - private PreloadPlanResponse response(PreloadPlanEntity plan) { if (plan == null) return null; return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(), diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 566c32cc3..2ae737ed2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -361,8 +361,9 @@ paths: post: summary: 推送智能装柜计划到预装清单 description: >- - 需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入, - 否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。 + 需要 platform:firstleg:smart-loading:push 权限。push-preload 不要求真实柜号, + 只冻结 EDITING 柜次计划的明细并建立稳定预装关联,不创建 PDA 作业任务;计划必须处于 EDITING 且版本匹配。 + 预装页面随后补录真实柜号;只有预装 CONFIRMED、真实柜号存在、已关联 CONFIRMED/RELEASED 订舱时才创建待领取 PDA 任务,领取后才进入 LOADING。 智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING, 但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。 推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联, diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index abc037a61..5f0b45719 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -17,7 +17,7 @@ paths: - name: status in: query description: 预装状态 - schema: {type: string, enum: [LOADING]} + schema: {type: string, enum: [EDITING, LOADING]} - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -34,7 +34,7 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/transport: patch: summary: 修改预装运输信息 - description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。 + description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。 x-permission: platform:firstleg:preload:edit parameters: - {$ref: '#/components/parameters/PlanId'} @@ -47,6 +47,17 @@ paths: schema: {$ref: '#/components/schemas/TransportUpdateRequest'} responses: '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/confirm: + post: + summary: 原子确认整份预装计划 + description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 + x-permission: platform:firstleg:preload:edit + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}} + responses: + '200': {$ref: '#/components/responses/PreloadConfirmationResponse'} /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 @@ -175,6 +186,9 @@ components: PreloadDetailResponse: description: 预装清单详情查询或操作成功,也可能返回业务失败 content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}} + PreloadConfirmationResponse: + description: 计划级预装确认成功、精确重放或业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/PreloadConfirmationEnvelope'}}} ConfirmedPageResponse: description: 装柜清单分页查询成功或业务失败,按响应体 code 判断 content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}} @@ -218,6 +232,14 @@ components: data: allOf: [{$ref: '#/components/schemas/PreloadDetail'}] nullable: true + PreloadConfirmationEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/PreloadConfirmation'}] + nullable: true ConfirmedPageEnvelope: allOf: - {$ref: '#/components/schemas/ApiEnvelope'} @@ -268,26 +290,61 @@ components: customerName: {type: string, nullable: true, description: 客户名称} PreloadSummary: type: object - required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version] + required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version, readiness] properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} preloadNo: {type: string, description: 预装单号} containerNo: {type: string, nullable: true, description: 集装箱号} - internalContainerNo: {type: string, nullable: true, description: 内部柜号} + cabinetNo: {type: string, nullable: true, description: 稳定柜次号;不得与真实集装箱号混用} customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}} warehouseId: {$ref: '#/components/schemas/NullableLongId'} warehouseName: {type: string, nullable: true, description: 仓库名称} vesselName: {type: string, nullable: true, description: 船名} voyageNo: {type: string, nullable: true, description: 航次} containerType: {type: string, nullable: true, description: 货柜类型} - status: {type: string, enum: [LOADING], description: 装柜状态} + status: {type: string, enum: [EDITING, LOADING], description: 装柜计划状态} skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数} totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} totalVolumeCbm: {type: number, description: 总体积,单位立方米} totalGrossWeightKg: {type: number, description: 总毛重,单位千克} createdAt: {type: string, format: date-time, description: 创建时间} version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} + readiness: {$ref: '#/components/schemas/PreloadReadiness'} + PreloadReadiness: + type: object + required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons] + properties: + preloadPushed: {type: boolean, description: 是否已完成 push-preload} + containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo} + preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数} + confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数} + oceanBookingId: {$ref: '#/components/schemas/NullableLongId'} + oceanBookingStatus: {type: string, nullable: true, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED], description: 真实订舱当前状态} + pdaTaskId: {$ref: '#/components/schemas/NullableLongId'} + 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} + 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]} + ConfirmPreloadRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version} + PreloadConfirmation: + type: object + required: [planId, containerBookingId, confirmationStatus, version, readiness] + properties: + planId: {$ref: '#/components/schemas/LongId'} + containerBookingId: {$ref: '#/components/schemas/LongId'} + confirmationStatus: {type: string, enum: [CONFIRMED]} + version: {type: integer, format: int32, minimum: 0, description: 确认后的装柜计划 version} + readiness: {$ref: '#/components/schemas/PreloadReadiness'} ConfirmedSummary: type: object required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version] @@ -298,7 +355,7 @@ components: warehouseId: {$ref: '#/components/schemas/NullableLongId'} warehouseName: {type: string, nullable: true, description: 仓库名称} containerNo: {type: string, nullable: true, description: 集装箱号} - internalContainerNo: {type: string, nullable: true, description: 内部柜号} + cabinetNo: {type: string, nullable: true, description: 稳定柜次号} status: {type: string, enum: [COMPLETED], description: 装柜清单状态} totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} totalGrossWeightKg: {type: number, description: 总毛重,单位千克} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml index e5c5ccacd..37506b612 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Phase 3 Preload and Container Loading Bridge API version: 1.0.0 - description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before PDA task creation; LOADING plans cannot be repaired through post-hoc binding. + description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before any PDA task creation; push-preload never requires containerNo and never advances LOADING. LOADING plans cannot be repaired through post-hoc binding. paths: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans: get: 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 3a40a3057..d228c6137 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.service.LoadingListService; @@ -34,6 +35,8 @@ class LoadingListControllerContractTest { authority("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class); authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, long.class, long.class, String.class, TransportUpdateRequest.class); + authority("confirmPreload", "platform:firstleg:preload:edit", AuthenticatedUser.class, + long.class, String.class, ConfirmPreloadRequest.class); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); @@ -50,6 +53,8 @@ class LoadingListControllerContractTest { void writesAudited() throws Exception { audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class, String.class, TransportUpdateRequest.class); + audit("confirmPreload", "PRELOAD_PLAN_CONFIRM", long.class, long.class, String.class, + String.class, ConfirmPreloadRequest.class); } @Test @@ -74,6 +79,16 @@ class LoadingListControllerContractTest { .contains("beforeSnapshot") .contains("containerNo") .contains("voyageNo") + .contains("已推送且仍为 EDITING") + .contains("不能清空已创建 PDA 任务的柜号") + .contains("/loading-plans/{planId}/confirm") + .contains("confirmationStatus") + .contains("canConfirm") + .contains("blockingReasons") + .contains("oceanBookingStatus") + .contains("pdaTaskId") + .contains("cabinetNo") + .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") .doesNotContain("/items/{itemId}"); @@ -114,6 +129,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-plans", "/api/platform/v1/first-leg/loading-plans/{planId}", "/api/platform/v1/first-leg/loading-plans/{planId}/transport", + "/api/platform/v1/first-leg/loading-plans/{planId}/confirm", "/api/platform/v1/first-leg/loading-lists", "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", @@ -125,7 +141,9 @@ class LoadingListControllerContractTest { assertThat(schemas).containsKeys( "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", - "ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope", + "ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness", + "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope", + "BusinessDocumentEnvelope", "DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument"); assertThat(properties(schemas, "DocumentUploadRequest")) .containsOnlyKeys("file", "expectedVersion", "operationReason"); @@ -134,7 +152,9 @@ class LoadingListControllerContractTest { assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages"); assertThat(properties(schemas, "PreloadSummary")) - .containsKeys("planId", "containerBookingId", "customers", "skuCount"); + .containsKeys("planId", "containerBookingId", "containerNo", "cabinetNo", + "customers", "skuCount", "readiness") + .doesNotContainKey("internalContainerNo"); assertThat(properties(schemas, "ConfirmedSummary")) .containsKeys("planId", "containerBookingId", "loadingListNo"); assertThat(properties(schemas, "LoadingItem")).containsKeys( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 6ef23e5ed..c84097b4b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -26,11 +26,13 @@ class PlatformSmartLoadingControllerContractTest { authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class); } - @Test void openApiRequiresRealContainerNumberBeforePreloadPush() throws Exception { + @Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception { String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:") - .contains("真实柜号必须已录入") - .contains("PDA作业任务"); + .contains("不要求真实柜号") + .contains("不创建 PDA 作业任务") + .contains("领取后才进入 LOADING") + .doesNotContain("真实柜号必须已录入"); } private void authority(String method,String permission,Class...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index aa90a16b2..1b786f3de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -166,6 +166,19 @@ class LoadingModelBridgeServiceTest { verify(repository, never()).insert(any()); } + @Test + void confirmationGateRejectsIncompleteAllocatedLinks() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot( + 40L, 50L, "C1", "EDITING", 3, LocalDateTime.now())); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + when(repository.byLoadingPlan(40)).thenReturn(List.of( + link("PENDING", 2, "EDITING", 3, 8))); + + assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40)) + .hasMessageContaining("关联不完整"); + } + @Test void terminalGateRequiresEveryLinkedContainerPlanCompleted() { when(repository.byInbound(10, 20)).thenReturn(List.of( 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 5c401ca66..6b46d2e3d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -37,6 +37,10 @@ class LoadingListQueryMapperContractTest { .contains("p.preload_pushed_at IS NOT NULL") .contains("p.plan_status IN ('EDITING', 'LOADING')") .contains("active_item.deleted = 0") + .contains("fl_preload_loading_plan_link") + .contains("fl_ocean_booking_container_allocation") + .contains("fl_loading_pick_task") + .contains("pda_task_status") .contains("customer_item.account_id = #{query.customerId}") .contains("b.warehouse_id = #{query.warehouseId}") .doesNotContain("query.accountId") @@ -54,14 +58,19 @@ class LoadingListQueryMapperContractTest { void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { String touch = sql("touchEditable", Update.class); String transport = sql("updateTransport", Update.class); + String confirmPreload = sql("confirmLinkedPreload", Update.class); + String confirmPlan = sql("touchForPreloadConfirmation", Update.class); String advance = sql("advance", Update.class); assertThat(touch) - .contains("plan_status = 'LOADING'") + .contains("plan_status IN ('EDITING', 'LOADING')") + .contains("preload_pushed_at IS NOT NULL") .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("preload_pushed_at IS NOT NULL") .contains("#{containerNo} IS NOT NULL") .contains("fl_loading_pick_task") .doesNotContain("DELETE") @@ -71,6 +80,16 @@ class LoadingListQueryMapperContractTest { .contains("plan_status = #{toStatus}") .contains("version = #{expectedVersion}") .doesNotContain("ROLLED_BACK"); + assertThat(confirmPreload) + .contains("status = 'CONFIRMED'") + .contains("status = #{fromStatus}") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + assertThat(confirmPlan) + .contains("plan_status = 'EDITING'") + .contains("preload_pushed_at IS NOT NULL") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); } @Test 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 0052368b6..c2d7bc9db 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -39,6 +39,13 @@ class LoadingListRepositoryTest { row.setPlanNo("LP-41"); row.setPlanStatus("LOADING"); row.setVersion(1); + row.setContainerNo("MSCU-001"); + row.setCabinetNo("CAB-001"); + row.setPreloadPushedAt(java.time.LocalDateTime.now()); + row.setPreloadLinkCount(2L); + row.setConfirmedPreloadCount(1L); + row.setOceanBookingId(81L); + row.setOceanBookingStatus("RELEASED"); Page preloadPage = new Page<>(1, 20); preloadPage.setRecords(List.of(row)); @@ -58,6 +65,10 @@ class LoadingListRepositoryTest { assertThat(preload.planId()).isEqualTo(41L); assertThat(preload.containerBookingId()).isEqualTo(61L); + assertThat(preload.containerNo()).isEqualTo("MSCU-001"); + assertThat(preload.cabinetNo()).isEqualTo("CAB-001"); + assertThat(preload.readiness().confirmationStatus()).isEqualTo("PARTIALLY_CONFIRMED"); + assertThat(preload.readiness().oceanBookingStatus()).isEqualTo("RELEASED"); assertThat(confirmed.planId()).isEqualTo(41L); assertThat(confirmed.containerBookingId()).isEqualTo(61L); } 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 0891ac78e..8fd023afa 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -6,6 +6,9 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.client.LoadingDocumentFileClient; @@ -13,6 +16,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; 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.fasterxml.jackson.databind.ObjectMapper; @@ -31,6 +35,7 @@ import java.util.List; 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.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -45,6 +50,8 @@ class LoadingListServiceTest { @MockBean private LoadingPickRepository pickRepository; @MockBean + private LoadingPickService loadingPickService; + @MockBean private LoadingDocumentFileClient documentFiles; @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; @@ -87,11 +94,43 @@ class LoadingListServiceTest { new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); verify(repository).updateTransport(10L, 3, "CSLU1234567", "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("补录箱号")); } + @Test + void pushedEditingPreloadCanReceiveContainerNumber() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + AggregateRow after = row("EDITING", 4); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + after.setContainerNo("CSLU1234567"); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSLU1234567", 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, "补录柜号")); + + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + } + + @Test + void editingPlanCannotReceiveTransportBeforePush() { + when(repository.aggregate(10L)).thenReturn(row("EDITING", 3)); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key", + new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"))) + .isInstanceOf(BizException.class).hasMessageContaining("已推送"); + + verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), + org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + @Test void shouldRejectTransportUpdateAfterConfirmation() { when(repository.history(anyString())).thenReturn(null); @@ -456,10 +495,110 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + AggregateRow after = row("EDITING", 4); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + List links = List.of( + new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null), + new Link(2L, 102L, 202L, 302L, "CONFIRMING", 5, 10L, 30L, + "MSCU-001", "EDITING", 3, 6, null)); + PreloadReadiness readiness = readiness("CONFIRMED", false, true, 80L); + var detail = org.mockito.Mockito.mock( + com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail.class); + var summary = org.mockito.Mockito.mock( + com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary.class); + when(detail.summary()).thenReturn(summary); + when(summary.readiness()).thenReturn(readiness); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1); + when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); + when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); + when(repository.preloadDetail(after)).thenReturn(detail); + + PreloadConfirmationResponse response = service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + + assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(4); + assertThat(response.readiness().pdaTaskId()).isEqualTo(80L); + verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); + verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator"); + verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); + verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), + eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), + eq("operator"), org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { + PreloadConfirmationResponse expected = new PreloadConfirmationResponse( + 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); + String requestHash = sha256("CONFIRM_PRELOAD|10|3"); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(new ObjectMapper().writeValueAsString( + java.util.Map.of("requestHash", requestHash, "response", expected))); + when(repository.history(anyString())).thenReturn(replay); + + PreloadConfirmationResponse actual = service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + + assertThat(actual).isEqualTo(expected); + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + } + + @Test + void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)) + .thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱")); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("第二票"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void reusedConfirmationKeyWithDifferentVersionConflictsBeforeReadiness() { + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + } + + private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm, + boolean readyForPda, Long taskId) { + return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId, + taskId == null ? null : "LOADING", confirmationStatus, canConfirm, + readyForPda, List.of()); + } + private static AggregateRow row(String status, int version) { AggregateRow row = new AggregateRow(); row.setPlanId(10L); row.setPlanNo("PL-10"); + row.setContainerBookingId(30L); row.setPlanStatus(status); row.setWarehouseId(100L); row.setVersion(version); 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 81ace047a..41f90b073 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -19,6 +19,8 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -38,6 +40,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -55,6 +58,8 @@ class LoadingPickServiceTest { @MockBean private OceanBookingRepository oceanBookingRepository; @MockBean + private LoadingModelBridgeService loadingModelBridge; + @MockBean private LoadingListService loadingListService; @MockBean private InboundStatusHistoryMapper inboundHistories; @@ -77,6 +82,8 @@ class LoadingPickServiceTest { when(smartLoadingRepository.booking(40L)).thenReturn(booking); 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))); when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); when(oceanBookingRepository.find(90L)).thenReturn(ocean); when(repository.taskByPlan(10L)).thenReturn(null); @@ -102,6 +109,8 @@ class LoadingPickServiceTest { var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED"); when(smartLoadingRepository.booking(40L)).thenReturn(booking); when(smartLoadingRepository.planByBooking(40L)).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))); when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); when(oceanBookingRepository.find(90L)).thenReturn(ocean); @@ -110,6 +119,93 @@ class LoadingPickServiceTest { verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); } + @Test + void doesNotOpenTaskBeforeEveryLinkedPreloadIsConfirmed() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(oceanBookingRepository, never()).allocationByContainer(40L); + } + + @Test + void planConfirmationGateRequiresRealContainerAndReleasedBooking() { + 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"); + 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); + + assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); + } + + @Test + void planConfirmationGateRejectsMissingRealContainerNumber() { + 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); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + + assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) + .isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号"); + verify(oceanBookingRepository, never()).allocationByContainer(anyLong()); + } + + @Test + void newKeyCannotConfirmAnAlreadyConfirmedPlanAgain() { + Link confirmed = new Link(1L, 1L, 50L, 2L, "CONFIRMED", 2, + 10L, 40L, "MSCU-001", "EDITING", 4, 2, null); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)) + .thenReturn(List.of(confirmed)); + + assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) + .isInstanceOf(BizException.class).hasMessageContaining("已确认"); + verify(smartLoadingRepository, never()).booking(anyLong()); + } + + @Test + void readinessCreatesAtMostOneTask() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).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))); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + when(repository.taskByPlan(10L)).thenReturn(new LoadingPickTaskEntity()); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(smartLoadingRepository, never()).startPicking( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString()); + } + @Test void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() { LoadingPickTaskEntity task = task(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 64cefd94a..98eedf0f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -397,10 +397,10 @@ class SmartLoadingServiceTest { assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD"); assertThat(history.getValue().getAfterSnapshot()) .contains(sha256("PUSH_PRELOAD|1|3")); - var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService); + var ordered = inOrder(splitPreloadService, loadingModelBridge); ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); - ordered.verify(loadingPickService).beginIfReady(99, 7, "u"); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test @@ -539,37 +539,34 @@ class SmartLoadingServiceTest { } @Test - void rejectsPushWithoutContainerNumberBeforePdaOpening() { + void pushesWithoutContainerNumberAndDoesNotOpenPda() { LoadingPlanEntity editing = plan("EDITING", 1); ContainerBookingEntity booking = booking(); booking.setContainerNo(null); - when(repository.plan(1)).thenReturn(editing); + when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2)); when(repository.booking(99)).thenReturn(booking); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1); - BizException error = catchThrowableOfType( - () -> service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"), - BizException.class); + service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"); - assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); - assertThat(error.getMessage()).contains("真实柜号"); - verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(repository).push(1, "no-container-key", 1, 7, "u"); verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test - void rejectsReplayWhenTheContainerNumberIsStillMissing() { + void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() { LoadingPlanEntity pushed = plan("EDITING", 2); pushed.setPushIdempotencyKey("replay-key"); ContainerBookingEntity booking = booking(); booking.setContainerNo(" "); when(repository.plan(1)).thenReturn(pushed); when(repository.booking(99)).thenReturn(booking); + when(repository.operation("replay-key")) + .thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2"))); - BizException error = catchThrowableOfType( - () -> service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u"), - BizException.class); + service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u"); - assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java index 716e160eb..f5deb15f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.split.controller; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -13,14 +12,19 @@ class PlatformSplitPreloadControllerContractTest { @Test void shouldDeclareStableOperationsAndButtonPermissions() throws Exception { assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class); - assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class); - assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class); assertThat(PlatformSplitPreloadController.class .getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class).getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('firstleg:inbound:preload-view')"); } + @Test + void doesNotExposeInboundScopedPreloadConfirmation() { + assertThat(PlatformSplitPreloadController.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("confirm") + || method.getName().equals("confirming")); + } + private void assertAuthority(String name, String expected, Class requestType) throws Exception { Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name, com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 9baf6a9e8..267aaaf36 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -1,8 +1,5 @@ package com.baiyue.firstleg.split.service; -import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; -import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; -import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; @@ -33,8 +30,6 @@ import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class SplitPreloadServiceTest { @Mock SplitRepository repository; - @Mock LoadingModelBridgeService loadingModelBridge; - @Mock InboundStatusHistoryMapper histories; @InjectMocks SplitPreloadService service; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -85,53 +80,4 @@ class SplitPreloadServiceTest { verify(repository, never()).createPlan(any()); } - @Test - void confirmsPlanAndMovesInboundToLoading() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4)); - PreloadPlanEntity plan = new PreloadPlanEntity(); - plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); - plan.setItemsJson("[]"); - when(repository.findPlan(10, 20)).thenReturn(plan); - org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20); - when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1); - when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1); - SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3", - new ConfirmPreloadRequest(1)); - assertThat(result.mainStatus()).isEqualTo("LOADING"); - verify(loadingModelBridge).requireStarted(10, 20); - verify(repository).beginLoading(10, 20, 7, 4); - } - - @Test - void startsPreloadConfirmationAfterLoadingBeginsWithLinkedContainerGate() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5)); - PreloadPlanEntity plan = new PreloadPlanEntity(); - plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("PENDING"); plan.setVersion(3); - plan.setItemsJson("[]"); - when(repository.findPlan(10, 20)).thenReturn(plan); - org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20); - when(repository.changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3)).thenReturn(1); - - SplitActionResponse result = service.startConfirming(10, 20, 7, "admin", "k4", - new ConfirmPreloadRequest(3)); - - assertThat(result.mainStatus()).isEqualTo("LOADING"); - verify(loadingModelBridge).requireStarted(10, 20); - verify(repository).changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3); - } - - @Test - void rejectsLoadingPreloadConfirmationWhenLinkedContainersHaveNotStarted() { - when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5)); - org.mockito.Mockito.doThrow(new IllegalStateException("not started")) - .when(loadingModelBridge).requireStarted(10, 20); - - assertThatThrownBy(() -> service.startConfirming(10, 20, 7, "admin", "k5", - new ConfirmPreloadRequest(3))) - .isInstanceOf(IllegalStateException.class) - .hasMessage("not started"); - } } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 3e69f893a..febd92aa5 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -53,6 +53,22 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 +## WAIT_SPLIT 到 LOADED 冻结链 + +默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为: + +`WAIT_SPLIT -> 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 预装 EDITING 补集装箱号 -> 关联 CONFIRMED 订舱 -> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货/PDA complete -> 五类证据 -> 报关及下游门禁 -> loading complete -> LOADED`。 + +其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;未补号、订舱未关联或未 `RELEASED`、整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 + +部署后需要定向验证该完整链时,模块顺序固定为: + +```powershell +node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize +``` + +该命令会写测试环境,必须另行获得当前对话授权。离线入口合同为 `../full-regression-entry-contract.test.mjs`,生产者与状态合同为 `phase3-producer-chain-contract.test.mjs`。 + ## 消息中心模块 `notifications.mjs` 覆盖平台消息模板的查询、新增、编辑、版本冲突、激活与最终停用,以及未来定时消息任务的创建、幂等重放、列表查询、取消和非法重试。模块只创建未来定时任务并在结束前取消,不实际投递验收消息。定向 HTTP 验收需要当前对话明确授权: diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 08d6816ab..ce35d2c11 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -233,6 +233,112 @@ export async function run(context) { staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)), `HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS) + const pushKey = `${context.marker}-container-push`.slice(0, 128) + const pushRequest = Object.freeze({ + path: `${SMART}/plans/${planId}/push-preload`, + body: Object.freeze({ expectedVersion: Number(assigned.version) }), + headers: Object.freeze({ 'Idempotency-Key': pushKey }), + }) + const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) + const pushed = context.apiSuccess(MODULE, '无真实柜号推送预装计划成功', pushResponse, '', OPTIONS) + context.assert(MODULE, '无号 push 仅冻结 EDITING 计划且版本递增', pushResponse, + id(pushed?.id) === planId && pushed?.status === 'EDITING' && pushed?.containerNo == null + && Number(pushed?.version) > Number(assigned.version), + `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) + if (pushed?.status !== 'EDITING') return + + const pushedDetailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS) + const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) + const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS) + const pushedLink = Array.isArray(pushedLinks) + ? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null + context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse, + Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1, + `linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS) + if (!pushedLink?.id) return + const pushedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse, + pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404', + responseEvidence(pushedTaskResponse), OPTIONS) + + const pushBaseline = JSON.stringify({ + plan: pushed, detail: pushedDetail, links: pushedLinks, + task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) }, + }) + const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) + context.apiSuccess(MODULE, '推送预装 exact replay', pushReplayResponse, '', OPTIONS) + const replayPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS) + const replayDetailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS) + const replayLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) + const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) + const replayTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const pushReplaySnapshot = JSON.stringify({ + plan: replayPlan, detail: replayDetail, links: replayLinks, + task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) }, + }) + context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse, + pushBaseline === pushReplaySnapshot && codeOf(replayTaskResponse) === 'A0404', + `baselineBytes=${pushBaseline.length}, replayBytes=${pushReplaySnapshot.length}`, OPTIONS) + const pushSameKeyDifferentBody = await request(context, 'POST', pushRequest.path, + { expectedVersion: Number(pushed.version) }, pushRequest.headers) + context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody, + failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS) + const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, + { 'Idempotency-Key': `${context.marker}-container-push-duplicate`.slice(0, 128) }) + context.addStep(MODULE, '已推送计划使用新 key 重复 push 拒绝', pushNewKeyDuplicate, + failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), OPTIONS) + + const rejectedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS) + const rejectedDetailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS) + const rejectedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) + const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) + const rejectedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const rejectedSnapshot = JSON.stringify({ + plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks, + task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) }, + }) + context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse, + failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate) + && pushBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404', + `baselineBytes=${pushBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS) + if (pushBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return + + const realContainerNo = `TST-${suffix}`.slice(0, 64) + const transportResponse = await request(context, 'PATCH', + `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { + containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), + expectedVersion: Number(pushed.version), operationReason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) }) + const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS) + context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse, + transported?.summary?.containerNo === realContainerNo + && id(transported?.summary?.planId) === planId + && id(transported?.summary?.containerBookingId) === containerId, + `containerNo=${transported?.summary?.containerNo || 'missing'}, containerBookingId=${id(transported?.summary?.containerBookingId) || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS) + if (transported?.summary?.containerNo !== realContainerNo) return + + const unlinkedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const unlinkedTaskAbsent = unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404' + context.addStep(MODULE, '已补号但未关联订舱不生成 PDA 任务', unlinkedTaskResponse, + unlinkedTaskAbsent, responseEvidence(unlinkedTaskResponse), OPTIONS) + if (!unlinkedTaskAbsent) return + + const transportedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) + const transportedContainer = context.apiSuccess(MODULE, '预装补号后读取模拟柜最新版本', transportedContainerResponse, '', OPTIONS) + if (!Number.isInteger(Number(transportedContainer?.version))) return + const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) const bookingCreated = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的已确认真实订舱', bookingCreate, '', OPTIONS) @@ -264,7 +370,7 @@ export async function run(context) { const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType) if (!requirement?.id || !wrongTypeRequirement?.id) return const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { - oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(containerDetail.version), + oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(transportedContainer.version), }) context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse, failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS) @@ -303,7 +409,7 @@ export async function run(context) { const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || []) const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { - oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(containerDetail.version), + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(transportedContainer.version), }) const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse, responseEvidence(linkResponse), OPTIONS) @@ -334,41 +440,11 @@ export async function run(context) { context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse, failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS) - const missingContainerPushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, - { expectedVersion: Number(assigned.version) }, - { 'Idempotency-Key': `${context.marker}-container-push-without-container-no`.slice(0, 128) }) - const missingContainerPushRejected = failure(missingContainerPushResponse) - context.addStep(MODULE, '无真实柜号拒绝推送预装计划', missingContainerPushResponse, - missingContainerPushRejected, - `HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS) - if (!missingContainerPushRejected) return - const gatedTaskResponse = await context.request('GET', - `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const gatedTaskAbsent = failure(gatedTaskResponse) - || !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) - context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', gatedTaskResponse, - gatedTaskAbsent, - `HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) - if (!gatedTaskAbsent) return - - const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { - containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64), - version: Number(linkedContainer.version), reason: context.marker, - }) - const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS) - context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse, - transported?.containerNo === `TST-${suffix}`.slice(0, 64) - && id(transported?.id) === containerId && transported?.cabinetNo === linkedContainer.cabinetNo, - `containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${planId}`, OPTIONS) - if (transported?.containerNo !== `TST-${suffix}`.slice(0, 64)) return - const confirmedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const confirmedTaskAbsent = failure(confirmedTaskResponse) - || !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) - context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse, - confirmedTaskAbsent, - `HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) + const confirmedTaskAbsent = confirmedTaskResponse.http === 200 && codeOf(confirmedTaskResponse) === 'A0404' + context.addStep(MODULE, '订舱已关联但未 RELEASED 不生成 PDA 任务', confirmedTaskResponse, + confirmedTaskAbsent, responseEvidence(confirmedTaskResponse), OPTIONS) if (!confirmedTaskAbsent) return const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { @@ -383,29 +459,6 @@ export async function run(context) { `status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS) if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return - const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) - const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS) - - const pushKey = `${context.marker}-container-push`.slice(0, 128) - const pushRequest = Object.freeze({ - path: `${SMART}/plans/${planId}/push-preload`, - body: Object.freeze({ expectedVersion: Number(assigned.version) }), - headers: Object.freeze({ 'Idempotency-Key': pushKey }), - }) - const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - const pushed = context.apiSuccess(MODULE, '柜号与 RELEASED 订舱就绪后推送预装计划', pushResponse, '', OPTIONS) - context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse, - pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1, - `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) - if (pushed?.status !== 'EDITING') return - - const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS) - context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse, - id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING' - && Number(pushReplay?.version) === Number(pushed.version), - `planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS) - const preloadPageResponse = await context.request('GET', `${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`) const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS) @@ -413,20 +466,84 @@ export async function run(context) { records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'), `records=${records(preloadPage).length}`, OPTIONS) + const unconfirmedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务', unconfirmedTaskResponse, + unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404', + responseEvidence(unconfirmedTaskResponse), OPTIONS) + + const confirmableResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS) + const confirmableVersion = Number(confirmable?.summary?.version) + context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse, + Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING' + && confirmable?.summary?.readiness?.canConfirm === true + && confirmable?.summary?.readiness?.readyForPda === false + && confirmable?.summary?.readiness?.pdaTaskId == null, + `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) + if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return + const confirmPath = `${FIRST_LEG}/loading-plans/${planId}/confirm` + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmHeaders = Object.freeze({ + 'Idempotency-Key': `${context.marker}-container-plan-confirm`.slice(0, 128), + }) + const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) + const confirmedPreload = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirmResponse, '', OPTIONS) + const confirmedTaskId = id(confirmedPreload?.readiness?.pdaTaskId) + context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirmResponse, + id(confirmedPreload?.planId) === planId && id(confirmedPreload?.containerBookingId) === containerId + && confirmedPreload?.confirmationStatus === 'CONFIRMED' + && confirmedPreload?.readiness?.confirmationStatus === 'CONFIRMED' + && confirmedPreload?.readiness?.readyForPda === true && Boolean(confirmedTaskId) + && Number(confirmedPreload?.version) > confirmableVersion, + `confirmationStatus=${confirmedPreload?.confirmationStatus || 'missing'}, readyForPda=${confirmedPreload?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS) + if (!confirmedTaskId || confirmedPreload?.confirmationStatus !== 'CONFIRMED') return + const confirmReplayResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) + context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplayResponse, + confirmReplayResponse.http === 200 && codeOf(confirmReplayResponse) === '200' + && id(confirmReplayResponse.body?.data?.readiness?.pdaTaskId) === confirmedTaskId + && Number(confirmReplayResponse.body?.data?.version) === Number(confirmedPreload.version), + responseEvidence(confirmReplayResponse), OPTIONS) + const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath, + { expectedVersion: Number(confirmedPreload.version) }, confirmHeaders) + context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody, + confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409', + responseEvidence(confirmSameKeyDifferentBody), OPTIONS) + const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody, + { 'Idempotency-Key': `${context.marker}-container-plan-confirm-duplicate`.slice(0, 128) }) + context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate, + confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409', + responseEvidence(confirmNewKeyDuplicate), OPTIONS) + + const confirmedInboundResponse = await context.request('GET', + `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`) + const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS) + const confirmedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS) + context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse, + confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING' + && Number(confirmedPlan?.version) === Number(confirmedPreload.version), + `inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS) + if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return + const taskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS) const taskId = id(task?.id) const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id)) context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse, - Boolean(taskId) && id(task?.loadingPlanId) === planId + Boolean(taskId) && taskId === confirmedTaskId && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo) - && task?.currentContainerNo === `TST-${suffix}`.slice(0, 64) && task?.taskStatus === 'LOADING' + && task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING' && task?.workflowStatus === 'UNASSIGNED' && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1, `taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS) if (!taskId || !pickItem?.id) return + const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) + const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS) + const stableIdentity = { taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId), cabinetNo: id(task.cabinetNo), diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 0b90ed253..8552df0cc 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -9,6 +9,7 @@ const overviewOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-c const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') const overviewAcceptance = read('phase3-container-overview.mjs') const oceanAcceptance = read('ocean-bookings.mjs') +const oceanFixture = read('ocean-booking-fixture.mjs') const adminRoot = '../../../apps/admin-web/src/modules/' const releaseView = read(`${adminRoot}ocean-bookings/pages/OceanBookingDetailView.vue`) const releaseTypes = read(`${adminRoot}ocean-bookings/types.ts`) @@ -28,7 +29,7 @@ test('registration freezes all five transport fields and release reuses the conf assert.match(releaseTypes, new RegExp(`${field}: string`), field) assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field) assert.match(releaseView, new RegExp(`detail\\.value\\.${field}`), field) - assert.match(oceanAcceptance, new RegExp(`${field}:`), field) + assert.match(oceanFixture, new RegExp(`${field}:`), field) } assert.match(releaseView, /当前订舱缺少已确认运输资料,无法放舱/) assert.match(releaseView, /放舱不会重新采集或修改这些信息/) @@ -84,14 +85,22 @@ test('existing container number replacement keeps container plan and PDA identit assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/) }) -test('PDA task opens only after preload push, real container number and ocean release converge', () => { - assert.match(containerLoadingAcceptance, /无真实柜号拒绝推送预装计划/) - assert.match(containerLoadingAcceptance, /无真实柜号推送失败后不生成 PDA 任务/) - assert.match(containerLoadingAcceptance, /version: Number\(linkedContainer\.version\), reason: context\.marker/) - assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/) +test('PDA task opens only after no-number push, transport, release and plan-level confirmation', () => { + assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/) + assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/) + assert.match(containerLoadingAcceptance, /预装 EDITING 清单绑定本轮真实柜号/) + assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/) assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/) assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/) - assert.match(containerLoadingAcceptance, /柜号与 RELEASED 订舱就绪后推送预装计划/) + assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) + assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/) + assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/) + assert.match(containerLoadingAcceptance, /confirmationStatus === 'CONFIRMED'/) + assert.match(containerLoadingAcceptance, /readiness\?\.readyForPda === true/) + assert.match(containerLoadingAcceptance, /taskId === confirmedTaskId/) + assert.match(containerLoadingAcceptance, /计划级确认同 key 异参返回冲突/) + assert.match(containerLoadingAcceptance, /已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(containerLoadingAcceptance, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 630836c0a..0c8970f4b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -144,38 +144,6 @@ export async function run(context) { && simulation?.oceanBookingId == null, `containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS) if (!containerBookingId) return - const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings', - { expectedVersion: 0, data: bookingDraft }, - { 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) }) - const bookingCreated = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期订舱', bookingCreateResponse, '', OPTIONS) - const bookingId = id(bookingCreated?.oceanBooking?.id) - if (!bookingId) return - const bookingConfirmed = bookingCreated - const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] - const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]])) - const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') - const frozenTransportMatches = frozenTransportValid - && transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])) - context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse, - frozenTransportMatches, - `bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) - if (!frozenTransportMatches) return - const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') - context.assert(MODULE, '一次登记直接返回 CONFIRMED 且不自动生成三期内部柜', bookingCreateResponse, - bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id) - && bookingConfirmed?.containerAllocations?.length === 0, - `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, requirementId=${id(requirement?.id) || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) - if (!requirement?.id) return - const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, { - oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version), - }) - const linked = context.apiSuccess(MODULE, '关联三期模拟柜与真实订舱', linkResponse, '', OPTIONS) - context.assert(MODULE, '三期订舱关联保持内部柜次身份', linkResponse, - id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo - && id(linked?.oceanBookingId) === bookingId, - `containerBookingId=${containerBookingId}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS) - if (id(linked?.id) !== containerBookingId) return - const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS) const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId @@ -204,93 +172,149 @@ export async function run(context) { && Number(assignedLoadingPlan?.version) === 1, `loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS) - const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, - items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }], - remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) }) - const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS) - const planId = id(smartData?.preloadPlan?.id) - const smartPreloadVersion = Number(smartData?.preloadPlan?.version) - context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart, - smartData?.mainStatus === 'WAIT_SPLIT' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING' - && Number.isInteger(smartPreloadVersion) && smartPreloadVersion >= 0 - && smartData?.preloadPlan?.items?.length === 1, - `mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS) + const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) + const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS) + const planId = id(pendingPreload?.id) + context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse, + Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)), + `preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS) if (!planId) return - const replay = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, items: [], remark: 'replay must not replace', - }, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) }) - context.assert(MODULE, '智能分柜幂等重放返回同一预装计划', replay, - codeOf(replay) === '200' && id(replay.body?.data?.preloadPlan?.id) === planId - && replay.body?.data?.preloadPlan?.items?.length === 1, - `HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS) - - const bindVersions = Object.freeze({ - expectedPreloadVersion: smartPreloadVersion, - expectedLoadingPlanVersion: Number(assignedLoadingPlan.version), + const pushRequest = Object.freeze({ + path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, + body: Object.freeze({ expectedVersion: Number(assignedLoadingPlan.version) }), + headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }), }) - const bindResponse = await request(context, 'POST', - `${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, - bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) }) - const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS) - context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse, - id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId - && id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1 - && Number(link?.preloadVersion) > bindVersions.expectedPreloadVersion - && Number(link?.loadingPlanVersion) > bindVersions.expectedLoadingPlanVersion, - `linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) + const pushedLoadingPlan = context.apiSuccess(MODULE, '无真实柜号推送预装成功并冻结柜次明细', pushResponse, '', OPTIONS) + context.assert(MODULE, '无号推送保持 EDITING、身份稳定且版本递增', pushResponse, + id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING' + && pushedLoadingPlan?.containerNo == null + && Number(pushedLoadingPlan?.version) > Number(assignedLoadingPlan.version), + `status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS) + if (pushedLoadingPlan?.status !== 'EDITING') return + + const pushedDetailResponse = await context.request('GET', + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS) + const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) + const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS) + const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null + context.assert(MODULE, 'push 自动建立唯一预装关联且分配身份稳定', pushedLinksResponse, + Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId + && Number(link?.allocatedPackageCount) === 1, + `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS) if (!link?.id) return - - const missingContainerPushResponse = await request(context, 'POST', - `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, - { expectedVersion: Number(link.loadingPlanVersion) }, - { 'Idempotency-Key': `${context.marker}-bridge-push-without-container-no`.slice(0, 128) }) - const missingContainerPushRejected = failure(missingContainerPushResponse) - context.addStep(MODULE, '无真实柜号拒绝推送柜次计划', missingContainerPushResponse, - missingContainerPushRejected, - `HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS) - if (!missingContainerPushRejected) return - const missingContainerTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const missingContainerTaskAbsent = failure(missingContainerTaskResponse) - || !id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id) - context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', missingContainerTaskResponse, + const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404' + context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse, missingContainerTaskAbsent, - `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, task=${id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) + `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS) if (!missingContainerTaskAbsent) return - const containerBeforeTransportResponse = await context.request('GET', - `/api/platform/v1/containers/${containerBookingId}`) - const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次最新版本', - containerBeforeTransportResponse, '', OPTIONS) - const transportVersion = Number(containerBeforeTransport?.version) - context.assert(MODULE, '补真实柜号使用订舱关联后的最新柜次版本', containerBeforeTransportResponse, - id(containerBeforeTransport?.id) === containerBookingId && Number.isInteger(transportVersion), - `containerBookingId=${id(containerBeforeTransport?.id) || 'missing'}, version=${containerBeforeTransport?.version ?? 'missing'}`, OPTIONS) - if (!Number.isInteger(transportVersion)) return + const replayBaseline = JSON.stringify({ + plan: pushedLoadingPlan, detail: pushedDetail, links: pushedLinks, + task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) }, + }) + const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) + context.apiSuccess(MODULE, 'push exact replay 返回首次结果', pushReplayResponse, '', OPTIONS) + const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS) + const replayDetailResponse = await context.request('GET', + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS) + const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) + const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) + const replayTaskResponse = await context.request('GET', + `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const replaySnapshot = JSON.stringify({ + plan: replayPlan, detail: replayDetail, links: replayLinks, + task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) }, + }) + context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse, + replayBaseline === replaySnapshot && codeOf(replayTaskResponse) === 'A0404', + `baselineBytes=${replayBaseline.length}, replayBytes=${replaySnapshot.length}`, OPTIONS) + + const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path, + { expectedVersion: Number(pushedLoadingPlan.version) }, pushRequest.headers) + context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody, + failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS) + const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, + { 'Idempotency-Key': `${context.marker}-bridge-push-duplicate`.slice(0, 128) }) + context.addStep(MODULE, '已推送计划使用新 key 重复 push 返回冲突', newKeyDuplicate, + failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, OPTIONS) + + const rejectedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS) + const rejectedDetailResponse = await context.request('GET', + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS) + const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) + const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) + const rejectedTaskResponse = await context.request('GET', + `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const rejectedSnapshot = JSON.stringify({ + plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks, + task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) }, + }) + context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse, + failure(sameKeyDifferentBody) && failure(newKeyDuplicate) + && replayBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404', + `baselineBytes=${replayBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS) + if (replayBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return + const transportResponse = await request(context, 'PATCH', - `/api/platform/v1/containers/${containerBookingId}/transport`, { - containerNo, voyageNo: `P3V-${suffix}`, version: transportVersion, - reason: `${context.marker}-bind-container-no`, - }) - const transported = context.apiSuccess(MODULE, '绑定真实柜号后开放三期 PDA 推送门禁', transportResponse, '', OPTIONS) - context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse, - transported?.containerNo === containerNo && id(transported?.id) === containerBookingId - && transported?.cabinetNo === linked.cabinetNo, - `containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${loadingPlanId}`, OPTIONS) - if (transported?.containerNo !== containerNo) return + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { + containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(pushedLoadingPlan.version), + operationReason: `${context.marker}-bind-container-no`, + }, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) }) + const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS) + context.assert(MODULE, '预装补号保持 loadingPlanId/containerBookingId/cabinetNo 身份稳定', transportResponse, + transported?.summary?.containerNo === containerNo && id(transported?.summary?.planId) === loadingPlanId + && id(transported?.summary?.containerBookingId) === containerBookingId, + `containerNo=${transported?.summary?.containerNo || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS) + if (transported?.summary?.containerNo !== containerNo) return + + const unlinkedTaskResponse = await context.request('GET', + `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '已补号但未关联订舱仍无 PDA 任务', unlinkedTaskResponse, + unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404', + `HTTP=${unlinkedTaskResponse.http}, code=${codeOf(unlinkedTaskResponse)}`, OPTIONS) + + const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings', + { expectedVersion: 0, data: bookingDraft }, + { 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) }) + const bookingConfirmed = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期 CONFIRMED 订舱', bookingCreateResponse, '', OPTIONS) + const bookingId = id(bookingConfirmed?.oceanBooking?.id) + const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]])) + const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') + const frozenTransportMatches = frozenTransportValid + && transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])) + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') + context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse, + Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.id) + && bookingConfirmed?.containerAllocations?.length === 0, + `bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS) + if (!bookingId || !requirement?.id || !frozenTransportMatches) return + const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`) + const latestContainer = context.apiSuccess(MODULE, '补号后读取模拟柜最新版本用于订舱关联', latestContainerResponse, '', OPTIONS) + const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(latestContainer?.version), + }) + const linked = context.apiSuccess(MODULE, '预装补号后关联三期 CONFIRMED 订舱', linkResponse, '', OPTIONS) + context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse, + id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo + && linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId, + `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) + if (id(linked?.id) !== containerBookingId) return const confirmedBookingTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const confirmedBookingTaskAbsent = failure(confirmedBookingTaskResponse) - || !id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) - context.addStep(MODULE, '订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务', confirmedBookingTaskResponse, - confirmedBookingTaskAbsent, - `HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}, task=${id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) - if (!confirmedBookingTaskAbsent) return + context.addStep(MODULE, '订舱已关联但未 RELEASED 仍无 PDA 任务', confirmedBookingTaskResponse, + confirmedBookingTaskResponse.http === 200 && codeOf(confirmedBookingTaskResponse) === 'A0404', + `HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}`, OPTIONS) const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { expectedVersion: Number(bookingConfirmed?.oceanBooking?.version), @@ -301,24 +325,66 @@ export async function run(context) { bookingReleased?.oceanBooking?.status === 'RELEASED' && bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId) && transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])), - `status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, transportFrozen=${transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS) + `status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS) if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return - const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, - { expectedVersion: Number(link.loadingPlanVersion) }, - { 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }) - const pushedLoadingPlan = context.apiSuccess(MODULE, '真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务', pushResponse, '', OPTIONS) - context.assert(MODULE, '推送后柜次计划保持 EDITING 且版本递增', pushResponse, - pushedLoadingPlan?.status === 'EDITING' - && Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1, - `status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS) + const unconfirmedTaskResponse = await context.request('GET', + `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务', unconfirmedTaskResponse, + unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404', + `HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}`, OPTIONS) - const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: initialVersion, remark: 'must confirm preload first' }, - { 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) }) - context.addStep(MODULE, '预装未确认时拒绝完成装柜', blockedBeforePreloadConfirm, - failure(blockedBeforePreloadConfirm), - `HTTP=${blockedBeforePreloadConfirm.http}, code=${codeOf(blockedBeforePreloadConfirm)}`, OPTIONS) + const confirmableResponse = await context.request('GET', + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS) + const confirmableVersion = Number(confirmable?.summary?.version) + context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse, + Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING' + && confirmable?.summary?.readiness?.canConfirm === true + && confirmable?.summary?.readiness?.readyForPda === false + && confirmable?.summary?.readiness?.pdaTaskId == null, + `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) + if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return + const confirmPath = `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/confirm` + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${context.marker}-p3-plan-confirm`.slice(0, 128) }) + const confirm = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) + const confirmed = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirm, '', OPTIONS) + const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId) + context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirm, + id(confirmed?.planId) === loadingPlanId && id(confirmed?.containerBookingId) === containerBookingId + && confirmed?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId) + && Number(confirmed?.version) > confirmableVersion, + `confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS) + if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') return + const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) + context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplay, + confirmReplay.http === 200 && codeOf(confirmReplay) === '200' + && id(confirmReplay.body?.data?.readiness?.pdaTaskId) === confirmedTaskId + && Number(confirmReplay.body?.data?.version) === Number(confirmed.version), + `HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS) + const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath, + { expectedVersion: Number(confirmed.version) }, confirmHeaders) + context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody, + confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409', + `HTTP=${confirmSameKeyDifferentBody.http}, code=${codeOf(confirmSameKeyDifferentBody)}, requestId=${confirmSameKeyDifferentBody.requestId || 'missing'}, traceId=${confirmSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS) + const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody, + { 'Idempotency-Key': `${context.marker}-p3-plan-confirm-duplicate`.slice(0, 128) }) + context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate, + confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409', + `HTTP=${confirmNewKeyDuplicate.http}, code=${codeOf(confirmNewKeyDuplicate)}, requestId=${confirmNewKeyDuplicate.requestId || 'missing'}, traceId=${confirmNewKeyDuplicate.traceId || 'missing'}`, OPTIONS) + + const confirmedInboundResponse = await context.request('GET', detailPath) + const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS) + const confirmedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS) + context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse, + confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING' + && Number(confirmedPlan?.version) === Number(confirmed.version), + `inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS) + if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return const canonicalTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) @@ -326,11 +392,17 @@ export async function run(context) { const canonicalTaskId = id(canonicalTask?.id) const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId) context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse, - Boolean(canonicalTaskId) && id(canonicalTask?.loadingPlanId) === loadingPlanId + Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId + && id(canonicalTask?.containerBookingId) === containerBookingId && canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id), `taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS) if (!canonicalTaskId || !canonicalItem?.id) return + const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const beforeClaimPlan = context.apiSuccess(MODULE, '领取前读取柜次计划即时版本', beforeClaimPlanResponse, '', OPTIONS) + const beforeClaimPlanVersion = Number(beforeClaimPlan?.version) + if (!Number.isInteger(beforeClaimPlanVersion)) return + const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, { expectedAssignmentVersion: Number(canonicalTask?.assignment?.version || 0), operationId: `${context.marker}-bridge-claim`.slice(0, 128), @@ -343,7 +415,7 @@ export async function run(context) { const claimedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const claimedPlan = context.apiSuccess(MODULE, '领取后回查柜次计划', claimedPlanResponse, '', OPTIONS) context.assert(MODULE, '领取 PDA 任务后计划进入 LOADING 且版本递增', claimedPlanResponse, - claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1, + claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion, `status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) const inventoryResponse = await context.request('GET', @@ -607,34 +679,11 @@ export async function run(context) { directPdaCompletion && pickedPlan?.status === 'COMPLETED', `status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS) - const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) }) - const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS) - context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming, - confirmingData?.preloadPlan?.status === 'CONFIRMING' - && Number(confirmingData?.preloadPlan?.version) > Number(link.preloadVersion), - `status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS) - if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return - - const confirm = await request(context, 'POST', `${base}/preload/confirm?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(confirmingData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirm`.slice(0, 128) }) - const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS) - context.assert(MODULE, directPdaCompletion - ? '装柜已开始时预装确认保持 LOADING 和 CONFIRMING' - : '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm, - directPdaCompletion - ? confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMING' - && Number(confirmed?.preloadPlan?.version) === Number(confirmingData.preloadPlan.version) - : confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED' - && Number(confirmed?.preloadPlan?.version) > Number(confirmingData.preloadPlan.version), - `mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS) - if (confirmed?.mainStatus !== 'LOADING') return - const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS) - context.assert(MODULE, '预装清单归属、明细和 marker 持久化', preload, + context.assert(MODULE, 'PDA 完成后预装清单仍为 CONFIRMED 且归属稳定', preload, id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId - && preloadData?.items?.[0]?.containerCode === `P3-${suffix}` && preloadData?.remark === context.marker, + && preloadData?.status === 'CONFIRMED', `planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS) const loadingResponse = await context.request('GET', detailPath) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 5f246d264..4b8e66ccd 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -83,47 +83,69 @@ test('container loading follows push, claim, real pick, completed sequence', () assert.match(value, /operationId: `\$\{context\.marker\}-pda-complete`\.slice\(0, 128\)/) }) -test('bridge loading starts through a real PDA batch before preload confirmation', () => { +test('bridge loading confirms preload before creating and claiming the PDA task', () => { const value = source('./phase3-loading.mjs') assertOrder(value, [ - '原子关联入库预装与柜次装柜计划', - '无真实柜号拒绝推送柜次计划', - '无真实柜号推送失败后不生成 PDA 任务', - '绑定真实柜号后开放三期 PDA 推送门禁', - '订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务', + '无真实柜号推送预装成功并冻结柜次明细', + '无号 push 成功后 PDA 查询明确 A0404', + 'push exact replay 对计划版本历史关联和任务零漂移', + '预装 EDITING 清单绑定真实柜号', + '已补号但未关联订舱仍无 PDA 任务', + '预装补号后关联三期 CONFIRMED 订舱', + '订舱已关联但未 RELEASED 仍无 PDA 任务', '三期订舱携带已冻结运输快照并放舱', - '三期订舱 RELEASED 后保持模拟柜关联身份', - '真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务', - "pushedLoadingPlan?.status === 'EDITING'", + '订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务', + '计划级确认前读取即时版本与就绪门禁', + '计划级原子确认整份预装并触发唯一 PDA 任务', + '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', + '读取柜次级 PDA 唯一事实任务', "claimedPlan?.status === 'LOADING'", "const canonicalPick = await request", "pickedPlan?.status === 'COMPLETED'", - "const confirming = await request", ]) - assert.match(value, /confirmed\?\.mainStatus === 'LOADING' && confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/) - assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMED'/) - assert.doesNotMatch(value, /关联柜次计划进入 LOADING/) - assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/) - assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/) - assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === Number\(confirmingData\.preloadPlan\.version\)/) - assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/) - assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/) - assert.match(value, /version: transportVersion/) - assert.match(value, /Number\.isInteger\(transportVersion\)/) + assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/) + assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/) + assert.match(value, /confirmed\?\.readiness\?\.readyForPda === true/) + assert.match(value, /canonicalTaskId === confirmedTaskId/) + assert.match(value, /计划级确认同 key 异参返回冲突/) + assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) + assert.match(value, /loading-plans\/\$\{loadingPlanId\}\/transport\?warehouseId=/) + assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/) + assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/) assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/) assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/) assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/) - assertOrder(value, [ - 'CONFIRMED 订舱回显完整且冻结五项运输快照', - 'if (!frozenTransportMatches) return', - '三期订舱携带已冻结运输快照并放舱', - ]) + assert.match(value, /CONFIRMED 订舱回显完整且冻结五项运输快照[\s\S]*if \(!bookingId \|\| !requirement\?\.id \|\| !frozenTransportMatches\) return[\s\S]*三期订舱携带已冻结运输快照并放舱/) assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/) assert.doesNotMatch(value, /new Date\(Date\.now\(\)/) assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/) }) +test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { + for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) { + const value = source(file) + assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/) + assert.match(value, /push exact replay[\s\S]*零漂移/) + assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/) + assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/) + assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/) + assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/) + } +}) + +test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => { + const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') + const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') + const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay')) + assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/) + assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/) + assert.doesNotMatch(push, /loadingPickService\.beginIfReady/) + assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/) + assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/) +}) + test('finalize rechecks the same unified order detail and list as LOADED', () => { const value = source('./phase3-loading-finalize.mjs') assertOrder(value, [ @@ -176,6 +198,8 @@ test('container loading arranges a simulation before linking a confirmed real bo "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", "const planResponse = await request(context, 'POST', `${SMART}/plans`", "const assignResponse = await request", + '无真实柜号推送预装计划成功', + '预装 EDITING 清单绑定本轮真实柜号', "const bookingCreate = await request", "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", ]) @@ -188,8 +212,7 @@ test('container loading arranges a simulation before linking a confirmed real bo } assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/) assert.match(value, /containerAllocations\.length === 0/) - assert.match(value, /version: Number\(linkedContainer\.version\), reason: context\.marker/) - assert.doesNotMatch(value, /version: Number\(containerDetail\?\.version \?\? 0\), reason: context\.marker/) + assert.match(value, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/) assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/) assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/) assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/) @@ -206,11 +229,18 @@ test('container loading arranges a simulation before linking a confirmed real bo assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) - assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport")) - assert.match(value, /在放舱前绑定本轮真实箱号/) + assert.ok(value.includes('`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=')) + assert.match(value, /预装 EDITING 清单绑定本轮真实柜号/) assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/) - assert.match(value, /无真实柜号拒绝推送预装计划/) - assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/) + assert.match(value, /无真实柜号推送预装计划成功/) + assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) + assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/) + assert.match(value, /confirmationStatus === 'CONFIRMED'/) + assert.match(value, /readiness\?\.readyForPda === true/) + assert.match(value, /taskId === confirmedTaskId/) + assert.match(value, /计划级确认同 key 异参返回冲突/) + assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/) assert.match(value, /POST', CONTAINERS/) }) @@ -283,13 +313,17 @@ test('container loading enforces channel isolation and stable PDA identity acros '新增真实异渠道模拟柜', '两个真实有效渠道之间拒绝跨渠道装柜', '人工分配本轮候选货物', + '无真实柜号推送预装计划成功', + '无号 push 成功后 PDA 查询 A0404', + '预装 EDITING 清单绑定本轮真实柜号', + '已补号但未关联订舱不生成 PDA 任务', '首次关联真实已确认订舱柜', - '无真实柜号拒绝推送预装计划', - '无真实柜号推送失败后不生成 PDA 任务', - '在放舱前绑定本轮真实箱号', - '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', + '订舱已关联但未 RELEASED 不生成 PDA 任务', '订舱携带已冻结运输快照放舱并满足 PDA 门禁', - '柜号与 RELEASED 订舱就绪后推送预装计划', + '订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务', + '计划级确认前读取即时版本与就绪门禁', + '计划级原子确认整份预装并触发唯一 PDA 任务', + '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', '换柜号前读取 PDA 稳定库存快照', 'PDA 任务创建后替换真实柜号', 'PDA 任务创建后清空真实柜号失败', @@ -320,11 +354,10 @@ test('preload and loading versions use immediate operation baselines and exact p assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) const loading = source('./phase3-loading.mjs') - assert.match(loading, /Number\.isInteger\(smartPreloadVersion\) && smartPreloadVersion >= 0/) - assert.match(loading, /Number\(link\?\.preloadVersion\) > bindVersions\.expectedPreloadVersion/) - assert.match(loading, /Number\(link\?\.loadingPlanVersion\) > bindVersions\.expectedLoadingPlanVersion/) - assert.match(loading, /Number\(confirmingData\?\.preloadPlan\?\.version\) > Number\(link\.preloadVersion\)/) - assert.doesNotMatch(loading, /Number\((?:smartData|link|confirmingData|confirmed)[^\n]*version\) === [0-9]/) + assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) + assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) + assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index f3bd64d95..77a62fec8 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs' const scriptsDir = dirname(fileURLToPath(import.meta.url)) const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8') @@ -62,33 +63,75 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/) }) -test('test-environment loading chains bind the latest container version before opening PDA', () => { +test('test-environment loading chains push without a number and open PDA only after all prerequisites', () => { const containerLoading = readFileSync( resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8') const bridgeLoading = readFileSync( resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') - assert.match(containerLoading, /version: Number\(linkedContainer\.version\), reason: context\.marker/) + assert.match(containerLoading, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/) - const bridgeIndex = bridgeLoading.indexOf('原子关联入库预装与柜次装柜计划') - const rejectedPushIndex = bridgeLoading.indexOf('无真实柜号拒绝推送柜次计划') - const transportIndex = bridgeLoading.indexOf('绑定真实柜号后开放三期 PDA 推送门禁') + const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细') + const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404') + const transportIndex = bridgeLoading.indexOf('预装 EDITING 清单绑定真实柜号') + const linkIndex = bridgeLoading.indexOf('预装补号后关联三期 CONFIRMED 订舱') const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱') - const successfulPushIndex = bridgeLoading.indexOf('真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务') + const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务') const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务') - assert.ok(bridgeIndex >= 0 && rejectedPushIndex > bridgeIndex && transportIndex > rejectedPushIndex - && releaseIndex > transportIndex && successfulPushIndex > releaseIndex && pdaIndex > successfulPushIndex) - assert.match(bridgeLoading, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/) - assert.match(bridgeLoading, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/) - assert.match(bridgeLoading, /version: transportVersion/) + assert.ok(pushIndex >= 0 && absentIndex > pushIndex && transportIndex > absentIndex + && linkIndex > transportIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex) + assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/) + assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/) + assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/) + assert.match(bridgeLoading, /readiness\?\.readyForPda === true/) + assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) - const containerRejectedPushIndex = containerLoading.indexOf('无真实柜号拒绝推送预装计划') - const containerTransportIndex = containerLoading.indexOf('在放舱前绑定本轮真实箱号') + const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功') + const containerTransportIndex = containerLoading.indexOf('预装 EDITING 清单绑定本轮真实柜号') + const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜') const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁') - const containerSuccessfulPushIndex = containerLoading.indexOf('柜号与 RELEASED 订舱就绪后推送预装计划') + const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务') const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务') - assert.ok(containerRejectedPushIndex >= 0 && containerTransportIndex > containerRejectedPushIndex - && containerReleaseIndex > containerTransportIndex && containerSuccessfulPushIndex > containerReleaseIndex - && containerPdaIndex > containerSuccessfulPushIndex) + assert.ok(containerPushIndex >= 0 && containerTransportIndex > containerPushIndex + && containerLinkIndex > containerTransportIndex && containerReleaseIndex > containerLinkIndex + && containerConfirmIndex > containerReleaseIndex + && containerPdaIndex > containerConfirmIndex) + assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/) + assert.match(containerLoading, /readiness\?\.readyForPda === true/) + assert.match(containerLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) +}) + +test('default Admin HTTP entry carries one marker-isolated loading chain through evidence and LOADED', () => { + const focused = normalizeModuleOrder([ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', + 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs', + 'phase3-loading-finalize', + ]) + assert.deepEqual(focused, [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', + 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs', + 'phase3-loading-finalize', + ]) + + const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') + const evidenceGate = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-evidence-gate.mjs'), 'utf8') + const customs = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-customs.mjs'), 'utf8') + const finalize = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading-finalize.mjs'), 'utf8') + + assert.match(loading, /context\.createdResources\.phase3Loading = \{[\s\S]*marker: context\.marker/) + for (const field of ['inboundId', 'loadingPlanId', 'containerBookingId', 'canonicalPdaTaskId']) { + assert.match(loading, new RegExp(`context\\.createdResources\\.phase3Loading = \\{[\\s\\S]*${field}`)) + } + assert.match(loading, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/) + assert.match(evidenceGate, /fixture\??\.marker !== context\.marker/) + assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/) + assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/) + assert.match(finalize, /fixture\?\.marker !== context\.marker/) + assert.match(finalize, /统一硬门禁通过后推进 LOADED 且五类有效证据仍完整/) + assert.match(finalize, /统一订单详情投影同一订单为 LOADED[\s\S]*统一订单列表投影同一订单为 LOADED/) })