fix(lastleg): keep unconfirmed container resources recoverable
This commit is contained in:
+8
@@ -11,6 +11,14 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Mapper
|
||||
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")
|
||||
ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key);
|
||||
|
||||
|
||||
+5
@@ -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")
|
||||
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})")
|
||||
|
||||
+2
-1
@@ -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 " +
|
||||
|
||||
+7
-1
@@ -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<InboundPlan> 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<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
|
||||
|
||||
+7
-2
@@ -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<InboundPlanResponse> 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())) {
|
||||
|
||||
@@ -71,8 +71,9 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
"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," +
|
||||
|
||||
+45
@@ -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)"));
|
||||
}
|
||||
}
|
||||
+18
@@ -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")));
|
||||
|
||||
+27
@@ -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)));
|
||||
|
||||
+24
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user