fix(lastleg): route all relabel pickups directly to outbound and verify final schemas

This commit is contained in:
2026-10-15 21:05:34 +08:00
parent 4277601999
commit 607aaf74dc
27 changed files with 717 additions and 185 deletions
@@ -244,7 +244,7 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -271,7 +271,7 @@ public interface DispatchCandidateMapper {
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
"WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
@@ -116,8 +116,8 @@ public class DispatchService {
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
if ("EXPRESS".equals(application.getDeliveryMethod())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单为快递派送,无需进行排车");
if (!"TRUCK".equals(application.getDeliveryMethod())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有平台卡车派送的换标单可以排车");
}
return application.getCustomerId();
}
@@ -607,10 +607,11 @@ public class DispatchService {
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
if (!"TRUCK".equals(application.getDeliveryMethod())
|| !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application);
if ("EXPRESS".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
@@ -512,7 +512,7 @@ public class PdaRelabelService {
updateTask(task, request.expectedVersion());
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""),
"manualCompletion", request.manualCompletion()));
"manualCompletion", request.manualCompletion(), "roundKey", mixedRound(task)));
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
applicationService.settlePdaCompletion(application.getCustomerId(), relabelNo, operatorKey);
@@ -816,7 +816,15 @@ public class PdaRelabelService {
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), !"EXPRESS".equals(application.getDeliveryMethod()));
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(),
detailItems.stream().anyMatch(item -> {
String operation = operationType(item);
if (item.getReturnCartonId() == null)
return Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL").contains(operation);
if ("SKU_LABEL".equals(operation) && application.getDeliveryMethod() == null) return false;
return !"EXPRESS".equals(application.getDeliveryMethod()) && !"PICKUP".equals(application.getDeliveryMethod())
&& !"AMAZON_PICKUP".equals(application.getDeliveryMethod()) && !"CUSTOMER_PICKUP".equals(application.getDeliveryMethod());
}));
}
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
@@ -94,6 +94,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
@Resource private RelabelBindingGuard bindingGuard;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper skuConversionRows;
@Resource private ReturnOrderMapper returnOrders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper returnPackingTraces;
@@ -368,7 +369,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|| request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) {
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
throw badRequest("箱唛换标请选择平台卡车、快递、亚马逊自提、客户自提或自提");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
@@ -933,6 +934,8 @@ public class RelabelApplicationService implements WalletResultHandler {
throw conflict("Upload the shipping label before confirming relabel completion");
}
List<RelabelItem> relabelItems = itemList(application.getId());
// Manual warehouse completion does not waive physical binding at customer confirmation.
bindingGuard.requireBound(application);
skuConversions.confirm(application);
WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null
: warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
@@ -940,6 +943,10 @@ public class RelabelApplicationService implements WalletResultHandler {
&& StringUtils.hasText(linkedTransfer.getAllocationIdempotencyKey());
boolean requiresOutbound = relabelItems.stream()
.anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
boolean directOutbound = "EXPRESS".equals(application.getDeliveryMethod())
|| "PICKUP".equals(application.getDeliveryMethod())
|| "AMAZON_PICKUP".equals(application.getDeliveryMethod())
|| "CUSTOMER_PICKUP".equals(application.getDeliveryMethod());
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
@@ -951,9 +958,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus(requiresOutbound
? ("EXPRESS".equals(application.getDeliveryMethod()) ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
? (directOutbound ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
: "NOT_REQUIRED");
if (requiresOutbound && !existingStockTransfer && "EXPRESS".equals(application.getDeliveryMethod())) {
if (requiresOutbound && !existingStockTransfer && directOutbound) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
@@ -993,7 +1000,7 @@ public class RelabelApplicationService implements WalletResultHandler {
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
boolean awaitingTruck = requiresOutbound && !"EXPRESS".equals(application.getDeliveryMethod());
boolean awaitingTruck = requiresOutbound && !directOutbound;
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
}
@@ -1050,6 +1057,7 @@ public class RelabelApplicationService implements WalletResultHandler {
boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream()
.allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode()));
application.setFulfillmentStatus(existingStockTransfer ? "WAIT_TRANSFER_OUTBOUND"
: StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_CREATED"
: "READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination
? "WAIT_PRE_DISPATCH" : transferStatus);
applications.updateById(application);
@@ -1085,6 +1093,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (source.getReturnCartonId() == null) continue;
ReturnInventoryCarton carton = returnCartons.lockId(source.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|| !Objects.equals(application.getOverseasWarehouseId(), carton.getOverseasWarehouseId())
|| !application.getId().equals(carton.getRelabelApplicationId())
|| !"RELABEL_PENDING".equals(carton.getStatus())
|| intValue(source.getNotifiedPackageCount()) != 1)
@@ -1103,7 +1112,8 @@ public class RelabelApplicationService implements WalletResultHandler {
this::outboundSourceKey, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null;
if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) {
if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId())
|| !Objects.equals(application.getOverseasWarehouseId(), position.getOverseasWarehouseId()))) {
throw conflict("Relabel source inventory position no longer exists");
}
List<RelabelItem> group = entry.getValue();
@@ -22,6 +22,7 @@ public class RelabelBindingGuard {
@Resource private PdaRelabelHistoryMapper histories;
@Resource private InventoryPositionMapper positions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper conversions;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnLines;
@Resource private ObjectMapper objectMapper;
@@ -32,10 +33,15 @@ public class RelabelBindingGuard {
.contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
String currentRound = null;
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())
.orderByAsc("created_at", "id"))) {
if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear();
if ("REWORK_ARCHIVE".equals(history.getAction())) { bindings.clear(); currentRound = null; }
if ("COMPLETE".equals(history.getAction())) {
try { currentRound = objectMapper.readTree(history.getDetailJson()).path("roundKey").asText(null); }
catch (Exception failure) { throw conflict("换标完工轮次记录无法读取"); }
}
if (!"PALLET_BIND".equals(history.getAction())) continue;
try {
var data = objectMapper.readTree(history.getDetailJson());
@@ -45,20 +51,26 @@ public class RelabelBindingGuard {
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
var sourceCarton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (item.getReturnCartonId() != null && (sourceCarton == null
|| !Objects.equals(app.getCustomerId(), sourceCarton.getCustomerId())
|| !Objects.equals(app.getOverseasWarehouseId(), sourceCarton.getOverseasWarehouseId())))
throw conflict("退货箱来源缺失或仓库不一致");
if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失");
var carton = sourceCarton;
if ("CARTON_AND_SKU".equals(item.getOperationType())) {
var expected = new HashMap<String, Long>();
try {
for (var sku : objectMapper.readTree(item.getSkuRelabelItemsJson()))
expected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum);
} catch (Exception failure) { throw conflict("混贴 SKU 明细无法读取"); }
projectCurrentContents(app, List.of(item), expected, currentRound);
var actual = new HashMap<String, Long>();
returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum));
if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱");
}
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
if ("EXPRESS".equals(app.getDeliveryMethod()) || "PICKUP".equals(app.getDeliveryMethod())
|| "AMAZON_PICKUP".equals(app.getDeliveryMethod()) || "CUSTOMER_PICKUP".equals(app.getDeliveryMethod())) continue;
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
throw conflict("退货箱尚未完成排车托盘绑定");
continue;
@@ -75,20 +87,54 @@ public class RelabelBindingGuard {
}
var actual = new HashMap<String, Long>();
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
projectCurrentContents(app, sameCarton, selected, currentRound);
if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工");
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
// SKU-only work has no outbound fulfillment, but must still cover the isolated carton.
if ("SKU_LABEL".equals(item.getOperationType()) && app.getDeliveryMethod() == null) continue;
if ("EXPRESS".equals(app.getDeliveryMethod()) || "PICKUP".equals(app.getDeliveryMethod())
|| "AMAZON_PICKUP".equals(app.getDeliveryMethod()) || "CUSTOMER_PICKUP".equals(app.getDeliveryMethod())) continue;
var carton = sourceCarton;
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus()))
throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标");
continue;
}
var position = item.getSourceInventoryPositionId() == null ? null
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
if (position == null || position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
if (position == null || !Objects.equals(app.getOverseasWarehouseId(), position.getOverseasWarehouseId())
|| position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
|| position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount())
throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库");
}
}
private void projectCurrentContents(RelabelApplication app, List<RelabelItem> selectedItems,
Map<String, Long> expected, String currentRound) {
if (!"CUSTOMER_CONFIRM_PENDING".equals(app.getStatus()) && !"READY_FOR_OUTBOUND".equals(app.getStatus())
&& !"COMPLETED".equals(app.getStatus())) return;
if (currentRound == null || currentRound.isBlank()) throw conflict("换标完工轮次缺失,不能核对当前 SKU 库存");
// Warehouse completion has already applied SKU conversions. Confirmation and dispatch
// compare against that round's persisted changes, never assume that every new SKU exists.
Map<String, Long> outgoing = new HashMap<>(), incoming = new HashMap<>();
for (var row : conversions.lockApplication(app.getCustomerId(), app.getId())) {
if (!Objects.equals(row.getCustomerId(), app.getCustomerId()) || !Objects.equals(row.getApplicationId(), app.getId())
|| !Objects.equals(row.getRoundKey(), currentRound)
|| !("APPLIED".equals(row.getStatus()) || "CONFIRMED".equals(row.getStatus()))
|| selectedItems.stream().noneMatch(item -> Objects.equals(item.getId(), row.getItemId())
&& Objects.equals(item.getReturnCartonId(), row.getCartonId()))) continue;
String oldSku = row.getOldSkuCode().toUpperCase(Locale.ROOT);
String newSku = row.getNewSkuCode().toUpperCase(Locale.ROOT);
long quantity = Objects.requireNonNullElse(row.getQuantity(), 0L);
if (quantity <= 0) throw conflict("换标转换数量与批准明细不一致");
outgoing.merge(oldSku, quantity, Math::addExact);
incoming.merge(newSku, quantity, Math::addExact);
}
for (var source : outgoing.entrySet()) {
long remaining = expected.getOrDefault(source.getKey(), 0L) - source.getValue();
if (remaining < 0) throw conflict("换标转换数量与批准明细不一致");
if (remaining == 0) expected.remove(source.getKey()); else expected.put(source.getKey(), remaining);
}
incoming.forEach((sku, quantity) -> expected.merge(sku, quantity, Math::addExact));
}
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}