diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 76699da30..e9c576057 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -86,7 +86,7 @@ public class InboundPlanController { if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可预览整柜入库计划"); } - return ApiResponse.success(service.previewContainer(sourceContainerBookingId)); + return ApiResponse.success(containerConversion.preview(sourceContainerBookingId)); } @GetMapping("/{planNo}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java index 639b9851e..e78d7805c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java @@ -23,8 +23,20 @@ public record ContainerInboundPlanPreviewResponse( int totalSuggestedPalletCount, int totalPlannedPalletCount, boolean ready, - List groups + List groups, + ResourceRetry resourceRetry ) { + /** Original persisted command; distinct from the current display snapshot. */ + public record ResourceRetry(String idempotencyKey, long snapshotVersion, String snapshotHash) { } + + public ContainerInboundPlanPreviewResponse withResourceRetry(ResourceRetry retry) { + return new ContainerInboundPlanPreviewResponse(sourceContainerBookingId, containerNo, + overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, snapshotVersion, snapshotHash, + totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, + sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, + totalPlannedPalletCount, ready, groups, retry); + } + public record Group( String destinationType, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 45f2f0447..31b7f2542 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -47,6 +47,20 @@ public class ContainerInboundPlanConversionCoordinator { @Resource private ObjectMapper objectMapper; @Resource private PlatformTransactionManager transactionManager; + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public ContainerInboundPlanPreviewResponse preview(String sourceContainerBookingId) { + ContainerInboundPlanPreviewResponse current = plans.previewContainer(sourceContainerBookingId); + ContainerPlanConversionCommand command = commands.findByBooking(current.sourceContainerBookingId()); + if (command == null || !"LOCAL_COMMITTED".equals(command.getStatus())) return current; + if (trimToNull(command.getIdempotencyKey()) == null || command.getSnapshotVersion() == null + || command.getSnapshotVersion() < 0 || command.getSnapshotHash() == null + || !command.getSnapshotHash().matches("[a-f0-9]{64}")) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "原柜级转换命令缺少重试依据,请联系管理员核查"); + } + return current.withResourceRetry(new ContainerInboundPlanPreviewResponse.ResourceRetry( + command.getIdempotencyKey(), command.getSnapshotVersion(), command.getSnapshotHash())); + } + /** * Saves one manually adjusted plan, then materializes every plan in the container as soon as * the last logical plan is complete. The manual endpoint is therefore never a separate pallet diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 61700639a..0713ee552 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -270,7 +270,7 @@ public class InboundPlanService { orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) - .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups); + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups, null); } private ContainerInboundPlanPreviewResponse.Order previewOrder( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 82472280f..cf50a33ac 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3417,6 +3417,15 @@ components: snapshotVersion: { type: integer, format: int64, minimum: 0 } snapshotHash: { type: string, pattern: '^[0-9a-f]{64}$' } totalOrderCount: { type: integer } + resourceRetry: + type: object + nullable: true + description: 仅本地计划已提交而资源确认未完成时返回原命令。重试必须使用此键和快照,当前外层快照仍用于展示;不得重新生成命令或预留第二批资源。 + required: [idempotencyKey, snapshotVersion, snapshotHash] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + snapshotVersion: { type: integer, format: int64, minimum: 0 } + snapshotHash: { type: string, pattern: '^[0-9a-f]{64}$' } readyOrderCount: { type: integer } blockedOrderCount: { type: integer } pendingOrderCount: { type: integer, description: 本次确认将新建计划的 READY 订单数 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java index 798a5228a..e44cfdaa9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -9,6 +9,34 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class TailPlanCommandOpenApiContractTest { + @Test + void previewDocumentsTheOriginalResourceRetrySeparatelyFromItsCurrentSnapshot() throws Exception { + try (var stream = getClass().getClassLoader().getResourceAsStream("openapi/overseas-business-v1.yaml")) { + java.util.Map document = new org.yaml.snakeyaml.Yaml().load(stream); + var components = (java.util.Map) document.get("components"); + var schemas = (java.util.Map) components.get("schemas"); + var preview = (java.util.Map) schemas.get("ContainerInboundPlanPreview"); + var properties = (java.util.Map) preview.get("properties"); + var retry = (java.util.Map) properties.get("resourceRetry"); + org.junit.jupiter.api.Assertions.assertEquals(true, retry.get("nullable")); + org.junit.jupiter.api.Assertions.assertEquals(java.util.List.of("idempotencyKey", "snapshotVersion", "snapshotHash"), retry.get("required")); + assertTrue(properties.containsKey("snapshotVersion") && properties.containsKey("snapshotHash")); + } + } + + @Test + void platformPreviewUsesTheCoordinatorThatCanRestorePendingResources() { + var controller = new com.baiyue.lastleg.inboundplan.controller.InboundPlanController(); + var coordinator = org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.service.ContainerInboundPlanConversionCoordinator.class); + var principal = org.mockito.Mockito.mock(com.baiyue.common.security.AuthenticatedUser.class); + var expected = org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse.class); + org.springframework.test.util.ReflectionTestUtils.setField(controller, "containerConversion", coordinator); + org.mockito.Mockito.when(principal.tenantId()).thenReturn(0L); + org.mockito.Mockito.when(principal.appCode()).thenReturn("PLATFORM"); + org.mockito.Mockito.when(coordinator.preview("BOOKING-1")).thenReturn(expected); + org.junit.jupiter.api.Assertions.assertSame(expected, controller.previewContainer(principal, "BOOKING-1").data()); + } + @Test void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception { try (var stream = getClass().getClassLoader() diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 5515f9dd3..8a2d4fc7a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyLong; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.doReturn; @@ -266,6 +267,59 @@ class ContainerInboundPlanConversionCoordinatorTest { verify(warehouse).confirmPalletBatch(any()); } + @Test + void reopenedPreviewRetainsCurrentDisplayButResumesTheOriginalCommittedCommand() throws Exception { + var committed = command("FIRST-KEY", "LOCAL_COMMITTED", request()); + committed.setResultJson("[]"); + setOnePreparedItem(committed); + var current = preview(8L, "b".repeat(64)); + when(plans.previewContainer(BOOKING_ID)).thenReturn(current); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + var reopened = coordinator.preview(BOOKING_ID); + assertEquals(8L, reopened.snapshotVersion()); + assertEquals("b".repeat(64), reopened.snapshotHash()); + assertEquals(current.groups(), reopened.groups()); + assertEquals("FIRST-KEY", reopened.resourceRetry().idempotencyKey()); + assertEquals(7L, reopened.resourceRetry().snapshotVersion()); + assertEquals(SNAPSHOT_HASH, reopened.resourceRetry().snapshotHash()); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed); + assertEquals(409, assertThrows(ResponseStatusException.class, () -> coordinator.confirm( + new ConvertContainerInboundPlanRequest(BOOKING_ID, reopened.snapshotVersion(), reopened.snapshotHash()), + "FIRST-KEY")).getStatusCode().value()); + when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED")))); + when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + assertEquals(List.of(), coordinator.confirm(new ConvertContainerInboundPlanRequest(BOOKING_ID, + reopened.resourceRetry().snapshotVersion(), reopened.resourceRetry().snapshotHash()), + reopened.resourceRetry().idempotencyKey())); + verify(plans, never()).convertContainerReserved(any(), anyLong(), any(), any()); + verify(warehouse, never()).ensurePalletBatch(any()); + verify(warehouse).confirmPalletBatch(any()); + } + + @Test + void nonCommittedStatesDoNotExposeAResourceRetryCommand() throws Exception { + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); + for (String status : List.of("PREPARED", "APPLYING", "FAILED", "COMPLETED")) { + when(commands.findByBooking(BOOKING_ID)).thenReturn(command("FIRST-KEY", status, request())); + org.junit.jupiter.api.Assertions.assertNull(coordinator.preview(BOOKING_ID).resourceRetry()); + } + when(commands.findByBooking(BOOKING_ID)).thenReturn(null); + org.junit.jupiter.api.Assertions.assertNull(coordinator.preview(BOOKING_ID).resourceRetry()); + } + + @Test + void missingCommittedSnapshotFailsClosedInsteadOfInventingAReplacement() throws Exception { + var committed = command("FIRST-KEY", "LOCAL_COMMITTED", request()); + committed.setSnapshotHash(null); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.preview(BOOKING_ID)).getStatusCode().value()); + verify(warehouse, never()).confirmPalletBatch(any()); + } + @Test void preparationCarriesUnderlyingFbaCodeForDropshipWithoutClassifyingOverseasDropshipAsFba() { ConvertContainerInboundPlanRequest request = request(); @@ -276,7 +330,7 @@ class ContainerInboundPlanConversionCoordinatorTest { List.of(new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", null, "ABE3", "Amazon ABE3", 1, 1, 1, 1, 0, 0, 1, 0, List.of(fba)), new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles", - 1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas)))); + 1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))) .thenReturn("IP-FBA", "IP-OVERSEAS"); @@ -309,7 +363,7 @@ class ContainerInboundPlanConversionCoordinatorTest { var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 2, 0, 2, 0, 1, 0, true, List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles", - 1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas)))); + 1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS"); when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of()); @@ -341,7 +395,7 @@ class ContainerInboundPlanConversionCoordinatorTest { var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 2, 1, 0, 3, 0, true, List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", - 1, 1, 3, 2, 1, 0, 3, 0, List.of(order)))); + 1, 1, 3, 2, 1, 0, 3, 0, List.of(order))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2"); when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( @@ -373,7 +427,7 @@ class ContainerInboundPlanConversionCoordinatorTest { var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 2, 1, 3, 3, 3, true, List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", - 1, 1, 3, 2, 1, 3, 3, 3, List.of(order)))); + 1, 1, 3, 2, 1, 3, 3, 3, List.of(order))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( "D10000-101-001", "D10000-101-002")); @@ -428,7 +482,7 @@ class ContainerInboundPlanConversionCoordinatorTest { "Los Angeles", 7L, SNAPSHOT_HASH, orders.size(), orders.size(), 0, pending, pending, pending, 0, 0, orders.size(), 1, true, List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", - orders.size(), pending, pending, pending, 0, 0, orders.size(), 1, orders))); + orders.size(), pending, pending, pending, 0, 0, orders.size(), 1, orders)), null); } private InboundPlanResponse plan(String planNo, List pallets) { @@ -476,6 +530,6 @@ class ContainerInboundPlanConversionCoordinatorTest { private ContainerInboundPlanPreviewResponse preview(long version, String hash) { return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", - "Los Angeles", version, hash, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, true, List.of()); + "Los Angeles", version, hash, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, true, List.of(), null); } }