修改提交

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
@@ -47,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2(
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark,
String palletNo,
/** 头程箱唛二维码中的箱号;这些箱号均对应本明细的 skuCode。 */
/** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */
List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
@@ -16229,6 +16229,23 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 尾程库存余额按客户入仓单与海外目的仓汇总箱数,SKU仅保留为快照。
SET @ddl = (SELECT CASE
WHEN EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension' AND column_name='sku_code')
THEN 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension, ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'
WHEN NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension')
THEN 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'
ELSE 'SELECT 1' END);
PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_inventory_balance' AND column_name='sku_code'),
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
'SELECT 1'));
PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt;
ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额';
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -235,7 +235,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'),
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code'));
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
@@ -248,8 +249,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'),
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)'));
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'),
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
@@ -270,4 +272,4 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与SKU库存结构已就绪' AS message;
SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与入仓单目的仓库存结构已就绪' AS message;
@@ -6,7 +6,7 @@ import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 读取国内已打印的稳定箱唛池;普通装柜不在国内逐箱确认归属。 */
/** 读取国内已打印的稳定箱唛池,供装柜完成事件按实装箱数抽取冻结。 */
@Mapper
public interface LoadingCartonManifestMapper {
@Select("""
@@ -24,6 +24,7 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -102,9 +103,7 @@ public class LoadingCompletedOutboxService {
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets);
}
/**
* 国内只确认本柜实装箱数,不扫每张箱唛。事件携带全量可用标签池,海外首扫才确权具体箱唛。
*/
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
for (Row row : rows) {
@@ -122,7 +121,14 @@ public class LoadingCompletedOutboxService {
if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件");
}
result.put(row.getLoadingPlanItemId(), labels);
int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0);
String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId();
List<String> selected = labels.stream()
.sorted(Comparator.comparing(label -> EventPayloadHash.sha256(
(seed + ":" + label).getBytes(StandardCharsets.UTF_8))))
.limit(loadedPackages)
.toList();
result.put(row.getLoadingPlanItemId(), selected);
}
return result;
}
@@ -68,6 +68,25 @@ class LoadingCompletedOutboxServiceTest {
.hasMessageContaining("缺少 SKU");
}
@Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception {
Row value = row(11, 100, 101);
value.setPackageCount(2);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(manifests.labelPool(11L, 100L, 101L)).thenReturn(
List.of("BOX-001", "BOX-002", "BOX-003", "BOX-004"));
service.publish(90L);
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
EventEnvelope first = mapper.readValue(captor.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
EventEnvelope second = mapper.readValue(captor.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
assertThat(first.payload().at("/orders/0/items/0/cartonMarks")).hasSize(2);
assertThat(first.payload().at("/orders/0/items/0/cartonMarks"))
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
+1 -1
View File
@@ -76,7 +76,7 @@
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
- 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。
- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
@@ -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); }
+23
View File
@@ -3655,6 +3655,29 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028;
-- ============================================================================
-- 2026-08-22 / 030
-- Inventory balances aggregate packages by customer inbound and destination;
-- SKU remains a snapshot and no longer participates in the unique dimension.
-- Existing duplicate dimensions intentionally fail the unique-index rebuild so
-- operators can reconcile pallet and location balances before retrying.
-- ============================================================================
SET @ddl = (SELECT CASE
WHEN EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension' AND column_name='sku_code')
THEN 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension, ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'
WHEN NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension')
THEN 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'
ELSE 'SELECT 1' END);
PREPARE phase4_inventory_dimension_030 FROM @ddl; EXECUTE phase4_inventory_dimension_030; DEALLOCATE PREPARE phase4_inventory_dimension_030;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_inventory_balance' AND column_name='sku_code'),
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
'SELECT 1'));
PREPARE phase4_inventory_dimension_030 FROM @ddl; EXECUTE phase4_inventory_dimension_030; DEALLOCATE PREPARE phase4_inventory_dimension_030;
ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额';
-- ============================================================================
-- 2026-08-20 / 029
-- FBA appointment expected loading time used by the platform appointment editor.