fix(inventory): enforce warehouse transfer reservation ownership

This commit is contained in:
2026-10-05 07:24:53 +08:00
parent 548a133de6
commit 5b4d9624a9
14 changed files with 293 additions and 62 deletions
@@ -114,12 +114,10 @@ public interface DispatchCandidateMapper {
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " +
"AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " +
// Warehouse-transfer inventory is intentionally reserved before dispatch. It must still be
// eligible for the normal smart-dispatch flow once its destination is bound; other order
// types keep the reservation guard to prevent duplicate dispatching.
"AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
// Incoming creation mode never grants ownership of a later transfer reservation.
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " +
"WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
// A warehouse-transfer pallet may contain both relabel and non-relabel inventory.
// Do not hide the whole pallet because one carton/SKU has an active relabel item;
// the remaining available inventory must still be dispatchable.
@@ -213,9 +211,9 @@ public interface DispatchCandidateMapper {
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
"AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " +
"JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " +
"WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " +
@@ -12,6 +12,15 @@ import java.util.List;
@Mapper
public interface InventoryCommandMapper {
@Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_application_id=#{applicationId} FOR UPDATE")
Long relabelTransfer(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
record TransferReservation(Long transferOrderId, long packageCount) {}
@Select("SELECT transfer_order_id,package_count FROM ll_warehouse_transfer_inventory_allocation " +
"WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} AND status='RESERVED' FOR UPDATE")
TransferReservation transferReservation(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1")
InventoryPosition findPosition(@Param("positionId") Long positionId);
@@ -36,6 +36,7 @@ import java.util.Objects;
@Service
public class InventoryCommandService {
private record ReservedTransferMove(InventoryCommandRequests.Move move, String sku, Long transferOrderId) {}
private record TransferMoveSnapshot(InventoryCommandRequests.Move move, String sku) {}
private record TransferAdjustmentSnapshot(InventoryCommandRequests.Adjustment adjustment, String sku) {}
@Resource private InventoryBalanceMapper balances;
@@ -243,32 +244,68 @@ public class InventoryCommandService {
}
@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);
public InventoryCommandResponse holdWarehouseTransfer(String customerId, String operatorId,
Long transferOrderId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request, null,
java.util.Objects.requireNonNull(transferOrderId));
}
@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);
public InventoryCommandResponse releaseWarehouseTransfer(String customerId, String operatorId,
Long transferOrderId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, null,
java.util.Objects.requireNonNull(transferOrderId));
}
/** Exact source snapshot comes from the transfer's locked allocation, not a mixed order aggregate. */
/** Internal transfer service entry points; no Controller binds this owner argument. */
@Transactional
public InventoryCommandResponse shipTransferSnapshot(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return ship(customerId, operatorId, request, skuSnapshot);
public InventoryCommandResponse allocateWarehouseTransfer(String customerId, String operatorId,
Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) {
if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot);
return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED,
request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId));
}
@Transactional
public InventoryCommandResponse pickWarehouseTransfer(String customerId, String operatorId,
Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) {
if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot);
return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED,
request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId));
}
@Transactional
public InventoryCommandResponse shipWarehouseTransfer(String customerId, String operatorId,
Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) {
if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot);
return ship(customerId, operatorId, request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId));
}
private void requireReservation(String customerId, Long positionId, Long owner, long packages, String operationType) {
var reservation = commands.transferReservation(customerId, positionId);
if (reservation == null) {
if (owner != null && !"RELEASE".equals(operationType) && !"HOLD".equals(operationType)) throw conflict("Warehouse transfer reservation is missing");
return;
}
boolean partial = "RELEASE".equals(operationType) || "HOLD".equals(operationType);
boolean quantityMatches = partial ? packages <= reservation.packageCount() : packages == reservation.packageCount();
if (owner == null || !owner.equals(reservation.transferOrderId()) || !quantityMatches) {
throw conflict("Inventory is reserved by a warehouse transfer");
}
}
private InventoryCommandResponse ship(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
return ship(customerId, operatorId, request, skuSnapshot, null);
}
private InventoryCommandResponse ship(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot, Long reservationOwner) {
return execute(customerId, operatorId, "SHIP", request.idempotencyKey(),
skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> {
reservationOwner != null ? new ReservedTransferMove(request, skuSnapshot, reservationOwner)
: skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> {
Locked locked = locked(customerId, request.positionId());
requireReservation(customerId, request.positionId(), reservationOwner, request.packages(), "SHIP");
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked");
@@ -556,10 +593,11 @@ public class InventoryCommandService {
}
@Transactional
public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId,
public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId, Long transferOrderId,
InventoryCommandRequests.Move request, String skuSnapshot) {
requireTransferSku(request.idempotencyKey(), skuSnapshot);
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot);
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot,
java.util.Objects.requireNonNull(transferOrderId));
}
private static void requireTransferSku(String key, String skuSnapshot) {
@@ -569,9 +607,16 @@ public class InventoryCommandService {
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot) {
return move(customerId, operatorId, operationType, from, to, request, skuSnapshot, null);
}
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot, Long reservationOwner) {
return execute(customerId, operatorId, operationType, request.idempotencyKey(),
skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> {
reservationOwner != null ? new ReservedTransferMove(request, skuSnapshot, reservationOwner)
: skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> {
Locked locked = locked(customerId, request.positionId());
requireReservation(customerId, request.positionId(), reservationOwner, request.packages(), operationType);
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase());
@@ -58,6 +58,7 @@ import java.util.stream.Collectors;
@Service
public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper;
@Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@@ -465,14 +466,33 @@ public class PdaPalletOperationService {
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
try {
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
}
saveTransitions(operation.getId(), transitions);
PalletOperationResponses.Operation result = transaction.execute(status ->
mutate(identity, operation, plan, normalizedKey, requestHash, reason));
PalletOperationResponses.Operation result = transaction.execute(status -> {
if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId());
Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer(
plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId());
// Allocation also locks the position first: preserve mutual exclusion until commit.
for (PlannedMove move : plan.moves()) {
PalletInventoryRow source = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId());
if (source == null || !Objects.equals(source.getPalletId(), move.sourcePalletId())
|| value(source.getVersion()) != move.expectedVersion()) throw conflict("Inventory source changed");
requirePalletReservation(source.getCustomerId(), move.positionId(),
move.bucket() == InventoryBucket.HOLD ? relabelOwner : null);
}
if (plan.relabelMutation() != null) {
for (Long anchorId : plan.relabelMutation().anchors().keySet()) {
PalletInventoryRow anchor = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), anchorId);
if (anchor == null) throw conflict("Relabel inventory anchor disappeared");
requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner);
}
}
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
}
saveTransitions(operation.getId(), transitions);
return mutate(identity, operation, plan, normalizedKey, requestHash, reason);
});
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
return result;
} catch (RuntimeException failure) {
@@ -482,6 +502,12 @@ public class PdaPalletOperationService {
}
}
private void requirePalletReservation(String customerId, Long positionId, Long owner) {
var reservation = inventoryCommands.transferReservation(customerId, positionId);
if (reservation != null && (owner == null || !owner.equals(reservation.transferOrderId())))
throw conflict("Inventory is reserved by a warehouse transfer");
}
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId());
@@ -427,10 +427,10 @@ public class RelabelApplicationService implements WalletResultHandler {
int holdIndex = 0;
for (SourceInventory source : sources) {
if (source.returnCarton() != null) continue;
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move(
source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++,
"贴标申请冻结原入仓单库存"));
"贴标申请冻结原入仓单库存"), existingTailOrderId, null);
}
LastlegOrder tailOrder = existingTailOrderId == null
? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now)
@@ -681,10 +681,10 @@ public class RelabelApplicationService implements WalletResultHandler {
for (RelabelItem item : itemList(application.getId())) {
if (item.getSourceInventoryPositionId() == null
|| Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue;
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:"
+ walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存"));
+ walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存"), null, application);
}
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion);
@@ -739,10 +739,10 @@ public class RelabelApplicationService implements WalletResultHandler {
}
} else if (item.getSourceInventoryPositionId() != null
&& Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) > 0) {
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null,
"RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":RESUBMIT-HOLD:" + holdIndex++,
"换标驳回修改后重新冻结库存"));
"换标驳回修改后重新冻结库存"), null, application);
}
}
application.setLabelDeadline(request.labelDeadline());
@@ -1214,13 +1214,39 @@ public class RelabelApplicationService implements WalletResultHandler {
continue;
}
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue;
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
releaseRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null,
"RELABEL:" + application.getRelabelNo(), application.getIdempotencyKey() + ":RELEASE:"
+ item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单"));
+ item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单"), application);
}
}
private WarehouseTransferOrder relabelInventoryOwner(String customerId, Long existingTailOrderId,
RelabelApplication application) {
if (application != null) {
WarehouseTransferOrder owner = warehouseTransfers.lockByRelabelNo(customerId, application.getRelabelNo());
if (owner != null && (!Objects.equals(owner.getRelabelApplicationId(), application.getId())
|| !Objects.equals(owner.getLastlegOrderId(), application.getLastlegOrderId())))
throw conflict("Relabel transfer association changed");
return owner;
}
return existingTailOrderId == null ? null : warehouseTransfers.lockTailOrder(customerId, existingTailOrderId);
}
private void holdRelabelInventory(String customerId, String operatorId, InventoryCommandRequests.Move request,
Long existingTailOrderId, RelabelApplication application) {
WarehouseTransferOrder owner = relabelInventoryOwner(customerId, existingTailOrderId, application);
if (owner == null) inventoryCommands.hold(customerId, operatorId, request);
else inventoryCommands.holdWarehouseTransfer(customerId, operatorId, owner.getId(), request);
}
private void releaseRelabelInventory(String customerId, String operatorId, InventoryCommandRequests.Move request,
RelabelApplication application) {
WarehouseTransferOrder owner = relabelInventoryOwner(customerId, null, application);
if (owner == null) inventoryCommands.release(customerId, operatorId, request);
else inventoryCommands.releaseWarehouseTransfer(customerId, operatorId, owner.getId(), request);
}
private List<RelabelItem> itemList(Long applicationId) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>().eq(RelabelItem::getApplicationId, applicationId)
.orderByAsc(RelabelItem::getId));
@@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper public interface WarehouseTransferOrderMapper extends BaseMapper<WarehouseTransferOrder> {
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND lastleg_order_id=#{tailOrderId} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockTailOrder(@Param("customerId") String customerId, @Param("tailOrderId") Long tailOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1")
@@ -71,7 +71,7 @@ public class WarehouseTransferRelabelCompletionService {
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,
inventory.releaseTransferRelabel(customer, operator, transfer.getId(), 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())) {
@@ -103,7 +103,7 @@ public class WarehouseTransferRelabelCompletionService {
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,
inventory.releaseTransferRelabel(customer, operator, transfer.getId(), new InventoryCommandRequests.Move(current.getId(), count,
transfer.getTransferNo(), null, key, key + ":RELEASE", "本票未换标余量确认"), row.getSkuCode());
addTarget(targets, balance, current, count, row.getSkuCode());
}
@@ -1122,7 +1122,7 @@ public class WarehouseTransferService {
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,
inventory.holdWarehouseTransfer(customerId, operatorId, transfer.getId(), new InventoryCommandRequests.Move(source.positionId,
source.remainingPackages, transferNo, null, holdKey, holdKey + ":HOLD", "本票转运未换标余量预留"));
}
}
@@ -1181,21 +1181,18 @@ public class WarehouseTransferService {
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark()));
if (Boolean.TRUE.equals(transfer.getNeedRelabel()))
inventory.allocateTransferSnapshot(customerId, operatorId, move, allocation.getSkuCode());
else inventory.allocate(customerId, operatorId, move);
inventory.allocateWarehouseTransfer(customerId, operatorId, transfer.getId(), move,
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : null);
var pick = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"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);
inventory.pickWarehouseTransfer(customerId, operatorId, transfer.getId(), pick,
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : null);
var ship = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"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);
inventory.shipWarehouseTransfer(customerId, operatorId, transfer.getId(), ship,
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : null);
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation);
}