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

This commit is contained in:
2026-10-05 03:37:13 +08:00
parent 3f8ac23aba
commit c194dd2862
10 changed files with 661 additions and 9 deletions
@@ -21,6 +21,53 @@ class HttpWarehouseResourceGatewayTest {
private HttpWarehouseResourceGateway gateway;
private MockRestServiceServer server;
@Test
void reserveAcceptsOnlyCompleteMatchingActiveLockWithStringEncodedLongs() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"200","data":{"lockId":"51","customerId":"1","warehouseId":"2",
"palletId":"3","locationId":"4","businessType":"WAREHOUSE_TRANSFER_RECEIPT",
"businessId":"target-hash","reservationKey":"resource-1","status":"ACTIVE"}}
""", MediaType.APPLICATION_JSON));
assertThat(gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1")).isEqualTo(51);
server.verify();
}
@Test
void reserveRejectsReleasedHistoricalLockRatherThanTreatingOldKeyAsNewOccupancy() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"200","data":{"lockId":51,"customerId":1,"warehouseId":2,"palletId":3,
"locationId":4,"businessType":"WAREHOUSE_TRANSFER_RECEIPT","businessId":"target-hash",
"reservationKey":"resource-1","status":"RELEASED"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
server.verify();
}
@Test
void reserveRejectsWrongOwnershipEvenWhenReturnedLockIsActive() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("""
{"code":"200","data":{"lockId":51,"customerId":99,"warehouseId":2,"palletId":3,
"locationId":4,"businessType":"WAREHOUSE_TRANSFER_RECEIPT","businessId":"target-hash",
"reservationKey":"resource-1","status":"ACTIVE"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
server.verify();
}
@Test
void reserveRejectsMissingActiveStateInsteadOfAcceptingBareLockId() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"lockId\":51}}", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
server.verify();
}
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.transfer;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseTransferReceiptOpenApiContractTest {
@Test
@SuppressWarnings("unchecked")
void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() {
Map<String,Object> document = new Yaml().load(getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml"));
var paths=(Map<String,Object>)document.get("paths");
var path=(Map<String,Object>)paths.get("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/receipts");
assertThat(path).isNotNull();
var post=(Map<String,Object>)path.get("post");
assertThat((String)post.get("description")).contains("lastleg:warehouse-transfer:manage", "原键原载荷", "共享一次ACTIVE资源锁");
var body=(Map<String,Object>)post.get("requestBody");
var content=(Map<String,Object>)body.get("content");
var media=(Map<String,Object>)content.get("application/json");
var schema=(Map<String,Object>)media.get("schema");
var properties=(Map<String,Object>)schema.get("properties");
Set<String> fields=Arrays.stream(WarehouseTransferRequests.Receipt.class.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName).collect(Collectors.toSet());
assertThat(properties.keySet()).isEqualTo(fields);
assertThat((List<String>)schema.get("required")).containsExactlyInAnyOrder(
"sourceInventoryPositionId","destinationOverseasWarehouseId","destinationWarehouseCode",
"destinationPalletId","destinationPalletNo","idempotencyKey");
assertThat((Map<String,Object>)post.get("responses")).containsKeys("200","409","503");
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.*;
import com.baiyue.lastleg.transfer.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WarehouseTransferReceiptCoordinatorTest {
JdbcTemplate jdbc; SqlSessionTemplate session; WarehouseTransferReceiptCoordinator coordinator;
WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; FleetWarehouseClient catalog;
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
AtomicInteger occupancies = new AtomicInteger(), reserveCalls = new AtomicInteger(), stockCalls = new AtomicInteger();
AtomicBoolean loseReserveResponse = new AtomicBoolean(), failLocal = new AtomicBoolean();
Map<Long, RemoteLock> remote = new HashMap<>();
record RemoteLock(String customer, String key, String hash, long warehouse, long location, long lockId) { }
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MYSQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
for (Class<?> type : List.of(WarehouseTransferOrder.class, WarehouseTransferInventoryAllocation.class,
WarehouseTransferReceipt.class, WarehouseTransferReceiptCommand.class)) {
List<String> fields = new ArrayList<>();
for (var f : type.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
String name = f.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
String sqlType = f.getType() == String.class ? "VARCHAR(16000)" : f.getType() == LocalDateTime.class
? "TIMESTAMP" : f.getType() == BigDecimal.class ? "DECIMAL(24,6)" : f.getType() == Boolean.class ? "BOOLEAN" : "BIGINT";
fields.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" : ""));
}
jdbc.execute("CREATE TABLE " + type.getAnnotation(TableName.class).value() + " (" + String.join(",", fields) + ")");
}
jdbc.execute("CREATE UNIQUE INDEX receipt_command_source ON ll_warehouse_transfer_receipt_command(transfer_order_id,source_inventory_position_id)");
jdbc.execute("CREATE UNIQUE INDEX receipt_command_key ON ll_warehouse_transfer_receipt_command(customer_id,idempotency_key)");
jdbc.execute("CREATE UNIQUE INDEX receipt_source ON ll_warehouse_transfer_receipt(transfer_order_id,source_inventory_position_id)");
jdbc.execute("CREATE TABLE local_effect(type VARCHAR(30), source_id BIGINT)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for (Class<?> mapper : List.of(WarehouseTransferOrderMapper.class, WarehouseTransferInventoryAllocationMapper.class,
WarehouseTransferReceiptMapper.class, WarehouseTransferReceiptCommandMapper.class)) configuration.addMapper(mapper);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
session = new SqlSessionTemplate(factory.getObject());
coordinator = new WarehouseTransferReceiptCoordinator();
ReflectionTestUtils.setField(coordinator, "transfers", session.getMapper(WarehouseTransferOrderMapper.class));
ReflectionTestUtils.setField(coordinator, "allocations", session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
ReflectionTestUtils.setField(coordinator, "receipts", session.getMapper(WarehouseTransferReceiptMapper.class));
ReflectionTestUtils.setField(coordinator, "commands", session.getMapper(WarehouseTransferReceiptCommandMapper.class));
ReflectionTestUtils.setField(coordinator, "transactionManager", new DataSourceTransactionManager(ds));
ReflectionTestUtils.setField(coordinator, "objectMapper", json);
warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(FleetWarehouseClient.class);
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "directory", directory);
ReflectionTestUtils.setField(coordinator, "catalog", catalog); ReflectionTestUtils.setField(coordinator, "internalToken", "test-token");
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
when(warehouse.resolvePallet(anyLong())).thenAnswer(call -> pallet(call.getArgument(0), remote.containsKey(call.getArgument(0)) ? "OCCUPIED" : "IDLE"));
when(warehouse.resolveLocation(eq(9L), anyLong())).thenAnswer(call -> new WarehouseResourceGateway.WarehouseLocationReference(
call.getArgument(1), "LOC-" + call.getArgument(1), "STORAGE", new BigDecimal("1000"), "ACTIVE"));
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive(), "Remote reserve must not hold local transaction locks");
reserveCalls.incrementAndGet(); long pallet = call.getArgument(2), wh = call.getArgument(1), location = call.getArgument(3);
String customer = call.getArgument(0), key = call.getArgument(6), hash = call.getArgument(5);
RemoteLock existing = remote.get(pallet);
if (existing != null && (!existing.customer.equals(customer) || !existing.key.equals(key) || !existing.hash.equals(hash)
|| existing.warehouse != wh || existing.location != location)) throw conflict("Other reservation owns pallet");
if (existing == null) { existing = new RemoteLock(customer, key, hash, wh, location, 500L + pallet); remote.put(pallet, existing); occupancies.incrementAndGet(); }
if (loseReserveResponse.getAndSet(false)) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "response lost after reserve commit");
return existing.lockId;
});
addTransfer(1, "WT-1", 101);
}
private WarehouseResourceGateway.WarehousePalletReference pallet(long id, String state) {
return new WarehouseResourceGateway.WarehousePalletReference(id, "PAL-" + id, 9, id + 20, state, "ABE4", remote.containsKey(id) ? 1 : 0);
}
private void addTransfer(long id, String no, long sourceId) {
var head = new WarehouseTransferOrder(); head.setId(id); head.setCustomerId("7"); head.setTransferNo(no);
head.setStatus("OUTBOUNDED"); head.setFbaWarehouseCode("ABE4"); head.setDestinationWarehouseId("30"); head.setDestinationWarehouseName("ABE4 warehouse");
session.getMapper(WarehouseTransferOrderMapper.class).insert(head); addSource(id, sourceId);
}
private void addSource(long transfer, long source) {
var allocation = new WarehouseTransferInventoryAllocation(); allocation.setId(source + 1000); allocation.setCustomerId("7");
allocation.setTransferOrderId(transfer); allocation.setInventoryPositionId(source); allocation.setPackageCount(1); allocation.setStatus("SHIPPED");
session.getMapper(WarehouseTransferInventoryAllocationMapper.class).insert(allocation);
}
private WarehouseTransferRequests.Receipt request(long source, long pallet, String key) {
return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse",
pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST");
}
private void receive(String no, WarehouseTransferRequests.Receipt request) {
coordinator.receive("7", no, "operator", request, () -> null, context -> {
assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet();
assertEquals(30L, Long.parseLong(context.target().fbaId())); assertTrue(context.resourceLockId() > 0);
jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('STOCK_IN',?)", request.sourceInventoryPositionId());
jdbc.update("UPDATE ll_warehouse_transfer_inventory_allocation SET status='RECEIVED' WHERE customer_id='7' AND inventory_position_id=?", request.sourceInventoryPositionId());
var head = session.getMapper(WarehouseTransferOrderMapper.class).findNo("7", no);
var receipt = new WarehouseTransferReceipt(); receipt.setId(request.sourceInventoryPositionId() + 2000);
receipt.setCustomerId("7"); receipt.setTransferOrderId(head.getId()); receipt.setSourceInventoryPositionId(request.sourceInventoryPositionId());
receipt.setRequestHash(hash(request)); receipt.setIdempotencyKey(request.idempotencyKey()); receipt.setPackageCount(1);
session.getMapper(WarehouseTransferReceiptMapper.class).insert(receipt);
long left = jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation WHERE transfer_order_id=? AND status='SHIPPED'", Long.class, head.getId());
jdbc.update("UPDATE ll_warehouse_transfer_order SET status=? WHERE id=?", left == 0 ? "RECEIVED" : "PARTIALLY_RECEIVED", head.getId());
if (left == 0) { jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('FEE_APPROVED',?)", request.sourceInventoryPositionId()); jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('SETTLE',?)", request.sourceInventoryPositionId()); }
if (failLocal.getAndSet(false)) throw new IllegalStateException("local stock/fee transaction failed");
return null;
});
}
private String hash(Object value) { try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } catch (Exception e) { throw new AssertionError(e); } }
private long count(String table) { return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); }
private String state(long source) { return jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_receipt_command WHERE source_inventory_position_id=?", String.class, source); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@Test void originalReceiptReplayDoesNotReserveOrStockAgainEvenAfterPhysicalResourceChanges() {
var request = request(101, 20, "KEY-1"); receive("WT-1", request); remote.clear(); receive("WT-1", request);
assertEquals(1, reserveCalls.get()); assertEquals(1, occupancies.get()); assertEquals(1, stockCalls.get());
assertEquals(1, count("ll_warehouse_transfer_receipt")); assertEquals("COMPLETED", state(101));
}
@Test void multipleSourcesShareOnePhysicalReservationAndCompleteDistinctReceipts() {
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1")); receive("WT-1", request(102, 20, "KEY-2"));
assertEquals(1, occupancies.get()); assertEquals(2, stockCalls.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(DISTINCT resource_lock_id) FROM ll_warehouse_transfer_receipt_command", Long.class));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(DISTINCT target_hash) FROM ll_warehouse_transfer_receipt_command", Long.class));
assertEquals(2L, jdbc.queryForObject("SELECT COUNT(DISTINCT request_hash) FROM ll_warehouse_transfer_receipt_command", Long.class));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='SETTLE'", Long.class));
}
@Test void remoteCommittedButLocalStockAndFeeFailureRollsBackThenSameRequestRecovers() {
var request = request(101, 20, "KEY-1"); failLocal.set(true);
assertThrows(IllegalStateException.class, () -> receive("WT-1", request));
assertEquals("RESERVED", state(101)); assertEquals(0, count("local_effect")); assertEquals(0, count("ll_warehouse_transfer_receipt"));
assertEquals("SHIPPED", jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE inventory_position_id=101", String.class));
receive("WT-1", request); assertEquals(1, occupancies.get()); assertEquals("COMPLETED", state(101));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class));
verify(warehouse, never()).release(anyString(), anyLong());
}
@Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() {
loseReserveResponse.set(true); var request = request(101, 20, "KEY-1");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request));
assertEquals("PREPARED", state(101)); assertEquals(0, stockCalls.get()); assertEquals(1, occupancies.get());
receive("WT-1", request); assertEquals("COMPLETED", state(101)); assertEquals(1, occupancies.get());
}
@Test void failedFirstSourceDoesNotPreventSecondSourceThenOriginalRecoveryOnSamePallet() {
addSource(1, 102); failLocal.set(true); var first = request(101, 20, "KEY-1");
assertThrows(IllegalStateException.class, () -> receive("WT-1", first));
receive("WT-1", request(102, 20, "KEY-2")); receive("WT-1", first);
assertEquals(1, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='SETTLE'", Long.class));
}
@Test void changedOriginalKeyPayloadOrTargetCannotBypassPreparedIntent() {
failLocal.set(true); assertThrows(IllegalStateException.class, () -> receive("WT-1", request(101, 20, "KEY-1")));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "NEW-KEY")));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 21, "KEY-1")));
var original = request(101, 20, "KEY-1"); var changed = new WarehouseTransferRequests.Receipt(101L, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", 20L, "PAL-20", "CHANGED-BATCH", 40L, "LOC-40", "KEY-1", "TEST");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", changed));
assertEquals(1, reserveCalls.get()); assertEquals(1, count("ll_warehouse_transfer_receipt_command")); assertEquals(0, count("ll_warehouse_transfer_receipt"));
}
@Test void sameKeyCannotBeReusedForAnotherSource() {
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1"));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(102, 20, "KEY-1")));
assertEquals(1, occupancies.get()); assertEquals(1, count("ll_warehouse_transfer_receipt_command"));
}
@Test void occupiedPalletOfAnotherTransferIsNotReused() {
receive("WT-1", request(101, 20, "KEY-1")); addTransfer(2, "WT-2", 201);
assertThrows(ResponseStatusException.class, () -> receive("WT-2", request(201, 20, "KEY-3")));
assertEquals(1, occupancies.get()); assertEquals(1, count("ll_warehouse_transfer_receipt"));
assertEquals(1, count("ll_warehouse_transfer_receipt_command"));
receive("WT-2", request(201, 21, "KEY-3"));
assertEquals(2, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
}
@Test void twoRealResourcesInSameWarehouseStillRequireExactPalletLocationAssociation() {
var wrong = new WarehouseTransferRequests.Receipt(101L, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", 20L, "PAL-20", "BATCH", 41L, "LOC-41", "KEY-1", "TEST");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", wrong)); assertEquals(0, reserveCalls.get()); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void omittedFbaIdsStillResolveAuthoritativeCatalogAndActivePublicMaster() {
jdbc.update("UPDATE ll_warehouse_transfer_order SET destination_warehouse_id=NULL WHERE id=1");
var request = new WarehouseTransferRequests.Receipt(101L, "9", null, null, null, "ABE4", null, 20L, "PAL-20", "BATCH", null, null, "KEY-1", "TEST");
receive("WT-1", request); verify(catalog).activeWarehouses("test-token"); verify(directory).activeWarehouse(30L, "test-token");
assertEquals(1, occupancies.get());
}
@Test void omittedIdsDoNotAllowStringOnlyFbaWhenAuthorityHasNoMatchingWarehouse() {
jdbc.update("UPDATE ll_warehouse_transfer_order SET destination_warehouse_id=NULL WHERE id=1");
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of()));
var request = new WarehouseTransferRequests.Receipt(101L, "9", null, null, null, "ABE4", null, 20L, "PAL-20", "BATCH", null, null, "KEY-1", "TEST");
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request)); assertEquals(0, count("ll_warehouse_transfer_receipt_command")); assertEquals(0, occupancies.get());
}
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE"), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED"), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "DISABLED"));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1")));
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
when(warehouse.resolvePallet(20L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(20, "PAL-20", 99, 40, "IDLE", "ABE4", 0));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void wrongFrozenFbaAndWrongMasterIdAreRejected() {
when(warehouse.resolvePallet(20L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(20, "PAL-20", 9, 40, "IDLE", "ABE3", 0));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(999, 20, "KEY-1")));
assertEquals(0, reserveCalls.get()); verify(warehouse, never()).requireActiveSharedWarehouse(anyLong());
}
@Test void legitimateDifferentTargetPalletsReceiveIndependentSourcesWithoutReducingCapability() {
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1")); receive("WT-1", request(102, 21, "KEY-2"));
assertEquals(2, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
assertEquals(2L, jdbc.queryForObject("SELECT COUNT(DISTINCT resource_lock_id) FROM ll_warehouse_transfer_receipt_command", Long.class));
}
}
@@ -173,10 +173,23 @@ class WarehouseTransferServiceTest {
@Mock InventoryCommandMapper inventoryCommands;
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
private WarehouseTransferService service;
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@BeforeEach
void setUp() {
service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "receiptCoordinator", receiptCoordinator);
lenient().when(receiptCoordinator.receive(anyString(), anyString(), anyString(), any(), any(), any()))
.thenAnswer(call -> {
WarehouseTransferRequests.Receipt request = call.getArgument(3);
var target = new WarehouseTransferReceiptCoordinator.Target(request.destinationOverseasWarehouseId(),
request.destinationOverseasWarehouseCode(), request.destinationOverseasWarehouseName(),
request.destinationPalletId(), request.destinationPalletNo(), request.warehouseLocationId(),
request.warehouseLocationCode(), request.destinationWarehouseId(), request.destinationWarehouseCode(),
request.destinationWarehouseName());
java.util.function.Function<WarehouseTransferReceiptCoordinator.Context, com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail> work = call.getArgument(5);
return work.apply(new WarehouseTransferReceiptCoordinator.Context(target, 51L, 61L));
});
ReflectionTestUtils.setField(service, "transfers", transfers);
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);