fix(lastleg): keep inventory warehouse snapshots canonical

This commit is contained in:
2026-09-08 16:16:20 +08:00
parent 6908969307
commit 40fe0ba0e1
16 changed files with 182 additions and 21 deletions
@@ -86,6 +86,7 @@ class InboundStockReceiptServiceTest {
assertEquals("SKU-1", request.getValue().skuCode());
assertEquals("Customer", request.getValue().customerName());
assertEquals("900", request.getValue().overseasWarehouseId());
assertEquals("US-LAX-01", request.getValue().overseasWarehouseCode());
assertEquals("Los Angeles", request.getValue().overseasWarehouseName());
assertEquals("PLT-21", request.getValue().palletNo());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
@@ -297,7 +298,8 @@ class InboundStockReceiptServiceTest {
line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0"));
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setSourceWarehouseId("100");
order.setOverseasWarehouseId("900"); order.setOverseasWarehouseName("Los Angeles");
order.setOverseasWarehouseId("900"); order.setOverseasWarehouseCode("US-LAX-01");
order.setOverseasWarehouseName("Los Angeles");
order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
@@ -119,6 +119,7 @@ class InboundPlanServiceTest {
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("193000000000200003"),
org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"),
org.mockito.ArgumentMatchers.eq(result.planNo()), any());
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
@@ -253,7 +254,7 @@ class InboundPlanServiceTest {
assertEquals(1L, result.sourceOrderId());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -484,7 +485,7 @@ class InboundPlanServiceTest {
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -504,7 +505,7 @@ class InboundPlanServiceTest {
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -567,7 +568,7 @@ class InboundPlanServiceTest {
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -9,6 +9,8 @@ import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -82,6 +84,22 @@ class InventorySchemaContractTest {
assertFalse(sql.contains("sku_code"));
}
@Test
void balanceQueriesHideZeroRowsAndMutationsKeepWarehouseDimensionCanonical() throws Exception {
assertTrue(InventoryQueryMapper.BALANCE_FILTER.contains("procurement_packages)>0"));
assertTrue(InventoryQueryMapper.JOINED_BALANCE_FILTER.contains("b.procurement_packages)>0"));
String bindSql = String.join(" ", com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper.class
.getMethod("bindInboundPlan", String.class, String.class, Long.class, String.class,
String.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class).value());
assertTrue(bindSql.contains("overseas_warehouse_id=#{overseasWarehouseId}"));
String deleteSql = String.join(" ", com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper.class
.getMethod("deleteEmptyBalance", String.class, Long.class)
.getAnnotation(Delete.class).value());
assertTrue(deleteSql.contains("in_transit_packages=0"));
assertTrue(deleteSql.contains("NOT EXISTS"));
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
@@ -140,6 +140,7 @@ class InventoryCommandServiceTest {
source.setInTransitPackages(12L);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(target);
when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(source));
when(commands.deleteEmptyBalance("100", 201L)).thenReturn(1);
InventoryCommandResponse result = service.stockIn("100", "7",
stockIn("IN-1", "stock-in-cross-warehouse", 12, 24));
@@ -152,6 +153,40 @@ class InventoryCommandServiceTest {
assertEquals(-12L, written.getAllValues().get(0).getDeltaPackages());
assertEquals("STOCK_IN", written.getAllValues().get(1).getBusinessType());
assertEquals("IN_TRANSIT", written.getAllValues().get(1).getStatusFrom());
verify(commands).deleteEmptyBalance("100", 201L);
}
@Test
void receivingWarehouseChangeRebindsOnlyInTransitBalanceAndWritesZeroDeltaAudit() {
InventoryBalance balance = balance(200L, 0);
balance.setInTransitPackages(12L);
when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance));
when(balances.lockDimension("100", "900", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
service.rebindReceivingWarehouse("100", 101L, 900L, "US-NYC-01", "纽约仓",
"admin", "change-1");
assertEquals("900", balance.getOverseasWarehouseId());
assertEquals("US-NYC-01", balance.getOverseasWarehouseCode());
assertEquals("纽约仓", balance.getOverseasWarehouseName());
ArgumentCaptor<InventoryLedger> audit = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(audit.capture());
assertEquals("RECEIVING_WAREHOUSE_CHANGED", audit.getValue().getBusinessType());
assertEquals(0L, audit.getValue().getDeltaPackages());
assertEquals(12L, audit.getValue().getBeforePackages());
assertEquals(12L, audit.getValue().getAfterPackages());
}
@Test
void receivingWarehouseChangeRejectsPhysicalInventoryBeforeAnySnapshotMutation() {
InventoryBalance balance = balance(200L, 1);
when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance));
assertThrows(ResponseStatusException.class, () -> service.rebindReceivingWarehouse(
"100", 101L, 900L, "US-NYC-01", "纽约仓", "admin", "change-physical"));
verify(balances, never()).updateById(any(InventoryBalance.class));
verify(ledgers, never()).insert(any(InventoryLedger.class));
}
@Test
@@ -311,6 +346,7 @@ class InventoryCommandServiceTest {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
value.setOverseasWarehouseId("WH-1");
value.setOverseasWarehouseCode("WH1"); value.setOverseasWarehouseName("Warehouse 1");
value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L);
value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1");
value.setAvailablePackages(available);
@@ -109,6 +109,10 @@ class LastlegOrderServiceTest {
assertEquals("US-NYC-01", result.overseasWarehouseCode());
assertTrue(result.allPlansReady());
assertTrue(result.canChangeReceivingWarehouse());
verify(inventory).rebindReceivingWarehouse("100", 1L, 900L,
"US-NYC-01", "纽约仓", "admin", "change-1");
verify(inventory).rebindReceivingWarehouse("200", 2L, 900L,
"US-NYC-01", "纽约仓", "admin", "change-1");
verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L);
verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class));
}
@@ -27,6 +27,12 @@ class LoadingCompletedConsumerTest {
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
service.consume(event());
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(value->"193000000000200003".equals(value.overseasWarehouseId())&&"US-LAX-01".equals(value.overseasWarehouseCode())&&"洛杉矶仓".equals(value.overseasWarehouseName())));
}
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));