diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 4515a15ff..5cc2b5d99 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -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 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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 03167652e..a8f2b8c77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -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 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 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 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 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) { } }