feat(fleet): bind quote versions and reconcile actual return quantities

This commit is contained in:
2026-10-03 13:32:55 +08:00
parent cb6c54ffe5
commit a6fd0ba3c5
60 changed files with 1363 additions and 75 deletions
@@ -31,6 +31,7 @@ public class ExceptionOrder {
private Integer packageCount;
private String returnDirection;
private String returnLogisticsNo;
private String returnContextJson;
private String handlingAction;
private String handlingResult;
private String evidenceFileIdsJson;
@@ -19,5 +19,6 @@ public class ExceptionReturnItem {
private String skuCode;
private Long maxReturnPackages;
private Long approvedReturnPackages;
private Long receivedPackages;
private LocalDateTime createdAt;
}
@@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
"AND exception_type='RETURN' LIMIT 1")
"AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING') LIMIT 1")
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
@@ -13,4 +13,16 @@ public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnIte
@Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id")
List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
@Param("exceptionOrderId") Long exceptionOrderId);
@Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " +
"JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " +
"WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId}")
long receivedByDelivery(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " +
"JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " +
"WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId} " +
"AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}")
long receivedBySource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId,
@Param("itemId") Long itemId, @Param("positionId") Long positionId);
}
@@ -130,7 +130,8 @@ public class ExceptionOrderService {
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")
&& !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("当前派送状态不允许标记退回");
}
return returnCandidates(delivery);
@@ -157,19 +158,13 @@ public class ExceptionOrderService {
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
}
@Transactional
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
throw conflict("当前派送状态不允许标记退回");
}
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
if (existing != null) throw conflict("该派送单已存在退回异常单");
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
@@ -198,6 +193,7 @@ public class ExceptionOrderService {
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setReceivedPackages(0L);
item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item);
}
@@ -222,6 +218,17 @@ public class ExceptionOrderService {
return created;
}
/** Caller keeps the delivery row locked until return creation commits. */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN"))
throw conflict("当前派送状态不允许标记退回");
if (exceptionOrders.findDeliveryReturn(customerId, delivery.getId()) != null)
throw conflict("该派送单已存在退回异常单");
return delivery;
}
@Transactional
public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId,
String exceptionNo,
@@ -247,6 +254,7 @@ public class ExceptionOrderService {
ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
restoreCancelledReturn(order);
LocalDateTime now = LocalDateTime.now();
order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason()));
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
@@ -266,29 +274,42 @@ public class ExceptionOrderService {
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
ReturnedInventoryRestoreResponse restored = null;
List<ExceptionReturnItem> stored = List.of();
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送单不是退回处理中状态");
stored = returnItems.findByException(customerId, order.getId());
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
Map<Long, Long> received = ReturnReceiptPolicy.quantities(stored, request.receipts());
restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(),
stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(),
item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(),
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds().stream().map(String::valueOf).toList()));
if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum())
throw conflict("退回库存实际入账数量与本次实收不一致");
for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) {
item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(item.getId()));
returnItems.updateById(item);
}
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
throw badRequest("非退回海外仓异常不能提交库存实收明细");
}
LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt();
order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult()));
order.setEvidenceFileIdsJson(write(request.evidenceFileIds()));
boolean completed = stored.isEmpty() || ReturnReceiptPolicy.complete(stored);
order.setStatus(completed ? "COMPLETED" : "PROCESSING"); order.setHandlingResult(trim(request.handlingResult()));
List<Long> evidence = new ArrayList<>(readEvidenceFileIds(order.getEvidenceFileIdsJson()));
evidence.addAll(request.evidenceFileIds());
order.setEvidenceFileIdsJson(write(evidence.stream().distinct().toList()));
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now);
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
closeReturnedDelivery(order, operatorId, now);
long restoredPackages = restored == null ? 0 : restored.restoredPackages();
@@ -352,6 +373,11 @@ public class ExceptionOrderService {
value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName());
value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction));
value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) {
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(),
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
}
if (StringUtils.hasText(direction)) {
value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理");
value.setHandlerId(operatorId); value.setHandlerName(operatorName);
@@ -414,7 +440,9 @@ public class ExceptionOrderService {
DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(Source::response).toList();
.map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource(
delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId())))
.filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList();
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(),
@@ -476,7 +504,8 @@ public class ExceptionOrderService {
return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList();
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item)))
.filter(item -> item.maxReturnPackages() > 0).toList();
}
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
@@ -506,10 +535,15 @@ public class ExceptionOrderService {
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
// Closing a non-warehouse case does not establish physical cargo handoff or inventory receipt.
if (!"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) return;
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("关联派送单不是退货处理中状态");
}
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致");
if (received < delivery.getPackageCount()) return;
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
@@ -526,6 +560,30 @@ public class ExceptionOrderService {
operation.setCreatedAt(now); deliveryOperations.insert(operation);
}
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous;
try { previous = objectMapper.readValue(order.getReturnContextJson(), ReturnContext.class); }
catch (Exception exception) { throw conflict("退回异常缺少原始状态,不能猜测恢复状态"); }
if (previous == null || !Set.of("DELIVERING", "DELIVERED", "REJECTED", "RETURN_PENDING").contains(previous.deliveryStatus()))
throw conflict("退回异常原始状态无效");
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
throw conflict("已有实际退回入仓,不能取消退回事实");
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
if (tail == null || !Objects.equals(tail.getCustomerId(), order.getCustomerId())
|| !"DELIVERY:RETURN_PENDING".equals(tail.getSubOrderStatus())) throw conflict("关联尾程状态已变化,不能覆盖");
tail.setSubOrderStatus(previous.tailStatus()); tail.setAbnormal(previous.abnormal());
tail.setUpdatedAt(LocalDateTime.now()); lastMileOrders.updateById(tail);
}
}
private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
final class ReturnReceiptPolicy {
private ReturnReceiptPolicy() { }
static Map<Long, Long> quantities(List<ExceptionReturnItem> sources, List<ReturnReceipt> receipts) {
Map<Long, Long> amounts = new LinkedHashMap<>();
if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细");
for (ReturnReceipt receipt : receipts) {
ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId())
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst()
.orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源"));
if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零");
amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact);
}
for (ExceptionReturnItem source : sources) {
long total = Math.addExact(received(source), amounts.getOrDefault(source.getId(), 0L));
if (total > source.getApprovedReturnPackages()) throw conflict("累计实收箱数超过批准退回数量");
}
return amounts;
}
static long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); }
static boolean complete(List<ExceptionReturnItem> items) {
return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages());
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}