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
@@ -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,