fix(firstleg): make truck status informational for departure

This commit is contained in:
2026-08-26 12:36:14 +08:00
parent 8eeb52ba77
commit 1584c7a0a3
4 changed files with 41 additions and 5 deletions
@@ -24,7 +24,6 @@ import java.util.*;
@Service
public class OutboundDispatchService {
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
private static final Set<String> TRUCK_READY = Set.of("DEPARTED", "COMPLETED");
private final OutboundDispatchRepository repository;
private final Map<String, OutboundDispatchGateContributor> contributors;
@@ -187,8 +186,6 @@ public class OutboundDispatchService {
status(pick), "COMPLETED"));
items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次",
ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED"));
items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()),
status(truck), "DEPARTED或COMPLETED"));
boolean evidenceReady = evidence != null && evidence.evidenceCount() != null
&& evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1);
items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady,
@@ -99,8 +99,8 @@ components:
loadingPlanStatus: {type: string}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingStatus: {type: string, nullable: true}
truckAppointmentId: {type: integer, format: int64, nullable: true}
truckAppointmentStatus: {type: string, nullable: true}
truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation}
truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed}
dispatchStatus: {type: string, description: One visible container-level status}
version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows}
inbounds:
@@ -44,6 +44,34 @@ class OutboundDispatchServiceTest {
.extracting(GateItem::passed).containsExactly(false);
}
@Test
void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
OutboundDispatchService service = serviceWithoutCarrier();
stubContainer();
when(repository.truck(22L, 44L)).thenReturn(null);
stubConfirmMutation("no-truck");
Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null));
assertThat(result.aggregate().truckAppointmentId()).isNull();
assertThat(result.aggregate().gate().passed()).isTrue();
verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull());
}
@Test
void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
OutboundDispatchService service = serviceWithoutCarrier();
stubContainer();
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
stubConfirmMutation("pending-truck");
Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null));
assertThat(result.aggregate().truckAppointmentStatus()).isEqualTo("PENDING");
assertThat(result.aggregate().gate().passed()).isTrue();
verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull());
}
@Test
void detailReturnsOneVersionAndAllInboundBindings() {
OutboundDispatchService service = serviceWithoutCarrier();
@@ -185,6 +213,15 @@ class OutboundDispatchServiceTest {
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
}
private void stubConfirmMutation(String key) {
when(repository.lockBindings(22L)).thenReturn(bindings());
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0));
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq(key), anyString())).thenReturn(true);
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1);
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", key)).thenReturn(1);
}
private static List<OutboundDispatchReadMapper.Binding> bindings() {
return List.of(binding(11L, "I-11"), binding(12L, "I-12"));
}
@@ -48,6 +48,8 @@ class OutboundDispatchSqlContractTest {
.contains("outbound-dispatches/{containerBookingId}/reverse:")
.contains("every real inbound binding", "No partial success is possible")
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
.contains("absence never blocks manual outbound confirmation")
.contains("PENDING or any non-terminal state never affects gate.passed")
.contains("expectedVersion")
.contains("Idempotency-Key")
.doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId");