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
+37 -1
View File
@@ -754,13 +754,17 @@ CREATE TABLE ll_exception_return_item (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
exception_order_id bigint NOT NULL COMMENT '异常订单ID', exception_order_id bigint NOT NULL COMMENT '异常订单ID',
outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID', outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id bigint NOT NULL COMMENT '原出库库存位置ID', inventory_position_id bigint DEFAULT NULL COMMENT '普通原出库库存位置;与退货原箱互斥',
return_carton_id bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥',
return_carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照',
pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照', pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照', sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照',
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限', max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数', approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
created_at datetime(3) NOT NULL COMMENT '创建时间', created_at datetime(3) NOT NULL COMMENT '创建时间',
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)),
UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id),
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -768,6 +772,38 @@ CREATE TABLE ll_exception_return_item (
KEY idx_ll_exception_return_order (customer_id,exception_order_id) KEY idx_ll_exception_return_order (customer_id,exception_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE ll_return_carton_stock_receipt (
id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号',
exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单',
exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细',
delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单',
source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据',
source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号',
source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细',
source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细',
return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识',
carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识',
target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘',
target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码',
target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位',
target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码',
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名',
quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数',
packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零',
evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用',
received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人',
received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code),
KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at),
KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id),
CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据';
CREATE TABLE ll_finance_fee_command ( CREATE TABLE ll_finance_fee_command (
id bigint NOT NULL COMMENT '幂等命令ID', id bigint NOT NULL COMMENT '幂等命令ID',
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
@@ -52,6 +52,7 @@ public class CustomerVisitService {
@Resource private LastlegInboundOrderMapper inboundOrders; @Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private CustomerVisitFileGateway files; @Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution;
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) { public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId()); String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -76,6 +77,7 @@ public class CustomerVisitService {
return detail(visit); return detail(visit);
} }
/** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */
@Transactional @Transactional
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId, public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
String deliveryNo, String deliveryNo,
@@ -84,8 +86,14 @@ public class CustomerVisitService {
if (!DeliveryStatusPolicy.isEditableTarget(target)) { if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态"); 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 (delivery == null) throw notFound("派送单不存在");
if (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound))
throw conflict("派送尚未实际出库,不能开始派送或拒签");
if (target.equals(delivery.getStatus())) { if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, 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); if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now); delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1); delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery); deliveryExecution.save(delivery);
String result = "派送状态由 " + from + " 修改为 " + target; String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark()); if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now); recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
@@ -12,6 +12,7 @@ import java.util.List;
@Mapper @Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> { 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, " + @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 " + "l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + "JOIN ll_exception_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 " + "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 " + "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) " + "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); 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} " + @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 org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */ /** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */
@Mapper @Mapper
public interface DispatchFeeCargoMapper { public interface DispatchFeeCargoMapper {
@Select(""" @Select("""
@@ -22,13 +22,10 @@ public interface DispatchFeeCargoMapper {
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked 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 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} 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 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 HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
ORDER BY di.id,b.inbound_order_id ORDER BY di.id,b.inbound_order_id
""") """)
/** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
} }
@@ -6,9 +6,9 @@ import java.util.Set;
public final class DeliveryStatusPolicy { public final class DeliveryStatusPolicy {
private static final Set<String> EDITABLE_TARGETS = Set.of( 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( 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"), "DELIVERING", Set.of("DELIVERED", "REJECTED"),
"DELIVERED", Set.of(), "DELIVERED", Set.of(),
"REJECTED", Set.of(), "REJECTED", Set.of(),
@@ -16,23 +16,32 @@ public final class DeliveryStatusPolicy {
"RETURNED", Set.of(), "RETURNED", Set.of(),
"CANCELLED", Set.of()); "CANCELLED", Set.of());
/** 状态规则为纯策略,实际出库由命令及查询层提供。 */
private DeliveryStatusPolicy() { } private DeliveryStatusPolicy() { }
/** 人工只允许开始派送或拒签,取消必须由出库聚合命令处理。 */
public static boolean isEditableTarget(String status) { public static boolean isEditableTarget(String status) {
return EDITABLE_TARGETS.contains(status); return EDITABLE_TARGETS.contains(status);
} }
/** 校验状态机,签收终态仍由签收资料命令触发。 */
public static boolean canTransition(String from, String target) { public static boolean canTransition(String from, String target) {
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target); return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
} }
/** 返回状态本身允许的动作;执行动作还须满足实际出库事实。 */
public static List<String> allowedActions(String status) { public static List<String> allowedActions(String status) {
return switch (status == null ? "" : status) { return switch (status == null ? "" : status) {
case "WAIT_DELIVERY" -> List.of( case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); "START_DELIVERY", "REJECT_DELIVERY");
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN"); case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN"); case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
default -> List.of(); 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 "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
return null; 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 static Decision none() { return new Decision(Set.of(), null, Set.of()); }
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) { public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
if (evidence == null) return List.of(); 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(); if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
var preview = preview(dispatchNo); var preview = preview(dispatchNo);
if (!preview.sourceComplete()) throw conflict(preview.blockReason()); if (!preview.sourceComplete()) throw conflict(preview.blockReason());
@@ -106,13 +107,13 @@ public class DeliveryFeeCoverageService {
} }
var included = new HashSet<String>(); var included = new HashSet<String>();
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE")) 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(), 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, private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) { List<DeliveryFeeCoverageModels.Reference> references, Set<String> externalInboundCodes) {
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true; if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true;
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference -> return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service)); reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
} }
@@ -132,6 +132,8 @@ public class DeliveryTransferService {
@Resource private FleetDispatchBindingGuard fleetBindings; @Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight; @Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage; @Resource private DeliveryFeeCoverageService feeCoverage;
@Resource private DeliveryExecutionGuard deliveryExecution;
@Resource private DispatchCargoSourceQuery cargoSources;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) { public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
@@ -140,13 +142,10 @@ public class DeliveryTransferService {
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
} }
/** 外接运费与车次绑定使用相同的实际来源和数量。 */
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) { private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream() return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(),
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(), cargoSources.sources(dispatch, items));
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());
} }
@Transactional @Transactional
@@ -169,6 +168,7 @@ public class DeliveryTransferService {
return transferResolved(dispatch, operatorId, request); return transferResolved(dispatch, operatorId, request);
} }
/** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) { DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId(); String customerId = dispatch.getCustomerId();
@@ -197,10 +197,9 @@ public class DeliveryTransferService {
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash())) if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash())) && !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对"); throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet()); var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none() 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()); validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes()); if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
@@ -303,8 +302,10 @@ public class DeliveryTransferService {
deliveries.add(delivery); deliveries.add(delivery);
} }
for (var quote : freight.quotes()) { for (var quote : freight.quotes()) {
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow(); var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey(
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow(); item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(),
item.getDeliveryAddress(), item.getPostalCode()))).findFirst()
.orElseThrow(() -> conflict("外接约定目的地未生成对应派送单"));
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT", recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 " "外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now); + quote.currency() + " " + quote.amount().toPlainString(), now);
@@ -320,7 +321,8 @@ public class DeliveryTransferService {
} }
DeliveryOrder feeDelivery = deliveries.get(0); DeliveryOrder feeDelivery = deliveries.get(0);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees(); 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) { for (DeliveryRequests.FeeItem input : feeInputs) {
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT); String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode); var uncovered = coverage.uncovered(feeCode);
@@ -328,11 +330,19 @@ public class DeliveryTransferService {
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction()) var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList() ? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee() : "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() var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow() ? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery; : feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems); 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())) { 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(); 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; firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
@@ -354,6 +364,7 @@ public class DeliveryTransferService {
return result(outbound, deliveries, false); return result(outbound, deliveries, false);
} }
/** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) { private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash())) if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程"); throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
@@ -1637,7 +1648,8 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3), allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4)); 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.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 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); } 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, int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt, LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow, 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, public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
@@ -37,6 +37,10 @@ import java.util.Objects;
@Component @Component
@Transactional(propagation = Propagation.MANDATORY) @Transactional(propagation = Propagation.MANDATORY)
public class DispatchFleetAdapter implements FleetDispatchGateway { 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 DispatchOrderMapper orders;
private final DispatchPdaTaskMapper tasks; private final DispatchPdaTaskMapper tasks;
private final OutboundOrderMapper outbounds; 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 (!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 (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) 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); 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) { private static String destination(String type, String code, String fba, String address, String postalCode) {
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode); return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
} }
/** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */
@Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) { @Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) {
var dispatch=orders.findGlobalByDispatchNo(dispatchNo); var dispatch=orders.findGlobalByDispatchNo(dispatchNo);
if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在"); if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");
return items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()).stream().map(item->new SourceCargo(item.getInboundCode(), return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()));
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();
} }
@Override @Override
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) { public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
@Service @Service
public class DispatchPdaWorkflowService { public class DispatchPdaWorkflowService {
@Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private FleetDispatchBindingGuard fleetBindings; @Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders; @Resource private DispatchOrderMapper orders;
@@ -238,6 +239,7 @@ public class DispatchPdaWorkflowService {
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
throw conflict("Dispatch must be transferred to a pending outbound order before departure"); throw conflict("Dispatch must be transferred to a pending outbound order before departure");
} }
deliveryExecution.requireDeparture(outbound);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus()) if (task == null || !"COMPLETED".equals(task.getStatus())
|| !"READY_TO_DEPART".equals(order.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); return response(responseOrder, assignedItems);
} }
/** 在申请锁内校验全托并原子建立整单排车。 */
@Transactional @Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { 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())) { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
} }
@@ -613,12 +614,15 @@ public class DispatchService {
relabelBindingGuard.requireBound(application); relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application); relabelMeasurements.requireMeasured(application);
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat( 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(); 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) Set<Long> requested = new HashSet<>(request.planPalletIds());
&& !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) if (requested.contains(null) || requested.size() != request.planPalletIds().size())
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘"); 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(); 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, "换标订单托盘候选已变化,请刷新后重试"); if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId(); String warehouse = selected.get(0).overseasWarehouseId();
@@ -626,17 +630,17 @@ public class DispatchService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); 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.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); 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); items.insert(item); } 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); pdaTasks.insert(task); 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 : items.findByDispatchOrder(customerId, dispatch.getId())) { for (DispatchItem item : mutations().items(dispatch)) {
if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId())
&& Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { && 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, "换标订单已被其他排车占用"); if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); return response(dispatch, mutations().items(dispatch));
} }
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) { private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
@@ -704,17 +708,14 @@ public class DispatchService {
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
} }
/** 仅编辑未执行且无有效出库或车次占用的排车。 */
@Transactional @Transactional
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { 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"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId()); requirePalletEditingAllowed(dispatch);
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Only a pending dispatch can be edited");
}
String customerId = dispatch.getCustomerId(); String customerId = dispatch.getCustomerId();
List<DispatchItem> assignedItems = items.findByDispatchOrder(customerId, dispatch.getId()); List<DispatchItem> assignedItems = mutations().items(dispatch);
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName())); dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone())); dispatch.setDriverPhone(trimToNull(request.driverPhone()));
@@ -726,62 +727,65 @@ public class DispatchService {
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
dispatch.setUpdatedAt(LocalDateTime.now()); dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch); mutations().saveOrder(dispatch);
return response(dispatch, assignedItems); return response(dispatch, assignedItems);
} }
/** 移除普通来源托盘,整单换标只允许整单取消重排。 */
@Transactional @Transactional
public DispatchResponse removeItem(String dispatchNo, Long itemId) { 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"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch); 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 (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
if ("RETURN_RELABEL".equals(item.getSourceStage())) if ("RELABEL_ORDER".equals(item.getSourceBusinessType()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车"); throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车");
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>() List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) 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()))) || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
.toList(); .toList();
return removeLockedItem(dispatch, item, matchingLocks); return removeLockedItem(dispatch, item, matchingLocks);
} }
/** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */
private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item, private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item,
List<DispatchPalletLock> matchingLocks) { List<DispatchPalletLock> matchingLocks) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
matchingLocks.forEach(lock -> releaseLock(lock, now)); matchingLocks.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId()); mutations().removeItem(item.getId());
appointmentAllocations.removeItem(dispatch.getId(),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.setItemCount(remaining.size());
dispatch.setPalletCount(remaining.size()); dispatch.setPalletCount(remaining.size());
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum()); .mapToInt(Integer::intValue).sum());
dispatch.setRoute(itemRoute(remaining)); dispatch.setRoute(itemRoute(remaining));
dispatch.setUpdatedAt(now); dispatch.setUpdatedAt(now);
orders.updateById(dispatch); mutations().saveOrder(dispatch);
return response(dispatch, remaining); return response(dispatch, remaining);
} }
/** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */
private void requirePalletEditingAllowed(DispatchOrder dispatch) { private void requirePalletEditingAllowed(DispatchOrder dispatch) {
fleetBindings.requireUnbound(dispatch.getId()); fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) 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"); 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, "已生成出库单,不能再修改排车托盘"); throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘");
} }
} }
/** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */
@Transactional @Transactional
public void cancel(String dispatchNo) { 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"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId()); fleetBindings.requireUnbound(dispatch.getId());
if (CANCELLED.equals(dispatch.getStatus())) return; 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"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
} }
String customerId = dispatch.getCustomerId(); 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, "排车已开始拣货,不能取消并释放已出库箱"); 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, "已生成出库单,请先取消对应出库单再取消排车"); throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED); dispatch.setStatus(CANCELLED);
workChanges.requirementsChanged(); workChanges.requirementsChanged();
dispatch.setUpdatedAt(now); dispatch.setUpdatedAt(now);
orders.updateById(dispatch); mutations().saveOrder(dispatch);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { for (DispatchItem item : mutations().items(dispatch)) {
// 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。
if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item); 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())) .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
.forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo)); .forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>() List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) { for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED"); lock.setStatus("RELEASED");
lock.setActiveKey(null); lock.setActiveKey(null);
lock.setReleasedAt(now); lock.setReleasedAt(now);
lock.setUpdatedAt(now); lock.setUpdatedAt(now);
locks.updateById(lock); mutations().saveLock(lock);
} }
} }
@@ -861,7 +865,36 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), 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) { private static String candidateRoute(List<DispatchPalletCandidate> selected) {
@@ -26,16 +26,16 @@ public final class ExceptionOrderRequests {
@Min(1) Integer packageCount, @Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {} @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 = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection, @NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo, @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) {} @NotBlank @Size(max = 255) String idempotencyKey) {}
public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId, public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId, @Min(1) Long inventoryPositionId,
@Min(1) long packageCount) {} @Min(1) long packageCount, @Min(1) Long returnCartonId) {}
public record StartProcessing(@Min(0) long expectedVersion, public record StartProcessing(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@@ -45,15 +45,16 @@ public final class ExceptionOrderRequests {
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 2000) String handlingResult, @NotBlank @Size(max = 2000) String handlingResult,
@NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds, @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, public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId, @Min(1) Long inventoryPositionId,
@Min(1) long receivedPackages, @Min(1) long receivedPackages,
@NotNull @Min(1) Long targetPalletId, @NotNull @Min(1) Long targetPalletId,
@NotBlank @Size(max = 128) String targetPalletNo, @NotBlank @Size(max = 128) String targetPalletNo,
@NotNull @Min(1) Long targetLocationId, @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, public record Cancel(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@@ -26,7 +26,8 @@ public final class ExceptionOrderResponses {
List<ReturnSource> returnSources) {} List<ReturnSource> returnSources) {}
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, 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 Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts) {}
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
List<Redispatch> redispatches) {} List<Redispatch> redispatches) {}
@@ -15,6 +15,8 @@ public class ExceptionReturnItem {
private Long exceptionOrderId; private Long exceptionOrderId;
private Long outboundPalletItemId; private Long outboundPalletItemId;
private Long inventoryPositionId; private Long inventoryPositionId;
private Long returnCartonId;
private String returnCartonMark;
private String palletNo; private String palletNo;
private String skuCode; private String skuCode;
private Long maxReturnPackages; 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 @Mapper
public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnItem> { 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") @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, List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
@Param("exceptionOrderId") Long exceptionOrderId); @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 InventoryCommandService inventoryCommands;
@Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ExceptionEvidenceFileGateway evidenceFiles;
@Resource private ObjectMapper objectMapper; @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; @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) { public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
@@ -116,7 +118,18 @@ public class ExceptionOrderService {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在"); if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), 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( private List<ExceptionOrderResponses.ReturnReceipt> receiptHistory(
@@ -183,10 +196,19 @@ public class ExceptionOrderService {
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream() .orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> withReturnSources( .map(order -> withReturnSources(
relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery)) returnRelation(order), delivery))
.distinct().toList(); .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 @Transactional
public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, String operatorName, public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, String operatorName,
ExceptionOrderRequests.Create request) { ExceptionOrderRequests.Create request) {
@@ -196,6 +218,7 @@ public class ExceptionOrderService {
request.skuCode(), null, null, "MANUAL", operatorId, operatorName); request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
} }
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) { ExceptionOrderRequests.MarkReturn request) {
@@ -206,8 +229,8 @@ public class ExceptionOrderService {
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery); List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream() ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) .filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); .findFirst().orElseThrow(() -> conflict("所选尾程主单不属于该派送单"));
List<ExceptionOrderResponses.ReturnSource> available = selected.returnSources(); List<ExceptionOrderResponses.ReturnSource> available = selected.returnSources();
List<ExceptionOrderRequests.ReturnItem> requested = request.items(); List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested); validateReturnItems(available, requested);
@@ -229,6 +252,7 @@ public class ExceptionOrderService {
ExceptionReturnItem item = new ExceptionReturnItem(); ExceptionReturnItem item = new ExceptionReturnItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id()); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id());
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); 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.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setReceivedPackages(0L); item.setReceivedPackages(0L);
@@ -302,6 +326,7 @@ public class ExceptionOrderService {
return result; return result;
} }
/** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */
@Transactional @Transactional
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId, public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
String exceptionNo, String exceptionNo,
@@ -312,6 +337,7 @@ public class ExceptionOrderService {
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
ReturnedInventoryRestoreResponse restored = null; ReturnedInventoryRestoreResponse restored = null;
long nativeRestored=0;
List<ExceptionReturnItem> stored = List.of(); List<ExceptionReturnItem> stored = List.of();
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo()); var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo());
@@ -320,18 +346,21 @@ public class ExceptionOrderService {
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
Map<Long, Long> received = ReturnReceiptPolicy.quantities(stored, request.receipts()); 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(), new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(), 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.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(), 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.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds().stream().map(String::valueOf).toList())); 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("退回库存实际入账数量与本次实收不一致"); throw conflict("退回库存实际入账数量与本次实收不一致");
for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) { for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) {
item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(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.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order); order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
closeReturnedDelivery(order, operatorId, now); 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); ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result); recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result);
return result; return result;
@@ -474,12 +503,15 @@ public class ExceptionOrderService {
readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value)); readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value));
} }
/** 原出库数量减去已实际回仓量,历史原箱与普通仓位分别核平。 */
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation, private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
DeliveryOrder delivery) { DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream() List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) .filter(source -> source.lastMileOrderId() != null
.map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource( ? Objects.equals(source.lastMileOrderId(), relation.lastMileOrderId())
delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId()))) : 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(); .filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList();
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
@@ -487,6 +519,7 @@ public class ExceptionOrderService {
relation.outboundNo(), relation.availablePackages(), sources); relation.outboundNo(), relation.availablePackages(), sources);
} }
/** 合并实际出库拣货证据,来源类型始终保留为普通仓位或原箱。 */
private List<Source> returnSources(DeliveryOrder delivery) { private List<Source> returnSources(DeliveryOrder delivery) {
OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo()); OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo());
if (outbound == null) throw conflict("派送单缺少原出库单据"); if (outbound == null) throw conflict("派送单缺少原出库单据");
@@ -499,13 +532,14 @@ public class ExceptionOrderService {
if (item == null || pick.getInventoryPositionId() == null) continue; if (item == null || pick.getInventoryPositionId() == null) continue;
String key = item.getId() + ":" + pick.getInventoryPositionId(); String key = item.getId() + ":" + pick.getInventoryPositionId();
aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(), 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())); (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
} }
appendDispatchPickSources(delivery, outbound, itemById, aggregated); appendDispatchPickSources(delivery, outbound, itemById, aggregated);
return new ArrayList<>(aggregated.values()); return new ArrayList<>(aggregated.values());
} }
/** 按实际拣货和原订单分摊归属恢复来源,原箱不伪造库存仓位。 */
private void appendDispatchPickSources(DeliveryOrder delivery, OutboundOrder outbound, private void appendDispatchPickSources(DeliveryOrder delivery, OutboundOrder outbound,
Map<Long, OutboundPalletItem> outboundItemById, Map<Long, OutboundPalletItem> outboundItemById,
Map<String, Source> aggregated) { Map<String, Source> aggregated) {
@@ -518,7 +552,7 @@ public class ExceptionOrderService {
.forEach(item -> dispatchItemById.put(item.getId(), item)); .forEach(item -> dispatchItemById.put(item.getId(), item));
for (DispatchPdaPick pick : dispatchPdaPicks.listTask(customerId, task.getId())) { for (DispatchPdaPick pick : dispatchPdaPicks.listTask(customerId, task.getId())) {
DispatchItem dispatchItem = dispatchItemById.get(pick.getDispatchItemId()); 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()); List<DispatchPdaPickOrder> attributions = dispatchPdaPickOrders.listPick(customerId, pick.getId());
for (DispatchPdaPickOrder attribution : attributions) { for (DispatchPdaPickOrder attribution : attributions) {
LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId()); LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId());
@@ -533,39 +567,53 @@ public class ExceptionOrderService {
if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源"); if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源");
if (matchingItems.isEmpty()) continue; if (matchingItems.isEmpty()) continue;
OutboundPalletItem outboundItem = matchingItems.get(0); 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); 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(), 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())); (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
} }
} }
} }
/** 详情仅返回冻结来源的剩余可收量及原箱业务码。 */
private List<ExceptionOrderResponses.ReturnSource> storedReturnSources(ExceptionOrder order) { private List<ExceptionOrderResponses.ReturnSource> storedReturnSources(ExceptionOrder order) {
return returnItems.findByException(order.getCustomerId(), order.getId()).stream() return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), 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(); .filter(item -> item.maxReturnPackages() > 0).toList();
} }
/** 申请的来源必须唯一、数量有效且不超过本派送原出库事实。 */
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available, private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
List<ExceptionOrderRequests.ReturnItem> requested) { List<ExceptionOrderRequests.ReturnItem> requested) {
Set<String> unique = new java.util.HashSet<>(); Set<String> unique = new java.util.HashSet<>();
for (ExceptionOrderRequests.ReturnItem item : requested) { 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("退回库存明细不能重复"); if (!unique.add(key)) throw badRequest("退回库存明细不能重复");
ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item)) ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item))
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单")); .findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和尾程主单"));
if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数"); if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数");
} }
} }
/** 出库项与两类互斥来源共同确定一条真实退回明细。 */
private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) { private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) {
return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId()) 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) { 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 static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo,
String skuCode, String inboundCode, long maxReturnPackages) { 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); } /** 保留原始来源身份,仅修订剩余可退数量。 */
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages, returnCartonId, cartonMark, lastMileOrderId); }
/** 以原箱业务码展示,内部来源标识仅用于提交核对。 */
ExceptionOrderResponses.ReturnSource response() { ExceptionOrderResponses.ReturnSource response() {
return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId, 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 { final class ReturnReceiptPolicy {
private ReturnReceiptPolicy() { } private ReturnReceiptPolicy() { }
/** 实收必须逐一匹配冻结的普通仓位或原箱来源,禁止两类标识同时提交。 */
static Map<Long, Long> quantities(List<ExceptionReturnItem> sources, List<ReturnReceipt> receipts) { static Map<Long, Long> quantities(List<ExceptionReturnItem> sources, List<ReturnReceipt> receipts) {
Map<Long, Long> amounts = new LinkedHashMap<>(); Map<Long, Long> amounts = new LinkedHashMap<>();
if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细"); if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细");
for (ReturnReceipt receipt : receipts) { for (ReturnReceipt receipt : receipts) {
requireSource(receipt.inventoryPositionId(), receipt.returnCartonId());
ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId()) 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("退回实收明细不属于本异常冻结来源")); .orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源"));
if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零"); if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零");
amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact); amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact);
@@ -29,6 +32,12 @@ final class ReturnReceiptPolicy {
return amounts; 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 long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); }
static boolean complete(List<ExceptionReturnItem> items) { static boolean complete(List<ExceptionReturnItem> items) {
return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages()); 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, public record Preview(String snapshotHash, boolean requiresDeliveryFee, List<Quote> quotes,
@com.fasterxml.jackson.annotation.JsonIgnore List<String> priorInboundCodes) { } @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, public Preview preview(long dispatchId, String dispatchNo, String customerId,
List<FleetDispatchGateway.SourceCargo> sources) { List<FleetDispatchGateway.SourceCargo> sources) {
var quotes = new ArrayList<Quote>(); var quotes = new ArrayList<Quote>();
@@ -38,7 +38,7 @@ public class FleetBookingFreight {
else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source); else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source);
} }
for (var entry : grouped.entrySet()) { 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 (booking == null) { requiresFee = true; continue; }
if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致"); if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致");
var accepted = reservations.receipt(booking); var accepted = reservations.receipt(booking);
@@ -62,7 +62,7 @@ public class FleetBookingFreight {
} }
var confirmed = new TreeMap<String, Integer>(); var confirmed = new TreeMap<String, Integer>();
for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact); 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); var cargo = reservations.cargo(booking);
BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE; BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE;
if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null
@@ -6,12 +6,13 @@ import java.util.List;
@Mapper @Mapper
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> { 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}") @Select("SELECT * FROM ll_fleet_external_booking WHERE booking_no=#{number}")
FleetBookingEntity byNo(@Param("number") String 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") @Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id")
List<FleetBookingEntity> list(@Param("tripId") long tripId); 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}") @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, 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, @Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { } @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, public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets, @NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets,
@NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice, @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);} 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());} 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){ 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)); var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
for(var entry:bySource.entrySet()){ 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.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
var accepted=receipt(row);if(accepted==null)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("排车货物托盘或目的地与外接实收不一致"); if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致");
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); 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); row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),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); save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
} }
@Transactional @Transactional
/** 首次或解除排车后的重新核对都要求当前实收摘要、版本、完整实托和容量一致。 */
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){ 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); 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(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
if(vehicleBlock!=null)throw conflict(vehicleBlock); if(vehicleBlock!=null)throw conflict(vehicleBlock);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); 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); 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(!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("实收数据已变化,请刷新后重新核对"); 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("请为每个实际托盘填写一次占用托位"); 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()); 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("托位确认须与实际收货托盘一致"); 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; 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("实际托盘缺少有效箱数、重量或体积"); 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()); 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); reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT"); 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("来货实收确认未完成或内容发生变化,请重新核对"); 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); row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt( if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt(
confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(), 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'" + "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," + " 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" + "-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,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,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'" + " 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 @Component
public class FleetIncomingAdapter implements FleetBookingIncomingGateway { 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; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
private final WarehouseTransferService service; private final WarehouseTransferService service;
private final WarehouseTransferOrderMapper orders; 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","外接承运已确认,按实际来货收货")); var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货"));
fleetPool.bindIncoming(input.bookingNo(),input.customerId(),approved.transferNo(),approved.lastlegOrderId(), fleetPool.bindIncoming(input.bookingNo(),input.customerId(),approved.transferNo(),approved.lastlegOrderId(),
approved.operatingWarehouseId(),key,operator); approved.operatingWarehouseId(),key,operator);
return evidence(approved); return evidence(input.customerId(),approved,false);
} }
@Override @Transactional(propagation=Propagation.MANDATORY) @Override @Transactional(propagation=Propagation.MANDATORY)
public Evidence inspect(String customerId,String incomingNo,boolean lock){ public Evidence inspect(String customerId,String incomingNo,boolean lock){
var row=lock?orders.lockNo(customerId,incomingNo):orders.findNo(customerId,incomingNo); var row=lock?orders.lockNo(customerId,incomingNo):orders.findNo(customerId,incomingNo);
if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前客户无此外接来货单"); 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) @Override @Transactional(propagation=Propagation.MANDATORY)
public Evidence confirm(String customerId,String incomingNo,String operator,String key){ public Evidence confirm(String customerId,String incomingNo,String operator,String key){
var row=orders.lockNo(customerId,incomingNo); var row=orders.lockNo(customerId,incomingNo);
if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"外接来货单不存在"); if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"外接来货单不存在");
if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(service.detail(customerId,incomingNo)); if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(customerId,service.detail(customerId,incomingNo),true);
return evidence(service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量"))); 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( 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())) row.warehousePalletId()==null?null:row.warehousePalletId().toString(),row.palletNo(),row.cartonCount(),row.grossWeightKg(),row.volumeCbm()))
.sorted(Comparator.comparing(row->Objects.requireNonNullElse(row.warehousePalletId(),""))).toList(); .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()); String destination=FleetDispatchGateway.destinationKey(value.destinationType(),value.destinationWarehouseCode(),value.fbaWarehouseCode(),value.privateDestinationAddress(),value.privateDestinationPostalCode());
int packages=pallets.stream().mapToInt(Pallet::packages).sum(); int packages=pallets.stream().mapToInt(Pallet::packages).sum();
try{ 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);
}
}
@@ -2927,16 +2927,53 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
CREATE TABLE IF NOT EXISTS ll_exception_return_item ( CREATE TABLE IF NOT EXISTS ll_exception_return_item (
id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', inventory_position_id BIGINT NULL COMMENT '普通原出库库存位置;与退货原箱互斥',
return_carton_id BIGINT NULL COMMENT '原生退货箱标识;与普通库存位置互斥',
return_carton_mark VARCHAR(128) NULL COMMENT '原生退货箱业务箱码快照',
pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照',
sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)),
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id),
KEY idx_ll_exception_return_order(customer_id,exception_order_id) KEY idx_ll_exception_return_order(customer_id,exception_order_id)
) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; ) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE IF NOT EXISTS ll_return_carton_stock_receipt (
id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号',
exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单',
exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细',
delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单',
source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据',
source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号',
source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细',
source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细',
return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识',
carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识',
target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘',
target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码',
target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位',
target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码',
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名',
quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数',
packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零',
evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用',
received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人',
received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code),
KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at),
KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id),
CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1))
) ENGINE=InnoDB COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据';
CREATE TABLE IF NOT EXISTS ll_exception_order_command ( CREATE TABLE IF NOT EXISTS ll_exception_order_command (
id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作',
@@ -167,7 +167,7 @@ paths:
parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}]
post: post:
summary: 运营确认委托方实收货量与运费 summary: 运营确认委托方实收货量与运费
description: '权限:platform:fleet:trip:booking-confirm;实际来货已完成测量,receiptHash和实托集合一致,重新核算容量和约定金额,引用真实收货证据并确认原来货流程;不重复入库。差异必须显式确认,超容量回滚。' description: '权限:platform:fleet:trip:booking-confirm;CONFIRMED或尚未绑定排车的RECEIVED可核对。实际来货已完成测量,receiptHash和实托集合一致,重新核算容量和约定金额,引用真实收货证据并确认原来货流程;不重复入库。拆合托后先解除排车关联,再以新请求键及当前版本重新确认实收和运费,完整实收量与承诺车次限制不放宽。差异必须显式确认,超容量回滚。'
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingAcceptReceipt'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingAcceptReceipt'}}}}
responses: {'200': {$ref: '#/components/responses/BookingResult'}} responses: {'200': {$ref: '#/components/responses/BookingResult'}}
/api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/dispatch: /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/dispatch:
@@ -450,6 +450,8 @@ components:
properties: properties:
warehousePalletId: {type: string} warehousePalletId: {type: string}
palletSpaces: {type: integer, minimum: 1} palletSpaces: {type: integer, minimum: 1}
weightKg: {type: number, nullable: true, minimum: 0.001, description: 当前组成缺少可信测量时必填,为该票外接货实测毛重,不填整托其他来源重量}
volumeCbm: {type: number, nullable: true, minimum: 0.001, description: 当前组成变化后重新实测的该票货体积;不沿用旧托盘或计费材积重}
BookingAcceptReceipt: BookingAcceptReceipt:
allOf: allOf:
- {$ref: '#/components/schemas/BookingCommand'} - {$ref: '#/components/schemas/BookingCommand'}
@@ -1044,7 +1044,7 @@ paths:
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete: /api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:
post: post:
summary: 完成异常处理并原子恢复退回库存 summary: 完成异常处理并原子恢复退回库存
description: 权限 platform:overseas-warehouse:exception-order:process;退回海外仓支持分批实收,每批使用新幂等键及当前版本,累计不得超过批准量。库存 RETURN_STOCK_IN 成功后累计实收,未收齐保留 PROCESSING,批准量收齐才转 COMPLETED。详情 returnSources 只返回剩余可收数量。派送仅在累计实际退回覆盖整单实发数量时写 RETURNED 和 RETURN_COMPLETED;非海外仓异常结案不证明货物交接完成。原 signedAt 保留,车次及资源不由工单关闭释放。 description: 权限 platform:overseas-warehouse:exception-order:process;退回海外仓支持分批实收,每批使用新幂等键及当前版本,累计不得超过批准量。普通库存 RETURN_STOCK_IN 或原箱不可变回仓凭据成功落账后累计实收,未收齐保留 PROCESSING,批准量收齐才转 COMPLETED。详情 returnSources 只返回剩余可收数量。派送仅在累计实际退回覆盖整单实发数量时写 RETURNED 和 RETURN_COMPLETED;非海外仓异常结案不证明货物交接完成。原 signedAt 保留,车次及资源不由工单关闭释放。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } }
@@ -1065,11 +1065,11 @@ paths:
responses: { '200': { description: 关联关系 } } responses: { '200': { description: 关联关系 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options:
get: get:
summary: 查询派送退回候选入仓单 summary: 查询派送退回候选主单
description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。 description: 权限 platform:overseas-warehouse:exception-order:list;每个候选以lastMileOrderId定位、lastMileOrderNo展示;inboundOrderId和inboundCode可空,换标主单不要求普通入仓单。返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。每条来源返回outboundPalletItemId、inventoryPositionId、returnCartonId、cartonMark、palletNo、skuCode和maxReturnPackages;普通仓位与原箱互斥,箱码用于展示,内部标识仅作提交键。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 退回候选入仓单 } } responses: { '200': { description: 退回候选主单及真实出库来源 } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:
post: post:
summary: 将派送单标记为退回入仓 summary: 将派送单标记为退回入仓
@@ -1081,7 +1081,7 @@ paths:
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:
put: put:
summary: 更新派送状态 summary: 更新派送状态
description: 权限 platform:overseas-warehouse:delivery:status。人工只允许 WAIT_DELIVERY→DELIVERING/REJECTED/CANCELLED、DELIVERING→REJECTED;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。 description: 权限 platform:overseas-warehouse:delivery:status。人工只允许实际出库后的 WAIT_DELIVERY→DELIVERING/REJECTED、DELIVERING→REJECTED;取消仅允许经未执行出库的聚合取消命令;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
@@ -2865,8 +2865,9 @@ components:
dispatch: { $ref: '#/components/schemas/DispatchResponse' } dispatch: { $ref: '#/components/schemas/DispatchResponse' }
SmartDispatchPreArrange: SmartDispatchPreArrange:
type: object type: object
description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。 description: 普通来源使用planPalletIds或returnReceiptIds,合计至少一项且最多500项。指定relabelNo时planPalletIds必须是本换标申请全部已绑定实物托盘ID,普通与退货换标都不允许部分选择或混选其他来源;换标选择暂不持有统一草稿锁,仅提交时在申请锁内完整复核。退回来源仅接受实际入账生成的实收记录;同一实物托盘不可重复选择。
properties: properties:
relabelNo: { type: string, nullable: true, description: 整单换标申请业务号;不与退回实收来源混选 }
batchAllocations: batchAllocations:
type: array type: array
maxItems: 1000 maxItems: 1000
@@ -2992,18 +2993,19 @@ components:
type: object type: object
required: [status, expectedVersion] required: [status, expectedVersion]
properties: properties:
status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED, CANCELLED] } status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED] }
expectedVersion: { type: integer, format: int64, minimum: 0 } expectedVersion: { type: integer, format: int64, minimum: 0 }
remark: { type: string, nullable: true, maxLength: 500 } remark: { type: string, nullable: true, maxLength: 500 }
DeliveryAllowedAction: DeliveryAllowedAction:
type: string type: string
enum: [START_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] enum: [START_DELIVERY, REJECT_DELIVERY, MARK_RETURN]
description: START/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。 description: START/REJECT 仅在关联出库已实际 OUTBOUNDED 后使用通用 status 接口;取消统一经未执行出库取消命令;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。
DeliveryMarkReturn: DeliveryMarkReturn:
type: object type: object
required: [inboundOrderId, reason, returnDirection, items, idempotencyKey] required: [lastMileOrderId, reason, returnDirection, items, idempotencyKey]
description: 按候选中真实尾程主单定位;普通及退货换标主单无需普通入仓单,不接受虚构入仓关系。
properties: properties:
inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' } lastMileOrderId: { type: string, pattern: '^[1-9][0-9]*$' }
reason: { type: string, minLength: 1, maxLength: 1000 } reason: { type: string, minLength: 1, maxLength: 1000 }
returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] } returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] }
returnLogisticsNo: { type: string, nullable: true, maxLength: 128 } returnLogisticsNo: { type: string, nullable: true, maxLength: 128 }
@@ -3014,10 +3016,11 @@ components:
maxItems: 200 maxItems: 200
items: items:
type: object type: object
required: [outboundPalletItemId, inventoryPositionId, packageCount] required: [outboundPalletItemId, packageCount]
properties: properties:
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } inventoryPositionId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 普通库存来源;与returnCartonId必须二选一 }
returnCartonId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 原生退货箱来源;按整箱实收,不得虚构库存仓位 }
packageCount: { type: integer, format: int64, minimum: 1 } packageCount: { type: integer, format: int64, minimum: 1 }
ExceptionStartProcessing: ExceptionStartProcessing:
type: object type: object
@@ -3046,10 +3049,11 @@ components:
maxItems: 200 maxItems: 200
items: items:
type: object type: object
required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode] required: [outboundPalletItemId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode]
properties: properties:
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } inventoryPositionId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 普通库存来源;与returnCartonId必须二选一 }
returnCartonId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 原生退货箱来源;按整箱实收,不得虚构库存仓位 }
receivedPackages: { type: integer, format: int64, minimum: 1 } receivedPackages: { type: integer, format: int64, minimum: 1 }
targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' } targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' }
targetPalletNo: { type: string, minLength: 1, maxLength: 128 } targetPalletNo: { type: string, minLength: 1, maxLength: 128 }
@@ -3122,8 +3126,9 @@ components:
properties: { reason: { type: string, minLength: 1, maxLength: 1000 } } properties: { reason: { type: string, minLength: 1, maxLength: 1000 } }
DispatchResponse: DispatchResponse:
type: object type: object
required: [appointmentBatches] required: [appointmentBatches, actions]
properties: properties:
actions: { $ref: '#/components/schemas/DispatchActions' }
id: { type: string } id: { type: string }
dispatchNo: { type: string } dispatchNo: { type: string }
vehiclePlate: { type: string, nullable: true } vehiclePlate: { type: string, nullable: true }
@@ -3155,12 +3160,27 @@ components:
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
workflow: workflow:
nullable: true nullable: true
description: 仅平台分页返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。 description: 平台查询返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。
allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }] allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }]
bolLines: bolLines:
type: array type: array
description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大
items: { $ref: '#/components/schemas/DispatchBolLine' } items: { $ref: '#/components/schemas/DispatchBolLine' }
DispatchActions:
type: object
required: [canEdit, canCancel, canRemovePallet, canTransfer]
description: 服务端按执行事实计算;入口写权限仍需单独校验。取消按解除车次、取消出库、取消原排车顺序;取消单据保留,转派送原请求重放返回原取消结果。
properties:
canEdit: { type: boolean }
editBlockReason: { type: string, nullable: true }
canCancel: { type: boolean }
cancelBlockReason: { type: string, nullable: true }
canRemovePallet: { type: boolean }
removePalletBlockReason: { type: string, nullable: true }
canTransfer: { type: boolean }
transferBlockReason: { type: string, nullable: true }
outboundNo: { type: string, nullable: true }
outboundStatus: { type: string, nullable: true }
DispatchBatchSource: DispatchBatchSource:
type: object type: object
required: [sourceKey, destinationKey, packageCount] required: [sourceKey, destinationKey, packageCount]
@@ -24,8 +24,22 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class CustomerVisitServiceTest { class CustomerVisitServiceTest {
/** 未出库不得开始或拒签,已发车也没有人工取消旁路。 */
@Test void manualExecutionRequiresActualOutboundAndNeverAcceptsCancellation() {
var delivery = delivery("WAIT_DELIVERY");
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(outboundOrders.lockByNo("1001", "OUT-1")).thenReturn(null);
for (String target : java.util.List.of("DELIVERING", "REJECTED"))
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus(target, 0L, null))).hasMessageContaining("尚未实际出库");
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, null))).hasMessageContaining("不支持");
verify(deliveryOrders, never()).updateById(delivery);
verifyNoInteractions(deliveryOperations);
}
@Mock private CustomerVisitMapper visits; @Mock private CustomerVisitMapper visits;
@Mock private DeliveryOrderMapper deliveryOrders; @Mock private DeliveryOrderMapper deliveryOrders;
@Mock private com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper outboundOrders;
@Mock private DeliveryOperationMapper deliveryOperations; @Mock private DeliveryOperationMapper deliveryOperations;
@Mock private LastlegOrderMapper lastMileOrders; @Mock private LastlegOrderMapper lastMileOrders;
private CustomerVisitService service; private CustomerVisitService service;
@@ -33,6 +47,7 @@ class CustomerVisitServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new CustomerVisitService(); service = new CustomerVisitService();
ReflectionTestUtils.setField(service, "deliveryExecution", new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders));
ReflectionTestUtils.setField(service, "visits", visits); ReflectionTestUtils.setField(service, "visits", visits);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
@@ -97,6 +112,11 @@ class CustomerVisitServiceTest {
delivery.setCustomerId("1001"); delivery.setCustomerId("1001");
delivery.setDeliveryNo("DL-1"); delivery.setDeliveryNo("DL-1");
delivery.setStatus(status); delivery.setStatus(status);
delivery.setOutboundOrderId(22L); delivery.setOutboundNo("OUT-1");
var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder();
outbound.setId(22L); outbound.setCustomerId("1001"); outbound.setOutboundNo("OUT-1"); outbound.setStatus("OUTBOUNDED");
org.mockito.Mockito.lenient().when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
org.mockito.Mockito.lenient().when(outboundOrders.lockByNo("1001", "OUT-1")).thenReturn(outbound);
return delivery; return delivery;
} }
} }
@@ -24,6 +24,15 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class DeliveryFeeCoverageServiceTest { class DeliveryFeeCoverageServiceTest {
/** 同一排车明细中的外接货覆盖不能免掉普通来源的应收。 */
@Test void externalFreightCoversOnlyItsOwnInboundEvenWhenAnItemIsShared() {
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 11))));
var preview = service.preview("DP-1");
var selection = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101, 11).sourceHash(), List.of("LOADING_FEE"));
var result = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selection)), "OP", Set.of("IN-A"));
assertThat(result.includedServices()).doesNotContain("DELIVERY_FEE");
assertThat(result.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::inboundCode).containsExactly("IN-B");
}
final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class); final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class);
final DispatchItemMapper items = mock(DispatchItemMapper.class); final DispatchItemMapper items = mock(DispatchItemMapper.class);
final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class); final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class);
@@ -12,6 +12,34 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class DispatchFeeCargoMapperTest { class DispatchFeeCargoMapperTest {
/** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */
@Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() {
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A");
item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> {
assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2);
});
}
/** 展示普通货或外接货均不能改变真实组成;保留同客户、同仓及本次拣货边界。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"})
void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) {
jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound);
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
var sources = query.sources(dispatch, java.util.List.of(item));
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B");
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10);
item.setPackageCount(19);
assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致");
}
JdbcTemplate jdbc; DispatchFeeCargoMapper mapper; JdbcTemplate jdbc; DispatchFeeCargoMapper mapper;
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
@@ -41,10 +69,10 @@ class DispatchFeeCargoMapperTest {
assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L); assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L);
assertThat(mapper.list("202", 1)).isEmpty(); assertThat(mapper.list("202", 1)).isEmpty();
} }
@Test void aSelectedPlanPalletCannotBorrowOtherInboundStockOnTheSamePhysicalPallet() { @Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() {
jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801"); jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801");
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)");
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)"); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)");
assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11"); assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11", "12");
} }
} }
@@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest {
@Test @Test
void exposesOnlyServerAuthorizedActionsForEachState() { void exposesOnlyServerAuthorizedActionsForEachState() {
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
.containsExactly("START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); .containsExactly("START_DELIVERY", "REJECT_DELIVERY");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
.containsExactly("REJECT_DELIVERY", "MARK_RETURN"); .containsExactly("REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DeliveryExecutionGuardTest {
/** 直接消费真实仓储访问适配器,状态和数量由生产门禁判定。 */
@Test void departureRequiresCompleteExecutableDeliveriesAndLocksThem() {
var deliveries = mock(DeliveryOrderMapper.class);
var guard = new DeliveryExecutionGuard(new DeliveryExecutionRepository(mock(OutboundOrderMapper.class), deliveries));
var outbound = new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C");
outbound.setOutboundNo("OUT-1"); outbound.setPackageCount(2);
var delivery = new DeliveryOrder(); delivery.setCustomerId("C"); delivery.setOutboundOrderId(1L);
delivery.setOutboundNo("OUT-1"); delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(2);
when(deliveries.lockByOutbound("C", 1L)).thenReturn(List.of(delivery));
guard.requireDeparture(outbound);
delivery.setStatus("CANCELLED");
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("已取消");
delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(1);
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("完整覆盖");
delivery.setPackageCount(2); delivery.setCustomerId("OTHER");
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("来源不一致");
when(deliveries.lockByOutbound("C", 1L)).thenReturn(List.of());
assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("缺少对应派送单");
verify(deliveries, times(5)).lockByOutbound("C", 1L);
}
}
@@ -26,6 +26,7 @@ class DeliveryReturnEvidencePersistenceTest {
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)"); jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_return_carton_stock_receipt(id BIGINT,customer_id VARCHAR(20),source_dispatch_item_id BIGINT,source_outbound_pallet_item_id BIGINT,packages BIGINT,received_at TIMESTAMP,delivery_order_id BIGINT,receipt_no VARCHAR(64))");
jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')"); jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')");
jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)"); jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)");
jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)"); jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)");
@@ -134,6 +134,16 @@ class DeliveryTransferServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new DeliveryTransferService(); service = new DeliveryTransferService();
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
var actualSources = mock(DispatchCargoSourceQuery.class);
ReflectionTestUtils.setField(service, "cargoSources", actualSources);
org.mockito.Mockito.lenient().when(actualSources.sources(any(), any())).thenAnswer(call -> {
List<DispatchItem> selected = call.getArgument(1);
return selected.stream().map(item -> new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo(
item.getInboundCode(), item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
java.util.Objects.requireNonNullElse(item.getPackageCount(), 0), com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()))).toList();
});
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight); ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight);
ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage); ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage);
@@ -67,6 +67,7 @@ class DispatchPdaWorkflowServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new DispatchPdaWorkflowService(); service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "deliveryExecution", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
objectMapper = new ObjectMapper(); objectMapper = new ObjectMapper();
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.work.AppointmentWorkDispatchMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
class DispatchAppointmentCancellationPersistenceTest {
/** 实际取消只解除有效预约运输占用,历史引用和后来重排的占用都保留。 */
@Test void cancellationReleasesEffectiveBatchAllocationAndReplayCannotReleaseTheNewDispatch() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(data);var tx=new TransactionTemplate(new DataSourceTransactionManager(data));
var config=new MybatisConfiguration();var mappers=List.of(DispatchOrderMapper.class,DispatchItemMapper.class,DispatchPalletLockMapper.class,OutboundOrderMapper.class,FleetTripDispatchMapper.class,AppointmentWorkDispatchMapper.class);
mappers.forEach(config::addMapper);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject());
for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) {
if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue;
var table=TableInfoHelper.getTableInfo((Class<?>)p.getActualTypeArguments()[0]);var columns=new ArrayList<String>();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
}
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(64),customer_id VARCHAR(64),source_key VARCHAR(64),destination_key VARCHAR(64),package_count INT)");
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(10,'C','DP-OLD','PENDING_DEPARTURE')");
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,package_count) VALUES(30,'C',10,1,4)");
db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,status) VALUES(40,'C',10,'CANCELLED')");
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(50,'C',10,'ACTIVE','RECEIVED:501')");
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,30,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)");
var allocations=session.getMapper(AppointmentWorkDispatchMapper.class);
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4);
var service=new DispatchService();ReflectionTestUtils.setField(service,"orders",session.getMapper(DispatchOrderMapper.class));ReflectionTestUtils.setField(service,"items",session.getMapper(DispatchItemMapper.class));ReflectionTestUtils.setField(service,"locks",session.getMapper(DispatchPalletLockMapper.class));ReflectionTestUtils.setField(service,"outboundOrders",session.getMapper(OutboundOrderMapper.class));ReflectionTestUtils.setField(service,"fleetBindings",new FleetDispatchBindingGuard(session.getMapper(FleetTripDispatchMapper.class),new ObjectMapper()));ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
tx.executeWithoutResult(s->service.cancel("DP-OLD"));
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=10",Long.class)).isEqualTo(1);
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(11,'C','DP-NEW','PENDING_DEPARTURE')");
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(51,'C',11,'ACTIVE','RECEIVED:501')");
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(11,31,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)");
tx.executeWithoutResult(s->service.cancel("DP-OLD"));
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4);
assertThat(db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=51",String.class)).isEqualTo("ACTIVE");
assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=50",String.class)).isNull();
}
/** 测试表保留生产Mapper列映射,正式DDL由独立Schema合同验证。 */
private static String sqlType(Class<?> type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(4000)";}
}
@@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest {
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2",
"NYEXX2609110214", null, null, 1)), null, List.of()); "NYEXX2609110214", null, null, 1)), null, List.of(), null);
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null); null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
@@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest {
new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse",
"NYEXX2609110214", null, null, 1), "NYEXX2609110214", null, null, 1),
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
"NYEXX2609110215", null, null, 1)), null, List.of()); "NYEXX2609110215", null, null, 1)), null, List.of(), null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
@@ -0,0 +1,430 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard;
import com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery;
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.mapper.*;
import com.baiyue.lastleg.relabel.mapper.*;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.service.RelabelBindingGuard;
import com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.ibatis.session.LocalCacheScope;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 连续调用真实服务、Mapper及事务;仅隔离外部通知、财务和MySQL专用候选聚合。 */
class DispatchRepairWorkflowTest {
private JdbcTemplate db;
private TransactionTemplate transactions;
private SqlSessionTemplate session;
private final Map<Class<?>, Object> dependencies = new LinkedHashMap<>();
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private DispatchService dispatch;
private DeliveryTransferService delivery;
private CustomerVisitService visits;
private ExceptionOrderService exceptions;
private DispatchFleetAdapter fleet;
private DispatchCandidateMapper candidates;
private OverseasFeeSourceService fees;
/** 每个用例使用独立H2库,不连接运行环境;真实Mapper元数据生成最小测试表。 */
@BeforeEach
void setUp() throws Exception {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
db = new JdbcTemplate(source);
transactions = new TransactionTemplate(new DataSourceTransactionManager(source));
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);
List<Class<?>> mappers = List.of(DispatchOrderMapper.class, DispatchItemMapper.class,
DispatchPalletLockMapper.class, DispatchBolMapper.class, DispatchPdaTaskMapper.class,
DispatchPdaPickMapper.class, DispatchPdaPickOrderMapper.class, OutboundOrderMapper.class,
OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class,
DeliveryOrderMapper.class, DeliveryOperationMapper.class, CustomerVisitMapper.class,
LastlegOrderMapper.class, LastlegInboundOrderMapper.class, LastlegOrderStatusHistoryMapper.class,
FleetTripDispatchMapper.class, FleetTripMapper.class, FleetBookingMapper.class, InventoryPositionMapper.class,
InventoryBalanceMapper.class, DispatchFeeCargoMapper.class, ExceptionReturnItemMapper.class, ExceptionOrderMapper.class,
RelabelApplicationMapper.class, RelabelItemMapper.class, PdaRelabelHistoryMapper.class,
RelabelPalletMeasurementMapper.class, ReturnInventoryCartonMapper.class, DispatchCandidateMapper.class,
WarehouseTransferOrderMapper.class);
mappers.forEach(configuration::addMapper);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(source);
factory.setConfiguration(configuration);
factory.afterPropertiesSet();
session = new SqlSessionTemplate(factory.getObject());
for (Class<?> mapper : mappers) dependencies.put(mapper, session.getMapper(mapper));
// 不复制正式SQL,也不声称验证生产DDL;只构造本文件使用的实体表。
for (Class<?> mapper : mappers) for (var parent : mapper.getGenericInterfaces()) {
if (!(parent instanceof java.lang.reflect.ParameterizedType generic)
|| generic.getRawType() != com.baomidou.mybatisplus.core.mapper.BaseMapper.class) continue;
var table = TableInfoHelper.getTableInfo((Class<?>) generic.getActualTypeArguments()[0]);
var columns = new ArrayList<String>();
if (table.havePK()) columns.add(table.getKeyColumn() + " " + sqlType(table.getKeyType())
+ (table.getIdType() == com.baomidou.mybatisplus.annotation.IdType.AUTO ? " AUTO_INCREMENT" : "") + " PRIMARY KEY");
table.getFieldList().forEach(field -> columns.add(field.getColumn() + " " + sqlType(field.getPropertyType())));
db.execute("CREATE TABLE IF NOT EXISTS " + table.getTableName() + " (" + String.join(",", columns) + ")");
}
db.execute("CREATE UNIQUE INDEX test_dispatch_active_lock ON ll_dispatch_pallet_lock(customer_id,active_key)");
// 普通换标与退货换标候选查询含MySQL专用聚合;固定输入不替代完整集合校验或状态写入。
candidates = spy(session.getMapper(DispatchCandidateMapper.class));
doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(), any(), any());
dependencies.put(DispatchCandidateMapper.class, candidates);
dependencies.put(ObjectMapper.class, json);
dependencies.put(LastlegOrderNoGenerator.class, new LastlegOrderNoGenerator());
dependencies.put(FleetDispatchBindingGuard.class,
new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class), json));
dependencies.put(FleetDispatchWorkflowQuery.class,
new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class), mapper(FleetTripMapper.class), json));
var execution = new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class));
dependencies.put(DeliveryExecutionRepository.class, execution);
dependencies.put(DeliveryExecutionGuard.class, new DeliveryExecutionGuard(execution));
dependencies.put(DispatchCargoSourceQuery.class,
new DispatchCargoSourceQuery(new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class))));
dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class,
mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class,
mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
fees = mock(OverseasFeeSourceService.class);
dependencies.put(OverseasFeeSourceService.class, fees);
var binding = wire(new RelabelBindingGuard());
var measurements = wire(new RelabelPalletMeasurementService());
var returnStock = wire(new ReturnRelabelDispatchStock());
dependencies.put(RelabelBindingGuard.class, binding);
dependencies.put(RelabelPalletMeasurementService.class, measurements);
dependencies.put(ReturnRelabelDispatchStock.class, returnStock);
dispatch = wire(new DispatchService());
delivery = wire(new DeliveryTransferService());
visits = wire(new CustomerVisitService());
exceptions = wire(new ExceptionOrderService());
fleet = new DispatchFleetAdapter(mapper(DispatchOrderMapper.class), mapper(DispatchPdaTaskMapper.class),
mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class), mapper(DispatchItemMapper.class),
mapper(DispatchPdaPickMapper.class), new DispatchPdaWorkflowService(), mapper(DeliveryOperationMapper.class));
wire(fleet);
}
/** 历史取消出库仍可追溯;取消旧排车后同货重排,重复取消不得释放新锁。 */
@Test
void cancelledOutboundThenDispatchCancellationAllowsNewArrangementWithoutReleasingItsLock() {
seedPendingOutbound();
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,'C',1,900,501,'PLAN:900','PLAN:900','ACTIVE')");
run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重新安排")));
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("CANCELLED");
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("CANCELLED");
long operations = count("ll_delivery_operation");
run(() -> dispatch.cancel("DP-1"));
assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("CANCELLED");
assertThat(text("ll_dispatch_pallet_lock", "status", 40)).isEqualTo("RELEASED");
assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=40", String.class)).isNull();
doReturn(List.of(candidate(501).planPalletId(900L))).when(candidates).lockSelected("C", List.of(900L));
var created = inTransaction(() -> dispatch.preArrange("C", "OP",
new DispatchRequests.PreArrange(List.of(900L), null, null, null)));
assertThat(created.dispatchNo()).isNotEqualTo("DP-1");
assertThat(created.actions().canCancel()).isTrue();
assertThat(created.actions().canTransfer()).isTrue();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重复取消")));
run(() -> dispatch.cancel("DP-1"));
assertThat(count("ll_outbound_order")).isEqualTo(1);
assertThat(count("ll_delivery_operation")).isEqualTo(operations);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
verify(fees, times(1)).listForBusiness("C", "DELIVERY_ORDER", 3L);
verify(fees, never()).voidSource(anyLong(), any(), any());
}
/** 真实车次关联和已拣事实分别阻止取消,失败事务不修改单据及占用。 */
@Test
void bindingThenPickingIndependentlyPreventOutboundCancellation() {
seedPendingOutbound();
db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(51,'TR-1',1,'DP-1','C')");
assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("解除关联");
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND");
db.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=1");
db.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=1");
assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消"))))
.isInstanceOf(ResponseStatusException.class);
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND");
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY");
assertThat(count("ll_delivery_operation")).isZero();
}
/** 未出库开始派送/拒签均拒绝;真实出库后允许开始,但普通状态编辑始终不能取消。 */
@ParameterizedTest
@ValueSource(strings = {"DELIVERING", "REJECTED"})
void deliveryActionsFollowActualOutboundAndNeverOfferAnIndependentCancellation(String target) {
seedPendingOutbound();
assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
new CustomerVisitRequests.DeliveryStatus(target, 0L, "提前操作"))))
.isInstanceOf(ResponseStatusException.class);
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY");
assertThat(count("ll_delivery_operation")).isZero();
db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=2 WHERE id=1");
db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2");
assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, "已发车取消"))))
.isInstanceOf(ResponseStatusException.class);
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY");
run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
new CustomerVisitRequests.DeliveryStatus(target, 0L, "合法操作")));
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo(target);
assertThat(db.queryForObject("SELECT version FROM ll_delivery_order WHERE id=3", Long.class)).isEqualTo(1);
long operations = count("ll_delivery_operation");
var replay = inTransaction(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
new CustomerVisitRequests.DeliveryStatus(target, 0L, "同状态重放")));
assertThat(replay.replayed()).isTrue();
assertThat(count("ll_delivery_operation")).isEqualTo(operations);
}
/** 已完成拣货仍须完整覆盖未执行派送;取消、缺失或数量失配均阻断发车。 */
@Test
void departureManifestRejectsCancelledMissingAndIncompleteDeliveryCoverage() {
seedPendingOutbound();
db.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=2 WHERE id=1");
db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'C',102,4,104,2)");
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNull();
db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3");
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank();
var guard = (DeliveryExecutionGuard) dependencies.get(DeliveryExecutionGuard.class);
assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("C", "OUT-1"))))
.isInstanceOf(ResponseStatusException.class);
db.update("UPDATE ll_delivery_order SET status='WAIT_DELIVERY',package_count=1 WHERE id=3");
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank();
db.update("DELETE FROM ll_delivery_order WHERE id=3");
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank();
assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("READY_TO_DEPART");
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND");
}
/** 两托部分选取和重复ID都必须零副作用,完整预排取消后仍可整单重新预排。 */
@Test
void ordinaryRelabelRequiresAllPalletsAndCanBeRearrangedAfterCancellation() {
db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,overseas_warehouse_id,status,fulfillment_status,delivery_method) VALUES(70,'C','RL-1','WH-1','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK')");
for (long pallet : List.of(501L, 502L)) {
db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,pallet_no,overseas_warehouse_id,hold_packages) VALUES(?,'C',?,?,'WH-1',1)", pallet, pallet, "P-" + pallet);
db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,carton_mark,notified_package_count) VALUES(?,'C',70,'CARTON_LABEL',?,?,1)", pallet, pallet, "BOX-" + pallet);
}
db.update("INSERT INTO ll_relabel_pda_history(id,customer_id,application_id,action,detail_json) VALUES(71,'C',70,'PALLET_BIND',?)",
"{\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}");
doReturn(List.of(candidate(501).orderRefs("RL-1").packageCount(1), candidate(502).orderRefs("RL-1").packageCount(1)))
.when(candidates).relabelAvailable("C", null, "RL-1");
for (var selection : List.of(List.of(501L), List.of(501L, 501L, 502L))) {
assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(selection))))
.isInstanceOf(ResponseStatusException.class);
assertThat(count("ll_dispatch_order")).isZero();
assertThat(count("ll_dispatch_item")).isZero();
assertThat(count("ll_dispatch_pda_task")).isZero();
assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH");
}
var first = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))));
assertThat(first.actions().canRemovePallet()).isFalse();
assertThat(count("ll_dispatch_item")).isEqualTo(2);
assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("PRE_DISPATCH_CREATED");
assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))))
.isInstanceOf(ResponseStatusException.class);
run(() -> dispatch.cancel(first.dispatchNo()));
assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH");
var second = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))));
assertThat(second.dispatchNo()).isNotEqualTo(first.dispatchNo());
assertThat(count("ll_dispatch_order")).isEqualTo(2);
}
/** 同实托展示任一来源,都要按真实组成绑定完整外接货并保留普通货收费资格。 */
@ParameterizedTest
@ValueSource(strings = {"IN-NORMAL", "IN-EXT"})
void mixedPalletUsesBothActualSourcesForBindingAndFreight(String displayInbound) throws Exception {
seedPendingOutbound();
db.update("UPDATE ll_dispatch_item SET inbound_code=?,package_count=5 WHERE id=4", displayInbound);
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'C',81,'IN-NORMAL'),(82,'C',83,'IN-EXT')");
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'C',80,501,'P-501','WH-1',3),(85,'C',82,501,'P-501','WH-1',2)");
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'C','LM-NORMAL','IN-NORMAL'),(83,'C','LM-EXT','IN-EXT')");
// 该测试同时校验客户隔离,不能把同物理托盘编号的他客库存算进来。
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'OTHER',87,'FOREIGN')");
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'OTHER',86,501,'WH-1',99)");
db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)");
var realCargo = mapper(DispatchFeeCargoMapper.class).list("C", 1);
assertThat(realCargo).extracting(row -> row.inboundCode()).containsExactly("IN-NORMAL", "IN-EXT");
assertThat(realCargo).extracting(row -> row.packages()).containsExactly(3L, 2L);
var sources = inTransaction(() -> fleet.sourceCargo("DP-1"));
assertThat(sources).extracting(FleetDispatchGateway.SourceCargo::inboundCode)
.containsExactlyInAnyOrder("IN-NORMAL", "IN-EXT");
assertThat(sources.stream().mapToInt(FleetDispatchGateway.SourceCargo::packages).sum()).isEqualTo(5);
seedExternalBooking();
var reservations = new FleetBookingReservations(mapper(FleetBookingMapper.class), json);
var trip = new FleetTripEntity(); trip.setId(91L); trip.setTripNo("TR-1");
run(() -> reservations.bindIncoming(trip,
new FleetDispatchGateway.Snapshot(1L, "DP-1", "C", "WH-1", 5), sources, "OP"));
assertThat(text("ll_fleet_external_booking", "status", 90)).isEqualTo("BOUND");
db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(91,'TR-1',1,'DP-1','C')");
var freight = new FleetBookingFreight(mapper(FleetBookingMapper.class), mapper(FleetTripDispatchMapper.class), reservations, json);
var preview = inTransaction(() -> freight.preview(1L, "DP-1", "C", sources));
assertThat(preview.requiresDeliveryFee()).isTrue();
assertThat(preview.quotes()).singleElement().satisfies(quote -> {
assertThat(quote.bookingNo()).isEqualTo("FB-EXT");
assertThat(quote.incomingNo()).isEqualTo("WT-EXT");
assertThat(quote.inboundCode()).isEqualTo("IN-EXT");
assertThat(quote.amount()).isEqualByComparingTo("60.00");
});
assertThat(mapper(FleetBookingMapper.class).byNo("FB-EXT").getDispatchNo()).isEqualTo("DP-1");
}
/** 退货换标真实箱来源必须成为可退候选,不得伪造普通库存仓位通过门禁。 */
@Test
void rejectedReturnRelabelRetainsItsNativeCartonSourceForExceptionReturn() {
seedPendingOutbound();
db.update("UPDATE ll_delivery_order SET status='REJECTED' WHERE id=3");
db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2");
db.update("UPDATE ll_dispatch_item SET source_stage='RETURN_RELABEL',source_business_type='RELABEL_ORDER',source_business_no='RL-1',inbound_code='RL-1' WHERE id=4");
db.update("UPDATE ll_outbound_pallet_item SET inbound_code='RL-1' WHERE id=5");
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'C','LM-RL','RL-1','DL-1','OUT-1',2,'WH-1')");
db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'C','RL-1',2)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'C','BOX-RL','P-501','WH-1','OUTBOUNDED','RL-1',6)");
db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,6)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages,product_quantity) VALUES(105,'C',103,100,1,6)");
var relations = inTransaction(() -> exceptions.returnCandidates("C", "DL-1"));
assertThat(relations).hasSize(1);
assertThat(relations.get(0).returnSources()).singleElement().satisfies(source -> {
var actual = json.valueToTree(source);
assertThat(actual.path("returnCartonId").asLong()).isEqualTo(101L);
assertThat(source.maxReturnPackages()).isEqualTo(1);
});
assertThat(db.queryForObject("SELECT inventory_position_id FROM ll_dispatch_pda_pick WHERE id=103", Long.class)).isNull();
assertThat(count("ll_inventory_position")).isZero();
}
/** 准备统一未拣货排车、出库及派送关系,避免用桩伪造取消资格。 */
private void seedPendingOutbound() {
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,overseas_warehouse_id,status,package_count,picked_package_count,shipped_package_count) VALUES(1,'C','DP-1','WH-1','PENDING_DEPARTURE',2,0,0)");
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)");
db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count) VALUES(3,'C','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2)");
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'C',1,501,'P-501','WH-1',2,'FBA','ONT8')");
db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'C',2,501,'P-501',2,0,'WAIT_PICK','ONT8')");
}
/** 固定真实外接实收两箱及约定金额,后续绑定和报价核对均运行生产逻辑。 */
private void seedExternalBooking() throws Exception {
var cargo = new FleetBookingModels.Cargo("FBA:ONT8", "外接货", 2, 1, BigDecimal.TEN,
BigDecimal.ONE, "PALLET", new BigDecimal("60.00"), "CNY", null, null, null);
var receipt = new FleetBookingModels.AcceptedReceipt(new FleetBookingIncomingGateway.Evidence(
"WT-EXT", "IN-EXT", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1",
List.of(new FleetBookingIncomingGateway.Pallet("501", "P-501", 2, BigDecimal.TEN, BigDecimal.ONE)), 2),
List.of(new FleetBookingModels.PalletSpaces("501", 1, null, null)), "OP", Instant.parse("2026-10-01T00:00:00Z"));
// 来货业务编号和库存入仓码是两个标识,必须经真实来货登记关联,不能混用。
db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'C','WT-EXT','IN-EXT','READY_FOR_DISPATCH')");
db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,91,'TR-1','FB-EXT','WT-EXT','C','FBA:ONT8','RECEIVED',60,1,?,?)",
json.writeValueAsString(cargo), json.writeValueAsString(receipt));
}
/** 构造来源输入;真实门禁仍由DispatchService及数据库锁决定。 */
private DispatchPalletCandidate candidate(long pallet) {
return new DispatchPalletCandidate().warehousePalletId(pallet).palletNo("P-" + pallet)
.customerId("C").overseasWarehouseId("WH-1").sourceStage("RECEIVED")
.destinationType("FBA").destinationWarehouseCode("ONT8").fbaWarehouseCode("ONT8")
.packageCount(2).grossWeightKg(BigDecimal.TEN).volumeCbm(BigDecimal.ONE);
}
/** 保留整单换标申请入口,不直接调用私有校验。 */
private DispatchRequests.PreArrange relabel(List<Long> pallets) {
return new DispatchRequests.PreArrange(pallets, null, null, null, null, "RL-1");
}
/** 按类型注入实际依赖;未用到的外部能力保持未装配,调用时显式失败。 */
private <T> T wire(T target) {
for (var field : target.getClass().getDeclaredFields()) {
if (!field.isAnnotationPresent(Resource.class)) continue;
Object value = dependencies.get(field.getType());
if (value != null) ReflectionTestUtils.setField(target, field.getName(), value);
}
return target;
}
/** 从当前隔离库取得生产Mapper代理。 */
private <T> T mapper(Class<T> type) {
return type.cast(dependencies.get(type));
}
/** 每条业务命令独立提交;失败必须整条回滚,不能依赖测试整体回滚隐藏副作用。 */
private <T> T inTransaction(Supplier<T> action) {
return transactions.execute(status -> action.get());
}
/** 对无返回值命令应用同样的真实事务边界。 */
private void run(Runnable action) {
inTransaction(() -> { action.run(); return null; });
}
/** 仅查询本文件常量指定的测试表,不接收用户输入。 */
private long count(String table) {
return db.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
}
/** 从数据库重新读取状态,避免断言内存实体误当持久化成功。 */
private String text(String table, String column, long id) {
return db.queryForObject("SELECT " + column + " FROM " + table + " WHERE id=?", String.class, id);
}
/** 测试表仅表达实体字段类型,DDL、索引及MySQL行为另由正式合同测试校验。 */
private String sqlType(Class<?> type) {
if (Number.class.isAssignableFrom(type) || type == long.class || type == int.class)
return type == BigDecimal.class ? "DECIMAL(24,6)" : "BIGINT";
if (type == LocalDateTime.class || type == Instant.class) return "TIMESTAMP";
if (type == java.time.LocalDate.class) return "DATE";
if (type == Boolean.class || type == boolean.class) return "BOOLEAN";
return "VARCHAR(32768)";
}
}
@@ -51,6 +51,42 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class DispatchServiceTest { class DispatchServiceTest {
/** 历史取消出库不阻止取消原排车,重放不影响后续占用。 */
@Test void cancelledOutboundAllowsOriginalDispatchCancellationAndPreservesAppointmentHistory() {
var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("100");
dispatch.setDispatchNo("DP-OLD"); dispatch.setStatus("PENDING_DEPARTURE");
var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder(); outbound.setStatus("CANCELLED");
var item = item(11L, "ONT8");
when(orders.lockGlobalByDispatchNo("DP-OLD")).thenReturn(dispatch);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
var allocations = mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
ReflectionTestUtils.setField(service, "appointmentAllocations", allocations);
service.cancel("DP-OLD"); service.cancel("DP-OLD");
assertEquals("CANCELLED", dispatch.getStatus());
verify(allocations, never()).removeItem(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
verify(orders).updateById(dispatch);
}
/** 普通换标部分选择与重复选择均在创建排车前拒绝。 */
@Test void ordinaryRelabelRequiresTheWholeLockedApplicationBeforeAnyMutation() {
var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);
ReflectionTestUtils.setField(service, "relabelApplications", relabels);
ReflectionTestUtils.setField(service, "relabelBindingGuard", mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
application.setDeliveryMethod("TRUCK"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH");
when(relabels.lockNo("100", "RL-1")).thenReturn(application);
when(candidates.relabelAvailable("100", null, "RL-1")).thenReturn(List.of(candidate(10L, 900L, "ONT8"), candidate(11L, 901L, "ONT8")));
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
new DispatchRequests.PreArrange(List.of(900L), null, null, null, null, "RL-1")));
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
new DispatchRequests.PreArrange(List.of(900L, 900L), null, null, null, null, "RL-1")));
verify(orders, never()).insert(any(DispatchOrder.class));
verify(pdaTasks, never()).insert(any(DispatchPdaTask.class));
verify(candidates, never()).markRelabelPreDispatched(any(), any());
}
@Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() { @Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() {
var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1"); var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1");
when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch);
@@ -137,7 +137,7 @@ class ExceptionOrderLifecycleTest {
when(lastMileOrders.selectList(any())).thenReturn(List.of()); when(lastMileOrders.selectList(any())).thenReturn(List.of());
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓", ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L, List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
"PLT-201", 301L, "LOC-301"))); "PLT-201", 301L, "LOC-301", null)));
var result = service.complete("1001", "9001", "EX-1", request); var result = service.complete("1001", "9001", "EX-1", request);
@@ -222,7 +222,7 @@ class ExceptionOrderLifecycleTest {
private ExceptionOrderRequests.Complete receipt(long amount) { private ExceptionOrderRequests.Complete receipt(long amount) {
return new ExceptionOrderRequests.Complete(1, "batch-1", "本次实际退回", List.of(101L), return new ExceptionOrderRequests.Complete(1, "batch-1", "本次实际退回", List.of(101L),
List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301"))); List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301", null)));
} }
private String hashFor(Object request) { private String hashFor(Object request) {
@@ -0,0 +1,196 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.entity.*;
import com.baiyue.lastleg.exceptionorder.mapper.*;
import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ReturnCartonReturnPersistenceTest {
JdbcTemplate db;TransactionTemplate tx;ReturnCartonReturnService service;
ExceptionOrder order;ExceptionReturnItem item;SqlSessionTemplate session;
/** 隔离H2中运行真实Mapper、来源归属查询、原生库存更新及事务。 */
@BeforeEach void setup() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
var config=new MybatisConfiguration();
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchOrderMapper.class,DispatchItemMapper.class,
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
mappers.forEach(config::addMapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
session=new SqlSessionTemplate(factory.getObject());
for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) {
if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue;
var table=TableInfoHelper.getTableInfo((Class<?>)p.getActualTypeArguments()[0]);var columns=new ArrayList<String>();
columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));
db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+String.join(",",columns)+")");
}
db.execute("CREATE UNIQUE INDEX balance_scope ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)");
db.execute("CREATE UNIQUE INDEX receipt_source ON ll_return_carton_stock_receipt(customer_id,source_pick_id,sku_code)");
db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(128),business_order_no VARCHAR(128),outbound_order_no VARCHAR(128),business_type VARCHAR(128),delta_packages BIGINT,source_event_id VARCHAR(128),occurred_at TIMESTAMP)");
var repo=new ReturnCartonReturnRepository(map(ReturnCartonStockReceiptMapper.class),map(ReturnInventoryCartonMapper.class),map(ReturnInventoryCartonItemMapper.class),map(ReturnInventoryMovementMapper.class),map(ReturnInventoryBalanceMapper.class));
var pallets=mock(PalletPhysicalGateway.class);var warehouse=mock(WarehouseResourceGateway.class);
when(pallets.resolve("P-NEW")).thenReturn(new PalletPhysicalGateway.PalletReference(501,"P-NEW",92,301L,"OCCUPIED","ONT8",1));
when(warehouse.resolveLocation(92,"LOC-301")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(301,"LOC-301","RACK",BigDecimal.TEN,"ACTIVE"));
service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
db.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,delivery_order_id,last_mile_order_id,outbound_no,status,return_direction,overseas_warehouse_id) VALUES(1,'C','EX-1',3,100,'OUT-1','PROCESSING','OVERSEAS_WAREHOUSE','92')");
db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)");
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')");
db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code) VALUES(5,'C',2,500,'RL-1','ONT8')");
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no) VALUES(10,'C','DP-1')");
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','RETURN_RELABEL','92')");
db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id) VALUES(102,'C',10)");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,8)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(105,'C',103,100,1)");
db.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id,approved_return_packages,received_packages) VALUES(9,'C',1,5,101,1,0)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity,relabel_application_id,relabel_no) VALUES(101,'C','92','BOX-1','OUTBOUNDED',8,88,'RL-1')");
db.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity,packing_reserved_quantity) VALUES(201,101,'C','SKU-A',6,0),(202,101,'C','SKU-B',2,0)");
db.update("INSERT INTO ll_return_inventory_outbound_movement(id,outbound_pick_id,customer_id,overseas_warehouse_id,carton_id,carton_mark,outbound_no,sku_code,quantity,cartons_before,cartons_after) VALUES(301,103,'C','92',101,'BOX-1','DP-1','SKU-A',6,1,0),(302,103,'C','92',101,'BOX-1','DP-1','SKU-B',2,1,0)");
order=map(ExceptionOrderMapper.class).selectById(1L);item=new ExceptionReturnItem();item.setId(9L);item.setOutboundPalletItemId(5L);item.setReturnCartonId(101L);item.setApprovedReturnPackages(1L);item.setReceivedPackages(0L);
}
/** 每个SKU件数恢复一次,原箱只计一运输箱,结案查询读取真实回仓凭据。 */
@Test void restoresOriginalCartonAndEverySkuExactlyOnceWithoutSyntheticPositions() {
tx.executeWithoutResult(s->assertThat(service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP")).isEqualTo(1));
assertThat(db.queryForList("SELECT total_quantity FROM ll_return_inventory_balance ORDER BY sku_code",Long.class)).containsExactly(6L,2L);
assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101",String.class)).isEqualTo("AVAILABLE");
assertThat(db.queryForObject("SELECT relabel_no FROM ll_return_inventory_carton WHERE id=101",String.class)).isNull();
assertThat(map(DeliveryOperationMapper.class).returnReceipts("C",3L)).singleElement().satisfies(fact->{assertThat(fact.packages()).isEqualTo(1);assertThat(fact.dispatchItemId()).isEqualTo(4);});
assertThatThrownBy(()->tx.executeWithoutResult(s->service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP"))).hasMessageContaining("已回仓");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(2);
}
/** 任一SKU凭据写入失败,前一SKU余额、原箱状态和所有凭据一起回滚。 */
@Test void failureInTheSecondSkuRollsBackAllStockAndReceiptWrites() {
db.execute("ALTER TABLE ll_return_carton_stock_receipt ADD CONSTRAINT reject_second CHECK(sku_code<>'SKU-B')");
assertThatThrownBy(()->tx.executeWithoutResult(s->service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP"))).isInstanceOf(RuntimeException.class);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_balance",Long.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_carton_stock_receipt",Long.class)).isZero();
assertThat(db.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101",String.class)).isEqualTo("OUTBOUNDED");
}
/** 其他客户和伪造出库归属不能恢复库存。 */
@Test void anotherCustomerCannotBorrowTheOriginalCartonOrShipment() {
order.setCustomerId("OTHER");
assertThatThrownBy(()->tx.executeWithoutResult(s->service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP"))).hasMessageContaining("出库及原订单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_carton_stock_receipt",Long.class)).isZero();
}
/** 完成命令真实落账后才推进派送并供车次结案消费,同键重放不重复恢复库存。 */
@Test void realExceptionCompletionAndTripCompletionConsumeTheNativeReceipt() {
db.update("UPDATE ll_exception_order SET exception_type='RETURN',delivery_no='DL-1',version=0 WHERE id=1");
db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3");
db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=1,overseas_warehouse_id='92' WHERE id=10");
db.update("UPDATE ll_outbound_order SET package_count=1 WHERE id=2");
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no) VALUES(100,'C','LM-1','DL-1')");
var business=business();
var request=new ExceptionOrderRequests.Complete(0,"native-complete","实收原箱",List.of(999L),List.of(receipt()));
var completed=tx.execute(s->business.complete("C","OP","EX-1",request));
assertThat(completed.status()).isEqualTo("COMPLETED");assertThat(completed.restoredPackages()).isEqualTo(1);
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED");
assertThat(tx.execute(s->business.complete("C","OP","EX-1",request)).replayed()).isTrue();
assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1);
var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(map(DispatchOrderMapper.class),map(DispatchPdaTaskMapper.class),map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DispatchItemMapper.class),map(DispatchPdaPickMapper.class),mock(com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService.class),map(DeliveryOperationMapper.class));
var outcome=tx.execute(s->fleet.completion("DP-1",true));
assertThat(outcome.blockReason()).isNull();assertThat(outcome.outcomes()).singleElement().satisfies(row->{assertThat(row.returnedPackages()).isEqualTo(1);assertThat(row.remainingPackages()).isZero();});
}
/** 不制造入仓单,从真实换标主单经过请求校验、标记、受理、回仓直至派送结案。 */
@Test void nativeMasterWithoutInboundCanMarkAndCompleteAfterBeanValidation() throws Exception {
prepareMarkReturn();
var business=business();
var candidates=business.returnCandidates("C","DL-1");
assertThat(candidates).singleElement().satisfies(row->{
assertThat(row.lastMileOrderId()).isEqualTo(100L);assertThat(row.inboundOrderId()).isNull();
assertThat(row.returnSources()).singleElement().satisfies(source->{
assertThat(source.returnCartonId()).isEqualTo(101L);assertThat(source.inventoryPositionId()).isNull();
});
});
var json=new ObjectMapper();
var request=json.readValue("{\"lastMileOrderId\":100,\"reason\":\"拒签原箱退回\",\"returnDirection\":\"OVERSEAS_WAREHOUSE\",\"items\":[{\"outboundPalletItemId\":5,\"returnCartonId\":101,\"packageCount\":1}],\"idempotencyKey\":\"native-mark\"}",ExceptionOrderRequests.MarkReturn.class);
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();assertThat(validator.validate(request)).isEmpty();
var missing=new ExceptionOrderRequests.MarkReturn(null,"原因","OVERSEAS_WAREHOUSE",null,request.items(),"missing");
assertThat(validator.validate(missing)).anySatisfy(v->assertThat(v.getPropertyPath().toString()).isEqualTo("lastMileOrderId"));
var invalid=new ExceptionOrderRequests.MarkReturn(100L,"原因","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,0,101L)),"invalid");
assertThat(validator.validate(invalid)).anySatisfy(v->assertThat(v.getPropertyPath().toString()).contains("packageCount"));
}
var created=tx.execute(t->business.markReturn("C","OP","操作员","DL-1",request));
assertThat(tx.execute(t->business.markReturn("C","OP","操作员","DL-1",request)).replayed()).isTrue();
assertThat(db.queryForObject("SELECT inbound_order_id FROM ll_exception_order WHERE id=?",Long.class,created.id())).isNull();
tx.executeWithoutResult(t->business.startProcessing("C","OP",created.exceptionNo(),new ExceptionOrderRequests.StartProcessing(0,"native-start","核实回仓")));
var result=tx.execute(t->business.complete("C","OP",created.exceptionNo(),new ExceptionOrderRequests.Complete(1,"native-close","实收原箱",List.of(999L),List.of(receipt()))));
assertThat(result.restoredPackages()).isEqualTo(1);assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED");
}
/** 同一派送的两个无入仓主单不能互借原箱,伪造主单不属于候选。 */
@Test void nativeCandidatesStayBoundToTheActualMasterWithoutInbound() {
prepareMarkReturn();
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no) VALUES(101,'C','LM-2','DL-1','OUT-1')");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)");
var business=business();var rows=business.returnCandidates("C","DL-1");
assertThat(rows).hasSize(2);
assertThat(rows.get(0).returnSources()).singleElement().satisfies(source->assertThat(source.returnCartonId()).isEqualTo(101L));
assertThat(rows.get(1).returnSources()).singleElement().satisfies(source->assertThat(source.returnCartonId()).isEqualTo(102L));
var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"wrong-master");
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",wrong))).hasMessageContaining("不属于该派送单和尾程主单");
var foreign=new ExceptionOrderRequests.MarkReturn(999L,"原因","OVERSEAS_WAREHOUSE",null,wrong.items(),"foreign-master");
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */
private void prepareMarkReturn() {
db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order");
db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',overseas_warehouse_id='92',status='REJECTED',package_count=1,version=0 WHERE id=3");
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(100,'C','LM-1','DL-1','OUT-1','92',1)");
}
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() {
var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"deliveryOrders",map(DeliveryOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"deliveryOperations",map(DeliveryOperationMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"lastMileOrders",map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"objectMapper",new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(business,"evidenceFiles",mock(com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"nativeReturns",service);
org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
org.springframework.test.util.ReflectionTestUtils.setField(business,"inboundOrders",map(com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundOrders",map(OutboundOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPalletItems",map(OutboundPalletItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPdaPicks",map(OutboundPdaPickMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchItems",map(DispatchItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchPdaTasks",map(DispatchPdaTaskMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchPdaPicks",map(DispatchPdaPickMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchPdaPickOrders",map(DispatchPdaPickOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnCartons",map(ReturnInventoryCartonMapper.class));
return business;
}
/** 只提交原箱来源和真实目标资源。 */
private ExceptionOrderRequests.ReturnReceipt receipt(){return new ExceptionOrderRequests.ReturnReceipt(5L,null,1,501L,"P-NEW",301L,"LOC-301",101L);}
/** 根据实体类型建立测试表;正式DDL另由Schema合同验证。 */
private static String sqlType(Class<?> type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(8000)";}
/** 获取隔离库的真实Mapper代理。 */
private <T>T map(Class<T> type){return session.getMapper(type);}
}
@@ -19,12 +19,12 @@ class ReturnReceiptPolicyTest {
.hasMessageContaining("累计实收"); .hasMessageContaining("累计实收");
} }
@Test void wrongPositionCannotBorrowAnotherSourceApproval() { @Test void wrongPositionCannotBorrowAnotherSourceApproval() {
var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L"); var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L", null);
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(wrong))).hasMessageContaining("冻结来源"); assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(wrong))).hasMessageContaining("冻结来源");
} }
private ExceptionReturnItem source() { private ExceptionReturnItem source() {
var value = new ExceptionReturnItem(); value.setId(1L); value.setOutboundPalletItemId(10L); var value = new ExceptionReturnItem(); value.setId(1L); value.setOutboundPalletItemId(10L);
value.setInventoryPositionId(11L); value.setApprovedReturnPackages(4L); value.setReceivedPackages(0L); return value; value.setInventoryPositionId(11L); value.setApprovedReturnPackages(4L); value.setReceivedPackages(0L); return value;
} }
private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L"); } private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L", null); }
} }
@@ -33,6 +33,21 @@ import static org.assertj.core.api.Assertions.*;
@Isolated("Manual retry regression temporarily changes the JVM default time zone") @Isolated("Manual retry regression temporarily changes the JVM default time zone")
class OverseasFeeCancellationPersistenceTest { class OverseasFeeCancellationPersistenceTest {
/** 相同派送费版本重复发布只保留一个真实Outbox事件,重试不制造新应收源。 */
@Test void deliveryFeePublicationReplayKeepsOneImmutableEvent() {
var producer=new OverseasFeeOutboxService();
ReflectionTestUtils.setField(producer,"mapper",events);
ReflectionTestUtils.setField(producer,"objectMapper",new ObjectMapper().findAndRegisterModules());
var deliveryFee=new OverseasFeeSource();org.springframework.beans.BeanUtils.copyProperties(source,deliveryFee);
deliveryFee.setSourceFeeId(702L);deliveryFee.setSourceType("OVERSEAS_DELIVERY_FEE");deliveryFee.setSourceNo("DL-REPLAY");
producer.publish(deliveryFee,"PUBLISHED");
String key=OverseasFeeCancellation.eventKey(deliveryFee,"PUBLISHED");
var first=events.findByEventKey(key);
producer.publish(deliveryFee,"PUBLISHED");producer.publish(deliveryFee,"PUBLISHED");
var replay=events.findByEventKey(key);
assertThat(replay.getId()).isEqualTo(first.getId());assertThat(replay.getPayloadJson()).isEqualTo(first.getPayloadJson());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE event_key=?",Integer.class,key)).isEqualTo(1);
}
JdbcTemplate jdbc; JdbcTemplate jdbc;
LastlegEventOutboxMapper events; LastlegEventOutboxMapper events;
OverseasFeeSourceMapper fees; OverseasFeeSourceMapper fees;
@@ -17,7 +17,7 @@ class FleetBookingFreightTest {
final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json); final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json);
final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json); final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json);
FleetBookingEntity booking; FleetBookingEntity booking;
final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8"); final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 12, "FBA:ONT8");
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
booking = new FleetBookingEntity(); booking = new FleetBookingEntity();
booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1"); booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1");
@@ -29,10 +29,10 @@ class FleetBookingFreightTest {
booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt( booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt(
new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1", new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1",
List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12), List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12),
List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z")))); List.of(new FleetBookingModels.PalletSpaces("501", 2, null, null)), "OP-1", Instant.parse("2026-10-01T00:00:00Z"))));
var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L); var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L);
when(bindings.findByDispatchNo("DP-1")).thenReturn(binding); when(bindings.findByDispatchNo("DP-1")).thenReturn(binding);
when(bookings.incomingForFreight("WT-1")).thenReturn(booking); when(bookings.forInboundSource(anyString(), eq("IN-1"))).thenReturn(booking);
} }
@Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() { @Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() {
var preview = service.preview(3, "DP-1", "C-1", List.of(source)); var preview = service.preview(3, "DP-1", "C-1", List.of(source));
@@ -53,9 +53,9 @@ class FleetBookingFreightTest {
@Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() { @Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() {
var before = service.preview(3, "DP-1", "C-1", List.of(source)); var before = service.preview(3, "DP-1", "C-1", List.of(source));
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8")))); new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 11, "FBA:ONT8"))));
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9")))); new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 12, "FBA:LAX9"))));
booking.setRecordVersion(5L); booking.setRecordVersion(5L);
assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash()); assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash());
} }
@@ -127,13 +127,18 @@ class FleetTripPersistenceTest {
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
"ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", "ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation", "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation",
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { "ll_outbound_pallet_item", "ll_return_carton_stock_receipt", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue(); assertThat(match.find()).as(table).isTrue();
jdbc.execute(match.group().replace(" JSON", " LONGTEXT") jdbc.execute(match.group().replace(" JSON", " LONGTEXT").replaceAll("(?i) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", "")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
} }
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)");
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))");
var configuration = new MybatisConfiguration(); var configuration = new MybatisConfiguration();
configuration.addMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class);
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class, FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper); DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper);
@@ -161,6 +166,9 @@ class FleetTripPersistenceTest {
var bindingMapper = session.getMapper(FleetTripDispatchMapper.class); var bindingMapper = session.getMapper(FleetTripDispatchMapper.class);
bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class); bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class);
var workflow = new DispatchPdaWorkflowService(); var workflow = new DispatchPdaWorkflowService();
var deliveryExecution = new com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class)));
ReflectionTestUtils.setField(workflow, "deliveryExecution", deliveryExecution);
ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class)); ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class));
ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class)); ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class));
@@ -175,6 +183,9 @@ class FleetTripPersistenceTest {
var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class), var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class),
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class), session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class),
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
ReflectionTestUtils.setField(dispatchGateway, "deliveryExecution", deliveryExecution);
ReflectionTestUtils.setField(dispatchGateway, "cargoSources", new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(session.getMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class))));
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
bindingMapper, dispatchGateway, reservations), FleetTripService.class); bindingMapper, dispatchGateway, reservations), FleetTripService.class);
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
@@ -530,6 +541,10 @@ class FleetTripPersistenceTest {
var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null));
var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0);
when(incoming.create(eq("1"),any())).thenReturn(awaiting); when(incoming.create(eq("1"),any())).thenReturn(awaiting);
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('9','WT-EXT','WT-EXT')");
jdbc.update("INSERT INTO ll_inventory_position VALUES(9001,'9',902,'92',9002,22)");
jdbc.update("INSERT INTO ll_inventory_balance VALUES(9002,'9',9003,'WT-EXT')");
jdbc.update("INSERT INTO ll_order VALUES(9003,'9','WAREHOUSE_TRANSFER',NULL)");
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start"); var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start");
var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request);
assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue();
@@ -540,11 +555,11 @@ class FleetTripPersistenceTest {
when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual); when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual);
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual); when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual);
assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成"); List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成");
assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00"); assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00");
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages()));
var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt")); List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt"));
assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22); assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22);
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00"); assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00");
assertThat(accepted.booking().quoteSource()).isEqualTo(source); assertThat(accepted.booking().quoteSource()).isEqualTo(source);
@@ -568,6 +583,33 @@ class FleetTripPersistenceTest {
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联")); service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联"));
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED");
assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull();
// 解除关联后重新核对已有实收,不创建新库存,也不重复预留承运容量。
var reopenedPlan=execution.capacityPlan(number);
var refreshed=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(
reopenedPlan.recordVersion(),reopenedPlan.manifestHash(),List.of(measurement()),"解除关联后重新核对"));
var currentBooking=bookingService.list(number).records().get(0);
var receiptAgain=new FleetBookingModels.AcceptReceipt(refreshed.recordVersion(),currentBooking.recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt-again");
var renewed=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain);
assertThat(renewed.booking().status()).isEqualTo("RECEIVED");
assertThat(renewed.booking().cargo().packageCount()).isEqualTo(22);
assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain).replayed()).isTrue();
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
// 实物换托后源摘要变化且旧测量失效;缺测量拒绝,显式实测后更新快照及容量。
var moved=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","READY_FOR_DISPATCH","moved-receipt",
List.of(new FleetBookingIncomingGateway.Pallet("903","PALLET-903",22,null,null)),22);
when(incoming.inspect("9","WT-EXT",true)).thenReturn(moved);
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(moved);
var withoutMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("903",1,null,null)),new BigDecimal("55.00"),"moved-confirm");
assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withoutMeasure)).hasMessageContaining("实际托盘");
var withMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("903",1,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-confirm");
var measuredAgain=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure);
assertThat(measuredAgain.booking().cargo().weightKg()).isEqualByComparingTo("170");
assertThat(measuredAgain.booking().cargo().volumeCbm()).isEqualByComparingTo("1.7");
assertThat(measuredAgain.booking().receipt().source().pallets()).singleElement().satisfies(p->{assertThat(p.warehousePalletId()).isEqualTo("903");assertThat(p.weightKg()).isEqualByComparingTo("170");});
assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure).replayed()).isTrue();
} }
@Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() { @Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() {
@@ -931,6 +973,31 @@ class FleetTripPersistenceTest {
} finally { executor.shutdownNow(); } } finally { executor.shutdownNow(); }
} }
/** 取消与实际整车发车并发时,已拣货车只能发车;出库和派送不得产生取消终态。 */
@Test void outboundCancellationRacingDepartureCannotCreateDepartedCancelledCargo() throws Exception {
String number=readyTrip();var loaded=confirm(number);
var cancel=new com.baiyue.lastleg.delivery.service.DeliveryTransferService();
var config=new MybatisConfiguration();config.addMapper(OutboundOrderMapper.class);config.addMapper(DispatchOrderMapper.class);
var source=(javax.sql.DataSource)jdbc.getDataSource();
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(config);factory.afterPropertiesSet();
var data=new SqlSessionTemplate(factory.getObject());
ReflectionTestUtils.setField(cancel,"outboundOrders",data.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(cancel,"dispatchOrders",data.getMapper(DispatchOrderMapper.class));
ReflectionTestUtils.setField(cancel,"fleetBindings",bindingGuard);
var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2);
try {
var departure=executor.submit(()->{start.await(5,TimeUnit.SECONDS);return execution.depart("92","DP-11","1",new FleetExecutionModels.Depart(loaded.recordVersion(),"race-depart","并发取消验证"));});
var cancellation=executor.submit(()->{start.await(5,TimeUnit.SECONDS);try{
new org.springframework.transaction.support.TransactionTemplate(transactions).executeWithoutResult(s->cancel.cancelOutbound("OUT-11","1",new com.baiyue.lastleg.delivery.dto.DeliveryRequests.Cancel("并发取消")));
return false;
}catch(ResponseStatusException rejected){return rejected.getStatusCode().value()==409;}});
start.countDown();assertThat(departure.get(20,TimeUnit.SECONDS).shippedPackages()).isEqualTo(20);assertThat(cancellation.get(20,TimeUnit.SECONDS)).isTrue();
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsOnly("OUTBOUNDED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE status='CANCELLED'",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'",Integer.class)).isEqualTo(2);
} finally {executor.shutdownNow();}
}
@Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() { @Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() {
String number = readyTrip(); var confirmed = confirm(number); String number = readyTrip(); var confirmed = confirm(number);
long driverId = Long.parseLong(service.detail(number).trip().driverId()); long driverId = Long.parseLong(service.detail(number).trip().driverId());
@@ -1125,6 +1192,8 @@ class FleetTripPersistenceTest {
dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId); dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId);
jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)", jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)",
dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0)); dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0));
jdbc.update("UPDATE ll_outbound_order SET package_count=10 WHERE id=?", dispatchId + 1000);
jdbc.update("UPDATE ll_delivery_order SET package_count=10 WHERE id=?", dispatchId + 2000);
} }
private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception { private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception {
@@ -54,7 +54,8 @@ class ReturnInventoryMovementSqlTest {
var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method) var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method)
.getBoundSql(params); .getBoundSql(params);
String sql = boundSql.getSql(); String sql = boundSql.getSql();
assertEquals(7, sql.split("UNION ALL", -1).length); assertEquals(8, sql.split("UNION ALL", -1).length);
assertTrue(sql.contains("ll_return_carton_stock_receipt"));
assertTrue(!sql.contains("CONVERT(")); assertTrue(!sql.contains("CONVERT("));
assertTrue(sql.contains("m.customerId=?")); assertTrue(sql.contains("m.customerId=?"));
assertTrue(sql.contains("m.overseasWarehouseId=?")); assertTrue(sql.contains("m.overseasWarehouseId=?"));
@@ -13,6 +13,34 @@ import java.util.List;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class FleetIncomingAdapterTest { class FleetIncomingAdapterTest {
/** 真实组成拆成新托后旧重量体积失效;其他客户库存不进入该票实收。 */
@Test void splitPalletsRefreshTheActualReceiptAndInvalidateOldMeasurements() throws Exception {
var data=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new org.springframework.jdbc.core.JdbcTemplate(data);
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(64))");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,balance_id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,pallet_no VARCHAR(64),overseas_warehouse_id VARCHAR(64),available_packages BIGINT,picked_packages BIGINT)");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C','IN-1'),(2,'OTHER','IN-1')");
db.update("INSERT INTO ll_inventory_position VALUES(10,1,'C',501,'P-501','92',5,0),(11,2,'OTHER',999,'P-999','92',99,0)");
var config=new com.baomidou.mybatisplus.core.MybatisConfiguration();config.addMapper(com.baiyue.lastleg.transfer.mapper.FleetIncomingActualSourceMapper.class);
var factory=new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
var session=new org.mybatis.spring.SqlSessionTemplate(factory.getObject());
var service=mock(WarehouseTransferService.class);var orders=mock(WarehouseTransferOrderMapper.class);
var adapter=new FleetIncomingAdapter(service,orders,new ObjectMapper());
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"actualSources",new com.baiyue.lastleg.transfer.repository.FleetIncomingActualSourceRepository(session.getMapper(com.baiyue.lastleg.transfer.mapper.FleetIncomingActualSourceMapper.class)));
when(orders.findNo("C","WT-1")).thenReturn(new com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder());
var detail=mock(WarehouseTransferResponses.Detail.class);when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("IN-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.destinationType()).thenReturn("FBA");when(detail.destinationWarehouseCode()).thenReturn("ONT8");when(detail.status()).thenReturn("READY_FOR_DISPATCH");
var prior=mock(WarehouseTransferResponses.Pallet.class);when(prior.status()).thenReturn("COMPLETED");when(prior.warehousePalletId()).thenReturn(501L);when(prior.palletNo()).thenReturn("P-501");when(prior.cartonCount()).thenReturn(5);when(prior.grossWeightKg()).thenReturn(BigDecimal.TEN);when(prior.volumeCbm()).thenReturn(BigDecimal.ONE);when(detail.pallets()).thenReturn(List.of(prior));
when(service.detail("C","WT-1")).thenReturn(detail);
var before=adapter.inspect("C","WT-1",false);assertThat(before.pallets()).singleElement().satisfies(p->assertThat(p.weightKg()).isEqualByComparingTo("10"));
db.update("UPDATE ll_inventory_position SET available_packages=0 WHERE id=10");
db.update("INSERT INTO ll_inventory_position VALUES(12,1,'C',502,'P-502','92',2,0),(13,1,'C',503,'P-503','92',3,0)");
var after=adapter.inspect("C","WT-1",false);
assertThat(after.receivedPackages()).isEqualTo(5);assertThat(after.receiptHash()).isNotEqualTo(before.receiptHash());
assertThat(after.pallets()).extracting(FleetBookingIncomingGateway.Pallet::warehousePalletId).containsExactly("502","503");
assertThat(after.pallets()).allSatisfy(p->{assertThat(p.weightKg()).isNull();assertThat(p.volumeCbm()).isNull();});
db.update("UPDATE ll_inventory_position SET available_packages=1 WHERE id=13");
assertThatThrownBy(()->adapter.inspect("C","WT-1",false)).hasMessageContaining("原实收量不一致");
}
@Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() { @Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() {
var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper());
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); org.springframework.test.util.ReflectionTestUtils.setField(adapter,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
@@ -9,9 +9,11 @@ import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSign
function fixture(fault = '') { function fixture(fault = '') {
const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = []
let returned = false, originalSigned = false, newSigned = false, departed = false let returned = false, originalSigned = false, newSigned = false, departed = false
const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: name, inboundOrderId: String(index + 10), inboundCode: `IN-${name}`, const masterIds = { A: '9007199254740993', B: '9007199254740995' }
const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`,
customerId: 'C', overseasWarehouseId: 'W', inboundOrderId: fault === 'no-inbound' ? null : String(index + 10), inboundCode: `IN-${name}`,
deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] })) deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] }))
const tail = name => ({ orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3,
subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY'
: originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' })
const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null } const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null }
@@ -28,9 +30,19 @@ function fixture(fault = '') {
const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned
return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] })
} }
if (path.includes('/orders/')) return envelope({ order: tail(path.split('/orders/')[1].split('?')[0]) }) if (path.includes('/orders/')) {
const masterId = path.split('/orders/')[1].split('?')[0]
const name = Object.keys(masterIds).find(name => masterIds[name] === masterId)
assert.ok(name, `unknown master ${masterId}`)
return envelope({ order: tail(name) })
}
if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations)
if (path.includes('/mark-return')) return envelope({ exceptionNo: 'EX-NEW', version: 0 }) if (path.includes('/mark-return')) {
assert.equal(body.lastMileOrderId, masterIds.A)
assert.equal(Object.hasOwn(body, 'inboundOrderId'), false)
assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }])
return envelope({ exceptionNo: 'EX-NEW', version: 0 })
}
if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 }) if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 })
if (path.includes('/exception-orders/') && path.includes('/complete')) { if (path.includes('/exception-orders/') && path.includes('/complete')) {
const replayed = returned; returned = true const replayed = returned; returned = true
@@ -40,7 +52,7 @@ function fixture(fault = '') {
if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') }
if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode }) if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode })
if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] })
if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: 'A', packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: masterIds.A, packageCount: fault === 'wrong-candidate' ? 4 : 2 }])
if (path.endsWith('/drafts/containers')) return envelope({ id: '400' }) if (path.endsWith('/drafts/containers')) return envelope({ id: '400' })
if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) } if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) }
if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) } if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) }
@@ -61,7 +73,7 @@ function fixture(fault = '') {
async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } },
} }
const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] }
return { context, trip, input, dependencies, requests, checkpoints } return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds }
} }
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
@@ -88,6 +100,43 @@ for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requ
await assert.rejects(run(f), /two boxes actually signed/) await assert.rejects(run(f), /two boxes actually signed/)
}) })
test('mark-return uses the selected master ID even without an ordinary inbound relation', async () => {
const f = fixture('no-inbound')
assert.ok(f.relations.every(row => row.inboundOrderId === null))
assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
const writes = f.requests.filter(row => row.path.includes('/mark-return'))
assert.equal(writes.length, 1)
assert.equal(writes[0].body.lastMileOrderId, f.masterIds.A)
assert.equal(Object.hasOwn(writes[0].body, 'inboundOrderId'), false)
})
for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) {
test(`return source identity ${fault} stops before mark-return and stock writes`, async () => {
const f = fixture(), request = f.context.request.bind(f.context)
if (fault === 'missing-master') delete f.relations[0].lastMileOrderId
if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER'
if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER'
if (fault === 'foreign-customer') f.relations[0].customerId = 'OTHER'
if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = 'OTHER'
if (fault === 'borrowed-master') f.context.request = async (...args) => {
const response = await request(...args)
if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B
return response
}
await assert.rejects(run(f), /real master ID|match this candidate/)
assert.ok(f.requests.every(row => row.method === 'GET'))
assert.equal(f.trip.returnRedispatch.exceptionNo, undefined)
})
}
for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) {
test('two-box return never borrows an inventory source from another master or a different source type', async () => {
const f = fixture(); mutate(f.relations[0].returnSources[0])
await assert.rejects(run(f), /Selected master must contain/)
assert.ok(f.requests.every(row => row.method === 'GET'))
})
}
test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => { test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => {
const f = fixture(); f.input.sourceSelectionRecovery = true const f = fixture(); f.input.sourceSelectionRecovery = true
assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
@@ -113,9 +113,14 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
} }
const relations = await request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}/return-inbound-options`, customerId)) const relations = await request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}/return-inbound-options`, customerId))
for (const relation of relations) { for (const relation of relations) {
check('Return source relation belongs to the original delivery', relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo) check('Return source relation belongs to the original delivery and has a real master ID', /^[1-9][0-9]*$/.test(id(relation.lastMileOrderId))
&& id(relation.customerId) === customerId && id(relation.overseasWarehouseId) === warehouseId
&& relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo)
const detail = await tail(relation) const detail = await tail(relation)
check('Tail source customer and warehouse match this run', id(detail.order.customerId) === customerId && id(detail.order.overseasWarehouseId) === warehouseId) check('Tail source identity, customer and warehouse match this candidate', id(detail.order.id) === id(relation.lastMileOrderId)
&& detail.order.orderNo === relation.lastMileOrderNo && detail.order.deliveryOrderNo === number
&& detail.order.outboundOrderNo === relation.outboundNo
&& id(detail.order.customerId) === customerId && id(detail.order.overseasWarehouseId) === warehouseId)
rows.push({ relation, tail: detail.order, delivery: current.order }) rows.push({ relation, tail: detail.order, delivery: current.order })
} }
} }
@@ -123,9 +128,12 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
proof.domesticBefore = { A: await domestic(a), B: await domestic(b) } proof.domesticBefore = { A: await domestic(a), B: await domestic(b) }
check('Both source inbounds must be unfinished in this fresh sample', proof.domesticBefore.A.processStatus !== 'DELIVERED' && proof.domesticBefore.B.processStatus !== 'DELIVERED') check('Both source inbounds must be unfinished in this fresh sample', proof.domesticBefore.A.processStatus !== 'DELIVERED' && proof.domesticBefore.B.processStatus !== 'DELIVERED')
proof.sources = { A: copy(a), B: copy(b) }; await context.checkpoint() proof.sources = { A: copy(a), B: copy(b) }; await context.checkpoint()
const source = a.relation.returnSources.find(row => Number(row.maxReturnPackages) >= 2) const source = a.relation.returnSources.find(row => /^[1-9][0-9]*$/.test(id(row.outboundPalletItemId))
&& /^[1-9][0-9]*$/.test(id(row.inventoryPositionId)) && row.returnCartonId == null
&& Number.isSafeInteger(Number(row.maxReturnPackages)) && Number(row.maxReturnPackages) >= 2)
check('Selected master must contain the actual ordinary stock source for the two returned boxes', Boolean(source))
const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), { const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), {
inboundOrderId: a.relation.inboundOrderId, reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', lastMileOrderId: id(a.relation.lastMileOrderId), reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE',
returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(), returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(),
}) })
proof.exceptionNo = marked.exceptionNo proof.exceptionNo = marked.exceptionNo
@@ -1,6 +1,6 @@
import test from 'node:test' import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
@@ -105,7 +105,7 @@ test('phase-five module proves receipt-save completion, cross-region convergence
"visitResult: 'SATISFIED'", '/exception-orders', "visitResult: 'SATISFIED'", '/exception-orders',
'/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'", '/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'",
'returnSources', 'outboundPalletItemId', 'inventoryPositionId', 'returnSources', 'outboundPalletItemId', 'inventoryPositionId',
'value.returnSources.some', 'returnInbound?.deliveryNo === deliveryNo', 'value.returnSources.some', 'returnCandidate?.deliveryNo === deliveryNo',
"idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放', "idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放',
'RETURN_PENDING_WITH_COMPLETED_VISIT', 'RETURN_PENDING_WITH_COMPLETED_VISIT',
]) assert.ok(source.includes(token), token) ]) assert.ok(source.includes(token), token)
@@ -183,6 +183,68 @@ function contextFor(request) {
} }
const ok = data => ({ http: 200, body: { code: '200', data } }) const ok = data => ({ http: 200, body: { code: '200', data } })
function deliveryReturnFixture() {
return { lastMileOrderId: '9007199254740993', lastMileOrderNo: 'LM-1', inboundOrderId: '81', inboundCode: 'IN-A',
customerId: '100', overseasWarehouseId: 'WH-1', deliveryNo: 'DL-1', outboundNo: 'OB-1',
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', returnCartonId: null, maxReturnPackages: 2 }] }
}
const returnScope = { customerId: '100', warehouseId: 'WH-1', deliveryNo: 'DL-1', outboundNo: 'OB-1' }
for (const ordinaryInbound of ['81', null, undefined]) test(`phase5 mark-return sends the actual master key; inbound=${ordinaryInbound}`, async () => {
const candidate = deliveryReturnFixture(); candidate.inboundOrderId = ordinaryInbound
if (!ordinaryInbound) candidate.inboundCode = null
const context = contextFor((method, path, body) => {
if (method === 'GET') { assert.match(path, /return-inbound-options/); return ok([candidate]) }
assert.equal(method, 'POST'); assert.match(path, /mark-return/)
assert.equal(body.lastMileOrderId, candidate.lastMileOrderId)
assert.equal(Object.hasOwn(body, 'inboundOrderId'), false)
assert.deepEqual(body.items, [{ outboundPalletItemId: '51', inventoryPositionId: '61', packageCount: 1 }])
assert.equal(body.returnDirection, 'OVERSEAS_WAREHOUSE')
assert.ok(body.idempotencyKey)
return ok({ exceptionNo: 'EX-1', status: 'PENDING', version: 0 })
})
const result = await createDeliveryReturn(context, returnScope)
assert.equal(result.returned.data.exceptionNo, 'EX-1')
assert.equal(context.calls.filter(row => row.method === 'POST').length, 1)
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
})
test('phase5 keeps the selected master and its own source together when candidates share no inbound relation', async () => {
const first = deliveryReturnFixture(); first.inboundOrderId = null; first.inboundCode = null; first.returnSources = []
const selected = { ...deliveryReturnFixture(), lastMileOrderId: '9007199254740995', lastMileOrderNo: 'LM-2', inboundOrderId: null, inboundCode: null,
returnSources: [{ outboundPalletItemId: '52', inventoryPositionId: '62', maxReturnPackages: 1 }] }
const context = contextFor((method, path, body) => {
if (method === 'GET') return ok([first, selected])
assert.equal(body.lastMileOrderId, selected.lastMileOrderId)
assert.equal(Object.hasOwn(body, 'inboundOrderId'), false)
assert.deepEqual(body.items, [{ outboundPalletItemId: '52', inventoryPositionId: '62', packageCount: 1 }])
return ok({ exceptionNo: 'EX-2' })
})
await createDeliveryReturn(context, returnScope)
assert.equal(context.calls.filter(row => row.method === 'POST').length, 1)
})
for (const [name, mutate] of [
['missing-master', row => { delete row.lastMileOrderId }], ['zero-master', row => { row.lastMileOrderId = '0' }],
['foreign-customer', row => { row.customerId = 'OTHER' }], ['foreign-warehouse', row => { row.overseasWarehouseId = 'OTHER' }],
['wrong-delivery', row => { row.deliveryNo = 'DL-OTHER' }], ['wrong-outbound', row => { row.outboundNo = 'OB-OTHER' }],
['missing-source', row => { row.returnSources = [] }], ['missing-position', row => { row.returnSources[0].inventoryPositionId = null }],
['ambiguous-source-type', row => { row.returnSources[0].returnCartonId = '701' }],
]) test(`phase5 refuses ${name} before any mark-return write even with an inbound ID`, async () => {
const candidate = deliveryReturnFixture(); mutate(candidate)
const context = contextFor((method) => { assert.equal(method, 'GET'); return ok([candidate]) })
assert.equal(await createDeliveryReturn(context, returnScope), null)
assert.equal(context.calls.length, 1)
assert.ok(context.assertions.some(row => !row.passed))
})
test('the phase5 lifecycle calls the tested request path and replays its exact returned body', () => {
assert.match(source, /const returnRequest = await createDeliveryReturn\(context, \{ customerId, warehouseId, deliveryNo, outboundNo \}\)/)
assert.match(source, /const \{ returned, returnBody \} = returnRequest/)
assert.doesNotMatch(source, /inboundOrderId:\s*id\(returnCandidate/)
assert.match(source, /lastMileOrderId: id\(returnCandidate\.lastMileOrderId\)/)
})
test('partial completion rejects different Orders and an already delivered pending fragment', async () => { test('partial completion rejects different Orders and an already delivered pending fragment', async () => {
for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' }, for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' },
{ orderId: 'ORDER', processStatus: 'DELIVERED' }]) { { orderId: 'ORDER', processStatus: 'DELIVERED' }]) {
@@ -892,6 +892,42 @@ export async function pickAll(context, token, dispatchNo) {
return success(replay.response) return success(replay.response)
} }
/** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */
export async function createDeliveryReturn(context, { customerId, warehouseId, deliveryNo, outboundNo }) {
const eligibleSource = source => /^[1-9][0-9]*$/.test(id(source?.outboundPalletItemId))
&& /^[1-9][0-9]*$/.test(id(source?.inventoryPositionId)) && source?.returnCartonId == null
&& Number.isSafeInteger(Number(source?.maxReturnPackages)) && Number(source.maxReturnPackages) > 0
const returnOptions = await platform(context, 'GET', '查询派送退回可选主单',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId }))
const returnCandidate = Array.isArray(returnOptions.data)
? returnOptions.data.find((value) => /^[1-9][0-9]*$/.test(id(value?.lastMileOrderId))
&& id(value?.customerId) === customerId && id(value?.overseasWarehouseId) === warehouseId
&& value?.deliveryNo === deliveryNo && value?.outboundNo === outboundNo
&& Array.isArray(value?.returnSources) && value.returnSources.some(eligibleSource)) : null
context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response,
id(returnCandidate?.lastMileOrderId) && returnCandidate?.deliveryNo === deliveryNo && returnCandidate?.outboundNo === outboundNo,
`candidate=${Boolean(returnCandidate)}; ${trace(returnOptions.response)}`, OPTIONS)
if (!returnCandidate) return null
const returnSource = Array.isArray(returnCandidate.returnSources)
? returnCandidate.returnSources.find(eligibleSource) : null
context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response,
Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS)
if (!returnSource) return null
const returnBody = {
lastMileOrderId: id(returnCandidate.lastMileOrderId), reason: `${context.marker}-delivery-return`,
returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128),
items: [{
outboundPalletItemId: id(returnSource.outboundPalletItemId),
inventoryPositionId: id(returnSource.inventoryPositionId),
packageCount: 1,
}],
idempotencyKey: key(context, 'DELIVERY-RETURN'),
}
const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody)
return { returned, returnBody }
}
export async function run(context) { export async function run(context) {
context.createdResources.phase5OverseasFulfillment = { context.createdResources.phase5OverseasFulfillment = {
marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [], marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [],
@@ -1196,34 +1232,9 @@ export async function run(context) {
nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS)
context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null
const returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单', const returnRequest = await createDeliveryReturn(context, { customerId, warehouseId, deliveryNo, outboundNo })
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) if (!returnRequest) return
const returnInbound = Array.isArray(returnOptions.data) const { returned, returnBody } = returnRequest
? returnOptions.data.find((value) => Array.isArray(value?.returnSources)
&& value.returnSources.some((source) => id(source?.outboundPalletItemId)
&& id(source?.inventoryPositionId) && Number(source?.maxReturnPackages) > 0)) : null
context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response,
id(returnInbound?.inboundOrderId) && returnInbound?.deliveryNo === deliveryNo && returnInbound?.outboundNo === outboundNo,
`candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS)
if (!returnInbound) return
const returnSource = Array.isArray(returnInbound.returnSources)
? returnInbound.returnSources.find((value) => id(value?.outboundPalletItemId)
&& id(value?.inventoryPositionId) && Number(value?.maxReturnPackages) > 0) : null
context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response,
Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS)
if (!returnSource) return
const returnBody = {
inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`,
returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128),
items: [{
outboundPalletItemId: id(returnSource.outboundPalletItemId),
inventoryPositionId: id(returnSource.inventoryPositionId),
packageCount: 1,
}],
idempotencyKey: key(context, 'DELIVERY-RETURN'),
}
const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody)
const returnExceptionNo = id(returned.data?.exceptionNo) const returnExceptionNo = id(returned.data?.exceptionNo)
context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response, context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response,
nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING' nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING'