Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/db/incremental.sql
This commit is contained in:
2026-09-08 23:54:12 +08:00
28 changed files with 1631 additions and 6 deletions
@@ -2016,3 +2016,31 @@ CALL assert_tail_order_commands_ready();
DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready;
SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message;
-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。
USE baiyue_lastlag_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet_batch (
id BIGINT NOT NULL COMMENT '批量托盘预留ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要',
status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数',
confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要',
compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要',
compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间',
compensated_at DATETIME(3) NULL COMMENT '补偿释放时间', 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_wh_pallet_batch_key (warehouse_id,batch_key),
KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录';
CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
id BIGINT NOT NULL COMMENT '批量托盘明细ID', batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID',
customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照',
sequence_no INT NOT NULL COMMENT '计划内托盘序号', pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID',
pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID',
resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED',
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_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
@@ -71,6 +72,19 @@ public class InboundPlanController {
return ApiResponse.success(service.convertContainer(request.sourceContainerBookingId()));
}
@GetMapping("/container-preview")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "预览按柜生成入库计划", description = "按稳定 sourceContainerBookingId 返回全柜服务端分组、托数、就绪与阻断事实;确认时必须回传 snapshotVersion/snapshotHash。Required authority: "
+ OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<ContainerInboundPlanPreviewResponse> previewContainer(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String sourceContainerBookingId) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可预览整柜入库计划");
}
return ApiResponse.success(service.previewContainer(sourceContainerBookingId));
}
@GetMapping("/{planNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
/** 查询入仓计划及其明细、托盘和分配信息。 */
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.math.BigDecimal;
import java.util.List;
/** Server-calculated cabinet conversion preview; callers must echo version/hash when confirming. */
public record ContainerInboundPlanPreviewResponse(
String sourceContainerBookingId,
String containerNo,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
long snapshotVersion,
String snapshotHash,
int totalOrderCount,
int readyOrderCount,
int blockedOrderCount,
int pendingOrderCount,
int pendingPalletCount,
int existingPlannedPalletCount,
int totalSuggestedPalletCount,
int totalPlannedPalletCount,
boolean ready,
List<Group> groups
) {
public record Group(
String destinationType,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int orderCount,
int pendingOrderCount,
int pendingPalletCount,
int existingPlannedPalletCount,
int suggestedPalletCount,
int plannedPalletCount,
List<Order> orders
) { }
public record Order(
String customerId,
String customerName,
String orderId,
String businessOrderNo,
String inboundCode,
String orderType,
String destinationType,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int packageCount,
BigDecimal volumeCbm,
Integer suggestedPalletCount,
Integer plannedPalletCount,
String existingPlanNo,
String planStatus,
int orderVersion,
String readiness,
List<String> blockers
) { }
}
@@ -18,6 +18,33 @@ public interface InboundPlanOrderMapper {
String overseasWarehouseName) { }
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
String overseasWarehouseId) { }
record ContainerPreviewOrderRow(
String customerId, Long orderId, String orderNo, String sourceOrderNo, String customerName,
String orderType, String destinationType, String inboundCode, String currentStatus,
String inboundOrderNo, String containerNo, int recommendedPalletCount, int packageCount,
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus, Integer plannedPalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
"o.source_order_no AS sourceOrderNo,o.customer_name AS customerName,o.order_type AS orderType," +
"o.destination_type AS destinationType,o.inbound_code AS inboundCode,o.current_status AS currentStatus," +
"o.inbound_order_no AS inboundOrderNo,o.container_no AS containerNo,COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"COALESCE(o.package_count,0) AS packageCount,COALESCE(o.volume_cbm,0) AS volumeCbm," +
"o.overseas_warehouse_id AS overseasWarehouseId,o.overseas_warehouse_code AS overseasWarehouseCode," +
"o.overseas_warehouse_name AS overseasWarehouseName,o.fba_warehouse_code AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),(SELECT MIN(NULLIF(d.destination_warehouse_id,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus,p.pallet_count AS plannedPalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List<ContainerPreviewOrderRow> containerPreviewOrders(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT c.customer_id AS customerId,c.id AS orderId," +
"c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " +
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
@@ -46,7 +47,9 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.stream.Collectors;
@Service
@@ -134,6 +137,150 @@ public class InboundPlanService {
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@Transactional(readOnly = true)
public ContainerInboundPlanPreviewResponse previewContainer(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
}
List<InboundPlanOrderMapper.ContainerPreviewOrderRow> rows = orderMapper.containerPreviewOrders(bookingId);
if (rows == null || rows.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
}
String containerNo = rows.stream().map(InboundPlanOrderMapper.ContainerPreviewOrderRow::containerNo)
.map(InboundPlanService::trimToNull).filter(Objects::nonNull).findFirst().orElse(null);
String warehouseId = singleValue(rows.stream()
.map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseId).toList());
String warehouseCode = singleValue(rows.stream()
.map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseCode).toList());
String warehouseName = singleValue(rows.stream()
.map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseName).toList());
List<ContainerInboundPlanPreviewResponse.Order> orders = rows.stream()
.map(row -> previewOrder(row, containerNo, warehouseId)).toList();
Comparator<ContainerInboundPlanPreviewResponse.Order> orderComparator = Comparator
.comparing((ContainerInboundPlanPreviewResponse.Order item) -> typeRank(item.destinationType()))
.thenComparing(item -> nullSafe(item.destinationWarehouseCode()))
.thenComparing(item -> nullSafe(item.destinationWarehouseName()))
.thenComparing(item -> nullSafe(item.destinationWarehouseId()))
.thenComparing(ContainerInboundPlanPreviewResponse.Order::customerId)
.thenComparing(ContainerInboundPlanPreviewResponse.Order::orderId);
orders = orders.stream().sorted(orderComparator).toList();
Map<String, List<ContainerInboundPlanPreviewResponse.Order>> grouped = new LinkedHashMap<>();
for (ContainerInboundPlanPreviewResponse.Order order : orders) {
String key = order.destinationType() + "|" + nullSafe(order.destinationWarehouseId()) + "|"
+ nullSafe(order.destinationWarehouseCode()) + "|" + nullSafe(order.destinationWarehouseName());
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(order);
}
List<ContainerInboundPlanPreviewResponse.Group> groups = grouped.values().stream().map(items -> {
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(),
items.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
}).toList();
int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count();
int pendingOrders = (int) orders.stream().filter(item -> "READY".equals(item.readiness())).count();
int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0);
String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
pendingOrders, pendingPallets, existingPallets,
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups);
}
private ContainerInboundPlanPreviewResponse.Order previewOrder(
InboundPlanOrderMapper.ContainerPreviewOrderRow row, String containerNo, String warehouseId) {
List<String> blockers = new ArrayList<>();
boolean planned = trimToNull(row.planNo()) != null;
if (!planned) {
if (trimToNull(row.inboundCode()) == null) blockers.add("MISSING_INBOUND_CODE");
if (row.detained()) blockers.add("ORDER_DETAINED");
if (!("PENDING_ASSIGNMENT".equals(row.currentStatus())
|| "DESTINATION_ASSIGNED".equals(row.currentStatus()))) blockers.add("ORDER_STATUS_NOT_READY");
if (containerNo == null || !Objects.equals(containerNo, trimToNull(row.containerNo()))) {
blockers.add("CONTAINER_NO_INCONSISTENT");
}
if (warehouseId == null || !Objects.equals(warehouseId, trimToNull(row.overseasWarehouseId()))) {
blockers.add("RECEIVING_WAREHOUSE_INCONSISTENT");
}
boolean fba = "FBA".equals(destinationType(row));
if (fba ? trimToNull(row.fbaWarehouseCode()) == null
: row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null
&& trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY");
if (row.packageCount() < 0 || row.volumeCbm() == null || row.volumeCbm().signum() < 0
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
Integer suggested = suggestedPalletCount(row, blockers);
String destinationType = destinationType(row);
String destinationCode = "FBA".equals(destinationType) && trimToNull(row.fbaWarehouseCode()) != null
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED";
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
destinationCode, row.destinationWarehouseName(), row.packageCount(), row.volumeCbm(),
suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
List.copyOf(blockers));
}
private Integer suggestedPalletCount(InboundPlanOrderMapper.ContainerPreviewOrderRow row, List<String> blockers) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) {
List<DropshipPalletSnapshot> pallets = dropshipOrders.palletSnapshots(row.customerId(), row.orderId());
if (pallets == null || pallets.isEmpty()) {
if (trimToNull(row.planNo()) == null) blockers.add("DROPSHIP_PALLET_SNAPSHOT_MISSING");
return null;
}
return pallets.size();
}
if (row.volumeCbm() == null || row.volumeCbm().signum() < 0 || row.packageCount() < 0
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) return null;
try {
return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(row.orderId(),
nullSafe(row.destinationWarehouseId()) + "|" + nullSafe("FBA".equals(destinationType(row))
? row.fbaWarehouseCode() : row.destinationWarehouseCode()),
row.volumeCbm(), row.packageCount(), row.recommendedPalletCount()))).pallets().size();
} catch (IllegalArgumentException exception) {
return null;
}
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
if ("FBA".equals(row.destinationType()) || trimToNull(row.fbaWarehouseCode()) != null) return "FBA";
return "PRIVATE_ADDRESS";
}
private static int typeRank(String type) {
return switch (type) { case "FBA" -> 0; case "ONE_CLICK_DROPSHIP" -> 1; default -> 2; };
}
private static String singleValue(List<String> values) {
List<String> normalized = values.stream().map(InboundPlanService::trimToNull).distinct().toList();
return normalized.size() == 1 ? normalized.get(0) : null;
}
private static String nullSafe(String value) { return value == null ? "" : value; }
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
return convert(customerId, selectedOrderId, List.of());
@@ -445,6 +445,16 @@ paths:
properties:
sourceContainerBookingId: { type: string, minLength: 1, maxLength: 128 }
responses: { '200': { description: 统一 ApiResponse 包装的入库计划数组 } }
/api/platform/v1/last-leg/inbound-plans/container-preview:
get:
summary: 预览按柜生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
responses:
'200': { description: 柜级入库计划转换预览, content: { application/json: { schema: { $ref: '#/components/schemas/ContainerInboundPlanPreviewApiResponse' } } } }
'404': { description: 来源柜没有尾程订单 }
/api/platform/v1/last-leg/dispatches:
get:
summary: 分页查询排车单
@@ -2179,6 +2189,11 @@ components:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } }
ContainerInboundPlanPreviewApiResponse:
allOf:
- { $ref: '#/components/schemas/MerchantApiResponse' }
- type: object
properties: { data: { $ref: '#/components/schemas/ContainerInboundPlanPreview' } }
LastlegOrderHoldRequest:
type: object
required: [held, chargeable, reason, expectedVersion]
@@ -2468,6 +2483,65 @@ components:
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
version: { type: integer }
ContainerInboundPlanPreview:
type: object
required: [sourceContainerBookingId, snapshotVersion, snapshotHash, totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, totalPlannedPalletCount, ready, groups]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, nullable: true }
overseasWarehouseId: { type: string, nullable: true }
overseasWarehouseCode: { type: string, nullable: true }
overseasWarehouseName: { type: string, nullable: true }
snapshotVersion: { type: integer, format: int64, minimum: 0 }
snapshotHash: { type: string, pattern: '^[0-9a-f]{64}$' }
totalOrderCount: { type: integer }
readyOrderCount: { type: integer }
blockedOrderCount: { type: integer }
pendingOrderCount: { type: integer, description: 本次确认将新建计划的 READY 订单数 }
pendingPalletCount: { type: integer, description: 本次确认将向资源服务批量预留的新托盘数 }
existingPlannedPalletCount: { type: integer, description: 已有有效计划的托盘总数,不计入本次新增 }
totalSuggestedPalletCount: { type: integer }
totalPlannedPalletCount: { type: integer }
ready: { type: boolean }
groups: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewGroup' } }
ContainerInboundPlanPreviewGroup:
type: object
required: [destinationType, orderCount, pendingOrderCount, pendingPalletCount, existingPlannedPalletCount, suggestedPalletCount, plannedPalletCount, orders]
properties:
destinationType: { type: string, enum: [FBA, ONE_CLICK_DROPSHIP, PRIVATE_ADDRESS] }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
orderCount: { type: integer }
pendingOrderCount: { type: integer }
pendingPalletCount: { type: integer }
existingPlannedPalletCount: { type: integer }
suggestedPalletCount: { type: integer }
plannedPalletCount: { type: integer }
orders: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewOrder' } }
ContainerInboundPlanPreviewOrder:
type: object
required: [customerId, orderId, businessOrderNo, orderType, destinationType, packageCount, volumeCbm, orderVersion, readiness, blockers]
properties:
customerId: { type: string }
customerName: { type: string, nullable: true }
orderId: { type: string, pattern: '^[0-9]+$' }
businessOrderNo: { type: string }
inboundCode: { type: string, nullable: true }
orderType: { type: string }
destinationType: { type: string, enum: [FBA, ONE_CLICK_DROPSHIP, PRIVATE_ADDRESS] }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
packageCount: { type: integer }
volumeCbm: { type: number }
suggestedPalletCount: { type: integer, nullable: true }
plannedPalletCount: { type: integer, nullable: true }
existingPlanNo: { type: string, nullable: true }
planStatus: { type: string, nullable: true }
orderVersion: { type: integer, minimum: 0 }
readiness: { type: string, enum: [READY, PLANNED, BLOCKED] }
blockers: { type: array, items: { type: string } }
InboundPlanLine:
type: object
properties:
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ContainerInboundPlanPreviewTest {
@Mock InboundPlanOrderMapper orderMapper;
@Mock DropshipOrderService dropshipOrders;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void groupsFbaByStableDestinationAndReturnsServerReadinessAndPalletTotals() {
when(orderMapper.containerPreviewOrders("BOOKING-1")).thenReturn(List.of(
row(2L, "LM-2", "FIRST_LEG", "FBA", "B-02", "仓B", 1, null, null, 3),
row(1L, "LM-1", "FIRST_LEG", "FBA", "A-01", "仓A", 2, null, null, 2),
row(4L, "LM-4", "FIRST_LEG", "FBA", "A-01", "仓A", 3, null, null, 4),
row(5L, "LM-5", "FIRST_LEG", "FBA", "C-03", "仓C", 2, "IP-5", 2, 5),
row(6L, "LM-6", "ONE_CLICK_DROPSHIP", "OVERSEAS", "DROPSHIP", "一件代发", 0, null, null, 6),
row(3L, "LM-3", "FIRST_LEG", "OVERSEAS", null, "私人地址", 0, null, null, 7)
));
when(dropshipOrders.palletSnapshots("100", 6L)).thenReturn(List.of(
new DropshipPalletSnapshot("SRC-P1", 10, List.of())));
var result = service.previewContainer("BOOKING-1");
assertEquals("BOOKING-1", result.sourceContainerBookingId());
assertEquals(6, result.totalOrderCount());
assertEquals(7, result.snapshotVersion());
assertNotNull(result.snapshotHash());
assertEquals(64, result.snapshotHash().length());
assertFalse(result.ready());
assertEquals(4, result.pendingOrderCount());
assertEquals(7, result.pendingPalletCount());
assertEquals(2, result.existingPlannedPalletCount());
assertEquals(List.of("A-01", "B-02", "C-03", "DROPSHIP", ""), result.groups().stream()
.map(group -> group.destinationWarehouseCode() == null ? "" : group.destinationWarehouseCode()).toList());
assertEquals(5, result.groups().get(0).pendingPalletCount());
assertEquals(2, result.groups().get(2).existingPlannedPalletCount());
assertEquals("PLANNED", result.groups().get(2).orders().get(0).readiness());
assertEquals("ONE_CLICK_DROPSHIP", result.groups().get(3).destinationType());
assertEquals("PRIVATE_ADDRESS", result.groups().get(4).destinationType());
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
return new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", id, orderNo, "FO-" + id,
"测试客户", orderType, destinationType, "IN-" + id, "PENDING_ASSIGNMENT", planNo,
"MSCU6639870", recommendedPallets, 10, new BigDecimal("1.0000"),
"193000000000200003", "US-LAX-01", "洛杉矶仓", "FBA".equals(destinationType) ? destinationCode : null,
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
}
}
+27
View File
@@ -3862,6 +3862,33 @@ PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLO
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)'));
PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt;
-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。
USE baiyue_lastlag_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet_batch (
id BIGINT NOT NULL COMMENT '批量托盘预留ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要',
status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数',
confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要',
compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要',
compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间',
compensated_at DATETIME(3) NULL COMMENT '补偿释放时间', 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_wh_pallet_batch_key (warehouse_id,batch_key),
KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录';
CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
id BIGINT NOT NULL COMMENT '批量托盘明细ID', batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID',
customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照',
sequence_no INT NOT NULL COMMENT '计划内托盘序号', pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID',
pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID',
resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED',
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_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt;
+3
View File
@@ -15,6 +15,7 @@
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` |
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;先预留,Lastleg 本地成功后确认,失败时仅补偿未确认资源 |
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
@@ -39,6 +40,8 @@
| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 |
| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 |
| 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 |
| 批量预留 | `wh_pallet_batch` | 柜级入库计划批量托盘预留与确认记录 |
| 批量明细 | `wh_pallet_batch_item` | 跨客户批量托盘明细与资源锁关联 |
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。
- `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.WarehousePalletBatchRequests;
import com.baiyue.warehouse.dto.WarehousePalletBatchResponse;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
import com.baiyue.warehouse.service.WarehousePalletBatchService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/v1/warehouse-pallet-batches")
public class InternalWarehousePalletBatchController {
@Resource private WarehousePalletBatchService service;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/ensure-reserved")
public ApiResponse<WarehousePalletBatchResponse> ensureReserved(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehousePalletBatchRequests.EnsureReserved request) {
tokenGuard.verify(token);
return ApiResponse.success(service.ensureReserved(request));
}
@PostMapping("/confirm")
public ApiResponse<WarehousePalletBatchResponse> confirm(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehousePalletBatchRequests.Confirm request) {
tokenGuard.verify(token);
return ApiResponse.success(service.confirm(request));
}
@PostMapping("/compensate")
public ApiResponse<WarehousePalletBatchResponse> compensate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehousePalletBatchRequests.Compensate request) {
tokenGuard.verify(token);
return ApiResponse.success(service.compensate(request));
}
}
@@ -0,0 +1,41 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class WarehousePalletBatchRequests {
private WarehousePalletBatchRequests() { }
public record EnsureReserved(
@Positive long warehouseId,
@NotBlank @Size(max = 160) String batchKey,
@NotEmpty @Size(max = 200) List<@Valid Item> items
) { }
public record Item(
@Positive long customerId,
@NotBlank @Size(max = 100) String businessKey,
@NotBlank @Size(max = 128) String inboundPlanNo,
@Positive int sequenceNo,
@NotBlank @Size(max = 32) String palletType,
@Size(max = 128) String fbaWarehouseCode
) { }
public record Confirm(
@Positive long warehouseId,
@NotBlank @Size(max = 160) String batchKey,
@NotBlank @Size(max = 160) String commandKey
) { }
public record Compensate(
@Positive long warehouseId,
@NotBlank @Size(max = 160) String batchKey,
@NotBlank @Size(max = 160) String commandKey,
@NotBlank @Size(max = 500) String reason
) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.dto;
import java.util.List;
public record WarehousePalletBatchResponse(
long batchId,
String batchKey,
String status,
int attemptNo,
boolean replayed,
List<Item> items
) {
public record Item(
long customerId,
String businessKey,
String inboundPlanNo,
int sequenceNo,
long palletId,
String palletCode,
long locationId,
long resourceLockId,
String status
) { }
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("wh_pallet_batch")
public class WarehousePalletBatchEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long warehouseId;
private String batchKey;
private String requestHash;
private String status;
private Integer attemptNo;
private String confirmCommandKey;
private String confirmCommandHash;
private String compensateCommandKey;
private String compensateCommandHash;
private String compensateReason;
private LocalDateTime confirmedAt;
private LocalDateTime compensatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("wh_pallet_batch_item")
public class WarehousePalletBatchItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long batchId;
private Long customerId;
private Long warehouseId;
private String businessKey;
private String inboundPlanNo;
private Integer sequenceNo;
private Long palletId;
private String palletCode;
private Long locationId;
private Long resourceLockId;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehousePalletBatchItemMapper extends BaseMapper<WarehousePalletBatchItemEntity> {
@Select("SELECT * FROM wh_pallet_batch_item WHERE batch_id=#{batchId} ORDER BY sequence_no,id FOR UPDATE")
List<WarehousePalletBatchItemEntity> lockByBatch(@Param("batchId") long batchId);
}
@@ -0,0 +1,21 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletBatchEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface WarehousePalletBatchMapper extends BaseMapper<WarehousePalletBatchEntity> {
@Insert("INSERT IGNORE INTO wh_pallet_batch " +
"(id,warehouse_id,batch_key,request_hash,status,attempt_no,created_at,updated_at) VALUES " +
"(#{id},#{warehouseId},#{batchKey},#{requestHash},#{status},#{attemptNo},#{createdAt},#{updatedAt})")
int insertIgnore(WarehousePalletBatchEntity entity);
@Select("SELECT * FROM wh_pallet_batch WHERE warehouse_id=#{warehouseId} AND batch_key=#{batchKey} " +
"LIMIT 1 FOR UPDATE")
WarehousePalletBatchEntity lockByKey(@Param("warehouseId") long warehouseId,
@Param("batchKey") String batchKey);
}
@@ -13,4 +13,11 @@ public interface WarehousePalletBindingMapper extends BaseMapper<WarehousePallet
"AND business_type=#{businessType} AND business_id=#{businessId} LIMIT 1")
WarehousePalletBindingEntity find(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("businessType") String businessType, @Param("businessId") String businessId);
@Select("SELECT * FROM wh_pallet_business_binding WHERE customer_id=#{customerId} AND warehouse_id=#{warehouseId} " +
"AND business_type=#{businessType} AND business_id=#{businessId} LIMIT 1 FOR UPDATE")
WarehousePalletBindingEntity lockByBusiness(@Param("customerId") long customerId,
@Param("warehouseId") long warehouseId,
@Param("businessType") String businessType,
@Param("businessId") String businessId);
}
@@ -18,6 +18,9 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
WarehouseResourceLockEntity findByReservationKey(@Param("customerId") long customerId,
@Param("reservationKey") String reservationKey);
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
WarehouseResourceLockEntity lockById(@Param("customerId") long customerId, @Param("id") long id);
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
"AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1")
WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId,
@@ -96,7 +99,9 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
@Update("""
UPDATE wh_resource_lock
SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)
SET status = #{status},
released_at = CASE WHEN #{status} IN ('RELEASED','SCRAPPED') THEN CURRENT_TIMESTAMP(3) ELSE released_at END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'ACTIVE'
""")
int closeLock(@Param("id") long id, @Param("status") String status);
@@ -23,9 +23,7 @@ public class WarehouseResourceLockRepository {
}
public WarehouseResourceLockEntity find(long customerId, long lockId) {
return locks.selectOne(new LambdaQueryWrapper<WarehouseResourceLockEntity>()
.eq(WarehouseResourceLockEntity::getCustomerId, customerId)
.eq(WarehouseResourceLockEntity::getId, lockId));
return locks.lockById(customerId, lockId);
}
public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) {
@@ -28,6 +28,11 @@ public class WarehouseResourceRepository {
return bindings.find(customerId, warehouseId, businessType, businessId);
}
public WarehousePalletBindingEntity lockPalletBinding(long customerId, long warehouseId,
String businessType, String businessId) {
return bindings.lockByBusiness(customerId, warehouseId, businessType, businessId);
}
public void insertPalletBinding(WarehousePalletBindingEntity entity) {
bindings.insert(entity);
}
@@ -0,0 +1,359 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.dto.WarehousePalletBatchRequests;
import com.baiyue.warehouse.dto.WarehousePalletBatchResponse;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.entity.WarehousePalletBatchEntity;
import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity;
import com.baiyue.warehouse.entity.WarehousePalletBindingEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.mapper.WarehousePalletBatchItemMapper;
import com.baiyue.warehouse.mapper.WarehousePalletBatchMapper;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
public class WarehousePalletBatchService {
private static final String BUSINESS_TYPE = "INBOUND_PLAN_AUTO";
@Resource private WarehousePalletBatchMapper batches;
@Resource private WarehousePalletBatchItemMapper items;
@Resource private WarehouseResourceService resources;
@Resource private WarehouseResourceLockService locks;
@Resource private WarehouseResourceLockRepository lockRecords;
@Resource private WarehouseResourceRepository repository;
@Resource private ObjectMapper objectMapper;
@Transactional
public WarehousePalletBatchResponse ensureReserved(WarehousePalletBatchRequests.EnsureReserved request) {
WarehousePalletBatchRequests.EnsureReserved normalized = normalize(request);
String requestHash = hash(normalized);
WarehousePalletBatchEntity existing = batches.lockByKey(normalized.warehouseId(), normalized.batchKey());
if (existing != null) {
requireSameRequest(existing, normalized.warehouseId(), requestHash);
if ("COMPENSATED".equals(existing.getStatus())) return reactivate(existing);
return response(existing, items.lockByBatch(existing.getId()), true);
}
WarehousePalletBatchEntity batch = new WarehousePalletBatchEntity();
batch.setId(IdWorker.getId());
batch.setWarehouseId(normalized.warehouseId());
batch.setBatchKey(normalized.batchKey());
batch.setRequestHash(requestHash);
batch.setStatus("RESERVED");
batch.setAttemptNo(1);
batch.setCreatedAt(LocalDateTime.now());
batch.setUpdatedAt(batch.getCreatedAt());
if (batches.insertIgnore(batch) == 0) {
WarehousePalletBatchEntity concurrent = batches.lockByKey(normalized.warehouseId(), normalized.batchKey());
if (concurrent == null) {
throw conflict("Pallet batch was created concurrently but cannot be loaded");
}
requireSameRequest(concurrent, normalized.warehouseId(), requestHash);
if ("COMPENSATED".equals(concurrent.getStatus())) return reactivate(concurrent);
return response(concurrent, items.lockByBatch(concurrent.getId()), true);
}
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
if (repository.lockPalletBinding(requested.customerId(), normalized.warehouseId(), BUSINESS_TYPE,
requested.businessKey()) != null) {
throw conflict("Inbound plan pallet business key is already bound");
}
}
List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
normalized.warehouseId(), new PalletRequests.SaveRequest(null, requested.palletType(), null,
BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(),
requested.fbaWarehouseCode()));
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
normalized.warehouseId(), palletId);
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
requested.customerId(), normalized.warehouseId(), pallet.getId(), pallet.getLocationId(),
BUSINESS_TYPE, requested.businessKey(), reservationKey(batch, requested.sequenceNo())));
WarehousePalletBatchItemEntity item = new WarehousePalletBatchItemEntity();
item.setId(IdWorker.getId());
item.setBatchId(batch.getId());
item.setCustomerId(requested.customerId());
item.setWarehouseId(normalized.warehouseId());
item.setBusinessKey(requested.businessKey());
item.setInboundPlanNo(requested.inboundPlanNo());
item.setSequenceNo(requested.sequenceNo());
item.setPalletId(pallet.getId());
item.setPalletCode(pallet.getPalletCode());
item.setLocationId(pallet.getLocationId());
item.setResourceLockId(lock.lockId());
item.setStatus("RESERVED");
item.setCreatedAt(LocalDateTime.now());
item.setUpdatedAt(item.getCreatedAt());
items.insert(item);
created.add(item);
}
return response(batch, created, false);
}
@Transactional
public WarehousePalletBatchResponse confirm(WarehousePalletBatchRequests.Confirm request) {
WarehousePalletBatchRequests.Confirm normalized = normalize(request);
String commandHash = hash(normalized);
WarehousePalletBatchEntity batch = requiredBatch(normalized.warehouseId(), normalized.batchKey());
List<WarehousePalletBatchItemEntity> batchItems = items.lockByBatch(batch.getId());
if ("CONFIRMED".equals(batch.getStatus())) {
requireSameCommand(batch.getConfirmCommandHash(), commandHash);
return response(batch, batchItems, true);
}
if (!"RESERVED".equals(batch.getStatus())) {
throw conflict("Only a RESERVED pallet batch can be confirmed");
}
for (WarehousePalletBatchItemEntity item : batchItems) {
var lock = lockRecords.find(item.getCustomerId(), item.getResourceLockId());
if (lock == null || !"ACTIVE".equals(lock.getStatus())
|| !Objects.equals(lock.getWarehouseId(), batch.getWarehouseId())
|| !Objects.equals(lock.getPalletId(), item.getPalletId())
|| !Objects.equals(lock.getLocationId(), item.getLocationId())
|| !BUSINESS_TYPE.equals(lock.getBusinessType())
|| !item.getBusinessKey().equals(lock.getBusinessId())) {
throw conflict("Inbound plan pallet reservation is no longer active");
}
WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(),
batch.getWarehouseId(), BUSINESS_TYPE, item.getBusinessKey());
if (binding == null) {
binding = new WarehousePalletBindingEntity();
binding.setId(IdWorker.getId());
binding.setCustomerId(item.getCustomerId());
binding.setWarehouseId(batch.getWarehouseId());
binding.setBusinessType(BUSINESS_TYPE);
binding.setBusinessId(item.getBusinessKey());
binding.setPalletId(item.getPalletId());
binding.setCreatedAt(LocalDateTime.now());
repository.insertPalletBinding(binding);
} else if (!Objects.equals(binding.getPalletId(), item.getPalletId())) {
throw conflict("Inbound plan pallet business key is already bound to another pallet");
}
if (lockRecords.close(lock, "CONFIRMED") != 1) {
throw conflict("Inbound plan pallet reservation changed concurrently");
}
item.setStatus("CONFIRMED");
item.setUpdatedAt(LocalDateTime.now());
items.updateById(item);
}
batch.setStatus("CONFIRMED");
batch.setConfirmCommandKey(normalized.commandKey());
batch.setConfirmCommandHash(commandHash);
batch.setConfirmedAt(LocalDateTime.now());
batch.setUpdatedAt(batch.getConfirmedAt());
batches.updateById(batch);
return response(batch, batchItems, false);
}
@Transactional
public WarehousePalletBatchResponse compensate(WarehousePalletBatchRequests.Compensate request) {
WarehousePalletBatchRequests.Compensate normalized = normalize(request);
String commandHash = hash(normalized);
WarehousePalletBatchEntity batch = requiredBatch(normalized.warehouseId(), normalized.batchKey());
List<WarehousePalletBatchItemEntity> batchItems = items.lockByBatch(batch.getId());
if ("COMPENSATED".equals(batch.getStatus())) {
requireSameCommand(batch.getCompensateCommandHash(), commandHash);
return response(batch, batchItems, true);
}
if (!"RESERVED".equals(batch.getStatus())) {
throw conflict("Confirmed or subsequently occupied pallets cannot be compensated");
}
for (WarehousePalletBatchItemEntity item : batchItems) {
WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(),
batch.getWarehouseId(), BUSINESS_TYPE, item.getBusinessKey());
if (binding != null && Objects.equals(binding.getPalletId(), item.getPalletId())) {
throw conflict("A bound inbound plan pallet cannot be compensated");
}
locks.release(item.getCustomerId(), item.getResourceLockId());
item.setStatus("RELEASED");
item.setUpdatedAt(LocalDateTime.now());
items.updateById(item);
}
batch.setStatus("COMPENSATED");
batch.setCompensateCommandKey(normalized.commandKey());
batch.setCompensateCommandHash(commandHash);
batch.setCompensateReason(normalized.reason());
batch.setCompensatedAt(LocalDateTime.now());
batch.setUpdatedAt(batch.getCompensatedAt());
batches.updateById(batch);
return response(batch, batchItems, false);
}
private WarehousePalletBatchResponse reactivate(WarehousePalletBatchEntity batch) {
List<WarehousePalletBatchItemEntity> batchItems = items.lockByBatch(batch.getId());
int attempt = number(batch.getAttemptNo()) + 1;
batch.setAttemptNo(attempt);
for (WarehousePalletBatchItemEntity item : batchItems) {
if (repository.lockPalletBinding(item.getCustomerId(), batch.getWarehouseId(), BUSINESS_TYPE,
item.getBusinessKey()) != null) {
throw conflict("Inbound plan pallet business key became bound after compensation");
}
}
for (WarehousePalletBatchItemEntity item : batchItems) {
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
batch.getWarehouseId(), item.getPalletId());
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
item.getCustomerId(), batch.getWarehouseId(), pallet.getId(), item.getLocationId(), BUSINESS_TYPE,
item.getBusinessKey(), reservationKey(batch.getId(), attempt, item.getSequenceNo())));
item.setResourceLockId(lock.lockId());
item.setStatus("RESERVED");
item.setUpdatedAt(LocalDateTime.now());
items.updateById(item);
}
batch.setStatus("RESERVED");
batch.setCompensateCommandKey(null);
batch.setCompensateCommandHash(null);
batch.setCompensateReason(null);
batch.setCompensatedAt(null);
batch.setUpdatedAt(LocalDateTime.now());
batches.updateById(batch);
return response(batch, batchItems, false);
}
private WarehousePalletBatchEntity requiredBatch(long warehouseId, String batchKey) {
WarehousePalletBatchEntity batch = batches.lockByKey(warehouseId, required(batchKey, "Batch key is required"));
if (batch == null) throw new BizException(ResultCode.NOT_FOUND, "Warehouse pallet batch not found");
return batch;
}
private static WarehousePalletBatchRequests.EnsureReserved normalize(
WarehousePalletBatchRequests.EnsureReserved request) {
if (request == null || request.warehouseId() <= 0) {
throw badRequest("Warehouse is required");
}
String batchKey = required(request.batchKey(), "Batch key is required");
requireMax(batchKey, 160, "Batch key is too long");
if (request.items() == null || request.items().isEmpty() || request.items().size() > 200) {
throw badRequest("One to 200 pallet items are required");
}
Set<String> businessKeys = new HashSet<>();
Set<Integer> sequences = new HashSet<>();
List<WarehousePalletBatchRequests.Item> normalized = request.items().stream().map(item -> {
if (item == null || item.customerId() <= 0 || item.sequenceNo() <= 0) {
throw badRequest("Pallet customer and sequence must be positive");
}
String businessKey = required(item.businessKey(), "Pallet business key is required");
String inboundPlanNo = required(item.inboundPlanNo(), "Inbound plan number is required");
String palletType = required(item.palletType(), "Pallet type is required");
requireMax(businessKey, 100, "Pallet business key is too long");
requireMax(inboundPlanNo, 128, "Inbound plan number is too long");
requireMax(palletType, 32, "Pallet type is too long");
if (item.fbaWarehouseCode() != null) {
requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long");
}
if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique");
if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique");
return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo,
item.sequenceNo(), palletType,
trim(item.fbaWarehouseCode()));
}).sorted(Comparator.comparingInt(WarehousePalletBatchRequests.Item::sequenceNo)).toList();
return new WarehousePalletBatchRequests.EnsureReserved(request.warehouseId(), batchKey, normalized);
}
private static WarehousePalletBatchRequests.Confirm normalize(WarehousePalletBatchRequests.Confirm request) {
if (request == null || request.warehouseId() <= 0) throw badRequest("Warehouse is required");
String batchKey = required(request.batchKey(), "Batch key is required");
String commandKey = required(request.commandKey(), "Confirm command key is required");
requireMax(batchKey, 160, "Batch key is too long");
requireMax(commandKey, 160, "Confirm command key is too long");
return new WarehousePalletBatchRequests.Confirm(request.warehouseId(), batchKey, commandKey);
}
private static WarehousePalletBatchRequests.Compensate normalize(
WarehousePalletBatchRequests.Compensate request) {
if (request == null || request.warehouseId() <= 0) throw badRequest("Warehouse is required");
String batchKey = required(request.batchKey(), "Batch key is required");
String commandKey = required(request.commandKey(), "Compensation command key is required");
String reason = required(request.reason(), "Compensation reason is required");
requireMax(batchKey, 160, "Batch key is too long");
requireMax(commandKey, 160, "Compensation command key is too long");
requireMax(reason, 500, "Compensation reason is too long");
return new WarehousePalletBatchRequests.Compensate(request.warehouseId(), batchKey, commandKey, reason);
}
private static void requireSameRequest(WarehousePalletBatchEntity batch, long warehouseId, String requestHash) {
if (batch.getWarehouseId() != warehouseId || !requestHash.equals(batch.getRequestHash())) {
throw conflict("Batch key was already used with a different pallet request");
}
}
private static void requireSameCommand(String storedHash, String requestHash) {
if (!Objects.equals(storedHash, requestHash)) {
throw conflict("Terminal pallet batch command cannot be replayed with a different payload");
}
}
private WarehousePalletBatchResponse response(WarehousePalletBatchEntity batch,
List<WarehousePalletBatchItemEntity> batchItems,
boolean replayed) {
return new WarehousePalletBatchResponse(batch.getId(), batch.getBatchKey(), batch.getStatus(),
number(batch.getAttemptNo()), replayed, batchItems.stream().map(item ->
new WarehousePalletBatchResponse.Item(item.getCustomerId(), item.getBusinessKey(), item.getInboundPlanNo(),
item.getSequenceNo(), item.getPalletId(), item.getPalletCode(), item.getLocationId(),
item.getResourceLockId(), item.getStatus())).toList());
}
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private String write(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize warehouse pallet batch request", exception);
}
}
private static String reservationKey(WarehousePalletBatchEntity batch, int sequenceNo) {
return reservationKey(batch.getId(), number(batch.getAttemptNo()), sequenceNo);
}
private static String reservationKey(long batchId, int attemptNo, int sequenceNo) {
return "INBOUND_PLAN_AUTO:" + batchId + ":" + attemptNo + ":" + sequenceNo;
}
private static int number(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw badRequest(message);
return normalized;
}
private static void requireMax(String value, int max, String message) {
if (value.length() > max) throw badRequest(message);
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
}
@@ -358,7 +358,8 @@ public class WarehouseResourceService {
}
private static String generatedPalletCode(String warehouseCode, long palletId) {
return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
String boundedWarehouseCode = warehouseCode.length() <= 30 ? warehouseCode : warehouseCode.substring(0, 30);
return "PLT-" + boundedWarehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + Long.toUnsignedString(palletId);
}
@@ -123,6 +123,49 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
CREATE TABLE IF NOT EXISTS wh_pallet_batch (
id BIGINT NOT NULL COMMENT '批量托盘预留ID',
warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键',
request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要',
status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED',
attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数',
confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键',
confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要',
compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键',
compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要',
compensate_reason VARCHAR(500) NULL COMMENT '补偿原因',
confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间',
compensated_at DATETIME(3) NULL COMMENT '补偿释放时间',
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_wh_pallet_batch_key (warehouse_id,batch_key),
KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录';
CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
id BIGINT NOT NULL COMMENT '批量托盘明细ID',
batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID',
customer_id BIGINT NOT NULL COMMENT '实际占用客户ID',
warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键',
inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照',
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID',
pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照',
location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID',
resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID',
status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED',
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_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no),
UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
ALTER TABLE wh_pallet
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
@@ -0,0 +1,93 @@
openapi: 3.0.3
info:
title: Warehouse Overseas Internal Pallet Batch API
version: 1.0.0
paths:
/internal/v1/warehouse-pallet-batches/ensure-reserved:
post:
summary: 按柜级入库计划稳定业务键批量确保并预留公共托盘
description: 仅内部服务令牌;同柜批次可跨客户。物理托盘固定customer_id=0,每个item资源锁使用其真实customerId。同warehouseId+batchKey同请求重放不重复造码,异请求返回A0409;COMPENSATED批次同请求复用原托盘并开启新attempt。
security: [{ internalServiceToken: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/EnsureReservedRequest' } } } }
responses:
'200': { description: 批量预留结果;业务冲突仍为HTTP 200且响应体code=A0409, content: { application/json: { schema: { $ref: '#/components/schemas/BatchApiResponse' } } } }
'401': { description: 内部服务令牌无效 }
/internal/v1/warehouse-pallet-batches/confirm:
post:
summary: 确认Lastleg已持久化托盘快照并建立业务绑定
description: 仅RESERVED可确认;重复确认返回同一结果,不生成新托盘或资源锁。
security: [{ internalServiceToken: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ConfirmRequest' } } } }
responses:
'200': { description: 已确认批次;业务冲突仍为HTTP 200且响应体code=A0409, content: { application/json: { schema: { $ref: '#/components/schemas/BatchApiResponse' } } } }
/internal/v1/warehouse-pallet-batches/compensate:
post:
summary: 补偿释放Lastleg本地失败对应的本批未确认资源
description: 仅RESERVED且尚未建立业务绑定的本批资源可补偿;CONFIRMED或已被后续占用时返回A0409。
security: [{ internalServiceToken: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/CompensateRequest' } } } }
responses:
'200': { description: 已补偿批次;业务冲突仍为HTTP 200且响应体code=A0409, content: { application/json: { schema: { $ref: '#/components/schemas/BatchApiResponse' } } } }
components:
securitySchemes:
internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
schemas:
BatchItemRequest:
type: object
required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
businessKey: { type: string, minLength: 1, maxLength: 100 }
inboundPlanNo: { type: string, minLength: 1, maxLength: 128 }
sequenceNo: { type: integer, minimum: 1 }
palletType: { type: string, minLength: 1, maxLength: 32 }
fbaWarehouseCode: { type: string, nullable: true, maxLength: 128 }
EnsureReservedRequest:
type: object
required: [warehouseId, batchKey, items]
properties:
warehouseId: { type: string, pattern: '^[1-9][0-9]*$' }
batchKey: { type: string, minLength: 1, maxLength: 160 }
items: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/BatchItemRequest' } }
ConfirmRequest:
type: object
required: [warehouseId, batchKey, commandKey]
properties:
warehouseId: { type: string, pattern: '^[1-9][0-9]*$' }
batchKey: { type: string, minLength: 1, maxLength: 160 }
commandKey: { type: string, minLength: 1, maxLength: 160 }
CompensateRequest:
allOf:
- { $ref: '#/components/schemas/ConfirmRequest' }
- type: object
required: [reason]
properties: { reason: { type: string, minLength: 1, maxLength: 500 } }
BatchItemResponse:
type: object
required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletId, palletCode, locationId, resourceLockId, status]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
businessKey: { type: string }
inboundPlanNo: { type: string }
sequenceNo: { type: integer, minimum: 1 }
palletId: { type: string, pattern: '^[1-9][0-9]*$' }
palletCode: { type: string, maxLength: 64 }
locationId: { type: string, pattern: '^[1-9][0-9]*$' }
resourceLockId: { type: string, pattern: '^[1-9][0-9]*$' }
status: { type: string, enum: [RESERVED, CONFIRMED, RELEASED] }
BatchResponse:
type: object
required: [batchId, batchKey, status, attemptNo, replayed, items]
properties:
batchId: { type: string, pattern: '^[1-9][0-9]*$' }
batchKey: { type: string }
status: { type: string, enum: [RESERVED, CONFIRMED, COMPENSATED] }
attemptNo: { type: integer, minimum: 1 }
replayed: { type: boolean }
items: { type: array, items: { $ref: '#/components/schemas/BatchItemResponse' } }
BatchApiResponse:
type: object
properties:
code: { type: string, example: '200' }
message: { type: string }
data: { $ref: '#/components/schemas/BatchResponse' }
@@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest {
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
assertEquals(5, tables.size(), "海外仓资源表数量变化时必须同步表索引和备注迁移");
assertEquals(7, tables.size(), "海外仓资源表数量变化时必须同步表索引和备注迁移");
assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse ("));
assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse_address ("));
assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_service_capability ("));
@@ -39,6 +39,10 @@ class SchemaChineseCommentContractTest {
assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
if (Pattern.compile("(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+" + table.name() + "\\b")
.matcher(migration).find()) {
continue;
}
assertTrue(migration.contains("table_name='" + table.name() + "'"),
"025 备注迁移缺少表: " + table.name());
for (String column : table.columns()) {
@@ -0,0 +1,44 @@
package com.baiyue.warehouse.schema;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class WarehousePalletBatchContractTest {
@Test
void baselineAndUnifiedIncrementalContainBatchTablesAndCommandHashes() throws Exception {
String baseline = read(Path.of("src", "main", "resources", "db", "warehouse.sql"));
String incremental = read(Path.of("..", "scripts", "db", "incremental.sql"));
String unified = read(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"));
for (String sql : new String[] {baseline, incremental, unified}) {
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_pallet_batch (")
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (")
.contains("confirm_command_hash CHAR(64)")
.contains("compensate_command_hash CHAR(64)")
.contains("business_key VARCHAR(100)")
.contains("pallet_code VARCHAR(64)");
}
}
@Test
void internalOpenApiFreezesEnvelopeAndLengthContract() throws Exception {
String yaml = read(Path.of("src", "main", "resources", "openapi",
"warehouse-pallet-batch-internal-v1.yaml"));
assertThat(yaml).contains("/internal/v1/warehouse-pallet-batches/ensure-reserved:")
.contains("/internal/v1/warehouse-pallet-batches/confirm:")
.contains("/internal/v1/warehouse-pallet-batches/compensate:")
.contains("业务冲突仍为HTTP 200且响应体code=A0409")
.contains("businessKey: { type: string, minLength: 1, maxLength: 100 }")
.contains("palletCode: { type: string, maxLength: 64 }")
.doesNotContain("'409':");
}
private static String read(Path path) throws Exception {
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,381 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.dto.WarehousePalletBatchRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.entity.WarehousePalletBatchEntity;
import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity;
import com.baiyue.warehouse.entity.WarehousePalletBindingEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.mapper.WarehousePalletBatchItemMapper;
import com.baiyue.warehouse.mapper.WarehousePalletBatchMapper;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehousePalletBatchServiceTest {
private static final long WAREHOUSE = 201L;
private static final long CUSTOMER_A = 101L;
private static final long CUSTOMER_B = 102L;
@Mock WarehousePalletBatchMapper batches;
@Mock WarehousePalletBatchItemMapper items;
@Mock WarehouseResourceService resources;
@Mock WarehouseResourceLockService locks;
@Mock WarehouseResourceLockRepository lockRecords;
@Mock WarehouseResourceRepository repository;
private WarehousePalletBatchService service;
@BeforeEach
void setUp() {
service = new WarehousePalletBatchService();
ReflectionTestUtils.setField(service, "batches", batches);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "resources", resources);
ReflectionTestUtils.setField(service, "locks", locks);
ReflectionTestUtils.setField(service, "lockRecords", lockRecords);
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@Test
void reservesPublicPalletsButIsolatesEachCrossCustomerLock() {
when(batches.insertIgnore(any())).thenReturn(1);
stubNewResources();
var response = service.ensureReserved(request());
assertThat(response.status()).isEqualTo("RESERVED");
assertThat(response.items()).extracting(item -> item.customerId()).containsExactly(CUSTOMER_A, CUSTOMER_B);
ArgumentCaptor<Long> scope = ArgumentCaptor.forClass(Long.class);
verify(resources, times(2)).createPallet(scope.capture(), anyLong(), any(PalletRequests.SaveRequest.class));
assertThat(scope.getAllValues()).containsOnly(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE);
ArgumentCaptor<WarehouseResourceLockRequests.ReserveRequest> reservations =
ArgumentCaptor.forClass(WarehouseResourceLockRequests.ReserveRequest.class);
verify(locks, times(2)).reserve(reservations.capture());
assertThat(reservations.getAllValues()).extracting(value -> value.customerId())
.containsExactly(CUSTOMER_A, CUSTOMER_B);
assertThat(reservations.getAllValues()).extracting(value -> value.businessId())
.containsExactly("container:plan-a:1", "container:plan-b:2");
}
@Test
void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() {
AtomicReference<WarehousePalletBatchEntity> inserted = new AtomicReference<>();
when(batches.lockByKey(WAREHOUSE, "container-1")).thenAnswer(call -> inserted.get());
when(batches.insertIgnore(any())).thenAnswer(call -> {
WarehousePalletBatchEntity losingInsert = call.getArgument(0);
WarehousePalletBatchEntity winner = batch(losingInsert.getId(), "RESERVED", 1);
winner.setRequestHash(losingInsert.getRequestHash());
inserted.set(winner);
return 0;
});
when(items.lockByBatch(anyLong())).thenReturn(List.of(item(1L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED")));
var response = service.ensureReserved(request());
assertThat(response.replayed()).isTrue();
assertThat(response.batchId()).isEqualTo(inserted.get().getId());
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
verify(locks, never()).reserve(any());
}
@Test
void sameBatchKeyWithDifferentPayloadIsRejectedAsA0409() {
WarehousePalletBatchEntity existing = batch(11L, "RESERVED", 1);
existing.setRequestHash("different");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(existing);
assertThatThrownBy(() -> service.ensureReserved(request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("different pallet request");
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
}
@Test
void existingBusinessBindingIsRejectedBeforeAnyPalletIsCreated() {
when(batches.insertIgnore(any())).thenReturn(1);
when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "container:plan-a:1"))
.thenReturn(new WarehousePalletBindingEntity());
assertThatThrownBy(() -> service.ensureReserved(request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("already bound");
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
}
@Test
void confirmValidatesAndBindsEveryItemInItsOwnCustomerScope() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
List<WarehousePalletBatchItemEntity> batchItems = List.of(
item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"),
item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RESERVED"));
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(batchItems);
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, batchItems.get(0)));
when(lockRecords.find(CUSTOMER_B, 502L)).thenReturn(lock(CUSTOMER_B, batchItems.get(1)));
when(lockRecords.close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"))).thenReturn(1);
var response = service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1"));
assertThat(response.status()).isEqualTo("CONFIRMED");
assertThat(response.items()).extracting(value -> value.status()).containsOnly("CONFIRMED");
verify(repository).lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1");
verify(repository).lockPalletBinding(CUSTOMER_B, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-2");
ArgumentCaptor<WarehousePalletBindingEntity> bindings = ArgumentCaptor.forClass(WarehousePalletBindingEntity.class);
verify(repository, times(2)).insertPalletBinding(bindings.capture());
assertThat(bindings.getAllValues()).extracting(WarehousePalletBindingEntity::getCustomerId)
.containsExactly(CUSTOMER_A, CUSTOMER_B);
}
@Test
void repeatedConfirmIsIdempotentAndDoesNotBindAgain() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, item));
when(lockRecords.close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"))).thenReturn(1);
var command = new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1");
service.confirm(command);
var response = service.confirm(command);
assertThat(response.replayed()).isTrue();
verify(lockRecords, times(1)).find(CUSTOMER_A, 501L);
verify(repository, times(1)).insertPalletBinding(any());
}
@Test
void terminalConfirmRejectsDifferentCommandPayload() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, item));
when(lockRecords.close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"))).thenReturn(1);
service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1"));
assertThatThrownBy(() -> service.confirm(
new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-2")))
.isInstanceOf(BizException.class)
.hasMessageContaining("different payload");
}
@Test
void compensationReleasesOnlyThisBatchUsingEachCustomerScope() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
List<WarehousePalletBatchItemEntity> batchItems = List.of(
item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"),
item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RESERVED"));
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(batchItems);
var response = service.compensate(
new WarehousePalletBatchRequests.Compensate(WAREHOUSE, "container-1", "compensate-1", "lastleg failed"));
assertThat(response.status()).isEqualTo("COMPENSATED");
verify(locks).release(CUSTOMER_A, 501L);
verify(locks).release(CUSTOMER_B, 502L);
assertThat(batchItems).extracting(WarehousePalletBatchItemEntity::getStatus).containsOnly("RELEASED");
}
@Test
void compensationRejectsConfirmedOrAlreadyBoundResources() {
WarehousePalletBatchEntity confirmed = batch(11L, "CONFIRMED", 1);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(confirmed);
when(items.lockByBatch(11L)).thenReturn(List.of());
var command = new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed");
assertThatThrownBy(() -> service.compensate(command)).isInstanceOf(BizException.class);
verify(locks, never()).release(anyLong(), anyLong());
}
@Test
void compensationRejectsBindingToThisBatchPallet() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity();
binding.setPalletId(301L);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1"))
.thenReturn(binding);
assertThatThrownBy(() -> service.compensate(new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed")))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be compensated");
verify(locks, never()).release(anyLong(), anyLong());
}
@Test
void compensationAllowsUnrelatedOldBindingToDifferentPallet() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
WarehousePalletBindingEntity oldBinding = new WarehousePalletBindingEntity();
oldBinding.setPalletId(999L);
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1"))
.thenReturn(oldBinding);
service.compensate(new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed"));
verify(locks).release(CUSTOMER_A, 501L);
}
@Test
void terminalCompensationOnlyReplaysIdenticalCommandAndReason() {
WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1);
WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED");
when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch);
when(items.lockByBatch(11L)).thenReturn(List.of(item));
var command = new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "lastleg failed");
service.compensate(command);
assertThat(service.compensate(command).replayed()).isTrue();
assertThatThrownBy(() -> service.compensate(new WarehousePalletBatchRequests.Compensate(
WAREHOUSE, "container-1", "compensate-1", "different reason")))
.isInstanceOf(BizException.class)
.hasMessageContaining("different payload");
verify(locks, times(1)).release(CUSTOMER_A, 501L);
}
@Test
void sameRequestAfterCompensationReusesPalletAndCreatesNextAttemptLocks() {
WarehousePalletBatchEntity batch = batch(11L, "COMPENSATED", 1);
List<WarehousePalletBatchItemEntity> batchItems = List.of(
item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RELEASED"),
item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RELEASED"));
AtomicInteger calls = new AtomicInteger();
when(batches.lockByKey(WAREHOUSE, "container-1")).thenAnswer(call -> {
if (batch.getRequestHash() == null) {
return null;
}
return batch;
});
when(batches.insertIgnore(any())).thenAnswer(call -> {
WarehousePalletBatchEntity candidate = call.getArgument(0);
batch.setRequestHash(candidate.getRequestHash());
return 0;
});
when(items.lockByBatch(11L)).thenReturn(batchItems);
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301"));
when(resources.pallet(0L, WAREHOUSE, 302L)).thenReturn(pallet(302L, 402L, "PAL-302"));
when(locks.reserve(any())).thenAnswer(call -> lockResponse(
((WarehouseResourceLockRequests.ReserveRequest) call.getArgument(0)).customerId(),
601L + calls.getAndIncrement()));
var response = service.ensureReserved(request());
assertThat(response.attemptNo()).isEqualTo(2);
assertThat(response.items()).extracting(value -> value.palletId()).containsExactly(301L, 302L);
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
ArgumentCaptor<WarehouseResourceLockRequests.ReserveRequest> reservations =
ArgumentCaptor.forClass(WarehouseResourceLockRequests.ReserveRequest.class);
verify(locks, times(2)).reserve(reservations.capture());
assertThat(reservations.getAllValues()).extracting(value -> value.reservationKey())
.allMatch(key -> key.contains(":2:"));
}
private void stubNewResources() {
AtomicInteger created = new AtomicInteger();
when(resources.createPallet(anyLong(), anyLong(), any())).thenAnswer(call -> 301L + created.getAndIncrement());
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301"));
when(resources.pallet(0L, WAREHOUSE, 302L)).thenReturn(pallet(302L, 402L, "PAL-302"));
when(locks.reserve(any())).thenAnswer(call -> {
WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0);
return lockResponse(value.customerId(), 500L + value.palletId() - 300L);
});
}
private static WarehousePalletBatchRequests.EnsureReserved request() {
return new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-1", List.of(
new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8"),
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9")));
}
private static WarehousePalletBatchEntity batch(long id, String status, int attempt) {
WarehousePalletBatchEntity entity = new WarehousePalletBatchEntity();
entity.setId(id);
entity.setWarehouseId(WAREHOUSE);
entity.setBatchKey("container-1");
entity.setStatus(status);
entity.setAttemptNo(attempt);
return entity;
}
private static WarehousePalletBatchItemEntity item(long id, long customerId, long palletId,
long locationId, long lockId, int sequence, String status) {
WarehousePalletBatchItemEntity entity = new WarehousePalletBatchItemEntity();
entity.setId(id);
entity.setBatchId(11L);
entity.setCustomerId(customerId);
entity.setWarehouseId(WAREHOUSE);
entity.setBusinessKey("business-" + sequence);
entity.setInboundPlanNo("PLAN-" + sequence);
entity.setSequenceNo(sequence);
entity.setPalletId(palletId);
entity.setPalletCode("PAL-" + palletId);
entity.setLocationId(locationId);
entity.setResourceLockId(lockId);
entity.setStatus(status);
return entity;
}
private static WarehousePalletEntity pallet(long id, long locationId, String code) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setId(id);
entity.setWarehouseId(WAREHOUSE);
entity.setLocationId(locationId);
entity.setPalletCode(code);
entity.setStatus("OCCUPIED");
return entity;
}
private static WarehouseResourceLockEntity lock(long customerId, WarehousePalletBatchItemEntity item) {
WarehouseResourceLockEntity entity = new WarehouseResourceLockEntity();
entity.setId(item.getResourceLockId());
entity.setCustomerId(customerId);
entity.setWarehouseId(WAREHOUSE);
entity.setPalletId(item.getPalletId());
entity.setLocationId(item.getLocationId());
entity.setBusinessType("INBOUND_PLAN_AUTO");
entity.setBusinessId(item.getBusinessKey());
entity.setStatus("ACTIVE");
return entity;
}
private static WarehouseResourceLockResponse lockResponse(long customerId, long lockId) {
return new WarehouseResourceLockResponse(lockId, customerId, WAREHOUSE, 0L, 0L,
"INBOUND_PLAN_AUTO", "business", "reservation", "ACTIVE", null, null);
}
}
@@ -251,6 +251,25 @@ class WarehouseResourceServiceTest {
assertThat(captor.getValue().getId()).isNotNull();
}
@Test
void generatedPalletCodeRemainsWithinLastlegSnapshotLimitForLongWarehouseCode() {
String longCode = "US-LONG-WAREHOUSE-CODE-THAT-EXCEEDS-THIRTY-CHARACTERS";
when(warehouseDirectory.required(WAREHOUSE))
.thenReturn(new SharedWarehouseReference(WAREHOUSE, longCode, "Long warehouse", "ACTIVE"));
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setId(30009L);
location.setLocationType("STORAGE");
location.setStatus("IDLE");
when(repository.findLocation(TENANT, WAREHOUSE, 30009L)).thenReturn(location);
service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", 30009L, null));
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).insertPallet(captor.capture());
assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(64);
}
@Test
void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() {
WarehouseLocationEntity resolved = new WarehouseLocationEntity();