From 4c1e9aa714dc319e014f228b2797c96bff7eb7de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 23:45:30 +0800 Subject: [PATCH 1/2] feat(lastleg): add container plan preview --- .../controller/InboundPlanController.java | 14 ++ .../ContainerInboundPlanPreviewResponse.java | 61 ++++++++ .../mapper/InboundPlanOrderMapper.java | 27 ++++ .../service/InboundPlanService.java | 147 ++++++++++++++++++ .../openapi/overseas-business-v1.yaml | 74 +++++++++ .../ContainerInboundPlanPreviewTest.java | 82 ++++++++++ 6 files changed, 405 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3a3c83bef..a18359725 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -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 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 + "')") /** 查询入仓计划及其明细、托盘和分配信息。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java new file mode 100644 index 000000000..af7d1d59f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java @@ -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 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 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 blockers + ) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 8aedf41ff..6eff4b2a3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -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 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 " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 8f8c9864e..058a8a0dd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -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 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 orders = rows.stream() + .map(row -> previewOrder(row, containerNo, warehouseId)).toList(); + Comparator 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> 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 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 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 blockers) { + if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) { + List 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 values) { + List 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()); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 9d99f9384..87d998a50 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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: 分页查询排车单 @@ -2000,6 +2010,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] @@ -2289,6 +2304,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: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java new file mode 100644 index 000000000..fb7c97688 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -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); + } +} From a88a04fd24553b285a8519f932a2988f738db694 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 23:52:36 +0800 Subject: [PATCH 2/2] feat(warehouse-overseas): reserve inbound pallet batches --- deploy/sql/海外区域统一增量-MySQL8.sql | 28 ++ scripts/db/incremental.sql | 28 ++ warehouse-overseas-service/AGENTS.md | 3 + ...nternalWarehousePalletBatchController.java | 45 +++ .../dto/WarehousePalletBatchRequests.java | 41 ++ .../dto/WarehousePalletBatchResponse.java | 24 ++ .../entity/WarehousePalletBatchEntity.java | 29 ++ .../WarehousePalletBatchItemEntity.java | 28 ++ .../WarehousePalletBatchItemMapper.java | 15 + .../mapper/WarehousePalletBatchMapper.java | 21 + .../mapper/WarehousePalletBindingMapper.java | 7 + .../mapper/WarehouseResourceLockMapper.java | 7 +- .../WarehouseResourceLockRepository.java | 4 +- .../WarehouseResourceRepository.java | 5 + .../service/WarehousePalletBatchService.java | 359 +++++++++++++++++ .../service/WarehouseResourceService.java | 3 +- .../src/main/resources/db/warehouse.sql | 43 ++ .../warehouse-pallet-batch-internal-v1.yaml | 93 +++++ .../SchemaChineseCommentContractTest.java | 6 +- .../WarehousePalletBatchContractTest.java | 44 ++ .../WarehousePalletBatchServiceTest.java | 381 ++++++++++++++++++ .../service/WarehouseResourceServiceTest.java | 19 + 22 files changed, 1227 insertions(+), 6 deletions(-) create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java create mode 100644 warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 3f6e67927..11ae9469b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1991,3 +1991,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='柜级入库计划批量托盘明细与资源锁关联'; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index b2843d52c..715e1cfda 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3834,6 +3834,34 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_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; diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 0662bfcfc..47622f276 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -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 和存量备注迁移。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java new file mode 100644 index 000000000..6e1d23408 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java @@ -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 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 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 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)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java new file mode 100644 index 000000000..a15574a96 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java @@ -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 + ) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java new file mode 100644 index 000000000..d1c0fa7e5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java @@ -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 items +) { + public record Item( + long customerId, + String businessKey, + String inboundPlanNo, + int sequenceNo, + long palletId, + String palletCode, + long locationId, + long resourceLockId, + String status + ) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java new file mode 100644 index 000000000..fb5e0235f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java @@ -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; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java new file mode 100644 index 000000000..79ad9b008 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java @@ -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; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java new file mode 100644 index 000000000..fb6e5a67e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java @@ -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 { + @Select("SELECT * FROM wh_pallet_batch_item WHERE batch_id=#{batchId} ORDER BY sequence_no,id FOR UPDATE") + List lockByBatch(@Param("batchId") long batchId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java new file mode 100644 index 000000000..2bb0ca9cd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java @@ -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 { + @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); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java index eb5c39c1c..a9dee2e8a 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java @@ -13,4 +13,11 @@ public interface WarehousePalletBindingMapper extends BaseMapper() - .eq(WarehouseResourceLockEntity::getCustomerId, customerId) - .eq(WarehouseResourceLockEntity::getId, lockId)); + return locks.lockById(customerId, lockId); } public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index bcc991bcd..2cac2c33d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -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); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java new file mode 100644 index 000000000..d52a09257 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -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 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 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 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 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 businessKeys = new HashSet<>(); + Set sequences = new HashSet<>(); + List 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 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); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 9b5aee983..202881ed8 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -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); } diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index fd952af77..b6acb11c6 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -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)', diff --git a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml new file mode 100644 index 000000000..c7a6792f2 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml @@ -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' } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java index 040873e9e..d9f0faca9 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest { Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); List 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()) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java new file mode 100644 index 000000000..3c4ef6ab7 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java @@ -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); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java new file mode 100644 index 000000000..292d6abee --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -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 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 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 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 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 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 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 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 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); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 1d6615537..807382a3f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -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 captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(64); + } + @Test void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() { WarehouseLocationEntity resolved = new WarehouseLocationEntity();