fix(transfer): preserve inbound ownership and expose durable receipt recovery
This commit is contained in:
+1
-1
@@ -968,7 +968,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
|
||||
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
|
||||
String transferOrderBefore = transferOrder.getCurrentStatus();
|
||||
transferOrder.setCurrentStatus(transferStatus);
|
||||
if (!existingStockTransfer) transferOrder.setCurrentStatus(transferStatus);
|
||||
transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
|
||||
transferOrder.setRelabelStatus("COMPLETED");
|
||||
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED");
|
||||
|
||||
+6
@@ -26,6 +26,11 @@ public final class WarehouseTransferResponses {
|
||||
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
|
||||
Long palletId, String palletNo, int packageCount, String status,
|
||||
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,
|
||||
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
|
||||
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
|
||||
@@ -51,6 +56,7 @@ public final class WarehouseTransferResponses {
|
||||
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
|
||||
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
|
||||
List<Allocation> allocations, List<MeasurementPhoto> measurementPhotos,
|
||||
List<ReceiptCommand> receiptCommands,
|
||||
LocalDateTime customerConfirmedAt,
|
||||
LocalDateTime actualShipAt, LocalDateTime createdAt) {}
|
||||
}
|
||||
|
||||
+5
@@ -8,6 +8,11 @@ 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} 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} " +
|
||||
"AND transfer_order_id=#{transferId} AND source_inventory_position_id=#{sourceId} LIMIT 1 FOR UPDATE")
|
||||
WarehouseTransferReceiptCommand lockSource(@Param("customerId") String customerId,
|
||||
|
||||
+71
-3
@@ -54,6 +54,72 @@ public class WarehouseTransferReceiptCoordinator {
|
||||
private record ResolvedTarget(Target target, String palletStatus) { }
|
||||
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,
|
||||
WarehouseTransferRequests.Receipt request, Supplier<WarehouseTransferResponses.Detail> replay,
|
||||
Function<Context, WarehouseTransferResponses.Detail> stockWork) {
|
||||
@@ -99,9 +165,11 @@ public class WarehouseTransferReceiptCoordinator {
|
||||
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;
|
||||
stockWork.apply(new Context(target, lockId, owned.getId()));
|
||||
owned.setStatus("COMPLETED"); owned.setUpdatedAt(LocalDateTime.now());
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -601,6 +601,7 @@ public class WarehouseTransferService {
|
||||
transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(),
|
||||
transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(),
|
||||
transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos,
|
||||
receiptCoordinator.receiptCommands(customerId, transfer.getId()),
|
||||
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
|
||||
}
|
||||
|
||||
@@ -1129,8 +1130,7 @@ public class WarehouseTransferService {
|
||||
transfer.setAllocationIdempotencyKey(key);
|
||||
transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
|
||||
LastlegOrder order = requireTailOrder(customerId, transfer);
|
||||
String before = order.getCurrentStatus(); order.setCurrentStatus(
|
||||
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? INBOUND_COMPLETED : "WAIT_OUTBOUND");
|
||||
String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED);
|
||||
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
|
||||
order.setRelabelOrderNo(transfer.getRelabelNo()); order.setRelabelStatus("PENDING_REVIEW");
|
||||
}
|
||||
@@ -1203,7 +1203,7 @@ public class WarehouseTransferService {
|
||||
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
|
||||
transfers.updateById(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);
|
||||
orders.updateById(order);
|
||||
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
|
||||
@@ -1254,12 +1254,16 @@ public class WarehouseTransferService {
|
||||
if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) {
|
||||
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();
|
||||
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
|
||||
new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(),
|
||||
target.warehouseId(), target.warehouseCode(), target.warehouseName(), target.fbaId(),
|
||||
target.fbaCode(), target.fbaName(),
|
||||
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
|
||||
sourceBalance.getInboundOrderId(), sourceBalance.getInboundOrderNo(), sourceBalance.getInboundCode(),
|
||||
Boolean.TRUE.equals(transfer.getNeedRelabel()) ? allocation.getSkuCode() : sourceBalance.getSkuCode(),
|
||||
sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(),
|
||||
target.palletId(), target.palletNo(),
|
||||
@@ -1281,7 +1285,7 @@ public class WarehouseTransferService {
|
||||
|| "RECEIVED".equals(value.getStatus()));
|
||||
transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(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.setUpdatedAt(now); orders.updateById(order);
|
||||
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
|
||||
|
||||
@@ -1106,7 +1106,7 @@ paths:
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
responses:
|
||||
'200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;原键原载荷返回原收货结果 }
|
||||
'200': { description: 原转运详情,部分来源PARTIALLY_RECEIVED、全部来源RECEIVED;receiptCommands包含真正COMPLETED的本次命令,原键原载荷返回持久结果, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailApiResponse' } } } }
|
||||
'409': { description: 原请求冲突、来源状态或目标资源归属不匹配;不得换键绕过 }
|
||||
'503': { description: 资源服务不可用,保留原收货命令待核对恢复 }
|
||||
/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/outbound:
|
||||
@@ -1193,7 +1193,7 @@ paths:
|
||||
description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。
|
||||
security: [{ bearerAuth: [] }]
|
||||
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:
|
||||
post:
|
||||
summary: 客户确认仓储转运实测数据
|
||||
@@ -1844,6 +1844,62 @@ paths:
|
||||
responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } }
|
||||
components:
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+2
-2
@@ -714,14 +714,14 @@ class RelabelApplicationServiceTest {
|
||||
var transfer=new WarehouseTransferOrder();transfer.setId(77L);transfer.setCustomerId("10001");
|
||||
transfer.setRelabelNo("FBR-1");transfer.setStatus("WAIT_RELABEL");transfer.setLastlegOrderId(88L);
|
||||
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(warehouseTransfers.lockByRelabelNo("10001","FBR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(88L)).thenReturn(transferOrder);
|
||||
var command=new RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","accepted");
|
||||
service.confirmCompletion("10001","FBR-1","customer-1",command);
|
||||
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);
|
||||
verify(transferRelabelCompletion).complete(eq(transfer),eq(application),any(),eq("customer-1"),any());
|
||||
verify(outboundOrders,never()).insert(any(OutboundOrder.class));
|
||||
|
||||
+14
@@ -11,6 +11,20 @@ import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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
|
||||
@SuppressWarnings("unchecked")
|
||||
void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() {
|
||||
|
||||
+43
-2
@@ -115,8 +115,13 @@ class WarehouseTransferReceiptCoordinatorTest {
|
||||
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 -> {
|
||||
private com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail receive(String no, WarehouseTransferRequests.Receipt request) {
|
||||
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();
|
||||
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());
|
||||
@@ -160,6 +165,42 @@ class WarehouseTransferReceiptCoordinatorTest {
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class));
|
||||
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() {
|
||||
loseReserveResponse.set(true); var request = request(101, 20, "KEY-1");
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request));
|
||||
|
||||
+97
-1
@@ -81,6 +81,18 @@ import static org.mockito.Mockito.times;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
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
|
||||
void pdaCandidatesUseTheMapperRestrictedToWaitingMeasurementOnly() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
@@ -747,14 +759,25 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals(701L, captured.getValue().getPalletId());
|
||||
assertEquals(5, captured.getValue().getPackageCount());
|
||||
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
|
||||
assertEquals("WAIT_OUTBOUND", result.orderStatus());
|
||||
assertEquals("INBOUND_COMPLETED", result.orderStatus());
|
||||
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
|
||||
void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
|
||||
LastlegOrder tailOrder = tailOrder();
|
||||
tailOrder.setCurrentStatus("INBOUND_COMPLETED");
|
||||
WarehouseTransferInventoryAllocation allocation = allocation();
|
||||
when(transfers.lockNo("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));
|
||||
assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus());
|
||||
assertEquals("SHIPPED", allocation.getStatus());
|
||||
assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus());
|
||||
|
||||
clearInvocations(inventory);
|
||||
service.outbound("TENANT-A", "WTR-1", "USER-1",
|
||||
@@ -793,10 +817,15 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals(5, stockIn.getValue().packages());
|
||||
assertEquals("Customer A", stockIn.getValue().customerName());
|
||||
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("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
|
||||
assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus());
|
||||
assertEquals("RECEIVED", allocation.getStatus());
|
||||
assertEquals("INBOUND_COMPLETED", tailOrder.getCurrentStatus());
|
||||
|
||||
ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt =
|
||||
ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class);
|
||||
@@ -807,6 +836,72 @@ class WarehouseTransferServiceTest {
|
||||
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
|
||||
void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
|
||||
@@ -1070,6 +1165,7 @@ class WarehouseTransferServiceTest {
|
||||
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
|
||||
InventoryBalance balance = new InventoryBalance();
|
||||
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
|
||||
balance.setInboundOrderNo("INBOUND-" + inboundOrderId);
|
||||
balance.setCustomerName("Customer A");
|
||||
balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER");
|
||||
return balance;
|
||||
|
||||
Reference in New Issue
Block a user