From c9792b1f638dc1c5748defbe10a5dbad66055d7d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 31 Aug 2026 15:26:47 +0800 Subject: [PATCH] fix(firstleg): resolve loading list lifecycle by plan --- .../PlatformLoadingListController.java | 12 ++++++ .../baiyue/firstleg/dto/LoadingListDtos.java | 11 ++++++ .../firstleg/service/LoadingListService.java | 25 ++++++++++-- .../resources/openapi/loading-lists-v1.yaml | 38 +++++++++++++++++++ .../LoadingListControllerContractTest.java | 10 ++++- .../LoadingListQueryMapperContractTest.java | 9 +++++ .../service/LoadingListServiceTest.java | 37 +++++++++++++++++- 7 files changed, 137 insertions(+), 5 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 af4927d6f..fe72ab844 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -12,6 +12,7 @@ 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.LifecycleResolution; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingListService; import io.swagger.v3.oas.annotations.Operation; @@ -60,6 +61,17 @@ public class PlatformLoadingListController { return ApiResponse.success(service.preloadDetail(planId, warehouseId)); } + @Operation(summary = "解析装柜计划当前详情阶段", + description = "按稳定 planId 返回 PRELOAD 或 LOADING_LIST,供页面在计划完成后受控切换详情接口。") + @GetMapping("/loading-plans/{planId}/lifecycle") + @PreAuthorize("hasAuthority('platform:firstleg:preload:list')") + public ApiResponse lifecycle( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.lifecycle(planId, warehouseId)); + } + @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") 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 2916068de..1ba217f2b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -115,6 +115,17 @@ public final class LoadingListDtos { ) { } + public record LifecycleResolution( + Long planId, + Long containerBookingId, + Long warehouseId, + String planNo, + String planStatus, + String detailResource, + String requiredPermission + ) { + } + public record PreloadSummary( Long planId, Long containerBookingId, 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 58575e296..86970d5fe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution; import com.baiyue.firstleg.client.LoadingDocumentFileClient; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; @@ -97,20 +98,38 @@ public class LoadingListService { AggregateRow row = requirePlan(planId); requireWarehouse(row, warehouseId); if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) { - throw notFound("预装清单不存在"); + throw conflict("装柜计划已离开预装详情阶段"); } return repository.preloadDetail(row); } + public LifecycleResolution lifecycle(long planId, long warehouseId) { + AggregateRow row = requirePlan(planId); + requireWarehouse(row, warehouseId); + if (PRELOAD_STATUSES.contains(row.getPlanStatus())) { + return lifecycle(row, "PRELOAD", "platform:firstleg:preload:list"); + } + if ("COMPLETED".equals(row.getPlanStatus())) { + return lifecycle(row, "LOADING_LIST", "platform:firstleg:loading-list:list"); + } + throw conflict("装柜计划处于未知生命周期阶段"); + } + public ConfirmedDetail confirmedDetail(long planId, long warehouseId) { AggregateRow row = requirePlan(planId); requireWarehouse(row, warehouseId); if (!"COMPLETED".equals(row.getPlanStatus())) { - throw notFound("装柜清单不存在"); + throw conflict("装柜计划尚未进入正式装柜清单阶段"); } return repository.confirmedDetail(row); } + private static LifecycleResolution lifecycle(AggregateRow row, String resource, + String permission) { + return new LifecycleResolution(row.getPlanId(), row.getContainerBookingId(), + row.getWarehouseId(), row.getPlanNo(), row.getPlanStatus(), resource, permission); + } + public BusinessDocument containerClearance(long bookingId) { requireBookingPlan(bookingId); return document(repository.document(bookingId, DESTINATION_CLEARANCE)); @@ -554,7 +573,7 @@ public class LoadingListService { throw badRequest("warehouseId必须大于0"); } if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) { - throw notFound("装柜计划不属于当前仓库"); + throw new BizException(ResultCode.FORBIDDEN, "装柜计划不属于当前仓库"); } } diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index f824a711d..92937c0b0 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -31,6 +31,20 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/PreloadDetailResponse'} + '409': {description: 计划已离开预装阶段;先查询 lifecycle 获取当前正式详情资源} + /api/platform/v1/first-leg/loading-plans/{planId}/lifecycle: + get: + summary: 解析装柜计划当前详情阶段 + description: >- + 需要 platform:firstleg:preload:list 权限。同一 planId 从 EDITING、LOADING 到 COMPLETED 永久稳定; + EDITING/LOADING 返回 PRELOAD,COMPLETED 返回 LOADING_LIST 及其独立读取权限。 + 页面据此选择预装详情或正式装柜清单详情,不得以错误文案或 A0404 猜测阶段。 + x-permission: platform:firstleg:preload:list + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] + responses: + '200': {$ref: '#/components/responses/LifecycleResolutionResponse'} + '403': {description: planId 不属于请求仓库或缺少平台读取权限} + '404': {description: planId 真实不存在} /api/platform/v1/first-leg/loading-plans/{planId}/transport: patch: summary: 修改预装运输信息 @@ -83,6 +97,7 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} + '409': {description: 计划尚未进入 COMPLETED 正式装柜清单阶段} components: parameters: PlanId: @@ -156,6 +171,9 @@ components: ConfirmedDetailResponse: description: 装柜清单详情查询或确认成功,也可能返回业务失败 content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}} + LifecycleResolutionResponse: + description: 装柜计划生命周期解析成功或业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/LifecycleResolutionEnvelope'}}} schemas: LongId: type: string @@ -214,6 +232,14 @@ components: data: allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}] nullable: true + LifecycleResolutionEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/LifecycleResolution'}] + nullable: true PreloadPage: type: object required: [records, page, size, total, pages] @@ -238,6 +264,18 @@ components: properties: accountId: {$ref: '#/components/schemas/LongId'} customerName: {type: string, nullable: true, description: 客户名称} + LifecycleResolution: + type: object + additionalProperties: false + required: [planId, containerBookingId, warehouseId, planNo, planStatus, detailResource, requiredPermission] + properties: + planId: {$ref: '#/components/schemas/LongId'} + containerBookingId: {$ref: '#/components/schemas/LongId'} + warehouseId: {$ref: '#/components/schemas/LongId'} + planNo: {type: string, description: 预装与正式装柜清单沿用的稳定业务号} + planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]} + detailResource: {type: string, enum: [PRELOAD, LOADING_LIST], description: 当前应读取的唯一详情资源} + requiredPermission: {type: string, enum: ['platform:firstleg:preload:list', 'platform:firstleg:loading-list:list']} PreloadSummary: type: object required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version, readiness] 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 3a05df888..3260d4bc5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -29,6 +29,7 @@ class LoadingListControllerContractTest { void permissionsMatch() throws Exception { authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class); authority("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class); + authority("lifecycle", "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, @@ -72,6 +73,8 @@ class LoadingListControllerContractTest { .contains("校验ISO 6346结构、设备类别和校验位") .contains("PDA任务尚未领取且无任何物理作业事实") .contains("/loading-plans/{planId}/confirm") + .contains("/loading-plans/{planId}/lifecycle") + .contains("不得以错误文案或 A0404 猜测阶段") .contains("confirmationStatus") .contains("canConfirm") .contains("blockingReasons") @@ -116,6 +119,7 @@ class LoadingListControllerContractTest { assertThat(paths).containsOnlyKeys( "/api/platform/v1/first-leg/loading-plans", "/api/platform/v1/first-leg/loading-plans/{planId}", + "/api/platform/v1/first-leg/loading-plans/{planId}/lifecycle", "/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", @@ -126,7 +130,11 @@ class LoadingListControllerContractTest { "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", "ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness", - "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope"); + "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope", + "LifecycleResolution", "LifecycleResolutionEnvelope"); + assertThat(properties(schemas, "LifecycleResolution")).containsOnlyKeys( + "planId", "containerBookingId", "warehouseId", "planNo", "planStatus", + "detailResource", "requiredPermission"); assertThat(paths) .doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete"); assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); 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 82c9101d6..d8a657a27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -67,6 +67,15 @@ class LoadingListQueryMapperContractTest { .contains("i.deleted = 0"); } + @Test + void lifecycleAggregateKeepsTheSamePlanIdAcrossEveryStatus() throws Exception { + String aggregate = sql("selectAggregate", Select.class); + assertThat(aggregate) + .contains("WHERE p.id = #{planId}") + .contains("p.plan_status") + .doesNotContain("plan_status = 'COMPLETED'", "plan_status IN ('EDITING', 'LOADING')"); + } + @Test void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { String touch = sql("touchEditable", Update.class); 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 bc047c0d3..db2e06853 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -361,7 +361,42 @@ class LoadingListServiceTest { assertThatThrownBy(() -> service.preloadDetail(10L, 200L)) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.NOT_FOUND); + .isEqualTo(ResultCode.FORBIDDEN); + } + + @Test + void resolvesStablePlanIdToPreloadOrCompletedLoadingListWithoutGuessingFrom404() { + when(repository.aggregate(10L)).thenReturn(row("LOADING", 2), row("COMPLETED", 3)); + + var preload = service.lifecycle(10L, 100L); + var completed = service.lifecycle(10L, 100L); + + assertThat(preload.planId()).isEqualTo(10L); + assertThat(preload.planStatus()).isEqualTo("LOADING"); + assertThat(preload.detailResource()).isEqualTo("PRELOAD"); + assertThat(preload.requiredPermission()).isEqualTo("platform:firstleg:preload:list"); + assertThat(completed.planId()).isEqualTo(10L); + assertThat(completed.planStatus()).isEqualTo("COMPLETED"); + assertThat(completed.detailResource()).isEqualTo("LOADING_LIST"); + assertThat(completed.requiredPermission()).isEqualTo("platform:firstleg:loading-list:list"); + } + + @Test + void stageMismatchIsConflictAndOnlyMissingPlanIsNotFound() { + when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 3)); + assertThatThrownBy(() -> service.preloadDetail(10L, 100L)) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + + when(repository.aggregate(11L)).thenReturn(row("LOADING", 2)); + assertThatThrownBy(() -> service.confirmedDetail(11L, 100L)) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + + when(repository.aggregate(12L)).thenReturn(null); + assertThatThrownBy(() -> service.lifecycle(12L, 100L)) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND)); } @Test