fix(lastleg): complete stock transfer through linked relabel

This commit is contained in:
2026-10-04 20:21:16 +08:00
parent 7319fba35a
commit 76f02d8d45
17 changed files with 740 additions and 31 deletions
+2 -2
View File
@@ -74,10 +74,10 @@ IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授
| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 |
| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql`、`V5__event_delivery_retry_audit.sql`、`V6__event_delivery_attempt.sql`、`V7__settle_partial_loaded_cargo.sql` | 收敛一件代发托盘码非空定义;补齐测量/报价、报关、船期和事件重试审计;V7 仅更新装柜明细保护触发器,允许普通箱件按冻结分配和实际拣货精确收敛件数、重量、体积和托数,保留身份及已完成冻结保护,不回填原事件或库存 |
| CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 |
| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 |
| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql`、`V6__event_delivery_retry_audit.sql`、`V7__event_delivery_attempt.sql`、`V8__bind_stock_transfer_relabel_sources.sql` | 补齐尾程字段、退货/换标/废弃结构与事件投递审计;V8 保存已有库存转运的实际 SKU、原分配和换标明细关联,允许同位置历史与当前分配共存,保留活动占用唯一约束,不回填旧业务或库存 |
| OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 |
上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。
候选编写和离线检查不证明环境已执行;测试环境执行事实只维护在 `../../../docs/测试环境单机部署与配置契约.md`,生产执行须另有明确授权。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=8`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。
迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}/<schema>/`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。
@@ -0,0 +1,14 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: bind_stock_transfer_relabel_sources
-- Preconditions: existing transfer allocation/binding tables and original position unique index.
-- Postconditions: exact source SKU and relabel links; active ownership remains unique, history is retained.
-- No business-row backfill, relabel completion fabrication, or inventory mutation.
ALTER TABLE ll_warehouse_transfer_inventory_allocation
ADD COLUMN sku_code VARCHAR(128) NULL COMMENT '本次来源分配的实际SKU快照' AFTER package_count,
DROP INDEX uk_ll_wh_transfer_allocation_position,
ADD KEY idx_ll_wh_transfer_allocation_position (transfer_order_id, inventory_position_id);
ALTER TABLE ll_warehouse_transfer_relabel_binding
ADD COLUMN source_allocation_id BIGINT NULL COMMENT '已有库存转运的原始来源分配ID' AFTER remark,
ADD COLUMN relabel_item_id BIGINT NULL COMMENT '本票关联换标明细ID' AFTER source_allocation_id;
@@ -3025,6 +3025,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation (
pallet_id bigint NOT NULL COMMENT '来源托盘ID',
pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘号快照',
package_count int NOT NULL COMMENT '分配箱数',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次来源分配的实际SKU快照',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
active_position_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的仓位排他键',
allocated_at datetime(3) NOT NULL COMMENT '分配时间',
@@ -3033,7 +3034,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation (
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id),
KEY idx_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id),
UNIQUE KEY uk_ll_wh_transfer_active_position (customer_id,active_position_key),
KEY idx_ll_wh_transfer_allocation_list (customer_id,transfer_order_id,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录';
@@ -3214,6 +3215,8 @@ CREATE TABLE ll_warehouse_transfer_relabel_binding (
fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新FBA箱唛',
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA目的仓',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
source_allocation_id bigint DEFAULT NULL COMMENT '已有库存转运的原始来源分配ID',
relabel_item_id bigint DEFAULT NULL COMMENT '本票关联换标明细ID',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_transfer_relabel_mark (transfer_order_id,customer_carton_mark),
@@ -36,6 +36,8 @@ import java.util.Objects;
@Service
public class InventoryCommandService {
private record TransferMoveSnapshot(InventoryCommandRequests.Move move, String sku) {}
private record TransferAdjustmentSnapshot(InventoryCommandRequests.Adjustment adjustment, String sku) {}
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryLedgerMapper ledgers;
@@ -237,7 +239,35 @@ public class InventoryCommandService {
@Transactional
public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> {
return ship(customerId, operatorId, request, null);
}
@Transactional
public InventoryCommandResponse allocateTransferSnapshot(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request, skuSnapshot);
}
@Transactional
public InventoryCommandResponse pickTransferSnapshot(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request, skuSnapshot);
}
/** Exact source snapshot comes from the transfer's locked allocation, not a mixed order aggregate. */
@Transactional
public InventoryCommandResponse shipTransferSnapshot(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return ship(customerId, operatorId, request, skuSnapshot);
}
private InventoryCommandResponse ship(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
return execute(customerId, operatorId, "SHIP", request.idempotencyKey(),
skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
@@ -255,7 +285,8 @@ public class InventoryCommandService {
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
ledger(customerId, operatorId, operation.getId(), balance, position,
skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -471,10 +502,23 @@ public class InventoryCommandService {
@Transactional
public InventoryCommandResponse adjust(String customerId, String operatorId,
InventoryCommandRequests.Adjustment request) {
return adjust(customerId, operatorId, request, null);
}
@Transactional
public InventoryCommandResponse adjustTransferRelabel(String customerId, String operatorId,
InventoryCommandRequests.Adjustment request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return adjust(customerId, operatorId, request, skuSnapshot);
}
private InventoryCommandResponse adjust(String customerId, String operatorId,
InventoryCommandRequests.Adjustment request, String skuSnapshot) {
if (request.packageDelta() == 0) {
throw badRequest("Adjustment package delta cannot be zero");
}
return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> {
return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(),
skuSnapshot == null ? request : new TransferAdjustmentSnapshot(request, skuSnapshot), operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
@@ -492,7 +536,8 @@ public class InventoryCommandService {
balance.setAvailablePackages(balanceAfter);
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "ADJUST",
ledger(customerId, operatorId, operation.getId(), balance, position,
skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, "ADJUST",
"AVAILABLE", "AVAILABLE", before, request.packageDelta(), after,
request.businessOrderNo(), null, null, null, request.idempotencyKey(),
request.reason() + remarkSuffix(request.remark()));
@@ -507,7 +552,25 @@ public class InventoryCommandService {
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request) {
return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
return move(customerId, operatorId, operationType, from, to, request, null);
}
@Transactional
public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot);
}
private static void requireTransferSku(String key, String skuSnapshot) {
if (key == null || !key.startsWith("WAREHOUSE_TRANSFER:") || skuSnapshot == null || skuSnapshot.isBlank()
|| "MIXED".equals(skuSnapshot)) throw badRequest("Exact warehouse transfer SKU snapshot is required");
}
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot) {
return execute(customerId, operatorId, operationType, request.idempotencyKey(),
skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
@@ -524,7 +587,8 @@ public class InventoryCommandService {
packages(balance, to, packages(balance, to) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), operationType,
ledger(customerId, operatorId, operation.getId(), balance, position,
skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, operationType,
from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -102,6 +102,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private OutboundPdaTaskMapper outboundTasks;
@Resource private OverseasFeeSourceService feeSources;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion;
@Transactional(readOnly = true)
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
@@ -812,6 +813,10 @@ public class RelabelApplicationService implements WalletResultHandler {
throw conflict("Upload the shipping label before confirming relabel completion");
}
List<RelabelItem> relabelItems = itemList(application.getId());
WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null
: warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
boolean existingStockTransfer = linkedTransfer != null
&& StringUtils.hasText(linkedTransfer.getAllocationIdempotencyKey());
boolean requiresOutbound = relabelItems.stream()
.anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
@@ -827,14 +832,12 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setFulfillmentStatus(requiresOutbound
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
: "NOT_REQUIRED");
if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) {
if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
application.setUpdatedAt(now);
applications.updateById(application);
WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null
: warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
for (LastlegOrder order : tailOrders(application)) {
if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue;
// 仓储转运尾程单由下方按转运目的仓一次性写入精确状态,避免先落入通用状态。
@@ -910,13 +913,18 @@ public class RelabelApplicationService implements WalletResultHandler {
&& StringUtils.hasText(transfer.getPrivateDestinationAddress());
String transferStatus = transferHasDestination
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
if (existingStockTransfer) {
transferRelabelCompletion.complete(transfer, application, relabelItems, operatorId, now);
transferStatus = "ALLOCATED";
}
transfer.setStatus(transferStatus);
transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream()
.allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode()));
application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination
application.setFulfillmentStatus(existingStockTransfer ? "WAIT_TRANSFER_OUTBOUND"
: "READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination
? "WAIT_PRE_DISPATCH" : transferStatus);
applications.updateById(application);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
@@ -25,7 +25,7 @@ public final class WarehouseTransferResponses {
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status,
Long destinationInventoryPositionId) {}
Long destinationInventoryPositionId, String skuCode) {}
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import lombok.Data;
import java.time.LocalDateTime;
@@ -20,7 +22,9 @@ public class WarehouseTransferInventoryAllocation {
private Long palletId;
private String palletNo;
private Integer packageCount;
private String skuCode;
private String status;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activePositionKey;
private LocalDateTime allocatedAt;
private LocalDateTime shippedAt;
@@ -21,5 +21,7 @@ public class WarehouseTransferRelabelBinding {
private String fbaCartonMark;
private String destinationWarehouseCode;
private String remark;
private Long sourceAllocationId;
private Long relabelItemId;
private LocalDateTime createdAt;
}
@@ -11,7 +11,7 @@ import java.util.List;
@Mapper
public interface WarehouseTransferInventoryAllocationMapper extends BaseMapper<WarehouseTransferInventoryAllocation> {
@Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " +
"AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE")
"AND transfer_order_id=#{transferOrderId} AND status<>'RELABELLED' ORDER BY id FOR UPDATE")
List<WarehouseTransferInventoryAllocation> lockTransfer(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@@ -0,0 +1,132 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
/** Runs in customer confirmation's transaction; preserves original selections and follows actual pallet moves. */
@Service
public class WarehouseTransferRelabelCompletionService {
@Resource private WarehouseTransferInventoryAllocationMapper allocations;
@Resource private WarehouseTransferRelabelBindingMapper bindings;
@Resource private InventoryCommandMapper inventoryRows;
@Resource private InventoryCommandService inventory;
private record Target(InventoryBalance balance, InventoryPosition position, int packages, String sku) {}
public void complete(WarehouseTransferOrder transfer, RelabelApplication application,
List<RelabelItem> items, String operator, LocalDateTime now) {
String customer = transfer.getCustomerId();
if (!Objects.equals(application.getCustomerId(), customer)
|| !Objects.equals(application.getId(), transfer.getRelabelApplicationId())
|| !Objects.equals(application.getLastlegOrderId(), transfer.getLastlegOrderId())
|| application.getCustomerConfirmedAt() == null) throw conflict("本票换标确认归属不一致");
List<WarehouseTransferInventoryAllocation> original = allocations.lockTransfer(customer, transfer.getId());
Map<Long, WarehouseTransferInventoryAllocation> byId = new HashMap<>();
Map<Long, Integer> remaining = new HashMap<>();
for (var row : original) {
if (!"RESERVED".equals(row.getStatus())) throw conflict("转运预留已被其他操作处理");
byId.put(row.getId(), row); remaining.put(row.getId(), row.getPackageCount());
}
Map<Long, RelabelItem> itemById = new HashMap<>(); items.forEach(item -> itemById.put(item.getId(), item));
var sources = bindings.listOrder(customer, transfer.getId());
if (sources.isEmpty()) throw conflict("本票换标来源绑定缺失");
Map<Long, Target> targets = new LinkedHashMap<>();
Set<Long> seen = new HashSet<>();
for (var source : sources) {
var allocation = byId.get(source.getSourceAllocationId());
RelabelItem item = itemById.get(source.getRelabelItemId());
if (allocation == null || item == null || !seen.add(item.getId())
|| !Objects.equals(item.getCustomerId(), customer)
|| !Objects.equals(item.getApplicationId(), application.getId())
|| !Objects.equals(item.getSourceInboundOrderId(), allocation.getInboundOrderId())
|| !Objects.equals(item.getCartonMark(), source.getCustomerCartonMark())
|| !Objects.equals(item.getSkuCode(), source.getOriginalSku())
|| !Objects.equals(item.getSkuCode(), allocation.getSkuCode()))
throw conflict("本票换标箱未绑定到原转运预留");
int count = Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0);
int left = remaining.get(allocation.getId()) - count;
if (count <= 0 || left < 0) throw conflict("本票换标数量超过预留来源");
remaining.put(allocation.getId(), left);
InventoryPosition current = inventoryRows.lockPosition(customer, item.getSourceInventoryPositionId());
InventoryBalance balance = current == null ? null : inventoryRows.lockBalance(customer, current.getBalanceId());
if (balance == null || !Objects.equals(balance.getInboundOrderId(), allocation.getInboundOrderId())
|| !Objects.equals(balance.getOverseasWarehouseId(), transfer.getOperatingWarehouseId())
|| current.getHoldPackages() == null || current.getHoldPackages() < count)
throw conflict("本票换标实物位置或冻结数量不一致");
if (allocations.countActiveDispatchLock(customer, current.getPalletId()) > 0)
throw conflict("本票换标托盘已被排车占用");
String key = "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL:" + item.getId();
inventory.releaseTransferRelabel(customer, operator, new InventoryCommandRequests.Move(current.getId(), count,
transfer.getTransferNo(), null, key, key + ":RELEASE", "本票换标客户确认"), item.getSkuCode());
String outputSku = source.getNewSku() == null ? item.getSkuCode() : source.getNewSku();
if (!outputSku.equals(item.getSkuCode())) {
inventory.adjustTransferRelabel(customer, operator, new InventoryCommandRequests.Adjustment(current.getId(), -count,
"WAREHOUSE_TRANSFER_RELABEL", transfer.getTransferNo(), key + ":SOURCE", "本票换标旧SKU转出"), item.getSkuCode());
var output = inventory.stockIn(customer, operator, new InventoryCommandRequests.StockIn(customer,
balance.getCustomerName(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseCode(),
balance.getOverseasWarehouseName(), transfer.getDestinationWarehouseId(),
source.getDestinationWarehouseCode() == null ? balance.getDestinationWarehouseCode() : source.getDestinationWarehouseCode(),
source.getDestinationWarehouseCode() == null ? balance.getDestinationWarehouseName() : source.getDestinationWarehouseCode(),
balance.getInboundOrderId(), balance.getInboundOrderNo(), balance.getInboundCode(), outputSku,
balance.getProductNameZh(), balance.getProductNameEn(), current.getPalletId(), current.getPalletNo(),
key + ":OUTPUT", current.getWarehouseLocationId(), current.getWarehouseLocationCode(), count,
transfer.getTransferNo(), key, key + ":TARGET", "本票换标新SKU转入"));
current = inventoryRows.lockPosition(customer, output.positionId());
balance = inventoryRows.lockBalance(customer, output.balanceId());
if (current == null || balance == null || !Objects.equals(current.getBalanceId(), balance.getId())
|| !Objects.equals(balance.getInboundOrderId(), allocation.getInboundOrderId()))
throw conflict("换标新SKU库存事实不一致");
}
addTarget(targets, balance, current, count, outputSku);
}
if (seen.size() != items.size()) throw conflict("换标明细存在未绑定本票的来源");
for (var row : original) {
int count = remaining.get(row.getId());
if (count > 0) {
var current = inventoryRows.lockPosition(customer, row.getInventoryPositionId());
var balance = inventoryRows.lockBalance(customer, row.getInventoryBalanceId());
if (current == null || balance == null || current.getHoldPackages() == null || current.getHoldPackages() < count)
throw conflict("未换标的转运余量不可用");
String key = "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":UNLABELLED:" + row.getId();
inventory.releaseTransferRelabel(customer, operator, new InventoryCommandRequests.Move(current.getId(), count,
transfer.getTransferNo(), null, key, key + ":RELEASE", "本票未换标余量确认"), row.getSkuCode());
addTarget(targets, balance, current, count, row.getSkuCode());
}
row.setStatus("RELABELLED"); row.setActivePositionKey(null); row.setUpdatedAt(now); allocations.updateById(row);
}
if (targets.values().stream().mapToInt(Target::packages).sum() != transfer.getPackageCount())
throw conflict("换标后转运总箱数不守恒");
for (Target target : targets.values()) {
var row = new WarehouseTransferInventoryAllocation(); row.setId(IdWorker.getId());
row.setTransferOrderId(transfer.getId()); row.setCustomerId(customer);
row.setInventoryPositionId(target.position().getId()); row.setInventoryBalanceId(target.balance().getId());
row.setInboundOrderId(target.balance().getInboundOrderId()); row.setInboundCode(target.balance().getInboundCode());
row.setPalletId(target.position().getPalletId()); row.setPalletNo(target.position().getPalletNo());
row.setPackageCount(target.packages()); row.setStatus("RESERVED");
row.setSkuCode(target.sku());
row.setActivePositionKey(customer + ":" + target.position().getId()); row.setAllocatedAt(now);
row.setCreatedAt(now); row.setUpdatedAt(now); allocations.insert(row);
}
}
private static void addTarget(Map<Long, Target> targets, InventoryBalance balance, InventoryPosition position, int count, String sku) {
Target previous = targets.get(position.getId());
if (previous != null && !Objects.equals(previous.sku(), sku)) throw conflict("不同SKU换标产出必须保留独立位置批次");
targets.put(position.getId(), new Target(balance, position, count + (previous == null ? 0 : previous.packages()), sku));
}
private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); }
}
@@ -562,12 +562,13 @@ public class WarehouseTransferService {
new LambdaQueryWrapper<WarehouseTransferInventoryAllocation>()
.eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId)
.eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId())
.ne(WarehouseTransferInventoryAllocation::getStatus, "RELABELLED")
.orderByAsc(WarehouseTransferInventoryAllocation::getId))
.stream().map(value -> {
WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId());
return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(),
value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(),
value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId());
value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId(), value.getSkuCode());
}).toList();
List<WarehouseTransferResponses.MeasurementPhoto> photos = measurementPhotos.listSubmitted(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(),
@@ -1036,7 +1037,7 @@ public class WarehouseTransferService {
if (transfer == null) throw notFound("Warehouse transfer order not found");
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) {
if (key.equals(transfer.getAllocationIdempotencyKey())) {
if (!requestHash.equals(transfer.getAllocationRequestHash())) {
throw conflict("Allocation idempotency key was reused with different inventory positions");
}
@@ -1065,6 +1066,7 @@ public class WarehouseTransferService {
if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders");
LocalDateTime now = LocalDateTime.now();
Map<String, List<ConfirmedStockSource>> selectedRelabelSources = new java.util.LinkedHashMap<>();
for (WarehouseTransferRequests.Allocation source : requested) {
InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId());
if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId());
@@ -1088,6 +1090,7 @@ public class WarehouseTransferService {
allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId());
allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId());
allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount());
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) allocation.setSkuCode(balance.getSkuCode());
allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId());
allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now);
try {
@@ -1095,12 +1098,42 @@ public class WarehouseTransferService {
} catch (DuplicateKeyException exception) {
throw conflict("Inventory position is already reserved by another warehouse transfer");
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel()) && "MIXED".equals(balance.getSkuCode()))
throw conflict("聚合库存不能证明本票旧SKU,请选择有明确SKU来源的库存");
selectedRelabelSources.computeIfAbsent(balance.getSkuCode(), ignored -> new ArrayList<>())
.add(new ConfirmedStockSource(balance.getId(), position.getId(), source.packageCount(),
balance.getInboundOrderId(), allocation.getId()));
}
transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key);
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
List<WarehouseTransferItem> cargo = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()));
if (relabelBindings.listOrder(customerId, transfer.getId()).isEmpty())
throw conflict("Required transfer relabel instructions are missing");
RelabelResponses.Detail linked = createLinkedRelabel(customerId, operatorId, transfer,
cargo.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())),
selectedRelabelSources);
transfer.setRelabelApplicationId(linked.application().id());
transfer.setRelabelNo(linked.application().relabelNo());
transfer.setStatus(WAIT_RELABEL);
for (var entry : selectedRelabelSources.entrySet()) {
for (ConfirmedStockSource source : entry.getValue()) {
if (source.remainingPackages <= 0) continue;
String holdKey = "WAREHOUSE_TRANSFER:" + transferNo + ":UNLABELLED:" + source.allocationId;
inventory.hold(customerId, operatorId, new InventoryCommandRequests.Move(source.positionId,
source.remainingPackages, transferNo, null, holdKey, holdKey + ":HOLD", "本票转运未换标余量预留"));
}
}
} else transfer.setStatus(ALLOCATED);
transfer.setAllocationIdempotencyKey(key);
transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus("WAIT_OUTBOUND");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:ALLOCATED"); order.setUpdatedAt(now);
String before = order.getCurrentStatus(); order.setCurrentStatus(
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? INBOUND_COMPLETED : "WAIT_OUTBOUND");
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW");
}
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus()); order.setUpdatedAt(now);
orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer inventory allocated", now);
@@ -1124,6 +1157,14 @@ public class WarehouseTransferService {
if (!ALLOCATED.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer inventory must be allocated before outbound");
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
RelabelApplication linked = StringUtils.hasText(transfer.getRelabelNo())
? relabelApplications.lockNo(customerId, transfer.getRelabelNo()) : null;
if (linked == null || !Objects.equals(linked.getId(), transfer.getRelabelApplicationId())
|| !Objects.equals(linked.getLastlegOrderId(), transfer.getLastlegOrderId())
|| linked.getCustomerConfirmedAt() == null || transfer.getRelabelCompletedAt() == null)
throw conflict("本转运单换标尚未完成客户确认,不可出库");
}
if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) {
throw conflict("A destination warehouse is required before outbound");
}
@@ -1139,13 +1180,21 @@ public class WarehouseTransferService {
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark()));
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(
if (Boolean.TRUE.equals(transfer.getNeedRelabel()))
inventory.allocateTransferSnapshot(customerId, operatorId, move, allocation.getSkuCode());
else inventory.allocate(customerId, operatorId, move);
var pick = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark())));
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()));
if (Boolean.TRUE.equals(transfer.getNeedRelabel()))
inventory.pickTransferSnapshot(customerId, operatorId, pick, allocation.getSkuCode());
else inventory.pick(customerId, operatorId, pick);
var ship = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())));
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()));
if (Boolean.TRUE.equals(transfer.getNeedRelabel()))
inventory.shipTransferSnapshot(customerId, operatorId, ship, allocation.getSkuCode());
else inventory.ship(customerId, operatorId, ship);
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation);
}
@@ -1158,6 +1207,10 @@ public class WarehouseTransferService {
orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer left source warehouse", now);
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
RelabelApplication linked = relabelApplications.lockNo(customerId, transfer.getRelabelNo());
linked.setFulfillmentStatus("OUTBOUNDED"); linked.setUpdatedAt(now); relabelApplications.updateById(linked);
}
return detail(customerId, transferNo);
}
@@ -1200,7 +1253,8 @@ public class WarehouseTransferService {
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(),
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(),
sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(),
request.destinationPalletId(), request.destinationPalletNo().trim(),
trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(),
trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo,
@@ -1225,6 +1279,13 @@ public class WarehouseTransferService {
order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer inventory received at destination", now);
if (complete && Boolean.TRUE.equals(transfer.getNeedRelabel())) {
RelabelApplication linked = relabelApplications.lockNo(customerId, transfer.getRelabelNo());
if (linked == null || !Objects.equals(linked.getId(), transfer.getRelabelApplicationId()))
throw conflict("本票换标完成归属不一致");
linked.setStatus("COMPLETED"); linked.setFulfillmentStatus("COMPLETED");
linked.setUpdatedAt(now); relabelApplications.updateById(linked);
}
return detail(customerId, transferNo);
}
@@ -1616,7 +1677,13 @@ public class WarehouseTransferService {
List<RelabelRequests.SkuItem> skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType())
? List.of(new RelabelRequests.SkuItem(effectiveSku, binding.getNewSku(),
binding.getQuantity(), binding.getRemark())) : List.of();
items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(),
if (stockSource.allocationId != null) {
binding.setOriginalSku(effectiveSku);
binding.setSourceAllocationId(stockSource.allocationId);
relabelBindings.updateById(binding);
}
items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId,
stockSource.inboundId == null ? transfer.getInboundOrderId() : stockSource.inboundId,
effectiveSku, null, null, null, source.getProductNameZh(), 1, 1,
StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(),
transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(),
@@ -1628,8 +1695,16 @@ public class WarehouseTransferService {
transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单",
"WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items,
firstText(transfer.getRelabelDeliveryMethod(), "TRUCK"), false);
return relabelService.createForExistingTailOrder(customerId, operatorId,
RelabelResponses.Detail created = relabelService.createForExistingTailOrder(customerId, operatorId,
transfer.getLastlegOrderId(), request);
for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) {
if (binding.getSourceAllocationId() == null) continue;
RelabelResponses.Item item = created.items().stream()
.filter(value -> Objects.equals(value.cartonMark(), binding.getCustomerCartonMark()))
.findFirst().orElseThrow(() -> conflict("Created relabel did not preserve the transfer carton"));
binding.setRelabelItemId(item.id()); relabelBindings.updateById(binding);
}
return created;
}
private static ConfirmedStockSource nextStockSource(List<ConfirmedStockSource> sources) {
@@ -1647,12 +1722,18 @@ public class WarehouseTransferService {
private final Long balanceId;
private final Long positionId;
private int remainingPackages;
private Long inboundId;
private Long allocationId;
private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages) {
this.balanceId = balanceId;
this.positionId = positionId;
this.remainingPackages = remainingPackages;
}
private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages,
Long inboundId, Long allocationId) {
this(balanceId, positionId, remainingPackages); this.inboundId = inboundId; this.allocationId = allocationId;
}
}
private InboundPlan ensureTransferInboundPlan(String customerId, WarehouseTransferOrder transfer,
@@ -2136,7 +2136,10 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU',
new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数',
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
remark VARCHAR(1000) NULL COMMENT '备注',
source_allocation_id BIGINT NULL COMMENT '已有库存转运的原始来源分配ID',
relabel_item_id BIGINT NULL COMMENT '本票关联换标明细ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark),
KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
@@ -2152,6 +2155,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
pallet_id BIGINT NOT NULL COMMENT '来源托盘ID',
pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
package_count INT NOT NULL COMMENT '分配箱数',
sku_code VARCHAR(128) NULL COMMENT '本次来源分配的实际SKU快照',
status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键',
allocated_at DATETIME(3) NOT NULL COMMENT '分配时间',
@@ -2160,7 +2164,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id),
KEY idx_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id),
UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key),
KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录';
@@ -1080,6 +1080,57 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } }
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/allocations:
post:
summary: 选择已有库存转运的真实来源
description: 权限lastleg:warehouse-transfer:manage;必须指定customerId。仅APPROVED原票选择精确来源位置和托盘,箱数等于原票箱数。needRelabel=true时按本票relabelBindings创建唯一关联换标并冻结所选数量,返回WAIT_RELABEL和relabelNo;客户完成作业复核确认后回到ALLOCATED,分配追随实际换标位置和新SKU。原idempotencyKey及原载荷重放不重复预留或建換标,同键异载荷冲突。
security: [{ bearerAuth: [] }]
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [idempotencyKey, allocations]
properties:
idempotencyKey: { type: string, maxLength: 255 }
allocations:
type: array
minItems: 1
items:
type: object
required: [inventoryPositionId, palletId, packageCount]
properties:
inventoryPositionId: { type: integer, format: int64 }
palletId: { type: integer, format: int64 }
packageCount: { type: integer, minimum: 1 }
responses:
'200': { description: 原转运详情;allocations内skuCode为锁定的实际发货SKU,换标后返回新SKU;不包括已被换标替换的历史分配 }
'409': { description: 来源不可用或幂等载荷冲突 }
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound:
post:
summary: 已有库存转运实际出库
description: 权限lastleg:warehouse-transfer:manage;必须指定customerId。仅ALLOCATED可执行,需换标本票必须关联本尾程订单的唯一换标申请,实际作业复核及客户确认已完成。按当前分配的新SKU和位置发货并保留唯一SHIP流水,不以MIXED聚合值替代实际SKU。成功原命令重放不重复扣减。
security: [{ bearerAuth: [] }]
parameters:
- { name: transferNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [idempotencyKey]
properties:
idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, maxLength: 1000 }
responses:
'200': { description: 本票OUTBOUNDED事实或成功原命令重放 }
'409': { description: 尚未完成本票换标客户确认或分配不完整 }
/api/merchant/v1/last-leg/warehouse-transfers:
get:
summary: 客户分页查询自己的仓储转运单
@@ -2840,7 +2891,7 @@ components:
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] }
printShippingLabel: { type: boolean }
shippingLabelFileId: { type: string, nullable: true }
fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] }
fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, WAIT_TRANSFER_OUTBOUND, READY_FOR_OUTBOUND, OUTBOUND_CREATED, OUTBOUNDED, COMPLETED] }
outboundOrderNo: { type: string, nullable: true }
customerConfirmedAt: { type: string, format: date-time, nullable: true }
reviewChannel: { type: string, enum: [TAIL_ORDER, RELABEL_ORDER], description: TAIL_ORDER 表示客户直接贴标,RELABEL_ORDER 表示其他业务关联换标 }
@@ -76,6 +76,7 @@ class RelabelApplicationServiceTest {
@Mock OutboundPalletItemMapper outboundItems;
@Mock OutboundPdaTaskMapper outboundTasks;
@Mock WarehouseTransferOrderMapper warehouseTransfers;
@Mock com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion;
@Mock OverseasFeeSourceService feeSources;
@Mock ReturnOrderMapper returnOrders;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@@ -93,6 +94,7 @@ class RelabelApplicationServiceTest {
});
lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "transferRelabelCompletion", transferRelabelCompletion);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "orders", orders);
@@ -704,6 +706,26 @@ class RelabelApplicationServiceTest {
assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus());
}
@Test
void existingStockTransferConfirmationFinalizesOriginalSourcesOnceWithoutIndependentOutbound() {
var application=confirmationApplication("EXPRESS",false);application.setLastlegOrderId(88L);
var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001");
transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L);
transfer.setAllocationIdempotencyKey("ORIGINAL-SOURCE");transfer.setFbaWarehouseCode("ONT8");
var transferOrder=order(88,"10001","LMO-TRANSFER-1","WAIT_RELABEL");
when(applications.lockNo("10001","FBR-1")).thenReturn(application);stubDetail(application);
when(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder);
var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted");
service.confirmCompletion("10001","FBR-1","customer-1",command);
assertEquals("ALLOCATED",transfer.getStatus());assertEquals("WAIT_TRANSFER_OUTBOUND",application.getFulfillmentStatus());
assertEquals("ALLOCATED",transferOrder.getCurrentStatus());
service.confirmCompletion("10001","FBR-1","customer-1",command);
verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any());
verify(outboundOrders,never()).insert(any(OutboundOrder.class));
assertNotNull(transfer.getRelabelCompletedAt());
}
@Test
void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() {
RelabelApplication application = confirmationApplication("TRUCK", false);
@@ -0,0 +1,190 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.transfer.entity.*;
import com.baiyue.lastleg.transfer.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class WarehouseTransferRelabelPersistenceTest {
JdbcTemplate jdbc; SqlSessionTemplate session; TransactionTemplate tx;
WarehouseTransferRelabelCompletionService completion; InventoryCommandService inventory;
WarehouseTransferOrder transfer; RelabelApplication application; RelabelItem item;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds); tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
for (Class<?> type : List.of(InventoryBalance.class,InventoryPosition.class,InventoryLedger.class,
InventoryOperation.class,WarehouseTransferInventoryAllocation.class,WarehouseTransferRelabelBinding.class,
WarehouseTransferOrder.class,RelabelApplication.class,RelabelItem.class)) {
var columns = new ArrayList<String>();
for (var f : type.getDeclaredFields()) {
if(java.lang.reflect.Modifier.isStatic(f.getModifiers()))continue;
TableField field=f.getAnnotation(TableField.class);if(field!=null&&!field.exist())continue;
String name=f.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
String sqlType=f.getType()==String.class?"VARCHAR(2048)":f.getType()==LocalDateTime.class?"TIMESTAMP":f.getType()==Boolean.class?"BOOLEAN":"BIGINT";
columns.add(name+" "+sqlType+(name.equals("id")?" PRIMARY KEY":""));
}
jdbc.execute("CREATE TABLE "+type.getAnnotation(TableName.class).value()+" ("+String.join(",",columns)+")");
}
jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(customer_id VARCHAR(100),warehouse_pallet_id BIGINT,status VARCHAR(40))");
jdbc.execute("CREATE UNIQUE INDEX allocation_active ON ll_warehouse_transfer_inventory_allocation(customer_id,active_position_key)");
jdbc.execute("CREATE INDEX idx_ll_wh_transfer_allocation_position ON ll_warehouse_transfer_inventory_allocation(transfer_order_id,inventory_position_id)");
jdbc.execute("CREATE UNIQUE INDEX operation_key ON ll_inventory_operation(customer_id,idempotency_key)");
var configuration=new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for(Class<?> mapper:List.of(InventoryBalanceMapper.class,InventoryPositionMapper.class,InventoryLedgerMapper.class,
InventoryOperationMapper.class,InventoryCommandMapper.class,WarehouseTransferInventoryAllocationMapper.class,
WarehouseTransferRelabelBindingMapper.class,WarehouseTransferOrderMapper.class,
com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class,
com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class))configuration.addMapper(mapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
session=new SqlSessionTemplate(factory.getObject());
inventory=new InventoryCommandService();
ReflectionTestUtils.setField(inventory,"balances",session.getMapper(InventoryBalanceMapper.class));
ReflectionTestUtils.setField(inventory,"positions",session.getMapper(InventoryPositionMapper.class));
ReflectionTestUtils.setField(inventory,"ledgers",session.getMapper(InventoryLedgerMapper.class));
ReflectionTestUtils.setField(inventory,"operations",session.getMapper(InventoryOperationMapper.class));
ReflectionTestUtils.setField(inventory,"commands",session.getMapper(InventoryCommandMapper.class));
ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(WarehouseResourceGateway.class));
ReflectionTestUtils.setField(inventory,"objectMapper",new ObjectMapper().findAndRegisterModules());
completion=new WarehouseTransferRelabelCompletionService();
ReflectionTestUtils.setField(completion,"allocations",session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
ReflectionTestUtils.setField(completion,"bindings",session.getMapper(WarehouseTransferRelabelBindingMapper.class));
ReflectionTestUtils.setField(completion,"inventoryRows",session.getMapper(InventoryCommandMapper.class));
ReflectionTestUtils.setField(completion,"inventory",inventory);
transfer=new WarehouseTransferOrder();transfer.setId(1L);transfer.setTransferNo("WT-ORIGINAL");transfer.setCustomerId("7");
transfer.setPackageCount(1);transfer.setOperatingWarehouseId("9");transfer.setLastlegOrderId(10L);transfer.setRelabelApplicationId(11L);
application=new RelabelApplication();application.setId(11L);application.setCustomerId("7");application.setLastlegOrderId(10L);
application.setCustomerConfirmedAt(LocalDateTime.now());
item=new RelabelItem();item.setId(12L);item.setApplicationId(11L);item.setCustomerId("7");item.setSkuCode("OLD");
item.setSourceInventoryPositionId(202L);item.setSourceInboundOrderId(50L);item.setCartonMark("BOX-1");item.setNotifiedPackageCount(1);
jdbc.update("INSERT INTO ll_inventory_balance(id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,in_transit_packages,procurement_packages,version) VALUES (60,'7','Synthetic customer','9','SRC','Source','ABE4',50,'IP-50','IN-50','OLD',2,2,0,0,0,0,0,0)");
position(101,701,2,0,"SOURCE");position(202,702,0,1,"MOVED");position(99,799,0,1,"OTHER-HOLD");
var allocation=new WarehouseTransferInventoryAllocation();allocation.setId(13L);allocation.setCustomerId("7");allocation.setTransferOrderId(1L);
allocation.setInventoryPositionId(101L);allocation.setInventoryBalanceId(60L);allocation.setInboundOrderId(50L);
allocation.setPackageCount(1);allocation.setStatus("RESERVED");allocation.setPalletId(701L);allocation.setActivePositionKey("7:101");
allocation.setSkuCode("OLD");
session.getMapper(WarehouseTransferInventoryAllocationMapper.class).insert(allocation);
var binding=new WarehouseTransferRelabelBinding();binding.setId(14L);binding.setCustomerId("7");binding.setTransferOrderId(1L);
binding.setSourceAllocationId(13L);binding.setRelabelItemId(12L);binding.setOriginalSku("OLD");binding.setNewSku("NEW");binding.setCustomerCartonMark("BOX-1");
session.getMapper(WarehouseTransferRelabelBindingMapper.class).insert(binding);
}
void position(long id,long pallet,long available,long hold,String batch){jdbc.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,pallet_id,pallet_no,batch_no,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES (?,'7',60,'9','ABE4',?,?,?, ?,?,0,0,0,0)",id,pallet,"P-"+pallet,batch,available,hold);}
@Test void confirmsMovedCartonIntoNewSkuAndPreservesOtherHoldWithRealCommandsAndMappers() {
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
assertEquals(2L,jdbc.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=101",Long.class));
var rows=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L);
assertEquals(1,rows.size());assertEquals(702L,rows.get(0).getPalletId());assertEquals(1,rows.get(0).getPackageCount());
assertEquals("NEW",rows.get(0).getSkuCode());
assertEquals("NEW",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_ledger WHERE business_type='STOCK_IN'",String.class));
}
@Test void missingExactCartonBindingRollsBackWithoutInventoryOrReservationMutation(){
item.setId(999L);
assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())));
assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class));
}
@Test void lateAllocationFailureRollsBackRealReleaseSkuOutputAndOriginalReservation(){
jdbc.update("INSERT INTO ll_warehouse_transfer_inventory_allocation(id,customer_id,transfer_order_id,inventory_position_id,active_position_key,status) VALUES (900,'7',999,901,'7:901','RESERVED')");
// The target ID is allocated dynamically; inject a failure after real stock changes via the source mapper.
var mapper=org.mockito.Mockito.spy(session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
org.mockito.Mockito.doThrow(new IllegalStateException("FORCED_FINAL_ALLOCATION_FAILURE")).when(mapper).insert(org.mockito.ArgumentMatchers.any(WarehouseTransferInventoryAllocation.class));
ReflectionTestUtils.setField(completion,"allocations",mapper);
assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())));
assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=202",Long.class));
assertEquals("OLD",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_balance WHERE id=60",String.class));
assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class));
}
@Test void shipsActualNewSkuFromMixedBalanceAndOriginalKeysReplayWithoutSecondDecrement(){
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
var actual=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L).get(0);
assertEquals("MIXED",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_balance WHERE id=60",String.class));
var allocate=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:ALLOCATE","");
var pick=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:PICK","");
var ship=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:SHIP","");
tx.executeWithoutResult(s->{inventory.allocateTransferSnapshot("7","operator",allocate,actual.getSkuCode());inventory.pickTransferSnapshot("7","operator",pick,actual.getSkuCode());inventory.shipTransferSnapshot("7","operator",ship,actual.getSkuCode());});
tx.executeWithoutResult(s->{assertTrue(inventory.allocateTransferSnapshot("7","operator",allocate,"NEW").replayed());assertTrue(inventory.pickTransferSnapshot("7","operator",pick,"NEW").replayed());assertTrue(inventory.shipTransferSnapshot("7","operator",ship,"NEW").replayed());});
assertEquals(3L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type='SHIP' AND sku_code='NEW'",Long.class));
assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type IN ('SHIP','PICK','ALLOCATE') AND sku_code<>'NEW'",Long.class));
assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->inventory.shipTransferSnapshot("7","operator",ship,"OLD")));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
}
@Test void cartonOnlyConfirmationKeepsSamePositionAndRetainsOriginalBindingHistory(){
jdbc.update("UPDATE ll_warehouse_transfer_relabel_binding SET new_sku=NULL WHERE id=14");
jdbc.update("UPDATE ll_inventory_position SET hold_packages=1 WHERE id=101");
jdbc.update("UPDATE ll_inventory_position SET hold_packages=0 WHERE id=202");item.setSourceInventoryPositionId(101L);
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
var current=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L);
assertEquals(1,current.size());assertEquals(101L,current.get(0).getInventoryPositionId());assertEquals("OLD",current.get(0).getSkuCode());
assertEquals(2L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation WHERE inventory_position_id=101",Long.class));
assertEquals("RELABELLED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class));
assertEquals(13L,jdbc.queryForObject("SELECT source_allocation_id FROM ll_warehouse_transfer_relabel_binding WHERE id=14",Long.class));
assertEquals(12L,jdbc.queryForObject("SELECT relabel_item_id FROM ll_warehouse_transfer_relabel_binding WHERE id=14",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
}
@Test void partiallyRelabeledTransferRetainsUnchangedOriginalRemainderAsSeparateCurrentAllocation(){
transfer.setPackageCount(2);jdbc.update("UPDATE ll_warehouse_transfer_inventory_allocation SET package_count=2 WHERE id=13");
jdbc.update("UPDATE ll_inventory_position SET available_packages=1,hold_packages=1 WHERE id=101");
jdbc.update("UPDATE ll_inventory_balance SET available_packages=1,hold_packages=3 WHERE id=60");
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
var current=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L);
assertEquals(2,current.size());assertEquals(2,current.stream().mapToInt(WarehouseTransferInventoryAllocation::getPackageCount).sum());
assertEquals(Set.of("OLD","NEW"),new HashSet<>(current.stream().map(WarehouseTransferInventoryAllocation::getSkuCode).toList()));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class));
}
@Test void customerConfirmationReplaysPersistedKeyWithoutSecondInventoryOrAllocationWrites(){
transfer.setStatus("WAIT_RELABEL");transfer.setAllocationIdempotencyKey("SELECT-ORIGINAL");transfer.setRelabelNo("RL-OWN");
transfer.setFbaWarehouseCode("ABE4");transfer.setDestinationType("FBA");transfer.setDestinationWarehouseCode("ABE4");
application.setRelabelNo("RL-OWN");application.setStatus("CUSTOMER_CONFIRM_PENDING");application.setDeliveryMethod("TRUCK");
application.setCustomerConfirmedAt(null);item.setOperationType("CARTON_AND_SKU");
session.getMapper(WarehouseTransferOrderMapper.class).insert(transfer);
var apps=session.getMapper(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);apps.insert(application);
var items=session.getMapper(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);items.insert(item);
var service=spy(new com.baiyue.lastleg.relabel.service.RelabelApplicationService());
ReflectionTestUtils.setField(service,"applications",apps);ReflectionTestUtils.setField(service,"items",items);
ReflectionTestUtils.setField(service,"warehouseTransfers",session.getMapper(WarehouseTransferOrderMapper.class));
ReflectionTestUtils.setField(service,"transferRelabelCompletion",completion);
var tasks=mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var task=new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();task.setStatus("WAIT_REVIEW");task.setVersion(1);
when(tasks.lock("7",11L)).thenReturn(task);when(tasks.updateWithVersion(any(),any())).thenReturn(1);
ReflectionTestUtils.setField(service,"pdaTasks",tasks);
ReflectionTestUtils.setField(service,"returnOrders",mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper.class));
ReflectionTestUtils.setField(service,"histories",mock(com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper.class));
ReflectionTestUtils.setField(service,"orderHistories",mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class));
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var order=new com.baiyue.lastleg.entity.LastlegOrder();
order.setId(10L);order.setCustomerId("7");order.setCurrentStatus("WAIT_RELABEL");order.setOrderType("WAREHOUSE_TRANSFER");
when(orders.selectList(any())).thenReturn(List.of(order));when(orders.selectById(10L)).thenReturn(order);
ReflectionTestUtils.setField(service,"orders",orders);doReturn(null).when(service).detail("7","RL-OWN");
var command=new com.baiyue.lastleg.relabel.dto.RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","confirmed");
tx.executeWithoutResult(s->service.confirmCompletion("7","RL-OWN","operator",command));
long ledgers=jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class);
long allocations=jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation",Long.class);
tx.executeWithoutResult(s->service.confirmCompletion("7","RL-OWN","operator",command));
assertEquals(ledgers,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
assertEquals(allocations,jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation",Long.class));
assertEquals("ALLOCATED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_order WHERE id=1",String.class));
assertEquals("CONFIRM-ORIGINAL",jdbc.queryForObject("SELECT customer_confirmation_key FROM ll_relabel_application WHERE id=11",String.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
}
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.transfer.service;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseTransferRelabelSchemaContractTest {
private List<String> baselines() throws Exception {
return List.of(new ClassPathResource("db/lastleg.sql").getContentAsString(StandardCharsets.UTF_8),
Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql")));
}
@Test
void bothBaselinesPersistExactAllocationSkuAndOriginalRelabelLinks() throws Exception {
for (String sql : baselines()) {
assertThat(table(sql, "ll_warehouse_transfer_inventory_allocation").toLowerCase())
.contains("sku_code varchar(128)");
assertThat(table(sql, "ll_warehouse_transfer_relabel_binding").toLowerCase())
.contains("source_allocation_id bigint", "relabel_item_id bigint")
.contains("uk_ll_transfer_relabel_mark");
}
}
@Test
void historyCanSharePositionWhileOnlyOneActiveAllocationOwnsIt() throws Exception {
for (String sql : baselines()) {
String allocation = table(sql, "ll_warehouse_transfer_inventory_allocation")
.replaceAll("\\s+", "").toLowerCase();
assertThat(allocation)
.contains("keyidx_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id)")
.contains("uniquekeyuk_ll_wh_transfer_active_position(customer_id,active_position_key)")
.doesNotContain("uniquekeyuk_ll_wh_transfer_allocation_position");
}
}
@Test
void controlledUpdateOnlyChangesTwoStructuresWithoutRepairingBusinessFacts() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "migrations", "overseas",
"baiyue_lastleg", "V8__bind_stock_transfer_relabel_sources.sql"))
.replaceAll("(?m)^--.*$", "").toLowerCase();
assertThat(sql.split(";")).filteredOn(part -> !part.isBlank()).hasSize(2);
assertThat(sql)
.contains("alter table ll_warehouse_transfer_inventory_allocation",
"alter table ll_warehouse_transfer_relabel_binding", "add column sku_code varchar(128) null",
"add column source_allocation_id bigint null", "add column relabel_item_id bigint null",
"drop index uk_ll_wh_transfer_allocation_position",
"add key idx_ll_wh_transfer_allocation_position")
.doesNotContain("drop index uk_ll_wh_transfer_active_position", "drop table", "truncate",
"insert into", "update ll_", "delete from", "disable keys");
}
private static String table(String sql, String name) {
int start = sql.indexOf("CREATE TABLE " + name + " (");
if (start < 0) start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + name + " (");
assertThat(start).as(name).isGreaterThanOrEqualTo(0);
int end = sql.indexOf(") ENGINE", start);
assertThat(end).isGreaterThan(start);
return sql.substring(start, end);
}
}
@@ -170,6 +170,7 @@ class WarehouseTransferServiceTest {
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
private WarehouseTransferService service;
@BeforeEach
@@ -201,6 +202,7 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next(anyString(), any(), anyLong()))
.thenReturn("WT-WH-20260901-000001");
@@ -791,6 +793,72 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(inventory);
}
@Test
void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
transfer.setNeedRelabel(true);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-RELABEL", "must wait")));
verifyNoInteractions(inventory);
}
@Test
void choosingOriginalStockStartsItsOwnRelabelAndReplaysWithoutSecondApplicationOrHold() {
var transfer = transfer("APPROVED"); transfer.setNeedRelabel(true); transfer.setPackageCount(1);
var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding();
binding.setId(80L); binding.setCustomerCartonMark("BOX-1");binding.setOriginalSku("SKU-TRANSFER");
binding.setNewSku("NEW");binding.setOperationType("CARTON_AND_SKU");binding.setQuantity(1);
var cargo = new com.baiyue.lastleg.transfer.entity.WarehouseTransferItem();cargo.setId(90L);cargo.setSkuCode("SKU-TRANSFER");cargo.setPackageCount(1);
var summary = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.dto.RelabelResponses.Summary.class);
when(summary.id()).thenReturn(42L);when(summary.relabelNo()).thenReturn("RL-OWN");
var carton = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.dto.RelabelResponses.Item.class);
when(carton.id()).thenReturn(43L);when(carton.cartonMark()).thenReturn("BOX-1");
var created = new com.baiyue.lastleg.relabel.dto.RelabelResponses.Detail(summary,"",List.of(carton),List.of(),List.of(),null);
when(relabelService.createForExistingTailOrder(eq("TENANT-A"),eq("USER-1"),eq(31L),any())).thenReturn(created);
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder()));
when(inventoryCommands.lockPosition("TENANT-A",501L)).thenReturn(position(501,601,701,3));
when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601,101,"IN-1"));
when(transferItems.selectList(any())).thenReturn(List.of(cargo));
when(relabelBindings.listOrder("TENANT-A",21L)).thenReturn(List.of(binding));
when(orders.selectById(31L)).thenReturn(tailOrder());
var command=new WarehouseTransferRequests.Allocate("ORIGINAL-SELECT",List.of(new WarehouseTransferRequests.Allocation(501L,701L,1)));
var response=service.allocate("TENANT-A","WTR-1","USER-1",command);
assertEquals("WAIT_RELABEL",response.status());assertEquals("RL-OWN",transfer.getRelabelNo());
assertEquals(43L,binding.getRelabelItemId());assertNotNull(binding.getSourceAllocationId());
service.allocate("TENANT-A","WTR-1","USER-1",command);
verify(relabelService).createForExistingTailOrder(eq("TENANT-A"),eq("USER-1"),eq(31L),any());
verify(inventoryAllocations).insert(any(WarehouseTransferInventoryAllocation.class));
verifyNoInteractions(inventory);
assertThrows(ResponseStatusException.class,()->service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("EARLY","")));
}
@Test
void confirmedOwnRelabelShipsAndReceivesItsNewSkuAndCompletesLinkedFulfillment() {
var transfer=transfer("ALLOCATED");transfer.setNeedRelabel(true);transfer.setRelabelNo("RL-OWN");
transfer.setRelabelApplicationId(42L);transfer.setRelabelCompletedAt(LocalDateTime.now());
var app=new RelabelApplication();app.setId(42L);app.setLastlegOrderId(31L);app.setCustomerConfirmedAt(LocalDateTime.now());
var current=allocation();current.setSkuCode("NEW");
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(relabelApplications.lockNo("TENANT-A","RL-OWN")).thenReturn(app);
when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(current));
when(orders.selectById(31L)).thenReturn(tailOrder());when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed"));
service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed"));
verify(inventory).shipTransferSnapshot(eq("TENANT-A"),eq("USER-1"),any(),eq("NEW"));
verify(inventory,never()).ship(anyString(),anyString(),any());assertEquals("OUTBOUNDED",app.getFulfillmentStatus());
var mixed=balance(601,101,"IN-1");mixed.setSkuCode("MIXED");when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(mixed);
when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn(new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false));
service.receive("TENANT-A","WTR-1","USER-1",receiptRequest());
var received=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"),eq("USER-1"),received.capture());assertEquals("NEW",received.getValue().skuCode());
assertEquals("RECEIVED",transfer.getStatus());assertEquals("COMPLETED",app.getStatus());assertEquals("COMPLETED",app.getFulfillmentStatus());
}
@Test
void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);