fix(firstleg): 修正船期货量顺延与订柜建议

This commit is contained in:
2026-10-27 16:56:20 +08:00
parent ed6a173b86
commit ab457e022d
7 changed files with 703 additions and 108 deletions
@@ -204,13 +204,15 @@ public final class SailingCalendarDtos {
int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm,
BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm,
BigDecimal availableBookedCapacityCbm) {}
public record CalendarResult(String month, String timezone, List<CalendarItem> records,
Summary summary, FilterOptions filterOptions) {
Summary summary, FilterOptions filterOptions, LocalDateTime nextRefreshAt) {
public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm,
int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm,
BigDecimal availableBookedCapacityCbm) {}
public record FilterOptions(List<Option> warehouses, List<Option> channels, List<Option> departurePorts, List<Option> destinationPorts) {}
public record Option(String value, String label) {}
}
@@ -1,83 +1,169 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 船期日历的只读展示投影。
*
* 这里直接读取现有入库审核结果,不写入船期事实表,也不触发归班、改绑或智能排柜。
* 主子入库单只统计未拆分主单或仍可用的余量子单,避免把父单和子单重复计入。
*/
/** 船期日历的只读投影;保留有效运输安排,未安排余量自然顺延,不改写业务归属。 */
@Mapper
public interface SailingCalendarDisplayMapper {
// 显式关联优先;无显式关联才按运输信息匹配。每条订舱只对应一个有效船期,
// 已确认货物允许保留在已截仓但尚未过期的船期,不能因为截仓就再次建议订柜。
String BOOKING_TARGETS = """
SELECT b.id AS booking_id, (
SELECT s.id FROM fl_sailing s
WHERE s.etd_at >= #{todayStart} AND s.status IN ('OPEN', 'CLOSED')
AND (
EXISTS (SELECT 1 FROM fl_sailing_booking_link link
WHERE link.sailing_id = s.id AND link.ocean_booking_id = b.id
AND link.link_status = 'ACTIVE')
OR (
NOT EXISTS (SELECT 1 FROM fl_sailing_booking_link link
WHERE link.ocean_booking_id = b.id AND link.link_status = 'ACTIVE')
AND b.warehouse_id = s.warehouse_id AND b.channel_code = s.channel_code_snapshot
AND b.departure_port_code = s.departure_port_code
AND b.destination_port_code = s.destination_port_code
AND b.vessel_name IS NOT NULL AND b.voyage_no IS NOT NULL
AND b.vessel_name = s.vessel_name AND b.voyage_no = s.voyage_no
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(s.etd_at))
)
)
ORDER BY s.etd_at, s.sailing_no, s.id LIMIT 1
) AS sailing_id
FROM fl_ocean_booking b WHERE b.status = 'RELEASED'
""";
// 普通箱分配可能在测量前创建,体积必须按最新已审核箱规重算;全量分配恰好等于实测,
// 按累计箱数分摊四位小数尾差,避免余下极小体积被向上取整为额外一柜。
// 已封托整托的体积是现场物理托盘快照,仅主明细保存体积,不能按多 SKU 行重复分摊。
String MEASURED_PLAN_ITEMS = """
SELECT quantities.id, quantities.loading_plan_id, quantities.account_id, quantities.inbound_id,
CASE WHEN quantities.unit_type = 'SEALED_PALLET' THEN quantities.pallet_volume_cbm
ELSE ROUND(quantities.measured_cbm * quantities.cumulative_boxes
/ NULLIF(GREATEST(quantities.measured_boxes, quantities.allocated_boxes), 0), 4)
- ROUND(quantities.measured_cbm * (quantities.cumulative_boxes - quantities.package_count)
/ NULLIF(GREATEST(quantities.measured_boxes, quantities.allocated_boxes), 0), 4)
END AS volume_cbm
FROM (
SELECT item.id, item.loading_plan_id, item.account_id, item.inbound_id, item.unit_type,
item.package_count, item.volume_cbm AS pallet_volume_cbm,
measured.package_count AS measured_boxes, measured.volume_cbm AS measured_cbm,
SUM(item.package_count) OVER (
PARTITION BY item.account_id, item.inbound_id, item.inbound_detail_id, item.unit_type
) AS allocated_boxes,
SUM(item.package_count) OVER (
PARTITION BY item.account_id, item.inbound_id, item.inbound_detail_id, item.unit_type
ORDER BY item.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_boxes
FROM fl_loading_plan_item item
JOIN fl_inbound_measurement batch
ON batch.tenant_id = item.account_id AND batch.inbound_id = item.inbound_id
AND batch.status = 'APPROVED'
AND batch.id = (SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = item.account_id AND latest.inbound_id = item.inbound_id
AND latest.status = 'APPROVED')
LEFT JOIN (
SELECT tenant_id, measurement_id, inbound_detail_id,
SUM(package_count) AS package_count, SUM(total_volume_cbm) AS volume_cbm
FROM fl_inbound_measurement_item WHERE package_count > 0
GROUP BY tenant_id, measurement_id, inbound_detail_id
) measured ON measured.tenant_id = item.account_id AND measured.measurement_id = batch.id
AND measured.inbound_detail_id = item.inbound_detail_id
WHERE item.deleted = 0
) quantities
""";
/** 已订柜总数仍展示原订舱需求,可用容量按柜扣除已确认离仓部分,避免余货少订。 */
@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 (#{vesselName} IS NOT NULL AND #{voyageNo} IS NOT NULL
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})))
)
)
SELECT COALESCE(SUM(r.quantity), 0) AS containerCount,
COALESCE(SUM(r.capacity_cbm * r.quantity), 0) AS capacityCbm,
COALESCE(SUM(r.capacity_cbm * GREATEST(r.quantity - COALESCE(d.departed_count, 0), 0)), 0) AS availableCapacityCbm
FROM (
""" + BOOKING_TARGETS + """
) target
JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = target.booking_id
LEFT JOIN (
SELECT allocation.ocean_booking_id, allocation.requirement_id,
COUNT(DISTINCT allocation.container_booking_id) AS departed_count
FROM fl_ocean_booking_container_allocation allocation
WHERE EXISTS (SELECT 1 FROM fl_outbound_dispatch dispatch
WHERE dispatch.container_booking_id = allocation.container_booking_id
AND dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL)
GROUP BY allocation.ocean_booking_id, allocation.requirement_id
) d ON d.ocean_booking_id = r.ocean_booking_id AND d.requirement_id = r.id
WHERE target.sailing_id = #{sailingId}
""")
BookingSummary bookingSummary(@Param("sailingId") long sailingId,
BookingSummary bookingSummary(@Param("sailingId") long sailingId, @Param("todayStart") LocalDateTime todayStart);
/** 按审核实测重算普通箱分配,再区分已离仓、有效订舱占用和未安排余量,保持体积守恒。 */
@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(allocated.current_cbm, 0) + CASE WHEN #{sailingId} = (
SELECT next_sailing.id FROM fl_sailing next_sailing
WHERE next_sailing.warehouse_id = o.warehouse_id
AND next_sailing.channel_code_snapshot = o.channel_code
AND next_sailing.status = 'OPEN'
AND next_sailing.etd_at >= #{todayStart}
AND next_sailing.warehouse_cutoff_at >= #{asOf}
ORDER BY next_sailing.etd_at, next_sailing.sailing_no, next_sailing.id LIMIT 1
) THEN GREATEST(o.actual_volume_cbm - COALESCE(allocated.reserved_cbm, 0), 0) ELSE 0 END AS volume_cbm
FROM fl_inbound_order o
LEFT JOIN (
SELECT item.account_id, item.inbound_id,
SUM(CASE WHEN (dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL)
OR target.sailing_id IS NOT NULL THEN item.volume_cbm ELSE 0 END) AS reserved_cbm,
SUM(CASE WHEN dispatch.status = 'WAITING_FOR_LOADING' AND dispatch.confirmed_at IS NOT NULL THEN 0
WHEN target.sailing_id = #{sailingId} THEN item.volume_cbm ELSE 0 END) AS current_cbm
FROM (
""" + MEASURED_PLAN_ITEMS + """
) item
JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id
JOIN fl_inbound_order source ON source.tenant_id = item.account_id AND source.id = item.inbound_id
LEFT JOIN fl_outbound_dispatch dispatch
ON dispatch.tenant_id = item.account_id AND dispatch.inbound_id = item.inbound_id
AND dispatch.container_booking_id = plan.container_booking_id
LEFT JOIN fl_ocean_booking_container_allocation allocation ON allocation.container_booking_id = plan.container_booking_id
LEFT JOIN (
""" + BOOKING_TARGETS + """
) target ON target.booking_id = allocation.ocean_booking_id
WHERE ((source.warehouse_id = #{warehouseId} AND source.channel_code = #{channelCode})
OR target.sailing_id = #{sailingId})
GROUP BY item.account_id, item.inbound_id
) allocated ON allocated.account_id = o.tenant_id AND allocated.inbound_id = o.id
WHERE o.deleted = 0 AND o.measure_review_status = 'APPROVED'
AND o.process_status IN ('WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED')
AND ((o.warehouse_id = #{warehouseId} AND o.channel_code = #{channelCode}) OR allocated.current_cbm > 0)
AND (COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT'
OR (o.split_status = 'FULLY_SPLIT' AND o.process_status IN ('LOADING', 'LOADED')))
AND o.actual_volume_cbm IS NOT NULL
AND COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{asOf}
) received WHERE volume_cbm > 0
""")
ReceiptSummary receiptSummary(@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);
@Param("cutoffAt") LocalDateTime cutoffAt,
@Param("asOf") LocalDateTime asOf,
@Param("todayStart") LocalDateTime todayStart);
/** 截仓节点跨月份、船名和港口筛选查询,避免隐藏的前班截仓后当前货量不刷新。 */
@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
SELECT MIN(warehouse_cutoff_at) FROM fl_sailing
WHERE status = 'OPEN' AND etd_at >= #{todayStart} AND warehouse_cutoff_at >= #{asOf}
AND (#{query.warehouseId} IS NULL OR warehouse_id = #{query.warehouseId})
AND (#{query.channelCode} IS NULL OR channel_code_snapshot = #{query.channelCode})
""")
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);
LocalDateTime nextCutoff(@Param("query") SailingQuery query, @Param("asOf") LocalDateTime asOf,
@Param("todayStart") LocalDateTime todayStart);
record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {}
record BookingSummary(Integer containerCount, BigDecimal capacityCbm) {}
record BookingSummary(Integer containerCount, BigDecimal capacityCbm, BigDecimal availableCapacityCbm) {}
}
@@ -132,13 +132,17 @@ 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.ReceiptSummary receiptSummary(SailingEntity sailing, LocalDateTime asOf) {
return display.receiptSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
sailing.getWarehouseCutoffAt(), asOf, asOf.toLocalDate().atStartOfDay());
}
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());
/** 订舱总需求和未离仓可用容量分别返回,使用与货量投影相同的自然日。 */
public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing, LocalDateTime asOf) {
return display.bookingSummary(sailing.getId(), asOf.toLocalDate().atStartOfDay());
}
/** 查找会影响当前仓库、渠道展示的下一截仓时刻,范围不受所选月份限制。 */
public LocalDateTime nextCutoff(SailingQuery query, LocalDateTime asOf) {
return display.nextCutoff(query, asOf, asOf.toLocalDate().atStartOfDay());
}
}
@@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -43,6 +44,7 @@ public class SailingCalendarService {
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private Clock clock = Clock.system(ZONE);
private static final String OPEN = "OPEN";
@Resource private SailingCalendarRepository repository;
@Resource private PriceBookQueryUseCase priceBooks;
@@ -93,12 +95,17 @@ public class SailingCalendarService {
return requiredTemplate(id);
}
/** 月历和汇总只纳入上海时区今天及之后开船的计划,历史计划按日期自然过期。 */
public CalendarResult calendar(SailingQuery query) {
LocalDate first;
try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); }
LocalDate next = first.plusMonths(1);
List<SailingEntity> values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay());
List<CalendarItem> items = values.stream().map(this::item).toList();
LocalDateTime asOf = LocalDateTime.now(clock.withZone(ZONE));
LocalDate today = asOf.toLocalDate();
LocalDate start = first.isBefore(today) ? today : first;
List<SailingEntity> values = start.isBefore(next)
? repository.calendar(query, start.atStartOfDay(), next.atStartOfDay()) : List.of();
List<CalendarItem> items = values.stream().map(value -> item(value, asOf)).toList();
BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
@@ -109,17 +116,23 @@ public class SailingCalendarService {
int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum();
BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived);
BigDecimal availableBookedCapacity = items.stream().map(v -> v.volumeSummary().availableBookedCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived, availableBookedCapacity);
LocalDateTime nextRefreshAt = today.plusDays(1).atStartOfDay();
LocalDateTime nextCutoff = start.isBefore(next) ? repository.nextCutoff(query, asOf) : null;
// 截止时间仍包含该毫秒,跨过后再刷新;隐藏月份和筛选之外的前班也参与调度。
if (nextCutoff != null && nextCutoff.plusNanos(1_000_000).isBefore(nextRefreshAt)) nextRefreshAt = nextCutoff.plusNanos(1_000_000);
return new CalendarResult(query.month(), ZONE.getId(), items, summary,
new CalendarResult.FilterOptions(
values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()));
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()), nextRefreshAt);
}
/** 单次详情使用同一上海业务时刻读取船期货量。 */
public SailingDetail detail(long id) {
SailingEntity value = requiredSailing(id);
return new SailingDetail(item(value), history(id));
return new SailingDetail(item(value, LocalDateTime.now(clock.withZone(ZONE))), history(id));
}
public List<SailingHistoryView> history(long id) {
requiredSailing(id);
@@ -321,6 +334,7 @@ public class SailingCalendarService {
}
recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId());
}
/** 返回当前未离仓货量投影及已有归属记录,不创建新归属。 */
public VolumeDetail volumeSummary(long id) {
SailingEntity value = requiredSailing(id);
List<AssignmentView> assignments = repository.assignments(id).stream().map(a -> {
@@ -328,20 +342,20 @@ public class SailingCalendarService {
? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion());
}).toList();
return new VolumeDetail(summary(value), assignments);
return new VolumeDetail(summary(value, LocalDateTime.now(clock.withZone(ZONE))), assignments);
}
/** 按全部已收和可核验预计货量向上取整,晚到量保留风险提示但不漏算订柜需求。 */
public BookingAdvice bookingAdvice(long id) {
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value);
BigDecimal catchable = catchableVolume(value.getId());
int recommended = recommendedContainers(catchable);
BigDecimal bookedCapacity = bookedPlanningCapacity(value.getId());
int pending = ceil(nvl(catchable.subtract(bookedCapacity)), defaultContainerCapacity());
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value, LocalDateTime.now(clock.withZone(ZONE)));
BigDecimal catchable = s.catchableReceivedCbm().add(s.catchableForecastCbm());
BigDecimal demand = s.actualReceivedCbm().add(s.catchableForecastCbm());
BigDecimal shortage = demand.subtract(s.availableBookedCapacityCbm());
List<BookingScenario> scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> {
int count = ceil(catchable, spec.capacityCbm());
int pendingForType = ceil(nvl(catchable.subtract(bookedCapacity)), spec.capacityCbm());
int count = ceil(demand, spec.capacityCbm());
int pendingForType = ceil(shortage, spec.capacityCbm());
return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType);
}).toList();
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), recommended, pending, scenarios, LocalDateTime.now(ZONE), s.catchableReceivedCbm(), s.lateReceivedCbm());
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), s.recommendedContainerCount(), s.pendingContainerCount(), scenarios, s.snapshotAt(), s.catchableReceivedCbm(), s.lateReceivedCbm());
}
@Transactional
public Map<String, Object> linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) {
@@ -410,9 +424,11 @@ public class SailingCalendarService {
private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) {
SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history);
}
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), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); }
private VolumeSummary summary(SailingEntity v) {
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v);
/** 整次月历共用查询时刻,避免跨过截止瞬间时把同一货量计入两班。 */
private CalendarItem item(SailingEntity v, LocalDateTime asOf) { 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, asOf), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); }
/** 统一日历、详情与订柜初算的货量和已订容量口径。 */
private VolumeSummary summary(SailingEntity v, LocalDateTime asOf) {
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v, asOf);
BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm());
BigDecimal actual = eligibleActual.add(lateActual);
@@ -420,12 +436,14 @@ public class SailingCalendarService {
// 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。
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());
BookingTotals bookedTotals = bookingTotals(v, asOf);
BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = eligibleActual.add(forecast);
boolean over = capacity != null && catchable.compareTo(capacity) > 0;
int recommended = recommendedContainers(catchable);
int pending = ceil(nvl(catchable.subtract(bookedTotals.capacityCbm())), defaultContainerCapacity());
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), LocalDateTime.now(ZONE), eligibleActual, lateActual);
// 截止时间仅拆分参考量与风险量;有实收货量就按统一柜型容量向上取整。
BigDecimal demand = actual.add(forecast);
int recommended = recommendedContainers(demand);
int pending = ceil(demand.subtract(bookedTotals.availableCapacityCbm()), defaultContainerCapacity());
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), asOf, eligibleActual, lateActual, bookedTotals.availableCapacityCbm());
}
private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); }
private static BigDecimal defaultContainerCapacity() {
@@ -435,18 +453,14 @@ public class SailingCalendarService {
.findFirst()
.orElseThrow(() -> new IllegalStateException("默认柜型未配置"));
}
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);
/** 已订总量用于展示,仍可承接货物的容量才参与待补柜扣减。 */
private BookingTotals bookingTotals(SailingEntity sailing, LocalDateTime asOf) {
SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing, asOf);
return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(),
value == null ? BigDecimal.ZERO : nvl(value.capacityCbm()));
value == null ? BigDecimal.ZERO : nvl(value.capacityCbm()),
value == null ? BigDecimal.ZERO : nvl(value.availableCapacityCbm()));
}
private record BookingTotals(int containerCount, BigDecimal capacityCbm) {}
private record BookingTotals(int containerCount, BigDecimal capacityCbm, BigDecimal availableCapacityCbm) {}
private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); }
private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); }
@@ -7,6 +7,7 @@ paths:
/api/platform/v1/sailings/calendar:
get:
summary: 查询船期月历
description: 按 Asia/Shanghai 自然日仅返回今天及之后开船的船期和同范围汇总。有效已放舱订舱对应的排柜货量保留在其 OPEN/CLOSED 船期,截仓后仍保留;未安排或原船期取消、日期过期的未离仓余量,计入同仓、同渠道最近一班 OPEN 且尚未截仓的船期,按开船时间、船期编号和 ID 稳定排序。只调整展示投影,不改写订舱或排柜归属。响应 nextRefreshAt 为上海本地时间,取下一零点和同仓渠道范围内最近截仓时刻之后 1 毫秒的较早值,截仓查询不受展示月份、船名和港口筛选限制。
x-permission: platform:firstleg:sailing-calendar:list
parameters:
- {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}}
@@ -17,7 +18,7 @@ paths:
- {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: status, in: query, schema: {type: string}}
responses: {'200': {description: 月历和按船期汇总}}
responses: {'200': {description: 月历和按船期汇总;nextRefreshAt 为下次自动刷新时间,volumeSummary 和 summary 的 availableBookedCapacityCbm 为扣除已离仓柜后的可用订舱容量,各船期 volumeSummary 的 bookedPlanningCapacityCbm 保留订舱总容量。}}
/api/platform/v1/sailings:
post:
summary: 新增单次船期
@@ -58,6 +59,7 @@ paths:
/api/platform/v1/sailings/{id}/booking-advice:
get:
summary: 查询只读订柜建议
description: 建议柜数按当前展示的全部待发实测与可核验预计体积除以统一柜型容量向上取整,正货量至少一柜;待补柜先扣除未离仓柜的可用订舱容量后再向上取整,下限为零。已离仓柜按所属柜型需求整柜扣除容量,同柜多票只扣一次。日历、汇总和柜型方案使用相同口径。
x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/booking-links:
@@ -0,0 +1,363 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class SailingCalendarDisplayMapperTest {
private JdbcTemplate jdbc;
private SqlSession session;
private SailingCalendarDisplayMapper mapper;
/** 使用隔离内存库执行实际 Mapper,覆盖顺延、离仓扣减和去重规则。 */
@BeforeEach
void setUp() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fl_sailing(id BIGINT, sailing_no VARCHAR, warehouse_id BIGINT, channel_code_snapshot VARCHAR, status VARCHAR, etd_at TIMESTAMP, warehouse_cutoff_at TIMESTAMP, departure_port_code VARCHAR, destination_port_code VARCHAR, vessel_name VARCHAR, voyage_no VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT, tenant_id BIGINT, deleted INT, measure_review_status VARCHAR, process_status VARCHAR, warehouse_id BIGINT, channel_code VARCHAR, split_status VARCHAR, actual_volume_cbm DECIMAL(18,6), inbounded_at TIMESTAMP, measure_completed_at TIMESTAMP, created_at TIMESTAMP, total_package_count INT)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT, container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT, loading_plan_id BIGINT, account_id BIGINT, inbound_id BIGINT, deleted INT, volume_cbm DECIMAL(18,6), inbound_detail_id BIGINT DEFAULT 1, package_count INT DEFAULT 0, unit_type VARCHAR DEFAULT 'PACKAGE')");
jdbc.execute("CREATE TABLE fl_inbound_measurement(id BIGINT, tenant_id BIGINT, inbound_id BIGINT, status VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT, tenant_id BIGINT, measurement_id BIGINT, inbound_detail_id BIGINT, package_count INT, total_volume_cbm DECIMAL(18,4))");
jdbc.execute("CREATE TABLE fl_outbound_dispatch(tenant_id BIGINT, inbound_id BIGINT, container_booking_id BIGINT, status VARCHAR, confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT,status VARCHAR,warehouse_id BIGINT,channel_code VARCHAR,departure_port_code VARCHAR,destination_port_code VARCHAR,vessel_name VARCHAR,voyage_no VARCHAR,estimated_sailing_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_requirement(id BIGINT,ocean_booking_id BIGINT,quantity INT,capacity_cbm DECIMAL(18,6))");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_allocation(ocean_booking_id BIGINT,requirement_id BIGINT,container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_sailing_booking_link(sailing_id BIGINT,ocean_booking_id BIGINT,link_status VARCHAR)");
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(SailingCalendarDisplayMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession();
mapper = session.getMapper(SailingCalendarDisplayMapper.class);
sailing(1, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
sailing(2, "2026-11-04T10:00:00", "2026-11-03T18:00:00", "OPEN");
}
/** 释放本测试的会话和内存表。 */
@AfterEach
void tearDown() {
session.close();
jdbc.execute("DROP ALL OBJECTS");
}
/** 测量前全票占位无论高估、低估或箱数调整,都只计入审核后的实测货量。 */
@ParameterizedTest
@CsvSource({"100,6000", "40,6000", "100,10000"})
void recomputesDeclaredAllocationFromApprovedMeasurement(String declaredVolume, int allocatedBoxes) {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_loading_plan_item SET volume_cbm=?,package_count=?", new java.math.BigDecimal(declaredVolume), allocatedBoxes);
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
assertThat(advice(2, "2026-10-27T12:00:00").recommendedContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
}
/** 不同规格按自己的实测单箱体积计算,避免用整票均值错误分配部分货量。 */
@Test
void computesPartialAllocationsUsingEachMeasuredSpecification() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3000 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=1000,total_volume_cbm=10 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(11,7,10,2,2000,50)");
allocate(1, 100, "5");
allocate(1, 200, "5");
jdbc.update("UPDATE fl_loading_plan_item SET inbound_detail_id=2 WHERE id=200");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("17.5");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,200,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("5");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
}
/** 三等分等循环小数的分摊总量仍等于实测,不产生额外余量补柜建议。 */
@Test
void conservesMeasuredVolumeAcrossRoundedAllocations() {
booking(2, 3, "68");
inbound(1, "1", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=3 WHERE id=10");
allocate(1, 100, "0.01");
allocate(1, 200, "0.01");
allocate(1, 300, "0.01");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("1");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("0.6667");
}
/** 只读取最新已审核批次,同明细多箱规累加,排除待审核批次及其他客户。 */
@Test
void usesOnlyTheLatestApprovedMeasurementForTheAllocationOwner() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=100 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(20,7,1,'APPROVED'),(30,7,1,'SUBMITTED'),(40,8,1,'APPROVED')");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(20,7,20,1,2000,10),(21,7,20,1,4000,50),(30,7,30,1,6000,999),(40,8,40,1,6000,999)");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 一件代发整托只使用主行的物理托盘体积,附加 SKU 行不能再次增加货量。 */
@Test
void keepsSealedPalletPhysicalVolumeWithoutDuplicatingSkuLines() {
booking(2, 1, "68");
inbound(1, "20", "LOADING", "NOT_SPLIT");
allocate(1, 100, "20");
jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET'");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,inbound_detail_id,package_count,unit_type) VALUES(200,100,7,1,0,0,2,2000,'SEALED_PALLET')");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("20");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 老货不按原收货窗口丢失,截止前只计入最近一班,截止后跨月顺延。 */
@Test
void rollsUnshippedBacklogForwardOnlyOnceAndAcrossMonths() {
inbound(1, "113.74", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(1, "2026-10-27T18:00:00").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-10-27T18:00:00").catchableCbm()).isZero();
assertThat(summary(1, "2026-10-27T18:00:01").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T18:00:01").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-11-02T12:00:00").catchableCbm()).isEqualByComparingTo("113.74");
}
/** 取消、截止和过期航次不承接货量;同一时间的两班也只选择一班。 */
@Test
void skipsIneligibleSailingsAndBreaksTiesDeterministically() {
inbound(1, "12", "WAIT_SPLIT", "NOT_SPLIT");
sailing(3, "2026-10-26T10:00:00", "2026-10-29T18:00:00", "OPEN");
sailing(4, "2026-10-27T15:00:00", "2026-10-27T14:00:00", "CANCELLED");
sailing(5, "2026-10-27T16:00:00", "2026-10-27T14:00:00", "CLOSED");
sailing(6, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("12");
for (long id : new long[]{2, 3, 4, 5, 6}) {
assertThat(summary(id, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
}
/** 实际离仓才扣减,部分离仓只扣该柜明细,撤销离仓后重新纳入。 */
@Test
void subtractsOnlyConfirmedDeparturesAndRestoresReversedVolume() {
inbound(1, "100", "LOADED", "NOT_SPLIT");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100),(20,200)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000),(2,20,7,1,0,60,6000),(3,10,8,1,0,999,99900),(4,10,7,1,1,999,99900)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00'),(7,1,200,'READY_FOR_DISPATCH',NULL),(8,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH' WHERE tenant_id=7 AND container_booking_id=100");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_at='2026-10-27 11:00:00' WHERE tenant_id=7");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 拣货结算后的源单和余量已各自保存实测体积,保留未离仓部分并排除失效数据。 */
@Test
void includesLoadedSplitSourceAndRemainderWithoutCountingInvalidReceipts() {
inbound(1, "40", "LOADED", "FULLY_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
inbound(3, "100", "WAIT_SPLIT", "PARTIALLY_SPLIT");
inbound(4, "999", "DELIVERED", "NOT_SPLIT");
inbound(5, "999", "CANCELLED", "NOT_SPLIT");
inbound(6, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(7, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(8, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(9, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(10, "999", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99 WHERE id=6");
jdbc.update("UPDATE fl_inbound_order SET channel_code='OTHER' WHERE id=7");
jdbc.update("UPDATE fl_inbound_order SET deleted=1 WHERE id=8");
jdbc.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=9");
jdbc.update("UPDATE fl_inbound_order SET inbounded_at='2026-10-28 10:00:00' WHERE id=10");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 无可赶上的船期时不把货量展示在已截止航次,新建有效船期后可再次承接。 */
@Test
void waitsForANewEligibleSailingWithoutLosingTheBacklog() {
inbound(1, "10", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(2, "2026-11-04T12:00:00").catchableCbm()).isZero();
sailing(3, "2026-11-11T10:00:00", "2026-11-10T18:00:00", "OPEN");
assertThat(summary(3, "2026-11-04T12:00:00").catchableCbm()).isEqualByComparingTo("10");
}
/** 已离仓柜的总需求仍可查看,但其容量不能继续抵扣仓内余货。 */
@Test
void excludesDepartedContainerCapacityFromRemainingCargoAdvice() {
booking(1, 1, "68");
inbound(1, "68", "LOADED", "NOT_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(1);
assertThat(booked.capacityCbm()).isEqualByComparingTo("68");
assertThat(booked.availableCapacityCbm()).isZero();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH'");
session.clearCache();
assertThat(mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00")).availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 同柜多票只扣一次柜容量,多个柜型需求按各自容量独立扣减。 */
@Test
void subtractsEachDepartedContainerOnceFromItsOwnRequirement() {
booking(1, 2, "68");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1002,1000,1,58)");
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,100),(1000,1001,200),(1000,1002,300)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(8,2,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(7,3,200,'READY_FOR_DISPATCH',NULL),(7,4,300,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(3);
assertThat(booked.capacityCbm()).isEqualByComparingTo("194");
assertThat(booked.availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 已确认后班的货保留在后班,只有未安排余量进入前班,避免重复订柜。 */
@Test
void preservesConfirmedSailingAndOnlyRollsTheUnallocatedRemainder() {
booking(2, 1, "68");
inbound(1, "100", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
// 全票已排柜时,前班不再产生任何补柜建议。
jdbc.update("UPDATE fl_inbound_order SET actual_volume_cbm=68,total_package_count=6800 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=68,package_count=6800 WHERE measurement_id=10");
session.clearCache();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("UPDATE fl_sailing SET status='CLOSED' WHERE id=2");
session.clearCache();
assertThat(summary(2, "2026-11-03T19:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 原安排取消后只顺延未离仓部分;已有实际离仓事实不会再次进入待发池。 */
@Test
void rollsInvalidatedBookingsWithoutRestoringDepartedCargo() {
booking(2, 1, "68");
inbound(1, "100", "LOADED", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_sailing SET status='CANCELLED' WHERE id=2");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
}
/** 显式关联优先于运输快照匹配,未关联时同名船期也只确定一班。 */
@Test
void resolvesOneBookingTargetAndHonorsAnExplicitLink() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_ocean_booking SET vessel_name='VESSEL',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'");
sailing(3, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
jdbc.update("UPDATE fl_sailing SET voyage_no='VOYAGE-1' WHERE id=3");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(summary(3, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(mapper.bookingSummary(3, LocalDateTime.parse("2026-10-27T00:00:00")).containerCount()).isZero();
}
/** 人工跨仓、跨渠道排柜已有正式订舱时,应跟随真实安排展示。 */
@Test
void preservesCrossWarehouseAndChannelAssignments() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99,channel_code='OTHER'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 查看后月或筛选后班时仍返回前班截仓节点,仓库和渠道范围继续生效。 */
@Test
void findsRefreshBoundaryOutsideTheDisplayedMonthAndKeyword() {
var query = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 8L, null, "MATSON", "OTHER", "OTHER", "SL-2", "CLOSED");
var now = LocalDateTime.parse("2026-10-27T12:00:00");
assertThat(mapper.nextCutoff(query, now, now.toLocalDate().atStartOfDay())).isEqualTo("2026-10-27T18:00:00");
assertThat(mapper.nextCutoff(query, LocalDateTime.parse("2026-10-27T18:00:01"), now.toLocalDate().atStartOfDay())).isEqualTo("2026-11-03T18:00:00");
var otherWarehouse = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 99L, null, "MATSON", null, null, null, null);
assertThat(mapper.nextCutoff(otherWarehouse, now, now.toLocalDate().atStartOfDay())).isNull();
}
/** 建立一条已放舱订舱及唯一显式船期关联。 */
private void booking(long sailingId, int quantity, String capacity) {
jdbc.update("INSERT INTO fl_ocean_booking VALUES(1000,'RELEASED',8,'MATSON','CNNGB','USLGB',NULL,NULL,NULL)");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1001,1000,?,?)", quantity, new java.math.BigDecimal(capacity));
jdbc.update("INSERT INTO fl_sailing_booking_link VALUES(?,1000,'ACTIVE')", sailingId);
}
/** 按真实柜次关联订舱需求,并记录对应入库体积。 */
private void allocate(long inboundId, long containerId, String volume) {
jdbc.update("INSERT INTO fl_loading_plan VALUES(?,?)", containerId, containerId);
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(?,?,7,?,0,?,?)", containerId, containerId, inboundId, new java.math.BigDecimal(volume), new java.math.BigDecimal(volume).movePointRight(2).intValueExact());
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,?)", containerId);
}
/** 将实际 Mapper 的货量与容量投影送入生产订柜算法,验证组合场景。 */
private com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.BookingAdvice advice(long id, String time) {
var now = LocalDateTime.parse(time);
var repository = org.mockito.Mockito.mock(com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository.class);
var sailing = new com.baiyue.firstleg.sailingcalendar.entity.SailingEntity();
sailing.setId(id);
org.mockito.Mockito.when(repository.sailing(id)).thenReturn(sailing);
org.mockito.Mockito.when(repository.receiptSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(summary(id, time));
org.mockito.Mockito.when(repository.bookingSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(mapper.bookingSummary(id, now.toLocalDate().atStartOfDay()));
var service = new com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "repository", repository);
return service.bookingAdvice(id);
}
/** 构造同仓同渠道的船期,只使用公开业务编号排序。 */
private void sailing(long id, String etd, String cutoff, String status) {
jdbc.update("INSERT INTO fl_sailing VALUES(?, ?, 8, 'MATSON', ?, ?, ?, 'CNNGB', 'USLGB', 'VESSEL', ?)", id, "SL-" + id, status,
LocalDateTime.parse(etd), LocalDateTime.parse(cutoff), "VOYAGE-" + id);
}
/** 构造上月遗留的审核实测货量。 */
private void inbound(long id, String volume, String status, String splitStatus) {
var measuredVolume = new java.math.BigDecimal(volume);
int boxes = measuredVolume.movePointRight(2).intValueExact();
jdbc.update("INSERT INTO fl_inbound_order VALUES(?,7,0,'APPROVED',?,8,'MATSON',?,?,'2026-09-01 10:00:00',NULL,NULL,?)",
id, status, splitStatus, measuredVolume, boxes);
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(?,7,?,'APPROVED')", id * 10, id);
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(?,7,?,1,?,?)", id * 10, id * 10, boxes, measuredVolume);
}
/** 用上海业务时间调用生产 SQL,读取目标船期自己的截止时间。 */
private SailingCalendarDisplayMapper.ReceiptSummary summary(long id, String time) {
LocalDateTime now = LocalDateTime.parse(time);
LocalDateTime cutoff = jdbc.queryForObject("SELECT warehouse_cutoff_at FROM fl_sailing WHERE id=?", LocalDateTime.class, id);
return mapper.receiptSummary(id, 8L, "MATSON", cutoff, now, now.toLocalDate().atStartOfDay());
}
}
@@ -2,16 +2,24 @@ package com.baiyue.firstleg.sailingcalendar.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
@@ -22,6 +30,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.mock;
class SailingCalendarServiceTest {
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
@@ -31,10 +41,13 @@ class SailingCalendarServiceTest {
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@BeforeEach
/** 固定上海跨日边界,避免测试依赖运行机日期和时区。 */
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "clock", Clock.fixed(Instant.parse("2026-10-26T16:00:00Z"), ZoneId.of("UTC")));
}
/** 复制幂等回放保留原有船期,只重新读取当前展示汇总。 */
@Test
void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
@@ -45,8 +58,8 @@ class SailingCalendarServiceTest {
when(repository.sailing(2L)).thenReturn(existing);
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
when(repository.histories(2L)).thenReturn(List.of(history));
when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null);
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
var result = service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)));
@@ -81,6 +94,117 @@ class SailingCalendarServiceTest {
verify(repository, never()).insert(any(SailingEntity.class));
}
/** 各柜型独立向上取整,正数不足一柜也应给出一柜建议。 */
@ParameterizedTest
@CsvSource({"0,0,0,0", "0.001,1,1,1", "58,1,1,1", "58.001,2,1,1", "68,2,1,1",
"68.001,2,2,1", "86,2,2,1", "86.001,2,2,2", "113.74,2,2,2"})
void roundsAllReceivedVolumeUpForEachContainerType(String volume, int gp, int hq, int largeHq) {
SailingEntity sailing = sailing(1L, "SL-ADVICE", "MANUAL", "TEST", copiedDeparture);
when(repository.sailing(1L)).thenReturn(sailing);
// 复现截图:全部实收都被标为晚到,不能因此得到零柜。
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(BigDecimal.ZERO, new BigDecimal(volume), 1));
var advice = service.bookingAdvice(1L);
var summary = service.volumeSummary(1L).summary();
assertThat(advice.actualReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.catchableTotalCbm()).isZero();
assertThat(advice.lateReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.recommendedContainerCount()).isEqualTo(hq);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(summary.recommendedContainerCount()).isEqualTo(hq);
assertThat(summary.pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.recommendedContainerCount()).containsExactly(gp, hq, largeHq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 待补柜扣除已订实际容量,而不是简单扣除已订柜数,超额容量不产生负数。 */
@ParameterizedTest
@CsvSource({"1,58,2,2,1", "2,126,1,1,1", "2,136,0,0,0", "3,204,0,0,0"})
void subtractsBookedCapacityBeforeRoundingTheShortage(int booked, String capacity, int gp, int hq, int largeHq) {
when(repository.sailing(1L)).thenReturn(sailing(1L, "SL-BOOKED", "MANUAL", "TEST", copiedDeparture));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("100"), new BigDecimal("36"), 1));
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.BookingSummary(booked, new BigDecimal(capacity), new BigDecimal(capacity)));
var advice = service.bookingAdvice(1L);
assertThat(advice.recommendedContainerCount()).isEqualTo(2);
assertThat(advice.bookedContainerCount()).isEqualTo(booked);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(service.volumeSummary(1L).summary().pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 上海今日零点前过期,今日整天及未来月份仍可参与列表和汇总。 */
@ParameterizedTest
@CsvSource({"2026-10,2026-10-27,2026-11-01", "2026-11,2026-11-01,2026-12-01"})
void limitsCalendarAndSummaryToTheUpcomingWindow(String month, LocalDate from, LocalDate to) {
SailingQuery query = new SailingQuery(month, null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-TODAY", "MANUAL", "TEST", from.atStartOfDay());
when(repository.calendar(query, from.atStartOfDay(), to.atStartOfDay())).thenReturn(List.of(first));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("113.74"), BigDecimal.ZERO, 0));
var result = service.calendar(query);
verify(repository).calendar(query, from.atStartOfDay(), to.atStartOfDay());
assertThat(result.records()).extracting(r -> r.etdAt()).containsExactly(from.atStartOfDay());
assertThat(result.summary().sailingCount()).isEqualTo(1);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("113.74");
assertThat(result.summary().recommendedContainerCount()).isEqualTo(2);
}
/** 过去月份不查询历史货量,也不把历史船期混入汇总。 */
@Test
void returnsAnEmptyCalendarForAnExpiredMonth() {
var result = service.calendar(new SailingQuery("2026-09", null, null, null, null, null, null, null));
assertThat(result.records()).isEmpty();
assertThat(result.summary().sailingCount()).isZero();
assertThat(result.summary().recommendedContainerCount()).isZero();
verifyNoInteractions(repository);
}
/** 刷新时间包括隐藏月份的前班截仓节点,并以上海零点为最长等待边界。 */
@ParameterizedTest
@CsvSource({"2026-10-27T00:00:00,2026-10-27T00:00:00.001", "2026-10-27T18:00:00,2026-10-27T18:00:00.001", "2026-11-03T18:00:00,2026-10-28T00:00:00"})
void schedulesRefreshUsingCrossMonthCutoff(String cutoff, String expectedRefresh) {
SailingQuery query = new SailingQuery("2026-11", null, null, null, null, null, "后班", null);
when(repository.nextCutoff(query, LocalDateTime.parse("2026-10-27T00:00:00")))
.thenReturn(LocalDateTime.parse(cutoff));
assertThat(service.calendar(query).nextRefreshAt()).isEqualTo(LocalDateTime.parse(expectedRefresh));
}
/** 即使系统时钟跨过截仓瞬间,同一次月历的所有船期也必须共享同一投影时刻。 */
@Test
void sharesOneSnapshotTimeAcrossCalendarRows() {
Clock movingClock = mock(Clock.class);
when(movingClock.withZone(any(ZoneId.class))).thenReturn(movingClock);
when(movingClock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai"));
when(movingClock.instant()).thenReturn(Instant.parse("2026-10-27T10:00:00Z"), Instant.parse("2026-10-27T10:00:01Z"));
ReflectionTestUtils.setField(service, "clock", movingClock);
SailingQuery query = new SailingQuery("2026-10", null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-FIRST", "MANUAL", "TEST-1", LocalDateTime.parse("2026-10-28T10:00:00"));
SailingEntity second = sailing(2L, "SL-SECOND", "MANUAL", "TEST-2", LocalDateTime.parse("2026-10-30T10:00:00"));
LocalDateTime cutoff = LocalDateTime.parse("2026-10-27T18:00:00");
when(repository.calendar(any(SailingQuery.class), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(first, second));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenAnswer(call -> {
SailingEntity sailing = call.getArgument(0);
LocalDateTime asOf = call.getArgument(1);
boolean counted = asOf.isAfter(cutoff) ? sailing.getId() == 2L : sailing.getId() == 1L;
return new SailingCalendarDisplayMapper.ReceiptSummary(counted ? BigDecimal.TEN : BigDecimal.ZERO, BigDecimal.ZERO, 0);
});
var result = service.calendar(query);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("10");
assertThat(result.records()).extracting(row -> row.volumeSummary().snapshotAt()).containsExactly(cutoff, cutoff);
verify(movingClock).instant();
}
/** 构造具备稳定业务编号的船期样本。 */
private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) {
SailingEntity value = new SailingEntity();
value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo);