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) {
|
||||
|
||||
+27
-5
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
@@ -159,17 +160,26 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
|
||||
var rows = outbound == null ? java.util.List.<DeliveryOrder>of() : lock
|
||||
? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
|
||||
var outcomes = rows.stream().map(row -> new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
|
||||
if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum()
|
||||
!= Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())))
|
||||
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物");
|
||||
var outcomes = rows.stream().map(row -> {
|
||||
var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()));
|
||||
return new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
|
||||
"PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()),
|
||||
originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0),
|
||||
originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
|
||||
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null)).toList();
|
||||
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null,
|
||||
quantities.signed(), quantities.returned(), quantities.remaining());
|
||||
}).toList();
|
||||
String reason = null;
|
||||
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成";
|
||||
else if (outcomes.isEmpty()) reason = "缺少对应派送单";
|
||||
else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0)
|
||||
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物";
|
||||
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
|
||||
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || row.remainingPackages() != 0
|
||||
|| row.signedPackages() + row.returnedPackages() != row.packages()
|
||||
|| !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
|
||||
reason = "所有派送单须已签收,或完成真实退回入仓";
|
||||
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason);
|
||||
}
|
||||
@@ -194,17 +204,25 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|
||||
|| !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId())
|
||||
|| row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null
|
||||
|| row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus()))
|
||||
|| row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus()))
|
||||
throw conflict("派送货物归属或执行状态与原始发运不一致");
|
||||
Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
|
||||
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null;
|
||||
if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null)
|
||||
throw conflict("派送终态缺少真实签收或退回完成证据");
|
||||
var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId());
|
||||
var quantities = quantities(row, receipts);
|
||||
if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物");
|
||||
String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null,
|
||||
row.getDeliveryAddress(), row.getPostalCode());
|
||||
if (key == null) throw conflict("派送单缺少真实卸货目的地");
|
||||
var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(),
|
||||
dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal);
|
||||
dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal,
|
||||
originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> {
|
||||
if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属");
|
||||
return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo(
|
||||
receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt()));
|
||||
}).toList());
|
||||
facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(),
|
||||
new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone())));
|
||||
packages = Math.addExact(packages, row.getPackageCount());
|
||||
@@ -236,6 +254,10 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|
||||
}
|
||||
|
||||
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
|
||||
private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> receipts) {
|
||||
try { return DeliveryCargoQuantities.from(row, receipts); }
|
||||
catch (IllegalArgumentException error) { throw conflict(error.getMessage()); }
|
||||
}
|
||||
private static boolean originallySigned(DeliveryOrder order) {
|
||||
return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus());
|
||||
}
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ public class ExceptionOrder {
|
||||
private Integer packageCount;
|
||||
private String returnDirection;
|
||||
private String returnLogisticsNo;
|
||||
private String returnContextJson;
|
||||
private String handlingAction;
|
||||
private String handlingResult;
|
||||
private String evidenceFileIdsJson;
|
||||
|
||||
+1
@@ -19,5 +19,6 @@ public class ExceptionReturnItem {
|
||||
private String skuCode;
|
||||
private Long maxReturnPackages;
|
||||
private Long approvedReturnPackages;
|
||||
private Long receivedPackages;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Select;
|
||||
@Mapper
|
||||
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
|
||||
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
|
||||
"AND exception_type='RETURN' LIMIT 1")
|
||||
"AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING') LIMIT 1")
|
||||
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
|
||||
@Param("deliveryOrderId") Long deliveryOrderId);
|
||||
|
||||
|
||||
+12
@@ -13,4 +13,16 @@ public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnIte
|
||||
@Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id")
|
||||
List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
|
||||
@Param("exceptionOrderId") Long exceptionOrderId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " +
|
||||
"JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " +
|
||||
"WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId}")
|
||||
long receivedByDelivery(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " +
|
||||
"JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " +
|
||||
"WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId} " +
|
||||
"AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}")
|
||||
long receivedBySource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId,
|
||||
@Param("itemId") Long itemId, @Param("positionId") Long positionId);
|
||||
}
|
||||
|
||||
+75
-17
@@ -130,7 +130,8 @@ public class ExceptionOrderService {
|
||||
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
|
||||
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")
|
||||
&& !"RETURN_PENDING".equals(delivery.getStatus())) {
|
||||
throw conflict("当前派送状态不允许标记退回");
|
||||
}
|
||||
return returnCandidates(delivery);
|
||||
@@ -157,19 +158,13 @@ public class ExceptionOrderService {
|
||||
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
|
||||
ExceptionOrderRequests.MarkReturn request) {
|
||||
String requestHash = hash(request);
|
||||
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
|
||||
if (replay != null) return replayCreated(replay, requestHash);
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
|
||||
throw conflict("当前派送状态不允许标记退回");
|
||||
}
|
||||
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
|
||||
if (existing != null) throw conflict("该派送单已存在退回异常单");
|
||||
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
|
||||
|
||||
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
|
||||
ExceptionOrderResponses.Relation selected = candidates.stream()
|
||||
@@ -198,6 +193,7 @@ public class ExceptionOrderService {
|
||||
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
|
||||
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
|
||||
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
|
||||
item.setReceivedPackages(0L);
|
||||
item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item);
|
||||
}
|
||||
|
||||
@@ -222,6 +218,17 @@ public class ExceptionOrderService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Caller keeps the delivery row locked until return creation commits. */
|
||||
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN"))
|
||||
throw conflict("当前派送状态不允许标记退回");
|
||||
if (exceptionOrders.findDeliveryReturn(customerId, delivery.getId()) != null)
|
||||
throw conflict("该派送单已存在退回异常单");
|
||||
return delivery;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId,
|
||||
String exceptionNo,
|
||||
@@ -247,6 +254,7 @@ public class ExceptionOrderService {
|
||||
ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim());
|
||||
if (replay != null) return replayResult(replay, requestHash);
|
||||
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
|
||||
restoreCancelledReturn(order);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason()));
|
||||
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
|
||||
@@ -266,29 +274,42 @@ public class ExceptionOrderService {
|
||||
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
|
||||
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
|
||||
ReturnedInventoryRestoreResponse restored = null;
|
||||
List<ExceptionReturnItem> stored = List.of();
|
||||
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
|
||||
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
|
||||
var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo());
|
||||
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送单不是退回处理中状态");
|
||||
stored = returnItems.findByException(customerId, order.getId());
|
||||
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
|
||||
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
|
||||
Map<Long, Long> received = ReturnReceiptPolicy.quantities(stored, request.receipts());
|
||||
restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
|
||||
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
|
||||
order.getOverseasWarehouseId(), order.getInboundOrderId(),
|
||||
stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
|
||||
stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
|
||||
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
|
||||
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(),
|
||||
item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(),
|
||||
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
|
||||
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
|
||||
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
|
||||
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
|
||||
request.evidenceFileIds().stream().map(String::valueOf).toList()));
|
||||
if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum())
|
||||
throw conflict("退回库存实际入账数量与本次实收不一致");
|
||||
for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) {
|
||||
item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(item.getId()));
|
||||
returnItems.updateById(item);
|
||||
}
|
||||
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
|
||||
throw badRequest("非退回海外仓异常不能提交库存实收明细");
|
||||
}
|
||||
LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt();
|
||||
order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult()));
|
||||
order.setEvidenceFileIdsJson(write(request.evidenceFileIds()));
|
||||
boolean completed = stored.isEmpty() || ReturnReceiptPolicy.complete(stored);
|
||||
order.setStatus(completed ? "COMPLETED" : "PROCESSING"); order.setHandlingResult(trim(request.handlingResult()));
|
||||
List<Long> evidence = new ArrayList<>(readEvidenceFileIds(order.getEvidenceFileIdsJson()));
|
||||
evidence.addAll(request.evidenceFileIds());
|
||||
order.setEvidenceFileIdsJson(write(evidence.stream().distinct().toList()));
|
||||
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
|
||||
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now);
|
||||
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
|
||||
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
|
||||
closeReturnedDelivery(order, operatorId, now);
|
||||
long restoredPackages = restored == null ? 0 : restored.restoredPackages();
|
||||
@@ -352,6 +373,11 @@ public class ExceptionOrderService {
|
||||
value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName());
|
||||
value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction));
|
||||
value.setReturnLogisticsNo(trim(logisticsNo));
|
||||
if (delivery != null) {
|
||||
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
|
||||
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(),
|
||||
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
|
||||
}
|
||||
if (StringUtils.hasText(direction)) {
|
||||
value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理");
|
||||
value.setHandlerId(operatorId); value.setHandlerName(operatorName);
|
||||
@@ -414,7 +440,9 @@ public class ExceptionOrderService {
|
||||
DeliveryOrder delivery) {
|
||||
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
|
||||
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
|
||||
.map(Source::response).toList();
|
||||
.map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource(
|
||||
delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId())))
|
||||
.filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList();
|
||||
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
|
||||
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
|
||||
relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(),
|
||||
@@ -476,7 +504,8 @@ public class ExceptionOrderService {
|
||||
return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
|
||||
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
|
||||
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(),
|
||||
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList();
|
||||
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item)))
|
||||
.filter(item -> item.maxReturnPackages() > 0).toList();
|
||||
}
|
||||
|
||||
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
|
||||
@@ -506,10 +535,15 @@ public class ExceptionOrderService {
|
||||
|
||||
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
|
||||
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
|
||||
// Closing a non-warehouse case does not establish physical cargo handoff or inventory receipt.
|
||||
if (!"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) return;
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
|
||||
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
|
||||
throw conflict("关联派送单不是退货处理中状态");
|
||||
}
|
||||
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
|
||||
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致");
|
||||
if (received < delivery.getPackageCount()) return;
|
||||
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
@@ -526,6 +560,30 @@ public class ExceptionOrderService {
|
||||
operation.setCreatedAt(now); deliveryOperations.insert(operation);
|
||||
}
|
||||
|
||||
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
|
||||
|
||||
private void restoreCancelledReturn(ExceptionOrder order) {
|
||||
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
|
||||
ReturnContext previous;
|
||||
try { previous = objectMapper.readValue(order.getReturnContextJson(), ReturnContext.class); }
|
||||
catch (Exception exception) { throw conflict("退回异常缺少原始状态,不能猜测恢复状态"); }
|
||||
if (previous == null || !Set.of("DELIVERING", "DELIVERED", "REJECTED", "RETURN_PENDING").contains(previous.deliveryStatus()))
|
||||
throw conflict("退回异常原始状态无效");
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
|
||||
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
|
||||
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
|
||||
throw conflict("已有实际退回入仓,不能取消退回事实");
|
||||
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
|
||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
|
||||
if (previous.tailId() != null) {
|
||||
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
|
||||
if (tail == null || !Objects.equals(tail.getCustomerId(), order.getCustomerId())
|
||||
|| !"DELIVERY:RETURN_PENDING".equals(tail.getSubOrderStatus())) throw conflict("关联尾程状态已变化,不能覆盖");
|
||||
tail.setSubOrderStatus(previous.tailStatus()); tail.setAbnormal(previous.abnormal());
|
||||
tail.setUpdatedAt(LocalDateTime.now()); lastMileOrders.updateById(tail);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> allowedActions(ExceptionOrder order) {
|
||||
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
|
||||
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.exceptionorder.service;
|
||||
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
final class ReturnReceiptPolicy {
|
||||
private ReturnReceiptPolicy() { }
|
||||
|
||||
static Map<Long, Long> quantities(List<ExceptionReturnItem> sources, List<ReturnReceipt> receipts) {
|
||||
Map<Long, Long> amounts = new LinkedHashMap<>();
|
||||
if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细");
|
||||
for (ReturnReceipt receipt : receipts) {
|
||||
ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId())
|
||||
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst()
|
||||
.orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源"));
|
||||
if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零");
|
||||
amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact);
|
||||
}
|
||||
for (ExceptionReturnItem source : sources) {
|
||||
long total = Math.addExact(received(source), amounts.getOrDefault(source.getId(), 0L));
|
||||
if (total > source.getApprovedReturnPackages()) throw conflict("累计实收箱数超过批准退回数量");
|
||||
}
|
||||
return amounts;
|
||||
}
|
||||
|
||||
static long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); }
|
||||
static boolean complete(List<ExceptionReturnItem> items) {
|
||||
return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages());
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+6
@@ -4,11 +4,17 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||
public interface FleetWarehouseClient {
|
||||
@GetMapping("/internal/warehouses/order-catalog")
|
||||
ApiResponse<List<Warehouse>> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token);
|
||||
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
|
||||
ApiResponse<Detail> activeDetail(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("warehouseId") long warehouseId);
|
||||
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
|
||||
record Identity(Long id, Long tenantId, String warehouseType, String status, String timezone) { }
|
||||
record Address(Long warehouseId, Long tenantId, String city) { }
|
||||
record Detail(Identity warehouse, List<Address> addresses) { }
|
||||
}
|
||||
|
||||
+2
-1
@@ -28,7 +28,7 @@ public class FleetHandoverPdaQuery {
|
||||
FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) {
|
||||
this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json;
|
||||
}
|
||||
public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { }
|
||||
public record Delivery(String deliveryNo, String status, int packages, int remainingPackages, Instant terminalAt, FleetTransitEvidence.Transport transport) { }
|
||||
public record Handover(String handoverNo, Instant handedOverAt, String location,
|
||||
FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { }
|
||||
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
|
||||
@@ -53,6 +53,7 @@ public class FleetHandoverPdaQuery {
|
||||
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
|
||||
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
|
||||
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
|
||||
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
|
||||
row.cargo().terminalAt(), row.transport())).toList();
|
||||
var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo)));
|
||||
Handover handover = null;
|
||||
|
||||
+17
-2
@@ -12,7 +12,8 @@ public final class FleetHandoverPolicy {
|
||||
String destinationKey, int packages) { }
|
||||
/** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */
|
||||
public record Delivery(String customerId, String dispatchNo, String deliveryNo,
|
||||
String destinationKey, int packages, Instant terminalAt) { }
|
||||
String destinationKey, int packages, Instant terminalAt, List<ReturnedCargo> returnedCargo) { }
|
||||
public record ReturnedCargo(String itemId, String palletId, int packages, Instant receivedAt) { }
|
||||
public record Pallet(String palletId, Load measuredLoad) { }
|
||||
public record Inspection(List<Cargo> remainingCargo, List<Delivery> completedDeliveries,
|
||||
Set<String> remainingPalletIds, Load occupied) { }
|
||||
@@ -48,6 +49,7 @@ public final class FleetHandoverPolicy {
|
||||
throw invalid("同一实托包含不同卸货目的地,请先核对原始装载");
|
||||
}
|
||||
Map<Destination, Delivery> byDestination = new HashMap<>();
|
||||
Map<Item, Integer> returned = new HashMap<>();
|
||||
Set<DeliveryKey> deliveryKeys = new HashSet<>();
|
||||
List<Delivery> completed = new ArrayList<>();
|
||||
for (Delivery delivery : deliveries) {
|
||||
@@ -60,6 +62,16 @@ public final class FleetHandoverPolicy {
|
||||
throw invalid("派送事实重复或目的地拆分尚未核平");
|
||||
if (!Objects.equals(shipped.get(key), delivery.packages()))
|
||||
throw invalid("派送客户、排车、目的地或箱数与原始发运不一致");
|
||||
if (delivery.returnedCargo() == null) throw invalid("缺少实际退回数量依据");
|
||||
for (ReturnedCargo receipt : delivery.returnedCargo()) {
|
||||
Item item = new Item(delivery.customerId(), delivery.dispatchNo(), receipt.itemId());
|
||||
Cargo source = departureCargo.stream().filter(cargo -> item.equals(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId())))
|
||||
.findFirst().orElseThrow(() -> invalid("退回来源不属于原始发运明细"));
|
||||
if (!destination(source).equals(key) || !Objects.equals(source.palletId(), receipt.palletId()) || receipt.packages() <= 0
|
||||
|| receipt.receivedAt() == null || receipt.receivedAt().isBefore(departedAt) || receipt.receivedAt().isAfter(handedOverAt))
|
||||
throw invalid("退回来源、数量或实际时间与交接不一致");
|
||||
if (returned.merge(item, receipt.packages(), Math::addExact) > source.packages()) throw invalid("累计退回超过原发运明细数量");
|
||||
}
|
||||
if (delivery.terminalAt() != null) {
|
||||
if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt))
|
||||
throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对");
|
||||
@@ -76,7 +88,10 @@ public final class FleetHandoverPolicy {
|
||||
}
|
||||
if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致");
|
||||
List<Cargo> remaining = departureCargo.stream()
|
||||
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList();
|
||||
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null)
|
||||
.map(cargo -> new Cargo(cargo.customerId(), cargo.dispatchNo(), cargo.itemId(), cargo.palletId(), cargo.destinationKey(),
|
||||
cargo.packages() - returned.getOrDefault(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()), 0)))
|
||||
.filter(cargo -> cargo.packages() > 0).toList();
|
||||
if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理");
|
||||
Set<String> palletIds = new TreeSet<>();
|
||||
remaining.forEach(cargo -> palletIds.add(cargo.palletId()));
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.fleet.quote;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class FleetQuoteCandidateModels {
|
||||
private FleetQuoteCandidateModels() { }
|
||||
/** The booking owner resolves this from its actual customer, route, currency and agreed service date. */
|
||||
public record Context(@NotNull @Positive Long customerId,
|
||||
@NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode,
|
||||
@NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn,
|
||||
@NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis) { }
|
||||
public record Query(@NotNull @Positive Long customerId,
|
||||
@NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode,
|
||||
@NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn,
|
||||
@NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis,
|
||||
@Min(1) Integer page,@Min(1) @Max(100) Integer size) {
|
||||
public Query { page=page==null?1:page;size=size==null?20:size; }
|
||||
public Context context(){return new Context(customerId,originCity,destinationCode,currency,effectiveOn,priceBasis);}
|
||||
}
|
||||
public record QuoteRef(@NotBlank @Size(max=64) String quoteNo,@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Pattern(regexp="FULL_DELIVERY|LOOSE_DELIVERY") String priceItem) { }
|
||||
/** Quoted price is a source fact, distinct from the booking owner's negotiated price/quantity/amount. */
|
||||
public record SourceSnapshot(String quoteNo,long recordVersion,String priceItem,String customerId,String customerName,
|
||||
String originCity,String destinationCode,BigDecimal distanceMiles,String currency,String priceBasis,
|
||||
BigDecimal quotedUnitPrice,LocalDate validFrom,LocalDate validTo,LocalDate effectiveOn) { }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.fleet.quote;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** Read-only projection of the existing quote library; no second quote persistence model or writes. */
|
||||
@Repository
|
||||
public class FleetQuoteCandidateRepository {
|
||||
record QuoteRow(String quoteNo,String partyName,String status,long version,String details) { }
|
||||
private static final RowMapper<QuoteRow> ROW=(r,i)->new QuoteRow(r.getString("quote_no"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("details_json"));
|
||||
private final JdbcTemplate jdbc;
|
||||
public FleetQuoteCandidateRepository(JdbcTemplate jdbc){this.jdbc=jdbc;}
|
||||
List<QuoteRow> candidates(FleetQuoteCandidateModels.Context context) {
|
||||
return jdbc.query("""
|
||||
SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote
|
||||
WHERE kind='SALE' AND status='ACTIVE' AND party_id=? AND LOWER(TRIM(origin_city))=?
|
||||
AND destination_code=? AND valid_from<=? AND valid_to>=?
|
||||
ORDER BY valid_from DESC,quote_no DESC LIMIT 5001
|
||||
""",ROW,context.customerId(),context.originCity(),context.destinationCode(),context.effectiveOn(),context.effectiveOn());
|
||||
}
|
||||
QuoteRow lock(String quoteNo) {
|
||||
return jdbc.query("SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote WHERE quote_no=? FOR UPDATE",ROW,quoteNo).stream().findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.lastleg.fleet.quote;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class FleetQuoteCandidateService {
|
||||
private final FleetQuoteCandidateRepository repository;
|
||||
private final FleetQuoteDirectory directory;
|
||||
private final ObjectMapper json;
|
||||
public FleetQuoteCandidateService(FleetQuoteCandidateRepository repository,FleetQuoteDirectory directory,ObjectMapper json){this.repository=repository;this.directory=directory;this.json=json;}
|
||||
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> candidates(FleetQuoteCandidateModels.Query query) {
|
||||
var context=normalize(query.context());directory.name("SALE",context.customerId());
|
||||
var rows=repository.candidates(context);
|
||||
if(rows.size()>5000)throw error(HttpStatus.BAD_REQUEST,"当前路线有效报价过多,请先整理报价库");
|
||||
var candidates=new ArrayList<FleetQuoteCandidateModels.SourceSnapshot>();
|
||||
for(var row:rows) {
|
||||
var draft=read(row.details());
|
||||
if(matches(row,draft,context) && price(draft,context.priceBasis())!=null)candidates.add(snapshot(row,draft,context));
|
||||
}
|
||||
long total=candidates.size();int start=(int)Math.min(total,((long)query.page()-1)*query.size());
|
||||
return new PageResult<>(List.copyOf(candidates.subList(start,Math.min(candidates.size(),start+query.size()))),query.page(),query.size(),total,(total+query.size()-1)/query.size());
|
||||
}
|
||||
|
||||
/** Called only inside the booking owner's write transaction, after its permission and scope checks.
|
||||
* The quote row lock lasts until that transaction commits; caller persists this returned snapshot.
|
||||
* No public write endpoint, no booking writes and no negotiated-price calculation live here.
|
||||
*/
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public FleetQuoteCandidateModels.SourceSnapshot lockAndResolve(FleetQuoteCandidateModels.Context requested,FleetQuoteCandidateModels.QuoteRef reference) {
|
||||
var context=normalize(requested);
|
||||
if(reference==null || reference.quoteNo()==null || reference.quoteNo().isBlank() || reference.recordVersion()==null || reference.recordVersion()<0
|
||||
|| !item(context.priceBasis()).equals(reference.priceItem()))throw error(HttpStatus.BAD_REQUEST,"报价引用或计价项目无效");
|
||||
var row=repository.lock(reference.quoteNo());
|
||||
if(row==null)throw error(HttpStatus.NOT_FOUND,"报价不存在");
|
||||
var draft=read(row.details());
|
||||
if(row.version()!=reference.recordVersion() || !matches(row,draft,context))throw error(HttpStatus.CONFLICT,"报价版本、客户、路线、币种或有效期已不匹配,请重新选择");
|
||||
if(price(draft,context.priceBasis())==null)throw error(HttpStatus.CONFLICT,"所选项目尚未报价,不能作为零价使用");
|
||||
directory.name("SALE",context.customerId());
|
||||
return snapshot(row,draft,context);
|
||||
}
|
||||
private static FleetQuoteCandidateModels.Context normalize(FleetQuoteCandidateModels.Context context) {
|
||||
if(context==null || context.customerId()==null || context.customerId()<=0 || context.originCity()==null || context.originCity().isBlank()
|
||||
|| context.destinationCode()==null || context.destinationCode().isBlank() || context.effectiveOn()==null
|
||||
|| !Set.of("USD","CNY").contains(Objects.toString(context.currency(),"")) || !Set.of("ORDER","PALLET").contains(Objects.toString(context.priceBasis(),"")))
|
||||
throw error(HttpStatus.BAD_REQUEST,"请选择明确客户、路线、币种、服务日期及计价单位");
|
||||
return new FleetQuoteCandidateModels.Context(context.customerId(),context.originCity().trim().toLowerCase(Locale.ROOT),context.destinationCode().trim().toUpperCase(Locale.ROOT),context.currency(),context.effectiveOn(),context.priceBasis());
|
||||
}
|
||||
private static boolean matches(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) {
|
||||
return "ACTIVE".equals(row.status()) && "SALE".equals(d.kind()) && c.customerId().equals(d.partyId())
|
||||
&& c.originCity().equals(d.originCity().trim().toLowerCase(Locale.ROOT)) && c.destinationCode().equals(d.destinationCode().trim().toUpperCase(Locale.ROOT))
|
||||
&& c.currency().equals(d.currency()) && !c.effectiveOn().isBefore(d.validFrom()) && !c.effectiveOn().isAfter(d.validTo());
|
||||
}
|
||||
private static String item(String basis){return "PALLET".equals(basis)?"LOOSE_DELIVERY":"FULL_DELIVERY";}
|
||||
private static BigDecimal price(FleetQuoteModels.Draft d,String basis){return "PALLET".equals(basis)?d.looseDeliveryPrice():d.fullDeliveryPrice();}
|
||||
private static FleetQuoteCandidateModels.SourceSnapshot snapshot(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) {
|
||||
return new FleetQuoteCandidateModels.SourceSnapshot(row.quoteNo(),row.version(),item(c.priceBasis()),d.partyId().toString(),row.partyName(),d.originCity(),d.destinationCode(),d.distanceMiles(),d.currency(),c.priceBasis(),price(d,c.priceBasis()),d.validFrom(),d.validTo(),c.effectiveOn());
|
||||
}
|
||||
private FleetQuoteModels.Draft read(String value){try{return json.readValue(value,FleetQuoteModels.Draft.class);}catch(JsonProcessingException e){throw new IllegalStateException("报价来源快照无法读取",e);}}
|
||||
private static ResponseStatusException error(HttpStatus status,String message){return new ResponseStatusException(status,message);}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.fleet.quote;
|
||||
|
||||
import com.baiyue.common.core.api.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController @Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/quotes/sale-candidates")
|
||||
public class PlatformFleetQuoteCandidateController {
|
||||
private final FleetQuoteCandidateService service;
|
||||
public PlatformFleetQuoteCandidateController(FleetQuoteCandidateService service){this.service=service;}
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
|
||||
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> candidates(@Valid FleetQuoteCandidateModels.Query query){return ApiResponse.success(service.candidates(query));}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class FleetBookingEntity {
|
||||
private String customerName;
|
||||
private String status;
|
||||
private String cargoJson;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson;
|
||||
private String destinationKey;
|
||||
private String destinationLabel;
|
||||
@TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount;
|
||||
|
||||
+10
-3
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
@@ -14,8 +15,13 @@ public final class FleetBookingModels {
|
||||
@Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice,
|
||||
@Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { }
|
||||
public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId,
|
||||
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo) { }
|
||||
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { }
|
||||
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { }
|
||||
public record QuoteCandidatesQuery(@NotNull @Positive Long customerId,@NotBlank @Size(max=160) String destinationKey,
|
||||
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotBlank @Pattern(regexp="PALLET|ORDER") String priceBasis,
|
||||
@Min(1) Integer page,@Min(1) @Max(100) Integer size) {
|
||||
public QuoteCandidatesQuery {page=page==null?1:page;size=size==null?20:size;}
|
||||
}
|
||||
public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { }
|
||||
public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
|
||||
@@ -32,7 +38,8 @@ public final class FleetBookingModels {
|
||||
public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { }
|
||||
public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List<PalletSpaces> pallets,String operatorId,Instant confirmedAt) { }
|
||||
public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName,
|
||||
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt) { }
|
||||
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt,
|
||||
FleetQuoteCandidateModels.SourceSnapshot quoteSource) { }
|
||||
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
|
||||
public record ListResult(long tripRecordVersion, List<Response> records) { }
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** Resolve quote eligibility from the trip and authoritative warehouse facts, never browser hints. */
|
||||
@Component
|
||||
public class FleetBookingQuoteContext {
|
||||
private final FleetWarehouseClient warehouses;
|
||||
private final FleetBookingReservations reservations;
|
||||
private final String token;
|
||||
public FleetBookingQuoteContext(FleetWarehouseClient warehouses, FleetBookingReservations reservations,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.warehouses = warehouses; this.reservations = reservations; this.token = token;
|
||||
}
|
||||
|
||||
public FleetQuoteCandidateModels.Context resolve(FleetTripEntity trip, long customerId,
|
||||
String destinationKey, String currency, String priceBasis) {
|
||||
if (customerId <= 0 || trip.getWarehouseId() == null || trip.getPlannedDepartureAt() == null
|
||||
|| FleetBookingReservations.index(reservations.stops(trip), destinationKey) < 1)
|
||||
throw conflict("请选择本车已有卸货站和委托客户");
|
||||
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4)
|
||||
throw conflict("该卸货站没有报价库目的仓码,请手填本次成交价");
|
||||
if (!Set.of("CNY", "USD").contains(currency == null ? "" : currency)
|
||||
|| !Set.of("PALLET", "ORDER").contains(priceBasis == null ? "" : priceBasis))
|
||||
throw conflict("请选择报价币种及计价方式");
|
||||
FleetWarehouseClient.Detail detail;
|
||||
try {
|
||||
var response = warehouses.activeDetail(token, trip.getWarehouseId());
|
||||
if (response == null || !response.successful() || response.data() == null) throw unavailable();
|
||||
detail = response.data();
|
||||
} catch (ResponseStatusException e) { throw e; }
|
||||
catch (RuntimeException e) { throw unavailable(); }
|
||||
var warehouse = detail.warehouse();
|
||||
if (warehouse == null || !trip.getWarehouseId().equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId())
|
||||
|| !"ACTIVE".equals(warehouse.status()) || !Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType() == null ? "" : warehouse.warehouseType()))
|
||||
throw conflict("起运仓资料已变化,请重新核对");
|
||||
var cities = new TreeSet<String>();
|
||||
if (detail.addresses() != null) for (var address : detail.addresses()) {
|
||||
if (address != null && trip.getWarehouseId().equals(address.warehouseId()) && Long.valueOf(0).equals(address.tenantId())
|
||||
&& address.city() != null && !address.city().isBlank()) cities.add(address.city().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (cities.size() != 1) throw conflict("起运仓城市尚未明确,请完善仓库资料或手填本次成交价");
|
||||
ZoneId zone;
|
||||
try { zone = ZoneId.of(warehouse.timezone() == null ? "" : warehouse.timezone().trim()); }
|
||||
catch (DateTimeException e) { throw conflict("起运仓时区尚未明确,请完善仓库资料或手填本次成交价"); }
|
||||
var date = trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).atZone(zone).toLocalDate();
|
||||
return new FleetQuoteCandidateModels.Context(customerId, cities.first(), destinationKey.substring(4), currency, date, priceBasis);
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "起运仓资料暂不可用,请重试"); }
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -21,8 +22,9 @@ public class FleetBookingReservations {
|
||||
public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); }
|
||||
public FleetBookingModels.Response response(FleetBookingEntity row) {
|
||||
return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(),
|
||||
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row));
|
||||
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row));
|
||||
}
|
||||
public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);}
|
||||
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
|
||||
Long excludedId, FleetBookingEntity addition) {
|
||||
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
|
||||
|
||||
+31
-3
@@ -2,6 +2,10 @@ package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -27,12 +31,21 @@ public class FleetBookingService {
|
||||
private final FleetCustomerDirectory customers;
|
||||
private final ObjectMapper json;
|
||||
private final FleetBookingIncomingGateway incoming;
|
||||
private final FleetBookingQuoteContext quoteContext;
|
||||
private final FleetQuoteCandidateService quotes;
|
||||
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming){
|
||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
||||
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
|
||||
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
|
||||
this.quoteContext=quoteContext;this.quotes=quotes;
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));}
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> quoteCandidates(String tripNo,FleetBookingModels.QuoteCandidatesQuery query){
|
||||
var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis());
|
||||
return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size()));
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
|
||||
var trip=trip(tripNo,true);String hash=hash(request);
|
||||
@@ -41,7 +54,7 @@ public class FleetBookingService {
|
||||
requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId());
|
||||
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo);
|
||||
row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT");
|
||||
apply(trip,row,request.cargo());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
|
||||
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
|
||||
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt());
|
||||
bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator);
|
||||
return result(trip,row,false);
|
||||
@@ -50,7 +63,7 @@ public class FleetBookingService {
|
||||
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
|
||||
var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion());
|
||||
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
|
||||
apply(trip,row,request.cargo());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
|
||||
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
|
||||
@@ -61,6 +74,8 @@ public class FleetBookingService {
|
||||
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
|
||||
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
|
||||
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
|
||||
var source=reservations.quoteSource(row);
|
||||
if(source!=null)row.setQuoteSourceJson(write(resolveQuote(trip,row,new FleetQuoteCandidateModels.QuoteRef(source.quoteNo(),source.recordVersion(),source.priceItem()))));
|
||||
var plan=execution.capacityPlan(tripNo);
|
||||
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运");
|
||||
reservations.segments(trip,plan.capacityPlan(),null,row);
|
||||
@@ -141,6 +156,19 @@ public class FleetBookingService {
|
||||
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
|
||||
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
|
||||
}
|
||||
private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
|
||||
var cargo=reservations.cargo(row);
|
||||
return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference);
|
||||
}
|
||||
private void applyQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
|
||||
if(reference!=null || row.getQuoteSourceJson()!=null)requireQuotePermission();
|
||||
row.setQuoteSourceJson(reference==null?null:write(resolveQuote(trip,row,reference)));
|
||||
}
|
||||
private static void requireQuotePermission(){
|
||||
var authentication=SecurityContextHolder.getContext().getAuthentication();
|
||||
if(authentication==null || authentication.getAuthorities().stream().noneMatch(a->"platform:fleet:quote:list".equals(a.getAuthority())))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"引用或清除报价来源需要报价读取权限");
|
||||
}
|
||||
private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){
|
||||
var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey());
|
||||
if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车");
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import java.util.List;
|
||||
public final class FleetCompletionModels {
|
||||
private FleetCompletionModels() { }
|
||||
public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination,
|
||||
String status, int packages, Instant terminalAt) { }
|
||||
String status, int packages, Instant terminalAt, int signedPackages, int returnedPackages, int remainingPackages) { }
|
||||
public record DispatchCompletion(String dispatchNo, String warehouseId, List<Outcome> outcomes, String blockReason) { }
|
||||
public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List<Outcome> outcomes) { }
|
||||
public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
|
||||
|
||||
+3
-1
@@ -117,7 +117,9 @@ public class FleetTripCompletionService {
|
||||
if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; }
|
||||
}
|
||||
Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now();
|
||||
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.terminalAt() == null
|
||||
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.packages() <= 0 || outcome.remainingPackages() != 0
|
||||
|| outcome.signedPackages() < 0 || outcome.returnedPackages() < 0
|
||||
|| (long) outcome.signedPackages() + outcome.returnedPackages() != outcome.packages() || outcome.terminalAt() == null
|
||||
|| outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单";
|
||||
}
|
||||
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes);
|
||||
|
||||
+4
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -20,6 +22,8 @@ public class PlatformFleetBookingController {
|
||||
public ApiResponse<List<FleetCustomerDirectory.Customer>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));}
|
||||
@GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetBookingModels.ListResult> list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));}
|
||||
@GetMapping("/{tripNo}/bookings/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:trip:list') and hasAuthority('platform:fleet:quote:list')")
|
||||
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String tripNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(tripNo,query));}
|
||||
@PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')")
|
||||
public ApiResponse<FleetBookingModels.Result> create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));}
|
||||
@PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
|
||||
|
||||
Reference in New Issue
Block a user