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,59 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Use SourceDeliveryCompletionQuery in an explicit READ_COMMITTED write transaction.
* lockSource is a current locking read; history queries deliberately do not lock delivery rows.
* No pagination or current ll_order.delivery_order_no association is used.
*/
@Mapper
public interface DeliverySourceEvidenceMapper {
@Select("SELECT source_inbound_id, id AS order_id, package_count AS packages FROM ll_order " +
"WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND source_inbound_id=#{sourceInboundId}")
Source source(@Param("customerId") String customerId, @Param("sourceInboundId") String sourceInboundId);
@Select("SELECT source_inbound_id,id AS order_id,package_count AS packages FROM ll_order " +
"WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE")
Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId);
@Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " +
"JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " +
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
"WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id")
List<Long> dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId);
@Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," +
"d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " +
"LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " +
"WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id")
List<Delivery> deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
@Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," +
"CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " +
"THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " +
"WHEN COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')) IS NOT NULL " +
"THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')))) " +
"WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," +
"a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " +
"JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " +
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
"LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " +
"LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " +
"AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " +
"AND re.id=r.exception_order_id AND re.exception_no=rl.business_order_no AND re.last_mile_order_id=a.order_id " +
"WHERE rl.customer_id=r.customer_id AND rl.id=r.inventory_ledger_id AND rl.business_type='RETURN_STOCK_IN' " +
"AND rl.outbound_order_no=r.source_outbound_no AND rl.delta_packages=r.received_packages " +
"AND rl.source_event_id=CONCAT('RETURN_SOURCE:',r.source_outbound_pallet_item_id,':',r.source_inventory_position_id)) " +
"WHERE a.customer_id=#{customerId} AND t.dispatch_order_id=#{dispatchId} ORDER BY a.id")
List<Pick> picks(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
@Select("SELECT l.id AS ledger_id,d.id AS delivery_id,e.last_mile_order_id AS order_id," +
"l.delta_packages AS packages,l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
"JOIN ll_inventory_ledger l ON l.customer_id=e.customer_id AND l.business_order_no=e.exception_no " +
"AND l.outbound_order_no=d.outbound_no AND l.business_type='RETURN_STOCK_IN' AND l.delta_packages>0 " +
"WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY l.id")
List<Returned> returns(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
}
@@ -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()); }
}
}
@@ -35,25 +35,14 @@ public class DeliveryCompletedOutboxService {
public enum Publication { PUBLISHED, NO_DOMESTIC_SOURCE, UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION, MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION }
public Publication publish(DeliveryOrder delivery, List<LastlegOrder> orders, Instant completedAt) {
public Publication publish(DeliveryOrder delivery, List<LastlegOrder> orders, Map<String, com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result> proofs, Instant completedAt) {
Objects.requireNonNull(delivery, "delivery");
Objects.requireNonNull(completedAt, "completedAt");
// The domestic consumer marks the whole inbound order DELIVERED (signed), not merely transport closed.
// A mixed return/signature must not publish that full-signature fact.
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
// Only orders projected from the domestic first-leg domain need an
// acknowledgement event. Customer/local overseas orders can still carry
// a local sourceInboundId, but deliberately have no domestic sourceOrderId.
if (StringUtils.hasText(order.getSourceRegion())
&& !"FIRSTLEG".equals(order.getSourceRegion())) continue;
// Relabel and warehouse-transfer deliveries are created and completed wholly
// in the overseas last-leg domain. They do not have a domestic source order
// to acknowledge, so publishing the cross-region event would make receipt
// completion fail on the missing source-order fields.
if ("RELABEL_APPLICATION".equals(order.getOrderType())
|| "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
if (!domestic(order)) continue;
requireText(order.getSourceOrderId(), "sourceOrderId");
requireText(order.getInboundCode(), "inboundCode");
requireText(order.getCustomerId(), "customerId");
@@ -67,15 +56,25 @@ public class DeliveryCompletedOutboxService {
if (delivery.getPackageCount() == null || delivery.getPackageCount() <= 0 || delivery.getSignedPackages() == null
|| delivery.getSignedPackages() <= 0 || delivery.getSignedPackages() > delivery.getPackageCount())
return Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION;
if (!Objects.equals(delivery.getSignedPackages(), delivery.getPackageCount()))
return Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION;
for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt);
return Publication.PUBLISHED;
boolean pending = false;
for (LastlegOrder order : bySourceInbound.values()) {
var proof = proofs.get(order.getSourceInboundId());
if (proof == null || !proof.fullySigned() || !proof.unresolvedReasons().isEmpty()
|| !Objects.equals(proof.sourceInboundId(),order.getSourceInboundId())
|| order.getPackageCount() == null || proof.originalPackages() != order.getPackageCount()
|| proof.provenSignedPackages() != proof.originalPackages()) { pending = true; continue; }
publishOne(delivery, order, completedAt);
}
return pending ? Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION : Publication.PUBLISHED;
}
static boolean domestic(LastlegOrder order) {
return "FIRSTLEG".equals(order.getSourceRegion()) && StringUtils.hasText(order.getSourceInboundId())
&& !"RELABEL_APPLICATION".equals(order.getOrderType()) && !"WAREHOUSE_TRANSFER".equals(order.getOrderType());
}
private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) {
long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion());
String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo()
String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + order.getCustomerId()
+ ':' + order.getSourceInboundId();
var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(),
String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(),
@@ -35,6 +35,8 @@ public class DeliveryDocumentService {
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox;
@Resource private CustomerVisitService customerVisits;
@Resource private SourceDeliveryCompletionQuery sourceCompletion;
@Resource private DeliverySourceEvidenceMapper sourceEvidence;
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
@@ -56,7 +58,7 @@ public class DeliveryDocumentService {
}
/** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */
@Transactional
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public Detail save(String customerId, String deliveryNo, String operatorId, Save request) {
DeliveryOrder order = orders.lockByNo(customerId, deliveryNo);
if (order == null) throw missing();
@@ -110,19 +112,8 @@ public class DeliveryDocumentService {
}
orders.updateById(order);
if (autoCompleted) {
List<LastlegOrder> completedOrders = completeRelatedOrders(order, now);
var publication = deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC));
if (publication == DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION
|| publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION) {
DeliveryOperation pending = new DeliveryOperation();
pending.setCustomerId(customerId); pending.setDeliveryOrderId(order.getId()); pending.setOperatorId(operatorId);
pending.setOperationNode("FIRSTLEG_COMPLETION_PENDING");
pending.setResult(publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION
? "签收数量未确认或无效,国内整票签收事件未发布,须核对真实实签量后继续回写"
: "原实发" + order.getPackageCount() + "箱,本次签收" + order.getSignedPackages()
+ "箱,其余已退回;国内整票签收事件未发布,须按来源入仓单核对签收与退回处置后继续回写");
pending.setCreatedAt(now); operations.insert(pending);
}
var completion = completeRelatedOrders(order, operatorId, now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.toInstant(ZoneOffset.UTC));
customerVisits.ensureForCompletedDelivery(order, now);
}
Detail result = detail(order, false);
@@ -166,20 +157,54 @@ public class DeliveryDocumentService {
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
}
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private List<LastlegOrder> completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) {
List<LastlegOrder> completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, delivery.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()));
for (LastlegOrder order : completedOrders) {
// Preserve the outbound leg when receipt completion closes delivery.
// Sub-order status is a composite fact; replacing it with DELIVERY alone
// makes a successfully departed outbound appear unfinished or disappear.
order.setSubOrderStatus(delivery.getSignedPackages().equals(delivery.getPackageCount())
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
order.setUpdatedAt(now);
lastlegOrders.updateById(order);
private record Completion(List<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs) { }
private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) {
var related = new TreeMap<Long, LastlegOrder>();
if (delivery.getDispatchOrderId() != null) {
var ids = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream()
.filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey()))
.map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList();
if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) {
if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致");
related.put(tail.getId(), tail);
}
}
return completedOrders;
// Non-dispatch local workflows retain their established relation; never use this to prove a domestic source.
for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) {
if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail);
else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now,
inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件");
}
var sourceIds = related.values().stream().filter(DeliveryCompletedOutboxService::domestic)
.map(LastlegOrder::getSourceInboundId).distinct().toList();
var proofs = sourceIds.isEmpty() ? Map.<String,SourceDeliveryCompletionPolicy.Result>of()
: sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds);
// All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots.
var current = new ArrayList<LastlegOrder>();
for (long id : related.keySet()) {
var tail = lastlegOrders.selectById(id);
if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化");
if (DeliveryCompletedOutboxService.domestic(tail)) {
var proof = proofs.get(tail.getSourceInboundId());
if (proof != null && proof.fullySigned()) tail.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED");
else {
tail.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY");
pendingCompletion(delivery, operatorId, now, inboundLabel(tail) + ":来源签收未核齐;"
+ (proof == null ? "缺少来源证明" : "已证明" + proof.provenSignedPackages() + "/" + proof.originalPackages() + "箱;" + (proof.unresolvedReasons().isEmpty() ? "仍有货物未完成签收" : "签收与退回来源证据尚未核齐,请核对实发、实收及签收记录")));
}
} else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount())
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail);
}
return new Completion(List.copyOf(current),proofs);
}
private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); }
private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) {
var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId());
operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId);
operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason);
operation.setCreatedAt(now); operations.insert(operation);
}
private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) {
DeliveryOperation operation = new DeliveryOperation();
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.*;
/** Caller locks its own delivery, then calls proveAll BEFORE any source-order updates.
* Explicit READ_COMMITTED is required. Lock source orders in numeric ID order until commit;
* never lock historical delivery/dispatch/exception/inventory rows from this query.
* RC evidence reads see the preceding source-lock owner's committed signatures without waiting
* for another transaction's delivery lock. All signature writers must follow this protocol.
*/
@Service
public class SourceDeliveryCompletionQuery {
private final DeliverySourceEvidenceMapper evidence;
public SourceDeliveryCompletionQuery(DeliverySourceEvidenceMapper evidence) { this.evidence = evidence; }
/** Source uses UNIQUE(customer_id,source_region,source_inbound_id); mapper selectOne has no LIMIT.
* A broken uniqueness invariant therefore raises TooManyResultsException, never picks one row.
*/
@Transactional(propagation = Propagation.MANDATORY)
public Result prove(String customerId, String sourceInboundId) {
return proveAll(customerId, List.of(sourceInboundId)).get(sourceInboundId);
}
/** One call with ALL affected sources; do not loop prove() for a multi-source delivery. */
@Transactional(propagation = Propagation.MANDATORY)
public Map<String, Result> proveAll(String customerId, Collection<String> sourceInboundIds) {
if (!TransactionSynchronizationManager.isActualTransactionActive()
|| TransactionSynchronizationManager.isCurrentTransactionReadOnly())
throw new IllegalStateException("Source signature proof requires the caller's active write transaction");
if (!Objects.equals(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel(), TransactionDefinition.ISOLATION_READ_COMMITTED))
throw new IllegalStateException("Source signature proof requires explicit READ_COMMITTED on the outer transaction");
if (customerId == null || customerId.isBlank() || sourceInboundIds == null || sourceInboundIds.isEmpty()
|| sourceInboundIds.stream().anyMatch(id -> id == null || id.isBlank()))
throw new IllegalArgumentException("Customer and source inbound ID are required");
var result = new LinkedHashMap<String, Result>(); var sources = new TreeMap<Long, Source>();
for (String id : new TreeSet<>(sourceInboundIds)) {
Source source = evidence.source(customerId, id);
if (source == null) { result.put(id, new Result(id,0,0,false,List.of("SOURCE_NOT_FOUND","HISTORY_NOT_COMPLETE"))); continue; }
if (!id.equals(source.sourceInboundId()) || source.orderId() <= 0 || sources.putIfAbsent(source.orderId(),source) != null)
throw new IllegalStateException("Source evidence identity mismatch");
}
var locked = new ArrayList<Source>();
for (var source : sources.values()) {
Source current = evidence.lockSource(customerId, source.orderId());
if (current == null || current.orderId() != source.orderId() || !current.sourceInboundId().equals(source.sourceInboundId()))
throw new IllegalStateException("Source changed while acquiring completion lock");
locked.add(current);
}
for (var source : locked) result.put(source.sourceInboundId(), proveLocked(customerId, source));
return Collections.unmodifiableMap(result);
}
private Result proveLocked(String customerId, Source source) {
String sourceInboundId = source.sourceInboundId();
var ids = Objects.requireNonNull(evidence.dispatchIds(customerId, source.orderId()), "Dispatch history missing");
if (ids.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(ids).size() != ids.size())
throw new IllegalStateException("Invalid or duplicate historical dispatch IDs");
var deliveries = new ArrayList<Delivery>(); var picks = new ArrayList<Pick>(); var returns = new ArrayList<Returned>();
for (long id : ids.stream().sorted().toList()) {
var dispatchDeliveries = Objects.requireNonNull(evidence.deliveries(customerId, id), "Delivery evidence missing");
var dispatchPicks = Objects.requireNonNull(evidence.picks(customerId, id), "Pick evidence missing");
var dispatchReturns = Objects.requireNonNull(evidence.returns(customerId, id), "Return evidence missing");
if (dispatchDeliveries.isEmpty() || dispatchPicks.isEmpty()
|| dispatchDeliveries.stream().anyMatch(row -> row == null || row.dispatchId() != id)
|| dispatchPicks.stream().anyMatch(row -> row == null || row.dispatchId() != id)
|| dispatchPicks.stream().noneMatch(row -> row.orderId() == source.orderId()))
return new Result(sourceInboundId, source.packages(), 0, false,
List.of("DISPATCH_HISTORY_INCOMPLETE:" + id, "HISTORY_NOT_COMPLETE"));
var deliveryIds = new HashSet<Long>(); dispatchDeliveries.forEach(row -> deliveryIds.add(row.deliveryId()));
if (dispatchReturns.stream().anyMatch(row -> row == null || !deliveryIds.contains(row.deliveryId())))
throw new IllegalStateException("Return evidence belongs to another dispatch");
deliveries.addAll(dispatchDeliveries); picks.addAll(dispatchPicks); returns.addAll(dispatchReturns);
}
return SourceDeliveryCompletionPolicy.evaluate(source, deliveries, picks, returns, true);
}
}