fix(dropship): preserve carton quantities and shared pallet outbound ownership

This commit is contained in:
2026-09-14 20:55:09 +08:00
parent 6dbcb2128c
commit 4f13ee2ec3
24 changed files with 302 additions and 99 deletions
@@ -27,7 +27,7 @@ class DeliverySchemaContractTest {
assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)"));
assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code)"));
assertTrue(schema.contains("destination_warehouse_code varchar(128) null"));
assertTrue(schema.contains("order_refs varchar(2000) null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
@@ -279,6 +279,31 @@ class DeliveryTransferServiceTest {
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301");
outbound.setOutboundType("ONE_CLICK_DROPSHIP");
outbound.setStatus("OUTBOUNDED");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(
pallet(501L, "DP-260914-0001", "ONT8", "Ontario", "IN-A", 4)));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
assertEquals("OUTBOUNDED", detail.outbound().status());
org.junit.jupiter.api.Assertions.assertNull(detail.delivery());
assertEquals("IN-A", detail.pallets().get(0).inboundCode());
assertEquals(4, detail.pallets().get(0).packageCount());
}
@Test
void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
outbound.setOutboundType("DISPATCH_DELIVERY");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
assertThrows(IllegalStateException.class, () -> service.outboundDetail("OUT-301"));
}
@Test
void outboundDetailIncludesPalletCodesForDisplay() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
@@ -406,6 +431,33 @@ class DeliveryTransferServiceTest {
verify(outboundPallets, never()).findByOutbound(any(), any());
}
@Test
void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() {
for (long i = 1; i <= 2; i++) {
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 900L + i, "SKU-1"))
.thenReturn(dropshipCandidate(900L + i, 1000L + i, "WH-1", "DP-SHARED", "SKU-1", 1L));
when(dropshipCartons.lockAvailable("TENANT-A", 1000L + i, "SKU-1", 1L))
.thenReturn(List.of(new DropshipCarton()));
InventoryPosition position = new InventoryPosition();
position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i);
position.setPalletId(501L); position.setPalletNo("DP-SHARED");
InventoryBalance balance = new InventoryBalance();
balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i);
when(inventoryPositions.selectById(900L + i)).thenReturn(position);
when(inventoryBalances.selectById(800L + i)).thenReturn(balance);
}
service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
new DeliveryRequests.OneClickItem(901L, 1L, "SKU-1"),
new DeliveryRequests.OneClickItem(902L, 1L, "SKU-1")), null, "shared-pallet"));
ArgumentCaptor<OutboundPalletItem> items = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(items.capture());
assertEquals(List.of("IN-1", "IN-2"), items.getAllValues().stream().map(OutboundPalletItem::getInboundCode).toList());
ArgumentCaptor<OutboundOrder> order = ArgumentCaptor.forClass(OutboundOrder.class);
verify(outboundOrders).updateById(order.capture());
assertEquals(1, order.getValue().getPalletCount());
assertEquals(2, order.getValue().getPackageCount());
}
@Test
void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() {
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L);
@@ -539,17 +591,26 @@ class DeliveryTransferServiceTest {
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A");
DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B");
InventoryPosition otherInbound = new InventoryPosition();
otherInbound.setId(902L); otherInbound.setCustomerId("TENANT-A"); otherInbound.setBalanceId(802L);
otherInbound.setOverseasWarehouseId("WH-1"); otherInbound.setPalletId(501L);
otherInbound.setAvailablePackages(2L);
InventoryBalance otherBalance = new InventoryBalance();
otherBalance.setId(802L); otherBalance.setCustomerId("TENANT-A"); otherBalance.setInboundCode("IN-B");
otherBalance.setSkuCode("SKU-1");
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position));
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, otherInbound));
when(inventoryBalances.selectById(801L)).thenReturn(balance);
when(inventoryBalances.selectById(802L)).thenReturn(otherBalance);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second));
DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701");
assertEquals(2, detail.items().get(0).positions().size());
verify(dropshipProfiles, never()).findPosition("TENANT-A", 902L);
assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream()
.map(DeliveryResponses.PdaPickPosition::cartonMark).toList());
DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0);
@@ -588,6 +649,41 @@ class DeliveryTransferServiceTest {
assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark());
}
@Test
void dropshipPartialPicksDeductExactCartonProductQuantities() {
var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class);
ReflectionTestUtils.setField(service, "inventoryCommands", commands);
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "quantity-test");
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP");
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2);
item.setSkuCode("SKU-1");
InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A");
position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1");
InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A");
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L);
DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(item.getId())).thenReturn(item);
when(inventoryPositions.selectById(901L)).thenReturn(position);
when(inventoryBalances.selectById(801L)).thenReturn(balance);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "SKU-1", 1L)).thenReturn(List.of(first), List.of(second));
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1);
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7"));
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13"));
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L);
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L);
assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus());
assertEquals(2, item.getPickedPackageCount());
}
@Test
void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
@@ -59,7 +59,7 @@ class DropshipOrderControllerContractTest {
"name: X-Internal-Service-Token",
"required: [customerId, orderId, warehouseId, idempotencyKey, pallets]",
"required: [palletNo, skuPackages]",
"required: [skuCode, packageCount, productQuantity, cartonMarks]",
"required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]",
"data: { type: integer, format: int32, minimum: 0 }");
assertThat(yaml.indexOf("name: X-Internal-Service-Token"))
.isLessThan(yaml.indexOf("requestBody:"));
@@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest {
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
List.of(new DropshipRequests.Pallet("PALLET-1",
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64))));
List.of("CARTON-1"), java.util.Map.of("CARTON-1", 2L))), 1, 1, 1, "0".repeat(64))));
}
}
@@ -52,10 +52,10 @@ class DropshipOrderServiceTest {
List.of(
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)),
List.of("MARK-1", "MARK-2"), java.util.Map.of("MARK-1", 7L, "MARK-2", 13L))), 1, 2, 1, "0".repeat(64)),
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
List.of("MARK-3"))), 1, 1, 1, "1".repeat(64))));
List.of("MARK-3"), java.util.Map.of("MARK-3", 10L))), 1, 1, 1, "1".repeat(64))));
assertThat(service.receive(request)).isEqualTo(3);
verifyNoInteractions(warehouse);
@@ -76,6 +76,8 @@ class DropshipOrderServiceTest {
.containsOnlyNulls();
ArgumentCaptor<DropshipCarton> cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class);
verify(cartons, times(3)).insert(cartonCaptor.capture());
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getProductQuantity)
.containsExactly(7L, 13L, 10L);
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus)
.containsOnly("PENDING_INBOUND");
ArgumentCaptor<DropshipCartonLedger> ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class);
@@ -141,6 +141,18 @@ class LoadingCompletedConsumerTest {
}
verify(dropshipOrders).receive(any());
}
@Test void preservesNonUniformCartonQuantitiesAcrossTheEventBoundary() {
EventEnvelope original=overseasDropshipEvent(false);
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
var sku=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("pallets").get(0).path("skuPackages").get(0);
var quantities=sku.putObject("cartonQuantities");
quantities.put("RK1#5-1",6).put("RK1#5-2",7).put("RK1#5-3",8).put("RK1#5-4",9).put("RK1#5-5",20);
service.consume(rehash(original,node));
var captured=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
verify(dropshipOrders).receive(captured.capture());
assertEquals(Map.of("RK1#5-1",6L,"RK1#5-2",7L,"RK1#5-3",8L,"RK1#5-4",9L,"RK1#5-5",20L),
captured.getValue().pallets().get(0).skuPackages().get(0).cartonQuantities());
}
@Test void rejectsAmbiguousDropshipSkuSourceBeforeWritingReceivingLabels() {
var failure=assertThrows(IllegalArgumentException.class,()->service.consume(overseasDropshipEvent(true)));
assertTrue(failure.getMessage().contains("来源明细"));
@@ -159,6 +171,7 @@ class LoadingCompletedConsumerTest {
var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0);
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet);
addManifest(secondPallet, 5);
service.consume(rehash(original,node));
@@ -230,5 +243,15 @@ class LoadingCompletedConsumerTest {
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); }
private void addManifest(JsonNode pallet,int packageCount){
var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet;
object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64));
// These fixtures explicitly represent uniform cartons; production must never infer this ratio.
for(JsonNode sku:object.path("skuPackages")) {
int count=sku.path("packageCount").asInt(), quantity=sku.path("productQuantity").asInt();
assertTrue(count>0 && quantity%count==0);
var quantities=((com.fasterxml.jackson.databind.node.ObjectNode)sku).putObject("cartonQuantities");
for(JsonNode mark:sku.path("cartonMarks")) quantities.put(mark.asText(),quantity/count);
}
}
}