fix(firstleg): preserve sailing forecast batch facts

This commit is contained in:
2026-09-27 19:23:56 +08:00
parent 4777f29c5b
commit 48de46739c
2 changed files with 153 additions and 14 deletions
@@ -13,6 +13,10 @@ import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import com.baiyue.firstleg.sailingcalendar.fact.repository.SailingFactsRepository;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -37,6 +41,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -105,6 +110,103 @@ class MeasurementReviewServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("多个预报按显式批次键只消费对应预报并保留剩余批次")
void shouldConsumeOnlyExplicitForecastBatch() {
Fixture fixture = fixture(false);
fixture.order.setChannelCode("MATSON");
fixture.measurement.setExternalEventNo("SAILING_FORECAST:BATCH-B");
when(fixture.sailingCalendar.channelByCode("MATSON")).thenReturn(activeChannel());
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
SailingInboundVolumeForecastEntity first = forecast(101L, "BATCH-A", new BigDecimal("0.4000"));
SailingInboundVolumeForecastEntity second = forecast(102L, "BATCH-B", new BigDecimal("1.0000"));
when(fixture.sailingFacts.currentForecasts(10001L, 10L)).thenReturn(List.of(first, second));
SailingAssignmentEntity secondAssignment = forecastAssignment(102L, 900L);
when(fixture.sailingFacts.currentAssignmentForForecast(10001L, 102L)).thenReturn(secondAssignment);
when(fixture.sailingFacts.consumeForecast(eq(10001L), eq(102L), any(LocalDateTime.class))).thenReturn(1);
when(fixture.sailingCalendar.sailing(900L)).thenReturn(openSailing(900L,
LocalDateTime.of(2026, 9, 21, 18, 0)));
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-explicit-forecast",
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.sailingFacts).consumeForecast(eq(10001L), eq(102L), any(LocalDateTime.class));
verify(fixture.sailingFacts, never()).consumeForecast(eq(10001L), eq(101L), any(LocalDateTime.class));
ArgumentCaptor<SailingInboundVolumeForecastEntity> forecasts =
ArgumentCaptor.forClass(SailingInboundVolumeForecastEntity.class);
verify(fixture.sailingFacts).insertForecast(forecasts.capture());
assertThat(forecasts.getValue().getForecastKey()).isEqualTo("BATCH-B");
assertThat(forecasts.getValue().getForecastVolumeCbm()).isEqualByComparingTo("0.2520");
ArgumentCaptor<SailingAssignmentEntity> assignments =
ArgumentCaptor.forClass(SailingAssignmentEntity.class);
verify(fixture.sailingFacts, org.mockito.Mockito.times(2)).insertAssignment(assignments.capture());
SailingAssignmentEntity receiptAssignment = assignments.getAllValues().stream()
.filter(value -> value.getReceiptBatchId() != null)
.findFirst().orElseThrow();
assertThat(receiptAssignment.getSailingId()).isEqualTo(900L);
assertThat(receiptAssignment.getMatchStatus()).isEqualTo("RECEIVED");
}
@Test
@DisplayName("多个有效预报没有批次键时保持待核对,不按体积猜测")
void shouldLeaveReceiptUnmatchedWhenForecastBatchIsAmbiguous() {
Fixture fixture = fixture(false);
fixture.order.setChannelCode("MATSON");
when(fixture.sailingCalendar.channelByCode("MATSON")).thenReturn(activeChannel());
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
when(fixture.sailingFacts.currentForecasts(10001L, 10L)).thenReturn(List.of(
forecast(101L, "BATCH-A", new BigDecimal("0.7480")),
forecast(102L, "BATCH-B", new BigDecimal("0.6000"))));
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-ambiguous-forecast",
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.sailingFacts, never()).consumeForecast(anyLong(), anyLong(), any(LocalDateTime.class));
verify(fixture.sailingCalendar, never()).openSailingsFrom(anyLong(), anyString(), anyString(), anyString(), any(LocalDateTime.class));
ArgumentCaptor<SailingAssignmentEntity> assignment =
ArgumentCaptor.forClass(SailingAssignmentEntity.class);
verify(fixture.sailingFacts).insertAssignment(assignment.capture());
assertThat(assignment.getValue().getForecastId()).isNull();
assertThat(assignment.getValue().getSailingId()).isNull();
assertThat(assignment.getValue().getMatchStatus()).isEqualTo("UNMATCHED");
assertThat(assignment.getValue().getReason()).isEqualTo("MULTI_FORECAST_REVIEW_REQUIRED");
}
@Test
@DisplayName("已有预报的实收晚于原船截止仍保留原船并标记LATE")
void shouldKeepForecastSailingWhenReceiptArrivesLate() {
Fixture fixture = fixture(false);
fixture.order.setChannelCode("MATSON");
fixture.measurement.setExternalEventNo("SAILING_FORECAST:BATCH-A");
when(fixture.sailingCalendar.channelByCode("MATSON")).thenReturn(activeChannel());
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
when(fixture.sailingFacts.currentForecasts(10001L, 10L)).thenReturn(List.of(
forecast(101L, "BATCH-A", new BigDecimal("0.7480"))));
when(fixture.sailingFacts.currentAssignmentForForecast(10001L, 101L))
.thenReturn(forecastAssignment(101L, 900L));
when(fixture.sailingFacts.consumeForecast(eq(10001L), eq(101L), any(LocalDateTime.class))).thenReturn(1);
when(fixture.sailingCalendar.sailing(900L)).thenReturn(openSailing(900L,
LocalDateTime.of(2026, 9, 19, 18, 0)));
fixture.measurement.setMeasuredAt(LocalDateTime.of(2026, 9, 20, 10, 0));
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-late-forecast",
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.sailingCalendar, never()).openSailingsFrom(anyLong(), anyString(), anyString(), anyString(), any(LocalDateTime.class));
ArgumentCaptor<SailingAssignmentEntity> assignment =
ArgumentCaptor.forClass(SailingAssignmentEntity.class);
verify(fixture.sailingFacts).insertAssignment(assignment.capture());
assertThat(assignment.getValue().getSailingId()).isEqualTo(900L);
assertThat(assignment.getValue().getMatchStatus()).isEqualTo("LATE");
assertThat(assignment.getValue().getLockedAt()).isNotNull();
assertThat(assignment.getValue().getLockReason()).isEqualTo("CUTOFF");
}
@Test
@DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量")
void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() {
@@ -330,7 +432,42 @@ class MeasurementReviewServiceTest {
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500")));
return new Fixture(service, repository, idempotency, order, measurement);
return new Fixture(service, repository, idempotency, sailingFacts, sailingCalendar, order, measurement);
}
private SailingChannelEntity activeChannel() {
SailingChannelEntity channel = new SailingChannelEntity();
channel.setChannelCode("MATSON");
channel.setChannelName("美森正班");
channel.setStatus("ACTIVE");
channel.setDefaultDeparturePortCode("NINGBO");
channel.setDefaultDestinationPortCode("LGB");
return channel;
}
private SailingInboundVolumeForecastEntity forecast(long id, String key, BigDecimal volume) {
SailingInboundVolumeForecastEntity value = new SailingInboundVolumeForecastEntity();
value.setId(id); value.setTenantId(10001L); value.setForecastKey(key); value.setFactVersion(1);
value.setInboundId(10L); value.setReceivingWarehouseId(88L); value.setChannelCode("MATSON");
value.setDeparturePortCode("NINGBO"); value.setDestinationPortCode("LGB");
value.setExpectedArrivalAt(LocalDateTime.of(2026, 9, 20, 10, 0));
value.setForecastVolumeCbm(volume); value.setSourceType("OPERATIONS"); value.setFactStatus("VALID");
return value;
}
private SailingAssignmentEntity forecastAssignment(long forecastId, long sailingId) {
SailingAssignmentEntity assignment = new SailingAssignmentEntity();
assignment.setId(forecastId + 1000); assignment.setTenantId(10001L); assignment.setForecastId(forecastId);
assignment.setSailingId(sailingId); assignment.setAssignmentVersion(1); assignment.setMatchStatus("FORECAST_MATCHED");
return assignment;
}
private SailingEntity openSailing(long id, LocalDateTime cutoff) {
SailingEntity sailing = new SailingEntity();
sailing.setId(id); sailing.setStatus("OPEN"); sailing.setWarehouseCutoffAt(cutoff);
sailing.setWarehouseId(88L); sailing.setChannelCodeSnapshot("MATSON");
sailing.setDeparturePortCode("NINGBO"); sailing.setDestinationPortCode("LGB");
return sailing;
}
private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) {
@@ -350,7 +487,8 @@ class MeasurementReviewServiceTest {
}
private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository,
InboundIdempotencyService idempotency, InboundOrderEntity order,
InboundIdempotencyService idempotency, SailingFactsRepository sailingFacts,
SailingCalendarRepository sailingCalendar, InboundOrderEntity order,
InboundMeasurementEntity measurement) {
}
}