fix(lastleg): keep unconfirmed container resources recoverable

This commit is contained in:
2026-10-05 05:26:01 +08:00
parent 3afb3bcfd1
commit 47acfc991b
10 changed files with 146 additions and 6 deletions
@@ -11,6 +11,14 @@ import java.time.LocalDateTime;
@Mapper @Mapper
public interface ContainerPlanConversionCommandMapper extends BaseMapper<ContainerPlanConversionCommand> { public interface ContainerPlanConversionCommandMapper extends BaseMapper<ContainerPlanConversionCommand> {
// 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") @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); ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key);
@@ -49,6 +49,11 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") @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); 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) " + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " +
"WHERE id=#{planId} AND customer_id=#{customerId} " + "WHERE id=#{planId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
@@ -47,7 +47,8 @@ public interface InboundPlanOrderMapper {
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," + "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," + "p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + "(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 " + "(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " +
"AND sp.order_id=o.id) AS sourcePalletCount " + "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 " + "FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
@@ -716,11 +716,14 @@ public class ReceivingContainerService {
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus())); .contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; 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); int pending = Math.max(0, totalOrders - plannedOrders);
String blockReason = operable ? null : (!warehouseSnapshotConsistent String blockReason = operable ? null : (!warehouseSnapshotConsistent
? "柜内接柜仓业务快照不一致" ? "柜内接柜仓业务快照不一致"
: !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码" : !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
: !resourcesConfirmed ? "入库计划资源尚未确认,请先在平台恢复原柜级转换命令"
: "柜号尚未达到海外收货可操作节点"); : "柜号尚未达到海外收货可操作节点");
LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt) LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt)
.filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null); .filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null);
@@ -958,6 +961,9 @@ public class ReceivingContainerService {
private void requireMaterializedPlans(List<InboundPlan> containerPlans) { private void requireMaterializedPlans(List<InboundPlan> containerPlans) {
for (InboundPlan plan : containerPlans) { for (InboundPlan plan : containerPlans) {
if (plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0) {
throw conflict("入库计划资源尚未确认,请先在平台恢复原柜级转换命令");
}
int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount(); int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount();
long materialized = pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>() long materialized = pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
@@ -81,9 +81,11 @@ public class ContainerInboundPlanConversionCoordinator {
.anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness())); .anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness()));
if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual; if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual;
var retry = preview(bookingId).resourceRetry();
List<InboundPlanResponse> materialized = confirm(new ConvertContainerInboundPlanRequest( List<InboundPlanResponse> materialized = confirm(new ConvertContainerInboundPlanRequest(
bookingId, preview.snapshotVersion(), preview.snapshotHash()), bookingId, retry == null ? preview.snapshotVersion() : retry.snapshotVersion(),
"AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey))); retry == null ? preview.snapshotHash() : retry.snapshotHash()),
retry == null ? "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey)) : retry.idempotencyKey());
return materialized.stream() return materialized.stream()
.filter(plan -> Objects.equals(plan.customerId(), manual.customerId()) .filter(plan -> Objects.equals(plan.customerId(), manual.customerId())
&& Objects.equals(plan.sourceOrderId(), manual.sourceOrderId())) && Objects.equals(plan.sourceOrderId(), manual.sourceOrderId()))
@@ -104,6 +106,9 @@ public class ContainerInboundPlanConversionCoordinator {
} else if ("FAILED".equals(resolved.getStatus())) { } else if ("FAILED".equals(resolved.getStatus())) {
resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash); resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash);
} else { } else {
if ("LOCAL_COMMITTED".equals(resolved.getStatus()) && !key.equals(resolved.getIdempotencyKey())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "资源恢复必须使用原柜级转换命令");
}
requireSameRequest(resolved, requestHash); requireSameRequest(resolved, requestHash);
} }
if ("COMPLETED".equals(resolved.getStatus())) { if ("COMPLETED".equals(resolved.getStatus())) {
@@ -71,8 +71,9 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"THEN 1 ELSE 0 END) AS pendingPlanCount," + "THEN 1 ELSE 0 END) AS pendingPlanCount," +
"SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " + "SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " +
"AND COALESCE(p.pallet_count,0)&gt;0 AND " + "AND COALESCE(p.pallet_count,0)&gt;0 AND " +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + "(" + com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)&lt;COALESCE(p.pallet_count,0) " + " 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)&lt;COALESCE(p.pallet_count,0)) " +
"THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " + "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," + "AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," +
@@ -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("&lt;"));
}
@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)"));
}
}
@@ -123,6 +123,24 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(warehouse); 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 @Test
void prepareIdempotencyHashIncludesContainerPath() { void prepareIdempotencyHashIncludesContainerPath() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
@@ -282,6 +282,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
assertEquals("FIRST-KEY", reopened.resourceRetry().idempotencyKey()); assertEquals("FIRST-KEY", reopened.resourceRetry().idempotencyKey());
assertEquals(7L, reopened.resourceRetry().snapshotVersion()); assertEquals(7L, reopened.resourceRetry().snapshotVersion());
assertEquals(SNAPSHOT_HASH, reopened.resourceRetry().snapshotHash()); 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); when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed);
assertEquals(409, assertThrows(ResponseStatusException.class, () -> coordinator.confirm( assertEquals(409, assertThrows(ResponseStatusException.class, () -> coordinator.confirm(
new ConvertContainerInboundPlanRequest(BOOKING_ID, reopened.snapshotVersion(), reopened.snapshotHash()), new ConvertContainerInboundPlanRequest(BOOKING_ID, reopened.snapshotVersion(), reopened.snapshotHash()),
@@ -298,6 +300,31 @@ class ContainerInboundPlanConversionCoordinatorTest {
verify(warehouse).confirmPalletBatch(any()); 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 @Test
void nonCommittedStatesDoNotExposeAResourceRetryCommand() throws Exception { void nonCommittedStatesDoNotExposeAResourceRetryCommand() throws Exception {
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64)));
@@ -176,6 +176,30 @@ class ContainerInboundPlanPreviewTest {
assertEquals("READY", order.readiness()); 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( private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) { int recommendedPallets, String planNo, Integer plannedPallets, int version) {