feat(firstleg): project sailing display metrics read-only
This commit is contained in:
+82
@@ -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) {}
|
||||
}
|
||||
@@ -57,6 +57,36 @@ public interface SailingMapper extends BaseMapper<SailingEntity> {
|
||||
@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},
|
||||
|
||||
+19
@@ -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<SailingChannelEntity> 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<SailingEntity> 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<SailingEntity>().eq(SailingEntity::getGeneratedKey, key));
|
||||
@@ -109,4 +119,13 @@ public class SailingCalendarRepository {
|
||||
}
|
||||
public OceanBookingEntity booking(long id) { return oceanBookings.find(id); }
|
||||
public List<OceanBookingContainerRequirementEntity> 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());
|
||||
}
|
||||
}
|
||||
|
||||
+20
-23
@@ -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(); }
|
||||
|
||||
@@ -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<SailingInboundVolumeForecastEntity> 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<SailingEntity> 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<SailingEntity> 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<SailingInboundVolumeForecastEntity> 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) {
|
||||
|
||||
+3
-142
@@ -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<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("审核通过使用订单权威货物总重量,不依赖箱规重量")
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user