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 70cd29af3..5c0e515b7 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 @@ -163,13 +163,13 @@ public class OutboundDispatchService { List inboundDtos = new ArrayList<>(); List states = new ArrayList<>(); for (OutboundDispatchReadMapper.Binding binding : bindings) { - GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + GateResult gate = gate(binding, state, pick, ocean, evidence, containerContributorGates); inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), id(pick), status(pick))); - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), - binding.containerBookingId()); if (state != null) states.add(state); } StateView visible = visibleStateForRead(states, bindings.size()); @@ -180,14 +180,21 @@ public class OutboundDispatchService { visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } - private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, + private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchEntity dispatch, + OutboundDispatchReadMapper.IdStatus pick, OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, List containerContributorGates) { List items = new ArrayList<>(); items.add(item("INBOUND_NOT_HELD", "入库单未扣货", !Boolean.TRUE.equals(binding.holdFlag()), Boolean.TRUE.equals(binding.holdFlag()) ? "HELD" : "RELEASED", "RELEASED")); - items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), - binding.inboundStatus(), "LOADED")); + // Later delivery progress does not erase this binding's confirmed departure. + // Never infer loading completion from a later inbound status alone. + boolean confirmedLoadedHistory = dispatch != null && dispatch.getConfirmedAt() != null + && OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(dispatch.getStatus()) + && ("DELIVERED".equals(binding.inboundStatus()) || "COMPLETED".equals(binding.inboundStatus())); + items.add(item("INBOUND_LOADED", "入库单装柜完成", + "LOADED".equals(binding.inboundStatus()) || confirmedLoadedHistory, + binding.inboundStatus(), confirmedLoadedHistory ? "已确认离仓,装柜历史已保留" : "LOADED")); items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()), binding.loadingPlanStatus(), "COMPLETED")); items.add(item("PDA_PICK_COMPLETED", "PDA柜次拣货完成", pick != null && "COMPLETED".equals(pick.status()), 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 9d4d38582..cce6c5889 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 @@ -11,6 +11,9 @@ import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -31,6 +34,72 @@ class OutboundDispatchServiceTest { when(repository.timeline(any(), any(), any())).thenReturn(List.of()); } + @ParameterizedTest + @ValueSource(strings = {"DELIVERED", "COMPLETED"}) + void confirmedDepartureKeepsLoadedGateWhenInboundAdvances(String inboundStatus) { + stubContainer(List.of(bindingWithStatus(11L, inboundStatus))); + OutboundDispatchEntity confirmed = state(31L, 11L, "WAITING_FOR_LOADING", 1); + confirmed.setConfirmedAt(LocalDateTime.of(2026, 10, 1, 12, 0)); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(confirmed); + Detail detail = serviceWithoutCarrier().detail(22L); + assertThat(detail.aggregate().gate().passed()).isTrue(); + assertThat(detail.aggregate().inbounds().get(0).inboundStatus()).isEqualTo(inboundStatus); + assertThat(detail.aggregate().gate().inbounds().get(0).gate().items()) + .filteredOn(item -> item.code().equals("INBOUND_LOADED")) + .singleElement().satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo(inboundStatus); + assertThat(item.requirement()).contains("已确认离仓"); + }); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + } + + @ParameterizedTest + @ValueSource(strings = {"DELIVERED", "COMPLETED"}) + void laterInboundStateWithoutConfirmedDepartureCannotPassOrConfirm(String inboundStatus) { + var bindings = List.of(bindingWithStatus(11L, inboundStatus)); + stubContainer(bindings); + when(repository.lockBindings(22L)).thenReturn(bindings); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("later-unconfirmed"), anyString())).thenReturn(true); + var service = serviceWithoutCarrier(); + assertThat(service.checkGate(22L).passed()).isFalse(); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "later-unconfirmed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("门禁"); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + } + + @Test + void reversedDepartureAndMissingConfirmationTimeDoNotProveLoadedHistory() { + stubContainer(List.of(bindingWithStatus(11L, "DELIVERED"))); + var state = state(31L, 11L, "READY_FOR_DISPATCH", 2); + state.setConfirmedAt(LocalDateTime.of(2026, 10, 1, 12, 0)); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + assertThat(serviceWithoutCarrier().checkGate(22L).passed()).isFalse(); + state.setStatus("WAITING_FOR_LOADING"); + state.setConfirmedAt(null); + assertThat(serviceWithoutCarrier().checkGate(22L).passed()).isFalse(); + } + + @Test + void confirmedDepartureDoesNotBypassLoadingOrOtherAuthoritativeGates() { + var confirmed = state(31L, 11L, "WAITING_FOR_LOADING", 1); + confirmed.setConfirmedAt(LocalDateTime.of(2026, 10, 1, 12, 0)); + stubContainer(List.of(bindingWithStatus(11L, "LOADING"))); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(confirmed); + assertThat(serviceWithoutCarrier().checkGate(22L).passed()).isFalse(); + stubContainer(List.of(bindingWithStatus(11L, "DELIVERED"))); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + var gate = serviceWithoutCarrier().checkGate(22L); + assertThat(gate.passed()).isFalse(); + assertThat(gate.inbounds().get(0).gate().items()).filteredOn(item -> item.code().equals("LOADING_EVIDENCE_COMPLETE")) + .extracting(GateItem::passed).containsExactly(false); + } + + private static OutboundDispatchReadMapper.Binding bindingWithStatus(long inboundId, String status) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, "I-" + inboundId, 7L, 8L, "客户A", + status, false, 22L, "CAB-22", "C-22", 44L, "COMPLETED"); + } + @Test void containerEvidenceFailureFailsEveryBindingConsistently() { OutboundDispatchService service = serviceWithoutCarrier();