fix(inbound): expose original command for resource confirmation recovery
This commit is contained in:
+1
-1
@@ -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}")
|
||||
|
||||
+13
-1
@@ -23,8 +23,20 @@ public record ContainerInboundPlanPreviewResponse(
|
||||
int totalSuggestedPalletCount,
|
||||
int totalPlannedPalletCount,
|
||||
boolean ready,
|
||||
List<Group> groups
|
||||
List<Group> 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,
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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 订单数 }
|
||||
|
||||
+28
@@ -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()
|
||||
|
||||
+60
-6
@@ -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<InboundPlanResponse.Pallet> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user