Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # scripts/db/incremental.sql
This commit is contained in:
@@ -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='柜级入库计划批量托盘明细与资源锁关联';
|
||||
|
||||
+14
@@ -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 + "')")
|
||||
/** 查询入仓计划及其明细、托盘和分配信息。 */
|
||||
|
||||
+61
@@ -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
|
||||
) { }
|
||||
}
|
||||
+27
@@ -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 " +
|
||||
|
||||
+147
@@ -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:
|
||||
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 和存量备注迁移。
|
||||
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
) { }
|
||||
}
|
||||
+24
@@ -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
|
||||
) { }
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
+6
-1
@@ -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);
|
||||
|
||||
+1
-3
@@ -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) {
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+359
@@ -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); }
|
||||
}
|
||||
+2
-1
@@ -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)',
|
||||
|
||||
+93
@@ -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' }
|
||||
+5
-1
@@ -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()) {
|
||||
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+381
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user