fix(transfer): reserve receipt resources with durable recovery intents

This commit is contained in:
2026-10-05 03:37:13 +08:00
parent 3f8ac23aba
commit c194dd2862
10 changed files with 661 additions and 9 deletions
@@ -75,7 +75,15 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
response.message() == null || response.message().isBlank()
? "Warehouse resource reservation was rejected" : response.message());
}
return response.data().lockId();
ReserveData lock = response.data();
if (!"200".equals(response.code()) || lock.lockId() <= 0 || !"ACTIVE".equals(lock.status())
|| !Long.valueOf(Long.parseLong(customerId)).equals(lock.customerId())
|| !Long.valueOf(warehouseId).equals(lock.warehouseId()) || !Long.valueOf(palletId).equals(lock.palletId())
|| !Long.valueOf(locationId).equals(lock.locationId()) || !java.util.Objects.equals(businessType, lock.businessType())
|| !java.util.Objects.equals(businessId, lock.businessId()) || !java.util.Objects.equals(reservationKey, lock.reservationKey())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Warehouse reservation is not the requested ACTIVE lock");
}
return lock.lockId();
}
@Override
@@ -340,7 +348,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
String locationType, String locationStatus, boolean reservable,
String reservationBlockReason) {}
private record ReserveEnvelope(String code, String message, ReserveData data) {}
private record ReserveData(long lockId) {}
private record ReserveData(long lockId, Long customerId, Long warehouseId, Long palletId, Long locationId,
String businessType, String businessId, String reservationKey, String status) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
private record PalletBatchEnvelope(String code, String message, PalletBatchResponse data) {}
@@ -0,0 +1,29 @@
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 lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_receipt_command")
public class WarehouseTransferReceiptCommand {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long transferOrderId;
private Long sourceInventoryPositionId;
private String idempotencyKey;
private String requestHash;
private String requestJson;
private Long targetWarehouseId;
private Long targetPalletId;
private Long targetLocationId;
private String targetHash;
private String targetJson;
private String reservationKey;
private Long resourceLockId;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceiptCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface WarehouseTransferReceiptCommandMapper extends BaseMapper<WarehouseTransferReceiptCommand> {
@Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " +
"AND transfer_order_id=#{transferId} AND source_inventory_position_id=#{sourceId} LIMIT 1 FOR UPDATE")
WarehouseTransferReceiptCommand lockSource(@Param("customerId") String customerId,
@Param("transferId") Long transferId, @Param("sourceId") Long sourceId);
@Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " +
"AND idempotency_key=#{key} LIMIT 1")
WarehouseTransferReceiptCommand findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " +
"AND transfer_order_id=#{transferId} AND target_pallet_id=#{palletId} ORDER BY id LIMIT 1")
WarehouseTransferReceiptCommand findTarget(@Param("customerId") String customerId,
@Param("transferId") Long transferId, @Param("palletId") Long palletId);
}
@@ -0,0 +1,208 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceiptCommand;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptCommandMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
/** Durable per-source receipt intents share one physical reservation per transfer target pallet. */
@Service
public class WarehouseTransferReceiptCoordinator {
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferInventoryAllocationMapper allocations;
@Resource private WarehouseTransferReceiptMapper receipts;
@Resource private WarehouseTransferReceiptCommandMapper commands;
@Resource private WarehouseResourceGateway warehouse;
@Resource private DomesticWarehouseDirectoryClient directory;
@Resource private FleetWarehouseClient catalog;
@Resource private PlatformTransactionManager transactionManager;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public record Target(String warehouseId, String warehouseCode, String warehouseName,
Long palletId, String palletNo, Long locationId, String locationCode,
String fbaId, String fbaCode, String fbaName) { }
public record Context(Target target, long resourceLockId, long commandId) { }
private record Binding(String warehouseId, Long palletId, Long locationId, String fbaCode) { }
private record ResolvedTarget(Target target, String palletStatus) { }
private record Admission(WarehouseTransferOrder transfer, boolean replay) { }
public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Receipt request, Supplier<WarehouseTransferResponses.Detail> replay,
Function<Context, WarehouseTransferResponses.Detail> stockWork) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException("Receipt resource coordination must start outside the stock transaction");
}
String requestHash = hash(request);
Admission first = transaction().execute(status -> admit(customerId, transferNo, request, requestHash));
if (first.replay()) return replay.get();
ResolvedTarget resolved = resolveTarget(first.transfer(), request);
Target target = resolved.target();
WarehouseTransferReceiptCommand command = transaction().execute(status -> {
Admission current = admit(customerId, transferNo, request, requestHash);
if (current.replay()) return null;
return prepare(customerId, current.transfer(), request, requestHash, target, resolved.palletStatus());
});
if (command == null) return replay.get();
// Idempotent remote ownership survives a later local rollback. Never release it blindly.
long lockId = warehouse.reserve(customerId, command.getTargetWarehouseId(), command.getTargetPalletId(),
command.getTargetLocationId(), "WAREHOUSE_TRANSFER_RECEIPT", command.getTargetHash(), command.getReservationKey());
var activeWarehouse = warehouse.requireActiveSharedWarehouse(command.getTargetWarehouseId());
require(activeWarehouse != null && activeWarehouse.warehouseId() == command.getTargetWarehouseId()
&& "ACTIVE".equals(activeWarehouse.status()), "Receiving warehouse changed during reservation");
var occupied = warehouse.resolvePallet(command.getTargetPalletId());
require(lockId > 0 && occupied != null && occupied.palletId() == target.palletId()
&& occupied.warehouseId() == command.getTargetWarehouseId()
&& occupied.locationId() == target.locationId() && "OCCUPIED".equals(occupied.status())
&& target.palletNo().equals(occupied.palletNo()) && target.fbaCode().equals(occupied.fbaWarehouseCode()),
"Reserved target pallet no longer matches the receipt");
transaction().executeWithoutResult(status -> {
WarehouseTransferReceiptCommand current = lockCommand(command);
require(current.getResourceLockId() == null || current.getResourceLockId() == lockId,
"Receipt resource lock changed");
if (!"COMPLETED".equals(current.getStatus())) {
current.setResourceLockId(lockId); current.setStatus("RESERVED");
current.setUpdatedAt(LocalDateTime.now()); commands.updateById(current);
}
});
return transaction().execute(status -> {
Admission current = admit(customerId, transferNo, request, requestHash);
WarehouseTransferReceiptCommand owned = lockCommand(command);
if (current.replay()) return replay.get();
require("RESERVED".equals(owned.getStatus()) && Objects.equals(owned.getResourceLockId(), lockId),
"Receipt has no active reserved resource");
WarehouseTransferResponses.Detail result = stockWork.apply(new Context(target, lockId, owned.getId()));
owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now()); commands.updateById(owned);
return result;
});
}
private Admission admit(String customerId, String transferNo, WarehouseTransferRequests.Receipt request,
String requestHash) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse transfer not found");
var received = receipts.findSource(customerId, transfer.getId(), request.sourceInventoryPositionId());
if (received != null) {
require(requestHash.equals(received.getRequestHash()), "Receipt original request changed");
return new Admission(transfer, true);
}
require(Set.of("OUTBOUNDED", "PARTIALLY_RECEIVED").contains(transfer.getStatus()),
"Only outbound transfer stock can be received");
require(allocations.lockTransfer(customerId, transfer.getId()).stream().anyMatch(a ->
Objects.equals(a.getInventoryPositionId(), request.sourceInventoryPositionId()) && "SHIPPED".equals(a.getStatus())),
"Source position is not awaiting this transfer receipt");
WarehouseTransferReceiptCommand existing = commands.lockSource(customerId, transfer.getId(), request.sourceInventoryPositionId());
if (existing != null) require(existing.getRequestHash().equals(requestHash)
&& existing.getIdempotencyKey().equals(request.idempotencyKey().trim()), "Receipt original command changed");
return new Admission(transfer, false);
}
private WarehouseTransferReceiptCommand prepare(String customerId, WarehouseTransferOrder transfer,
WarehouseTransferRequests.Receipt request, String requestHash, Target target, String palletStatus) {
require(Objects.equals(transfer.getFbaWarehouseCode(), target.fbaCode()), "Frozen transfer destination changed");
String targetHash = hash(new Binding(target.warehouseId(), target.palletId(), target.locationId(), target.fbaCode()));
WarehouseTransferReceiptCommand shared = commands.findTarget(customerId, transfer.getId(), target.palletId());
require(!"OCCUPIED".equals(palletStatus) || shared != null, "Occupied target pallet is not reserved by this transfer");
if (shared != null) require(shared.getTargetHash().equals(targetHash), "Transfer target pallet binding changed");
WarehouseTransferReceiptCommand existing = commands.lockSource(customerId, transfer.getId(), request.sourceInventoryPositionId());
if (existing != null) {
require(existing.getTargetHash().equals(targetHash), "Receipt frozen target changed");
return existing;
}
require(commands.findKey(customerId, request.idempotencyKey().trim()) == null, "Receipt key belongs to another source");
var value = new WarehouseTransferReceiptCommand(); value.setId(IdWorker.getId()); value.setCustomerId(customerId);
value.setTransferOrderId(transfer.getId()); value.setSourceInventoryPositionId(request.sourceInventoryPositionId());
value.setIdempotencyKey(request.idempotencyKey().trim()); value.setRequestHash(requestHash); value.setRequestJson(json(request));
value.setTargetWarehouseId(Long.parseLong(target.warehouseId())); value.setTargetPalletId(target.palletId());
value.setTargetLocationId(target.locationId()); value.setTargetHash(targetHash); value.setTargetJson(json(target));
value.setReservationKey("WTR_RECEIPT_RES:" + transfer.getId() + ":" + target.palletId());
value.setStatus("PREPARED"); value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt());
commands.insert(value); return value;
}
private WarehouseTransferReceiptCommand lockCommand(WarehouseTransferReceiptCommand expected) {
var value = commands.lockSource(expected.getCustomerId(), expected.getTransferOrderId(), expected.getSourceInventoryPositionId());
require(value != null && Objects.equals(value.getId(), expected.getId())
&& value.getRequestHash().equals(expected.getRequestHash()) && value.getTargetHash().equals(expected.getTargetHash()),
"Receipt intent changed");
return value;
}
private ResolvedTarget resolveTarget(WarehouseTransferOrder transfer, WarehouseTransferRequests.Receipt request) {
long warehouseId = positive(request.destinationOverseasWarehouseId(), "Receiving warehouse ID is invalid");
var master = warehouse.requireActiveSharedWarehouse(warehouseId);
require(master != null && master.warehouseId() == warehouseId && "ACTIVE".equals(master.status()),
"Receiving warehouse is not active shared overseas receiving");
snapshot(request.destinationOverseasWarehouseCode(), master.warehouseCode(), "Receiving warehouse code changed");
snapshot(request.destinationOverseasWarehouseName(), master.warehouseName(), "Receiving warehouse name changed");
var pallet = warehouse.resolvePallet(request.destinationPalletId());
require(pallet != null && pallet.palletId() == request.destinationPalletId() && pallet.warehouseId() == warehouseId
&& Set.of("IDLE", "OCCUPIED").contains(pallet.status()) && request.destinationPalletNo().trim().equals(pallet.palletNo()),
"Target pallet does not belong to receiving warehouse");
long locationId = request.warehouseLocationId() == null ? pallet.locationId() : request.warehouseLocationId();
require(locationId > 0 && pallet.locationId() == locationId, "Target pallet is not bound to requested location");
var location = warehouse.resolveLocation(warehouseId, locationId);
require(location != null && location.locationId() == locationId && Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status()),
"Target location does not belong to receiving warehouse");
snapshot(request.warehouseLocationCode(), location.locationCode(), "Target location code changed");
String frozenFba = transfer.getFbaWarehouseCode();
require(frozenFba != null && !frozenFba.isBlank() && frozenFba.equals(request.destinationWarehouseCode().trim())
&& frozenFba.equals(pallet.fbaWarehouseCode()), "Target pallet FBA differs from frozen transfer destination");
var activeCatalog = catalog.activeWarehouses(internalToken);
require(activeCatalog != null && activeCatalog.successful() && activeCatalog.data() != null,
"Authoritative FBA warehouse catalog is unavailable");
var candidates = activeCatalog.data().stream().filter(w -> "FBA".equals(w.warehouseType())
&& frozenFba.equals(w.warehouseCode()) && w.id() != null && w.id() > 0).toList();
require(candidates.size() == 1, "Frozen FBA warehouse is absent or ambiguous");
long fbaId = candidates.get(0).id();
snapshot(transfer.getDestinationWarehouseId(), Long.toString(fbaId), "Frozen FBA ID is not authoritative");
snapshot(request.destinationWarehouseId(), Long.toString(fbaId), "Receipt FBA ID is not authoritative");
var response = directory.activeWarehouse(fbaId, internalToken);
var fba = response == null || response.data() == null ? null : response.data().warehouse();
require(response != null && response.successful() && fba != null && Long.valueOf(fbaId).equals(fba.id())
&& Long.valueOf(0).equals(fba.tenantId()) && "ACTIVE".equals(fba.status()) && "FBA".equals(fba.warehouseType())
&& frozenFba.equals(fba.warehouseCode()), "FBA warehouse does not match frozen destination");
snapshot(request.destinationWarehouseName(), fba.warehouseName(), "FBA warehouse name changed");
return new ResolvedTarget(new Target(Long.toString(warehouseId), master.warehouseCode(), master.warehouseName(), pallet.palletId(),
pallet.palletNo(), locationId, location.locationCode(), Long.toString(fbaId), frozenFba, fba.warehouseName()), pallet.status());
}
private TransactionTemplate transaction() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); return template;
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException("Receipt snapshot serialization failed", e); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException("Receipt hash failed", e); } }
private static long positive(String value, String message) { try { long id = Long.parseLong(value.trim()); require(id > 0, message); return id; } catch (NumberFormatException | NullPointerException e) { throw new ResponseStatusException(HttpStatus.CONFLICT, message); } }
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static void snapshot(String supplied, String expected, String message) { if (text(supplied) != null) require(Objects.equals(text(supplied), expected), message); }
private static void require(boolean value, String message) { if (!value) throw new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -126,6 +126,7 @@ public class WarehouseTransferService {
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelApplicationService relabelService;
@Resource private WarehouseResourceGateway warehouseResources;
@Resource private WarehouseTransferReceiptCoordinator receiptCoordinator;
@Resource private InboundPlanMapper inboundPlans;
@Resource private InboundPlanLineMapper inboundPlanLines;
@Resource private InboundPlanPalletMapper inboundPlanPallets;
@@ -1214,9 +1215,15 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Receipt request) {
return receiptCoordinator.receive(customerId, transferNo, operatorId, request,
() -> detail(customerId, transferNo),
context -> receiveReserved(customerId, transferNo, operatorId, request, context));
}
private WarehouseTransferResponses.Detail receiveReserved(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Receipt request, WarehouseTransferReceiptCoordinator.Context context) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!Set.of(OUTBOUNDED, PARTIALLY_RECEIVED).contains(transfer.getStatus())) {
@@ -1247,17 +1254,17 @@ public class WarehouseTransferService {
if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) {
throw conflict("Source inventory balance is missing for warehouse transfer receipt");
}
WarehouseTransferReceiptCoordinator.Target target = context.target();
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(),
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
target.warehouseId(), target.warehouseCode(), target.warehouseName(), target.fbaId(),
target.fbaCode(), target.fbaName(),
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
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,
target.palletId(), target.palletNo(),
trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), target.locationId(),
target.locationCode(), allocation.getPackageCount(), transferNo,
"WAREHOUSE_TRANSFER_RECEIPT:" + transferNo, operationKey, trim(request.remark())));
LocalDateTime now = LocalDateTime.now();
WarehouseTransferReceipt receipt = new WarehouseTransferReceipt();
@@ -1075,6 +1075,40 @@ paths:
responses:
'200': { description: 原转运详情;allocations内skuCode为锁定的实际发货SKU,换标后返回新SKU;不包括已被换标替换的历史分配 }
'409': { description: 来源不可用或幂等载荷冲突 }
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/receipts:
post:
summary: 已有库存转运逐来源在目标仓实收
description: 权限lastleg:warehouse-transfer:manage;必须指定customerId。只接收本票SHIPPED来源,服务端读取实际OVERSEAS/ACTIVE/RECEIVING仓、托盘及绑定库位、权威FBA主档并核对冻结目的仓。先持久化完整原请求,再占用资源;同转运同目标托盘的多来源共享一次ACTIVE资源锁。远端成功但本地事务失败时保留原命令,原键原载荷重试继续;新键或变更载荷不能绕过。库存、收货记录和最终换标费用结算命令同本地事务提交,成功重放不重复入账或占用,未知结果须先核对原命令。
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: [sourceInventoryPositionId, destinationOverseasWarehouseId, destinationWarehouseCode, destinationPalletId, destinationPalletNo, idempotencyKey]
properties:
sourceInventoryPositionId: { type: integer, format: int64 }
destinationOverseasWarehouseId: { type: string, maxLength: 128 }
destinationOverseasWarehouseCode: { type: string, nullable: true, maxLength: 128 }
destinationOverseasWarehouseName: { type: string, nullable: true, maxLength: 255 }
destinationWarehouseId: { type: string, nullable: true, maxLength: 128 }
destinationWarehouseCode: { type: string, maxLength: 128 }
destinationWarehouseName: { type: string, nullable: true, maxLength: 255 }
destinationPalletId: { type: integer, format: int64 }
destinationPalletNo: { type: string, maxLength: 128 }
batchNo: { type: string, nullable: true, maxLength: 128 }
warehouseLocationId: { type: integer, format: int64, nullable: true, description: 未传时使用该实物托盘绑定库位 }
warehouseLocationCode: { type: string, nullable: true, maxLength: 128 }
idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 1000 }
responses:
'200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;原键原载荷返回原收货结果 }
'409': { description: 原请求冲突、来源状态或目标资源归属不匹配;不得换键绕过 }
'503': { description: 资源服务不可用,保留原收货命令待核对恢复 }
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound:
post:
summary: 已有库存转运实际出库
@@ -21,6 +21,53 @@ class HttpWarehouseResourceGatewayTest {
private HttpWarehouseResourceGateway gateway;
private MockRestServiceServer server;
@Test
void reserveAcceptsOnlyCompleteMatchingActiveLockWithStringEncodedLongs() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"200","data":{"lockId":"51","customerId":"1","warehouseId":"2",
"palletId":"3","locationId":"4","businessType":"WAREHOUSE_TRANSFER_RECEIPT",
"businessId":"target-hash","reservationKey":"resource-1","status":"ACTIVE"}}
""", MediaType.APPLICATION_JSON));
assertThat(gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1")).isEqualTo(51);
server.verify();
}
@Test
void reserveRejectsReleasedHistoricalLockRatherThanTreatingOldKeyAsNewOccupancy() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"200","data":{"lockId":51,"customerId":1,"warehouseId":2,"palletId":3,
"locationId":4,"businessType":"WAREHOUSE_TRANSFER_RECEIPT","businessId":"target-hash",
"reservationKey":"resource-1","status":"RELEASED"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
server.verify();
}
@Test
void reserveRejectsWrongOwnershipEvenWhenReturnedLockIsActive() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"200","data":{"lockId":51,"customerId":99,"warehouseId":2,"palletId":3,
"locationId":4,"businessType":"WAREHOUSE_TRANSFER_RECEIPT","businessId":"target-hash",
"reservationKey":"resource-1","status":"ACTIVE"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
server.verify();
}
@Test
void reserveRejectsMissingActiveStateInsteadOfAcceptingBareLockId() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"lockId\":51}}", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
server.verify();
}
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.transfer;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseTransferReceiptOpenApiContractTest {
@Test
@SuppressWarnings("unchecked")
void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() {
Map<String,Object> document = new Yaml().load(getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml"));
var paths=(Map<String,Object>)document.get("paths");
var path=(Map<String,Object>)paths.get("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/receipts");
assertThat(path).isNotNull();
var post=(Map<String,Object>)path.get("post");
assertThat((String)post.get("description")).contains("lastleg:warehouse-transfer:manage", "原键原载荷", "共享一次ACTIVE资源锁");
var body=(Map<String,Object>)post.get("requestBody");
var content=(Map<String,Object>)body.get("content");
var media=(Map<String,Object>)content.get("application/json");
var schema=(Map<String,Object>)media.get("schema");
var properties=(Map<String,Object>)schema.get("properties");
Set<String> fields=Arrays.stream(WarehouseTransferRequests.Receipt.class.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName).collect(Collectors.toSet());
assertThat(properties.keySet()).isEqualTo(fields);
assertThat((List<String>)schema.get("required")).containsExactlyInAnyOrder(
"sourceInventoryPositionId","destinationOverseasWarehouseId","destinationWarehouseCode",
"destinationPalletId","destinationPalletNo","idempotencyKey");
assertThat((Map<String,Object>)post.get("responses")).containsKeys("200","409","503");
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.*;
import com.baiyue.lastleg.transfer.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WarehouseTransferReceiptCoordinatorTest {
JdbcTemplate jdbc; SqlSessionTemplate session; WarehouseTransferReceiptCoordinator coordinator;
WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; FleetWarehouseClient catalog;
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
AtomicInteger occupancies = new AtomicInteger(), reserveCalls = new AtomicInteger(), stockCalls = new AtomicInteger();
AtomicBoolean loseReserveResponse = new AtomicBoolean(), failLocal = new AtomicBoolean();
Map<Long, RemoteLock> remote = new HashMap<>();
record RemoteLock(String customer, String key, String hash, long warehouse, long location, long lockId) { }
@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);
for (Class<?> type : List.of(WarehouseTransferOrder.class, WarehouseTransferInventoryAllocation.class,
WarehouseTransferReceipt.class, WarehouseTransferReceiptCommand.class)) {
List<String> fields = new ArrayList<>();
for (var f : type.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
String name = f.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
String sqlType = f.getType() == String.class ? "VARCHAR(16000)" : f.getType() == LocalDateTime.class
? "TIMESTAMP" : f.getType() == BigDecimal.class ? "DECIMAL(24,6)" : f.getType() == Boolean.class ? "BOOLEAN" : "BIGINT";
fields.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" : ""));
}
jdbc.execute("CREATE TABLE " + type.getAnnotation(TableName.class).value() + " (" + String.join(",", fields) + ")");
}
jdbc.execute("CREATE UNIQUE INDEX receipt_command_source ON ll_warehouse_transfer_receipt_command(transfer_order_id,source_inventory_position_id)");
jdbc.execute("CREATE UNIQUE INDEX receipt_command_key ON ll_warehouse_transfer_receipt_command(customer_id,idempotency_key)");
jdbc.execute("CREATE UNIQUE INDEX receipt_source ON ll_warehouse_transfer_receipt(transfer_order_id,source_inventory_position_id)");
jdbc.execute("CREATE TABLE local_effect(type VARCHAR(30), source_id BIGINT)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for (Class<?> mapper : List.of(WarehouseTransferOrderMapper.class, WarehouseTransferInventoryAllocationMapper.class,
WarehouseTransferReceiptMapper.class, WarehouseTransferReceiptCommandMapper.class)) configuration.addMapper(mapper);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
session = new SqlSessionTemplate(factory.getObject());
coordinator = new WarehouseTransferReceiptCoordinator();
ReflectionTestUtils.setField(coordinator, "transfers", session.getMapper(WarehouseTransferOrderMapper.class));
ReflectionTestUtils.setField(coordinator, "allocations", session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
ReflectionTestUtils.setField(coordinator, "receipts", session.getMapper(WarehouseTransferReceiptMapper.class));
ReflectionTestUtils.setField(coordinator, "commands", session.getMapper(WarehouseTransferReceiptCommandMapper.class));
ReflectionTestUtils.setField(coordinator, "transactionManager", new DataSourceTransactionManager(ds));
ReflectionTestUtils.setField(coordinator, "objectMapper", json);
warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(FleetWarehouseClient.class);
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "directory", directory);
ReflectionTestUtils.setField(coordinator, "catalog", catalog); ReflectionTestUtils.setField(coordinator, "internalToken", "test-token");
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
when(warehouse.resolvePallet(anyLong())).thenAnswer(call -> pallet(call.getArgument(0), remote.containsKey(call.getArgument(0)) ? "OCCUPIED" : "IDLE"));
when(warehouse.resolveLocation(eq(9L), anyLong())).thenAnswer(call -> new WarehouseResourceGateway.WarehouseLocationReference(
call.getArgument(1), "LOC-" + call.getArgument(1), "STORAGE", new BigDecimal("1000"), "ACTIVE"));
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive(), "Remote reserve must not hold local transaction locks");
reserveCalls.incrementAndGet(); long pallet = call.getArgument(2), wh = call.getArgument(1), location = call.getArgument(3);
String customer = call.getArgument(0), key = call.getArgument(6), hash = call.getArgument(5);
RemoteLock existing = remote.get(pallet);
if (existing != null && (!existing.customer.equals(customer) || !existing.key.equals(key) || !existing.hash.equals(hash)
|| existing.warehouse != wh || existing.location != location)) throw conflict("Other reservation owns pallet");
if (existing == null) { existing = new RemoteLock(customer, key, hash, wh, location, 500L + pallet); remote.put(pallet, existing); occupancies.incrementAndGet(); }
if (loseReserveResponse.getAndSet(false)) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "response lost after reserve commit");
return existing.lockId;
});
addTransfer(1, "WT-1", 101);
}
private WarehouseResourceGateway.WarehousePalletReference pallet(long id, String state) {
return new WarehouseResourceGateway.WarehousePalletReference(id, "PAL-" + id, 9, id + 20, state, "ABE4", remote.containsKey(id) ? 1 : 0);
}
private void addTransfer(long id, String no, long sourceId) {
var head = new WarehouseTransferOrder(); head.setId(id); head.setCustomerId("7"); head.setTransferNo(no);
head.setStatus("OUTBOUNDED"); head.setFbaWarehouseCode("ABE4"); head.setDestinationWarehouseId("30"); head.setDestinationWarehouseName("ABE4 warehouse");
session.getMapper(WarehouseTransferOrderMapper.class).insert(head); addSource(id, sourceId);
}
private void addSource(long transfer, long source) {
var allocation = new WarehouseTransferInventoryAllocation(); allocation.setId(source + 1000); allocation.setCustomerId("7");
allocation.setTransferOrderId(transfer); allocation.setInventoryPositionId(source); allocation.setPackageCount(1); allocation.setStatus("SHIPPED");
session.getMapper(WarehouseTransferInventoryAllocationMapper.class).insert(allocation);
}
private WarehouseTransferRequests.Receipt request(long source, long pallet, String key) {
return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse",
pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST");
}
private void receive(String no, WarehouseTransferRequests.Receipt request) {
coordinator.receive("7", no, "operator", request, () -> null, context -> {
assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet();
assertEquals(30L, Long.parseLong(context.target().fbaId())); assertTrue(context.resourceLockId() > 0);
jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('STOCK_IN',?)", request.sourceInventoryPositionId());
jdbc.update("UPDATE ll_warehouse_transfer_inventory_allocation SET status='RECEIVED' WHERE customer_id='7' AND inventory_position_id=?", request.sourceInventoryPositionId());
var head = session.getMapper(WarehouseTransferOrderMapper.class).findNo("7", no);
var receipt = new WarehouseTransferReceipt(); receipt.setId(request.sourceInventoryPositionId() + 2000);
receipt.setCustomerId("7"); receipt.setTransferOrderId(head.getId()); receipt.setSourceInventoryPositionId(request.sourceInventoryPositionId());
receipt.setRequestHash(hash(request)); receipt.setIdempotencyKey(request.idempotencyKey()); receipt.setPackageCount(1);
session.getMapper(WarehouseTransferReceiptMapper.class).insert(receipt);
long left = jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation WHERE transfer_order_id=? AND status='SHIPPED'", Long.class, head.getId());
jdbc.update("UPDATE ll_warehouse_transfer_order SET status=? WHERE id=?", left == 0 ? "RECEIVED" : "PARTIALLY_RECEIVED", head.getId());
if (left == 0) { jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('FEE_APPROVED',?)", request.sourceInventoryPositionId()); jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('SETTLE',?)", request.sourceInventoryPositionId()); }
if (failLocal.getAndSet(false)) throw new IllegalStateException("local stock/fee transaction failed");
return null;
});
}
private String hash(Object value) { try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } catch (Exception e) { throw new AssertionError(e); } }
private long count(String table) { return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); }
private String state(long source) { return jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_receipt_command WHERE source_inventory_position_id=?", String.class, source); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@Test void originalReceiptReplayDoesNotReserveOrStockAgainEvenAfterPhysicalResourceChanges() {
var request = request(101, 20, "KEY-1"); receive("WT-1", request); remote.clear(); receive("WT-1", request);
assertEquals(1, reserveCalls.get()); assertEquals(1, occupancies.get()); assertEquals(1, stockCalls.get());
assertEquals(1, count("ll_warehouse_transfer_receipt")); assertEquals("COMPLETED", state(101));
}
@Test void multipleSourcesShareOnePhysicalReservationAndCompleteDistinctReceipts() {
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1")); receive("WT-1", request(102, 20, "KEY-2"));
assertEquals(1, occupancies.get()); assertEquals(2, stockCalls.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(DISTINCT resource_lock_id) FROM ll_warehouse_transfer_receipt_command", Long.class));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(DISTINCT target_hash) FROM ll_warehouse_transfer_receipt_command", Long.class));
assertEquals(2L, jdbc.queryForObject("SELECT COUNT(DISTINCT request_hash) FROM ll_warehouse_transfer_receipt_command", Long.class));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='SETTLE'", Long.class));
}
@Test void remoteCommittedButLocalStockAndFeeFailureRollsBackThenSameRequestRecovers() {
var request = request(101, 20, "KEY-1"); failLocal.set(true);
assertThrows(IllegalStateException.class, () -> receive("WT-1", request));
assertEquals("RESERVED", state(101)); assertEquals(0, count("local_effect")); assertEquals(0, count("ll_warehouse_transfer_receipt"));
assertEquals("SHIPPED", jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE inventory_position_id=101", String.class));
receive("WT-1", request); assertEquals(1, occupancies.get()); assertEquals("COMPLETED", state(101));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class));
verify(warehouse, never()).release(anyString(), anyLong());
}
@Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() {
loseReserveResponse.set(true); var request = request(101, 20, "KEY-1");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request));
assertEquals("PREPARED", state(101)); assertEquals(0, stockCalls.get()); assertEquals(1, occupancies.get());
receive("WT-1", request); assertEquals("COMPLETED", state(101)); assertEquals(1, occupancies.get());
}
@Test void failedFirstSourceDoesNotPreventSecondSourceThenOriginalRecoveryOnSamePallet() {
addSource(1, 102); failLocal.set(true); var first = request(101, 20, "KEY-1");
assertThrows(IllegalStateException.class, () -> receive("WT-1", first));
receive("WT-1", request(102, 20, "KEY-2")); receive("WT-1", first);
assertEquals(1, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='SETTLE'", Long.class));
}
@Test void changedOriginalKeyPayloadOrTargetCannotBypassPreparedIntent() {
failLocal.set(true); assertThrows(IllegalStateException.class, () -> receive("WT-1", request(101, 20, "KEY-1")));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "NEW-KEY")));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 21, "KEY-1")));
var original = request(101, 20, "KEY-1"); var changed = new WarehouseTransferRequests.Receipt(101L, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", 20L, "PAL-20", "CHANGED-BATCH", 40L, "LOC-40", "KEY-1", "TEST");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", changed));
assertEquals(1, reserveCalls.get()); assertEquals(1, count("ll_warehouse_transfer_receipt_command")); assertEquals(0, count("ll_warehouse_transfer_receipt"));
}
@Test void sameKeyCannotBeReusedForAnotherSource() {
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1"));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(102, 20, "KEY-1")));
assertEquals(1, occupancies.get()); assertEquals(1, count("ll_warehouse_transfer_receipt_command"));
}
@Test void occupiedPalletOfAnotherTransferIsNotReused() {
receive("WT-1", request(101, 20, "KEY-1")); addTransfer(2, "WT-2", 201);
assertThrows(ResponseStatusException.class, () -> receive("WT-2", request(201, 20, "KEY-3")));
assertEquals(1, occupancies.get()); assertEquals(1, count("ll_warehouse_transfer_receipt"));
assertEquals(1, count("ll_warehouse_transfer_receipt_command"));
receive("WT-2", request(201, 21, "KEY-3"));
assertEquals(2, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
}
@Test void twoRealResourcesInSameWarehouseStillRequireExactPalletLocationAssociation() {
var wrong = new WarehouseTransferRequests.Receipt(101L, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", 20L, "PAL-20", "BATCH", 41L, "LOC-41", "KEY-1", "TEST");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", wrong)); assertEquals(0, reserveCalls.get()); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void omittedFbaIdsStillResolveAuthoritativeCatalogAndActivePublicMaster() {
jdbc.update("UPDATE ll_warehouse_transfer_order SET destination_warehouse_id=NULL WHERE id=1");
var request = new WarehouseTransferRequests.Receipt(101L, "9", null, null, null, "ABE4", null, 20L, "PAL-20", "BATCH", null, null, "KEY-1", "TEST");
receive("WT-1", request); verify(catalog).activeWarehouses("test-token"); verify(directory).activeWarehouse(30L, "test-token");
assertEquals(1, occupancies.get());
}
@Test void omittedIdsDoNotAllowStringOnlyFbaWhenAuthorityHasNoMatchingWarehouse() {
jdbc.update("UPDATE ll_warehouse_transfer_order SET destination_warehouse_id=NULL WHERE id=1");
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of()));
var request = new WarehouseTransferRequests.Receipt(101L, "9", null, null, null, "ABE4", null, 20L, "PAL-20", "BATCH", null, null, "KEY-1", "TEST");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request)); assertEquals(0, count("ll_warehouse_transfer_receipt_command")); assertEquals(0, occupancies.get());
}
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE"), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED"), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "DISABLED"));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1")));
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
when(warehouse.resolvePallet(20L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(20, "PAL-20", 99, 40, "IDLE", "ABE4", 0));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void wrongFrozenFbaAndWrongMasterIdAreRejected() {
when(warehouse.resolvePallet(20L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(20, "PAL-20", 9, 40, "IDLE", "ABE3", 0));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(999, 20, "KEY-1")));
assertEquals(0, reserveCalls.get()); verify(warehouse, never()).requireActiveSharedWarehouse(anyLong());
}
@Test void legitimateDifferentTargetPalletsReceiveIndependentSourcesWithoutReducingCapability() {
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1")); receive("WT-1", request(102, 21, "KEY-2"));
assertEquals(2, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
assertEquals(2L, jdbc.queryForObject("SELECT COUNT(DISTINCT resource_lock_id) FROM ll_warehouse_transfer_receipt_command", Long.class));
}
}
@@ -173,10 +173,23 @@ class WarehouseTransferServiceTest {
@Mock InventoryCommandMapper inventoryCommands;
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
private WarehouseTransferService service;
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@BeforeEach
void setUp() {
service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "receiptCoordinator", receiptCoordinator);
lenient().when(receiptCoordinator.receive(anyString(), anyString(), anyString(), any(), any(), any()))
.thenAnswer(call -> {
WarehouseTransferRequests.Receipt request = call.getArgument(3);
var target = new WarehouseTransferReceiptCoordinator.Target(request.destinationOverseasWarehouseId(),
request.destinationOverseasWarehouseCode(), request.destinationOverseasWarehouseName(),
request.destinationPalletId(), request.destinationPalletNo(), request.warehouseLocationId(),
request.warehouseLocationCode(), request.destinationWarehouseId(), request.destinationWarehouseCode(),
request.destinationWarehouseName());
java.util.function.Function<WarehouseTransferReceiptCoordinator.Context, com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail> work = call.getArgument(5);
return work.apply(new WarehouseTransferReceiptCoordinator.Context(target, 51L, 61L));
});
ReflectionTestUtils.setField(service, "transfers", transfers);
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);