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