fix(lastleg): keep inventory warehouse snapshots canonical

This commit is contained in:
2026-09-08 16:16:20 +08:00
parent 6908969307
commit 40fe0ba0e1
16 changed files with 182 additions and 21 deletions
+1
View File
@@ -99,6 +99,7 @@
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。
- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
@@ -228,6 +228,8 @@ public class InboundStockReceiptService {
"Actual overseas warehouse is required for stock-in");
String overseasWarehouseName = required(order.getOverseasWarehouseName(),
"Actual overseas warehouse name is required for stock-in");
String overseasWarehouseCode = required(order.getOverseasWarehouseCode(),
"Actual overseas warehouse code is required for stock-in");
if (plan.getOverseasWarehouseId() == null
|| !overseasWarehouseId.equals(String.valueOf(plan.getOverseasWarehouseId()))) {
throw conflict("Inbound plan and tail-leg order overseas warehouses must match for stock-in");
@@ -236,7 +238,8 @@ public class InboundStockReceiptService {
throw conflict("Actual overseas warehouse must not use the domestic source warehouse");
}
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName,
overseasWarehouseId, null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
@@ -259,8 +259,8 @@ public class InboundPlanService {
if (updated != 1) {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(),
plan.getPlanNo(), now);
inventoryBalanceMapper.bindInboundPlan(customerId, receivingWarehouseId, order.orderId(),
order.inboundCode(), plan.getPlanNo(), now);
orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(),
"Inbound order converted to inbound plan " + plan.getPlanNo(), now);
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inventory.command.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -32,6 +33,20 @@ public interface InventoryCommandMapper {
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} ORDER BY id FOR UPDATE")
List<InventoryBalance> lockInboundBalances(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId);
@Select("SELECT COUNT(*) FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}")
long countPositions(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Delete("DELETE FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " +
"AND available_packages=0 AND hold_packages=0 AND allocated_packages=0 AND picked_packages=0 " +
"AND in_transit_packages=0 AND receiving_packages=0 AND procurement_packages=0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)")
int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
@@ -28,6 +28,7 @@ import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
@Service
public class InventoryCommandService {
@@ -57,6 +58,44 @@ public class InventoryCommandService {
});
}
@Transactional
public void rebindReceivingWarehouse(String customerId, Long inboundOrderId, long warehouseId,
String warehouseCode, String warehouseName,
String operatorId, String idempotencyKey) {
String targetId = Long.toString(warehouseId);
String targetCode = required(warehouseCode, "Receiving warehouse code is required");
String targetName = required(warehouseName, "Receiving warehouse name is required");
Long auditOperationId = IdWorker.getId();
for (InventoryBalance balance : commands.lockInboundBalances(customerId, inboundOrderId)) {
if (value(balance.getAvailablePackages()) != 0 || value(balance.getHoldPackages()) != 0
|| value(balance.getAllocatedPackages()) != 0 || value(balance.getPickedPackages()) != 0
|| value(balance.getReceivingPackages()) != 0 || value(balance.getProcurementPackages()) != 0
|| commands.countPositions(customerId, balance.getId()) != 0) {
throw conflict("Receiving warehouse cannot change after physical inventory exists");
}
InventoryBalance target = balances.lockDimension(customerId, targetId,
balance.getDestinationWarehouseCode(), balance.getInboundOrderId(), balance.getInboundCode());
if (target != null && !target.getId().equals(balance.getId())) {
throw conflict("Receiving warehouse change would create a duplicate inventory dimension");
}
String sourceId = balance.getOverseasWarehouseId();
if (Objects.equals(sourceId, targetId)
&& Objects.equals(balance.getOverseasWarehouseCode(), targetCode)
&& Objects.equals(balance.getOverseasWarehouseName(), targetName)) continue;
long packages = value(balance.getInTransitPackages());
balance.setOverseasWarehouseId(targetId);
balance.setOverseasWarehouseCode(targetCode);
balance.setOverseasWarehouseName(targetName);
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, auditOperationId, balance, null, balance.getSkuCode(),
"RECEIVING_WAREHOUSE_CHANGED", "IN_TRANSIT", "IN_TRANSIT",
packages, 0, packages, null, balance.getInboundOrderNo(), null, null,
idempotencyKey + ":BALANCE:" + balance.getId(),
"In-transit receiving warehouse changed from " + sourceId + " to " + targetId);
}
}
@Transactional
public InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
@@ -126,6 +165,9 @@ public class InventoryCommandService {
transferred += moved;
pending -= moved;
if (targetDimension) continue;
if (commands.countPositions(customerId, source.getId()) != 0) {
throw conflict("In-transit source balance unexpectedly has physical positions");
}
touch(source);
balances.updateById(source);
ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()),
@@ -133,6 +175,9 @@ public class InventoryCommandService {
sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null,
request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(),
"In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId());
if (empty(source) && commands.deleteEmptyBalance(customerId, source.getId()) != 1) {
throw conflict("Empty in-transit source balance changed concurrently");
}
}
return transferred;
}
@@ -287,6 +332,9 @@ public class InventoryCommandService {
String inboundCode, String requestedSkuCode, String productNameZh,
String productNameEn) {
String frozenCustomerName = required(customerName, "Customer name snapshot is required");
String frozenWarehouseId = required(overseasWarehouseId, "Overseas warehouse ID snapshot is required");
String frozenWarehouseCode = required(overseasWarehouseCode, "Overseas warehouse code snapshot is required");
String frozenWarehouseName = required(overseasWarehouseName, "Overseas warehouse name snapshot is required");
String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode);
@@ -294,10 +342,11 @@ public class InventoryCommandService {
throw badRequest("Destination warehouse ID or code is required");
}
if (destinationCode == null) destinationCode = "";
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
destinationCode, inboundOrderId, inboundCode);
if (balance != null) {
requireCustomerSnapshot(balance, frozenCustomerName);
requireWarehouseSnapshot(balance, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName);
mergeSkuSnapshot(balance, skuCode);
return balance;
}
@@ -305,9 +354,9 @@ public class InventoryCommandService {
balance.setId(IdWorker.getId());
balance.setCustomerId(customerId);
balance.setCustomerName(frozenCustomerName);
balance.setOverseasWarehouseId(overseasWarehouseId.trim());
balance.setOverseasWarehouseCode(trim(overseasWarehouseCode));
balance.setOverseasWarehouseName(trim(overseasWarehouseName));
balance.setOverseasWarehouseId(frozenWarehouseId);
balance.setOverseasWarehouseCode(frozenWarehouseCode);
balance.setOverseasWarehouseName(frozenWarehouseName);
balance.setDestinationWarehouseId(destinationId);
balance.setDestinationWarehouseCode(destinationCode);
balance.setDestinationWarehouseName(trim(destinationWarehouseName));
@@ -325,10 +374,11 @@ public class InventoryCommandService {
balances.insert(balance);
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
InventoryBalance concurrent = balances.lockDimension(customerId, frozenWarehouseId,
destinationCode, inboundOrderId, inboundCode);
if (concurrent != null) {
requireCustomerSnapshot(concurrent, frozenCustomerName);
requireWarehouseSnapshot(concurrent, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName);
mergeSkuSnapshot(concurrent, skuCode);
return concurrent;
}
@@ -505,6 +555,13 @@ public class InventoryCommandService {
balance.setProcurementPackages(0L);
}
private static boolean empty(InventoryBalance balance) {
return value(balance.getAvailablePackages()) == 0 && value(balance.getHoldPackages()) == 0
&& value(balance.getAllocatedPackages()) == 0 && value(balance.getPickedPackages()) == 0
&& value(balance.getInTransitPackages()) == 0 && value(balance.getReceivingPackages()) == 0
&& value(balance.getProcurementPackages()) == 0;
}
private static long positionPackages(InventoryPosition position) {
return value(position.getAvailablePackages()) + value(position.getHoldPackages())
+ value(position.getAllocatedPackages()) + value(position.getPickedPackages())
@@ -583,6 +640,15 @@ public class InventoryCommandService {
}
}
private static void requireWarehouseSnapshot(InventoryBalance balance, String requestedWarehouseId,
String requestedWarehouseCode, String requestedWarehouseName) {
if (!requestedWarehouseId.equals(trim(balance.getOverseasWarehouseId()))
|| !requestedWarehouseCode.equals(trim(balance.getOverseasWarehouseCode()))
|| !requestedWarehouseName.equals(trim(balance.getOverseasWarehouseName()))) {
throw conflict("Inventory balance overseas warehouse snapshot does not match");
}
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw badRequest(message);
@@ -35,8 +35,10 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Param("inboundCode") String inboundCode);
@Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
int bindInboundPlan(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode,
@Param("planNo") String planNo,
@@ -38,7 +38,7 @@ public class InventoryQueryController {
@GetMapping(BASE_PATH + "/summary")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
@Operation(summary = "Get inbound-order package inventory totals", description = "Zero-balance dimensions are excluded. Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -48,7 +48,7 @@ public class InventoryQueryController {
@GetMapping(BASE_PATH + "/balances")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Returns only non-zero dimensions with the frozen receiving overseas warehouse ID/code/name. Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -79,7 +79,7 @@ public class InventoryQueryController {
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -14,7 +14,8 @@ import java.util.List;
@Mapper
public interface InventoryQueryMapper {
String BALANCE_FILTER = " WHERE 1=1" +
String BALANCE_FILTER = " WHERE (available_packages+hold_packages+allocated_packages+picked_packages+" +
"in_transit_packages+receiving_packages+procurement_packages)>0" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
@@ -37,7 +38,8 @@ public interface InventoryQueryMapper {
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 0</when>" +
"</choose>";
String JOINED_BALANCE_FILTER = " WHERE 1=1" +
String JOINED_BALANCE_FILTER = " WHERE (b.available_packages+b.hold_packages+b.allocated_packages+" +
"b.picked_packages+b.in_transit_packages+b.receiving_packages+b.procurement_packages)>0" +
"<if test='customerId != null and customerId != \"\"'> AND b.customer_id=#{customerId}</if>" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
@@ -140,6 +140,10 @@ public class LastlegOrderService {
}
WarehouseResourceGateway.WarehouseReference warehouse =
warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId());
for (LastlegOrder order : orders) {
inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(),
warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key);
}
int updated = orderMapper.updateContainerReceivingWarehouse(bookingId,
Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version);
if (updated != orders.size()) {
@@ -341,8 +341,9 @@ public class LoadingCompletedConsumer {
FirstLegLoadingPlanCompletedV1.Item item = source.items().get(index);
if (item.packageCount() <= 0) continue;
inventory.registerInTransit(order.getCustomerId(), "SYSTEM",
new InventoryCommandRequests.InTransit(order.getCustomerName(), payload.warehouseId(), null,
payload.warehouseName(), item.destinationWarehouseId(), item.destinationWarehouseCode(),
new InventoryCommandRequests.InTransit(order.getCustomerName(), order.getOverseasWarehouseId(),
order.getOverseasWarehouseCode(), order.getOverseasWarehouseName(),
item.destinationWarehouseId(), item.destinationWarehouseCode(),
item.destinationWarehouseName(), order.getId(), inboundOrderNo, source.inboundCode(),
item.skuCode(), item.productNameZh(), item.productNameEn(), item.packageCount(),
order.getOrderNo(), event.eventId(),
@@ -86,6 +86,7 @@ class InboundStockReceiptServiceTest {
assertEquals("SKU-1", request.getValue().skuCode());
assertEquals("Customer", request.getValue().customerName());
assertEquals("900", request.getValue().overseasWarehouseId());
assertEquals("US-LAX-01", request.getValue().overseasWarehouseCode());
assertEquals("Los Angeles", request.getValue().overseasWarehouseName());
assertEquals("PLT-21", request.getValue().palletNo());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
@@ -297,7 +298,8 @@ class InboundStockReceiptServiceTest {
line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0"));
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setSourceWarehouseId("100");
order.setOverseasWarehouseId("900"); order.setOverseasWarehouseName("Los Angeles");
order.setOverseasWarehouseId("900"); order.setOverseasWarehouseCode("US-LAX-01");
order.setOverseasWarehouseName("Los Angeles");
order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
@@ -119,6 +119,7 @@ class InboundPlanServiceTest {
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("193000000000200003"),
org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"),
org.mockito.ArgumentMatchers.eq(result.planNo()), any());
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
@@ -253,7 +254,7 @@ class InboundPlanServiceTest {
assertEquals(1L, result.sourceOrderId());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -484,7 +485,7 @@ class InboundPlanServiceTest {
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -504,7 +505,7 @@ class InboundPlanServiceTest {
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -567,7 +568,7 @@ class InboundPlanServiceTest {
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any());
}
@Test
@@ -9,6 +9,8 @@ import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -82,6 +84,22 @@ class InventorySchemaContractTest {
assertFalse(sql.contains("sku_code"));
}
@Test
void balanceQueriesHideZeroRowsAndMutationsKeepWarehouseDimensionCanonical() throws Exception {
assertTrue(InventoryQueryMapper.BALANCE_FILTER.contains("procurement_packages)>0"));
assertTrue(InventoryQueryMapper.JOINED_BALANCE_FILTER.contains("b.procurement_packages)>0"));
String bindSql = String.join(" ", com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper.class
.getMethod("bindInboundPlan", String.class, String.class, Long.class, String.class,
String.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class).value());
assertTrue(bindSql.contains("overseas_warehouse_id=#{overseasWarehouseId}"));
String deleteSql = String.join(" ", com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper.class
.getMethod("deleteEmptyBalance", String.class, Long.class)
.getAnnotation(Delete.class).value());
assertTrue(deleteSql.contains("in_transit_packages=0"));
assertTrue(deleteSql.contains("NOT EXISTS"));
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
@@ -140,6 +140,7 @@ class InventoryCommandServiceTest {
source.setInTransitPackages(12L);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(target);
when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(source));
when(commands.deleteEmptyBalance("100", 201L)).thenReturn(1);
InventoryCommandResponse result = service.stockIn("100", "7",
stockIn("IN-1", "stock-in-cross-warehouse", 12, 24));
@@ -152,6 +153,40 @@ class InventoryCommandServiceTest {
assertEquals(-12L, written.getAllValues().get(0).getDeltaPackages());
assertEquals("STOCK_IN", written.getAllValues().get(1).getBusinessType());
assertEquals("IN_TRANSIT", written.getAllValues().get(1).getStatusFrom());
verify(commands).deleteEmptyBalance("100", 201L);
}
@Test
void receivingWarehouseChangeRebindsOnlyInTransitBalanceAndWritesZeroDeltaAudit() {
InventoryBalance balance = balance(200L, 0);
balance.setInTransitPackages(12L);
when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance));
when(balances.lockDimension("100", "900", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
service.rebindReceivingWarehouse("100", 101L, 900L, "US-NYC-01", "纽约仓",
"admin", "change-1");
assertEquals("900", balance.getOverseasWarehouseId());
assertEquals("US-NYC-01", balance.getOverseasWarehouseCode());
assertEquals("纽约仓", balance.getOverseasWarehouseName());
ArgumentCaptor<InventoryLedger> audit = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(audit.capture());
assertEquals("RECEIVING_WAREHOUSE_CHANGED", audit.getValue().getBusinessType());
assertEquals(0L, audit.getValue().getDeltaPackages());
assertEquals(12L, audit.getValue().getBeforePackages());
assertEquals(12L, audit.getValue().getAfterPackages());
}
@Test
void receivingWarehouseChangeRejectsPhysicalInventoryBeforeAnySnapshotMutation() {
InventoryBalance balance = balance(200L, 1);
when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance));
assertThrows(ResponseStatusException.class, () -> service.rebindReceivingWarehouse(
"100", 101L, 900L, "US-NYC-01", "纽约仓", "admin", "change-physical"));
verify(balances, never()).updateById(any(InventoryBalance.class));
verify(ledgers, never()).insert(any(InventoryLedger.class));
}
@Test
@@ -311,6 +346,7 @@ class InventoryCommandServiceTest {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
value.setOverseasWarehouseId("WH-1");
value.setOverseasWarehouseCode("WH1"); value.setOverseasWarehouseName("Warehouse 1");
value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L);
value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1");
value.setAvailablePackages(available);
@@ -109,6 +109,10 @@ class LastlegOrderServiceTest {
assertEquals("US-NYC-01", result.overseasWarehouseCode());
assertTrue(result.allPlansReady());
assertTrue(result.canChangeReceivingWarehouse());
verify(inventory).rebindReceivingWarehouse("100", 1L, 900L,
"US-NYC-01", "纽约仓", "admin", "change-1");
verify(inventory).rebindReceivingWarehouse("200", 2L, 900L,
"US-NYC-01", "纽约仓", "admin", "change-1");
verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L);
verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class));
}
@@ -27,6 +27,12 @@ class LoadingCompletedConsumerTest {
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
service.consume(event());
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(value->"193000000000200003".equals(value.overseasWarehouseId())&&"US-LAX-01".equals(value.overseasWarehouseCode())&&"洛杉矶仓".equals(value.overseasWarehouseName())));
}
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));