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