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());