修改提交

This commit is contained in:
2026-08-22 10:16:45 +08:00
parent af8724dd70
commit 93feb7da67
16 changed files with 150 additions and 44 deletions
@@ -7,7 +7,7 @@ import lombok.Data;
import java.time.LocalDateTime;
/** 本柜在国内冻结、由海外首次扫码确权的箱级作业事实。 */
/** 头程装柜完成时冻结并关联尾程订单、由海外扫码补充托盘归属的箱级事实。 */
@Data
@TableName("ll_inbound_plan_carton")
public class InboundPlanCarton {
@@ -48,7 +48,7 @@ public class InventoryCommandService {
balance.setInTransitPackages(before + request.packages());
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, null, "IN_TRANSIT",
ledger(customerId, operatorId, operation.getId(), balance, null, sku(request.skuCode()), "IN_TRANSIT",
"OUTSIDE", "IN_TRANSIT", before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -74,7 +74,7 @@ public class InventoryCommandService {
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, "STOCK_IN",
ledger(customerId, operatorId, operation.getId(), balance, position, sku(request.skuCode()), "STOCK_IN",
transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", "AVAILABLE",
before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
@@ -138,7 +138,7 @@ public class InventoryCommandService {
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, "SHIP",
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -170,7 +170,7 @@ public class InventoryCommandService {
balance.setAvailablePackages(balanceAfter);
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, "ADJUST",
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "ADJUST",
"AVAILABLE", "AVAILABLE", before, request.packageDelta(), after,
request.businessOrderNo(), null, null, null, request.idempotencyKey(),
request.reason() + remarkSuffix(request.remark()));
@@ -202,7 +202,7 @@ public class InventoryCommandService {
packages(balance, to, packages(balance, to) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, operationType,
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), operationType,
from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -234,8 +234,11 @@ public class InventoryCommandService {
String productNameEn) {
String skuCode = sku(requestedSkuCode);
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
destinationWarehouseCode, inboundOrderId, inboundCode, skuCode);
if (balance != null) return balance;
destinationWarehouseCode, inboundOrderId, inboundCode);
if (balance != null) {
mergeSkuSnapshot(balance, skuCode);
return balance;
}
balance = new InventoryBalance();
balance.setId(IdWorker.getId());
balance.setCustomerId(customerId);
@@ -261,8 +264,11 @@ public class InventoryCommandService {
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
destinationWarehouseCode, inboundOrderId, inboundCode, skuCode);
if (concurrent != null) return concurrent;
destinationWarehouseCode, inboundOrderId, inboundCode);
if (concurrent != null) {
mergeSkuSnapshot(concurrent, skuCode);
return concurrent;
}
throw exception;
}
}
@@ -365,7 +371,7 @@ public class InventoryCommandService {
}
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
InventoryPosition position, String businessType, String from, String to,
InventoryPosition position, String skuCode, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages,
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
String sourceEventId, String idempotencyKey, String remark) {
@@ -381,7 +387,7 @@ public class InventoryCommandService {
ledger.setPalletNo(position == null ? null : position.getPalletNo());
ledger.setBatchNo(position == null ? null : position.getBatchNo());
ledger.setWarehouseLocationCode(position == null ? null : position.getWarehouseLocationCode());
ledger.setSkuCode(balance.getSkuCode());
ledger.setSkuCode(skuCode);
ledger.setBusinessType(businessType);
ledger.setBusinessOrderId(balance.getInboundOrderId());
ledger.setBusinessOrderNo(trim(businessOrderNo));
@@ -406,6 +412,17 @@ public class InventoryCommandService {
ledgers.insert(ledger);
}
private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) {
String existing = trim(balance.getSkuCode());
if (existing == null) {
balance.setSkuCode(skuCode);
} else if (!existing.equals(skuCode) && !"MIXED".equals(existing)) {
balance.setSkuCode("MIXED");
balance.setProductNameZh(null);
balance.setProductNameEn(null);
}
}
private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance,
InventoryPosition position, boolean palletEmpty) {
return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(),
@@ -12,24 +12,22 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1")
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1")
InventoryBalance findDimension(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode,
@Param("skuCode") String skuCode);
@Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE")
InventoryBalance lockDimension(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode,
@Param("skuCode") String skuCode);
@Param("inboundCode") String inboundCode);
@Select("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
@@ -118,8 +118,8 @@ public class LoadingCompletedConsumer {
throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU");
}
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
if (marks.size() < loadedPackages) {
throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量少于实装箱数: " + skuCode);
if (marks.size() != loadedPackages) {
throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量必须等于实装箱数: " + skuCode);
}
for (String mark : marks) {
if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) {
@@ -183,7 +183,7 @@ public class LoadingCompletedConsumer {
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
createCartonLabelPool(customerId, item);
createCartonLabels(customerId, order.getId(), item);
}
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory();
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
@@ -218,7 +218,7 @@ public class LoadingCompletedConsumer {
}
}
private void createCartonLabelPool(String customerId, FirstLegLoadingPlanCompletedV1.Item item) {
private void createCartonLabels(String customerId, Long orderId, FirstLegLoadingPlanCompletedV1.Item item) {
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
@@ -226,7 +226,7 @@ public class LoadingCompletedConsumer {
}
for (String mark : marks) {
InboundPlanCarton carton = new InboundPlanCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(customerId);
carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); carton.setOrderId(orderId);
carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode());
carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId());
carton.setDestinationWarehouseCode(item.destinationWarehouseCode());
@@ -372,7 +372,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID',
order_id BIGINT NULL COMMENT '生成尾程订单时绑定的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID',
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
@@ -678,7 +678,7 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance (
inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID',
inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照',
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码;无SKU的专项库存使用UNSPECIFIED',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数',
@@ -689,11 +689,11 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code),
UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code),
KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at),
KEY idx_ll_inventory_inbound (customer_id,inbound_code),
KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)
) ENGINE=InnoDB COMMENT='入仓单与SKU维度实时库存余额';
) ENGINE=InnoDB COMMENT='客户入仓单与海外目的仓维度实时库存余额';
CREATE TABLE IF NOT EXISTS ll_inventory_position (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID',
@@ -28,7 +28,7 @@ class InventorySchemaContractTest {
}
assertTrue(schema.contains("create table if not exists ll_inventory_balance"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)"));
assertTrue(schema.contains("inbound_order_id bigint not null"));
assertTrue(schema.contains("inbound_code varchar(128) not null"));
assertTrue(schema.contains("available_packages bigint not null default 0"));
@@ -89,6 +89,9 @@ class InventorySchemaContractTest {
assertTrue(migration.contains("alter table ll_inventory_ledger add column inbound_order_id"));
assertTrue(migration.contains("update ll_inventory_ledger"));
assertTrue(migration.contains("alter table ll_dispatch_order add column partial_completion_reason"));
assertTrue(migration.contains("2026-08-22 / 030"));
assertTrue(migration.contains("drop index uk_ll_inventory_dimension"));
assertTrue(migration.contains("customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)"));
assertFalse(migration.contains("drop column sku_code"));
assertFalse(migration.contains("drop column product_quantity"));
}
@@ -63,7 +63,7 @@ class InventoryCommandServiceTest {
void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class,
InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24));
assertEquals(12, result.availablePackages());
@@ -79,7 +79,7 @@ class InventoryCommandServiceTest {
@Test
void registersInTransitWithoutCreatingAPalletPosition() {
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
InventoryCommandResponse result = service.registerInTransit("100", "SYSTEM", inTransit("transit-1", 12));
@@ -95,8 +95,9 @@ class InventoryCommandServiceTest {
@Test
void stockInTransfersPackagesFromInTransitToAvailable() {
InventoryBalance balance = balance(200L, 0);
balance.setSkuCode("SKU-1");
balance.setInTransitPackages(12L);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(balance);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(balance);
InventoryCommandResponse result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-transit", 12, 24));
@@ -105,9 +106,28 @@ class InventoryCommandServiceTest {
assertEquals(12L, balance.getAvailablePackages());
}
@Test
void differentSkusShareInboundBalanceAndKeepSkuInLedger() {
InventoryBalance balance = balance(200L, 4);
balance.setSkuCode("SKU-1");
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(balance);
InventoryCommandRequests.StockIn request = new InventoryCommandRequests.StockIn(
"100", "Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-1", "ONT8", "ONT8",
101L, "IN-1", "IN-CODE-1", "SKU-2", "Product 2", null, 300L, "PLT-1", null,
null, null, 3, "ORD-1", "EVT-1", "stock-in-sku-2", null);
service.stockIn("100", "7", request);
assertEquals("MIXED", balance.getSkuCode());
assertEquals(7L, balance.getAvailablePackages());
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(ledger.capture());
assertEquals("SKU-2", ledger.getValue().getSkuCode());
}
@Test
void stockInAllowsPalletPositionWithZeroOptionalPackages() {
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0));
@@ -133,7 +153,7 @@ class InventoryCommandServiceTest {
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
service.stockIn("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
@@ -47,6 +47,7 @@ class LoadingCompletedConsumerTest {
verify(cartons,times(8)).insert(cartonCaptor.capture());
assertEquals("SKU-1",cartonCaptor.getAllValues().get(0).getSkuCode());
assertEquals("BOX-1",cartonCaptor.getAllValues().get(0).getCartonMark());
assertNotNull(cartonCaptor.getAllValues().get(0).getOrderId());
}
@Test void rejectsV2EventWithoutSkuCartonMapping(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
@@ -55,7 +56,7 @@ class LoadingCompletedConsumerTest {
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2));
assertTrue(error.getMessage().contains("箱唛数量少于实装箱数"));
assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数"));
verify(orders,never()).insert(any(LastlegOrder.class));
}
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","MSCU1","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)),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)))); 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); }