feat(lastleg): redispatch returned cargo and reconcile source signatures
This commit is contained in:
+134
@@ -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()); }
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user