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
@@ -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));
@@ -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() {
@@ -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));
@@ -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;