package com.baiyue.lastleg.appointmentplanning; import java.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; /** Stage-specific planning rules. Planning never creates stock, vehicles or carrier confirmations. */ public final class AppointmentPlanningPolicy { private AppointmentPlanningPolicy() { } public record Cargo(String sourceKey, String customerId, String originWarehouseId, String destinationKey, String originCountry, String destinationCountry, Integer packages, Instant readyFrom, Instant readyTo, boolean eligibleForPlanning, String blockReason) { } public record Group(String originWarehouseId, String destinationKey, Long packages, int sourceCount, int customerCount, Instant readyFrom, Instant readyTo, boolean readinessIncomplete, int unknownQuantitySources) { } public record Forecast(String sourceKey, String sourceVersion, Instant eta, Instant readyAt) { } public record Impact(String sourceKey, String reason, Instant previousEta, Instant latestEta, Instant confirmedAt, boolean reviewRequired) { } public static Group group(List cargo) { if (cargo == null || cargo.isEmpty()) throw new IllegalArgumentException("请选择需要安排的真实货物"); if (cargo.size() > 500) throw new IllegalArgumentException("每批最多选择500条来源货物"); Set sources = new HashSet<>(); Set customers = new HashSet<>(); String origin = null, destination = null; long packages = 0; int unknownQuantitySources = 0; Instant from = null, to = null; boolean incomplete = false; for (Cargo row : cargo) { if (row == null || blank(row.sourceKey()) || blank(row.customerId())) { throw new IllegalArgumentException("货物缺少稳定来源或客户归属"); } if (!sources.add(row.sourceKey())) throw new IllegalArgumentException("同一来源货物不能重复选择"); if (!row.eligibleForPlanning()) throw new IllegalArgumentException( blank(row.blockReason()) ? "所选货物暂不具备筹划资格" : row.blockReason()); if (!"US".equals(row.originCountry()) || !"US".equals(row.destinationCountry())) { throw new IllegalArgumentException("请选择已确认美国起运点及目的地的货物"); } if (blank(row.originWarehouseId()) || blank(row.destinationKey())) { throw new IllegalArgumentException("货物缺少实际接仓点或目的地"); } if (origin == null) { origin = row.originWarehouseId(); destination = row.destinationKey(); } if (!origin.equals(row.originWarehouseId()) || !destination.equals(row.destinationKey())) { throw new IllegalArgumentException("同批货物必须具有相同实际起运仓和目的地"); } if (row.packages() == null || row.packages() <= 0) unknownQuantitySources++; else packages = Math.addExact(packages, row.packages().longValue()); customers.add(row.customerId()); if (row.readyFrom() == null || row.readyTo() == null) incomplete = true; if (row.readyFrom() != null && row.readyTo() != null && row.readyTo().isBefore(row.readyFrom())) { throw new IllegalArgumentException("货物预计备妥窗口无效"); } if (row.readyFrom() != null && (from == null || row.readyFrom().isAfter(from))) from = row.readyFrom(); if (row.readyTo() != null && (to == null || row.readyTo().isBefore(to))) to = row.readyTo(); } if (from != null && to != null && to.isBefore(from)) throw new IllegalArgumentException("所选货物预计备妥窗口不相容,请分别安排"); return new Group(origin, destination, unknownQuantitySources==0?packages:null, sources.size(), customers.size(), from, to, incomplete, unknownQuantitySources); } /** A new forecast only requests review; it never changes an externally confirmed appointment. */ public static Impact compare(Forecast acknowledged, Forecast latest, Instant confirmedAt) { if (acknowledged == null || latest == null || blank(latest.sourceKey()) || !Objects.equals(acknowledged.sourceKey(), latest.sourceKey())) { throw new IllegalArgumentException("只能比较同一真实货源的预测"); } boolean changed = !Objects.equals(acknowledged.eta(), latest.eta()) || !Objects.equals(acknowledged.readyAt(), latest.readyAt()); String reason = "UNCHANGED"; if (changed) { reason = latest.eta() == null ? "FORECAST_MISSING" : "FORECAST_CHANGED"; if (confirmedAt != null && latest.readyAt() != null && latest.readyAt().isAfter(confirmedAt)) { reason = "READINESS_AFTER_APPOINTMENT"; } } return new Impact(latest.sourceKey(), reason, acknowledged.eta(), latest.eta(), confirmedAt, changed); } private static boolean blank(String value) { return value == null || value.isBlank(); } }