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
@@ -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)"));
}
}
@@ -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")));
@@ -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)));
@@ -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) {