贴标申请,仓储转运

This commit is contained in:
2026-10-10 13:48:25 +08:00
parent 721d92ac7b
commit 12a5093b41
26 changed files with 1347 additions and 47 deletions
@@ -2378,7 +2378,7 @@ CREATE TABLE ll_return_inventory_outbound_movement (
carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码',
outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号',
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照',
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正',
total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量',
@@ -2390,11 +2390,11 @@ CREATE TABLE ll_return_inventory_outbound_movement (
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
AND total_before-total_after=quantity AND available_before-available_after=quantity
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
CREATE TABLE ll_return_inventory_balance (
@@ -80,7 +80,13 @@ public final class DeliveryRequests {
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey,
@Size(max=128) String skuCode,
@jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType) {
@jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = WholePieceQuantityDeserializer.class)
@jakarta.validation.constraints.Positive Long quantity) {
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
List<String> cartonMarks, String remark, String idempotencyKey, String skuCode, String unitType) {
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, unitType, null);
}
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
List<String> cartonMarks, String remark, String idempotencyKey) {
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, null, null);
@@ -114,7 +120,13 @@ public final class DeliveryRequests {
public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey,
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks,
@Size(max=128) String skuCode) {
@Size(max=128) String skuCode,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = WholePieceQuantityDeserializer.class)
@jakarta.validation.constraints.Positive Long productQuantity) {
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey,
List<String> cartonMarks, String skuCode) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, cartonMarks, skuCode, null);
}
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of(), null);
}
@@ -93,6 +93,6 @@ public final class DeliveryResponses {
long pickedPackages) {}
public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode,
int packageCount, int pickedPackageCount, String status,
List<PdaPickPosition> positions) {}
List<PdaPickPosition> positions, Long returnSkuQuantity) {}
public record PdaTaskDetail(PdaTask task, List<PdaTaskItem> items) {}
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.delivery.dto;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
/** Piece quantities must not silently truncate a fractional JSON number. */
public final class WholePieceQuantityDeserializer extends JsonDeserializer<Long> {
@Override public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT)
return (Long) context.handleUnexpectedToken(Long.class, parser);
return parser.getLongValue();
}
}
@@ -28,6 +28,8 @@ public class OutboundPalletItem {
private Integer pickedPackageCount;
private String status;
private String skuCode;
/** Immutable piece quantity for a selected single-SKU return source. Null means legacy whole-carton work. */
private Long returnSkuQuantity;
private String cartonMarks;
private String orderRefs;
private String inboundCode;
@@ -499,6 +499,8 @@ public class DeliveryTransferService {
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
String warehouseId = request.overseasWarehouseId().trim();
if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0
|| "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库");
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList();
if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复");
LocalDateTime now = LocalDateTime.now();
@@ -514,6 +516,8 @@ public class DeliveryTransferService {
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
for (ReturnInventoryCarton carton : locked) {
if ("PARCEL".equals(carton.getSourceUnitType()) && !StringUtils.hasText(request.skuCode()))
throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
var contents = returnCartonItems.lockCarton(carton.getId());
@@ -521,12 +525,22 @@ public class DeliveryTransferService {
throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark());
if (StringUtils.hasText(request.skuCode()) && (contents.size() != 1
|| !request.skuCode().trim().equals(contents.get(0).getSkuCode())))
throw conflict("单SKU出库只支持指定SKU的散件库存");
if (StringUtils.hasText(request.skuCode()) && !"PARCEL".equals(carton.getSourceUnitType()))
throw conflict("已装箱库存请按箱唛出库");
throw conflict("单SKU出库只支持箱内仅有指定SKU的库存;混合箱请先拆箱");
if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode()))
throw conflict("整托出库必须存在托盘绑定");
}
Map<String, Long> skuQuantities = new LinkedHashMap<>();
if (StringUtils.hasText(request.skuCode())) {
long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum();
long remaining = request.quantity() == null ? available : request.quantity();
if (remaining > available) throw conflict("出库件数超过所选来源库存数量");
for (ReturnInventoryCarton carton : locked) {
long selected = Math.min(remaining, value(carton.getTotalQuantity()));
if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected);
remaining -= selected;
}
locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList();
}
if ("PALLET".equals(request.unitType())) {
String pallet = locked.get(0).getPalletCode();
if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘");
@@ -539,7 +553,7 @@ public class DeliveryTransferService {
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
@@ -547,7 +561,9 @@ public class DeliveryTransferService {
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo());
item.setSkuCode(trim(request.skuCode()));
// Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning.
item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? trim(request.skuCode()) : null);
item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark()));
item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null);
item.setCreatedAt(now); outboundPallets.insert(item);
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
@@ -557,7 +573,7 @@ public class DeliveryTransferService {
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
delivery.setPackageCount(marks.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
delivery.setPackageCount(locked.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "RETURN-DELIVERY"));
delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
deliveryOrders.insert(delivery);
@@ -822,7 +838,10 @@ public class DeliveryTransferService {
requireReturnScan(outbound, item, request);
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱不可重复出库");
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId());
if (item.getReturnSkuQuantity() != null)
returnStock.shipCartonQuantity(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(),
outbound.getOutboundNo(), pick.getId(), item.getReturnSkuQuantity());
else returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId());
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
@@ -1287,7 +1306,7 @@ public class DeliveryTransferService {
value(item.getPickedPackageCount()), item.getStatus(),
("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item)
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item), item.getReturnSkuQuantity())).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
@@ -1326,6 +1345,8 @@ public class DeliveryTransferService {
|| !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在");
if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1)
throw conflict("退货出库须逐箱或逐SKU扫描,无需库存仓位");
if (item.getReturnSkuQuantity() != null && !item.getReturnSkuQuantity().equals(request.productQuantity()))
throw conflict("实际出库件数必须等于本明细计划件数,请核对数量");
if (StringUtils.hasText(item.getSkuCode())) {
if (!Objects.equals(item.getSkuCode(), trim(request.skuCode()))) throw conflict("请扫描指定的退货SKU");
} else if (request.cartonMarks() == null || request.cartonMarks().size() != 1
@@ -10,7 +10,8 @@ public final class PdaRelabelResponses {
String applicationStatus, String taskStatus, String operationType, int itemCount,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int version, boolean claimedByCurrentOperator,
String reviewDecision, String reviewRemark, LocalDateTime reviewedAt) { }
String reviewDecision, String reviewRemark, LocalDateTime reviewedAt,
String customerName) { }
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String contactName, String contactPhone, String applicationStatus, String operationType,
@@ -72,6 +72,7 @@ public class PdaRelabelService {
@Resource private RelabelPhotoFileGateway photoFiles;
@Resource private ObjectMapper sourceObjectMapper;
@Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders;
@Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService exceptionOrders;
private ObjectMapper objectMapper;
@@ -84,14 +85,27 @@ public class PdaRelabelService {
@Transactional(readOnly = true)
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
List<RelabelApplication> rows = applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING,
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING)
.orderByAsc(RelabelApplication::getLabelDeadline)
.orderByAsc(RelabelApplication::getCreatedAt))
.stream().map(application -> candidate(application,
tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList();
.orderByAsc(RelabelApplication::getCreatedAt));
List<Long> orderIds = rows.stream().map(RelabelApplication::getLastlegOrderId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, com.baiyue.lastleg.entity.LastlegOrder> linkedOrders = orderIds.isEmpty() ? Map.of()
: tailOrders.selectBatchIds(orderIds).stream().collect(Collectors.toMap(
com.baiyue.lastleg.entity.LastlegOrder::getId, order -> order));
Map<String, String> companyNames = new HashMap<>();
return rows.stream().map(application -> {
var order = linkedOrders.get(application.getLastlegOrderId());
String customerName = order != null && Objects.equals(application.getCustomerId(), order.getCustomerId())
? order.getCustomerName() : null;
if (!StringUtils.hasText(customerName)) {
customerName = companyNames.computeIfAbsent(application.getCustomerId(), customerId -> customerDirectory.find(customerId).name());
}
return candidate(application, tasks.find(application.getCustomerId(), application.getId()), operatorKey, customerName);
}).toList();
}
@Transactional(readOnly = true)
@@ -458,6 +472,7 @@ public class PdaRelabelService {
"manualCompletion", request.manualCompletion()));
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
applicationService.settlePdaCompletion(application.getCustomerId(), relabelNo, operatorKey);
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
@@ -704,7 +719,7 @@ public class PdaRelabelService {
}
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
String operatorKey) {
String operatorKey, String customerName) {
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task),
operationType(application), value(application.getItemCount()),
@@ -713,7 +728,7 @@ public class PdaRelabelService {
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
task != null && PROCESSING.equals(task.getStatus()) && operatorKey.equals(task.getOperatorKey()),
task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(),
task == null ? null : task.getReviewedAt());
task == null ? null : task.getReviewedAt(), customerName);
}
private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) {
@@ -850,7 +850,7 @@ public class RelabelApplicationService implements WalletResultHandler {
private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId,
List<LastlegOrder> linkedOrders) {
if ("NOT_REQUIRED".equals(application.getWalletStatus())) return;
if (Set.of("NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(Objects.toString(application.getWalletStatus(), ""))) return;
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo();
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
@@ -868,6 +868,14 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
}
@Transactional
public void settlePdaCompletion(String customerId, String relabelNo, String operatorId) {
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) throw conflict("换标尚未完成,不能扣款");
publishCompletionFeesAndSettlement(application, operatorId, tailOrders(application));
}
@Transactional
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
RelabelRequests.CustomerConfirm request) {
@@ -12,6 +12,12 @@ import java.util.List;
@Mapper
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " +
"available_quantity=available_quantity-#{quantity},updated_at=NOW(3) " +
"WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
"AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1")
int deductOutboundPieces(@Param("id") long id, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("quantity") long quantity);
String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," +
"LEAST(b.available_quantity,COALESCE((SELECT SUM(GREATEST(0,i.quantity-COALESCE(i.packing_reserved_quantity,0))) " +
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id=c.customer_id " +
@@ -382,6 +382,17 @@ public class ReturnInventoryService {
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void shipCarton(String customerId, String warehouseId, String returnNo, String cartonMark,
String outboundNo, long outboundPickId) {
shipCartonInternal(customerId, warehouseId, returnNo, cartonMark, outboundNo, outboundPickId, null);
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void shipCartonQuantity(String customerId, String warehouseId, String returnNo, String cartonMark,
String outboundNo, long outboundPickId, long quantity) {
shipCartonInternal(customerId, warehouseId, returnNo, cartonMark, outboundNo, outboundPickId, quantity);
}
private void shipCartonInternal(String customerId, String warehouseId, String returnNo, String cartonMark,
String outboundNo, long outboundPickId, Long requestedQuantity) {
ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "退货箱码不能为空"));
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !warehouseId.equals(carton.getOverseasWarehouseId()) || !matchesReturnSource(carton, returnNo)
@@ -400,22 +411,27 @@ public class ReturnInventoryService {
}
if (contents.isEmpty() || total != value(carton.getTotalQuantity())
|| carton.getSkuTypes() == null || contents.size() != carton.getSkuTypes()) throw conflict("退货箱内容与实物数量不一致");
if (requestedQuantity != null && (contents.size() != 1 || requestedQuantity <= 0 || requestedQuantity > total))
throw conflict("单SKU出库件数或来源明细不匹配");
boolean partial = requestedQuantity != null && requestedQuantity < total;
boolean parcel = "PARCEL".equals(carton.getSourceUnitType());
long cartonDelta = partial || parcel ? 0 : 1;
contents = contents.stream().sorted(Comparator.comparing(ReturnInventoryCartonItem::getSkuCode)).toList();
String scopedCustomer = carton.getCustomerId(), scopedWarehouse = carton.getOverseasWarehouseId();
Map<String, com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance> lockedBalances = new LinkedHashMap<>();
LocalDateTime now = LocalDateTime.now();
for (ReturnInventoryCartonItem content : contents) {
var balance = balances.lockSku(scopedCustomer, scopedWarehouse, content.getSkuCode());
long quantity = content.getQuantity();
long quantity = requestedQuantity == null ? content.getQuantity() : requestedQuantity;
if (balance == null || value(balance.getTotalQuantity()) < quantity
|| value(balance.getAvailableQuantity()) < quantity || value(balance.getCartonQuantity()) < 1) {
|| value(balance.getAvailableQuantity()) < quantity || (!parcel && value(balance.getCartonQuantity()) < 1)) {
throw conflict("退货库存不足,不能完成该箱出库:" + content.getSkuCode());
}
lockedBalances.put(content.getSkuCode(), balance);
}
for (ReturnInventoryCartonItem content : contents) {
var balance = lockedBalances.get(content.getSkuCode());
long quantity = content.getQuantity();
long quantity = requestedQuantity == null ? content.getQuantity() : requestedQuantity;
var movement = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement();
movement.setId(IdWorker.getId()); movement.setOutboundPickId(outboundPickId);
movement.setCustomerId(scopedCustomer); movement.setOverseasWarehouseId(scopedWarehouse);
@@ -424,12 +440,19 @@ public class ReturnInventoryService {
movement.setSkuCode(content.getSkuCode()); movement.setProductNameZh(content.getProductNameZh()); movement.setQuantity(quantity);
movement.setTotalBefore(balance.getTotalQuantity()); movement.setTotalAfter(balance.getTotalQuantity() - quantity);
movement.setAvailableBefore(balance.getAvailableQuantity()); movement.setAvailableAfter(balance.getAvailableQuantity() - quantity);
movement.setCartonsBefore(balance.getCartonQuantity()); movement.setCartonsAfter(balance.getCartonQuantity() - 1);
movement.setCartonsBefore(balance.getCartonQuantity()); movement.setCartonsAfter(balance.getCartonQuantity() - cartonDelta);
movement.setOccurredAt(now);
if (balances.deductOutbound(balance.getId(), scopedCustomer, scopedWarehouse, quantity) != 1) throw conflict("退货库存余额已变化,请重新核对");
int changed = partial || parcel ? balances.deductOutboundPieces(balance.getId(), scopedCustomer, scopedWarehouse, quantity)
: balances.deductOutbound(balance.getId(), scopedCustomer, scopedWarehouse, quantity);
if (changed != 1) throw conflict("退货库存余额已变化,请重新核对");
if (movements.insert(movement) != 1) throw new IllegalStateException("退货出库流水写入失败");
if (partial) {
content.setQuantity(content.getQuantity() - quantity);
if (cartonItems.updateById(content) != 1) throw conflict("退货箱内数量已变化,请重新核对");
}
carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(now);
}
if (partial) carton.setTotalQuantity(total - requestedQuantity);
carton.setStatus(partial ? "AVAILABLE" : "OUTBOUNDED"); carton.setUpdatedAt(now);
if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对");
}
@@ -441,7 +464,9 @@ public class ReturnInventoryService {
required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
// A parcel row tracks loose SKU provenance; it is never a physical carton or printable box label.
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "SP" + hash(List.of(receipt.getId(), lines.get(0).skuCode())).substring(0, 24).toUpperCase(java.util.Locale.ROOT);
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
@@ -465,7 +490,7 @@ public class ReturnInventoryService {
item.setSkuCode(line.skuCode()); item.setProductNameZh(text(line.productNameZh())); item.setQuantity(line.quantity());
cartonItems.insert(item);
balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(),
text(line.productNameZh()), line.quantity(), line.quantity(), 1);
text(line.productNameZh()), line.quantity(), line.quantity(), "PARCEL".equals(receipt.getUnitType()) ? 0 : 1);
}
if ("BOX".equals(receipt.getUnitType())) {
var contents = lines.stream().map(line -> new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku(line.skuCode(), line.productNameZh(), line.quantity())).toList();
@@ -968,7 +993,7 @@ public class ReturnInventoryService {
productNames.putIfAbsent(sku, item.getProductNameZh());
item.setQuantity(item.getQuantity() - take);
if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item);
if (item.getQuantity() == 0) emptiedBySku.merge(sku, 1, Integer::sum);
if (item.getQuantity() == 0 && !"PARCEL".equals(source.getSourceUnitType())) emptiedBySku.merge(sku, 1, Integer::sum);
}
if (item.getQuantity() > 0) { sourceTotal = Math.addExact(sourceTotal, item.getQuantity()); sourceTypes++; }
}
@@ -255,7 +255,7 @@ public class ReturnReceiptService {
.toList();
long boxes = value(receipt.getTotalBoxCount());
if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum();
String cartonMark = text(receipt.getCartonMark());
String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark());
List<ReturnInventoryService.ReturnOrderLine> labelLines = lines.stream()
.map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount()))
.toList();
@@ -579,6 +579,9 @@ public class ReturnReceiptService {
if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode());
if (unitCode == null && "BOX".equals(unitType))
unitCode = "RC" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT) + "#1-1";
if (unitCode == null && "PARCEL".equals(unitType)) {
unitCode = "PK" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT);
}
if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空");
if ("PALLET".equals(unitType)
&& (request.length() == null || request.width() == null || request.height() == null || request.weight() == null
@@ -633,7 +636,7 @@ public class ReturnReceiptService {
} else {
inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values())));
}
receipt.setCartonMark(inventoryCartons.get(0).getCartonMark());
receipt.setCartonMark("PARCEL".equals(unitType) ? null : inventoryCartons.get(0).getCartonMark());
receipts.updateById(receipt);
syncTailCargo(order, receipt, allocations, now);
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
@@ -777,10 +780,10 @@ public class ReturnReceiptService {
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
String cartonMark = text(receipt.getCartonMark());
String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark());
String printableHtml = null;
List<ReturnOrderResponses.CartonLabel> labels = new java.util.ArrayList<>();
if (receipt.getId() != null) {
if (receipt.getId() != null && !"PARCEL".equals(receipt.getUnitType())) {
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
var label = inventory.label(order.getCustomerId(), carton.getCartonMark());
if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml()));
@@ -2043,7 +2043,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code),
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
@@ -3079,11 +3079,11 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
AND total_before-total_after=quantity AND available_before-available_after=quantity
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
@@ -3264,6 +3264,29 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。
SET @return_sku_quantity_ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'),
'SELECT 1',
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code'
);
PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl;
EXECUTE return_sku_quantity_stmt;
DEALLOCATE PREPARE return_sku_quantity_stmt;
-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。
SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE return_sku_check_stmt FROM @return_sku_check_ddl;
EXECUTE return_sku_check_stmt;
DEALLOCATE PREPARE return_sku_check_stmt;
-- 同箱余数允许再次出库,防重仍由拣货ID+SKU唯一索引保证。
SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', 'SELECT 1');
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE return_sku_index_stmt;
CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt (
id BIGINT NOT NULL COMMENT '请求尝试ID',
order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -822,6 +822,30 @@ paths:
- { name: warehouseId, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
responses: { '200': { description: 出库单分页 } }
/api/platform/v1/last-leg/outbound-orders/return:
post:
summary: 创建退货出库单,支持单SKU全部或部分件数
description: 权限 platform:overseas-warehouse:outbound:create;单SKU来源须为纯SKU可用箱或散件。混合SKU箱须先拆箱。按箱唛排序分配件数,未拣货前占用整个使用来源;拣货后原箱余数恢复可用。相同幂等键改数量拒绝。
security: [{ bearerAuth: [] }]
parameters: [{ name: customerId, in: query, required: true, schema: { type: string } }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recipient, overseasWarehouseId, returnNo, cartonMarks, idempotencyKey]
properties:
recipient: { type: string, maxLength: 1000 }
overseasWarehouseId: { type: string, maxLength: 128 }
returnNo: { type: string, maxLength: 128 }
cartonMarks: { type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: { type: string, maxLength: 128 } }
skuCode: { type: string, maxLength: 128, nullable: true }
quantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU计划件数,不得超过所选来源总数;省略出全部,只允许skuCode已填且非PALLET维度,不接受小数 }
unitType: { type: string, enum: [BOX, PALLET], nullable: true }
remark: { type: string, maxLength: 1000, nullable: true }
idempotencyKey: { type: string, maxLength: 255 }
responses: { '200': { description: 退货出库PDA任务 }, '400': { description: 数量或请求格式错误 }, '409': { description: 来源占用、库存或幂等冲突 } }
/api/platform/v1/last-leg/outbound-orders/one-click-dropship:
post:
summary: 创建一件代发出库单
@@ -875,6 +875,8 @@ components:
outboundPalletItemId: { type: string, pattern: '^[0-9]+$' }
inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID }
packages: { type: integer, format: int64, minimum: 1 }
productQuantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU退货明细存在returnSkuQuantity时必填且必须等于计划件数;不接受小数。历史无快照明细可省略 }
skuCode: { type: string, maxLength: 128, nullable: true, description: PARCEL散件单SKU扫码值;已装箱来源必须扫描箱唛 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
cartonMarks:
type: array
@@ -911,6 +913,7 @@ components:
outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 }
palletNo: { type: string, nullable: true, description: Warehouse 权威实物托盘码;退货箱明细不伪造托盘码 }
skuCode: { type: string, nullable: true }
returnSkuQuantity: { type: integer, format: int64, nullable: true, minimum: 1, description: 本来源计划出库件数快照;部分拣货后余数保留原箱,PDA不得改变计划件数 }
inboundCode: { type: string }
packageCount: { type: integer }
pickedPackageCount: { type: integer }
@@ -236,6 +236,38 @@ class DeliveryTransferServiceTest {
assertEquals(1, delivery.getValue().getPackageCount());
}
@Test
void partialSkuOutboundDistributesSixPiecesAcrossTwoFivePieceSources() {
var first = returnCarton("RC-1", null); first.setId(901L); first.setTotalQuantity(5L);
var second = returnCarton("RC-2", null); second.setId(902L); second.setTotalQuantity(5L);
when(returnCartons.lockMark("RC-1")).thenReturn(first); when(returnCartons.lockMark("RC-2")).thenReturn(second);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-1", "RC-2"), null, "partial-six", "SKU-A", "BOX", 6L));
var capture = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture());
assertEquals(List.of(5L, 1L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList());
}
@Test
void singleSkuReturnOutboundAcceptsPackedSingleSkuButKeepsCartonScanning() {
ReturnInventoryCarton carton = returnCarton("RC-BOX", "PAL-1");
carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode("SKU-A"); line.setQuantity(5L);
when(returnCartons.lockMark("RC-BOX")).thenReturn(carton);
when(returnCartonItems.lockCarton(carton.getId())).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-BOX"), null, "packed-sku", "SKU-A"));
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets).insert(item.capture());
assertNull(item.getValue().getSkuCode());
assertEquals("RC-BOX", item.getValue().getCartonMarks());
assertEquals("OUTBOUND_PENDING", carton.getStatus());
}
@Test
void singleSkuReturnOutboundRejectsMixedCarton() {
ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null);
@@ -71,9 +71,18 @@ class ReturnOutboundClosureTest {
verify(outbounds, times(1)).insert(any(OutboundOrder.class));
}
@Test void preventsSingleSkuBypassingExistingBox() {
@Test void singleSkuPackedBoxKeepsCartonScanAndPieceSnapshot() {
service.createReturnOutbound("C1", "USER", command("SKU1"));
verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && Long.valueOf(6L).equals(i.getReturnSkuQuantity())));
assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING");
}
@Test void singleSkuCannotConsumeMixedSkuCarton() {
ReturnInventoryCartonItem other = new ReturnInventoryCartonItem(); other.setSkuCode("SKU2"); other.setQuantity(1L);
var first = contents.lockCarton(10L).get(0);
when(contents.lockCarton(10L)).thenReturn(List.of(first, other));
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("按箱唛");
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("SKU");
verify(outbounds, never()).insert(any(OutboundOrder.class));
}
@@ -82,6 +82,24 @@ class PdaRelabelServiceTest {
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
}
@Test
void candidatesUseCustomerNameOnlyFromMatchingCustomerOrder() {
var orders = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
var directory = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
ReflectionTestUtils.setField(service, "customerDirectory", directory);
ReflectionTestUtils.setField(service, "tailOrders", orders);
application.setLastlegOrderId(20L);
var order = new com.baiyue.lastleg.entity.LastlegOrder();
order.setId(20L); order.setCustomerId("100"); order.setCustomerName("测试客户");
when(applications.selectList(any())).thenReturn(List.of(application));
when(orders.selectBatchIds(any())).thenReturn(List.of(order));
assertEquals("测试客户", service.candidates("7", 900L).get(0).customerName());
order.setCustomerId("OTHER");
when(directory.find("100")).thenReturn(new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("100", "C100", "所属公司", null, null));
assertEquals("所属公司", service.candidates("7", 900L).get(0).customerName());
verify(orders, org.mockito.Mockito.times(2)).selectBatchIds(List.of(20L));
}
@Test
void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() {
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
@@ -54,6 +54,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -643,6 +644,18 @@ class RelabelApplicationServiceTest {
assertEquals(6L, move.getValue().packages());
}
@Test
void pdaCompletionSettlesBeforeCustomerConfirmationAndDoesNotSettleTwice() {
var app = confirmationApplication("TRUCK", false);
when(applications.lockNo("10001", "FBR-1")).thenReturn(app);
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-1", "WAIT_RELABEL"));
service.settlePdaCompletion("10001", "FBR-1", "picker");
service.settlePdaCompletion("10001", "FBR-1", "picker");
verify(walletCommands).publish(eq("SETTLE"), eq(10001L), eq("RELABEL_ORDER"), eq("FBR-1"), isNull(), anyString(), eq(2L));
assertEquals("SETTLE_PENDING", app.getWalletStatus());
assertNull(app.getCustomerConfirmedAt());
}
@Test
void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() {
RelabelApplication application = confirmationApplication("TRUCK", false);
@@ -662,6 +675,7 @@ class RelabelApplicationServiceTest {
assertEquals("CONFIRMED", review.getValue().getReviewDecision());
assertEquals("WAIT_OUTBOUND", linked.getCurrentStatus());
assertNull(application.getOutboundOrderNo());
verify(walletCommands, never()).publish(eq("FREEZE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
service.confirmCompletion("10001", "FBR-1", "customer-1",
@@ -838,6 +852,7 @@ class RelabelApplicationServiceTest {
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash",
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
application.setLastlegOrderId(21L);
application.setOverseasWarehouseId("900");
application.setDeliveryMethod(deliveryMethod);
application.setPrintShippingLabel(printShippingLabel);
return application;
@@ -30,6 +30,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -75,6 +76,30 @@ class ReturnInventoryServiceTest {
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
}
@Test
void partialSkuShipmentKeepsOriginalCartonAndDoesNotSubtractCartonCount() {
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCartonMark("RC-1");
carton.setCustomerId("10001"); carton.setOverseasWarehouseId("42"); carton.setSourceReturnNo("RT-1");
carton.setStatus("OUTBOUND_PENDING"); carton.setCountStatus("CONFIRMED"); carton.setSkuTypes(1); carton.setTotalQuantity(5L);
var line = item(32L, "SKU-A", 5);
var balance = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance();
balance.setId(41L); balance.setTotalQuantity(5L); balance.setAvailableQuantity(5L); balance.setCartonQuantity(1L);
when(cartons.lockMark("RC-1")).thenReturn(carton);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
when(balances.lockSku("10001", "42", "SKU-A")).thenReturn(balance);
when(balances.deductOutboundPieces(41L, "10001", "42", 2L)).thenReturn(1);
when(movements.insert(any(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement.class))).thenReturn(1);
when(cartonItems.updateById(line)).thenReturn(1); when(cartons.updateById(carton)).thenReturn(1);
service.shipCartonQuantity("10001", "42", "RT-1", "RC-1", "OB-1", 51L, 2L);
assertThat(carton.getStatus()).isEqualTo("AVAILABLE"); assertThat(carton.getTotalQuantity()).isEqualTo(3L);
assertThat(line.getQuantity()).isEqualTo(3L);
var capture = org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement.class);
verify(movements).insert(capture.capture());
assertThat(capture.getValue().getQuantity()).isEqualTo(2L);
assertThat(capture.getValue().getCartonsAfter()).isEqualTo(1L);
verify(balances, never()).deductOutbound(anyLong(), anyString(), anyString(), anyLong());
}
@Test
void platformPlanNumberFilterIsTrimmedAndUsedForCountAndPage() {
when(packingPlans.countPlatform(null, "PLAN-1", "C1", "W1", "SKU1")).thenReturn(1L);
@@ -124,6 +149,17 @@ class ReturnInventoryServiceTest {
assertThat(capture.getValue().getResultJson()).contains("RC-BOX", "RR-BOX", "商品A", "\"quantity\":3", "\"quantity\":7");
}
@Test
void parcelSigningStoresLooseSkuSourceWithoutGeneratingCartonNumberOrIncreasingBoxCount() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
var source = service.createReceiptCarton(completedOrder(), receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
assertThat(source.getCartonMark()).startsWith("SP");
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
verifyNoInteractions(numbers, operations);
verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L));
}
@Test
void cartonDetailsExposeImmutableMergeHistoryWithinCustomerScope() {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
@@ -320,7 +356,7 @@ class ReturnInventoryServiceTest {
}
@Test
void fullyConsumedSourceIsMergedAndSkuCartonCountRemainsOne() {
void fullyConsumedParcelSourceIsMergedAndCreatesOnePhysicalCarton() {
ReturnInventoryCarton locked = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
@@ -330,7 +366,7 @@ class ReturnInventoryServiceTest {
assertThat(locked.getSkuTypes()).isZero();
assertThat(locked.getStatus()).isEqualTo(ReturnInventoryService.MERGED);
verify(cartonItems).deleteById(41L);
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 0L);
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L);
}
@Test
@@ -433,6 +433,30 @@ class ReturnReceiptServiceTest {
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
}
@Test
void parcelWithoutTrackingGeneratesParcelCodeAndNeverReturnsBoxLabelsOnReplay() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 3, 0);
ReturnOrderItem line = item(11L, "SKU-A", 3, 0);
stubNewReceipt(order, List.of(line), "SP-SOURCE");
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)), "parcel-key",
"PARCEL", null, null, null, null, null, null);
var result = service.confirm(identity, request);
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture());
assertThat(result.unitCode()).startsWith("PK");
assertThat(result.cartonMark()).isNull();
assertThat(result.printableHtml()).isNull();
assertThat(result.labels()).isEmpty();
assertThat(capture.getValue().getTrackingNo()).isNull();
when(receipts.lockKey("operator-1", "parcel-key")).thenReturn(capture.getValue());
var replay = service.confirm(identity, request);
assertThat(replay.unitCode()).isEqualTo(result.unitCode());
assertThat(replay.labels()).isEmpty();
verify(inventory, never()).label(anyString(), anyString());
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
}
@Test
void rejectsOverReceiptBeforeCreatingReceiptInventoryOrBatch() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
+5
View File
@@ -3,6 +3,11 @@
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
`db/return-single-sku-quantity-incremental.sql`:MySQL 8已有尾程库的单SKU按件数出库增量,显式 `USE baiyue_lastleg`。新增明细件数快照,流水箱差允许0或1,同箱索引改为普通索引,保留拣货ID+SKU唯一防重和库存守恒约束;可重复执行,无凭据,不修改业务行。使用外部明确数据库连接,失败停止,保留DDL与错误,修复后重跑;执行后核对字段、CHECK和索引。
`db/overseas-business-acceptance-incremental-20261009.sql`:本轮海外业务本地验收已有库增量汇总,显式指向 `baiyue_lastleg` 和 `baiyue_iam`。合并四流程结构、退货合箱/合托、换标排车、箱内清点及会话、单SKU部分出库;来源文件列在每段头部,无SOURCE依赖,可重复执行。包含正式平台管理员作业权限补齐,以及条件限定的历史待清点和关联单号快照修正;不改库存件数、不强制推进履约、不删除业务数据、不生成测试资源。数据库实例和凭据由外部连接明确提供,MySQL8工具执行整份文件;遇错立即停止并保留DDL与原错误,核对后重跑,不使用--force。执行记录和结构回查为报告,未自动在TEST或生产执行。
`db/local-four-flows-schema-incremental-20261009.sql`:本地退货换标、仓储转运、贴标出库及退货出库结构比对所得的 MySQL 8 定向增量。显式 `USE baiyue_lastleg`,补齐预约结束时间/版本、转运换标来源绑定、分配 SKU 快照及转运收货幂等命令表;字段按存在性判断,表使用 `CREATE TABLE IF NOT EXISTS`,可重复执行,不删除或回填业务数据。使用操作者显式选择的本地数据库连接,无内置凭据;完整执行输出 `LOCAL_FLOW_SCHEMA_READY`。失败保留已完成的 DDL 与原错误,核对后重跑;不自动清理业务记录,不是全库迁移或生产验收入口。
`db/return-tail-order-number-incremental-20261009.sql` 将已验证关联的退货尾程单号及退货单尾程号快照统一为退货单号。显式 `USE baiyue_lastleg`,可重复执行,只更新同客户、关联订单 ID 和来源退货号均一致的 `RETURN_RELABEL` 单据;不改变业务 ID、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。
+26 -3
View File
@@ -979,7 +979,7 @@ SET b.inbound_order_id=COALESCE(b.inbound_order_id,b.id),
b.inbound_code=COALESCE(NULLIF(b.inbound_code,''),NULLIF(l.inbound_code,''),CONCAT('LEGACY-',b.id))
WHERE b.inbound_order_id IS NULL OR b.inbound_code IS NULL OR b.inbound_code='';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NULL', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', 'SELECT 1'));
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1'));
PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt;
@@ -4439,11 +4439,11 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
AND total_before-total_after=quantity AND available_before-available_after=quantity
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
-- 原生退货换标排车:保留独立退货箱来源,不伪造普通库存仓位。
@@ -5031,3 +5031,26 @@ CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost_history (
UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version),
CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史';
-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。
SET @return_sku_quantity_ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'),
'SELECT 1',
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code'
);
PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl;
EXECUTE return_sku_quantity_stmt;
DEALLOCATE PREPARE return_sku_quantity_stmt;
-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。
SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE return_sku_check_stmt FROM @return_sku_check_ddl;
EXECUTE return_sku_check_stmt;
DEALLOCATE PREPARE return_sku_check_stmt;
-- 同箱余数允许再次出库,防重仍由拣货ID+SKU唯一索引保证。
SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', 'SELECT 1');
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE return_sku_index_stmt;
@@ -0,0 +1,956 @@
-- 2026-10-09 海外业务本地验收增量汇总;MySQL 8.0.16+;已有业务库升级。
-- 显式目标:baiyue_lastleg(业务结构)及 baiyue_iam(平台管理员正式作业权限)。
-- 独立文件,无 SOURCE 依赖;在对应实例的数据库工具中执行整个文件,出错停止,不使用 --force。
-- 包含四流程缺列/表、合箱合托及退货换标排车、清点会话、单SKU全部/部分出库。
-- 不强制完结单据,不修改库存数量,不生成验收单据,不删除业务数据。
-- 包含两项限定历史修正:符合条件的历史托盘批次标记待清点;关联一致的退货尾程单号对齐。
-- IAM只补正式平台管理员权限;数据库凭据从外部连接提供。
SET NAMES utf8mb4;
-- 来源:test-lastleg-relabel-return-transfer-columns.sql(复用结构定义,目标连接由本汇总脚本操作者指定)
-- 本地与 test 结构比对(2026-09-20):仅补 test 缺失的贴标/退货换标字段。
-- 本段只作用于已选择实例的尾程业务库。
-- MySQL 8;可重复执行。DDL 会隐式提交,遇错应停止执行,不使用 mysql --force。
USE `baiyue_lastleg`;
-- 贴标 PDA:一次 SKU 扫描可登记多件;旧记录保持默认数量 1。
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'),
'SELECT 1',
'ALTER TABLE `ll_relabel_pda_unit_fact` ADD COLUMN `quantity` INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER `reason`'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 退货签收和库存箱记录来源托盘。
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'),
'SELECT 1',
'ALTER TABLE `ll_return_receipt` ADD COLUMN `pallet_code` VARCHAR(128) NULL COMMENT ''本次签收绑定的托盘码'' AFTER `receipt_batch_no`'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'),
'SELECT 1',
'ALTER TABLE `ll_return_inventory_carton` ADD COLUMN `pallet_code` VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER `source_return_no`'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 退货单手动差异完结的审计字段。
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'),
'SELECT 1',
'ALTER TABLE `ll_return_order` ADD COLUMN `discrepancy_reason` VARCHAR(500) NULL COMMENT ''手动完结差异原因'' AFTER `remark`'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'),
'SELECT 1',
'ALTER TABLE `ll_return_order` ADD COLUMN `manually_completed_at` DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER `discrepancy_reason`'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 预期返回 5;低于 5 表示本次补齐不完整。
SELECT DATABASE() AS target_schema, COUNT(*) AS aligned_column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND (table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'
OR table_name = 'll_return_receipt' AND column_name = 'pallet_code'
OR table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'
OR table_name = 'll_return_order' AND column_name IN ('discrepancy_reason', 'manually_completed_at'));
-- 来源:return-receipt-box-count-incremental-20260929.sql(复用结构定义,目标连接由本汇总脚本操作者指定)
-- 2026-09-29 海外退货签收 SKU 箱数增量
-- MySQL 8.0+;目标数据库:baiyue_lastleg
-- 可重复执行,不修改既有业务数据。
SET NAMES utf8mb4;
USE baiyue_lastleg;
SET @ddl = IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt_item'
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_return_receipt_item'
AND column_name = 'box_count'
),
'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''本次 SKU 对应箱数'' AFTER received_quantity',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT DATABASE() AS target_database, 'll_return_receipt_item.box_count' AS updated_column;
-- 来源:return-outbound-box-nullable-20260929.sql(复用结构定义,目标连接由本汇总脚本操作者指定)
-- MySQL 8.0+; target database: baiyue_lastleg.
-- Re-runnable: allow pallet-less carton outbound records.
SET NAMES utf8mb4;
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item'
AND column_name='pallet_no' AND is_nullable='NO'
), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号,箱维度出库为空''', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT DATABASE() AS target_database, table_name, column_name, is_nullable
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='pallet_no';
-- 来源:local-four-flows-schema-incremental-20261009.sql
-- 本地四流程结构补齐;目标 baiyue_lastleg;MySQL 8;不删除或回填业务数据。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS ensure_local_flow_column;
DELIMITER $$
CREATE PROCEDURE ensure_local_flow_column(IN t VARCHAR(64), IN n VARCHAR(64), IN d TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=t AND column_name=n) THEN
SET @local_flow_ddl=CONCAT('ALTER TABLE `',t,'` ADD COLUMN `',n,'` ',d);
PREPARE local_flow_stmt FROM @local_flow_ddl; EXECUTE local_flow_stmt; DEALLOCATE PREPARE local_flow_stmt;
END IF;
END$$
DELIMITER ;
CALL ensure_local_flow_column('ll_inbound_fba_appointment','appointment_end_at','DATETIME(3) NULL COMMENT ''预约结束时间,未明确时为空''');
CALL ensure_local_flow_column('ll_inbound_fba_appointment','version','INT NOT NULL DEFAULT 0 COMMENT ''并发版本''');
CALL ensure_local_flow_column('ll_warehouse_transfer_relabel_binding','source_allocation_id','BIGINT NULL COMMENT ''已有库存转运的原始来源分配ID''');
CALL ensure_local_flow_column('ll_warehouse_transfer_relabel_binding','relabel_item_id','BIGINT NULL COMMENT ''本票关联换标明细ID''');
CALL ensure_local_flow_column('ll_warehouse_transfer_inventory_allocation','sku_code','VARCHAR(128) NULL COMMENT ''本次来源分配的实际SKU快照''');
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt_command (
id BIGINT NOT NULL COMMENT '收货命令主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
transfer_order_id BIGINT NOT NULL COMMENT '转运单标识',
source_inventory_position_id BIGINT NOT NULL COMMENT '本次收货来源库存位置',
idempotency_key VARCHAR(255) NOT NULL COMMENT '原始收货幂等键',
request_hash CHAR(64) NOT NULL COMMENT '完整原始收货请求摘要',
request_json JSON NOT NULL COMMENT '完整原始收货请求快照',
target_warehouse_id BIGINT NOT NULL COMMENT '实际目标海外收货仓',
target_pallet_id BIGINT NOT NULL COMMENT '目标实物托盘',
target_location_id BIGINT NOT NULL COMMENT '目标实际库位',
target_hash CHAR(64) NOT NULL COMMENT '共享目标资源绑定摘要',
target_json JSON NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照',
reservation_key VARCHAR(128) NOT NULL COMMENT '同转运同目标托盘共享资源占用键',
resource_lock_id BIGINT NULL COMMENT '已确认有效的海外资源锁标识',
status VARCHAR(32) NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成',
created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_wh_receipt_cmd_source (transfer_order_id, source_inventory_position_id),
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
DROP PROCEDURE IF EXISTS ensure_local_flow_column;
SELECT 'LOCAL_FLOW_SCHEMA_READY' AS migration_status, DATABASE() AS target_database;
-- 来源:return-packing-incremental-20261008.sql
-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。
SET NAMES utf8mb4;
-- IAM:平台退货合箱写操作需要正式库存管理权限;仅授予 PLATFORM_ADMIN。
USE baiyue_iam;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at)
VALUES (5107044, 0, 'PLATFORM', 'BUTTON', 5107040, 'lastleg:inventory:manage', '后台退货库存管理', NULL, 'POST', 744, 'ACTIVE', 1, 0, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.id=5107044
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0
AND NOT EXISTS(SELECT 1 FROM iam_role_permission existing WHERE existing.role_id=role.id AND existing.permission_id=permission.id);
USE baiyue_lastleg;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
CREATE TABLE IF NOT EXISTS ll_return_packing_plan (
id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细',
mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消',
remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no),
UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key),
KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at)
) ENGINE=InnoDB COMMENT='退货合箱计划';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'));
CREATE TABLE IF NOT EXISTS ll_return_packing_trace (
id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键',
operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照',
target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号',
sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数',
created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id),
KEY idx_return_packing_trace_order(customer_id,return_no,created_at),
KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code),
KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code),
CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯';
ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
-- Existing outbound tables need this field even when created before the fee snapshot baseline.
USE baiyue_lastleg;
SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照''');
PREPARE return_outbound_stmt FROM @return_outbound_ddl;
EXECUTE return_outbound_stmt;
DEALLOCATE PREPARE return_outbound_stmt;
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID',
carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码',
outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号',
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正',
total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量',
total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量',
available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量',
available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量',
cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数',
cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数',
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
AND total_before-total_after=quantity AND available_before-available_after=quantity
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
-- 原生退货换标排车:保留独立退货箱来源,不伪造普通库存仓位。
USE baiyue_lastleg;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='return_carton_id')=0,
'ALTER TABLE ll_dispatch_pda_pick ADD COLUMN return_carton_id BIGINT NULL COMMENT ''独立退货库存箱ID,与普通仓位互斥''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID';
CREATE TABLE IF NOT EXISTS ll_return_stock_receipt (
id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属',
receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号',
inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水',
exception_order_id BIGINT NOT NULL COMMENT '退回异常订单',
order_id BIGINT NOT NULL COMMENT '原尾程订单',
inbound_order_id BIGINT NOT NULL COMMENT '原入仓单',
source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号',
source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细',
source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置',
inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额',
inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置',
received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数',
received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间',
created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no),
UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id),
KEY idx_return_stock_receipt_order(customer_id,order_id),
KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id),
CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据';
ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥';
-- 真实退回凭据与尾程订单客户键使用同一排序规则,避免相关库存子查询冲突。
SET @return_customer_collation = (SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id');
SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''客户数据归属''');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN source_outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''原发运出库业务编号''');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_carton_id')=0,
'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_carton_id BIGINT NULL COMMENT ''原生退货箱来源ID,与实收来源互斥''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND constraint_name='chk_dispatch_pick_receipt_source')>0,
'ALTER TABLE ll_dispatch_pda_pick_order DROP CHECK chk_dispatch_pick_receipt_source', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
ALTER TABLE ll_dispatch_pda_pick_order ADD CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1);
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='signed_packages')=0,
'ALTER TABLE ll_delivery_order ADD COLUMN signed_packages INT NULL COMMENT ''保存签收资料时冻结的实际签收箱数,不含此前已退回量''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
CREATE TABLE IF NOT EXISTS event_delivery_attempt (
attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键',
service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务',
event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型',
entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键',
event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空',
target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务',
started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间',
finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间',
duration_ms BIGINT NULL COMMENT '尝试耗时毫秒',
outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED',
error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌',
next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间',
PRIMARY KEY (attempt_id),
KEY idx_delivery_attempt_entry (service_name,entry_id,started_at),
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
-- 补齐排车实体引用的真实退回来源字段;原生退货箱单独使用 return_carton_id。
USE baiyue_lastleg;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_item ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
-- 来源:return-relabel-truck-incremental-20261008.sql
-- 退货换标排车使用正式车次域,补齐旧本地库缺少的车队结构。
-- MySQL 8;显式 baiyue_lastleg;不写业务数据,可重复执行。
-- IAM:退货换标真实排车需要平台管理员的正式司机/车队权限;不授予其他角色。
USE baiyue_iam;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at)
VALUES
(5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-master-data:driver:list',
'platform:overseas-master-data:driver:create',
'platform:overseas-master-data:driver:edit',
'platform:overseas-master-data:driver:upload',
'platform:overseas-master-data:driver:review',
'platform:fleet:trip:list',
'platform:fleet:trip:create',
'platform:fleet:trip:assign',
'platform:fleet:trip:edit',
'platform:fleet:trip:dispatch-review',
'platform:fleet:trip:loading-review')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0
AND NOT EXISTS(SELECT 1 FROM iam_role_permission existing WHERE existing.role_id=role.id AND existing.permission_id=permission.id);
USE baiyue_lastleg;
USE baiyue_lastleg;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_pallet_spaces')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_pallet_spaces INT NULL COMMENT ''最大标准托位,未知时为空''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_load_kg')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_load_kg DECIMAL(12,3) NULL COMMENT ''最大载重,千克''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_volume_cbm')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_volume_cbm DECIMAL(12,3) NULL COMMENT ''可用容积,立方米''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
CREATE TABLE IF NOT EXISTS ll_fleet_driver (
id BIGINT NOT NULL COMMENT '内部主键',
driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号',
warehouse_id BIGINT NULL COMMENT '所属公共仓库引用',
warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照',
warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照',
vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述',
languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域',
medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质',
training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质',
insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质',
name VARCHAR(128) NOT NULL COMMENT '姓名',
phone VARCHAR(64) NOT NULL COMMENT '联系电话',
ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派',
region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回',
license_number VARCHAR(128) NULL COMMENT '驾驶证编号',
license_type VARCHAR(64) NULL COMMENT '驾驶证类型',
license_issued_on DATE NULL COMMENT '驾驶证签发日期',
license_expires_on DATE NULL COMMENT '驾驶证有效截止日',
license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区',
license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用',
remark VARCHAR(1000) NULL COMMENT '备注',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_no (driver_no),
KEY idx_fleet_driver_page (active,review_status,updated_at,id),
KEY idx_fleet_driver_expiry (license_expires_on),
CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')),
CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案';
CREATE TABLE IF NOT EXISTS ll_fleet_driver_history (
id BIGINT NOT NULL COMMENT '内部主键',
driver_id BIGINT NOT NULL COMMENT '司机内部引用',
record_version BIGINT NOT NULL COMMENT '操作后的档案版本',
action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作',
reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见',
license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用',
snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version),
KEY idx_fleet_driver_history_file (driver_id,license_file_id),
CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史';
CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record (
id BIGINT NOT NULL COMMENT '记录内部主键',
record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号',
vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键',
vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照',
record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常',
occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)',
details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照',
files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人',
updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_no (record_no),
UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key),
KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id),
CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录';
CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record_history (
id BIGINT NOT NULL COMMENT '历史内部主键',
record_id BIGINT NOT NULL COMMENT '运维记录主键',
record_version BIGINT NOT NULL COMMENT '对应业务版本',
action VARCHAR(32) NOT NULL COMMENT '操作类型',
reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)',
snapshot_json JSON NOT NULL COMMENT '当时完整业务快照',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_revision (record_id, record_version),
CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip (
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256',
status VARCHAR(16) NOT NULL COMMENT '车次状态',
warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用',
warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照',
warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照',
vehicle_id BIGINT NULL COMMENT '车辆档案引用',
vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照',
driver_id BIGINT NULL COMMENT '司机档案引用',
driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名',
driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话',
max_pallet_spaces INT NULL COMMENT '分配时托位容量快照',
max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克',
max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米',
planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC',
expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场',
departed_at DATETIME(3) NULL COMMENT '实际发车UTC',
completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC',
stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点',
planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量',
load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照',
loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核',
departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识',
departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要',
departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果',
completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照',
vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录',
driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录',
remark VARCHAR(1000) NULL COMMENT '备注',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_no (trip_no),
UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key),
KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id),
CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id),
CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')),
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer (
id BIGINT NOT NULL COMMENT '内部主键',
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer_command (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
CREATE TABLE IF NOT EXISTS ll_fleet_external_booking (
id BIGINT NOT NULL COMMENT '内部主键',
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属',
customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照',
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照',
quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存',
destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键',
destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称',
agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收',
idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键',
request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要',
confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键',
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
incoming_hash CHAR(64) NULL COMMENT '来货登记摘要',
receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键',
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
KEY idx_fleet_booking_offer(offer_id,status,id),
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
KEY idx_fleet_booking_trip(trip_id,status,id),
UNIQUE KEY uk_fleet_booking_incoming(incoming_no),
KEY idx_fleet_booking_dispatch(dispatch_no),
CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
CREATE TABLE IF NOT EXISTS ll_fleet_pool_order_link (
id BIGINT NOT NULL COMMENT '内部主键',
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围',
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
CREATE TABLE IF NOT EXISTS ll_fleet_resource_assignment (
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
trip_id BIGINT NOT NULL COMMENT '占用车次',
trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号',
PRIMARY KEY (resource_kind,resource_id),
KEY idx_fleet_resource_trip (trip_id),
CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_dispatch (
dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联',
dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号',
customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属',
trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用',
trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号',
package_count INT NOT NULL COMMENT '关联时待发运箱数快照',
bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人',
bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间',
review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核',
PRIMARY KEY (dispatch_id),
UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no),
KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id),
CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_history (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
record_version BIGINT NOT NULL COMMENT '操作后版本',
action VARCHAR(32) NOT NULL COMMENT '车次操作',
reason VARCHAR(1000) NULL COMMENT '操作原因',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version),
CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_operations_record (
id BIGINT NOT NULL COMMENT '出车实测历史主键',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',
details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',
reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',
request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',
occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),
UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_handover (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号',
request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本',
handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间',
snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照',
vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果',
driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果',
created_by VARCHAR(64) NOT NULL COMMENT '记录操作人',
created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_handover_no (handover_no),
UNIQUE KEY uk_fleet_handover_request (trip_id, request_key),
UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version),
KEY idx_fleet_handover_page (trip_id, id),
CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接';
CREATE TABLE IF NOT EXISTS ll_fleet_finance_source (
id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键',
record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键',
source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增',
record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本',
operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀',
event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID',
payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version),
UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key),
UNIQUE KEY uk_fleet_finance_source_event (event_id),
CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0)
) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定';
CREATE TABLE IF NOT EXISTS ll_fleet_quote (
id BIGINT PRIMARY KEY COMMENT '报价记录主键',
quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号',
kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售',
party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识',
party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照',
origin_city VARCHAR(128) NOT NULL COMMENT '发货城市',
destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码',
valid_from DATE NOT NULL COMMENT '报价生效日期',
valid_to DATE NOT NULL COMMENT '报价失效日期含当天',
status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态',
natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要',
details_json JSON NOT NULL COMMENT '完整报价明细快照',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC',
UNIQUE KEY uk_fleet_quote_no (quote_no),
UNIQUE KEY uk_fleet_quote_natural (natural_key),
UNIQUE KEY uk_fleet_quote_create (created_by,create_key),
KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价';
CREATE TABLE IF NOT EXISTS ll_fleet_quote_history (
id BIGINT PRIMARY KEY COMMENT '报价历史主键',
quote_id BIGINT NOT NULL COMMENT '报价记录标识',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
action VARCHAR(24) NOT NULL COMMENT '报价操作类型',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
snapshot_json JSON NOT NULL COMMENT '报价完整版本快照',
UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号',
category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER',
currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总',
amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款',
incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间',
remark VARCHAR(1000) NULL COMMENT '费用备注',
record_version BIGINT NOT NULL COMMENT '成本记录版本',
request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_no(cost_no),
UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key),
KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id),
CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0),
CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')),
CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost_history (
id BIGINT NOT NULL COMMENT '内部主键',
cost_id BIGINT NOT NULL COMMENT '成本记录内部引用',
record_version BIGINT NOT NULL COMMENT '成本更正后版本',
action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE',
reason VARCHAR(1000) NULL COMMENT '登记或更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version),
CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史';
-- 来源:return-carton-counting-incremental-20261009.sql
-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。
USE baiyue_lastleg;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
-- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。
-- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。
UPDATE ll_return_inventory_carton c
JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id
FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET'
GROUP BY a.source_receipt_id
HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0
) pending) p ON p.source_receipt_id=c.source_receipt_id
SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL;
-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。
CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识',
warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识',
receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL',
started_at DATETIME(3) NULL COMMENT '本次开始清点时间',
created_at DATETIME(3) NOT NULL COMMENT '会话创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间',
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
-- 来源:return-tail-order-number-incremental-20261009.sql
-- 退货业务统一单号;只修改已验证关联的单号快照,保留订单ID、库存、清点、换标及履约状态。
USE baiyue_lastleg;
UPDATE ll_order o
JOIN ll_return_order r ON r.lastleg_order_id=o.id AND r.customer_id=o.customer_id
SET o.order_no=r.return_no, r.lastleg_order_no=r.return_no
WHERE o.order_type='RETURN_RELABEL' AND o.source_order_no=r.return_no
AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no));
-- 应返回零行;非零表示来源关系异常,须核实,脚本不猜测或改绑。
SELECT r.return_no, o.order_no, r.lastleg_order_no
FROM ll_return_order r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id
WHERE o.order_type='RETURN_RELABEL'
AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no));
-- 来源:return-single-sku-quantity-incremental.sql
-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。
USE baiyue_lastleg;
SET @return_sku_quantity_ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'),
'SELECT 1',
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code'
);
PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl;
EXECUTE return_sku_quantity_stmt;
DEALLOCATE PREPARE return_sku_quantity_stmt;
-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。
SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE return_sku_check_stmt FROM @return_sku_check_ddl;
EXECUTE return_sku_check_stmt;
DEALLOCATE PREPARE return_sku_check_stmt;
-- 同箱余数允许再次出库,防重仍由拣货ID+SKU唯一索引保证。
SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', 'SELECT 1');
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE return_sku_index_stmt;
USE baiyue_lastleg;
SELECT 'OVERSEAS_INCREMENTAL_COMPLETE' AS migration_status,DATABASE() AS business_database;
@@ -33,7 +33,8 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code
'platform:fleet:trip:dispatch-review',
'platform:fleet:trip:loading-review')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0
AND NOT EXISTS(SELECT 1 FROM iam_role_permission existing WHERE existing.role_id=role.id AND existing.permission_id=permission.id);
USE baiyue_lastleg;
USE baiyue_lastleg;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_pallet_spaces')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_pallet_spaces INT NULL COMMENT ''最大标准托位,未知时为空''', 'SELECT 1');
@@ -0,0 +1,22 @@
-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。
USE baiyue_lastleg;
SET @return_sku_quantity_ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'),
'SELECT 1',
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code'
);
PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl;
EXECUTE return_sku_quantity_stmt;
DEALLOCATE PREPARE return_sku_quantity_stmt;
-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。
SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE return_sku_check_stmt FROM @return_sku_check_ddl;
EXECUTE return_sku_check_stmt;
DEALLOCATE PREPARE return_sku_check_stmt;
-- 同箱余数允许再次出库,防重仍由拣货ID+SKU唯一索引保证。
SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', 'SELECT 1');
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE return_sku_index_stmt;