fix(firstleg): preserve sailing forecast batch facts
This commit is contained in:
+140
-2
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user