feat(fleet): bind quote versions and reconcile actual return quantities
This commit is contained in:
@@ -55,6 +55,7 @@ public class DeliveryOrder {
|
||||
private LocalDateTime dispatchedAt;
|
||||
private LocalDateTime expectedArrivalAt;
|
||||
private LocalDateTime signedAt;
|
||||
private Integer signedPackages;
|
||||
private String remark;
|
||||
private String idempotencyKey;
|
||||
private String createdBy;
|
||||
|
||||
+24
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -10,6 +11,29 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
||||
|
||||
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_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_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " +
|
||||
"JOIN ll_outbound_pallet_item oi ON oi.customer_id=d.customer_id AND oi.outbound_order_id=d.outbound_order_id AND oi.id=i.outbound_pallet_item_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 " +
|
||||
"AND l.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id) " +
|
||||
"LEFT JOIN ll_dispatch_item di ON di.customer_id=d.customer_id AND di.dispatch_order_id=d.dispatch_order_id " +
|
||||
"AND di.warehouse_pallet_id=oi.warehouse_pallet_id " +
|
||||
"AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " +
|
||||
"ON a.customer_id=p.customer_id AND a.pick_id=p.id " +
|
||||
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
|
||||
"WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
|
||||
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
|
||||
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} ORDER BY l.occurred_at,l.id")
|
||||
List<DeliveryReturnReceiptFact> returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
|
||||
"AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING')")
|
||||
long activeReturns(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
||||
|
||||
@Select("SELECT MAX(created_at) FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} AND operation_node='RETURN_COMPLETED'")
|
||||
java.time.LocalDateTime returnedAt(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
||||
@Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id")
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.delivery.model;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Quantity accounting for the original journey; later post-signature returns belong to another movement. */
|
||||
public record DeliveryCargoQuantities(int shipped, int signed, int returned, int remaining) {
|
||||
public static DeliveryCargoQuantities from(DeliveryOrder order, List<DeliveryReturnReceiptFact> receipts) {
|
||||
if (order.getPackageCount() == null || order.getPackageCount() <= 0) throw invalid("派送缺少实发数量");
|
||||
var ids = new HashSet<Long>();
|
||||
long received = 0;
|
||||
for (var receipt : receipts) {
|
||||
if (receipt.ledgerId() == null || !ids.add(receipt.ledgerId()) || receipt.packages() <= 0 || receipt.receivedAt() == null)
|
||||
throw invalid("退回实际入账流水缺失或来源映射重复");
|
||||
received = Math.addExact(received, receipt.packages());
|
||||
}
|
||||
int shipped = order.getPackageCount();
|
||||
if (received > shipped) throw invalid("退回实收超过原实发数量");
|
||||
if (order.getSignedAt() != null) {
|
||||
Integer signed = order.getSignedPackages();
|
||||
long beforeSignature = receipts.stream().filter(receipt -> !receipt.receivedAt().isAfter(order.getSignedAt())).mapToLong(DeliveryReturnReceiptFact::packages).sum();
|
||||
if (signed == null || signed <= 0 || signed > shipped || beforeSignature < shipped - signed)
|
||||
throw invalid("签收数量快照缺失或与实际退回不守恒");
|
||||
// Do not re-open or reclassify signed cargo when another return is later received.
|
||||
return new DeliveryCargoQuantities(shipped, signed, shipped - signed, 0);
|
||||
}
|
||||
if (order.getSignedPackages() != null) throw invalid("签收数量缺少签收时间");
|
||||
return new DeliveryCargoQuantities(shipped, 0, Math.toIntExact(received), shipped - Math.toIntExact(received));
|
||||
}
|
||||
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.lastleg.delivery.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Immutable actual receipt, attributed to the original dispatch item and physical pallet. */
|
||||
public record DeliveryReturnReceiptFact(Long ledgerId, Long dispatchItemId, Long palletId, long packages, LocalDateTime receivedAt) { }
|
||||
+1
-1
@@ -31,7 +31,7 @@ public final class DeliveryStatusPolicy {
|
||||
case "WAIT_DELIVERY" -> List.of(
|
||||
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
|
||||
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
|
||||
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
+12
-1
@@ -33,9 +33,13 @@ public class DeliveryCompletedOutboxService {
|
||||
@Resource private LastlegEventOutboxMapper outbox;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void publish(DeliveryOrder delivery, List<LastlegOrder> orders, Instant completedAt) {
|
||||
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) {
|
||||
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
|
||||
@@ -59,7 +63,14 @@ public class DeliveryCompletedOutboxService {
|
||||
throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突");
|
||||
}
|
||||
}
|
||||
if (bySourceInbound.isEmpty()) return Publication.NO_DOMESTIC_SOURCE;
|
||||
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;
|
||||
}
|
||||
|
||||
private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) {
|
||||
|
||||
+24
-5
@@ -86,12 +86,18 @@ public class DeliveryDocumentService {
|
||||
}).toList();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
|
||||
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
|
||||
boolean autoCompleted = receiptSaved && order.getSignedAt() == null && !"DELIVERED".equals(order.getStatus());
|
||||
int signedPackages = 0;
|
||||
if (autoCompleted) {
|
||||
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) throw conflict("当前派送状态不允许通过签收文件完成");
|
||||
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus()))
|
||||
throw conflict("当前派送状态不允许通过签收文件完成");
|
||||
// A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible.
|
||||
var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo());
|
||||
if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送");
|
||||
if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收");
|
||||
try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); }
|
||||
catch (IllegalArgumentException error) { throw conflict(error.getMessage()); }
|
||||
if (signedPackages <= 0) throw conflict("本次派送已无待签收货物");
|
||||
}
|
||||
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
|
||||
order.setDocumentsJson(write(categories));
|
||||
@@ -100,11 +106,23 @@ public class DeliveryDocumentService {
|
||||
if (autoCompleted) {
|
||||
order.setStatus("DELIVERED");
|
||||
order.setSignedAt(now);
|
||||
order.setSignedPackages(signedPackages);
|
||||
}
|
||||
orders.updateById(order);
|
||||
if (autoCompleted) {
|
||||
List<LastlegOrder> completedOrders = completeRelatedOrders(order, now);
|
||||
deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC));
|
||||
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);
|
||||
}
|
||||
customerVisits.ensureForCompletedDelivery(order, now);
|
||||
}
|
||||
Detail result = detail(order, false);
|
||||
@@ -156,7 +174,8 @@ public class DeliveryDocumentService {
|
||||
// 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("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED");
|
||||
order.setSubOrderStatus(delivery.getSignedPackages().equals(delivery.getPackageCount())
|
||||
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
|
||||
order.setUpdatedAt(now);
|
||||
lastlegOrders.updateById(order);
|
||||
}
|
||||
@@ -167,7 +186,7 @@ public class DeliveryDocumentService {
|
||||
operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId());
|
||||
operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
|
||||
operation.setOperationNode("RECEIPT_AUTO_COMPLETE");
|
||||
operation.setResult("签收文件保存成功,派送单自动完成");
|
||||
operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱");
|
||||
operation.setCreatedAt(now); operations.insert(operation);
|
||||
}
|
||||
private String hash(Object value) {
|
||||
|
||||
Reference in New Issue
Block a user