fix(lastleg): complete stock transfer through linked relabel

This commit is contained in:
2026-10-04 20:21:16 +08:00
parent 7319fba35a
commit 76f02d8d45
17 changed files with 740 additions and 31 deletions
@@ -76,6 +76,7 @@ class RelabelApplicationServiceTest {
@Mock OutboundPalletItemMapper outboundItems;
@Mock OutboundPdaTaskMapper outboundTasks;
@Mock WarehouseTransferOrderMapper warehouseTransfers;
@Mock com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion;
@Mock OverseasFeeSourceService feeSources;
@Mock ReturnOrderMapper returnOrders;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@@ -93,6 +94,7 @@ class RelabelApplicationServiceTest {
});
lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "transferRelabelCompletion", transferRelabelCompletion);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "orders", orders);
@@ -704,6 +706,26 @@ class RelabelApplicationServiceTest {
assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus());
}
@Test
void existingStockTransferConfirmationFinalizesOriginalSourcesOnceWithoutIndependentOutbound() {
var application=confirmationApplication("EXPRESS",false);application.setLastlegOrderId(88L);
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");
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());
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));
assertNotNull(transfer.getRelabelCompletedAt());
}
@Test
void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() {
RelabelApplication application = confirmationApplication("TRUCK", false);
@@ -0,0 +1,190 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.transfer.entity.*;
import com.baiyue.lastleg.transfer.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class WarehouseTransferRelabelPersistenceTest {
JdbcTemplate jdbc; SqlSessionTemplate session; TransactionTemplate tx;
WarehouseTransferRelabelCompletionService completion; InventoryCommandService inventory;
WarehouseTransferOrder transfer; RelabelApplication application; RelabelItem item;
@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); tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
for (Class<?> type : List.of(InventoryBalance.class,InventoryPosition.class,InventoryLedger.class,
InventoryOperation.class,WarehouseTransferInventoryAllocation.class,WarehouseTransferRelabelBinding.class,
WarehouseTransferOrder.class,RelabelApplication.class,RelabelItem.class)) {
var columns = new ArrayList<String>();
for (var f : type.getDeclaredFields()) {
if(java.lang.reflect.Modifier.isStatic(f.getModifiers()))continue;
TableField field=f.getAnnotation(TableField.class);if(field!=null&&!field.exist())continue;
String name=f.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
String sqlType=f.getType()==String.class?"VARCHAR(2048)":f.getType()==LocalDateTime.class?"TIMESTAMP":f.getType()==Boolean.class?"BOOLEAN":"BIGINT";
columns.add(name+" "+sqlType+(name.equals("id")?" PRIMARY KEY":""));
}
jdbc.execute("CREATE TABLE "+type.getAnnotation(TableName.class).value()+" ("+String.join(",",columns)+")");
}
jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(customer_id VARCHAR(100),warehouse_pallet_id BIGINT,status VARCHAR(40))");
jdbc.execute("CREATE UNIQUE INDEX allocation_active ON ll_warehouse_transfer_inventory_allocation(customer_id,active_position_key)");
jdbc.execute("CREATE INDEX idx_ll_wh_transfer_allocation_position ON ll_warehouse_transfer_inventory_allocation(transfer_order_id,inventory_position_id)");
jdbc.execute("CREATE UNIQUE INDEX operation_key ON ll_inventory_operation(customer_id,idempotency_key)");
var configuration=new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for(Class<?> mapper:List.of(InventoryBalanceMapper.class,InventoryPositionMapper.class,InventoryLedgerMapper.class,
InventoryOperationMapper.class,InventoryCommandMapper.class,WarehouseTransferInventoryAllocationMapper.class,
WarehouseTransferRelabelBindingMapper.class,WarehouseTransferOrderMapper.class,
com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class,
com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class))configuration.addMapper(mapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
session=new SqlSessionTemplate(factory.getObject());
inventory=new InventoryCommandService();
ReflectionTestUtils.setField(inventory,"balances",session.getMapper(InventoryBalanceMapper.class));
ReflectionTestUtils.setField(inventory,"positions",session.getMapper(InventoryPositionMapper.class));
ReflectionTestUtils.setField(inventory,"ledgers",session.getMapper(InventoryLedgerMapper.class));
ReflectionTestUtils.setField(inventory,"operations",session.getMapper(InventoryOperationMapper.class));
ReflectionTestUtils.setField(inventory,"commands",session.getMapper(InventoryCommandMapper.class));
ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(WarehouseResourceGateway.class));
ReflectionTestUtils.setField(inventory,"objectMapper",new ObjectMapper().findAndRegisterModules());
completion=new WarehouseTransferRelabelCompletionService();
ReflectionTestUtils.setField(completion,"allocations",session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
ReflectionTestUtils.setField(completion,"bindings",session.getMapper(WarehouseTransferRelabelBindingMapper.class));
ReflectionTestUtils.setField(completion,"inventoryRows",session.getMapper(InventoryCommandMapper.class));
ReflectionTestUtils.setField(completion,"inventory",inventory);
transfer=new WarehouseTransferOrder();transfer.setId(1L);transfer.setTransferNo("WT-ORIGINAL");transfer.setCustomerId("7");
transfer.setPackageCount(1);transfer.setOperatingWarehouseId("9");transfer.setLastlegOrderId(10L);transfer.setRelabelApplicationId(11L);
application=new RelabelApplication();application.setId(11L);application.setCustomerId("7");application.setLastlegOrderId(10L);
application.setCustomerConfirmedAt(LocalDateTime.now());
item=new RelabelItem();item.setId(12L);item.setApplicationId(11L);item.setCustomerId("7");item.setSkuCode("OLD");
item.setSourceInventoryPositionId(202L);item.setSourceInboundOrderId(50L);item.setCartonMark("BOX-1");item.setNotifiedPackageCount(1);
jdbc.update("INSERT INTO ll_inventory_balance(id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,in_transit_packages,procurement_packages,version) VALUES (60,'7','Synthetic customer','9','SRC','Source','ABE4',50,'IP-50','IN-50','OLD',2,2,0,0,0,0,0,0)");
position(101,701,2,0,"SOURCE");position(202,702,0,1,"MOVED");position(99,799,0,1,"OTHER-HOLD");
var allocation=new WarehouseTransferInventoryAllocation();allocation.setId(13L);allocation.setCustomerId("7");allocation.setTransferOrderId(1L);
allocation.setInventoryPositionId(101L);allocation.setInventoryBalanceId(60L);allocation.setInboundOrderId(50L);
allocation.setPackageCount(1);allocation.setStatus("RESERVED");allocation.setPalletId(701L);allocation.setActivePositionKey("7:101");
allocation.setSkuCode("OLD");
session.getMapper(WarehouseTransferInventoryAllocationMapper.class).insert(allocation);
var binding=new WarehouseTransferRelabelBinding();binding.setId(14L);binding.setCustomerId("7");binding.setTransferOrderId(1L);
binding.setSourceAllocationId(13L);binding.setRelabelItemId(12L);binding.setOriginalSku("OLD");binding.setNewSku("NEW");binding.setCustomerCartonMark("BOX-1");
session.getMapper(WarehouseTransferRelabelBindingMapper.class).insert(binding);
}
void position(long id,long pallet,long available,long hold,String batch){jdbc.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,pallet_id,pallet_no,batch_no,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES (?,'7',60,'9','ABE4',?,?,?, ?,?,0,0,0,0)",id,pallet,"P-"+pallet,batch,available,hold);}
@Test void confirmsMovedCartonIntoNewSkuAndPreservesOtherHoldWithRealCommandsAndMappers() {
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
assertEquals(2L,jdbc.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=101",Long.class));
var rows=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L);
assertEquals(1,rows.size());assertEquals(702L,rows.get(0).getPalletId());assertEquals(1,rows.get(0).getPackageCount());
assertEquals("NEW",rows.get(0).getSkuCode());
assertEquals("NEW",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_ledger WHERE business_type='STOCK_IN'",String.class));
}
@Test void missingExactCartonBindingRollsBackWithoutInventoryOrReservationMutation(){
item.setId(999L);
assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())));
assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class));
}
@Test void lateAllocationFailureRollsBackRealReleaseSkuOutputAndOriginalReservation(){
jdbc.update("INSERT INTO ll_warehouse_transfer_inventory_allocation(id,customer_id,transfer_order_id,inventory_position_id,active_position_key,status) VALUES (900,'7',999,901,'7:901','RESERVED')");
// The target ID is allocated dynamically; inject a failure after real stock changes via the source mapper.
var mapper=org.mockito.Mockito.spy(session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
org.mockito.Mockito.doThrow(new IllegalStateException("FORCED_FINAL_ALLOCATION_FAILURE")).when(mapper).insert(org.mockito.ArgumentMatchers.any(WarehouseTransferInventoryAllocation.class));
ReflectionTestUtils.setField(completion,"allocations",mapper);
assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now())));
assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=202",Long.class));
assertEquals("OLD",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_balance WHERE id=60",String.class));
assertEquals("RESERVED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class));
}
@Test void shipsActualNewSkuFromMixedBalanceAndOriginalKeysReplayWithoutSecondDecrement(){
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
var actual=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L).get(0);
assertEquals("MIXED",jdbc.queryForObject("SELECT sku_code FROM ll_inventory_balance WHERE id=60",String.class));
var allocate=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:ALLOCATE","");
var pick=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:PICK","");
var ship=new com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move(actual.getInventoryPositionId(),1,"WT-ORIGINAL","WT-ORIGINAL","WAREHOUSE_TRANSFER_OUTBOUND:WT-ORIGINAL","WAREHOUSE_TRANSFER:WT-ORIGINAL:SHIP","");
tx.executeWithoutResult(s->{inventory.allocateTransferSnapshot("7","operator",allocate,actual.getSkuCode());inventory.pickTransferSnapshot("7","operator",pick,actual.getSkuCode());inventory.shipTransferSnapshot("7","operator",ship,actual.getSkuCode());});
tx.executeWithoutResult(s->{assertTrue(inventory.allocateTransferSnapshot("7","operator",allocate,"NEW").replayed());assertTrue(inventory.pickTransferSnapshot("7","operator",pick,"NEW").replayed());assertTrue(inventory.shipTransferSnapshot("7","operator",ship,"NEW").replayed());});
assertEquals(3L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type='SHIP' AND sku_code='NEW'",Long.class));
assertEquals(0L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger WHERE business_type IN ('SHIP','PICK','ALLOCATE') AND sku_code<>'NEW'",Long.class));
assertThrows(RuntimeException.class,()->tx.executeWithoutResult(s->inventory.shipTransferSnapshot("7","operator",ship,"OLD")));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
}
@Test void cartonOnlyConfirmationKeepsSamePositionAndRetainsOriginalBindingHistory(){
jdbc.update("UPDATE ll_warehouse_transfer_relabel_binding SET new_sku=NULL WHERE id=14");
jdbc.update("UPDATE ll_inventory_position SET hold_packages=1 WHERE id=101");
jdbc.update("UPDATE ll_inventory_position SET hold_packages=0 WHERE id=202");item.setSourceInventoryPositionId(101L);
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
var current=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L);
assertEquals(1,current.size());assertEquals(101L,current.get(0).getInventoryPositionId());assertEquals("OLD",current.get(0).getSkuCode());
assertEquals(2L,jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation WHERE inventory_position_id=101",Long.class));
assertEquals("RELABELLED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE id=13",String.class));
assertEquals(13L,jdbc.queryForObject("SELECT source_allocation_id FROM ll_warehouse_transfer_relabel_binding WHERE id=14",Long.class));
assertEquals(12L,jdbc.queryForObject("SELECT relabel_item_id FROM ll_warehouse_transfer_relabel_binding WHERE id=14",Long.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
}
@Test void partiallyRelabeledTransferRetainsUnchangedOriginalRemainderAsSeparateCurrentAllocation(){
transfer.setPackageCount(2);jdbc.update("UPDATE ll_warehouse_transfer_inventory_allocation SET package_count=2 WHERE id=13");
jdbc.update("UPDATE ll_inventory_position SET available_packages=1,hold_packages=1 WHERE id=101");
jdbc.update("UPDATE ll_inventory_balance SET available_packages=1,hold_packages=3 WHERE id=60");
tx.executeWithoutResult(s->completion.complete(transfer,application,List.of(item),"operator",LocalDateTime.now()));
var current=session.getMapper(WarehouseTransferInventoryAllocationMapper.class).lockTransfer("7",1L);
assertEquals(2,current.size());assertEquals(2,current.stream().mapToInt(WarehouseTransferInventoryAllocation::getPackageCount).sum());
assertEquals(Set.of("OLD","NEW"),new HashSet<>(current.stream().map(WarehouseTransferInventoryAllocation::getSkuCode).toList()));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
assertEquals(4L,jdbc.queryForObject("SELECT SUM(available_packages+hold_packages+allocated_packages+picked_packages) FROM ll_inventory_position",Long.class));
}
@Test void customerConfirmationReplaysPersistedKeyWithoutSecondInventoryOrAllocationWrites(){
transfer.setStatus("WAIT_RELABEL");transfer.setAllocationIdempotencyKey("SELECT-ORIGINAL");transfer.setRelabelNo("RL-OWN");
transfer.setFbaWarehouseCode("ABE4");transfer.setDestinationType("FBA");transfer.setDestinationWarehouseCode("ABE4");
application.setRelabelNo("RL-OWN");application.setStatus("CUSTOMER_CONFIRM_PENDING");application.setDeliveryMethod("TRUCK");
application.setCustomerConfirmedAt(null);item.setOperationType("CARTON_AND_SKU");
session.getMapper(WarehouseTransferOrderMapper.class).insert(transfer);
var apps=session.getMapper(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);apps.insert(application);
var items=session.getMapper(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);items.insert(item);
var service=spy(new com.baiyue.lastleg.relabel.service.RelabelApplicationService());
ReflectionTestUtils.setField(service,"applications",apps);ReflectionTestUtils.setField(service,"items",items);
ReflectionTestUtils.setField(service,"warehouseTransfers",session.getMapper(WarehouseTransferOrderMapper.class));
ReflectionTestUtils.setField(service,"transferRelabelCompletion",completion);
var tasks=mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var task=new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();task.setStatus("WAIT_REVIEW");task.setVersion(1);
when(tasks.lock("7",11L)).thenReturn(task);when(tasks.updateWithVersion(any(),any())).thenReturn(1);
ReflectionTestUtils.setField(service,"pdaTasks",tasks);
ReflectionTestUtils.setField(service,"returnOrders",mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper.class));
ReflectionTestUtils.setField(service,"histories",mock(com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper.class));
ReflectionTestUtils.setField(service,"orderHistories",mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class));
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var order=new com.baiyue.lastleg.entity.LastlegOrder();
order.setId(10L);order.setCustomerId("7");order.setCurrentStatus("WAIT_RELABEL");order.setOrderType("WAREHOUSE_TRANSFER");
when(orders.selectList(any())).thenReturn(List.of(order));when(orders.selectById(10L)).thenReturn(order);
ReflectionTestUtils.setField(service,"orders",orders);doReturn(null).when(service).detail("7","RL-OWN");
var command=new com.baiyue.lastleg.relabel.dto.RelabelRequests.CustomerConfirm("CONFIRM-ORIGINAL","confirmed");
tx.executeWithoutResult(s->service.confirmCompletion("7","RL-OWN","operator",command));
long ledgers=jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class);
long allocations=jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation",Long.class);
tx.executeWithoutResult(s->service.confirmCompletion("7","RL-OWN","operator",command));
assertEquals(ledgers,jdbc.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
assertEquals(allocations,jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation",Long.class));
assertEquals("ALLOCATED",jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_order WHERE id=1",String.class));
assertEquals("CONFIRM-ORIGINAL",jdbc.queryForObject("SELECT customer_confirmation_key FROM ll_relabel_application WHERE id=11",String.class));
assertEquals(1L,jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=99",Long.class));
}
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.transfer.service;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseTransferRelabelSchemaContractTest {
private List<String> baselines() throws Exception {
return List.of(new ClassPathResource("db/lastleg.sql").getContentAsString(StandardCharsets.UTF_8),
Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql")));
}
@Test
void bothBaselinesPersistExactAllocationSkuAndOriginalRelabelLinks() throws Exception {
for (String sql : baselines()) {
assertThat(table(sql, "ll_warehouse_transfer_inventory_allocation").toLowerCase())
.contains("sku_code varchar(128)");
assertThat(table(sql, "ll_warehouse_transfer_relabel_binding").toLowerCase())
.contains("source_allocation_id bigint", "relabel_item_id bigint")
.contains("uk_ll_transfer_relabel_mark");
}
}
@Test
void historyCanSharePositionWhileOnlyOneActiveAllocationOwnsIt() throws Exception {
for (String sql : baselines()) {
String allocation = table(sql, "ll_warehouse_transfer_inventory_allocation")
.replaceAll("\\s+", "").toLowerCase();
assertThat(allocation)
.contains("keyidx_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id)")
.contains("uniquekeyuk_ll_wh_transfer_active_position(customer_id,active_position_key)")
.doesNotContain("uniquekeyuk_ll_wh_transfer_allocation_position");
}
}
@Test
void controlledUpdateOnlyChangesTwoStructuresWithoutRepairingBusinessFacts() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "migrations", "overseas",
"baiyue_lastleg", "V8__bind_stock_transfer_relabel_sources.sql"))
.replaceAll("(?m)^--.*$", "").toLowerCase();
assertThat(sql.split(";")).filteredOn(part -> !part.isBlank()).hasSize(2);
assertThat(sql)
.contains("alter table ll_warehouse_transfer_inventory_allocation",
"alter table ll_warehouse_transfer_relabel_binding", "add column sku_code varchar(128) null",
"add column source_allocation_id bigint null", "add column relabel_item_id bigint null",
"drop index uk_ll_wh_transfer_allocation_position",
"add key idx_ll_wh_transfer_allocation_position")
.doesNotContain("drop index uk_ll_wh_transfer_active_position", "drop table", "truncate",
"insert into", "update ll_", "delete from", "disable keys");
}
private static String table(String sql, String name) {
int start = sql.indexOf("CREATE TABLE " + name + " (");
if (start < 0) start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + name + " (");
assertThat(start).as(name).isGreaterThanOrEqualTo(0);
int end = sql.indexOf(") ENGINE", start);
assertThat(end).isGreaterThan(start);
return sql.substring(start, end);
}
}
@@ -170,6 +170,7 @@ class WarehouseTransferServiceTest {
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
private WarehouseTransferService service;
@BeforeEach
@@ -201,6 +202,7 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next(anyString(), any(), anyLong()))
.thenReturn("WT-WH-20260901-000001");
@@ -791,6 +793,72 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(inventory);
}
@Test
void requiredRelabelCannotShipReservedOriginalStockBeforeCustomerConfirmation() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
transfer.setNeedRelabel(true);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-RELABEL", "must wait")));
verifyNoInteractions(inventory);
}
@Test
void choosingOriginalStockStartsItsOwnRelabelAndReplaysWithoutSecondApplicationOrHold() {
var transfer = transfer("APPROVED"); transfer.setNeedRelabel(true); transfer.setPackageCount(1);
var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding();
binding.setId(80L); binding.setCustomerCartonMark("BOX-1");binding.setOriginalSku("SKU-TRANSFER");
binding.setNewSku("NEW");binding.setOperationType("CARTON_AND_SKU");binding.setQuantity(1);
var cargo = new com.baiyue.lastleg.transfer.entity.WarehouseTransferItem();cargo.setId(90L);cargo.setSkuCode("SKU-TRANSFER");cargo.setPackageCount(1);
var summary = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.dto.RelabelResponses.Summary.class);
when(summary.id()).thenReturn(42L);when(summary.relabelNo()).thenReturn("RL-OWN");
var carton = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.dto.RelabelResponses.Item.class);
when(carton.id()).thenReturn(43L);when(carton.cartonMark()).thenReturn("BOX-1");
var created = new com.baiyue.lastleg.relabel.dto.RelabelResponses.Detail(summary,"",List.of(carton),List.of(),List.of(),null);
when(relabelService.createForExistingTailOrder(eq("TENANT-A"),eq("USER-1"),eq(31L),any())).thenReturn(created);
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(sourceOrder()));
when(inventoryCommands.lockPosition("TENANT-A",501L)).thenReturn(position(501,601,701,3));
when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(balance(601,101,"IN-1"));
when(transferItems.selectList(any())).thenReturn(List.of(cargo));
when(relabelBindings.listOrder("TENANT-A",21L)).thenReturn(List.of(binding));
when(orders.selectById(31L)).thenReturn(tailOrder());
var command=new WarehouseTransferRequests.Allocate("ORIGINAL-SELECT",List.of(new WarehouseTransferRequests.Allocation(501L,701L,1)));
var response=service.allocate("TENANT-A","WTR-1","USER-1",command);
assertEquals("WAIT_RELABEL",response.status());assertEquals("RL-OWN",transfer.getRelabelNo());
assertEquals(43L,binding.getRelabelItemId());assertNotNull(binding.getSourceAllocationId());
service.allocate("TENANT-A","WTR-1","USER-1",command);
verify(relabelService).createForExistingTailOrder(eq("TENANT-A"),eq("USER-1"),eq(31L),any());
verify(inventoryAllocations).insert(any(WarehouseTransferInventoryAllocation.class));
verifyNoInteractions(inventory);
assertThrows(ResponseStatusException.class,()->service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("EARLY","")));
}
@Test
void confirmedOwnRelabelShipsAndReceivesItsNewSkuAndCompletesLinkedFulfillment() {
var transfer=transfer("ALLOCATED");transfer.setNeedRelabel(true);transfer.setRelabelNo("RL-OWN");
transfer.setRelabelApplicationId(42L);transfer.setRelabelCompletedAt(LocalDateTime.now());
var app=new RelabelApplication();app.setId(42L);app.setLastlegOrderId(31L);app.setCustomerConfirmedAt(LocalDateTime.now());
var current=allocation();current.setSkuCode("NEW");
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer);
when(relabelApplications.lockNo("TENANT-A","RL-OWN")).thenReturn(app);
when(inventoryAllocations.lockTransfer("TENANT-A",21L)).thenReturn(List.of(current));
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-NEW","departed"));
service.outbound("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Outbound("OUT-NEW","departed"));
verify(inventory).shipTransferSnapshot(eq("TENANT-A"),eq("USER-1"),any(),eq("NEW"));
verify(inventory,never()).ship(anyString(),anyString(),any());assertEquals("OUTBOUNDED",app.getFulfillmentStatus());
var mixed=balance(601,101,"IN-1");mixed.setSkuCode("MIXED");when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(mixed);
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());
var received=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"),eq("USER-1"),received.capture());assertEquals("NEW",received.getValue().skuCode());
assertEquals("RECEIVED",transfer.getStatus());assertEquals("COMPLETED",app.getStatus());assertEquals("COMPLETED",app.getFulfillmentStatus());
}
@Test
void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);