diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 175f3f47b..73b848de5 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index c2a92f054..e511e9880 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -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(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 14ccb5533..8f8c9864e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index 343491524..84b6975c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -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 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 860677428..f36082193 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index b2cb58169..da0ee47ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -35,8 +35,10 @@ public interface InventoryBalanceMapper extends BaseMapper { @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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 5133e9791..945f00ec2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -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 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> 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 exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index cb1aac0a7..7b7aaee31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -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" + " AND customer_id=#{customerId}" + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + @@ -37,7 +38,8 @@ public interface InventoryQueryMapper { " AND procurement_packages > 0" + ""; - 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" + " AND b.customer_id=#{customerId}" + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 9c965b470..82308b8d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -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()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index d024176b5..e4ceb686b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -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(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index a6278e98c..c4035d8f9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -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 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index f7ed9407a..84fc1f422 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index 176cbba79..ab4d4b9b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -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) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index bdc374e24..6da77c0ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -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 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index bd7fd3eaa..971ffa434 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -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)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 1fc89e9f7..4fa5b1133 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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 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 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"));