fix(firstleg): 修正船期货量顺延与订柜建议
This commit is contained in:
+5
-3
@@ -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) {}
|
||||
}
|
||||
|
||||
+149
-63
@@ -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) {}
|
||||
}
|
||||
|
||||
+11
-7
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+46
-32
@@ -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())); }
|
||||
|
||||
Reference in New Issue
Block a user