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); }
}
@@ -1,21 +1,21 @@
-- DDL proposal only. The coordinator must integrate the approved baseline; do not execute from this task.
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
dispatch_id BIGINT NOT NULL,
dispatch_item_id BIGINT NOT NULL,
plan_pallet_id BIGINT NOT NULL,
batch_key VARCHAR(96) NOT NULL,
customer_id VARCHAR(64) NOT NULL,
source_key VARCHAR(160) NOT NULL,
destination_key VARCHAR(160) NOT NULL,
package_count INT NOT NULL,
dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
package_count INT NOT NULL COMMENT '本票分配实物箱数',
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
entity_key VARCHAR(160) NOT NULL,
entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
impact_version BIGINT NOT NULL DEFAULT 0,
impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
@@ -2210,6 +2210,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
declared_quantity BIGINT NULL COMMENT 'SKU申报件数,非预计或实收箱数',
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称',
package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头',
@@ -2218,8 +2219,21 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
) ENGINE=InnoDB COMMENT='转仓货物冻结明细';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
length_cm DECIMAL(12,2) NULL COMMENT '箱子实测长度厘米',
width_cm DECIMAL(12,2) NULL COMMENT '箱子实测宽度厘米',
height_cm DECIMAL(12,2) NULL COMMENT '箱子实测高度厘米',
gross_weight_kg DECIMAL(12,2) NULL COMMENT '箱子实测重量千克',
counted_quantity BIGINT NULL COMMENT '箱内SKU实点件数',
count_idempotency_key VARCHAR(255) NULL COMMENT '箱内清点幂等键',
count_request_hash VARCHAR(64) NULL COMMENT '箱内清点请求摘要',
counted_by VARCHAR(128) NULL COMMENT '箱内清点操作人',
counted_at DATETIME(3) NULL COMMENT '箱内清点完成时间',
discrepancy_reason VARCHAR(1000) NULL COMMENT '实收与预报箱数差异原因',
transfer_pallet_id BIGINT NULL COMMENT '签收箱所属转运托盘事实ID',
sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
customer_carton_mark VARCHAR(255) NULL COMMENT '客户原箱唛,用于换标现场绑定',
carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号',
total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -2370,7 +2384,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车',
print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单',
shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID',
shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名',
@@ -2407,7 +2421,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK平台卡车、EXPRESS快递、PICKUP自提、AMAZON_PICKUP亚马逊自提、CUSTOMER_PICKUP客户自提;仅平台卡车进入预排车'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -2662,6 +2676,8 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
,
KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
@@ -3981,21 +3997,21 @@ CREATE TABLE ll_selfop_sku_profile (
-- PDA cargo discard photo evidence
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
id BIGINT NOT NULL COMMENT '照片记录标识',
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
application_id BIGINT NOT NULL COMMENT '废弃申请标识',
pallet_no VARCHAR(128) NOT NULL COMMENT '托盘业务码',
file_id BIGINT NOT NULL COMMENT '照片文件标识',
file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
content_type VARCHAR(128) NOT NULL COMMENT '文件媒体类型',
size_bytes BIGINT NOT NULL COMMENT '文件字节数',
operator_id VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(6) NOT NULL COMMENT '照片创建时间',
submitted_at DATETIME(6) NULL COMMENT '照片提交时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA货物废弃照片凭证';
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
@@ -4154,22 +4170,22 @@ PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPA
-- Appointment work scope, audit and physical batch allocation.
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
dispatch_id BIGINT NOT NULL,
dispatch_item_id BIGINT NOT NULL,
plan_pallet_id BIGINT NOT NULL,
batch_key VARCHAR(96) NOT NULL,
customer_id VARCHAR(64) NOT NULL,
source_key VARCHAR(160) NOT NULL,
destination_key VARCHAR(160) NOT NULL,
package_count INT NOT NULL,
dispatch_id BIGINT NOT NULL COMMENT '排车单标识',
dispatch_item_id BIGINT NOT NULL COMMENT '排车明细标识',
plan_pallet_id BIGINT NOT NULL COMMENT '入仓计划托盘标识',
batch_key VARCHAR(96) NOT NULL COMMENT '预约稳定批次键',
customer_id VARCHAR(64) NOT NULL COMMENT '所属客户标识',
source_key VARCHAR(160) NOT NULL COMMENT '货物来源稳定键',
destination_key VARCHAR(160) NOT NULL COMMENT '目的地稳定键',
package_count INT NOT NULL COMMENT '本票分配实物箱数',
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
entity_key VARCHAR(160) NOT NULL,
entity_key VARCHAR(160) NOT NULL COMMENT '原业务实体稳定键',
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
impact_version BIGINT NOT NULL DEFAULT 0,
impact_version BIGINT NOT NULL DEFAULT 0 COMMENT '承诺影响单调递增版本',
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
@@ -1710,7 +1710,7 @@ paths:
'200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } }
post:
summary: 创建客户换标申请
description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。一张申请只能选择一种换标类型,所有 items.operationType 必须一致;不同类型混合提交返回业务参数错误且不冻结库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。一张申请只能选择一种换标类型,所有 items.operationType 必须一致;不同类型混合提交返回业务参数错误且不冻结库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、EXPRESS、PICKUP、AMAZON_PICKUP 或 CUSTOMER_PICKUP。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses:
@@ -1791,7 +1791,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:
post:
summary: 客户确认换标完成
description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认,确认前复验实际托盘绑定,仓库手动完工不豁免该门禁。普通库存箱唛换标必须绑定实际托盘;退货整箱 EXPRESS、PICKUP、AMAZON_PICKUP、CUSTOMER_PICKUP 可不组托但仍校验全箱 SKU 数量。只有平台卡车 TRUCK 进入 WAIT_PRE_DISPATCH;EXPRESS 与全部自提方式幂等生成 RELABEL_OUTBOUND 出库单和 PDA 出库任务,不进入预排车候选,直接预排请求也拒绝。已分配仓储转运保持原转运出库链,不生成独立出库。EXPRESS 且要求打印面单时必须先上传面单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } }
@@ -3154,7 +3154,7 @@ components:
notifiedPackageCount: { type: integer }
productNameZh: { type: string, description: 明细非空中文品名按原顺序去重并以顿号连接 }
labelRequirement: { type: string, description: 明细非空贴标要求按原顺序去重并以中文分号连接 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, PICKUP, AMAZON_PICKUP, CUSTOMER_PICKUP], description: 仅TRUCK进入平台预排车;快递及全部自提直接创建出库任务 }
printShippingLabel: { type: boolean }
shippingLabelFileId: { type: string, nullable: true }
fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, WAIT_TRANSFER_OUTBOUND, READY_FOR_OUTBOUND, OUTBOUND_CREATED, OUTBOUNDED, COMPLETED] }
@@ -3293,7 +3293,7 @@ components:
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传;仅TRUCK进入平台预排车,其余方式直接创建出库任务 }
printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem:
@@ -858,8 +858,8 @@ components:
type: object
required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
properties:
deliveryMethod: { type: string, nullable: true, description: 申请真实运输方式,留仓为空 }
requiresPalletBinding: { type: boolean, description: 退货任务仅TRUCK为true,留仓及快递为false }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, PICKUP, AMAZON_PICKUP, CUSTOMER_PICKUP], description: 申请真实运输方式,纯SKU留仓为空;只有平台卡车TRUCK进入预排车,快递和所有自提直接创建出库任务 }
requiresPalletBinding: { type: boolean, description: 普通库存箱唛或混合换标须实际托盘绑定;退货整箱EXPRESS、PICKUP、AMAZON_PICKUP、CUSTOMER_PICKUP不要求排车组托,TRUCK要求;纯SKU留仓为空派送方式时不要求组托 }
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
labelDeadline: { type: string, format: date-time, nullable: true }