feat(lastleg): add container plan preview

This commit is contained in:
2026-09-08 23:45:30 +08:00
parent ae44b82a85
commit 4c1e9aa714
6 changed files with 405 additions and 0 deletions
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
@@ -71,6 +72,19 @@ public class InboundPlanController {
return ApiResponse.success(service.convertContainer(request.sourceContainerBookingId()));
}
@GetMapping("/container-preview")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "预览按柜生成入库计划", description = "按稳定 sourceContainerBookingId 返回全柜服务端分组、托数、就绪与阻断事实;确认时必须回传 snapshotVersion/snapshotHash。Required authority: "
+ OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<ContainerInboundPlanPreviewResponse> previewContainer(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String sourceContainerBookingId) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可预览整柜入库计划");
}
return ApiResponse.success(service.previewContainer(sourceContainerBookingId));
}
@GetMapping("/{planNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
/** 查询入仓计划及其明细、托盘和分配信息。 */
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.math.BigDecimal;
import java.util.List;
/** Server-calculated cabinet conversion preview; callers must echo version/hash when confirming. */
public record ContainerInboundPlanPreviewResponse(
String sourceContainerBookingId,
String containerNo,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
long snapshotVersion,
String snapshotHash,
int totalOrderCount,
int readyOrderCount,
int blockedOrderCount,
int pendingOrderCount,
int pendingPalletCount,
int existingPlannedPalletCount,
int totalSuggestedPalletCount,
int totalPlannedPalletCount,
boolean ready,
List<Group> groups
) {
public record Group(
String destinationType,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int orderCount,
int pendingOrderCount,
int pendingPalletCount,
int existingPlannedPalletCount,
int suggestedPalletCount,
int plannedPalletCount,
List<Order> orders
) { }
public record Order(
String customerId,
String customerName,
String orderId,
String businessOrderNo,
String inboundCode,
String orderType,
String destinationType,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int packageCount,
BigDecimal volumeCbm,
Integer suggestedPalletCount,
Integer plannedPalletCount,
String existingPlanNo,
String planStatus,
int orderVersion,
String readiness,
List<String> blockers
) { }
}
@@ -18,6 +18,33 @@ public interface InboundPlanOrderMapper {
String overseasWarehouseName) { }
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
String overseasWarehouseId) { }
record ContainerPreviewOrderRow(
String customerId, Long orderId, String orderNo, String sourceOrderNo, String customerName,
String orderType, String destinationType, String inboundCode, String currentStatus,
String inboundOrderNo, String containerNo, int recommendedPalletCount, int packageCount,
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus, Integer plannedPalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
"o.source_order_no AS sourceOrderNo,o.customer_name AS customerName,o.order_type AS orderType," +
"o.destination_type AS destinationType,o.inbound_code AS inboundCode,o.current_status AS currentStatus," +
"o.inbound_order_no AS inboundOrderNo,o.container_no AS containerNo,COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"COALESCE(o.package_count,0) AS packageCount,COALESCE(o.volume_cbm,0) AS volumeCbm," +
"o.overseas_warehouse_id AS overseasWarehouseId,o.overseas_warehouse_code AS overseasWarehouseCode," +
"o.overseas_warehouse_name AS overseasWarehouseName,o.fba_warehouse_code AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),(SELECT MIN(NULLIF(d.destination_warehouse_id,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus,p.pallet_count AS plannedPalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List<ContainerPreviewOrderRow> containerPreviewOrders(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT c.customer_id AS customerId,c.id AS orderId," +
"c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " +
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
@@ -46,7 +47,9 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.stream.Collectors;
@Service
@@ -134,6 +137,150 @@ public class InboundPlanService {
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@Transactional(readOnly = true)
public ContainerInboundPlanPreviewResponse previewContainer(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
}
List<InboundPlanOrderMapper.ContainerPreviewOrderRow> rows = orderMapper.containerPreviewOrders(bookingId);
if (rows == null || rows.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
}
String containerNo = rows.stream().map(InboundPlanOrderMapper.ContainerPreviewOrderRow::containerNo)
.map(InboundPlanService::trimToNull).filter(Objects::nonNull).findFirst().orElse(null);
String warehouseId = singleValue(rows.stream()
.map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseId).toList());
String warehouseCode = singleValue(rows.stream()
.map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseCode).toList());
String warehouseName = singleValue(rows.stream()
.map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseName).toList());
List<ContainerInboundPlanPreviewResponse.Order> orders = rows.stream()
.map(row -> previewOrder(row, containerNo, warehouseId)).toList();
Comparator<ContainerInboundPlanPreviewResponse.Order> orderComparator = Comparator
.comparing((ContainerInboundPlanPreviewResponse.Order item) -> typeRank(item.destinationType()))
.thenComparing(item -> nullSafe(item.destinationWarehouseCode()))
.thenComparing(item -> nullSafe(item.destinationWarehouseName()))
.thenComparing(item -> nullSafe(item.destinationWarehouseId()))
.thenComparing(ContainerInboundPlanPreviewResponse.Order::customerId)
.thenComparing(ContainerInboundPlanPreviewResponse.Order::orderId);
orders = orders.stream().sorted(orderComparator).toList();
Map<String, List<ContainerInboundPlanPreviewResponse.Order>> grouped = new LinkedHashMap<>();
for (ContainerInboundPlanPreviewResponse.Order order : orders) {
String key = order.destinationType() + "|" + nullSafe(order.destinationWarehouseId()) + "|"
+ nullSafe(order.destinationWarehouseCode()) + "|" + nullSafe(order.destinationWarehouseName());
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(order);
}
List<ContainerInboundPlanPreviewResponse.Group> groups = grouped.values().stream().map(items -> {
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(),
items.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
}).toList();
int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count();
int pendingOrders = (int) orders.stream().filter(item -> "READY".equals(item.readiness())).count();
int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0);
String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
pendingOrders, pendingPallets, existingPallets,
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups);
}
private ContainerInboundPlanPreviewResponse.Order previewOrder(
InboundPlanOrderMapper.ContainerPreviewOrderRow row, String containerNo, String warehouseId) {
List<String> blockers = new ArrayList<>();
boolean planned = trimToNull(row.planNo()) != null;
if (!planned) {
if (trimToNull(row.inboundCode()) == null) blockers.add("MISSING_INBOUND_CODE");
if (row.detained()) blockers.add("ORDER_DETAINED");
if (!("PENDING_ASSIGNMENT".equals(row.currentStatus())
|| "DESTINATION_ASSIGNED".equals(row.currentStatus()))) blockers.add("ORDER_STATUS_NOT_READY");
if (containerNo == null || !Objects.equals(containerNo, trimToNull(row.containerNo()))) {
blockers.add("CONTAINER_NO_INCONSISTENT");
}
if (warehouseId == null || !Objects.equals(warehouseId, trimToNull(row.overseasWarehouseId()))) {
blockers.add("RECEIVING_WAREHOUSE_INCONSISTENT");
}
boolean fba = "FBA".equals(destinationType(row));
if (fba ? trimToNull(row.fbaWarehouseCode()) == null
: row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null
&& trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY");
if (row.packageCount() < 0 || row.volumeCbm() == null || row.volumeCbm().signum() < 0
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
Integer suggested = suggestedPalletCount(row, blockers);
String destinationType = destinationType(row);
String destinationCode = "FBA".equals(destinationType) && trimToNull(row.fbaWarehouseCode()) != null
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED";
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
destinationCode, row.destinationWarehouseName(), row.packageCount(), row.volumeCbm(),
suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
List.copyOf(blockers));
}
private Integer suggestedPalletCount(InboundPlanOrderMapper.ContainerPreviewOrderRow row, List<String> blockers) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) {
List<DropshipPalletSnapshot> pallets = dropshipOrders.palletSnapshots(row.customerId(), row.orderId());
if (pallets == null || pallets.isEmpty()) {
if (trimToNull(row.planNo()) == null) blockers.add("DROPSHIP_PALLET_SNAPSHOT_MISSING");
return null;
}
return pallets.size();
}
if (row.volumeCbm() == null || row.volumeCbm().signum() < 0 || row.packageCount() < 0
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) return null;
try {
return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(row.orderId(),
nullSafe(row.destinationWarehouseId()) + "|" + nullSafe("FBA".equals(destinationType(row))
? row.fbaWarehouseCode() : row.destinationWarehouseCode()),
row.volumeCbm(), row.packageCount(), row.recommendedPalletCount()))).pallets().size();
} catch (IllegalArgumentException exception) {
return null;
}
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
if ("FBA".equals(row.destinationType()) || trimToNull(row.fbaWarehouseCode()) != null) return "FBA";
return "PRIVATE_ADDRESS";
}
private static int typeRank(String type) {
return switch (type) { case "FBA" -> 0; case "ONE_CLICK_DROPSHIP" -> 1; default -> 2; };
}
private static String singleValue(List<String> values) {
List<String> normalized = values.stream().map(InboundPlanService::trimToNull).distinct().toList();
return normalized.size() == 1 ? normalized.get(0) : null;
}
private static String nullSafe(String value) { return value == null ? "" : value; }
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
return convert(customerId, selectedOrderId, List.of());
@@ -445,6 +445,16 @@ paths:
properties:
sourceContainerBookingId: { type: string, minLength: 1, maxLength: 128 }
responses: { '200': { description: 统一 ApiResponse 包装的入库计划数组 } }
/api/platform/v1/last-leg/inbound-plans/container-preview:
get:
summary: 预览按柜生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
responses:
'200': { description: 柜级入库计划转换预览, content: { application/json: { schema: { $ref: '#/components/schemas/ContainerInboundPlanPreviewApiResponse' } } } }
'404': { description: 来源柜没有尾程订单 }
/api/platform/v1/last-leg/dispatches:
get:
summary: 分页查询排车单
@@ -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:
@@ -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);
}
}