fix(lastleg): repair dispatch cancellation and return execution contracts

This commit is contained in:
2026-10-18 16:34:14 +08:00
parent 66a0beb653
commit aa33514ec6
64 changed files with 2061 additions and 213 deletions
@@ -52,6 +52,7 @@ public class CustomerVisitService {
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution;
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -76,6 +77,7 @@ public class CustomerVisitService {
return detail(visit);
}
/** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */
@Transactional
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
String deliveryNo,
@@ -84,8 +86,14 @@ public class CustomerVisitService {
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态");
}
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
var observed = deliveryExecution.findDelivery(customerId, deliveryNo);
if (observed == null) throw notFound("派送单不存在");
// 不反向申请排车锁;按出库→派送顺序与取消、发车串行。
var outbound = deliveryExecution.lockOutbound(customerId, observed.getOutboundNo());
DeliveryOrder delivery = deliveryExecution.lockDelivery(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound))
throw conflict("派送尚未实际出库,不能开始派送或拒签");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
@@ -99,7 +107,7 @@ public class CustomerVisitService {
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
deliveryExecution.save(delivery);
String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
@@ -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} " +
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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));
}
@@ -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); }
@@ -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); }
}
@@ -13,7 +13,13 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) {
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches,
Actions actions) {
public record Actions(boolean canEdit, String editBlockReason,
boolean canCancel, String cancelBlockReason,
boolean canRemovePallet, String removePalletBlockReason,
boolean canTransfer, String transferBlockReason,
String outboundNo, String outboundStatus) { }
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
@@ -37,6 +37,10 @@ import java.util.Objects;
@Component
@Transactional(propagation = Propagation.MANDATORY)
public class DispatchFleetAdapter implements FleetDispatchGateway {
@jakarta.annotation.Resource
private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@jakarta.annotation.Resource
private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources;
private final DispatchOrderMapper orders;
private final DispatchPdaTaskMapper tasks;
private final OutboundOrderMapper outbounds;
@@ -137,6 +141,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物";
else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认";
if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock);
return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason);
}
@@ -151,12 +156,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
private static String destination(String type, String code, String fba, String address, String postalCode) {
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
}
/** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */
@Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) {
var dispatch=orders.findGlobalByDispatchNo(dispatchNo);
if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");
return items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()).stream().map(item->new SourceCargo(item.getInboundCode(),
item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),Objects.requireNonNullElse(item.getPackageCount(),0),
destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()))).toList();
return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()));
}
@Override
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
@Service
public class DispatchPdaWorkflowService {
@Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders;
@@ -238,6 +239,7 @@ public class DispatchPdaWorkflowService {
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
throw conflict("Dispatch must be transferred to a pending outbound order before departure");
}
deliveryExecution.requireDeparture(outbound);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus())
|| !"READY_TO_DEPART".equals(order.getStatus())) {
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import java.util.List;
/** 排车变更的数据操作,事务及状态规则由排车服务维护。 */
public class DispatchMutationRepository {
private final DispatchOrderMapper orders;
private final DispatchItemMapper items;
private final DispatchPalletLockMapper locks;
private final OutboundOrderMapper outbounds;
private final DispatchCandidateMapper candidates;
private final RelabelApplicationMapper relabels;
private final DispatchPdaTaskMapper tasks;
/** 复用现有 Mapper 装配,供原排车服务分步收口变更路径。 */
public DispatchMutationRepository(DispatchOrderMapper orders, DispatchItemMapper items, DispatchPalletLockMapper locks,
OutboundOrderMapper outbounds, DispatchCandidateMapper candidates, RelabelApplicationMapper relabels, DispatchPdaTaskMapper tasks) {
this.orders=orders; this.items=items; this.locks=locks; this.outbounds=outbounds;
this.candidates=candidates; this.relabels=relabels; this.tasks=tasks;
}
/** 按业务号锁定唯一排车。 */
public DispatchOrder lockOrder(String dispatchNo) { return orders.lockGlobalByDispatchNo(dispatchNo); }
/** 保存排车的业务变更。 */
public void saveOrder(DispatchOrder order) { orders.updateById(order); }
/** 创建已校验客户和完整货量的新排车。 */
public void createOrder(DispatchOrder order) { orders.insert(order); }
/** 读取当前客户排车的原始明细。 */
public List<DispatchItem> items(DispatchOrder order) { return items.findByDispatchOrder(order.getCustomerId(), order.getId()); }
/** 锁定本排车的指定托盘明细。 */
public DispatchItem lockItem(DispatchOrder order, Long itemId) { return items.lockById(order.getCustomerId(), order.getId(), itemId); }
/** 保存新换标排车明细。 */
public void createItem(DispatchItem item) { items.insert(item); }
/** 保存退货换标来源快照。 */
public void saveItem(DispatchItem item) { items.updateById(item); }
/** 删除尚未执行的排车托盘,历史单据由取消命令保留。 */
public void removeItem(Long itemId) { items.deleteById(itemId); }
/** 读取该排车自己的活动占用,不触碰后续新排车。 */
public List<DispatchPalletLock> activeLocks(DispatchOrder order) {
return locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, order.getCustomerId()).eq(DispatchPalletLock::getDispatchOrderId, order.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
}
/** 保存单条原排车占用的释放事实。 */
public void saveLock(DispatchPalletLock lock) { locks.updateById(lock); }
/** 读取或锁定历史关联出库;业务层区分活动与取消。 */
public OutboundOrder outbound(DispatchOrder order, boolean lock) {
return lock ? outbounds.lockByDispatch(order.getCustomerId(), order.getId()) : outbounds.findByDispatch(order.getCustomerId(), order.getId());
}
/** 锁定换标申请,串行完整候选检查与整单状态推进。 */
public RelabelApplication lockRelabel(String customer, String number) { return relabels.lockNo(customer, number); }
/** 读取锁定申请下的全部普通换标候选。 */
public List<DispatchPalletCandidate> relabelCandidates(String customer, String number) { return candidates.relabelAvailable(customer, null, number); }
/** 整单申请只允许一次转为有效排车。 */
public int markRelabel(String customer, String number) { return candidates.markRelabelPreDispatched(customer, number); }
/** 取消未执行排车后恢复原申请候选。 */
public void restoreRelabel(String customer, String number) { candidates.restoreRelabelPreDispatch(customer, number); }
/** 创建与新排车同事务的 PDA 任务。 */
public void createTask(DispatchPdaTask task) { tasks.insert(task); }
}
@@ -601,9 +601,10 @@ public class DispatchService {
return response(responseOrder, assignedItems);
}
/** 在申请锁内校验全托并原子建立整单排车。 */
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo());
RelabelApplication application = mutations().lockRelabel(customerId, request.relabelNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
@@ -613,12 +614,15 @@ public class DispatchService {
relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application);
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
measuredRelabelCandidates(mutations().relabelCandidates(customerId, request.relabelNo())).stream(),
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource)
&& !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘");
// 整单履约状态只能由完整托盘集合推进,普通换标与退货换标遵守同一门禁。
Set<Long> requested = new HashSet<>(request.planPalletIds());
if (requested.contains(null) || requested.size() != request.planPalletIds().size())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标托盘不得为空或重复选择");
if (selected.isEmpty() || !requested.equals(selected.stream()
.map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标排车须一次选择本申请全部已绑定托盘,请刷新候选后完整选择");
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId();
@@ -626,17 +630,17 @@ public class DispatchService {
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); mutations().createOrder(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); mutations().createItem(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); mutations().createTask(task);
for (DispatchItem item : mutations().items(dispatch)) {
if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId())
&& Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) {
item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item);
item.setSourceStage("RETURN_RELABEL"); mutations().saveItem(item); returnRelabelStock.reserve(item);
}
}
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, mutations().items(dispatch));
}
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
@@ -704,17 +708,14 @@ public class DispatchService {
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
/** 仅编辑未执行且无有效出库或车次占用的排车。 */
@Transactional
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Only a pending dispatch can be edited");
}
requirePalletEditingAllowed(dispatch);
String customerId = dispatch.getCustomerId();
List<DispatchItem> assignedItems = items.findByDispatchOrder(customerId, dispatch.getId());
List<DispatchItem> assignedItems = mutations().items(dispatch);
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
@@ -726,62 +727,65 @@ public class DispatchService {
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
mutations().saveOrder(dispatch);
return response(dispatch, assignedItems);
}
/** 移除普通来源托盘,整单换标只允许整单取消重排。 */
@Transactional
public DispatchResponse removeItem(String dispatchNo, Long itemId) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId);
DispatchItem item = mutations().lockItem(dispatch, itemId);
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
if ("RETURN_RELABEL".equals(item.getSourceStage()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车");
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车");
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())
|| (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
.toList();
return removeLockedItem(dispatch, item, matchingLocks);
}
/** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */
private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item,
List<DispatchPalletLock> matchingLocks) {
LocalDateTime now = LocalDateTime.now();
matchingLocks.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
mutations().removeItem(item.getId());
appointmentAllocations.removeItem(dispatch.getId(),item.getId());
List<DispatchItem> remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
List<DispatchItem> remaining = mutations().items(dispatch);
dispatch.setItemCount(remaining.size());
dispatch.setPalletCount(remaining.size());
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum());
dispatch.setRoute(itemRoute(remaining));
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
mutations().saveOrder(dispatch);
return response(dispatch, remaining);
}
/** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */
private void requirePalletEditingAllowed(DispatchOrder dispatch) {
fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) {
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
}
if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) {
var outbound = mutations().outbound(dispatch, false);
if (outbound != null && !CANCELLED.equals(outbound.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘");
}
}
/** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */
@Transactional
public void cancel(String dispatchNo) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId());
if (CANCELLED.equals(dispatch.getStatus())) return;
@@ -789,32 +793,32 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
}
String customerId = dispatch.getCustomerId();
if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0)
throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱");
if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null)
var outbound = mutations().outbound(dispatch, true);
if (outbound != null && !CANCELLED.equals(outbound.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
workChanges.requirementsChanged();
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
mutations().saveOrder(dispatch);
for (DispatchItem item : mutations().items(dispatch)) {
// 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。
if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item);
}
items.findByDispatchOrder(customerId, dispatch.getId()).stream()
mutations().items(dispatch).stream()
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
.forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
.forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
locks.updateById(lock);
mutations().saveLock(lock);
}
}
@@ -861,7 +865,36 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches);
bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches, actions(order, itemList, workflow));
}
/** 返回与写命令相同的操作资格;权限仍由入口独立校验。 */
private DispatchResponse.Actions actions(DispatchOrder order, List<DispatchItem> itemList,
com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
if (workflow == null) workflow = fleetWorkflow.read(List.of(order.getDispatchNo())).get(order.getDispatchNo());
var outbound = mutations().outbound(order, false);
boolean activeOutbound = outbound != null && !CANCELLED.equals(outbound.getStatus());
String pendingReason = !PENDING_DEPARTURE.equals(order.getStatus()) ? "仅未执行的待发车排车可操作"
: Objects.requireNonNullElse(order.getPickedPackageCount(), 0) > 0
|| Objects.requireNonNullElse(order.getShippedPackageCount(), 0) > 0 ? "排车已开始拣货或发车" : null;
String mutationReason = workflow != null ? "请先在车次 " + workflow.tripNo() + " 中解除关联" : pendingReason;
String editReason = mutationReason != null ? mutationReason : activeOutbound ? "请先取消对应出库单" : null;
String removeReason = editReason != null ? editReason : itemList.stream()
.anyMatch(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) ? "换标按整单排车,请取消整张排车后重排" : null;
String transferReason = outbound != null ? (CANCELLED.equals(outbound.getStatus())
? "原出库已取消,请取消原排车后通过新预排重新安排" : "已转派送,请查看原出库及派送单")
: !Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART").contains(Objects.toString(order.getStatus(), ""))
? "当前状态不能转派送" : itemList.isEmpty() || itemList.stream().anyMatch(item -> item.getWarehousePalletId() == null)
? "请先完成收货并核对实物托盘" : null;
return new DispatchResponse.Actions(editReason == null, editReason, editReason == null, editReason,
removeReason == null, removeReason, transferReason == null, transferReason,
outbound == null ? null : outbound.getOutboundNo(), outbound == null ? null : outbound.getStatus());
}
/** 为本次变更路径集中组装数据访问,保持既有 Mapper 注入与事务连接。 */
private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() {
return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository(
orders, items, locks, outboundOrders, candidates, relabelApplications, pdaTasks);
}
private static String candidateRoute(List<DispatchPalletCandidate> selected) {
@@ -26,16 +26,16 @@ public final class ExceptionOrderRequests {
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
public record MarkReturn(@NotNull @Min(1) Long inboundOrderId,
public record MarkReturn(@NotNull @Min(1) Long lastMileOrderId,
@NotBlank @Size(max = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo,
@NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items,
@NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid ReturnItem> items,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) long packageCount) {}
@Min(1) Long inventoryPositionId,
@Min(1) long packageCount, @Min(1) Long returnCartonId) {}
public record StartProcessing(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@@ -45,15 +45,16 @@ public final class ExceptionOrderRequests {
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 2000) String handlingResult,
@NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds,
@Size(max = 200) List<@NotNull ReturnReceipt> receipts) {}
@Size(max = 200) List<@NotNull @jakarta.validation.Valid ReturnReceipt> receipts) {}
public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) Long inventoryPositionId,
@Min(1) long receivedPackages,
@NotNull @Min(1) Long targetPalletId,
@NotBlank @Size(max = 128) String targetPalletNo,
@NotNull @Min(1) Long targetLocationId,
@NotBlank @Size(max = 128) String targetLocationCode) {}
@NotBlank @Size(max = 128) String targetLocationCode,
@Min(1) Long returnCartonId) {}
public record Cancel(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@@ -26,7 +26,8 @@ public final class ExceptionOrderResponses {
List<ReturnSource> returnSources) {}
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
String palletNo, String skuCode, long maxReturnPackages) {}
String palletNo, String skuCode, long maxReturnPackages,
Long returnCartonId, String cartonMark) {}
public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts) {}
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
List<Redispatch> redispatches) {}
@@ -15,6 +15,8 @@ public class ExceptionReturnItem {
private Long exceptionOrderId;
private Long outboundPalletItemId;
private Long inventoryPositionId;
private Long returnCartonId;
private String returnCartonMark;
private String palletNo;
private String skuCode;
private Long maxReturnPackages;
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.exceptionorder.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 原箱回仓的不可变逐SKU凭据,箱数仅计一次。 */
@Data
@TableName("ll_return_carton_stock_receipt")
public class ReturnCartonStockReceipt {
@TableId(type=IdType.INPUT) private Long id;
private String customerId;
private String receiptNo;
private Long exceptionOrderId;
private Long exceptionReturnItemId;
private Long deliveryOrderId;
private Long sourcePickId;
private String sourceOutboundNo;
private Long sourceDispatchItemId;
private Long sourceOutboundPalletItemId;
private Long returnCartonId;
private String cartonMark;
private String overseasWarehouseId;
private Long targetPalletId;
private String targetPalletNo;
private Long targetLocationId;
private String targetLocationCode;
private String skuCode;
private String productNameZh;
private Long quantity;
private Long packages;
private String evidenceFileIdsJson;
private String receivedBy;
private LocalDateTime receivedAt;
}
@@ -10,6 +10,13 @@ import java.util.List;
@Mapper
public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnItem> {
/** 原箱回仓按本次派送和原出库明细分别累计,不能借用其他轮次。 */
@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.return_carton_id=#{cartonId} AND i.inventory_position_id IS NULL")
long receivedByCartonSource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId,
@Param("itemId") Long itemId, @Param("cartonId") Long cartonId);
@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);
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 原箱的出库归属证明和回仓凭据。 */
@Mapper
public interface ReturnCartonStockReceiptMapper extends BaseMapper<ReturnCartonStockReceipt> {
record Shipment(Long pickId, Long dispatchItemId, String dispatchNo,Long productQuantity) { }
/** 原箱必须真实出库且逐箱归属于当前异常、派送、客户及原尾程订单。 */
@Select("SELECT p.id pick_id,di.id dispatch_item_id,d.dispatch_no,p.product_quantity FROM ll_exception_order e " +
"JOIN ll_exception_return_item frozen ON frozen.exception_order_id=e.id AND frozen.customer_id=e.customer_id " +
"AND frozen.outbound_pallet_item_id=#{itemId} AND frozen.return_carton_id=#{cartonId} AND frozen.inventory_position_id IS NULL AND frozen.approved_return_packages=1 AND frozen.received_packages=0 " +
"JOIN ll_delivery_order delivery ON delivery.id=e.delivery_order_id AND delivery.customer_id=e.customer_id " +
"JOIN ll_outbound_order o ON o.id=delivery.outbound_order_id AND o.customer_id=e.customer_id AND o.outbound_no=e.outbound_no AND o.status='OUTBOUNDED' " +
"JOIN ll_outbound_pallet_item oi ON oi.outbound_order_id=o.id AND oi.customer_id=e.customer_id AND oi.id=#{itemId} " +
"JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id AND d.customer_id=e.customer_id " +
"JOIN ll_dispatch_item di ON di.dispatch_order_id=d.id AND di.customer_id=e.customer_id AND di.warehouse_pallet_id=oi.warehouse_pallet_id AND di.source_stage='RETURN_RELABEL' " +
"AND di.inbound_code=oi.inbound_code AND COALESCE(di.destination_warehouse_code,'')=COALESCE(oi.destination_warehouse_code,'') " +
"JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id AND t.customer_id=e.customer_id " +
"JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND p.customer_id=e.customer_id AND p.dispatch_item_id=di.id AND p.return_carton_id=#{cartonId} AND p.inventory_position_id IS NULL AND p.packages=1 " +
"JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=e.customer_id AND a.order_id=e.last_mile_order_id AND a.packages=1 " +
"WHERE e.id=#{exceptionId} AND e.customer_id=#{customerId} AND e.status='PROCESSING' AND e.return_direction='OVERSEAS_WAREHOUSE'")
Shipment shipment(@Param("customerId") String customerId,@Param("exceptionId") Long exceptionId,
@Param("itemId") Long itemId,@Param("cartonId") Long cartonId);
/** 原出库每个SKU只允许恢复一次,独立于可变箱状态。 */
@Select("SELECT * FROM ll_return_carton_stock_receipt WHERE customer_id=#{customerId} AND source_pick_id=#{pickId}")
List<ReturnCartonStockReceipt> byPick(@Param("customerId") String customerId,@Param("pickId") Long pickId);
/** 异常详情按真实回仓凭据展示,重复请求不生成新记录。 */
@Select("SELECT * FROM ll_return_carton_stock_receipt WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionId} ORDER BY received_at,id")
List<ReturnCartonStockReceipt> byException(@Param("customerId") String customerId,@Param("exceptionId") Long exceptionId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.exceptionorder.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.exceptionorder.mapper.ReturnCartonStockReceiptMapper;
import com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** 原箱回仓只写原生退货账本,不构造普通库存仓位。 */
@Repository
public class ReturnCartonReturnRepository {
private final ReturnCartonStockReceiptMapper receipts;
private final ReturnInventoryCartonMapper cartons;
private final ReturnInventoryCartonItemMapper items;
private final ReturnInventoryMovementMapper movements;
private final ReturnInventoryBalanceMapper balances;
/** 装配同库原生库存及不可变出库证据。 */
public ReturnCartonReturnRepository(ReturnCartonStockReceiptMapper receipts,ReturnInventoryCartonMapper cartons,
ReturnInventoryCartonItemMapper items,ReturnInventoryMovementMapper movements,ReturnInventoryBalanceMapper balances) {
this.receipts=receipts;this.cartons=cartons;this.items=items;this.movements=movements;this.balances=balances;
}
/** 读取与原箱对应的唯一实际出库。 */
public ReturnCartonStockReceiptMapper.Shipment shipment(String customer,Long exceptionId,Long itemId,Long cartonId) { return receipts.shipment(customer,exceptionId,itemId,cartonId); }
/** 在同客户内锁定真实原箱。 */
public ReturnInventoryCarton lockCarton(String customer,Long id) { return cartons.selectOne(new LambdaQueryWrapper<ReturnInventoryCarton>().eq(ReturnInventoryCarton::getCustomerId,customer).eq(ReturnInventoryCarton::getId,id).last("FOR UPDATE")); }
/** 锁定原箱SKU内容,供与不可变出库快照核平。 */
public List<ReturnInventoryCartonItem> contents(Long id) { return items.lockCarton(id); }
/** 读取原出库逐SKU扣减事实。 */
public List<ReturnInventoryOutboundMovement> shipped(String customer,Long pickId) { return movements.selectList(new LambdaQueryWrapper<ReturnInventoryOutboundMovement>().eq(ReturnInventoryOutboundMovement::getCustomerId,customer).eq(ReturnInventoryOutboundMovement::getOutboundPickId,pickId).orderByAsc(ReturnInventoryOutboundMovement::getSkuCode)); }
/** 已恢复的原出库不能再次入账。 */
public boolean alreadyReceived(String customer,Long pickId) { return !receipts.byPick(customer,pickId).isEmpty(); }
/** 恢复原SKU的件数和原箱占位,不借用其他客户余额。 */
public void restore(ReturnInventoryOutboundMovement shipment) {
if(balances.adjust(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),shipment.getCustomerId(),shipment.getOverseasWarehouseId(),shipment.getSkuCode(),shipment.getProductNameZh(),shipment.getQuantity(),shipment.getQuantity(),shipment.getCartonsBefore()-shipment.getCartonsAfter())<1)
throw new IllegalStateException("原箱SKU余额恢复失败");
}
/** 保存不可变回仓行,唯一键防止重复恢复原出库。 */
public void receipt(ReturnCartonStockReceipt receipt) { receipts.insert(receipt); }
/** 实际回仓后开放原箱,清除已完成换标的预留引用,保留原始退货归属。 */
public int available(ReturnInventoryCarton carton,String pallet,String location,LocalDateTime now) {
return cartons.update(null,new LambdaUpdateWrapper<ReturnInventoryCarton>().eq(ReturnInventoryCarton::getId,carton.getId()).eq(ReturnInventoryCarton::getCustomerId,carton.getCustomerId()).eq(ReturnInventoryCarton::getStatus,"OUTBOUNDED")
.set(ReturnInventoryCarton::getStatus,"AVAILABLE").set(ReturnInventoryCarton::getPalletCode,pallet).set(ReturnInventoryCarton::getLocationCode,location).set(ReturnInventoryCarton::getPutawayAt,now)
.set(ReturnInventoryCarton::getRelabelApplicationId,null).set(ReturnInventoryCarton::getRelabelNo,null).set(ReturnInventoryCarton::getRelabelStatus,null).set(ReturnInventoryCarton::getUpdatedAt,now));
}
/** 查询原生退回历史。 */
public List<ReturnCartonStockReceipt> history(String customer,Long exceptionId) { return receipts.byException(customer,exceptionId); }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.exceptionorder.repository;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
/** 异常候选只读取得原箱业务快照,不写原生库存。 */
public class ReturnCartonSourceRepository {
private final ReturnInventoryCartonMapper cartons;
private final com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returns;
/** 复用异常来源查询中的原箱数据入口。 */
public ReturnCartonSourceRepository(ReturnInventoryCartonMapper cartons,com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returns){this.cartons=cartons;this.returns=returns;}
/** 按不可变拣货引用取得原箱,再由服务核对客户和仓库。 */
public ReturnInventoryCarton find(Long cartonId){return cartons.selectById(cartonId);}
/** 两类来源分别累计真实实收,禁止用空仓位合并不同原箱。 */
public long received(String customer,Long deliveryId,Long itemId,Long positionId,Long cartonId){
return cartonId==null?returns.receivedBySource(customer,deliveryId,itemId,positionId)
:returns.receivedByCartonSource(customer,deliveryId,itemId,cartonId);
}
}
@@ -83,6 +83,8 @@ public class ExceptionOrderService {
@Resource private InventoryCommandService inventoryCommands;
@Resource private ExceptionEvidenceFileGateway evidenceFiles;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnCartonReturnService nativeReturns;
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
@@ -116,7 +118,18 @@ public class ExceptionOrderService {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order),
receiptHistory(returnReceipts.history(customerId, order.getId())));
returnHistory(order));
}
/** 普通仓位与原箱回仓都只展示实际入账凭据。 */
private List<ExceptionOrderResponses.ReturnReceipt> returnHistory(ExceptionOrder order) {
var history=new ArrayList<>(receiptHistory(returnReceipts.history(order.getCustomerId(),order.getId())));
if(returnItems.findByException(order.getCustomerId(),order.getId()).stream().anyMatch(item->item.getReturnCartonId()!=null)) {
var grouped=nativeReturns.history(order.getCustomerId(),order.getId()).stream().collect(java.util.stream.Collectors.groupingBy(
com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt::getReceiptNo,LinkedHashMap::new,java.util.stream.Collectors.toList()));
grouped.forEach((number,lines)->history.add(new ExceptionOrderResponses.ReturnReceipt(number,lines.stream().mapToLong(line->line.getPackages()).sum(),lines.get(0).getReceivedAt(),List.of())));
}
return List.copyOf(history);
}
private List<ExceptionOrderResponses.ReturnReceipt> receiptHistory(
@@ -183,10 +196,19 @@ public class ExceptionOrderService {
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> withReturnSources(
relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery))
returnRelation(order), delivery))
.distinct().toList();
}
/** 以派送关联的真实尾程主单构建退回候选,换标主单无需普通入仓关系。 */
private ExceptionOrderResponses.Relation returnRelation(LastlegOrder tail) {
LastlegInboundOrder inbound = optionalInbound(tail.getCustomerId(), tail.getInboundCode());
return new ExceptionOrderResponses.Relation(tail.getId(), tail.getOrderNo(),
inbound == null ? null : inbound.getId(), tail.getInboundCode(), tail.getCustomerId(), tail.getCustomerName(),
tail.getOverseasWarehouseId(), tail.getOverseasWarehouseName(), tail.getDeliveryOrderNo(),
tail.getOutboundOrderNo(), tail.getPackageCount(), List.of());
}
@Transactional
public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, String operatorName,
ExceptionOrderRequests.Create request) {
@@ -196,6 +218,7 @@ public class ExceptionOrderService {
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
}
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
@@ -206,8 +229,8 @@ public class ExceptionOrderService {
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
.findFirst().orElseThrow(() -> conflict("所选尾程主单不属于该派送单"));
List<ExceptionOrderResponses.ReturnSource> available = selected.returnSources();
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested);
@@ -229,6 +252,7 @@ public class ExceptionOrderService {
ExceptionReturnItem item = new ExceptionReturnItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id());
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
item.setReturnCartonId(source.returnCartonId()); item.setReturnCartonMark(source.cartonMark());
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setReceivedPackages(0L);
@@ -302,6 +326,7 @@ public class ExceptionOrderService {
return result;
}
/** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */
@Transactional
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
String exceptionNo,
@@ -312,6 +337,7 @@ 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;
long nativeRestored=0;
List<ExceptionReturnItem> stored = List.of();
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo());
@@ -320,18 +346,21 @@ public class ExceptionOrderService {
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,
var ordinaryReceipts=request.receipts().stream().filter(receipt->receipt.returnCartonId()==null).toList();
if(!ordinaryReceipts.isEmpty()) restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(),
stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
stored.stream().filter(item -> item.getReturnCartonId()==null && received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(),
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
ordinaryReceipts.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())
if(request.receipts().stream().anyMatch(receipt->receipt.returnCartonId()!=null))
nativeRestored=nativeReturns.restore(order,stored,request.receipts(),request.evidenceFileIds(),operatorId);
if ((restored==null?0:restored.restoredPackages())+nativeRestored != 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()));
@@ -350,7 +379,7 @@ public class ExceptionOrderService {
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();
long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored;
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result);
return result;
@@ -474,12 +503,15 @@ public class ExceptionOrderService {
readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value));
}
/** 原出库数量减去已实际回仓量,历史原箱与普通仓位分别核平。 */
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource(
delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId())))
.filter(source -> source.lastMileOrderId() != null
? Objects.equals(source.lastMileOrderId(), relation.lastMileOrderId())
: StringUtils.hasText(relation.inboundCode()) && Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(source -> source.withPackages(source.maxReturnPackages() - new com.baiyue.lastleg.exceptionorder.repository.ReturnCartonSourceRepository(returnCartons,returnItems).received(
delivery.getCustomerId(),delivery.getId(),source.outboundPalletItemId(),source.inventoryPositionId(),source.returnCartonId())))
.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(),
@@ -487,6 +519,7 @@ public class ExceptionOrderService {
relation.outboundNo(), relation.availablePackages(), sources);
}
/** 合并实际出库拣货证据,来源类型始终保留为普通仓位或原箱。 */
private List<Source> returnSources(DeliveryOrder delivery) {
OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo());
if (outbound == null) throw conflict("派送单缺少原出库单据");
@@ -499,13 +532,14 @@ public class ExceptionOrderService {
if (item == null || pick.getInventoryPositionId() == null) continue;
String key = item.getId() + ":" + pick.getInventoryPositionId();
aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(),
item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)),
item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L), null, null, null),
(left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
}
appendDispatchPickSources(delivery, outbound, itemById, aggregated);
return new ArrayList<>(aggregated.values());
}
/** 按实际拣货和原订单分摊归属恢复来源,原箱不伪造库存仓位。 */
private void appendDispatchPickSources(DeliveryOrder delivery, OutboundOrder outbound,
Map<Long, OutboundPalletItem> outboundItemById,
Map<String, Source> aggregated) {
@@ -518,7 +552,7 @@ public class ExceptionOrderService {
.forEach(item -> dispatchItemById.put(item.getId(), item));
for (DispatchPdaPick pick : dispatchPdaPicks.listTask(customerId, task.getId())) {
DispatchItem dispatchItem = dispatchItemById.get(pick.getDispatchItemId());
if (dispatchItem == null || pick.getInventoryPositionId() == null) continue;
if (dispatchItem == null || (pick.getInventoryPositionId() == null && pick.getReturnCartonId() == null)) continue;
List<DispatchPdaPickOrder> attributions = dispatchPdaPickOrders.listPick(customerId, pick.getId());
for (DispatchPdaPickOrder attribution : attributions) {
LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId());
@@ -533,39 +567,53 @@ public class ExceptionOrderService {
if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源");
if (matchingItems.isEmpty()) continue;
OutboundPalletItem outboundItem = matchingItems.get(0);
// A mixed pallet has one physical outbound item; each source is identified by the actual pick attribution.
String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + tail.getInboundCode();
// 同一实托可包含多票;逐票仓位或原箱以实际拣货归属分别确定。
String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + pick.getReturnCartonId() + ":" + tail.getId();
var carton = pick.getReturnCartonId() == null ? null : new com.baiyue.lastleg.exceptionorder.repository.ReturnCartonSourceRepository(returnCartons,returnItems).find(pick.getReturnCartonId());
if (pick.getReturnCartonId() != null && (carton == null || !customerId.equals(carton.getCustomerId())
|| !Objects.equals(dispatchItem.getOverseasWarehouseId(), carton.getOverseasWarehouseId())
|| !"RETURN_RELABEL".equals(dispatchItem.getSourceStage())))
throw conflict("原箱出库来源与客户、仓库或换标申请不一致");
long packages = Objects.requireNonNullElse(attribution.getPackages(), 0L);
if(pick.getReturnCartonId()!=null&&(packages!=1||!Objects.equals(pick.getPackages(),1L)))throw conflict("原箱出库数量凭据不一致");
aggregated.merge(key, new Source(outboundItem.getId(), pick.getInventoryPositionId(),
outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages),
outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages,
pick.getReturnCartonId(), carton == null ? null : carton.getCartonMark(), tail.getId()),
(left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
}
}
}
/** 详情仅返回冻结来源的剩余可收量及原箱业务码。 */
private List<ExceptionOrderResponses.ReturnSource> storedReturnSources(ExceptionOrder order) {
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) - ReturnReceiptPolicy.received(item)))
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item),
item.getReturnCartonId(), item.getReturnCartonMark()))
.filter(item -> item.maxReturnPackages() > 0).toList();
}
/** 申请的来源必须唯一、数量有效且不超过本派送原出库事实。 */
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
List<ExceptionOrderRequests.ReturnItem> requested) {
Set<String> unique = new java.util.HashSet<>();
for (ExceptionOrderRequests.ReturnItem item : requested) {
String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId();
ReturnReceiptPolicy.requireSource(item.inventoryPositionId(), item.returnCartonId());
if(item.packageCount()<1||item.returnCartonId()!=null&&item.packageCount()!=1)throw badRequest("退回箱数必须为正数,退货原箱必须按一整箱申请");
String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId() + ":" + item.returnCartonId();
if (!unique.add(key)) throw badRequest("退回库存明细不能重复");
ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item))
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单"));
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和尾程主单"));
if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数");
}
}
/** 出库项与两类互斥来源共同确定一条真实退回明细。 */
private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) {
return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId())
&& Objects.equals(source.inventoryPositionId(), item.inventoryPositionId());
&& Objects.equals(source.inventoryPositionId(), item.inventoryPositionId())
&& Objects.equals(source.returnCartonId(), item.returnCartonId());
}
private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) {
@@ -691,11 +739,13 @@ public class ExceptionOrderService {
private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo,
String skuCode, String inboundCode, long maxReturnPackages) {
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); }
String skuCode, String inboundCode, long maxReturnPackages, Long returnCartonId, String cartonMark, Long lastMileOrderId) {
/** 保留原始来源身份,仅修订剩余可退数量。 */
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages, returnCartonId, cartonMark, lastMileOrderId); }
/** 以原箱业务码展示,内部来源标识仅用于提交核对。 */
ExceptionOrderResponses.ReturnSource response() {
return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId,
palletNo, skuCode, maxReturnPackages);
palletNo, skuCode, maxReturnPackages, returnCartonId, cartonMark);
}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.entity.*;
import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
/** 异常实收事务内按原出库证据恢复原箱,永不创建普通库存仓位。 */
@Service
public class ReturnCartonReturnService {
private final ReturnCartonReturnRepository repository;
private final PalletPhysicalGateway pallets;
private final WarehouseResourceGateway warehouse;
private final ObjectMapper json;
private final com.baiyue.lastleg.service.LastlegOrderNoGenerator numbers;
/** 装配真实原箱数据入口和仓储资源校验。 */
public ReturnCartonReturnService(ReturnCartonReturnRepository repository,PalletPhysicalGateway pallets,WarehouseResourceGateway warehouse,ObjectMapper json,com.baiyue.lastleg.service.LastlegOrderNoGenerator numbers) {
this.repository=repository;this.pallets=pallets;this.warehouse=warehouse;this.json=json;this.numbers=numbers;
}
/** 同一异常完成命令持有异常、派送行锁;任一原箱失败时全部库存和凭据回滚。 */
@Transactional(propagation=Propagation.MANDATORY)
public long restore(ExceptionOrder order,List<ExceptionReturnItem> frozen,List<ExceptionOrderRequests.ReturnReceipt> inputs,List<Long> evidence,String operator) {
if(evidence==null||evidence.isEmpty())throw conflict("原箱实收必须保留有效证据文件");
long packages=0;
for (var input:inputs.stream().filter(i->i.returnCartonId()!=null).sorted(Comparator.comparing(ExceptionOrderRequests.ReturnReceipt::returnCartonId)).toList()) {
var source=frozen.stream().filter(i->Objects.equals(i.getOutboundPalletItemId(),input.outboundPalletItemId())&&Objects.equals(i.getReturnCartonId(),input.returnCartonId())).findFirst().orElseThrow(()->conflict("退回原箱不属于本异常冻结来源"));
if(input.receivedPackages()!=1||source.getApprovedReturnPackages()!=1||ReturnReceiptPolicy.received(source)!=0||input.inventoryPositionId()!=null)throw conflict("原箱须按整箱实收且只能入账一次");
var proof=repository.shipment(order.getCustomerId(),order.getId(),source.getOutboundPalletItemId(),source.getReturnCartonId());
if(proof==null)throw conflict("原箱缺少本派送实际出库及原订单归属凭据");
var carton=repository.lockCarton(order.getCustomerId(),source.getReturnCartonId());
if(carton==null||!Objects.equals(carton.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!"OUTBOUNDED".equals(carton.getStatus())||repository.alreadyReceived(order.getCustomerId(),proof.pickId()))throw conflict("原箱已回仓或客户、仓库、出库状态不匹配");
var shipped=repository.shipped(order.getCustomerId(),proof.pickId());
var expected=new TreeMap<String,Long>();
for(var movement:shipped) {
if(!Objects.equals(movement.getCartonId(),carton.getId())||!Objects.equals(movement.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!Objects.equals(movement.getOutboundNo(),proof.dispatchNo())||movement.getQuantity()==null||movement.getQuantity()<1)throw conflict("原箱出库SKU事实不完整或归属不一致");
expected.merge(movement.getSkuCode(),movement.getQuantity(),Math::addExact);
}
var current=new TreeMap<String,Long>();
for(var item:repository.contents(carton.getId())) {
if(!Objects.equals(item.getCustomerId(),order.getCustomerId())||Objects.requireNonNullElse(item.getPackingReservedQuantity(),0L)>0)throw conflict("原箱内容客户或预留状态不一致");
current.merge(item.getSkuCode(),item.getQuantity(),Math::addExact);
}
long shippedQuantity=expected.values().stream().mapToLong(Long::longValue).sum();
if(expected.isEmpty()||!expected.equals(current)||shippedQuantity!=Objects.requireNonNullElse(carton.getTotalQuantity(),0L)
||!Objects.equals(proof.productQuantity(),shippedQuantity))throw conflict("原箱件数与不可变出库SKU快照不一致");
if(input.targetPalletId()==null||input.targetPalletId()<1||input.targetLocationId()==null||input.targetLocationId()<1||input.targetPalletNo()==null||input.targetPalletNo().isBlank()||input.targetLocationCode()==null||input.targetLocationCode().isBlank())throw conflict("请填写真实回仓目标托盘和库位");
var pallet=pallets.resolve(input.targetPalletNo());
var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode());
if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓");
LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true;
for(var movement:shipped) {
repository.restore(movement);
var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId());
receipt.setExceptionOrderId(order.getId());receipt.setExceptionReturnItemId(source.getId());receipt.setDeliveryOrderId(order.getDeliveryOrderId());receipt.setSourcePickId(proof.pickId());receipt.setSourceOutboundNo(order.getOutboundNo());receipt.setSourceDispatchItemId(proof.dispatchItemId());receipt.setSourceOutboundPalletItemId(source.getOutboundPalletItemId());receipt.setReturnCartonId(carton.getId());receipt.setCartonMark(carton.getCartonMark());receipt.setOverseasWarehouseId(order.getOverseasWarehouseId());receipt.setTargetPalletId(input.targetPalletId());receipt.setTargetPalletNo(pallet.palletCode());receipt.setTargetLocationId(input.targetLocationId());receipt.setTargetLocationCode(location.locationCode());receipt.setSkuCode(movement.getSkuCode());receipt.setProductNameZh(movement.getProductNameZh());receipt.setQuantity(movement.getQuantity());receipt.setPackages(first?1L:0L);receipt.setReceivedAt(now);
receipt.setReceivedBy(operator);
try{receipt.setEvidenceFileIdsJson(json.writeValueAsString(evidence));}catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException(error);}
repository.receipt(receipt);first=false;
}
if(repository.available(carton,pallet.palletCode(),location.locationCode(),now)!=1)throw conflict("原箱回仓状态已变化");
packages++;
}
return packages;
}
/** 只展示已落账的原箱回仓凭据。 */
public List<ReturnCartonStockReceipt> history(String customer,Long exceptionId){return repository.history(customer,exceptionId);}
/** 拒绝不完整来源或实收,不保留部分入账。 */
private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);}
}
@@ -12,12 +12,15 @@ 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) {
requireSource(receipt.inventoryPositionId(), receipt.returnCartonId());
ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId())
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst()
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())
&& Objects.equals(item.getReturnCartonId(), receipt.returnCartonId())).findFirst()
.orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源"));
if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零");
amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact);
@@ -29,6 +32,12 @@ final class ReturnReceiptPolicy {
return amounts;
}
/** 普通仓位与原箱引用恰有一个,零值和负值不能作为来源。 */
static void requireSource(Long positionId, Long cartonId) {
if ((positionId == null) == (cartonId == null) || positionId != null && positionId < 1 || cartonId != null && cartonId < 1)
throw conflict("库存仓位与退货原箱必须且只能填写一个有效来源");
}
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());
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.fleet.trip;
/** 原承运登记的真实入仓来源查询入口。 */
public class FleetBookingCargoRepository {
private final FleetBookingMapper bookings;
/** 复用车次事务中的承运登记Mapper。 */
public FleetBookingCargoRepository(FleetBookingMapper bookings){this.bookings=bookings;}
/** 按客户与真实入仓码定位承运,历史取消记录交由业务层分别处理。 */
public FleetBookingEntity forInboundSource(String customerId,String inboundCode){return bookings.forInboundSource(customerId,inboundCode);}
}
@@ -26,7 +26,7 @@ public class FleetBookingFreight {
public record Preview(String snapshotHash, boolean requiresDeliveryFee, List<Quote> quotes,
@com.fasterxml.jackson.annotation.JsonIgnore List<String> priorInboundCodes) { }
/** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */
/** 转派送持有排车锁,按真实入仓组成复核约定运费,不反向申请父车次锁。 */
public Preview preview(long dispatchId, String dispatchNo, String customerId,
List<FleetDispatchGateway.SourceCargo> sources) {
var quotes = new ArrayList<Quote>();
@@ -38,7 +38,7 @@ public class FleetBookingFreight {
else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source);
}
for (var entry : grouped.entrySet()) {
var booking = bookings.incomingForFreight(entry.getKey());
var booking = new FleetBookingCargoRepository(bookings).forInboundSource(customerId, entry.getKey());
if (booking == null) { requiresFee = true; continue; }
if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致");
var accepted = reservations.receipt(booking);
@@ -62,7 +62,7 @@ public class FleetBookingFreight {
}
var confirmed = new TreeMap<String, Integer>();
for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact);
if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致");
if (!actual.equals(confirmed)) throw conflict("外接发运托盘或数量与已确认实收不一致;请先解除排车关联,在外接预订重新确认实际收货与运费后再绑定");
var cargo = reservations.cargo(booking);
BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE;
if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null
@@ -6,12 +6,13 @@ import java.util.List;
@Mapper
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
/** 按实际入仓来源定位原承运登记,取消记录保留供费用核对。 */
@Select("SELECT booking.* FROM ll_fleet_external_booking booking JOIN ll_warehouse_transfer_order incoming " +
"ON incoming.customer_id=booking.customer_id AND incoming.transfer_no=booking.incoming_no " +
"WHERE booking.customer_id=#{customerId} AND incoming.inbound_code=#{inboundCode}")
FleetBookingEntity forInboundSource(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_fleet_external_booking WHERE booking_no=#{number}")
FleetBookingEntity byNo(@Param("number") String number);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'")
FleetBookingEntity incoming(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}")
FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id")
List<FleetBookingEntity> list(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}")
@@ -28,7 +28,9 @@ public final class FleetBookingModels {
Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress,
@Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { }
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces,
@DecimalMin("0.001") @Digits(integer=9,fraction=3) BigDecimal weightKg,
@DecimalMin("0.001") @Digits(integer=9,fraction=3) BigDecimal volumeCbm) { }
public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets,
@NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice,
@@ -48,10 +48,11 @@ public class FleetBookingReservations {
}
public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);}
private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());}
/** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List<FleetDispatchGateway.SourceCargo> sources,String operator){
var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
for(var entry:bySource.entrySet()){
var row=bookings.incoming(entry.getKey());if(row==null)continue;
var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue;
if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认");
@@ -62,7 +63,7 @@ public class FleetBookingReservations {
if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致");
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
}
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
if(!selected.equals(expected))throw conflict("当前托盘与外接实收不一致;请在外接预订重新确认实际收货与运费后再关联排车");
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),operator);
}
@@ -143,12 +143,13 @@ public class FleetBookingService {
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
}
@Transactional
/** 首次或解除排车后的重新核对都要求当前实收摘要、版本、完整实托和容量一致。 */
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){
var trip=trip(tripNo,true);String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
if(vehicleBlock!=null)throw conflict(vehicleBlock);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
if(!java.util.Set.of("CONFIRMED","RECEIVED").contains(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货;已绑定排车须先解除关联后重新核对");
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量");
if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对");
@@ -156,8 +157,19 @@ public class FleetBookingService {
for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位");
var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet());
if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致");
// 旧测量仅适用于同实托同箱数;组成变化后必须明确输入新的该票实测值。
var measured=new java.util.ArrayList<FleetBookingIncomingGateway.Pallet>();
for(var pallet:evidence.pallets()) {
var input=request.pallets().stream().filter(p->Objects.equals(p.warehousePalletId(),pallet.warehousePalletId())).findFirst().orElseThrow();
var kg=pallet.weightKg()==null?input.weightKg():pallet.weightKg();
var cbm=pallet.volumeCbm()==null?input.volumeCbm():pallet.volumeCbm();
if(pallet.weightKg()!=null&&input.weightKg()!=null&&pallet.weightKg().compareTo(input.weightKg())!=0
||pallet.volumeCbm()!=null&&input.volumeCbm()!=null&&pallet.volumeCbm().compareTo(input.volumeCbm())!=0)
throw conflict("未变化托盘须使用仓储转运的原实测值,不能覆盖原收货证据");
measured.add(new FleetBookingIncomingGateway.Pallet(pallet.warehousePalletId(),pallet.palletNo(),pallet.packages(),kg,cbm));
}
BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0;
for(var pallet:evidence.pallets()){
for(var pallet:measured){
if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积");
weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages());
}
@@ -168,7 +180,8 @@ public class FleetBookingService {
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT");
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
var measuredReceipt=new FleetBookingIncomingGateway.Evidence(confirmed.incomingNo(),confirmed.inboundCode(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),confirmed.receiptHash(),java.util.List.copyOf(measured),confirmed.receivedPackages());
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(measuredReceipt,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt(
confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),
@@ -18,6 +18,7 @@ public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," +
"-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'EXCEPTION_RETURN_IN',receipt_no,quantity,quantity,received_at FROM ll_return_carton_stock_receipt" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_OUT',relabel_no,-quantity,-quantity,completed_at FROM ll_return_sku_conversion" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_IN',relabel_no,quantity,quantity,completed_at FROM ll_return_sku_conversion" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_IN',relabel_no,quantity,quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" +
@@ -18,6 +18,7 @@ import java.util.*;
@Component
public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
@jakarta.annotation.Resource private com.baiyue.lastleg.transfer.repository.FleetIncomingActualSourceRepository actualSources;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
private final WarehouseTransferService service;
private final WarehouseTransferOrderMapper orders;
@@ -49,25 +50,36 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货"));
fleetPool.bindIncoming(input.bookingNo(),input.customerId(),approved.transferNo(),approved.lastlegOrderId(),
approved.operatingWarehouseId(),key,operator);
return evidence(approved);
return evidence(input.customerId(),approved,false);
}
@Override @Transactional(propagation=Propagation.MANDATORY)
public Evidence inspect(String customerId,String incomingNo,boolean lock){
var row=lock?orders.lockNo(customerId,incomingNo):orders.findNo(customerId,incomingNo);
if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前客户无此外接来货单");
return evidence(service.detail(customerId,incomingNo));
return evidence(customerId,service.detail(customerId,incomingNo),lock);
}
@Override @Transactional(propagation=Propagation.MANDATORY)
public Evidence confirm(String customerId,String incomingNo,String operator,String key){
var row=orders.lockNo(customerId,incomingNo);
if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"外接来货单不存在");
if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(service.detail(customerId,incomingNo));
return evidence(service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量")));
if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(customerId,service.detail(customerId,incomingNo),true);
return evidence(customerId,service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量")),true);
}
private Evidence evidence(WarehouseTransferResponses.Detail value){
/** 实收完成后按真实组成重建托盘;变化后的测量值必须重新确认,不能比例推算。 */
private Evidence evidence(String customerId,WarehouseTransferResponses.Detail value,boolean lock){
var pallets=value.pallets().stream().filter(row->"COMPLETED".equals(row.status())).map(row->new Pallet(
row.warehousePalletId()==null?null:row.warehousePalletId().toString(),row.palletNo(),row.cartonCount(),row.grossWeightKg(),row.volumeCbm()))
.sorted(Comparator.comparing(row->Objects.requireNonNullElse(row.warehousePalletId(),""))).toList();
if(java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(value.status())) {
var measured=pallets;
pallets=actualSources.pallets(customerId,value.inboundCode(),value.operatingWarehouseId(),lock).stream().map(actual->{
if(actual.palletId()==null||actual.palletId()<1||actual.palletNo()==null||actual.palletNo().isBlank())throw conflict("外接实收货物尚未绑定真实实物托盘");
var prior=measured.stream().filter(p->Objects.equals(p.warehousePalletId(),String.valueOf(actual.palletId()))&&p.packages()==actual.packages()).findFirst().orElse(null);
return new Pallet(String.valueOf(actual.palletId()),actual.palletNo(),Math.toIntExact(actual.packages()),prior==null?null:prior.weightKg(),prior==null?null:prior.volumeCbm());
}).toList();
if(pallets.stream().mapToLong(Pallet::packages).sum()!=measured.stream().mapToLong(Pallet::packages).sum())
throw conflict("实际库存箱数与仓储转运原实收量不一致,请先核对来源,不允许部分确认外接实收");
}
String destination=FleetDispatchGateway.destinationKey(value.destinationType(),value.destinationWarehouseCode(),value.fbaWarehouseCode(),value.privateDestinationAddress(),value.privateDestinationPostalCode());
int packages=pallets.stream().mapToInt(Pallet::packages).sum();
try{
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.transfer.mapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 外接实收复核读取同库实际库存组成,不复制旧收货托盘。 */
@Mapper
public interface FleetIncomingActualSourceMapper {
record Pallet(Long palletId,String palletNo,Long packages) { }
/** 按仓位主键顺序锁定完整来源,确认期间拆合托必须等待同一库存行。 */
@Select("SELECT p.id FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_code=#{inboundCode} AND p.overseas_warehouse_id=#{warehouseId} ORDER BY p.id FOR UPDATE")
List<Long> lockSources(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode,@Param("warehouseId") String warehouseId);
/** 按真实入仓码及起运仓归并本票可用和已拣箱数,排除其他货主及其他来源。 */
@Select("SELECT p.pallet_id,p.pallet_no,SUM(p.available_packages+COALESCE(p.picked_packages,0)) packages " +
"FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_code=#{inboundCode} AND p.overseas_warehouse_id=#{warehouseId} " +
"GROUP BY p.pallet_id,p.pallet_no HAVING SUM(p.available_packages+COALESCE(p.picked_packages,0))>0 ORDER BY p.pallet_id")
List<Pallet> pallets(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode,@Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.transfer.repository;
import com.baiyue.lastleg.transfer.mapper.FleetIncomingActualSourceMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 库内拆合托后的真实外接货源读取入口。 */
@Repository
public class FleetIncomingActualSourceRepository {
private final FleetIncomingActualSourceMapper mapper;
/** 装配同库组成查询。 */
public FleetIncomingActualSourceRepository(FleetIncomingActualSourceMapper mapper){this.mapper=mapper;}
/** 当前托盘由本票库存确定,旧收货托盘仅保留为历史测量证据。 */
public List<FleetIncomingActualSourceMapper.Pallet> pallets(String customer,String inbound,String warehouse,boolean lock){
if(lock)mapper.lockSources(customer,inbound,warehouse);
return mapper.pallets(customer,inbound,warehouse);
}
}