fix(transfer): reserve receipt resources with durable recovery intents
This commit is contained in:
+11
-2
@@ -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) {}
|
||||
|
||||
+29
@@ -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;
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
+208
@@ -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); }
|
||||
}
|
||||
+14
-7
@@ -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: 已有库存转运实际出库
|
||||
|
||||
+47
@@ -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();
|
||||
|
||||
+36
@@ -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");
|
||||
}
|
||||
}
|
||||
+245
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user