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);
}
}
@@ -75,7 +75,7 @@ public class SmartDispatchController {
@Valid @RequestBody DispatchRequests.PreArrange request) {
requirePlatform(user);
String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank()
? service.resolveCustomerScopeForPallets(request.planPalletIds())
? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds())
: service.resolveCustomerScopeForRelabel(request.relabelNo());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
@@ -88,7 +88,7 @@ public class SmartDispatchController {
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftPallet request) {
requirePlatform(user);
String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId()));
String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId()));
service.lockDraftPallet(customerId, operatorId(user), request);
return ApiResponse.success(null);
}
@@ -113,7 +113,7 @@ public class SmartDispatchController {
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.DraftPallet request) {
requirePlatform(user);
String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId()));
String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId()));
service.unlockDraftPallet(customerId, operatorId(user), request);
return ApiResponse.success(null);
}
@@ -19,12 +19,21 @@ public final class DispatchRequests {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo) {
@Size(max = 64) String relabelNo,
@Size(max = 500) List<@NotNull Long> returnReceiptIds) {
public PreArrange {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, String draftToken, String relabelNo) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of());
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
@@ -35,8 +44,15 @@ public final class DispatchRequests {
}
}
public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId) {
public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId,
Long returnReceiptId, String sourceType) {
public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); }
public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); }
@jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源")
public boolean isValidSource() {
return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType)
: planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType));
}
}
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {}
@@ -18,6 +18,7 @@ public class DispatchItem {
private String customerId;
private Long planId;
private Long planPalletId;
private Long returnReceiptId;
private Long warehousePalletId;
private Long orderId;
private String overseasWarehouseId;
@@ -22,6 +22,7 @@ public class DispatchPalletLock {
private String activeKey;
private Long planId;
private Long planPalletId;
private Long returnReceiptId;
private Long warehousePalletId;
private Long dispatchOrderId;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface ReturnDispatchCandidateMapper {
String SELECT = "SELECT r.id AS returnReceiptId,r.receipt_no AS returnReceiptNo,r.order_id AS orderId," +
"r.customer_id AS customerId,o.customer_name AS customerName,o.order_type AS orderType,o.order_no AS orderNo," +
"o.order_no AS orderRefs,b.inbound_code AS inboundCode,p.pallet_id AS warehousePalletId,p.pallet_no AS palletNo," +
"p.overseas_warehouse_id AS overseasWarehouseId,b.overseas_warehouse_name AS overseasWarehouseName," +
"b.overseas_warehouse_code AS overseasWarehouseCode,d.destination_type AS destinationType," +
"d.destination_warehouse_code AS destinationWarehouseCode,d.destination_warehouse_code AS fbaWarehouseCode," +
"d.destination_warehouse_name AS destinationWarehouseName,d.delivery_address AS deliveryAddress,d.postal_code AS postalCode," +
"d.contact_name AS contactName,d.contact_phone AS contactPhone,'RECEIVED' AS sourceStage," +
"LEAST(p.available_packages,r.received_packages-(SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " +
"WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id)) AS packageCount " +
"FROM ll_return_stock_receipt r JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " +
"JOIN ll_inventory_position p ON p.customer_id=r.customer_id AND p.id=r.inventory_position_id " +
"JOIN ll_inventory_balance b ON b.customer_id=r.customer_id AND b.id=r.inventory_balance_id " +
"JOIN ll_exception_order e ON e.customer_id=r.customer_id AND e.id=r.exception_order_id " +
"JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id ";
String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 AND r.received_packages>(SELECT COALESCE(SUM(a.packages),0) " +
"FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) ";
@Select("<script>" + SELECT + "WHERE 1=1 " + ELIGIBLE +
"<if test='customerId != null'>AND r.customer_id=#{customerId} </if>" +
"<if test='orderNo != null'>AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR b.inbound_code LIKE CONCAT('%',#{orderNo},'%') OR r.receipt_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"<if test='fba != null'>AND d.destination_warehouse_code=#{fba} </if>" +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.customer_id=r.customer_id " +
"AND k.source_pallet_key=CONCAT('RECEIVED:',p.pallet_id) AND k.status='ACTIVE' " +
"AND (k.expires_at IS NULL OR k.expires_at>CURRENT_TIMESTAMP) AND (#{draftToken} IS NULL OR k.draft_token IS NULL OR k.draft_token != #{draftToken})) " +
"ORDER BY r.received_at,r.id</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId, @Param("orderNo") String orderNo,
@Param("fba") String fba, @Param("draftToken") String draftToken);
@Select("<script>" + SELECT + "WHERE r.customer_id=#{customerId} AND r.id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" + ELIGIBLE + " ORDER BY r.id FOR UPDATE</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId, @Param("ids") List<Long> ids);
@Select("<script>SELECT DISTINCT customer_id FROM ll_return_stock_receipt WHERE id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<String> customers(@Param("ids") List<Long> ids);
}
@@ -3,42 +3,87 @@ package com.baiyue.lastleg.dispatch.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record DispatchPalletCandidate(
Long planId,
Long planPalletId,
Long warehousePalletId,
Long orderId,
String customerId,
String customerName,
String planNo,
String orderType,
BigDecimal volumeWeightKg,
BigDecimal grossWeightKg,
String containerNo,
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String orderNo,
String orderRefs,
String inboundCode,
String palletNo,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String fbaWarehouseCode,
Integer packageCount,
BigDecimal volumeCbm,
String sourceStage,
String cartonMarks,
String relabelCartonMarks,
String destinationType,
String deliveryAddress,
String postalCode,
String contactName,
String contactPhone
) {
@lombok.Data
@lombok.NoArgsConstructor
@lombok.experimental.Accessors(fluent = true)
@com.fasterxml.jackson.annotation.JsonAutoDetect(fieldVisibility = com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY)
public class DispatchPalletCandidate {
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long orderId;
private String customerId;
private String customerName;
private String planNo;
private String orderType;
private BigDecimal volumeWeightKg;
private BigDecimal grossWeightKg;
private String containerNo;
private LocalDateTime appointmentAt;
private LocalDateTime expectedLoadingAt;
private String overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private String orderNo;
private String orderRefs;
private String inboundCode;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String fbaWarehouseCode;
private Integer packageCount;
private BigDecimal volumeCbm;
private String sourceStage;
private String cartonMarks;
private String relabelCartonMarks;
private String destinationType;
private String deliveryAddress;
private String postalCode;
private String contactName;
private String contactPhone;
private Long returnReceiptId;
private String returnReceiptNo;
@com.fasterxml.jackson.annotation.JsonProperty("sourceType")
public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; }
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String customerId, String customerName, String planNo, String orderType, BigDecimal volumeWeightKg, BigDecimal grossWeightKg, String containerNo, LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm, String sourceStage, String cartonMarks, String relabelCartonMarks, String destinationType, String deliveryAddress, String postalCode, String contactName, String contactPhone) {
this.planId = planId;
this.planPalletId = planPalletId;
this.warehousePalletId = warehousePalletId;
this.orderId = orderId;
this.customerId = customerId;
this.customerName = customerName;
this.planNo = planNo;
this.orderType = orderType;
this.volumeWeightKg = volumeWeightKg;
this.grossWeightKg = grossWeightKg;
this.containerNo = containerNo;
this.appointmentAt = appointmentAt;
this.expectedLoadingAt = expectedLoadingAt;
this.overseasWarehouseId = overseasWarehouseId;
this.overseasWarehouseCode = overseasWarehouseCode;
this.overseasWarehouseName = overseasWarehouseName;
this.orderNo = orderNo;
this.orderRefs = orderRefs;
this.inboundCode = inboundCode;
this.palletNo = palletNo;
this.destinationWarehouseId = destinationWarehouseId;
this.destinationWarehouseCode = destinationWarehouseCode;
this.destinationWarehouseName = destinationWarehouseName;
this.fbaWarehouseCode = fbaWarehouseCode;
this.packageCount = packageCount;
this.volumeCbm = volumeCbm;
this.sourceStage = sourceStage;
this.cartonMarks = cartonMarks;
this.relabelCartonMarks = relabelCartonMarks;
this.destinationType = destinationType;
this.deliveryAddress = deliveryAddress;
this.postalCode = postalCode;
this.contactName = contactName;
this.contactPhone = contactPhone;
}
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId,
@@ -15,6 +15,7 @@ public class DispatchPdaPickOrder {
private String customerId;
private Long pickId;
private Long receiptId;
private Long returnReceiptId;
private Long orderId;
private Long packages;
private Long productQuantity;
@@ -81,7 +81,7 @@ public class DispatchPdaWorkflowService {
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
}
@Transactional
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId,
String operatorId, String operatorName,
DispatchPdaRequests.Pick request) {
@@ -147,7 +147,7 @@ public class DispatchPdaWorkflowService {
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(customerId, pick,
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null);
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId());
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.TreeMap;
import java.util.List;
import java.util.Map;
@@ -24,12 +24,14 @@ public class DispatchPickOrderAttributionService {
@Resource private DispatchPdaPickOrderMapper attributions;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper history;
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
/** Acquire source order locks before any inventory lock, matching order hold/release. */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void preparePicking(String customerId, long positionId, Long fallbackOrderId) {
var sourceIds = new java.util.TreeSet<Long>();
attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId()));
returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId()));
if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId);
if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order");
for (Long orderId : sourceIds) {
@@ -42,6 +44,10 @@ public class DispatchPickOrderAttributionService {
.anyMatch(receipt -> !sourceIds.contains(receipt.getOrderId()))) {
throw conflict("PDA pick source orders changed; reload the task");
}
for (var receipt : returnReceipts.available(customerId, positionId)) {
if (!sourceIds.contains(receipt.getOrderId())) throw conflict("Returned source orders changed; reload the task");
if (returnReceipts.lock(customerId, receipt.getId()) == null) throw conflict("Returned stock receipt disappeared");
}
}
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
@@ -55,14 +61,18 @@ public class DispatchPickOrderAttributionService {
* authoritative tail-leg order, so use that order only for relabel picks.
*/
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) {
return attribute(customerId, pick, fallbackOrderId, null);
}
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) {
long remainingPackages = value(pick.getPackages());
long totalPackages = remainingPackages;
long totalQuantity = value(pick.getProductQuantity());
long allocatedPackages = 0;
long allocatedQuantity = 0;
Map<Long, Long> pickedByOrder = new LinkedHashMap<>();
for (InboundStockReceipt receipt : attributions.availableReceipts(customerId,
pick.getInventoryPositionId())) {
Map<Long, Long> pickedByOrder = new TreeMap<>();
List<DispatchPdaPickOrder> pendingAttributions = new java.util.ArrayList<>();
for (InboundStockReceipt receipt : selectedReturnReceiptId == null ? attributions.availableReceipts(customerId,
pick.getInventoryPositionId()) : List.<InboundStockReceipt>of()) {
if (remainingPackages == 0) break;
long receiptPackages = value(receipt.getReceivedPackages())
- attributions.attributedPackages(customerId, receipt.getId());
@@ -80,17 +90,36 @@ public class DispatchPickOrderAttributionService {
row.setPackages(packages);
row.setProductQuantity(quantity);
row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt());
attributions.insert(row);
pendingAttributions.add(row);
pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum);
remainingPackages -= packages;
allocatedPackages = nextPackages;
allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) {
for (var receipt : returnReceipts.available(customerId, pick.getInventoryPositionId())) {
if (remainingPackages == 0) break;
if (selectedReturnReceiptId != null && !selectedReturnReceiptId.equals(receipt.getId())) continue;
var locked = returnReceipts.lock(customerId, receipt.getId());
if (locked == null) throw conflict("Returned stock receipt disappeared");
long available = locked.getReceivedPackages() - returnReceipts.picked(customerId, locked.getId());
if (available <= 0) continue;
long packages = Math.min(remainingPackages, available);
long nextPackages = allocatedPackages + packages;
long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages);
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId());
row.setReturnReceiptId(locked.getId()); row.setOrderId(locked.getOrderId()); row.setPackages(packages);
row.setProductQuantity(nextQuantity - allocatedQuantity);
row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt());
pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum);
remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) {
pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum);
remainingPackages = 0;
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
pendingAttributions.forEach(attributions::insert);
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) {
throw conflict("Picked packages exceed tail-leg order remaining packages");
@@ -102,7 +131,7 @@ public class DispatchPickOrderAttributionService {
/** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */
public List<OrderPackages> depart(String customerId, String outboundNo, String dispatchNo, List<DispatchPdaPick> picks,
LocalDateTime departedAt) {
Map<Long, Long> shippedByOrder = new LinkedHashMap<>();
Map<Long, Long> shippedByOrder = new TreeMap<>();
for (DispatchPdaPick pick : picks) {
for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) {
shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum);
@@ -117,7 +146,7 @@ public class DispatchPickOrderAttributionService {
throw conflict("Tail-leg outbound sub-order status could not be synchronized");
}
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
String to = shipped == value(before.getPackageCount()) + orders.returnedPackages(customerId, entry.getKey()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
row.setCustomerId(customerId);
row.setOrderId(entry.getKey());
@@ -62,6 +62,14 @@ public class DispatchService {
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@Resource private DispatchCandidateMapper candidates;
@Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates;
public String resolveCustomerScopeForSources(List<Long> planPalletIds, List<Long> returnReceiptIds) {
var scopes = new java.util.ArrayList<String>();
if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds));
if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds));
return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources");
}
@Resource private DispatchPdaTaskMapper pdaTasks;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private LastlegOrderMapper tailOrders;
@@ -120,8 +128,11 @@ public class DispatchService {
.toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList();
}
}
if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(),
returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList();
return result;
}
private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); }
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) {
return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null);
}
@@ -134,7 +145,7 @@ public class DispatchService {
private List<DispatchContainerResponse> containers(String token) {
return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(),
locks.findActiveByDraftContainer(token, c.getId()).stream()
.map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList();
.map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList();
}
@Transactional
@@ -177,7 +188,7 @@ public class DispatchService {
boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(500); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer);
}
if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在");
DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId());
DispatchPalletCandidate candidate = requireDraftSource(customerId, request);
LocalDateTime now = LocalDateTime.now();
DispatchPalletLock active = activeLock(customerId, candidate);
if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) {
@@ -199,6 +210,7 @@ public class DispatchService {
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setReturnReceiptId(candidate.returnReceiptId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDraftToken(request.draftToken());
lock.setDraftContainerId(request.draftContainerId());
@@ -218,7 +230,7 @@ public class DispatchService {
@Transactional
public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) {
DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId());
DispatchPalletCandidate candidate = requireDraftSource(customerId, request);
DispatchPalletLock active = activeLock(customerId, candidate);
if (active == null) return;
requireDraftOwner(active, operatorId, request.draftToken());
@@ -226,7 +238,9 @@ public class DispatchService {
DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId());
DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId())
: items.selectOne(new LambdaQueryWrapper<DispatchItem>().eq(DispatchItem::getCustomerId, customerId)
.eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE"));
if (item != null) removeLockedItem(dispatch, item, List.of(active));
else releaseLock(active, LocalDateTime.now());
return;
@@ -261,6 +275,7 @@ public class DispatchService {
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
if (StringUtils.hasText(request.relabelNo())) {
if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用");
return preArrangeRelabel(customerId, operatorId, request);
}
if (!StringUtils.hasText(customerId)) {
@@ -268,13 +283,20 @@ public class DispatchService {
"No customer business data is available for dispatch");
}
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
List<Long> receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList();
if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源");
if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size()
|| receiptIds.size() != request.returnReceiptIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
}
List<DispatchPalletCandidate> selected = candidates.lockSelected(customerId, palletIds);
if (selected.size() != palletIds.size()) {
List<DispatchPalletCandidate> selected = new java.util.ArrayList<>();
if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds));
if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds));
if (selected.size() != palletIds.size() + receiptIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size())
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源");
if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) {
boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType()));
throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba
@@ -338,24 +360,22 @@ public class DispatchService {
if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch);
List<DispatchItem> previousItems = items.findByDispatchOrder(customerId, dispatch.getId());
Set<Long> selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId)
Set<String> selectedPalletIds = selected.stream().map(DispatchService::selectionKey)
.collect(Collectors.toSet());
if (refreshing) {
List<DispatchPalletLock> previousLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
previousItems.stream().filter(item -> item.getPlanPalletId() != null
&& !selectedPalletIds.contains(item.getPlanPalletId()))
previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item)))
.forEach(item -> {
previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()))
previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()))
.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
});
}
Set<Long> existingPalletIds = previousItems.stream()
.filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId()))
.map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet());
Set<String> existingPalletIds = previousItems.stream()
.map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet());
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = activeLock(customerId, candidate);
@@ -373,6 +393,7 @@ public class DispatchService {
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setReturnReceiptId(candidate.returnReceiptId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDispatchOrderId(dispatch.getId());
lock.setOperatorId(trimToNull(operatorId));
@@ -387,13 +408,14 @@ public class DispatchService {
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
}
if (existingPalletIds.contains(candidate.planPalletId())) continue;
if (existingPalletIds.contains(selectionKey(candidate))) continue;
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
item.setCustomerId(customerId);
item.setPlanId(candidate.planId());
item.setPlanPalletId(candidate.planPalletId());
item.setReturnReceiptId(candidate.returnReceiptId());
item.setWarehousePalletId(candidate.warehousePalletId());
item.setOrderId(candidate.orderId());
item.setOverseasWarehouseId(candidate.overseasWarehouseId());
@@ -564,7 +586,7 @@ public class DispatchService {
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())
|| (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
.toList();
return removeLockedItem(dispatch, item, matchingLocks);
@@ -705,6 +727,15 @@ public class DispatchService {
}
return found.get(0);
}
private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); }
private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); }
private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) {
if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源");
if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId());
var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId()));
if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排");
return found.get(0);
}
private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) {
DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey());
@@ -41,6 +41,7 @@ public class InventoryCommandService {
@Resource private InventoryLedgerMapper ledgers;
@Resource private InventoryOperationMapper operations;
@Resource private InventoryCommandMapper commands;
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
@Resource private WarehouseResourceGateway warehouseGateway;
@Resource private ObjectMapper objectMapper;
@@ -335,6 +336,17 @@ public class InventoryCommandService {
InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey);
if (existing != null) return replayReturned(existing, requestHash);
Map<Long, com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner> returnOwners = new LinkedHashMap<>();
for (var source : sources.values()) {
var owner = returnReceipts.owner(customerId, exceptionNo, outboundNo, source.outboundPalletItemId(), source.inventoryPositionId());
if (owner == null || owner.orderId() == null || !Objects.equals(owner.inboundOrderId(), request.inboundOrderId()))
throw conflict("Returned stock receipt is missing the original order attribution");
returnOwners.put(source.outboundPalletItemId(), owner);
}
for (Long orderId : returnOwners.values().stream().map(com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner::orderId).distinct().sorted().toList()) {
if (returnReceipts.lockOrder(customerId, orderId) == null) throw conflict("Returned stock source order is missing or held");
}
InventoryOperation operation = new InventoryOperation();
operation.setId(IdWorker.getId());
operation.setCustomerId(customerId);
@@ -430,6 +442,16 @@ public class InventoryCommandService {
normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(),
"Exception return received with evidence " + request.evidenceFileIds().size());
ledgerIds.add(ledger.getId());
var owner = returnOwners.get(source.outboundPalletItemId());
var stockReceipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt();
stockReceipt.setId(IdWorker.getId()); stockReceipt.setCustomerId(customerId);
stockReceipt.setReceiptNo("RSR" + stockReceipt.getId()); stockReceipt.setInventoryLedgerId(ledger.getId());
stockReceipt.setExceptionOrderId(owner.exceptionOrderId()); stockReceipt.setOrderId(owner.orderId());
stockReceipt.setInboundOrderId(owner.inboundOrderId()); stockReceipt.setSourceOutboundNo(outboundNo);
stockReceipt.setSourceOutboundPalletItemId(source.outboundPalletItemId()); stockReceipt.setSourceInventoryPositionId(source.inventoryPositionId());
stockReceipt.setInventoryBalanceId(balance.getId()); stockReceipt.setInventoryPositionId(target.getId());
stockReceipt.setReceivedPackages(receipt.receivedPackages()); stockReceipt.setReceivedAt(ledger.getOccurredAt());
stockReceipt.setCreatedAt(ledger.getOccurredAt()); returnReceipts.insert(stockReceipt);
restoredPackages += receipt.receivedPackages();
}
long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum();
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** An immutable receipt from a real return ledger; it is not another customer order or initial inbound receipt. */
@Data
@TableName("ll_return_stock_receipt")
public class ReturnStockReceipt {
@TableId private Long id;
private String customerId;
private String receiptNo;
private Long inventoryLedgerId;
private Long exceptionOrderId;
private Long orderId;
private Long inboundOrderId;
private String sourceOutboundNo;
private Long sourceOutboundPalletItemId;
private Long sourceInventoryPositionId;
private Long inventoryBalanceId;
private Long inventoryPositionId;
private Long receivedPackages;
private LocalDateTime receivedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.inventory.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface ReturnStockReceiptMapper extends BaseMapper<ReturnStockReceipt> {
@Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE")
Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id);
record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { }
@Select("SELECT e.id AS exception_order_id,e.last_mile_order_id AS order_id,e.inbound_order_id " +
"FROM ll_exception_order e JOIN ll_order o ON o.customer_id=e.customer_id AND o.id=e.last_mile_order_id " +
"JOIN ll_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " +
"WHERE e.customer_id=#{customerId} AND e.exception_no=#{exceptionNo} AND e.outbound_no=#{outboundNo} " +
"AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE' AND e.status='PROCESSING' " +
"AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}")
ReturnOwner owner(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo,
@Param("outboundNo") String outboundNo, @Param("itemId") Long itemId, @Param("positionId") Long positionId);
@Select("SELECT r.* FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=#{positionId} " +
"AND p.customer_id=r.customer_id AND p.balance_id=r.inventory_balance_id " +
"WHERE r.customer_id=#{customerId} AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) " +
"FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) " +
"ORDER BY r.received_at,r.id")
List<ReturnStockReceipt> available(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("SELECT * FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE")
ReturnStockReceipt lock(@Param("customerId") String customerId, @Param("id") Long id);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND return_receipt_id=#{id}")
long picked(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -220,18 +220,24 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Param("expectedVersion") int expectedVersion,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
String RETURNED_PACKAGES = "(SELECT COALESCE(SUM(r.received_packages),0) FROM ll_return_stock_receipt r " +
"WHERE r.customer_id=ll_order.customer_id AND r.order_id=ll_order.id)";
@Select("SELECT COALESCE(SUM(received_packages),0) FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND order_id=#{id}")
long returnedPackages(@Param("customerId") String customerId, @Param("id") Long id);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " +
"WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " +
"AND package_count-shipped_package_count-picked_package_count >= #{packages}")
"AND #{packages}>0 AND package_count+" + RETURNED_PACKAGES + "-shipped_package_count-picked_package_count >= #{packages}")
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages);
@Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
@Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count+" + RETURNED_PACKAGES + " " +
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," +
"picked_package_count=picked_package_count-#{packages}," +
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " +
"WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " +
"AND shipped_package_count+#{packages} <= package_count")
"AND #{packages}>0 AND shipped_package_count+#{packages} <= package_count+" + RETURNED_PACKAGES)
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);