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
@@ -182,14 +182,18 @@ public class MeasurementReviewService {
fact.setReceiptBatchKey("INBOUND-" + order.getId() + "-MEASUREMENT-" + measurement.getId()); fact.setFactVersion(1);
fact.setInboundId(order.getId()); fact.setMeasurementId(measurement.getId()); fact.setReceivingWarehouseId(order.getWarehouseId());
fact.setChannelCode(order.getChannelCode()); fact.setChannelName(order.getChannelName()); fact.setReceivedAt(receivedAt);
fact.setActualVolumeCbm(order.getActualVolumeCbm()); fact.setFactStatus("APPROVED"); fact.setSourceType("FIRSTLEG_MEASUREMENT_REVIEW");
// The reviewed measurement is the stable receipt-batch volume. The inbound
// aggregate is updated by the measurement workflow too, but must not be used
// here because split/child inbounds can be reviewed independently.
BigDecimal actualVolume = measurement.getTotalVolumeCbm();
fact.setActualVolumeCbm(actualVolume); fact.setFactStatus("APPROVED"); fact.setSourceType("FIRSTLEG_MEASUREMENT_REVIEW");
LocalDateTime now = LocalDateTime.now();
fact.setCreatedBy(operatorId); fact.setCreatedAt(now); fact.setUpdatedAt(now);
SailingChannelEntity channel = order.getChannelCode() == null ? null : sailingCalendar.channelByCode(order.getChannelCode().trim().toUpperCase());
if (channel != null) { fact.setDeparturePortCode(channel.getDefaultDeparturePortCode()); fact.setDestinationPortCode(channel.getDefaultDestinationPortCode()); }
sailingFacts.insertReceiptFact(fact);
List<SailingInboundVolumeForecastEntity> forecasts = sailingFacts.currentForecasts(order.getTenantId(), order.getId());
SailingInboundVolumeForecastEntity forecast = selectForecastForReceipt(forecasts, measurement, order.getActualVolumeCbm());
SailingInboundVolumeForecastEntity forecast = selectForecastForReceipt(forecasts, measurement, actualVolume);
boolean ambiguousForecast = forecast == null && forecasts.size() > 1;
SailingAssignmentEntity forecastAssignment = forecast == null
? null : sailingFacts.currentAssignmentForForecast(order.getTenantId(), forecast.getId());
@@ -198,7 +202,7 @@ public class MeasurementReviewService {
if (sailingFacts.consumeForecast(order.getTenantId(), forecast.getId(), now) != 1) {
throw new BizException(ResultCode.CONFLICT, "预报批次已被其他收货事件消费,请刷新后重试");
}
BigDecimal remaining = forecast.getForecastVolumeCbm().subtract(order.getActualVolumeCbm());
BigDecimal remaining = forecast.getForecastVolumeCbm().subtract(actualVolume);
if (remaining.signum() > 0) {
SailingInboundVolumeForecastEntity residual = new SailingInboundVolumeForecastEntity();
residual.setId(IdWorker.getId()); residual.setTenantId(forecast.getTenantId()); residual.setForecastKey(forecast.getForecastKey());
@@ -228,7 +232,7 @@ public class MeasurementReviewService {
SailingAssignmentEntity assignment = new SailingAssignmentEntity(); assignment.setId(IdWorker.getId()); assignment.setTenantId(order.getTenantId());
assignment.setSailingId(sailing == null ? null : sailing.getId()); assignment.setInboundId(order.getId()); assignment.setInboundCode(order.getInboundCode()); assignment.setReceiptBatchId(fact.getId()); assignment.setForecastId(forecast == null ? null : forecast.getId()); assignment.setAssignmentSource("AUTO"); assignment.setAssignmentVersion(forecastAssignment == null || forecastAssignment.getAssignmentVersion() == null ? 1 : forecastAssignment.getAssignmentVersion() + 1);
boolean late = sailing != null && receivedAt.isAfter(sailing.getWarehouseCutoffAt());
assignment.setMatchStatus(sailing == null ? "UNMATCHED" : late ? "LATE" : "RECEIVED"); assignment.setVolumeCbm(order.getActualVolumeCbm());
assignment.setMatchStatus(sailing == null ? "UNMATCHED" : late ? "LATE" : "RECEIVED"); assignment.setVolumeCbm(actualVolume);
assignment.setRouteSource(channel == null ? "INBOUND_FACT" : "CHANNEL_DEFAULT"); assignment.setVolumeSource("MEASURED_ACTUAL"); assignment.setRuleVersion("V1_CUTOFF_3D_18");
assignment.setMatchedAt(now); assignment.setLockedAt(late ? now : null);
assignment.setLockReason(late ? "CUTOFF" : null); assignment.setReason(ambiguousForecast ? "MULTI_FORECAST_REVIEW_REQUIRED" : sailing == null ? "NO_MATCHING_OPEN_SAILING" : forecast != null ? "FORECAST_REPLACED_BY_RECEIPT" : null); assignment.setOperatorId(operatorId); assignment.setCreatedAt(now);
@@ -248,14 +252,11 @@ public class MeasurementReviewService {
return null;
}
if (forecasts.size() == 1) return forecasts.get(0);
SailingInboundVolumeForecastEntity exact = null;
for (SailingInboundVolumeForecastEntity forecast : forecasts) {
if (forecast.getForecastVolumeCbm() != null && actualVolume != null && forecast.getForecastVolumeCbm().compareTo(actualVolume) == 0) {
if (exact != null) return null;
exact = forecast;
}
}
return exact;
// Several valid forecasts represent separate physical batches. Volume is
// not a stable batch key (two batches can have the same CBM, and a partial
// receipt can differ from its forecast), so leave the receipt unmatched
// until the caller supplies the explicit forecast key.
return null;
}
@Transactional
@@ -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) {
}
}