feat(lastleg): redispatch returned cargo and reconcile source signatures

This commit is contained in:
2026-10-03 16:06:54 +08:00
parent e740047e03
commit 8700fc3423
39 changed files with 1605 additions and 139 deletions
@@ -0,0 +1,134 @@
package com.baiyue.lastleg.delivery.model;
import java.time.LocalDateTime;
import java.util.*;
/** Read-only proof of whole-source signature. Inputs must cover ALL history for the requested source. */
public final class SourceDeliveryCompletionPolicy {
private SourceDeliveryCompletionPolicy() { }
public record Source(String sourceInboundId, long orderId, long packages) { }
public record Delivery(long deliveryId, long dispatchId, String groupKey, long packages,
Long signedPackages, LocalDateTime signedAt, LocalDateTime departedAt) { }
public record Pick(long attributionId, long dispatchId, String groupKey, long orderId, long packages,
Long receiptId, Long returnReceiptId, Long returnLedgerId, Long returnPackages) { }
public record Returned(long ledgerId, long deliveryId, long orderId, long packages, LocalDateTime receivedAt) { }
public record Result(String sourceInboundId, long originalPackages, long provenSignedPackages,
boolean fullySigned, List<String> unresolvedReasons) { }
public static Result evaluate(Source source, List<Delivery> deliveries, List<Pick> picks,
List<Returned> returns, boolean completeHistory) {
Objects.requireNonNull(source);
var reasons = new LinkedHashSet<String>();
if (!completeHistory) reasons.add("HISTORY_NOT_COMPLETE");
if (source.sourceInboundId() == null || source.sourceInboundId().isBlank() || source.packages() <= 0)
reasons.add("SOURCE_QUANTITY_MISSING");
// Only destinations containing this source can affect its proof. Keep co-shipped sources
// within those destinations to reconcile the exact delivery signature total.
var relevant = deliveries.stream().filter(d -> picks.stream().anyMatch(p -> p.orderId() == source.orderId()
&& p.dispatchId() == d.dispatchId() && Objects.equals(p.groupKey(),d.groupKey()))).toList();
var relevantIds = new HashSet<Long>();
for (var d : relevant) if (!relevantIds.add(d.deliveryId())) reasons.add("DUPLICATE_DELIVERY");
var attributionIds = new HashSet<Long>(); var ledgerIds = new HashSet<Long>();
for (var p : picks) if (relevant.stream().anyMatch(d -> d.dispatchId()==p.dispatchId() && Objects.equals(d.groupKey(),p.groupKey()))) {
if (!attributionIds.add(p.attributionId()) || p.packages() <= 0 || p.groupKey() == null) reasons.add("INVALID_PICK_ATTRIBUTION");
if ((p.receiptId() == null) == (p.returnReceiptId() == null)
|| (p.receiptId()!=null && p.receiptId()<=0)) reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED");
}
for (var r : returns) if (relevantIds.contains(r.deliveryId()) && (!ledgerIds.add(r.ledgerId())
|| r.packages()<=0 || r.receivedAt()==null)) reasons.add("INVALID_RETURN_LEDGER");
long signed = 0;
for (var d : relevant) {
var matching = picks.stream().filter(p -> p.dispatchId() == d.dispatchId() && Objects.equals(p.groupKey(), d.groupKey())).toList();
var quantities = new HashMap<Long, Long>();
for (var p : matching) quantities.merge(p.orderId(), p.packages(), Math::addExact);
if (d.groupKey() == null || d.packages() <= 0 || d.departedAt() == null
|| matching.stream().mapToLong(Pick::packages).sum() != d.packages()
|| deliveries.stream().filter(other -> other.dispatchId() == d.dispatchId() && Objects.equals(other.groupKey(), d.groupKey())).count() != 1) {
reasons.add("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:" + d.deliveryId()); continue;
}
if (d.signedAt() == null) {
if (d.signedPackages() != null) reasons.add("SIGNATURE_TIME_MISSING:" + d.deliveryId());
continue;
}
if (d.signedAt().isBefore(d.departedAt()) || d.signedPackages() == null || d.signedPackages() <= 0 || d.signedPackages() > d.packages()) {
reasons.add("SIGNATURE_QUANTITY_UNRESOLVED:" + d.deliveryId()); continue;
}
var before = new HashMap<Long, Long>(); boolean valid = true;
for (var r : returns) if (r.deliveryId() == d.deliveryId()) {
if (r.receivedAt() == null || r.receivedAt().isBefore(d.departedAt()) || !quantities.containsKey(r.orderId())) {
valid = false; break;
}
// Millisecond-equal timestamps cannot prove which transaction happened first.
if (r.receivedAt().equals(d.signedAt())) { valid = false; break; }
if (r.receivedAt().isBefore(d.signedAt())) before.merge(r.orderId(), r.packages(), Math::addExact);
}
if (!valid || before.entrySet().stream().anyMatch(e -> e.getValue() > quantities.getOrDefault(e.getKey(), 0L))
|| d.packages() - before.values().stream().mapToLong(Long::longValue).sum() != d.signedPackages()) {
reasons.add("PRE_SIGNATURE_RETURN_UNRESOLVED:" + d.deliveryId()); continue;
}
long fresh = 0, previouslySigned = 0; boolean lineageValid = true;
for (var p : matching) if (p.orderId() == source.orderId()) {
Boolean eligible = freshOrigin(p,d,deliveries,picks,returns,new HashSet<>(),reasons);
if (eligible == null) lineageValid = false;
else if (eligible) fresh = Math.addExact(fresh,p.packages());
else previouslySigned = Math.addExact(previouslySigned,p.packages());
}
long returnedBefore = before.getOrDefault(source.orderId(),0L);
if (fresh>0 && previouslySigned>0 && returnedBefore>0) {
reasons.add("MIXED_RESIGNED_RETURN_UNRESOLVED:"+d.deliveryId()); lineageValid=false;
}
if (lineageValid) signed = Math.addExact(signed,fresh==0?0:fresh-returnedBefore);
}
for (var p : picks) if (p.orderId() == source.orderId() && deliveries.stream().noneMatch(d ->
d.dispatchId() == p.dispatchId() && Objects.equals(d.groupKey(), p.groupKey()))) reasons.add("PICK_DELIVERY_MISSING");
// Repeated post-signature shipments cannot be treated as distinct originally loaded boxes.
if (signed > source.packages()) reasons.add("SIGNED_EXCEEDS_ORIGINAL_SOURCE");
return new Result(source.sourceInboundId(), source.packages(), signed,
reasons.isEmpty() && signed == source.packages(), List.copyOf(reasons));
}
/** Receipt identity is exact; package identity inside mixed fresh/already-signed cohorts is not. */
private static Boolean freshOrigin(Pick pick, Delivery current, List<Delivery> deliveries, List<Pick> picks,
List<Returned> returns, Set<Long> visiting, Set<String> reasons) {
if (!visiting.add(pick.attributionId())) { reasons.add("RETURN_LINEAGE_CYCLE"); return null; }
try {
if ((pick.receiptId()==null)==(pick.returnReceiptId()==null)) { reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); return null; }
if (pick.receiptId()!=null) {
if(pick.receiptId()<=0) { reasons.add("PICK_RECEIPT_ORIGIN_UNRESOLVED"); return null; }
return true;
}
var receipts=returns.stream().filter(r->Objects.equals(pick.returnLedgerId(),r.ledgerId())).toList();
if(pick.returnReceiptId()<=0 || receipts.size()!=1 || pick.returnPackages()==null
|| receipts.get(0).orderId()!=pick.orderId() || receipts.get(0).packages()!=pick.returnPackages()
|| picks.stream().filter(p->Objects.equals(p.returnLedgerId(),pick.returnLedgerId())).mapToLong(Pick::packages).sum()>pick.returnPackages()) {
reasons.add("RETURN_PICK_ORIGIN_UNRESOLVED:"+pick.attributionId()); return null;
}
var receipt=receipts.get(0);
var parents=deliveries.stream().filter(d->d.deliveryId()==receipt.deliveryId()).toList();
if(parents.size()!=1 || receipt.receivedAt()==null || current.departedAt()==null
|| !receipt.receivedAt().isBefore(current.departedAt())) {
reasons.add("RETURN_PICK_CHRONOLOGY_UNRESOLVED:"+pick.attributionId());return null;
}
var parent=parents.get(0);
if(parent.departedAt()==null || receipt.receivedAt().isBefore(parent.departedAt())
|| (parent.signedAt()!=null && receipt.receivedAt().equals(parent.signedAt()))) {
reasons.add("RETURN_PICK_LINEAGE_UNRESOLVED:"+pick.attributionId()); return null;
}
var parentPicks=picks.stream().filter(p->p.dispatchId()==parent.dispatchId()
&& Objects.equals(p.groupKey(),parent.groupKey()) && p.orderId()==pick.orderId()).toList();
if(parentPicks.isEmpty() || receipt.packages()>parentPicks.stream().mapToLong(Pick::packages).sum()) {
reasons.add("RETURN_PICK_ORIGIN_UNRESOLVED:"+pick.attributionId());return null;
}
// After a validated signature every box remaining in that movement is already signed.
if(parent.signedAt()!=null && parent.signedAt().isBefore(receipt.receivedAt())) return false;
Boolean eligibility=null;
for(var ancestor:parentPicks) {
Boolean value=freshOrigin(ancestor,parent,deliveries,picks,returns,visiting,reasons);
if(value==null)return null;
if(eligibility!=null && !eligibility.equals(value)) { reasons.add("RETURN_LINEAGE_MIXED_COHORT");return null; }
eligibility=value;
}
return eligibility;
} finally { visiting.remove(pick.attributionId()); }
}
}