From 12a5093b419e76936596563ee3a395d57e70fde9 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Sat, 10 Oct 2026 13:48:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7=EF=BC=8C?= =?UTF-8?q?=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 6 +- .../delivery/dto/DeliveryRequests.java | 16 +- .../delivery/dto/DeliveryResponses.java | 4 +- .../dto/WholePieceQuantityDeserializer.java | 16 + .../delivery/entity/OutboundPalletItem.java | 2 + .../service/DeliveryTransferService.java | 37 +- .../relabel/pda/dto/PdaRelabelResponses.java | 3 +- .../pda/service/PdaRelabelService.java | 27 +- .../service/RelabelApplicationService.java | 10 +- .../mapper/ReturnInventoryBalanceMapper.java | 6 + .../service/ReturnInventoryService.java | 43 +- .../service/ReturnReceiptService.java | 11 +- .../src/main/resources/db/lastleg.sql | 29 +- .../openapi/overseas-business-v1.yaml | 24 + .../openapi/overseas-pda-operations-v1.yaml | 3 + .../service/DeliveryTransferServiceTest.java | 32 + .../service/ReturnOutboundClosureTest.java | 13 +- .../pda/service/PdaRelabelServiceTest.java | 18 + .../RelabelApplicationServiceTest.java | 15 + .../service/ReturnInventoryServiceTest.java | 40 +- .../service/ReturnReceiptServiceTest.java | 24 + scripts/README.md | 5 + scripts/db/incremental.sql | 29 +- ...siness-acceptance-incremental-20261009.sql | 956 ++++++++++++++++++ ...urn-relabel-truck-incremental-20261008.sql | 3 +- ...return-single-sku-quantity-incremental.sql | 22 + 26 files changed, 1347 insertions(+), 47 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/WholePieceQuantityDeserializer.java create mode 100644 scripts/db/overseas-business-acceptance-incremental-20261009.sql create mode 100644 scripts/db/return-single-sku-quantity-incremental.sql diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7694a8ef2..a9f0af766 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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 ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 49bb33ff4..9ec4b7071 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -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 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 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 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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index d192e88fc..a30ed1d3e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -92,7 +92,7 @@ public final class DeliveryResponses { String skuCode, String inboundCode, long availablePackages, long pickedPackages) {} public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode, - int packageCount, int pickedPackageCount, String status, - List positions) {} + int packageCount, int pickedPackageCount, String status, + List positions, Long returnSkuQuantity) {} public record PdaTaskDetail(PdaTask task, List items) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/WholePieceQuantityDeserializer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/WholePieceQuantityDeserializer.java new file mode 100644 index 000000000..b19e56cad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/WholePieceQuantityDeserializer.java @@ -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 { + @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(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java index 6c69084c3..3345ff9d3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index bd8b9d91f..56aea78ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -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 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 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 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index fc19f9bce..02afc5ff0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index 55f50f00d..6e18f077a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -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 candidates(String operatorKey, long overseasWarehouseId) { - return applications.selectList(new LambdaQueryWrapper() + List rows = applications.selectList(new LambdaQueryWrapper() .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 orderIds = rows.stream().map(RelabelApplication::getLastlegOrderId) + .filter(Objects::nonNull).distinct().toList(); + Map linkedOrders = orderIds.isEmpty() ? Map.of() + : tailOrders.selectBatchIds(orderIds).stream().collect(Collectors.toMap( + com.baiyue.lastleg.entity.LastlegOrder::getId, order -> order)); + Map 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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 3be6ada40..4ad45ee05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -850,7 +850,7 @@ public class RelabelApplicationService implements WalletResultHandler { private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId, List 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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java index 5ba059f93..7dae31f47 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -12,6 +12,12 @@ import java.util.List; @Mapper public interface ReturnInventoryBalanceMapper extends BaseMapper { + @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 " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index e5841fed6..e20cfe196 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -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 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++; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index ae6c0c841..55bd35fd0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -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 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 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 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())); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 76cacb2c6..4af712887 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 74a9e31fe..8396cd8d5 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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: 创建一件代发出库单 diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 6610f9397..b0dad9ad6 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -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 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 8a506392e..c75ff3859 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -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 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index 25b935c9f..992de081d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -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)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index d6a50dfbb..e325d8e1d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index f985d5af3..f2260afd3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -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; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index a872ca7bc..f5485a34a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index d01ba1164..78a863c14 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -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); diff --git a/scripts/README.md b/scripts/README.md index 4ea96c75f..5b084247c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。 diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 8802ee090..3215f965f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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; diff --git a/scripts/db/overseas-business-acceptance-incremental-20261009.sql b/scripts/db/overseas-business-acceptance-incremental-20261009.sql new file mode 100644 index 000000000..87343eb81 --- /dev/null +++ b/scripts/db/overseas-business-acceptance-incremental-20261009.sql @@ -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; diff --git a/scripts/db/return-relabel-truck-incremental-20261008.sql b/scripts/db/return-relabel-truck-incremental-20261008.sql index d713b6bfc..dabe0c23f 100644 --- a/scripts/db/return-relabel-truck-incremental-20261008.sql +++ b/scripts/db/return-relabel-truck-incremental-20261008.sql @@ -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'); diff --git a/scripts/db/return-single-sku-quantity-incremental.sql b/scripts/db/return-single-sku-quantity-incremental.sql new file mode 100644 index 000000000..1ddc96a0e --- /dev/null +++ b/scripts/db/return-single-sku-quantity-incremental.sql @@ -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;