fix(transfer): preserve inbound ownership and expose durable receipt recovery

This commit is contained in:
2026-10-05 06:34:23 +08:00
parent 47acfc991b
commit 548a133de6
10 changed files with 306 additions and 16 deletions
@@ -968,7 +968,7 @@ public class RelabelApplicationService implements WalletResultHandler {
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
String transferOrderBefore = transferOrder.getCurrentStatus(); String transferOrderBefore = transferOrder.getCurrentStatus();
transferOrder.setCurrentStatus(transferStatus); if (!existingStockTransfer) transferOrder.setCurrentStatus(transferStatus);
transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
transferOrder.setRelabelStatus("COMPLETED"); transferOrder.setRelabelStatus("COMPLETED");
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED");
@@ -26,6 +26,11 @@ public final class WarehouseTransferResponses {
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status, Long palletId, String palletNo, int packageCount, String status,
Long destinationInventoryPositionId, String skuCode) {} Long destinationInventoryPositionId, String skuCode) {}
public record ReceiptTarget(String warehouseId, String warehouseCode, String warehouseName,
Long palletId, String palletNo, Long locationId, String locationCode,
String fbaId, String fbaCode, String fbaName) {}
public record ReceiptCommand(Long sourceInventoryPositionId, String status, String requestHash,
WarehouseTransferRequests.Receipt request, ReceiptTarget target) {}
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
String destinationType, String destinationWarehouseCode, String destinationWarehouseName, String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
@@ -51,6 +56,7 @@ public final class WarehouseTransferResponses {
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons, List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
List<Pallet> pallets, List<RelabelBinding> relabelBindings, List<Pallet> pallets, List<RelabelBinding> relabelBindings,
List<Allocation> allocations, List<MeasurementPhoto> measurementPhotos, List<Allocation> allocations, List<MeasurementPhoto> measurementPhotos,
List<ReceiptCommand> receiptCommands,
LocalDateTime customerConfirmedAt, LocalDateTime customerConfirmedAt,
LocalDateTime actualShipAt, LocalDateTime createdAt) {} LocalDateTime actualShipAt, LocalDateTime createdAt) {}
} }
@@ -8,6 +8,11 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface WarehouseTransferReceiptCommandMapper extends BaseMapper<WarehouseTransferReceiptCommand> { public interface WarehouseTransferReceiptCommandMapper extends BaseMapper<WarehouseTransferReceiptCommand> {
@Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " +
"AND transfer_order_id=#{transferId} ORDER BY id")
java.util.List<WarehouseTransferReceiptCommand> listTransfer(@Param("customerId") String customerId,
@Param("transferId") Long transferId);
@Select("SELECT * FROM ll_warehouse_transfer_receipt_command WHERE customer_id=#{customerId} " + @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") "AND transfer_order_id=#{transferId} AND source_inventory_position_id=#{sourceId} LIMIT 1 FOR UPDATE")
WarehouseTransferReceiptCommand lockSource(@Param("customerId") String customerId, WarehouseTransferReceiptCommand lockSource(@Param("customerId") String customerId,
@@ -54,6 +54,72 @@ public class WarehouseTransferReceiptCoordinator {
private record ResolvedTarget(Target target, String palletStatus) { } private record ResolvedTarget(Target target, String palletStatus) { }
private record Admission(WarehouseTransferOrder transfer, boolean replay) { } private record Admission(WarehouseTransferOrder transfer, boolean replay) { }
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public java.util.List<WarehouseTransferResponses.ReceiptCommand> receiptCommands(String customerId, long transferId) {
var transfer = transfers.selectById(transferId);
if (transfer == null || !Objects.equals(customerId, transfer.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse transfer order not found");
}
var rows = commands.listTransfer(customerId, transferId);
require(rows != null, "持久实收命令读取失败,请联系管理员核查");
java.util.Set<Long> sources = new java.util.HashSet<>();
return rows.stream().map(row -> {
require(row != null && Objects.equals(customerId, row.getCustomerId()) && Objects.equals(transferId, row.getTransferOrderId())
&& row.getSourceInventoryPositionId() != null && row.getSourceInventoryPositionId() > 0
&& sources.add(row.getSourceInventoryPositionId())
&& row.getStatus() != null && Set.of("PREPARED", "RESERVED", "COMPLETED").contains(row.getStatus()),
"持久实收命令归属或状态无效,请联系管理员核查");
var request = readSnapshot(row.getRequestJson(), WarehouseTransferRequests.Receipt.class);
var target = readSnapshot(row.getTargetJson(), Target.class);
require(Objects.equals(request.sourceInventoryPositionId(), row.getSourceInventoryPositionId())
&& text(request.idempotencyKey()) != null && text(request.idempotencyKey()).equals(row.getIdempotencyKey())
&& Objects.equals(hash(request), row.getRequestHash()), "持久实收原请求损坏,请联系管理员核查");
long warehouseId = positive(target.warehouseId(), "持久实收目标损坏,请联系管理员核查");
require(target.palletId() != null && target.palletId() > 0 && target.locationId() != null && target.locationId() > 0
&& text(target.warehouseCode()) != null && text(target.warehouseName()) != null
&& text(target.palletNo()) != null && text(target.locationCode()) != null && text(target.fbaCode()) != null
&& text(target.fbaName()) != null && positive(target.fbaId(), "持久实收目标损坏,请联系管理员核查") > 0
&& Objects.equals(row.getTargetWarehouseId(), warehouseId)
&& Objects.equals(row.getTargetPalletId(), target.palletId())
&& Objects.equals(row.getTargetLocationId(), target.locationId())
&& Objects.equals(row.getTargetHash(), hash(new Binding(target.warehouseId(), target.palletId(), target.locationId(), target.fbaCode())))
&& positive(request.destinationOverseasWarehouseId(), "持久实收原请求损坏,请联系管理员核查") == warehouseId
&& Objects.equals(request.destinationPalletId(), target.palletId())
&& Objects.equals(text(request.destinationPalletNo()), target.palletNo())
&& Objects.equals(text(request.destinationWarehouseCode()), target.fbaCode())
&& (text(request.destinationWarehouseId()) == null || positive(request.destinationWarehouseId(),
"持久实收原请求损坏,请联系管理员核查") == positive(target.fbaId(), "持久实收目标损坏,请联系管理员核查"))
&& (request.warehouseLocationId() == null || Objects.equals(request.warehouseLocationId(), target.locationId()))
&& ("PREPARED".equals(row.getStatus()) || row.getResourceLockId() != null && row.getResourceLockId() > 0),
"持久实收目标损坏,请联系管理员核查");
if ("COMPLETED".equals(row.getStatus())) {
var receipt = receipts.findSource(customerId, transferId, row.getSourceInventoryPositionId());
require(receipt != null && Objects.equals(receipt.getCustomerId(), customerId)
&& Objects.equals(receipt.getTransferOrderId(), transferId)
&& Objects.equals(receipt.getRequestHash(), row.getRequestHash())
&& Objects.equals(receipt.getIdempotencyKey(), row.getIdempotencyKey()),
"持久实收完成凭据缺失,请联系管理员核查");
}
return new WarehouseTransferResponses.ReceiptCommand(row.getSourceInventoryPositionId(), row.getStatus(),
row.getRequestHash(), request, new WarehouseTransferResponses.ReceiptTarget(target.warehouseId(),
target.warehouseCode(), target.warehouseName(), target.palletId(), target.palletNo(),
target.locationId(), target.locationCode(), target.fbaId(), target.fbaCode(), target.fbaName()));
}).toList();
}
private <T> T readSnapshot(String payload, Class<T> type) {
try {
com.fasterxml.jackson.databind.JsonNode tree = objectMapper.reader().with(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload);
require(tree != null && tree.isObject(), "持久实收快照损坏,请联系管理员核查");
var fields = java.util.Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName)
.collect(java.util.stream.Collectors.toSet());
tree.fieldNames().forEachRemaining(field -> require(fields.contains(field), "持久实收快照含未知字段,请联系管理员核查"));
return objectMapper.treeToValue(tree, type);
} catch (ResponseStatusException failure) { throw failure; }
catch (Exception failure) { throw new ResponseStatusException(HttpStatus.CONFLICT, "持久实收快照损坏,请联系管理员核查"); }
}
public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId, public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Receipt request, Supplier<WarehouseTransferResponses.Detail> replay, WarehouseTransferRequests.Receipt request, Supplier<WarehouseTransferResponses.Detail> replay,
Function<Context, WarehouseTransferResponses.Detail> stockWork) { Function<Context, WarehouseTransferResponses.Detail> stockWork) {
@@ -99,9 +165,11 @@ public class WarehouseTransferReceiptCoordinator {
if (current.replay()) return replay.get(); if (current.replay()) return replay.get();
require("RESERVED".equals(owned.getStatus()) && Objects.equals(owned.getResourceLockId(), lockId), require("RESERVED".equals(owned.getStatus()) && Objects.equals(owned.getResourceLockId(), lockId),
"Receipt has no active reserved resource"); "Receipt has no active reserved resource");
WarehouseTransferResponses.Detail result = stockWork.apply(new Context(target, lockId, owned.getId())); stockWork.apply(new Context(target, lockId, owned.getId()));
owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now()); commands.updateById(owned); owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now());
return result; require(commands.updateById(owned) == 1, "Receipt completion changed concurrently");
// Read inside the same transaction after completion, so the response cannot leak the earlier RESERVED view.
return replay.get();
}); });
} }
@@ -601,6 +601,7 @@ public class WarehouseTransferService {
transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(), transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(),
transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(),
transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos, transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos,
receiptCoordinator.receiptCommands(customerId, transfer.getId()),
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
} }
@@ -1129,8 +1130,7 @@ public class WarehouseTransferService {
transfer.setAllocationIdempotencyKey(key); transfer.setAllocationIdempotencyKey(key);
transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus( String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED);
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? INBOUND_COMPLETED : "WAIT_OUTBOUND");
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW"); order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW");
} }
@@ -1203,7 +1203,7 @@ public class WarehouseTransferService {
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); String before = order.getCurrentStatus();
order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now);
orders.updateById(order); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
@@ -1254,12 +1254,16 @@ public class WarehouseTransferService {
if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) { if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) {
throw conflict("Source inventory balance is missing for warehouse transfer receipt"); throw conflict("Source inventory balance is missing for warehouse transfer receipt");
} }
if (sourceBalance.getInboundOrderId() == null || sourceBalance.getInboundOrderId() <= 0
|| !StringUtils.hasText(sourceBalance.getInboundOrderNo()) || !StringUtils.hasText(sourceBalance.getInboundCode())) {
throw conflict("Source inventory inbound identity is missing for warehouse transfer receipt");
}
WarehouseTransferReceiptCoordinator.Target target = context.target(); WarehouseTransferReceiptCoordinator.Target target = context.target();
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(), new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(),
target.warehouseId(), target.warehouseCode(), target.warehouseName(), target.fbaId(), target.warehouseId(), target.warehouseCode(), target.warehouseName(), target.fbaId(),
target.fbaCode(), target.fbaName(), target.fbaCode(), target.fbaName(),
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, sourceBalance.getInboundOrderId(), sourceBalance.getInboundOrderNo(), sourceBalance.getInboundCode(),
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(), Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(),
sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(),
target.palletId(), target.palletNo(), target.palletId(), target.palletNo(),
@@ -1281,7 +1285,7 @@ public class WarehouseTransferService {
|| "RECEIVED".equals(value.getStatus())); || "RECEIVED".equals(value.getStatus()));
transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer); transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT"); String before = order.getCurrentStatus();
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED)); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED));
order.setUpdatedAt(now); orders.updateById(order); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
@@ -1106,7 +1106,7 @@ paths:
idempotencyKey: { type: string, maxLength: 255 } idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
responses: responses:
'200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;原键原载荷返回原收货结果 } '200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;receiptCommands包含真正COMPLETED的本次命令,原键原载荷返回持久结果, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } }
'409': { description: 原请求冲突、来源状态或目标资源归属不匹配;不得换键绕过 } '409': { description: 原请求冲突、来源状态或目标资源归属不匹配;不得换键绕过 }
'503': { description: 资源服务不可用,保留原收货命令待核对恢复 } '503': { description: 资源服务不可用,保留原收货命令待核对恢复 }
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound: /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound:
@@ -1193,7 +1193,7 @@ paths:
description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。 description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户仓储转运单详情 }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 } } responses: { '200': { description: 当前客户仓储转运单详情,receiptCommands仅本客户本票;未知状态或损坏快照拒绝返回,不当作新请求, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 }, '409': { description: 持久实收命令状态、原请求或目标损坏,须核查原命令 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement: /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement:
post: post:
summary: 客户确认仓储转运实测数据 summary: 客户确认仓储转运实测数据
@@ -1844,6 +1844,62 @@ paths:
responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } }
components: components:
schemas: schemas:
WarehouseTransferDetailApiResponse:
type: object
properties:
data: { $ref: '#/components/schemas/WarehouseTransferDetail' }
WarehouseTransferDetail:
type: object
description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。
required: [receiptCommands]
properties:
receiptCommands:
type: array
items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' }
WarehouseTransferReceiptCommand:
type: object
additionalProperties: false
required: [sourceInventoryPositionId, status, requestHash, request, target]
properties:
sourceInventoryPositionId: { type: string, description: 原来源库存位置标识,仅用于原命令恢复 }
status: { type: string, enum: [PREPARED, RESERVED, COMPLETED] }
requestHash: { type: string, pattern: '^[a-f0-9]{64}$' }
request: { $ref: '#/components/schemas/WarehouseTransferReceiptRequest' }
target: { $ref: '#/components/schemas/WarehouseTransferReceiptTarget' }
WarehouseTransferReceiptTarget:
type: object
additionalProperties: false
required: [warehouseId, warehouseCode, warehouseName, palletId, palletNo, locationId, locationCode, fbaId, fbaCode, fbaName]
properties:
warehouseId: { type: string }
warehouseCode: { type: string }
warehouseName: { type: string }
palletId: { type: string }
palletNo: { type: string }
locationId: { type: string }
locationCode: { type: string }
fbaId: { type: string }
fbaCode: { type: string }
fbaName: { type: string }
WarehouseTransferReceiptRequest:
type: object
additionalProperties: false
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 }
warehouseLocationCode: { type: string, nullable: true, maxLength: 128 }
idempotencyKey: { type: string, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 1000 }
ExceptionOrderDetailResponse: ExceptionOrderDetailResponse:
type: object type: object
properties: properties:
@@ -714,14 +714,14 @@ class RelabelApplicationServiceTest {
var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001"); var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001");
transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L); transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L);
transfer.setAllocationIdempotencyKey("ORIGINAL-SOURCE");transfer.setFbaWarehouseCode("ONT8"); transfer.setAllocationIdempotencyKey("ORIGINAL-SOURCE");transfer.setFbaWarehouseCode("ONT8");
var transferOrder=order(88,"10001","LMO-TRANSFER-1","WAIT_RELABEL"); var transferOrder=order(88,"10001","LMO-TRANSFER-1","INBOUND_COMPLETED");
when(applications.lockNo("10001","FBR-1")).thenReturn(application);stubDetail(application); when(applications.lockNo("10001","FBR-1")).thenReturn(application);stubDetail(application);
when(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer); when(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer);
when(orders.selectById(88L)).thenReturn(transferOrder); when(orders.selectById(88L)).thenReturn(transferOrder);
var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted"); var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted");
service.confirmCompletion("10001","FBR-1","customer-1",command); service.confirmCompletion("10001","FBR-1","customer-1",command);
assertEquals("ALLOCATED",transfer.getStatus());assertEquals("WAIT_TRANSFER_OUTBOUND",application.getFulfillmentStatus()); assertEquals("ALLOCATED",transfer.getStatus());assertEquals("WAIT_TRANSFER_OUTBOUND",application.getFulfillmentStatus());
assertEquals("ALLOCATED",transferOrder.getCurrentStatus()); assertEquals("INBOUND_COMPLETED",transferOrder.getCurrentStatus());
service.confirmCompletion("10001","FBR-1","customer-1",command); service.confirmCompletion("10001","FBR-1","customer-1",command);
verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any()); verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any());
verify(outboundOrders,never()).insert(any(OutboundOrder.class)); verify(outboundOrders,never()).insert(any(OutboundOrder.class));
@@ -11,6 +11,20 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class WarehouseTransferReceiptOpenApiContractTest { class WarehouseTransferReceiptOpenApiContractTest {
@Test
@SuppressWarnings("unchecked")
void durableProjectionDocumentsExactPublicRecordsWithoutInternalLocks() {
Map<String,Object> document=new Yaml().load(getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml"));
var schemas=(Map<String,Object>)((Map<String,Object>)document.get("components")).get("schemas");
for(var entry:Map.of("WarehouseTransferReceiptCommand",com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptCommand.class,
"WarehouseTransferReceiptTarget",com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptTarget.class,
"WarehouseTransferReceiptRequest",WarehouseTransferRequests.Receipt.class).entrySet()) {
var properties=(Map<String,Object>)((Map<String,Object>)schemas.get(entry.getKey())).get("properties");
assertThat(properties.keySet()).isEqualTo(Arrays.stream(entry.getValue().getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName).collect(Collectors.toSet()));
assertThat(properties).doesNotContainKeys("resourceLockId","reservationKey");
}
}
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() { void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() {
@@ -115,8 +115,13 @@ class WarehouseTransferReceiptCoordinatorTest {
return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse",
pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST"); pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST");
} }
private void receive(String no, WarehouseTransferRequests.Receipt request) { private com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail receive(String no, WarehouseTransferRequests.Receipt request) {
coordinator.receive("7", no, "operator", request, () -> null, context -> { return coordinator.receive("7", no, "operator", request, () -> {
var view = mock(com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail.class);
var head = session.getMapper(WarehouseTransferOrderMapper.class).findNo("7", no);
when(view.receiptCommands()).thenReturn(coordinator.receiptCommands("7", head.getId()));
return view;
}, context -> {
assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet(); assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet();
assertEquals(30L, Long.parseLong(context.target().fbaId())); assertTrue(context.resourceLockId() > 0); 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("INSERT INTO local_effect(type,source_id) VALUES('STOCK_IN',?)", request.sourceInventoryPositionId());
@@ -160,6 +165,42 @@ class WarehouseTransferReceiptCoordinatorTest {
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class)); assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class));
verify(warehouse, never()).release(anyString(), anyLong()); verify(warehouse, never()).release(anyString(), anyLong());
} }
@Test void durableProjectionRecoversReservedOriginalPayloadAndSuccessfulResponseIsCompleted() throws Exception {
var original = request(101,20,"KEY-1"); failLocal.set(true);
assertThrows(IllegalStateException.class, () -> receive("WT-1",original));
var reserved = coordinator.receiptCommands("7",1L);
assertEquals(1,reserved.size()); assertEquals("RESERVED",reserved.get(0).status());
assertEquals(original,reserved.get(0).request()); assertEquals(hash(original),reserved.get(0).requestHash());
assertEquals(20L,reserved.get(0).target().palletId());
assertEquals(reserved,coordinator.receiptCommands("7",1L));
int before=reserveCalls.get();var success=receive("WT-1",original);
assertEquals("COMPLETED",success.receiptCommands().get(0).status());
assertEquals(1,occupancies.get());assertEquals(before+1,reserveCalls.get());
var again=receive("WT-1",original);assertEquals(success.receiptCommands(),again.receiptCommands());
assertEquals(before+1,reserveCalls.get());assertEquals(1,count("ll_warehouse_transfer_receipt"));
var serialized=json.writeValueAsString(success.receiptCommands());
assertFalse(serialized.contains("resourceLockId"));assertFalse(serialized.contains("reservationKey"));
}
@Test void durableProjectionIsCustomerAndTransferScopedAndRejectsCorruptionWithoutRemoteCalls() {
loseReserveResponse.set(true);assertThrows(ResponseStatusException.class,()->receive("WT-1",request(101,20,"KEY-1")));
assertEquals("PREPARED",coordinator.receiptCommands("7",1L).get(0).status());
addTransfer(2,"WT-2",102);
assertEquals(List.of(),coordinator.receiptCommands("7",2L));
assertEquals(404,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("8",1L)).getStatusCode().value());
int calls=reserveCalls.get();
String saved=jdbc.queryForObject("SELECT request_json FROM ll_warehouse_transfer_receipt_command WHERE transfer_order_id=1",String.class);
for(String payload:List.of("not-json","{}","{\"sourceInventoryPositionId\":101,\"unknown\":true}")) {
jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET request_json=? WHERE transfer_order_id=1",payload);
assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value());
}
jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET request_json=?,status='UNKNOWN' WHERE transfer_order_id=1",saved);
assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value());
jdbc.update("UPDATE ll_warehouse_transfer_receipt_command SET status='PREPARED',target_json='{}' WHERE transfer_order_id=1");
assertEquals(409,assertThrows(ResponseStatusException.class,()->coordinator.receiptCommands("7",1L)).getStatusCode().value());
assertEquals(calls,reserveCalls.get());assertEquals(0,count("ll_warehouse_transfer_receipt"));
}
@Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() { @Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() {
loseReserveResponse.set(true); var request = request(101, 20, "KEY-1"); loseReserveResponse.set(true); var request = request(101, 20, "KEY-1");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request)); assertThrows(ResponseStatusException.class, () -> receive("WT-1", request));
@@ -81,6 +81,18 @@ import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class WarehouseTransferServiceTest { class WarehouseTransferServiceTest {
@Test
void detailIncludesOnlyTheCoordinatorScopedDurableReceiptProjection() {
var transfer=transfer(WarehouseTransferService.PARTIALLY_RECEIVED);
when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer);
var command=new com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptCommand(501L,
"RESERVED","a".repeat(64),receiptRequest(),new com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.ReceiptTarget(
"900","NY","NY warehouse",801L,"PALLET-DEST",901L,"LOC-DEST","30","ONT8","Amazon ONT8"));
when(receiptCoordinator.receiptCommands("TENANT-A",21L)).thenReturn(List.of(command));
var detail=service.detail("TENANT-A","WTR-1");
assertEquals(List.of(command),detail.receiptCommands());
verify(receiptCoordinator).receiptCommands("TENANT-A",21L);
}
@Test @Test
void pdaCandidatesUseTheMapperRestrictedToWaitingMeasurementOnly() { void pdaCandidatesUseTheMapperRestrictedToWaitingMeasurementOnly() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
@@ -747,14 +759,25 @@ class WarehouseTransferServiceTest {
assertEquals(701L, captured.getValue().getPalletId()); assertEquals(701L, captured.getValue().getPalletId());
assertEquals(5, captured.getValue().getPackageCount()); assertEquals(5, captured.getValue().getPackageCount());
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("WAIT_OUTBOUND", result.orderStatus()); assertEquals("INBOUND_COMPLETED", result.orderStatus());
verifyNoInteractions(inventory); verifyNoInteractions(inventory);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(captured.getValue()));
var outbound = service.outbound("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Outbound("OUT-NO-RELABEL", ""));
assertEquals("INBOUND_COMPLETED", outbound.orderStatus());
assertEquals("WAREHOUSE_TRANSFER:OUTBOUNDED", tailOrder.getSubOrderStatus());
when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn(
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
var received = service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
assertEquals("RECEIVED", received.status());
assertEquals("INBOUND_COMPLETED", received.orderStatus());
assertEquals("WAREHOUSE_TRANSFER:RECEIVED", tailOrder.getSubOrderStatus());
} }
@Test @Test
void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() { void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
LastlegOrder tailOrder = tailOrder(); LastlegOrder tailOrder = tailOrder();
tailOrder.setCurrentStatus("INBOUND_COMPLETED");
WarehouseTransferInventoryAllocation allocation = allocation(); WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
@@ -776,6 +799,7 @@ class WarehouseTransferServiceTest {
assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5)); assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5));
assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus()); assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus());
assertEquals("SHIPPED", allocation.getStatus()); assertEquals("SHIPPED", allocation.getStatus());
assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus());
clearInvocations(inventory); clearInvocations(inventory);
service.outbound("TENANT-A", "WTR-1", "USER-1", service.outbound("TENANT-A", "WTR-1", "USER-1",
@@ -793,10 +817,15 @@ class WarehouseTransferServiceTest {
assertEquals(5, stockIn.getValue().packages()); assertEquals(5, stockIn.getValue().packages());
assertEquals("Customer A", stockIn.getValue().customerName()); assertEquals("Customer A", stockIn.getValue().customerName());
assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode()); assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode());
assertEquals(101L,stockIn.getValue().inboundOrderId());
assertEquals("INBOUND-101",stockIn.getValue().inboundOrderNo());
assertEquals("IN-1",stockIn.getValue().inboundCode());
assertTrue(!stockIn.getValue().inboundOrderId().equals(transfer.getLastlegOrderId()));
assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus());
assertEquals("RECEIVED", allocation.getStatus()); assertEquals("RECEIVED", allocation.getStatus());
assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus());
ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt = ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt =
ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class); ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class);
@@ -807,6 +836,72 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(inventory); verifyNoInteractions(inventory);
} }
@Test
void partialTransferReceiptKeepsEstablishedMainStateAndOnlyAdvancesItsSubState() {
var transfer = transfer(WarehouseTransferService.OUTBOUNDED);
var tail = tailOrder(); tail.setCurrentStatus("INBOUND_COMPLETED");
var first = allocation(); first.setStatus("SHIPPED");
var second = allocation(); second.setId(22L); second.setInventoryPositionId(502L); second.setStatus("SHIPPED");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tail);
when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(first,second));
when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601L,101L,"IN-1"));
when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn(
new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false));
var result=service.receive("TENANT-A","WTR-1","USER-1",receiptRequest());
assertEquals("PARTIALLY_RECEIVED",result.status());
assertEquals("INBOUND_COMPLETED",result.orderStatus());
assertEquals("WAREHOUSE_TRANSFER:PARTIALLY_RECEIVED",tail.getSubOrderStatus());
assertEquals("SHIPPED",second.getStatus());
}
@Test
void twoSourcesOnOneTargetPalletKeepTheirDistinctLegalInboundIdentities() {
var transfer=transfer(WarehouseTransferService.OUTBOUNDED);
var tail=tailOrder();tail.setCurrentStatus("INBOUND_COMPLETED");
var first=allocation();first.setStatus("SHIPPED");
var second=allocation();second.setId(52L);second.setInventoryPositionId(502L);
second.setInventoryBalanceId(602L);second.setInboundOrderId(202L);second.setInboundCode("IN-2");second.setStatus("SHIPPED");
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tail);
when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(first,second));
when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601L,101L,"IN-1"));
when(inventoryCommands.lockBalance("TENANT-A",602L)).thenReturn(balance(602L,202L,"IN-2"));
when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn(
new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false),
new InventoryCommandResponse(2L,"STOCK_IN",802L,902L,5,0,0,0,false,false));
service.receive("TENANT-A","WTR-1","USER-1",receiptRequest());
var r=receiptRequest();
service.receive("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Receipt(502L,
r.destinationOverseasWarehouseId(),r.destinationOverseasWarehouseCode(),r.destinationOverseasWarehouseName(),
r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.destinationPalletId(),
r.destinationPalletNo(),r.batchNo(),r.warehouseLocationId(),r.warehouseLocationCode(),"RECEIVE-2",r.remark()));
var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory,times(2)).stockIn(eq("TENANT-A"),eq("USER-1"),captured.capture());
assertEquals(List.of(101L,202L),captured.getAllValues().stream().map(value->value.inboundOrderId()).toList());
assertEquals(List.of("INBOUND-101","INBOUND-202"),captured.getAllValues().stream().map(value->value.inboundOrderNo()).toList());
assertEquals(List.of("IN-1","IN-2"),captured.getAllValues().stream().map(value->value.inboundCode()).toList());
assertTrue(captured.getAllValues().stream().allMatch(value->value.palletId().equals(r.destinationPalletId())
&& value.warehouseLocationId().equals(r.warehouseLocationId())
&& value.overseasWarehouseId().equals(r.destinationOverseasWarehouseId())
&& value.destinationWarehouseId().equals(r.destinationWarehouseId())&&value.businessOrderNo().equals("WTR-1")));
assertEquals("RECEIVED",transfer.getStatus());assertEquals("INBOUND_COMPLETED",tail.getCurrentStatus());
}
@Test
void receiptRejectsMissingSourceInboundIdentityInsteadOfSubstitutingTheTransferTail() {
var transfer=transfer(WarehouseTransferService.OUTBOUNDED);
var allocated=allocation();allocated.setStatus("SHIPPED");
var source=balance(601L,101L,"IN-1");source.setInboundOrderNo(null);
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(allocated));
when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(source);
assertThrows(ResponseStatusException.class,()->service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()));
verifyNoInteractions(inventory);assertEquals("SHIPPED",allocated.getStatus());
}
@Test @Test
void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() { void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
@@ -1070,6 +1165,7 @@ class WarehouseTransferServiceTest {
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
InventoryBalance balance = new InventoryBalance(); InventoryBalance balance = new InventoryBalance();
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
balance.setInboundOrderNo("INBOUND-" + inboundOrderId);
balance.setCustomerName("Customer A"); balance.setCustomerName("Customer A");
balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER"); balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER");
return balance; return balance;