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
@@ -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(),