Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
This commit is contained in:
+1
-1
@@ -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,
|
||||
|
||||
@@ -325,7 +325,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'));
|
||||
@@ -338,8 +339,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;
|
||||
@@ -360,4 +362,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("""
|
||||
|
||||
+10
-4
@@ -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;
|
||||
}
|
||||
|
||||
+19
@@ -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+", " ");
|
||||
|
||||
@@ -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`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 本柜在国内冻结、由海外首次扫码确权的箱级作业事实。 */
|
||||
/** 头程装柜完成时冻结并关联尾程订单、由海外扫码补充托盘归属的箱级事实。 */
|
||||
@Data
|
||||
@TableName("ll_inbound_plan_carton")
|
||||
public class InboundPlanCarton {
|
||||
|
||||
+28
-11
@@ -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(),
|
||||
|
||||
+4
-6
@@ -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>" +
|
||||
|
||||
+5
-5
@@ -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',
|
||||
|
||||
+4
-1
@@ -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"));
|
||||
}
|
||||
|
||||
+25
-5
@@ -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());
|
||||
|
||||
+2
-1
@@ -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); }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user