暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.transfer;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WarehouseTransferSchemaContractTest {
@Test
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
"ll_warehouse_transfer_receipt"}) {
assertTrue(baseline.contains("create table if not exists " + table));
assertTrue(migration.contains("create table if not exists " + table));
}
assertTrue(migration.contains("information_schema.statistics"));
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
}
}
@@ -0,0 +1,371 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseTransferServiceTest {
@Mock WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Mock WarehouseTransferReceiptMapper transferReceipts;
@Mock InboundSnapshotMapper inboundSnapshots;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock RelabelApplicationMapper relabelApplications;
@Mock RelabelItemMapper relabelItems;
@Mock RelabelStatusHistoryMapper relabelHistories;
@Mock LastlegOrderNoGenerator orderNoGenerator;
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
private WarehouseTransferService service;
@BeforeEach
void setUp() {
service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "transfers", transfers);
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1");
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
}
@Test
void relabelTransferLinksHistoricalOrderAndInboundAndCreatesOnePendingReviewTailOrder() {
WarehouseTransferRequests.Create request = request(true);
LastlegOrder historical = historicalOrder();
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historical));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.create("TENANT-A", "USER-1", request);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications).insert(any(RelabelApplication.class));
verify(relabelItems).insert(any(RelabelItem.class));
verify(relabelHistories).insert(any(RelabelStatusHistory.class));
verify(orders).updateById(tailOrder.getValue());
assertNotNull(tailOrder.getValue().getRelabelOrderNo());
assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
}
@Test
void transferWithoutRelabelDoesNotCreateRelabelDocument() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.create("TENANT-A", "USER-1", request(false));
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
verify(relabelItems, never()).insert(any(RelabelItem.class));
verify(relabelHistories, never()).insert(any(RelabelStatusHistory.class));
assertNull(result.relabelNo());
}
@Test
void approvedTransferWithoutRelabelEntersPendingAssignment() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
transfer.setNeedRelabel(false);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.review("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Review("APPROVE", "approved"));
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verifyNoInteractions(inventory);
}
@Test
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
WarehouseTransferOrder transfer = transfer("APPROVED");
LastlegOrder tailOrder = tailOrder();
WarehouseTransferSourceOrder source = sourceOrder();
InventoryPosition position = position(501L, 601L, 701L, 5L);
InventoryBalance balance = balance(601L, 101L, "IN-1");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(source));
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position);
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
var result = service.allocate("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Allocate("ALLOC-1",
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5))));
ArgumentCaptor<WarehouseTransferInventoryAllocation> captured =
ArgumentCaptor.forClass(WarehouseTransferInventoryAllocation.class);
verify(inventoryAllocations).insert(captured.capture());
assertEquals(101L, captured.getValue().getInboundOrderId());
assertEquals(701L, captured.getValue().getPalletId());
assertEquals(5, captured.getValue().getPackageCount());
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("WAIT_OUTBOUND", result.orderStatus());
verifyNoInteractions(inventory);
}
@Test
void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
LastlegOrder tailOrder = tailOrder();
WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
var ordered = inOrder(inventory);
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move> move =
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class);
ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture());
ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture());
ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture());
assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5));
assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus());
assertEquals("SHIPPED", allocation.getStatus());
clearInvocations(inventory);
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
verifyNoInteractions(inventory);
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(null);
when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn(
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn> stockIn =
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
assertEquals(5, stockIn.getValue().packages());
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());
ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt =
ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class);
verify(transferReceipts).insert(receipt.capture());
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(receipt.getValue());
clearInvocations(inventory);
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
verifyNoInteractions(inventory);
}
@Test
void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient"))
.when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any());
assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", null)));
verify(inventory, never()).ship(eq("TENANT-A"), eq("USER-1"), any());
verify(inventoryAllocations, never()).updateById(any(WarehouseTransferInventoryAllocation.class));
verify(transfers, never()).updateById(transfer);
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("RESERVED", allocation.getStatus());
}
@Test
void allocationRejectsPositionOutsideSelectedInboundOrder() {
WarehouseTransferOrder transfer = transfer("APPROVED");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder()));
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position(501L, 601L, 701L, 5L));
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 999L, "OTHER"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.allocate("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Allocate("ALLOC-1",
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5)))));
assertEquals(409, exception.getStatusCode().value());
verify(inventoryAllocations, never()).insert(any(WarehouseTransferInventoryAllocation.class));
verifyNoInteractions(inventory);
}
@Test
void selectedOrderMustBelongToTenantAndHaveInboundBinding() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of());
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request(false)));
assertEquals(404, exception.getStatusCode().value());
verify(transfers, never()).insert(any(WarehouseTransferOrder.class));
verify(orders, never()).insert(any(LastlegOrder.class));
}
private static WarehouseTransferRequests.Create request(boolean needRelabel) {
return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8",
"123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1",
"TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
}
private static LastlegOrder historicalOrder() {
LastlegOrder order = new LastlegOrder();
order.setId(11L); order.setCustomerId("TENANT-A"); order.setOrderNo("LMO-OLD-1"); order.setInboundCode("IN-1");
return order;
}
private static WarehouseTransferOrder persistedTransfer() {
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1");
transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L);
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5);
return transfer;
}
private static WarehouseTransferOrder transfer(String status) {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setTransferNo("WTR-1"); transfer.setStatus(status); transfer.setPackageCount(5);
return transfer;
}
private static LastlegOrder tailOrder() {
LastlegOrder order = historicalOrder();
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");
return order;
}
private static WarehouseTransferSourceOrder sourceOrder() {
WarehouseTransferSourceOrder source = new WarehouseTransferSourceOrder();
source.setId(41L); source.setTransferOrderId(21L); source.setCustomerId("TENANT-A");
source.setInboundOrderId(101L); source.setInboundCode("IN-1");
return source;
}
private static InventoryPosition position(long id, long balanceId, long palletId, long available) {
InventoryPosition position = new InventoryPosition();
position.setId(id); position.setCustomerId("TENANT-A"); position.setBalanceId(balanceId);
position.setPalletId(palletId); position.setPalletNo("PALLET-1"); position.setAvailablePackages(available);
return position;
}
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.setInboundCode(inboundCode);
return balance;
}
private static WarehouseTransferInventoryAllocation allocation() {
WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation();
allocation.setId(51L); allocation.setTransferOrderId(21L); allocation.setCustomerId("TENANT-A");
allocation.setInventoryPositionId(501L); allocation.setInventoryBalanceId(601L);
allocation.setInboundOrderId(101L); allocation.setInboundCode("IN-1"); allocation.setPalletId(701L);
allocation.setPalletNo("PALLET-1"); allocation.setPackageCount(5); allocation.setStatus("RESERVED");
return allocation;
}
private static WarehouseTransferRequests.Receipt receiptRequest() {
return new WarehouseTransferRequests.Receipt(501L, "WH-DEST", "WH-D", "Destination warehouse",
"FBA-ID", "ONT8", "Amazon ONT8", 801L, "PALLET-DEST", "WTR-1", 901L,
"LOC-DEST", "RECEIVE-1", "received");
}
}