From 465d26ba4218760baa16bbce268c71078e6c862c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 22:07:54 +0800 Subject: [PATCH] feat(firstleg): project sailing display metrics read-only --- .../mapper/SailingCalendarDisplayMapper.java | 82 ++++++++++ .../sailingcalendar/mapper/SailingMapper.java | 30 ++++ .../repository/SailingCalendarRepository.java | 19 +++ .../service/SailingCalendarService.java | 43 +++--- .../service/MeasurementReviewService.java | 106 ------------- .../service/MeasurementReviewServiceTest.java | 145 +----------------- 6 files changed, 154 insertions(+), 271 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java new file mode 100644 index 000000000..c16e5d571 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java @@ -0,0 +1,82 @@ +package com.baiyue.firstleg.sailingcalendar.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; + +/** + * 船期日历的只读展示投影。 + * + * 这里直接读取现有入库审核结果,不写入船期事实表,也不触发归班、改绑或智能排柜。 + * 主子入库单只统计未拆分主单或仍可用的余量子单,避免把父单和子单重复计入。 + */ +@Mapper +public interface SailingCalendarDisplayMapper { + @Select(""" + SELECT + COALESCE(SUM(r.quantity), 0) AS containerCount, + COALESCE(SUM(COALESCE(r.capacity_cbm, 0) * r.quantity), 0) AS capacityCbm + FROM fl_ocean_booking b + JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = b.id + WHERE b.status = 'RELEASED' + AND ( + EXISTS ( + SELECT 1 FROM fl_sailing_booking_link link + WHERE link.sailing_id = #{sailingId} + AND link.ocean_booking_id = b.id + AND link.link_status = 'ACTIVE' + ) + OR ( + b.warehouse_id = #{warehouseId} + AND b.channel_code = #{channelCode} + AND b.departure_port_code = #{departurePortCode} + AND b.destination_port_code = #{destinationPortCode} + AND b.vessel_name = #{vesselName} + AND b.voyage_no = #{voyageNo} + AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt})) + ) + ) + """) + BookingSummary bookingSummary(@Param("sailingId") long sailingId, + @Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("vesselName") String vesselName, + @Param("voyageNo") String voyageNo, + @Param("etdAt") java.time.LocalDateTime etdAt); + + @Select(""" + SELECT + COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm, + COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm, + COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount + FROM ( + SELECT + COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at, + COALESCE(o.actual_volume_cbm, 0) AS volume_cbm + FROM fl_inbound_order o + WHERE o.deleted = 0 + AND o.measure_review_status = 'APPROVED' + AND o.warehouse_id = #{warehouseId} + AND o.channel_code = #{channelCode} + AND ( + (o.inbound_role = 'MASTER' AND COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT') + OR (o.inbound_role = 'CHILD' AND o.split_purpose = 'REMAINDER' AND o.split_status = 'NOT_SPLIT') + ) + AND o.actual_volume_cbm IS NOT NULL + AND (#{fromAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) > #{fromAt}) + AND (#{toAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{toAt}) + ) received + """) + ReceiptSummary receiptSummary(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("cutoffAt") java.time.LocalDateTime cutoffAt, + @Param("fromAt") java.time.LocalDateTime fromAt, + @Param("toAt") java.time.LocalDateTime toAt); + + record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {} + record BookingSummary(Integer containerCount, BigDecimal capacityCbm) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java index 9649b1259..59e5b96f1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java @@ -57,6 +57,36 @@ public interface SailingMapper extends BaseMapper { @Param("destinationPortCode") String destinationPortCode, @Param("from") java.time.LocalDateTime from); + @Select(""" + SELECT warehouse_cutoff_at + FROM fl_sailing + WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode} + AND departure_port_code=#{departurePortCode} AND destination_port_code=#{destinationPortCode} + AND status <> 'CANCELLED' AND warehouse_cutoff_at < #{cutoffAt} + ORDER BY warehouse_cutoff_at DESC, id DESC + LIMIT 1 + """) + java.time.LocalDateTime selectPreviousCutoff(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("cutoffAt") java.time.LocalDateTime cutoffAt); + + @Select(""" + SELECT warehouse_cutoff_at + FROM fl_sailing + WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode} + AND departure_port_code=#{departurePortCode} AND destination_port_code=#{destinationPortCode} + AND status <> 'CANCELLED' AND warehouse_cutoff_at > #{cutoffAt} + ORDER BY warehouse_cutoff_at ASC, id ASC + LIMIT 1 + """) + java.time.LocalDateTime selectNextCutoff(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("cutoffAt") java.time.LocalDateTime cutoffAt); + @Update(""" UPDATE fl_sailing SET channel_id=#{value.channelId},channel_code_snapshot=#{value.channelCodeSnapshot}, channel_name_snapshot=#{value.channelNameSnapshot},warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java index 341be2ea1..00c9b817e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java @@ -14,6 +14,7 @@ import com.baiyue.firstleg.sailingcalendar.mapper.SailingChannelMapper; import com.baiyue.firstleg.sailingcalendar.mapper.SailingHistoryMapper; import com.baiyue.firstleg.sailingcalendar.mapper.SailingMapper; import com.baiyue.firstleg.sailingcalendar.mapper.SailingTemplateMapper; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; @@ -36,6 +37,7 @@ public class SailingCalendarRepository { @Resource private SailingMapper sailings; @Resource private SailingFactsRepository facts; @Resource private OceanBookingRepository oceanBookings; + @Resource private SailingCalendarDisplayMapper display; public List channels(ChannelQuery query) { return channels.selectChannels(query); } public SailingChannelEntity channel(long id) { return channels.selectById(id); } @@ -67,6 +69,14 @@ public class SailingCalendarRepository { public List openSailingsFrom(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) { return sailings.selectOpenSailingsFrom(warehouseId, channelCode, departurePortCode, destinationPortCode, from); } + public LocalDateTime previousCutoff(SailingEntity sailing) { + return sailings.selectPreviousCutoff(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt()); + } + public LocalDateTime nextCutoff(SailingEntity sailing) { + return sailings.selectNextCutoff(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt()); + } public SailingEntity sailing(long id) { return sailings.selectById(id); } public SailingEntity sailingByGeneratedKey(String key) { return sailings.selectOne(new LambdaQueryWrapper().eq(SailingEntity::getGeneratedKey, key)); @@ -109,4 +119,13 @@ public class SailingCalendarRepository { } public OceanBookingEntity booking(long id) { return oceanBookings.find(id); } public List requirements(long id) { return oceanBookings.requirements(id); } + public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing) { + return display.receiptSummary(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getWarehouseCutoffAt(), previousCutoff(sailing), nextCutoff(sailing)); + } + public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing) { + return display.bookingSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getVesselName(), + sailing.getVoyageNo(), sailing.getEtdAt()); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java index fae1dd1b9..cda06b6f8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -29,6 +29,7 @@ import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.common.core.container.ContainerSpecificationCatalog; @@ -389,16 +390,15 @@ public class SailingCalendarService { } private CalendarItem item(SailingEntity v) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v)); } private VolumeSummary summary(SailingEntity v) { - BigDecimal actual = BigDecimal.ZERO, eligibleActual = BigDecimal.ZERO, lateActual = BigDecimal.ZERO, forecast = BigDecimal.ZERO, risk = BigDecimal.ZERO; int late = 0; - for (SailingAssignmentEntity a : repository.assignments(v.getId())) { - if ("RECEIVED".equals(a.getMatchStatus()) || "LATE".equals(a.getMatchStatus())) actual = actual.add(nvl(a.getVolumeCbm())); - if ("RECEIVED".equals(a.getMatchStatus())) eligibleActual = eligibleActual.add(nvl(a.getVolumeCbm())); - if ("LATE".equals(a.getMatchStatus())) lateActual = lateActual.add(nvl(a.getVolumeCbm())); - if ("FORECAST_MATCHED".equals(a.getMatchStatus())) forecast = forecast.add(nvl(a.getForecastVolumeCbm())); - if ("UNMATCHED".equals(a.getMatchStatus()) || "LATE".equals(a.getMatchStatus())) risk = risk.add(nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm()))); - if ("LATE".equals(a.getMatchStatus())) late++; - } - BookingTotals bookedTotals = bookingTotals(v.getId()); + SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v); + BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm()); + BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm()); + BigDecimal actual = eligibleActual.add(lateActual); + BigDecimal forecast = BigDecimal.ZERO; + // 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。 + BigDecimal risk = BigDecimal.ZERO; + int late = received == null || received.lateCount() == null ? 0 : received.lateCount(); + BookingTotals bookedTotals = bookingTotals(v); BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = catchableVolume(v.getId()); boolean over = capacity != null && catchable.compareTo(capacity) > 0; int recommended = recommendedContainers(catchable); @@ -413,19 +413,16 @@ public class SailingCalendarService { .findFirst() .orElseThrow(() -> new IllegalStateException("默认柜型未配置")); } - private BigDecimal catchableVolume(long sailingId) { return repository.assignments(sailingId).stream().map(a -> "RECEIVED".equals(a.getMatchStatus()) ? nvl(a.getVolumeCbm()) : "FORECAST_MATCHED".equals(a.getMatchStatus()) ? nvl(a.getForecastVolumeCbm()) : BigDecimal.ZERO).reduce(BigDecimal.ZERO, BigDecimal::add); } - private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(sailingId).capacityCbm(); } - private BookingTotals bookingTotals(long sailingId) { - int count = 0; BigDecimal capacity = BigDecimal.ZERO; - for (SailingBookingLinkEntity link : repository.bookingLinks(sailingId)) { - OceanBookingEntity booking = repository.booking(link.getOceanBookingId()); - if (booking == null || !"RELEASED".equals(booking.getStatus())) continue; - for (OceanBookingContainerRequirementEntity r : repository.requirements(link.getOceanBookingId())) { - int quantity = r.getQuantity() == null ? 0 : r.getQuantity(); count += quantity; - if (r.getCapacityCbm() != null) capacity = capacity.add(r.getCapacityCbm().multiply(BigDecimal.valueOf(quantity))); - } - } - return new BookingTotals(count, capacity); + private BigDecimal catchableVolume(long sailingId) { + SailingEntity sailing = requiredSailing(sailingId); + SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(sailing); + return received == null ? BigDecimal.ZERO : nvl(received.catchableCbm()); + } + private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(requiredSailing(sailingId)).capacityCbm(); } + private BookingTotals bookingTotals(SailingEntity sailing) { + SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing); + return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(), + value == null ? BigDecimal.ZERO : nvl(value.capacityCbm())); } private record BookingTotals(int containerCount, BigDecimal capacityCbm) {} private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); } 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 5cc2b5d99..399d0c584 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -13,21 +13,12 @@ import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity; -import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; -import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; -import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity; -import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; -import com.baiyue.firstleg.sailingcalendar.fact.repository.SailingFactsRepository; -import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.math.BigDecimal; -import java.util.List; /** * 测量审核应用服务。审核通过后直接进入 WAIT_SPLIT,费用初算和人工维护不阻塞主流程。 @@ -44,10 +35,6 @@ public class MeasurementReviewService { private InboundProperties properties; @Resource private DropshipPalletizationService dropshipPalletization; - @Resource - private SailingFactsRepository sailingFacts; - @Resource - private SailingCalendarRepository sailingCalendar; @Transactional public void approve(long tenantId, long operatorId, String operatorName, long inboundId, @@ -163,102 +150,9 @@ public class MeasurementReviewService { repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", approved ? approvedStatus : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); - if (approved) { - recordSailingReceiptFact(order, measurement, operatorId); - } idempotency.complete(context, inboundId); } - private void recordSailingReceiptFact(InboundOrderEntity order, InboundMeasurementEntity measurement, long operatorId) { - // measuredAt is the warehouse receiving batch timestamp captured by the PDA/API. - // Review time and CURRENT_TIMESTAMP are not valid substitutes because they can - // move a batch across a sailing cutoff. - LocalDateTime receivedAt = measurement.getMeasuredAt(); - if (receivedAt == null) { - throw new BizException(ResultCode.VALIDATION_FAILED, "收货批次缺少实际到仓时间,不能生成船期实收事实"); - } - SailingReceiptBatchFactEntity fact = new SailingReceiptBatchFactEntity(); - fact.setId(IdWorker.getId()); fact.setTenantId(order.getTenantId()); - 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); - // 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, actualVolume); - boolean ambiguousForecast = forecast == null && forecasts.size() > 1; - SailingAssignmentEntity forecastAssignment = forecast == null - ? null : sailingFacts.currentAssignmentForForecast(order.getTenantId(), forecast.getId()); - if (forecast != null) { - sailingFacts.supersedeForecastAssignments(order.getTenantId(), forecast.getId(), now); - if (sailingFacts.consumeForecast(order.getTenantId(), forecast.getId(), now) != 1) { - throw new BizException(ResultCode.CONFLICT, "预报批次已被其他收货事件消费,请刷新后重试"); - } - 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()); - residual.setFactVersion((forecast.getFactVersion() == null ? 1 : forecast.getFactVersion()) + 1); residual.setInboundId(forecast.getInboundId()); - residual.setReceivingWarehouseId(forecast.getReceivingWarehouseId()); residual.setChannelCode(forecast.getChannelCode()); residual.setChannelName(forecast.getChannelName()); - residual.setDeparturePortCode(forecast.getDeparturePortCode()); residual.setDestinationPortCode(forecast.getDestinationPortCode()); residual.setExpectedArrivalAt(forecast.getExpectedArrivalAt()); - residual.setForecastVolumeCbm(remaining); residual.setSourceType(forecast.getSourceType()); residual.setFactStatus("VALID"); residual.setEffectiveAt(now); - residual.setCreatedBy(operatorId); residual.setCreatedAt(now); residual.setUpdatedAt(now); sailingFacts.insertForecast(residual); - List residualCandidates = residual.getDeparturePortCode() != null && residual.getDestinationPortCode() != null - ? sailingCalendar.futureCandidates(residual.getReceivingWarehouseId(), residual.getChannelCode(), residual.getDeparturePortCode(), residual.getDestinationPortCode(), residual.getExpectedArrivalAt()) - : List.of(); - SailingAssignmentEntity residualAssignment = new SailingAssignmentEntity(); residualAssignment.setId(IdWorker.getId()); residualAssignment.setTenantId(residual.getTenantId()); - residualAssignment.setSailingId(residualCandidates.isEmpty() ? null : residualCandidates.get(0).getId()); residualAssignment.setInboundId(residual.getInboundId()); residualAssignment.setInboundCode(order.getInboundCode()); residualAssignment.setForecastId(residual.getId()); residualAssignment.setAssignmentSource("AUTO"); residualAssignment.setAssignmentVersion(1); - residualAssignment.setMatchStatus(residualCandidates.isEmpty() ? "UNMATCHED" : "FORECAST_MATCHED"); residualAssignment.setForecastVolumeCbm(remaining); residualAssignment.setRouteSource("FORECAST_FACT"); residualAssignment.setVolumeSource(residual.getSourceType()); residualAssignment.setRuleVersion("V1_CUTOFF_3D_18"); residualAssignment.setMatchedAt(now); residualAssignment.setReason(residualCandidates.isEmpty() ? "NO_MATCHING_OPEN_SAILING" : "RESIDUAL_FORECAST"); residualAssignment.setOperatorId(operatorId); residualAssignment.setCreatedAt(now); sailingFacts.insertAssignment(residualAssignment); - } - } - // A receipt that already had a forecast keeps that forecast's sailing anchor even when - // the warehouse cutoff has passed; only a first-time receipt searches the next open sailing. - SailingEntity sailing = forecastAssignment == null || forecastAssignment.getSailingId() == null - ? null : sailingCalendar.sailing(forecastAssignment.getSailingId()); - if (sailing != null && "CANCELLED".equals(sailing.getStatus())) sailing = null; - if (sailing == null && !ambiguousForecast && fact.getDeparturePortCode() != null && fact.getDestinationPortCode() != null) { - List candidates = sailingCalendar.openSailingsFrom(order.getWarehouseId(), order.getChannelCode(), - fact.getDeparturePortCode(), fact.getDestinationPortCode(), receivedAt); - sailing = candidates.isEmpty() ? null : candidates.get(0); - } - 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(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); - sailingFacts.insertAssignment(assignment); - } - - private SailingInboundVolumeForecastEntity selectForecastForReceipt(List forecasts, - InboundMeasurementEntity measurement, - BigDecimal actualVolume) { - if (forecasts.isEmpty()) return null; - String explicitKey = measurement.getExternalEventNo(); - if (explicitKey != null && explicitKey.startsWith("SAILING_FORECAST:")) { - String key = explicitKey.substring("SAILING_FORECAST:".length()).trim(); - for (SailingInboundVolumeForecastEntity forecast : forecasts) { - if (key.equals(forecast.getForecastKey())) return forecast; - } - return null; - } - if (forecasts.size() == 1) return forecasts.get(0); - // 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 public void exceptionRelease(long tenantId, long operatorId, String operatorName, long inboundId, String idempotencyKey, ExceptionReleaseRequest request) { 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 a8f2b8c77..2a6c71181 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -12,12 +12,6 @@ import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; 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; import org.junit.jupiter.api.Test; @@ -55,8 +49,6 @@ class MeasurementReviewServiceTest { @MockBean private InboundIdempotencyService idempotency; @MockBean private InboundProperties properties; @MockBean private DropshipPalletizationService dropshipPalletization; - @MockBean private SailingFactsRepository sailingFacts; - @MockBean private SailingCalendarRepository sailingCalendar; @Resource private MeasurementReviewService service; @Test @@ -110,102 +102,6 @@ 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("审核通过使用订单权威货物总重量,不依赖箱规重量") @@ -432,42 +328,7 @@ class MeasurementReviewServiceTest { confirmedWeight("357.500", "349.500"), confirmedWeight("357.500", "349.500"), confirmedWeight("357.500", "349.500"))); - 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; + return new Fixture(service, repository, idempotency, order, measurement); } private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) { @@ -487,8 +348,8 @@ class MeasurementReviewServiceTest { } private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository, - InboundIdempotencyService idempotency, SailingFactsRepository sailingFacts, - SailingCalendarRepository sailingCalendar, InboundOrderEntity order, + InboundIdempotencyService idempotency, InboundOrderEntity order, InboundMeasurementEntity measurement) { } } +