diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 4192ec0b2..2ba9538d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -24,7 +24,6 @@ import java.util.*; @Service public class OutboundDispatchService { private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); - private static final Set TRUCK_READY = Set.of("DEPARTED", "COMPLETED"); private final OutboundDispatchRepository repository; private final Map 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, diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index b590dfc68..f2dc216bb 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -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: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index e85d69d17..3b5ec26d7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -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 bindings() { return List.of(binding(11L, "I-11"), binding(12L, "I-12")); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 2bba4fc07..076c56f63 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -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");