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 a4bc87cc3..a9053503e 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 @@ -11,6 +11,14 @@ import java.time.LocalDateTime; @Mapper public interface ContainerPlanConversionCommandMapper extends BaseMapper { + // Only plans listed in this durable command are pending; older plans in the same cabinet stay confirmed. + String UNCONFIRMED_PLAN = "EXISTS (SELECT 1 FROM ll_container_plan_conversion_command rc " + + "JOIN JSON_TABLE(rc.preparation_json, '$.items[*]' COLUMNS (" + + "customer_id VARCHAR(20) PATH '$.customerId' ERROR ON EMPTY ERROR ON ERROR," + + "plan_id BIGINT PATH '$.planId' ERROR ON EMPTY ERROR ON ERROR)) resource_item " + + "ON resource_item.plan_id=p.id AND CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY) " + + "WHERE rc.source_container_booking_id=o.source_container_booking_id AND rc.status='LOCAL_COMMITTED')"; + @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} AND idempotency_key=#{key} LIMIT 1") ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 534969f23..d572e8e41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -49,6 +49,11 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + @Select("SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " + + "AND o.id=p.source_order_id WHERE p.customer_id=#{customerId} AND p.id=#{planId} AND " + + ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN) + long countUnconfirmedResourcePlans(@Param("customerId") String customerId, @Param("planId") long planId); + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " + "WHERE id=#{planId} AND customer_id=#{customerId} " + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 0569b86d0..c21a77c65 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -47,7 +47,8 @@ public interface InboundPlanOrderMapper { "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," + "p.pallet_count AS plannedPalletCount," + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + - "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL AND NOT " + + ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + ") AS materializedPalletCount," + "(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " + "AND sp.order_id=o.id) AS sourcePalletCount " + "FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + 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 63ed8f26a..b4998a5e3 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 @@ -716,11 +716,14 @@ public class ReceivingContainerService { && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; - boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; + boolean resourcesConfirmed = matchedPlans.stream().noneMatch(plan -> + plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0); + boolean operable = allPlansReady && resourcesConfirmed && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent ? "柜内接柜仓业务快照不一致" : !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码" + : !resourcesConfirmed ? "入库计划资源尚未确认,请先在平台恢复原柜级转换命令" : "柜号尚未达到海外收货可操作节点"); LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt) .filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null); @@ -958,6 +961,9 @@ public class ReceivingContainerService { private void requireMaterializedPlans(List containerPlans) { for (InboundPlan plan : containerPlans) { + if (plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0) { + throw conflict("入库计划资源尚未确认,请先在平台恢复原柜级转换命令"); + } int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount(); long materialized = pallets.selectCount(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) 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 31b7f2542..13e3782bc 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 @@ -81,9 +81,11 @@ public class ContainerInboundPlanConversionCoordinator { .anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness())); if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual; + var retry = preview(bookingId).resourceRetry(); List materialized = confirm(new ConvertContainerInboundPlanRequest( - bookingId, preview.snapshotVersion(), preview.snapshotHash()), - "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey))); + bookingId, retry == null ? preview.snapshotVersion() : retry.snapshotVersion(), + retry == null ? preview.snapshotHash() : retry.snapshotHash()), + retry == null ? "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey)) : retry.idempotencyKey()); return materialized.stream() .filter(plan -> Objects.equals(plan.customerId(), manual.customerId()) && Objects.equals(plan.sourceOrderId(), manual.sourceOrderId())) @@ -104,6 +106,9 @@ public class ContainerInboundPlanConversionCoordinator { } else if ("FAILED".equals(resolved.getStatus())) { resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash); } else { + if ("LOCAL_COMMITTED".equals(resolved.getStatus()) && !key.equals(resolved.getIdempotencyKey())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "资源恢复必须使用原柜级转换命令"); + } requireSameRequest(resolved, requestHash); } if ("COMPLETED".equals(resolved.getStatus())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 2119553a5..ac6290793 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -71,8 +71,9 @@ public interface LastlegOrderMapper extends BaseMapper { "THEN 1 ELSE 0 END) AS pendingPlanCount," + "SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " + "AND COALESCE(p.pallet_count,0)>0 AND " + - "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + - "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " + + "(" + com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + + " OR (SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0)) " + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + "CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " + "AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java new file mode 100644 index 000000000..d39443749 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class UnconfirmedContainerResourcesContractTest { + @Test + void localPalletRowsCannotHideAnUnconfirmedCommandInPreviewOrCabinetCandidate() throws Exception { + String preview = String.join("", InboundPlanOrderMapper.class.getMethod("containerPreviewOrders", String.class) + .getAnnotation(Select.class).value()); + String progress = String.join("", LastlegOrderMapper.class.getMethod("pageContainerProgress", + LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class).value()); + for (String sql : new String[]{preview, progress}) { + assertTrue(sql.contains("JSON_TABLE")); + assertTrue(sql.contains("rc.status='LOCAL_COMMITTED'")); + assertTrue(sql.contains("resource_item.plan_id=p.id")); + assertTrue(sql.contains("CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY)")); + assertTrue(sql.contains("rc.source_container_booking_id=o.source_container_booking_id")); + assertFalse(sql.contains("rc.status='COMPLETED'")); + } + assertTrue(preview.contains("AND NOT EXISTS")); + String rendered = new XMLLanguageDriver().createSqlSource(new Configuration(), progress, Map.class) + .getBoundSql(Map.of("q", new LastlegOrderRequests.ContainerProgressQuery(1,20,null,null,null), + "limit",20L,"offset",0L)).getSql(); + assertTrue(rendered.contains("JSON_TABLE")); + assertFalse(rendered.contains("<")); + } + + @Test + void pdaResourceGateUsesTheExactPlanAndCustomerRatherThanEveryPlanInTheCabinet() throws Exception { + String sql = String.join("", InboundPlanMapper.class.getMethod("countUnconfirmedResourcePlans", String.class, long.class) + .getAnnotation(Select.class).value()); + assertTrue(sql.contains("p.customer_id=#{customerId}")); + assertTrue(sql.contains("p.id=#{planId}")); + assertTrue(sql.contains("resource_item.plan_id=p.id")); + assertTrue(sql.contains("CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY)")); + assertFalse(sql.contains("CAST(resource_item.customer_id AS UNSIGNED)")); + } +} 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 cb3ca9041..f2f13a06e 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 @@ -123,6 +123,24 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(warehouse); } + @Test + void localCommittedResourcesAreNotPdaCandidatesOrReceivableDespiteCompleteLocalPalletBindings() { + InboundPlan pending = plan("CONT-A"); + when(plans.selectList(any())).thenReturn(List.of(pending)); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of(containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"))); + when(plans.countUnconfirmedResourcePlans(pending.getCustomerId(), pending.getId())).thenReturn(1L); + var candidates = service.candidates("7", 100L); + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0).operable()).isFalse(); + assertThat(candidates.get(0).blockReason()).contains("资源尚未确认"); + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", + new ReceivingContainerRequests.Action("reserved-only"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("资源尚未确认"); + verify(pallets, never()).selectCount(any()); + verifyNoInteractions(warehouse); + } + @Test void prepareIdempotencyHashIncludesContainerPath() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); 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 8a2d4fc7a..49195f710 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 @@ -282,6 +282,8 @@ class ContainerInboundPlanConversionCoordinatorTest { assertEquals("FIRST-KEY", reopened.resourceRetry().idempotencyKey()); assertEquals(7L, reopened.resourceRetry().snapshotVersion()); assertEquals(SNAPSHOT_HASH, reopened.resourceRetry().snapshotHash()); + assertEquals(reopened, coordinator.preview(BOOKING_ID)); + verify(commands, never()).insert(any(ContainerPlanConversionCommand.class)); when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed); assertEquals(409, assertThrows(ResponseStatusException.class, () -> coordinator.confirm( new ConvertContainerInboundPlanRequest(BOOKING_ID, reopened.snapshotVersion(), reopened.snapshotHash()), @@ -298,6 +300,31 @@ class ContainerInboundPlanConversionCoordinatorTest { verify(warehouse).confirmPalletBatch(any()); } + @Test + void localCommittedRecoveryRejectsANewKeyEvenWithTheOriginalBody() throws Exception { + var committed = command("FIRST-KEY", "LOCAL_COMMITTED", request()); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.confirm(request(), "NEW-KEY")).getStatusCode().value()); + verify(warehouse, never()).confirmPalletBatch(any()); + verify(plans, never()).convertContainerReserved(any(), anyLong(), any(), any()); + } + + @Test + void manualReplayResumesTheOriginalResourceCommandRatherThanMintingAnotherKey() throws Exception { + ManualInboundPlanRequest manual = manualRequest(); + InboundPlanResponse existing = plan("IP-MANUAL", List.of()); + var committed = command("FIRST-KEY", "LOCAL_COMMITTED", request()); + when(plans.convertManual(manual, "manual-key", "7")).thenReturn(existing); + when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID); + when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder()))); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + var spy = org.mockito.Mockito.spy(coordinator); + doReturn(List.of(existing)).when(spy).confirm(any(ConvertContainerInboundPlanRequest.class), any(String.class)); + assertEquals(existing, spy.convertManualAndAutoMaterialize(manual, "manual-key", "7")); + verify(spy).confirm(eq(request()), eq("FIRST-KEY")); + } + @Test void nonCommittedStatesDoNotExposeAResourceRetryCommand() throws Exception { when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); 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 ccd9e9fd8..49d5f1540 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 @@ -176,6 +176,30 @@ class ContainerInboundPlanPreviewTest { assertEquals("READY", order.readiness()); } + @Test + void unconfirmedLocalPlanStaysPendingBesideAnOlderConfirmedPlanAndCompletesAfterConfirmation() { + var oldPlan = row(1L, "OLD", "FIRST_LEG", "FBA", "ACY2", "Amazon", 1, + "IP-OLD", 1, 1, "PENDING_ASSIGNMENT", 1); + var pending = row(2L, "NEW", "FIRST_LEG", "FBA", "ACY2", "Amazon", 1, + "IP-NEW", 1, 2, "PENDING_ASSIGNMENT", 0); + var confirmed = row(2L, "NEW", "FIRST_LEG", "FBA", "ACY2", "Amazon", 1, + "IP-NEW", 1, 2, "PENDING_ASSIGNMENT", 1); + when(orderMapper.containerPreviewOrders("MIXED")).thenReturn(List.of(oldPlan, pending), + List.of(oldPlan, pending), List.of(oldPlan, confirmed)); + var first = service.previewContainer("MIXED"); + assertEquals(first, service.previewContainer("MIXED")); + assertEquals(1, first.pendingOrderCount()); + assertEquals(1, first.pendingPalletCount()); + assertEquals(1, first.existingPlannedPalletCount()); + assertEquals(List.of("PLANNED", "RESOURCE_PENDING"), first.groups().get(0).orders().stream() + .map(ContainerInboundPlanPreviewResponse.Order::readiness).toList()); + var complete = service.previewContainer("MIXED"); + assertEquals(0, complete.pendingOrderCount()); + assertEquals(2, complete.existingPlannedPalletCount()); + org.mockito.Mockito.verify(orderMapper, org.mockito.Mockito.times(3)).containerPreviewOrders("MIXED"); + org.mockito.Mockito.verifyNoMoreInteractions(orderMapper); + } + private InboundPlanOrderMapper.ContainerPreviewOrderRow row( long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, int recommendedPallets, String planNo, Integer plannedPallets, int version) {