From 990b74f3e8b5de1803ae8fb540915937cce7b2e7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 00:25:57 +0800 Subject: [PATCH 1/4] fix(lastleg): confirm pre-reserved pallets on PDA scan --- .../gateway/HttpWarehouseResourceGateway.java | 63 +++++++++---- .../pda/gateway/WarehouseResourceGateway.java | 4 + .../service/InboundPdaWorkflowService.java | 50 +++++++--- .../service/ReceivingContainerService.java | 66 ++++++++++--- .../HttpWarehouseResourceGatewayTest.java | 32 +++++++ .../InboundPdaWorkflowServiceTest.java | 92 +++++++++++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 31 +++++++ 7 files changed, 295 insertions(+), 43 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 2b5043237..447b06d2b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -117,23 +117,32 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { @Override public WarehousePalletReference resolvePallet(String palletCode) { - WarehousePalletResponse response = client.get() - .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) - .header("X-Internal-Service-Token", internalToken) - .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { - throw palletLookupFailure(result.getStatusCode()); - }).body(WarehousePalletResponse.class); - if (response != null && "A0404".equals(response.code())) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); - } - if (response == null || response.data() == null) { - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, - "Warehouse pallet service returned no usable response"); - } + WarehousePalletResponse response = lookupPallet(palletCode); requireReservable(response.data()); - return new WarehousePalletReference(response.data().id(), response.data().palletCode(), - response.data().warehouseId(), response.data().locationId(), response.data().status(), - response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version()); + return reference(response.data()); + } + + @Override + public WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, + long palletId, long locationId, long resourceLockId) { + if (customerId == null || customerId.isBlank() || warehouseId <= 0 || palletId <= 0 + || locationId <= 0 || resourceLockId <= 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Reserved pallet binding is incomplete"); + } + try { + Long.parseLong(customerId); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Reserved pallet customer binding is invalid"); + } + WarehousePalletData pallet = lookupPallet(palletCode).data(); + if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId + || !"OCCUPIED".equals(pallet.status())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Scanned pallet does not match the reserved inbound plan resource"); + } + return reference(pallet); } @Override @@ -156,6 +165,28 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); } + private WarehousePalletResponse lookupPallet(String palletCode) { + WarehousePalletResponse response = client.get() + .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw palletLookupFailure(result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response != null && "A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); + } + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + } + return response; + } + + private WarehousePalletReference reference(WarehousePalletData pallet) { + return new WarehousePalletReference(pallet.id(), pallet.palletCode(), pallet.warehouseId(), + pallet.locationId(), pallet.status(), pallet.fbaWarehouseCode(), pallet.unitPrice(), pallet.version()); + } + private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) { HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index ca8a41da2..9830668a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -11,6 +11,10 @@ public interface WarehouseResourceGateway { void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); WarehousePalletReference resolvePallet(long palletId); + default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, + long palletId, long locationId, long resourceLockId) { + throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured"); + } default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { throw new UnsupportedOperationException("Warehouse location resolution is not configured"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 78ca21628..578da55e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -113,9 +113,8 @@ public class InboundPdaWorkflowService { InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); validateDestinationWarehouse(pallet, request.warehouseId()); if (pallet.getWarehouseResourceLockId() != null) { - if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) - && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; - throw conflict("Planned pallet is already assigned to another warehouse resource"); + confirmReservedAssignment(plan, pallet, request.palletId(), request.locationId(), null); + return; } String destinationKey = destinationKey(pallet); String businessId = plan.getContainerNo() + ":" + destinationKey; @@ -130,12 +129,7 @@ public class InboundPdaWorkflowService { pallet.setAssignmentStatus("ASSIGNED"); pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); - List pallets = repository.pallets(plan.getId()); - if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) { - plan.setStatus("ASSIGNED"); - plan.setUpdatedAt(LocalDateTime.now()); - repository.update(plan); - } + promotePlanWhenAllPalletsAssigned(plan); } @Transactional @@ -167,13 +161,13 @@ public class InboundPdaWorkflowService { @Transactional public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, WarehouseResourceGateway.WarehousePalletReference resource) { - if (pallet.getWarehouseResourceLockId() != null) { - if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null; - throw conflict("Destination pallet is already bound to another physical pallet"); - } if (resource.palletNo() == null || resource.palletNo().isBlank()) { throw conflict("Resolved physical pallet has no pallet code"); } + if (pallet.getWarehouseResourceLockId() != null) { + confirmReservedAssignment(plan, pallet, resource.palletId(), resource.locationId(), resource.palletNo()); + return null; + } String destinationKey = destinationKey(pallet); String businessId = plan.getContainerNo() + ":" + destinationKey; String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); @@ -184,6 +178,7 @@ public class InboundPdaWorkflowService { pallet.setWarehouseLocationId(resource.locationId()); pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + promotePlanWhenAllPalletsAssigned(plan); return lockId; } catch (RuntimeException failure) { compensateReservation(customerId, lockId, failure); @@ -878,6 +873,35 @@ public class InboundPdaWorkflowService { || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); } + private void confirmReservedAssignment(InboundPlan plan, InboundPlanPallet pallet, + long palletId, long locationId, String palletNo) { + boolean sameReservedResource = pallet.getWarehouseResourceLockId() != null + && pallet.getWarehouseResourceLockId() > 0 + && Objects.equals(pallet.getWarehousePalletId(), palletId) + && Objects.equals(pallet.getWarehouseLocationId(), locationId); + if (!sameReservedResource) { + throw conflict("Planned pallet is already assigned to another physical pallet or location"); + } + if ("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())) return; + if (!"RESERVED".equals(pallet.getAssignmentStatus())) { + throw conflict("Reserved physical pallet has an invalid assignment status"); + } + if (palletNo != null && !palletNo.isBlank()) pallet.setPalletNo(palletNo.trim()); + pallet.setAssignmentStatus("ASSIGNED"); + if (pallet.getLockedAt() == null) pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + promotePlanWhenAllPalletsAssigned(plan); + } + + private void promotePlanWhenAllPalletsAssigned(InboundPlan plan) { + List plannedPallets = repository.pallets(plan.getId()); + if (plannedPallets.isEmpty() || plannedPallets.stream().anyMatch(this::isNotAssigned)) return; + if (!java.util.Set.of("PENDING_ASSIGNMENT", "RESERVED").contains(plan.getStatus())) return; + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + private InboundPlan requirePlan(String customerId, String planNo) { InboundPlan plan = repository.lockPlan(customerId, planNo); if (plan == null) throw notFound("Inbound plan not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 3dd5353c4..67b4082c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -274,14 +274,8 @@ public class ReceivingContainerService { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); - WarehouseResourceGateway.WarehousePalletReference pallet; - try { - pallet = warehouse.resolvePallet(request.palletCode()); - } catch (IllegalArgumentException exception) { - throw notFound("Pallet was not found"); - } - if (pallet == null) throw notFound("Pallet was not found"); - validatePallet(ref, pallet); + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false); + WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); } @@ -301,9 +295,9 @@ public class ReceivingContainerService { List skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), allocation.packages())); - WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); - validatePallet(ref, resource); - InboundPlanPallet pallet = choosePallet(ref, resource); + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true); + WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); + InboundPlanPallet pallet = selection.pallet(); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { int accepted = 0; @@ -344,9 +338,9 @@ public class ReceivingContainerService { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { throw conflict("FBA destination must use bulk count"); } - WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); - validatePallet(ref, resource); - InboundPlanPallet pallet = choosePallet(ref, resource); + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true); + WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); + InboundPlanPallet pallet = selection.pallet(); workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark()); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { @@ -640,6 +634,47 @@ public class ReceivingContainerService { } private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { + private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { + List reserved = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() + .filter(value -> sameDestination(ref.line(), value)) + .filter(value -> palletCode.trim().equals(value.getPalletNo())) + .filter(this::hasConfirmedReservation) + .toList(); + if (reserved.size() > 1) { + throw conflict("Pallet code matches multiple reserved resources in this inbound plan"); + } + WarehouseResourceGateway.WarehousePalletReference resource; + InboundPlanPallet pallet; + if (reserved.size() == 1) { + pallet = reserved.get(0); + long warehouseId = ref.plan().getOverseasWarehouseId() == null + ? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); + resource = warehouse.resolveReservedPallet(palletCode.trim(), ref.order().getCustomerId(), warehouseId, + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); + } else { + try { + resource = warehouse.resolvePallet(palletCode.trim()); + } catch (IllegalArgumentException exception) { + throw notFound("Pallet was not found"); + } + if (resource == null) throw notFound("Pallet was not found"); + pallet = requirePlanPallet ? choosePallet(ref, resource) : null; + } + validatePallet(ref, resource); + return new PalletSelection(pallet, resource); + } + + private boolean hasConfirmedReservation(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0 + && pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0 + && pallet.getWarehouseResourceLockId() != null && pallet.getWarehouseResourceLockId() > 0 + && ("RESERVED".equals(pallet.getAssignmentStatus()) + || "ASSIGNED".equals(pallet.getAssignmentStatus()) + || "LOCKED".equals(pallet.getAssignmentStatus())); + } + if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); long actualWarehouse; try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } @@ -667,6 +702,9 @@ public class ReceivingContainerService { } private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { + private record PalletSelection(InboundPlanPallet pallet, + WarehouseResourceGateway.WarehousePalletReference resource) { } + List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 1da4230b5..64a1fb152 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -66,6 +66,38 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + @Test + void resolveReservedAcceptsOnlyExactOccupiedPlanBinding() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED", + "warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3, + "reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}} + """, MediaType.APPLICATION_JSON)); + + var result = gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L); + + assertThat(result.palletId()).isEqualTo(31L); + assertThat(result.status()).isEqualTo("OCCUPIED"); + server.verify(); + } + + @Test + void resolveReservedRejectsDifferentPhysicalBinding() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED", + "warehouseId":21,"locationId":99,"status":"OCCUPIED","version":3, + "reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}} + """, MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409") + .hasMessageContaining("does not match"); + server.verify(); + } + @Test void resolveFailsClosedWhenWarehouseOmitsReservationFacts() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN")) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index f8093b7c4..f91971b8a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -188,6 +188,98 @@ class InboundPdaWorkflowServiceTest { verify(repository).update(targetPallet); } + @Test + void reservedPalletScanConfirmsOnSiteWithoutReservingAgainAndAdvancesPlan() { + plan.setStatus("RESERVED"); + targetPallet.setPalletNo("IP-ACY2-20260909-P001"); + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + targetPallet.setLockedAt(null); + WarehouseResourceGateway.WarehousePalletReference resource = + new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L, + "OCCUPIED", "ACY2", 1); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assignResolvedPallet("100", plan, targetPallet, resource); + service.assignResolvedPallet("100", plan, targetPallet, resource); + + assertEquals("A002", targetPallet.getPalletNo()); + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + org.junit.jupiter.api.Assertions.assertNotNull(targetPallet.getLockedAt()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any()); + verify(repository, times(1)).update(targetPallet); + verify(repository, times(1)).update(plan); + } + + @Test + void publicAssignmentAlsoConfirmsReservedPalletWithoutReservingAgain() { + plan.setStatus("PENDING_ASSIGNMENT"); + targetPallet.setDestinationWarehouseId("88"); + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + targetPallet.setLockedAt(null); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "reserved-public")); + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "reserved-public-retry")); + + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any()); + verify(repository, times(1)).update(targetPallet); + verify(repository, times(1)).update(plan); + } + + @Test + void reservedConfirmationDoesNotRegressAdvancedPlanStatus() { + plan.setStatus("SIGNED"); + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + WarehouseResourceGateway.WarehousePalletReference resource = + new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L, + "OCCUPIED", "ACY2", 1); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assignResolvedPallet("100", plan, targetPallet, resource); + + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("SIGNED", plan.getStatus()); + verify(repository, never()).update(plan); + } + + @Test + void reservedPalletScanRejectsAnotherPhysicalPalletOrLocation() { + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + WarehouseResourceGateway.WarehousePalletReference anotherPallet = + new WarehouseResourceGateway.WarehousePalletReference(189L, "A003", 88L, 288L, + "IDLE", "ACY2", 1); + WarehouseResourceGateway.WarehousePalletReference anotherLocation = + new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 289L, + "IDLE", "ACY2", 1); + + assertThrows(ResponseStatusException.class, + () -> service.assignResolvedPallet("100", plan, targetPallet, anotherPallet)); + assertThrows(ResponseStatusException.class, + () -> service.assignResolvedPallet("100", plan, targetPallet, anotherLocation)); + + assertEquals("RESERVED", targetPallet.getAssignmentStatus()); + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any()); + verify(repository, never()).update(targetPallet); + } + @Test void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() { targetPallet.setWarehouseResourceLockId(null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 0a72f267c..3b9fbcc2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -465,6 +465,37 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow).compensateReservation("100", 55L, failure); } + @Test + void bulkReceivingResolvesExactPreReservedPalletWithoutIdleLookup() { + stubCurrent("FBA", "CONT-A"); + stubSku(); + InboundPlanPallet reserved = planPallet(); + reserved.setPalletNo("PAL-RESERVED"); + reserved.setWarehousePalletId(30L); + reserved.setWarehouseLocationId(40L); + reserved.setWarehouseResourceLockId(50L); + reserved.setAssignmentStatus("RESERVED"); + when(pallets.selectList(any())).thenReturn(List.of(reserved)); + WarehouseResourceGateway.WarehousePalletReference occupied = + new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-RESERVED", 100L, 40L, + "OCCUPIED", "FBA-1", 0); + when(warehouse.resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L)) + .thenReturn(occupied); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-RESERVED", 1, "reserved-key")); + + verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L); + verify(warehouse, never()).resolvePallet("PAL-RESERVED"); + verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"), + any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved), + org.mockito.ArgumentMatchers.same(occupied)); + } + @Test void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); From 2ba5f0c3071dae588e8d2f64ba4c3eae826c18d4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 00:31:50 +0800 Subject: [PATCH 2/4] fix(lastleg): harden container plan saga recovery --- deploy/sql/海外区域统一增量-MySQL8.sql | 6 +- .../ContainerPlanConversionCommand.java | 1 + .../ContainerPlanConversionCommandMapper.java | 26 ++- ...ainerInboundPlanConversionCoordinator.java | 184 +++++++++++++----- .../service/InboundPlanService.java | 3 +- .../src/main/resources/db/lastleg.sql | 9 +- ...rInboundPlanConversionCoordinatorTest.java | 143 +++++++++++++- .../ContainerInboundPlanPreviewTest.java | 15 ++ 8 files changed, 327 insertions(+), 60 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 96ff3dd7a..e9591329d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1995,12 +1995,14 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', - status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', + status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码'; SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java index 327338d62..8130c3593 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java @@ -19,6 +19,7 @@ public class ContainerPlanConversionCommand { private Long warehouseId; private String batchKey; private String status; + private String leaseToken; private String preparationJson; private String resultJson; private String lastError; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java index 8ddca8d0b..a4bc87cc3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java @@ -17,17 +17,33 @@ public interface ContainerPlanConversionCommandMapper extends BaseMapper existing = inTransaction(() -> { List converted = plans.convertContainerReserved(bookingId, request.snapshotVersion(), request.snapshotHash(), List.of()); - state(command.getId(), "APPLYING", "COMPLETED", write(converted), null); + state(command, "APPLYING", "COMPLETED", write(converted), null); return converted; }); command.setStatus("COMPLETED"); return existing; } catch (RuntimeException failure) { - resetForRetry(command, failure); + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); + failWithoutResources(command, failure); throw failure; } } - WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure( - preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> - new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), item.businessKey(), - item.planNo(), item.globalSequenceNo(), "STANDARD", item.fbaWarehouseCode())).toList()); - WarehouseResourceGateway.PalletBatchResponse reservation; - try { - reservation = warehouse.ensurePalletBatch(ensure); - requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED"); - } catch (RuntimeException failure) { - resetForRetry(command, failure); - throw failure; - } - List localResult; if ("LOCAL_COMMITTED".equals(command.getStatus())) { localResult = result(command); } else { + WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure( + preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> + new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), + item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD", + item.fbaWarehouseCode())).toList()); + WarehouseResourceGateway.PalletBatchResponse reservation; + try { + reservation = warehouse.ensurePalletBatch(ensure); + requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED"); + } catch (RuntimeException failure) { + compensateAndFail(command, preparation, failure); + throw failure; + } try { List preparedPlans = bind(preparation, reservation); localResult = inTransaction(() -> { List created = plans.convertContainerReserved(bookingId, request.snapshotVersion(), request.snapshotHash(), preparedPlans); - state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null); + state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null); return created; }); command.setStatus("LOCAL_COMMITTED"); } catch (RuntimeException failure) { - try { - warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation( - preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(), - "Lastleg local transaction did not commit")); - } catch (RuntimeException compensationFailure) { - failure.addSuppressed(compensationFailure); + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) { + return result(current); + } + if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) { + command.setStatus(current.getStatus()); + command.setResultJson(current.getResultJson()); + localResult = result(current); + } else { + compensateAndFail(command, preparation, failure); + throw failure; } - resetForRetry(command, failure); - throw failure; } } @@ -136,6 +162,35 @@ public class ContainerInboundPlanConversionCoordinator { private ContainerPlanConversionCommand createCommand(String bookingId, String key, ConvertContainerInboundPlanRequest request, String requestHash) { + long commandId = IdWorker.getId(); + ContainerPlanConversionCommand command = prepareCommand(commandId, commandId, bookingId, key, request, requestHash); + try { inTransaction(() -> { commands.insert(command); return null; }); } + catch (DuplicateKeyException concurrent) { + ContainerPlanConversionCommand existing = commands.findByBooking(bookingId); + if (existing == null) throw concurrent; + return existing; + } + return command; + } + + private ContainerPlanConversionCommand replaceFailedCommand(ContainerPlanConversionCommand previous, + String bookingId, String key, + ConvertContainerInboundPlanRequest request, + String requestHash) { + ContainerPlanConversionCommand replacement = prepareCommand(previous.getId(), IdWorker.getId(), + bookingId, key, request, requestHash); + int updated = commands.replaceFailed(previous.getId(), key, requestHash, request.snapshotVersion(), + request.snapshotHash(), replacement.getWarehouseId(), replacement.getBatchKey(), + replacement.getPreparationJson(), replacement.getUpdatedAt()); + if (updated != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在刷新入库计划,请勿重复提交"); + } + return replacement; + } + + private ContainerPlanConversionCommand prepareCommand(long commandId, long generationId, String bookingId, + String key, ConvertContainerInboundPlanRequest request, + String requestHash) { ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); if (preview.snapshotVersion() != request.snapshotVersion() || !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) { @@ -146,7 +201,6 @@ public class ContainerInboundPlanConversionCoordinator { catch (RuntimeException exception) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception); } - long commandId = IdWorker.getId(); List items = new ArrayList<>(); int globalSequence = 0; for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream() @@ -155,7 +209,7 @@ public class ContainerInboundPlanConversionCoordinator { String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, - localSequence, ++globalSequence, "CIP:" + commandId + ":" + globalSequence, + localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, "FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null)); } } @@ -164,15 +218,9 @@ public class ContainerInboundPlanConversionCoordinator { command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key); command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion()); command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId); - command.setBatchKey("CIP:" + hash(bookingId) + ":" + commandId); command.setStatus("PREPARED"); + command.setBatchKey("CIP:" + hash(bookingId) + ":" + generationId); command.setStatus("PREPARED"); command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now()); command.setUpdatedAt(command.getCreatedAt()); - try { inTransaction(() -> { commands.insert(command); return null; }); } - catch (DuplicateKeyException concurrent) { - ContainerPlanConversionCommand existing = commands.findByBooking(bookingId); - if (existing == null) throw concurrent; - return existing; - } return command; } @@ -213,11 +261,26 @@ public class ContainerInboundPlanConversionCoordinator { private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size, String... acceptedStatuses) { - if (response == null || !Objects.equals(batchKey, response.batchKey()) || response.items() == null + if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0 + || !Objects.equals(batchKey, response.batchKey()) || response.items() == null || response.items().size() != size || java.util.Arrays.stream(acceptedStatuses) .noneMatch(status -> status.equals(response.status()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致"); } + String expectedItemStatus = switch (response.status()) { + case "RESERVED" -> "RESERVED"; + case "CONFIRMED" -> "CONFIRMED"; + case "COMPENSATED" -> "RELEASED"; + default -> null; + }; + boolean invalidItem = expectedItemStatus == null || response.items().stream().anyMatch(item -> + item.customerId() <= 0 || item.sequenceNo() <= 0 || item.palletId() <= 0 || item.locationId() <= 0 + || item.resourceLockId() <= 0 || trimToNull(item.businessKey()) == null + || trimToNull(item.inboundPlanNo()) == null || trimToNull(item.palletCode()) == null + || item.palletCode().length() > 128 || !expectedItemStatus.equals(item.status())); + if (invalidItem) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次明细不完整或状态不一致"); + } } private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) { @@ -235,19 +298,41 @@ public class ContainerInboundPlanConversionCoordinator { } private void complete(ContainerPlanConversionCommand command, List result) { - inTransaction(() -> { state(command.getId(), "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); + inTransaction(() -> { state(command, "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); command.setStatus("COMPLETED"); } - private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) { + private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) { if ("APPLYING".equals(command.getStatus())) { - state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure)); - command.setStatus("PREPARED"); + state(command, "APPLYING", "FAILED", null, safeMessage(failure)); + command.setStatus("FAILED"); } } - private void state(long id, String expectedStatus, String status, String resultJson, String lastError) { - if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) { + private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation, + RuntimeException failure) { + try { + WarehouseResourceGateway.PalletBatchResponse compensated = warehouse.compensatePalletBatch( + new WarehouseResourceGateway.PalletBatchCompensation(preparation.warehouseId(), + command.getBatchKey(), "COMPENSATE:" + command.getBatchKey(), + "Lastleg local transaction did not commit")); + requireBatch(compensated, command.getBatchKey(), preparation.items().size(), "COMPENSATED"); + failWithoutResources(command, failure); + } catch (ResponseStatusException compensationFailure) { + if (compensationFailure.getStatusCode().value() == 404) { + failWithoutResources(command, failure); + return; + } + failure.addSuppressed(compensationFailure); + } catch (RuntimeException compensationFailure) { + failure.addSuppressed(compensationFailure); + } + } + + private void state(ContainerPlanConversionCommand command, String expectedStatus, String status, + String resultJson, String lastError) { + if (commands.updateState(command.getId(), expectedStatus, command.getLeaseToken(), status, + resultJson, lastError, LocalDateTime.now()) != 1) { throw new IllegalStateException("无法更新柜级转换命令状态"); } } @@ -281,6 +366,9 @@ public class ContainerInboundPlanConversionCoordinator { String message = value.getMessage(); return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length())); } + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } public record Preparation(long warehouseId, List items) { } public record PreparedItem(String customerId, long orderId, long planId, String planNo, 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 b2ab343e1..bae48b5c6 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 @@ -198,7 +198,8 @@ public class InboundPlanService { .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0); - String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders)); + String snapshotHash = sha256(java.util.Arrays.asList( + bookingId, containerNo, warehouseId, snapshotVersion, orders)); return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode, warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked, pendingOrders, pendingPallets, existingPallets, diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 49374729d..2712d3cbc 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -587,17 +587,20 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', - status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', + status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status')); +PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt; CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', 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 196833bd4..0e8b2736d 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 @@ -1,9 +1,11 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; 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.eq; @ExtendWith(MockitoExtension.class) class ContainerInboundPlanConversionCoordinatorTest { @@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest { @Mock InboundPlanService plans; @Mock WarehouseResourceGateway warehouse; @Mock ContainerPlanConversionCommandMapper commands; + @Mock LastlegOrderNoGenerator numbers; + @Mock PlatformTransactionManager transactionManager; private ContainerInboundPlanConversionCoordinator coordinator; private JsonMapper objectMapper; @@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest { ReflectionTestUtils.setField(coordinator, "plans", plans); ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "commands", commands); + ReflectionTestUtils.setField(coordinator, "numbers", numbers); ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper); + ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any())) + .thenReturn(new SimpleTransactionStatus()); } @Test @@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest { verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class)); } + @Test + void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception { + ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request()); + completed.setResultJson("[]"); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed); + + ConvertContainerInboundPlanRequest changed = + new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)); + + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value()); + } + @Test void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception { ConvertContainerInboundPlanRequest request = request(); @@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest { when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null); when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared); when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"), - org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0); + org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> coordinator.confirm(request, "SECOND-KEY")); @@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest { assertEquals(409, error.getStatusCode().value()); } + @Test + void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request); + stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3)); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale); + when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1); + when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of()); + when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + + assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY")); + + verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class), + any(String.class), any()); + } + + @Test + void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception { + ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request()); + when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null); + when(commands.findByBooking(BOOKING_ID)).thenReturn(failed); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); + when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)), + eq(31L), any(), any(), any())).thenReturn(1); + when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1); + when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of()); + when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + + assertEquals(List.of(), coordinator.confirm( + new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY")); + } + + @Test + void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request); + setOnePreparedItem(prepared); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared); + when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1); + when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED")))); + when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED")))); + when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"), + org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1); + + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value()); + + verify(warehouse).compensatePalletBatch(any()); + } + + @Test + void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request); + setOnePreparedItem(prepared); + ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request); + committed.setResultJson("[]"); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared); + when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1); + when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED")))); + when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any())) + .thenThrow(new IllegalStateException("commit outcome unknown")); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + 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(request, "FIRST-KEY")); + + verify(warehouse, never()).compensatePalletBatch(any()); + } + + @Test + void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request); + committed.setResultJson("[]"); + setOnePreparedItem(committed); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed); + 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(request, "FIRST-KEY")); + + verify(warehouse, never()).ensurePalletBatch(any()); + verify(warehouse).confirmPalletBatch(any()); + } + private ConvertContainerInboundPlanRequest request() { return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH); } @@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest { command.setWarehouseId(31L); command.setBatchKey("BATCH-1"); command.setStatus(status); + if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1"); command.setPreparationJson(objectMapper.writeValueAsString( new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of()))); return command; } + + private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception { + var item = new ContainerInboundPlanConversionCoordinator.PreparedItem( + "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8"); + command.setPreparationJson(objectMapper.writeValueAsString( + new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item)))); + } + + private WarehouseResourceGateway.PalletBatchItem reserved(String status) { + return new WarehouseResourceGateway.PalletBatchItem( + 100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status); + } + + 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, 1, 1, 1, true, List.of()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java index fb7c97688..b5186dec5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest { assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers()); } + @Test + void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() { + when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户", + "FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0, + BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1, + null, null, null))); + + var result = service.previewContainer("BOOKING-NULL"); + + assertNotNull(result.snapshotHash()); + assertEquals(64, result.snapshotHash().length()); + assertFalse(result.ready()); + } + private InboundPlanOrderMapper.ContainerPreviewOrderRow row( long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, int recommendedPallets, String planNo, Integer plannedPallets, int version) { From df0088750b48649bd2a91e37b81443ae60db5497 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 00:37:42 +0800 Subject: [PATCH 3/4] fix(lastleg): restore receiving service structure --- .../inboundplan/pda/service/ReceivingContainerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 67b4082c7..cfc65b0a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -701,10 +701,10 @@ public class ReceivingContainerService { return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); } - private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { private record PalletSelection(InboundPlanPallet pallet, WarehouseResourceGateway.WarehousePalletReference resource) { } + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); From e7cbe98766daa8edaa78f52c41643dd50e489b47 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 00:39:38 +0800 Subject: [PATCH 4/4] fix(lastleg): restore pallet validation structure --- .../inboundplan/pda/service/ReceivingContainerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index cfc65b0a5..3dc060640 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -633,7 +633,6 @@ public class ReceivingContainerService { return candidates.get(0); } - private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { List reserved = pallets.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) @@ -675,6 +674,7 @@ public class ReceivingContainerService { || "LOCKED".equals(pallet.getAssignmentStatus())); } + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); long actualWarehouse; try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }