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] 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); + } +}