fix(lastleg): repair dispatch cancellation and return execution contracts
This commit is contained in:
+5
-1
@@ -12,6 +12,7 @@ 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 " +
|
||||
@@ -27,7 +28,10 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
||||
"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")
|
||||
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
|
||||
"FROM ll_return_carton_stock_receipt r JOIN ll_outbound_pallet_item oi ON oi.id=r.source_outbound_pallet_item_id AND oi.customer_id=r.customer_id " +
|
||||
"WHERE r.customer_id=#{customerId} AND r.delivery_order_id=#{deliveryOrderId} " +
|
||||
"GROUP BY r.receipt_no,r.source_dispatch_item_id,oi.warehouse_pallet_id,r.received_at ORDER BY received_at,ledger_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} " +
|
||||
|
||||
+2
-5
@@ -4,7 +4,7 @@ import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
|
||||
/** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */
|
||||
@Mapper
|
||||
public interface DispatchFeeCargoMapper {
|
||||
@Select("""
|
||||
@@ -22,13 +22,10 @@ public interface DispatchFeeCargoMapper {
|
||||
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
|
||||
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
|
||||
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
|
||||
AND (di.plan_pallet_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
|
||||
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id
|
||||
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
|
||||
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
|
||||
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
|
||||
ORDER BY di.id,b.inbound_order_id
|
||||
""")
|
||||
/** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */
|
||||
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
|
||||
}
|
||||
|
||||
+12
-3
@@ -6,9 +6,9 @@ import java.util.Set;
|
||||
|
||||
public final class DeliveryStatusPolicy {
|
||||
private static final Set<String> EDITABLE_TARGETS = Set.of(
|
||||
"WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED");
|
||||
"WAIT_DELIVERY", "DELIVERING", "REJECTED");
|
||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
|
||||
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED"),
|
||||
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
|
||||
"DELIVERED", Set.of(),
|
||||
"REJECTED", Set.of(),
|
||||
@@ -16,23 +16,32 @@ public final class DeliveryStatusPolicy {
|
||||
"RETURNED", Set.of(),
|
||||
"CANCELLED", Set.of());
|
||||
|
||||
/** 状态规则为纯策略,实际出库由命令及查询层提供。 */
|
||||
private DeliveryStatusPolicy() { }
|
||||
|
||||
/** 人工只允许开始派送或拒签,取消必须由出库聚合命令处理。 */
|
||||
public static boolean isEditableTarget(String status) {
|
||||
return EDITABLE_TARGETS.contains(status);
|
||||
}
|
||||
|
||||
/** 校验状态机,签收终态仍由签收资料命令触发。 */
|
||||
public static boolean canTransition(String from, String target) {
|
||||
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
|
||||
}
|
||||
|
||||
/** 返回状态本身允许的动作;执行动作还须满足实际出库事实。 */
|
||||
public static List<String> allowedActions(String status) {
|
||||
return switch (status == null ? "" : status) {
|
||||
case "WAIT_DELIVERY" -> List.of(
|
||||
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
"START_DELIVERY", "REJECT_DELIVERY");
|
||||
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
|
||||
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
/** 未实际出库不得开始派送、拒签或创建运输退回。 */
|
||||
public static List<String> allowedActions(String status, boolean outboundCompleted) {
|
||||
return outboundCompleted ? allowedActions(status) : List.of();
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.lastleg.delivery.repository;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 派送执行门禁的数据入口,保留取消单据历史。 */
|
||||
@Repository
|
||||
public class DeliveryExecutionRepository {
|
||||
private final OutboundOrderMapper outbounds;
|
||||
private final DeliveryOrderMapper deliveries;
|
||||
|
||||
/** 装配同库的出库及派送访问。 */
|
||||
public DeliveryExecutionRepository(OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries) {
|
||||
this.outbounds = outbounds;
|
||||
this.deliveries = deliveries;
|
||||
}
|
||||
|
||||
/** 按客户和业务编号读取原派送,供命令确定出库锁顺序。 */
|
||||
public DeliveryOrder findDelivery(String customerId, String deliveryNo) {
|
||||
return deliveries.findByNo(customerId, deliveryNo);
|
||||
}
|
||||
|
||||
/** 先锁出库,再锁派送,与取消和发车保持一致。 */
|
||||
public OutboundOrder lockOutbound(String customerId, String outboundNo) {
|
||||
return outbounds.lockByNo(customerId, outboundNo);
|
||||
}
|
||||
|
||||
/** 锁定客户范围内的派送,锁后由业务层复核关联。 */
|
||||
public DeliveryOrder lockDelivery(String customerId, String deliveryNo) {
|
||||
return deliveries.lockByNo(customerId, deliveryNo);
|
||||
}
|
||||
|
||||
/** 读取客户范围内的出库事实,用于动作展示。 */
|
||||
public OutboundOrder findOutbound(String customerId, String outboundNo) {
|
||||
return outbounds.findByNo(customerId, outboundNo);
|
||||
}
|
||||
|
||||
/** 发车时锁定全部派送,取消与执行只能产生一个合法结果。 */
|
||||
public List<DeliveryOrder> deliveries(OutboundOrder outbound, boolean lock) {
|
||||
return lock ? deliveries.lockByOutbound(outbound.getCustomerId(), outbound.getId())
|
||||
: deliveries.findByOutbound(outbound.getCustomerId(), outbound.getId());
|
||||
}
|
||||
|
||||
/** 保存已校验版本的派送动作。 */
|
||||
public void save(DeliveryOrder delivery) {
|
||||
deliveries.updateById(delivery);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.delivery.repository;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 复用库存组成及本次拣货来源,不把展示入仓码当作整托归属。 */
|
||||
@Repository
|
||||
public class DispatchCargoSourceRepository {
|
||||
private final DispatchFeeCargoMapper sources;
|
||||
/** 装配实际货物组成查询。 */
|
||||
public DispatchCargoSourceRepository(DispatchFeeCargoMapper sources) { this.sources = sources; }
|
||||
/** 按客户和当前排车读取逐入仓来源数量。 */
|
||||
public List<DeliveryFeeCoverageModels.Cargo> sources(String customerId, long dispatchId) {
|
||||
return sources.list(customerId, dispatchId);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 统一派送执行事实,防止状态编辑绕过出库及车次事务。 */
|
||||
@Service
|
||||
public class DeliveryExecutionGuard {
|
||||
private final DeliveryExecutionRepository repository;
|
||||
|
||||
/** 装配执行事实的数据入口。 */
|
||||
public DeliveryExecutionGuard(DeliveryExecutionRepository repository) { this.repository = repository; }
|
||||
|
||||
/** 展示动作只认同客户、同出库单的实际出库事实。 */
|
||||
public boolean outboundCompleted(DeliveryOrder delivery) {
|
||||
return matches(delivery, repository.findOutbound(delivery.getCustomerId(), delivery.getOutboundNo()));
|
||||
}
|
||||
|
||||
/** 发车前要求派送覆盖完整、均未执行,锁定检查与出库提交同事务。 */
|
||||
public String departureBlockReason(OutboundOrder outbound, boolean lock) {
|
||||
var rows = repository.deliveries(outbound, lock);
|
||||
if (rows.isEmpty()) return "缺少对应派送单,请先核对转派送结果";
|
||||
if (rows.stream().anyMatch(row -> !Objects.equals(row.getCustomerId(), outbound.getCustomerId())
|
||||
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|
||||
|| !Objects.equals(row.getOutboundNo(), outbound.getOutboundNo())
|
||||
|| !"WAIT_DELIVERY".equals(row.getStatus()) || row.getDispatchedAt() != null || row.getSignedAt() != null
|
||||
|| row.getPackageCount() == null || row.getPackageCount() <= 0))
|
||||
return "关联派送单已取消、已执行或来源不一致,不能发车";
|
||||
if (rows.stream().mapToLong(DeliveryOrder::getPackageCount).sum()
|
||||
!= Objects.requireNonNullElse(outbound.getPackageCount(), 0))
|
||||
return "关联派送数量未完整覆盖出库货物";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 真正发车时再次加锁校验,不能依赖列表查询结果。 */
|
||||
public void requireDeparture(OutboundOrder outbound) {
|
||||
String reason = departureBlockReason(outbound, true);
|
||||
if (reason != null) throw new ResponseStatusException(HttpStatus.CONFLICT, reason);
|
||||
}
|
||||
|
||||
/** 共享实际出库判据,不能只凭派送状态或发车时间推断。 */
|
||||
public static boolean matches(DeliveryOrder delivery, OutboundOrder outbound) {
|
||||
return outbound != null && "OUTBOUNDED".equals(outbound.getStatus())
|
||||
&& Objects.equals(delivery.getCustomerId(), outbound.getCustomerId())
|
||||
&& Objects.equals(delivery.getOutboundOrderId(), outbound.getId())
|
||||
&& Objects.equals(delivery.getOutboundNo(), outbound.getOutboundNo());
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -78,14 +78,15 @@ public class DeliveryFeeCoverageService {
|
||||
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
|
||||
return null;
|
||||
}
|
||||
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<Long> externalItems) {
|
||||
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<String> externalInboundCodes) {
|
||||
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
|
||||
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
|
||||
if (evidence == null) return List.of();
|
||||
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList();
|
||||
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList();
|
||||
}
|
||||
}
|
||||
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<Long> externalItems) {
|
||||
/** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */
|
||||
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<String> externalInboundCodes) {
|
||||
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
|
||||
var preview = preview(dispatchNo);
|
||||
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
|
||||
@@ -106,13 +107,13 @@ public class DeliveryFeeCoverageService {
|
||||
}
|
||||
var included = new HashSet<String>();
|
||||
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
|
||||
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service);
|
||||
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service);
|
||||
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
|
||||
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems));
|
||||
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes));
|
||||
}
|
||||
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
|
||||
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) {
|
||||
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true;
|
||||
List<DeliveryFeeCoverageModels.Reference> references, Set<String> externalInboundCodes) {
|
||||
if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true;
|
||||
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
|
||||
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
|
||||
}
|
||||
|
||||
+26
-14
@@ -132,6 +132,8 @@ public class DeliveryTransferService {
|
||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||
@Resource private FleetBookingFreight bookingFreight;
|
||||
@Resource private DeliveryFeeCoverageService feeCoverage;
|
||||
@Resource private DeliveryExecutionGuard deliveryExecution;
|
||||
@Resource private DispatchCargoSourceQuery cargoSources;
|
||||
|
||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
|
||||
@@ -140,13 +142,10 @@ public class DeliveryTransferService {
|
||||
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
/** 外接运费与车次绑定使用相同的实际来源和数量。 */
|
||||
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
|
||||
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
|
||||
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
|
||||
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
|
||||
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
|
||||
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
|
||||
.toList());
|
||||
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(),
|
||||
cargoSources.sources(dispatch, items));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -169,6 +168,7 @@ public class DeliveryTransferService {
|
||||
return transferResolved(dispatch, operatorId, request);
|
||||
}
|
||||
|
||||
/** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */
|
||||
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
String customerId = dispatch.getCustomerId();
|
||||
@@ -197,10 +197,9 @@ public class DeliveryTransferService {
|
||||
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
|
||||
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
|
||||
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
|
||||
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
|
||||
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
|
||||
var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet());
|
||||
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
|
||||
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
|
||||
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes);
|
||||
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
|
||||
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
|
||||
|
||||
@@ -303,8 +302,10 @@ public class DeliveryTransferService {
|
||||
deliveries.add(delivery);
|
||||
}
|
||||
for (var quote : freight.quotes()) {
|
||||
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
|
||||
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
|
||||
var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey(
|
||||
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(),
|
||||
item.getDeliveryAddress(), item.getPostalCode()))).findFirst()
|
||||
.orElseThrow(() -> conflict("外接约定目的地未生成对应派送单"));
|
||||
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
|
||||
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
|
||||
+ quote.currency() + " " + quote.amount().toPlainString(), now);
|
||||
@@ -320,7 +321,8 @@ public class DeliveryTransferService {
|
||||
}
|
||||
DeliveryOrder feeDelivery = deliveries.get(0);
|
||||
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
|
||||
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
|
||||
var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream()
|
||||
.filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.<FleetDispatchGateway.SourceCargo>of();
|
||||
for (DeliveryRequests.FeeItem input : feeInputs) {
|
||||
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
|
||||
var uncovered = coverage.uncovered(feeCode);
|
||||
@@ -328,11 +330,19 @@ public class DeliveryTransferService {
|
||||
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
|
||||
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
|
||||
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
|
||||
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
|
||||
? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(),
|
||||
item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey(
|
||||
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items;
|
||||
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
|
||||
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
|
||||
: feeDelivery;
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
|
||||
// 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。
|
||||
if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) {
|
||||
var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes);
|
||||
firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null;
|
||||
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
|
||||
}
|
||||
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
|
||||
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
|
||||
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
|
||||
@@ -354,6 +364,7 @@ public class DeliveryTransferService {
|
||||
return result(outbound, deliveries, false);
|
||||
}
|
||||
|
||||
/** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */
|
||||
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
|
||||
if (!requestHash.equals(outbound.getRequestHash()))
|
||||
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
|
||||
@@ -1637,7 +1648,8 @@ public class DeliveryTransferService {
|
||||
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
/** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
|
||||
/** 外接绑定和约定运费共同消费同一份逐来源货物。 */
|
||||
@Service
|
||||
public class DispatchCargoSourceQuery {
|
||||
private final DispatchCargoSourceRepository repository;
|
||||
/** 装配当前库存与本次拣货的真实来源入口。 */
|
||||
public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; }
|
||||
|
||||
/** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */
|
||||
public Set<Long> ordinaryOrderIds(DispatchOrder dispatch, Set<String> externalInboundCodes) {
|
||||
return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream()
|
||||
.filter(row -> !externalInboundCodes.contains(row.inboundCode()))
|
||||
.map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
/** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */
|
||||
public List<FleetDispatchGateway.SourceCargo> sources(DispatchOrder dispatch, List<DispatchItem> items) {
|
||||
if (items.isEmpty()) return List.of();
|
||||
var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId());
|
||||
var result = new ArrayList<FleetDispatchGateway.SourceCargo>();
|
||||
for (var item : items) {
|
||||
String destination = FleetDispatchGateway.destinationKey(item.getDestinationType(),
|
||||
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode());
|
||||
// 实收重派按不可变实收记录选货,箱级换标按箱来源选货;都不是普通整托候选。
|
||||
if (item.getReturnReceiptId() != null || Set.of("RELABEL", "RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(), ""))) {
|
||||
result.add(new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
|
||||
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(),
|
||||
item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination));
|
||||
continue;
|
||||
}
|
||||
var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList();
|
||||
if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount())
|
||||
throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源");
|
||||
for (var row : selected) {
|
||||
if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0
|
||||
|| !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId()))
|
||||
throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对");
|
||||
result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(),
|
||||
row.palletNo(), Math.toIntExact(row.packages()), destination));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 来源不足直接阻断,不回退展示字段。 */
|
||||
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
|
||||
}
|
||||
Reference in New Issue
Block a user