This commit is contained in:
2026-08-28 13:54:32 +08:00
parent fe68551124
commit 6b5f2f0565
29 changed files with 959 additions and 178 deletions
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/** 国内 Warehouse Service 平台公共仓库只读目录。 */
@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient",
url = "${baiyue.clients.domestic-warehouse.url:}")
public interface DomesticWarehouseDirectoryClient {
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
ApiResponse<Detail> activeWarehouse(
@PathVariable("warehouseId") long warehouseId,
@RequestHeader("X-Internal-Service-Token") String internalToken);
record Detail(Warehouse warehouse, List<Address> addresses) { }
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String countryCode, String status) { }
record Address(String stateProvince) { }
}
@@ -7,7 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -77,11 +78,23 @@ public class LastlegOrderController {
@Operation(summary = "分页查询待分配柜级摘要",
description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<PageResult<PendingContainerSummary>> containerSummaries(
public ApiResponse<PageResult<ContainerPlanProgressSummary>> containerSummaries(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid LastlegOrderRequests.PendingContainerQuery query) {
@Valid LastlegOrderRequests.ContainerProgressQuery query) {
platformScope(user, null);
return ApiResponse.success(orderService.pendingContainerPage(query));
return ApiResponse.success(orderService.containerProgressPage(query));
}
@GetMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "查询柜级入库计划进度详情",
description = "按稳定 sourceContainerBookingId 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页影响。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<ContainerPlanProgressDetail> containerSummaryDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceContainerBookingId) {
platformScope(user, null);
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@@ -110,7 +123,9 @@ public class LastlegOrderController {
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
@Operation(summary = "将单票尾程订单转入 FBA 目的仓",
description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -121,7 +136,9 @@ public class LastlegOrderController {
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
@Operation(summary = "批量将尾程订单转入 FBA 目的仓",
description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.dto;
import java.time.LocalDateTime;
import java.util.List;
/** 柜级摘要及柜内全部尾程订单、入仓码和计划状态。 */
public record ContainerPlanProgressDetail(
ContainerPlanProgressSummary summary,
List<Item> orders
) {
public ContainerPlanProgressDetail {
orders = orders == null ? List.of() : List.copyOf(orders);
}
public record Item(
Long orderId,
String customerId,
String orderNo,
String inboundCode,
String orderStatus,
Long planId,
String planNo,
String planStatus,
boolean planValid,
LocalDateTime plannedArrivalAt
) {
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.dto;
import java.time.LocalDateTime;
/** 平台按稳定来源柜查询的入库计划完整度事实。 */
public record ContainerPlanProgressSummary(
String sourceContainerBookingId,
String containerNo,
long totalOrderCount,
long inboundCodeCount,
long missingInboundCodeCount,
long planCount,
long plannedInboundCodeCount,
long pendingPlanCount,
LocalDateTime plannedArrivalAt,
boolean allPlansReady,
String blockingReason
) {
}
@@ -36,14 +36,14 @@ public final class LastlegOrderRequests {
}
}
public record PendingContainerQuery(
public record ContainerProgressQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
String containerNo,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public PendingContainerQuery {
public ContainerProgressQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
@@ -1,18 +0,0 @@
package com.baiyue.lastleg.dto;
import java.math.BigDecimal;
/** 平台待分配尾程订单按来源货柜聚合的只读摘要。 */
public record PendingContainerSummary(
String sourceContainerBookingId,
String containerNo,
long orderCount,
long inboundCodeCount,
long customerCount,
long totalPackageCount,
long totalPalletCount,
BigDecimal totalVolumeCbm,
boolean eligible,
String ineligibleReason
) {
}
@@ -6,9 +6,13 @@ import java.util.List;
public final class ReceivingContainerResponses {
private ReceivingContainerResponses() { }
public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount,
public record Candidate(String sourceContainerBookingId, String containerNo, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
int totalPackages, int receivedPackages,
boolean operable, boolean allPlansReady, String blockReason,
LocalDateTime plannedArrivalAt, boolean currentAccount) { }
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
@@ -16,6 +16,8 @@ public class ReceivingContainerSession {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String sourceContainerBookingId;
private String containerFactHash;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseName;
@@ -96,28 +96,24 @@ public class ReceivingContainerService {
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
}
});
List<LastlegOrder> orderFacts = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.isNotNull(LastlegOrder::getSourceContainerBookingId)
.isNotNull(LastlegOrder::getContainerNo)
.orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId));
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>().orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>().orderByAsc(InboundPlanLine::getId));
Map<String, List<LastlegOrder>> grouped = orderFacts.stream()
.filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null)
.collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList()));
String operatorLockKey = activeOperatorKey(operatorKey);
Set<String> reservedByOthers = sessions.findActiveContainers().stream()
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
return grouped.entrySet().stream()
.filter(entry -> eligible(entry.getValue(), overseasWarehouseId))
.filter(entry -> !reservedByOthers.contains(activeContainerKey(entry.getKey())))
.map(entry -> {
requireSingleCustomer(entry.getValue(), entry.getKey());
Totals totals = totals(entry.getValue());
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(),
totals.total(), totals.received(), active);
}).toList();
.map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId))
.filter(Objects::nonNull)
.filter(value -> !reservedByOthers.contains(activeContainerKey(value.containerNo())))
.map(value -> candidate(value, current)).toList();
}
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
@@ -144,6 +140,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true);
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
@@ -176,6 +173,8 @@ public class ReceivingContainerService {
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setSourceContainerBookingId(readiness.sourceContainerBookingId());
session.setContainerFactHash(readiness.factHash());
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
@@ -381,9 +380,117 @@ public class ReceivingContainerService {
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
: sessions.findActiveByOperator(key);
if (session == null) throw notFound("Current receiving container session was not found");
requireStableSessionFacts(session, lock);
return session;
}
private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) {
LambdaQueryWrapper<LastlegOrder> query = new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId);
if (lock) query.last("FOR UPDATE");
List<LastlegOrder> orderFacts = orders.selectList(query);
Set<String> bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
if (orderFacts.isEmpty() || bookingIds.size() != 1) {
throw conflict("Container must resolve exactly one sourceContainerBookingId");
}
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList())
.orderByAsc(InboundPlanLine::getId));
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts,
overseasWarehouseId);
if (value == null || !value.operable()) {
throw conflict(value == null ? "Container is assigned to another overseas warehouse" : value.blockReason());
}
return value;
}
private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) {
if (trim(session.getContainerFactHash()) == null) return;
ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock);
if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId())
|| !Objects.equals(session.getContainerFactHash(), current.factHash())) {
throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session");
}
}
private ContainerReadiness readiness(String sourceContainerBookingId, List<LastlegOrder> orderFacts,
List<InboundPlan> planFacts, List<InboundPlanLine> lineFacts,
long overseasWarehouseId) {
Set<Long> orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet());
Map<Long, List<InboundPlanLine>> linesByOrder = lineFacts.stream()
.filter(line -> orderIds.contains(line.getOrderId()))
.collect(Collectors.groupingBy(InboundPlanLine::getOrderId));
Map<Long, InboundPlan> plansById = planFacts.stream().collect(Collectors.toMap(InboundPlan::getId,
value -> value, (left, right) -> left));
List<InboundPlan> matchedPlans = linesByOrder.values().stream().flatMap(List::stream)
.map(line -> plansById.get(line.getPlanId())).filter(Objects::nonNull).distinct().toList();
Set<Long> assignedWarehouses = matchedPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet());
orderFacts.stream().map(LastlegOrder::getOverseasWarehouseId).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).forEach(value -> {
try { assignedWarehouses.add(Long.valueOf(value)); }
catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); }
});
if (assignedWarehouses.size() > 1) throw conflict("Container plans have inconsistent receiving warehouse assignments");
if (!assignedWarehouses.isEmpty() && !assignedWarehouses.contains(overseasWarehouseId)) return null;
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
int totalOrders = orderFacts.size();
int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).distinct().count();
int plannedOrders = (int) orderFacts.stream().filter(order -> {
List<InboundPlanLine> values = linesByOrder.getOrDefault(order.getId(), List.of());
return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId());
}).count();
int planCount = matchedPlans.size();
boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders
&& plannedOrders == totalOrders && planCount == totalOrders;
String customerId = customers.size() == 1 ? customers.iterator().next() : null;
boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady;
int pending = Math.max(0, totalOrders - plannedOrders);
String blockReason = operable ? null : (!allPlansReady
? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
: "柜号尚未达到海外收货可操作节点");
LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt)
.filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null);
String factHash = hash(orderFacts.stream().sorted(Comparator.comparing(LastlegOrder::getId)).map(order -> {
List<InboundPlanLine> values = linesByOrder.getOrDefault(order.getId(), List.of());
return order.getId() + ":" + trim(order.getInboundCode()) + ":" + values.stream()
.map(InboundPlanLine::getPlanId).sorted().toList();
}).toList());
Map<Long, LastlegOrder> ordersById = orderFacts.stream().collect(Collectors.toMap(LastlegOrder::getId,
value -> value, (left, right) -> left));
List<InboundRef> matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId()))
.map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId())))
.filter(ref -> ref.plan() != null && ref.order() != null).toList();
List<ReceivingContainerResponses.Inbound> inboundValues = matchedRefs.stream().map(this::inbound).toList();
List<String> destinationCodes = inboundValues.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList();
Totals totals = new Totals(inboundValues.size(), destinationCodes,
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
return new ContainerReadiness(sourceContainerBookingId, containerNo, totalOrders, inboundCodes,
totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, operable,
blockReason, arrival, factHash, totals);
}
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
ReceivingContainerSession current) {
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
Totals totals = value.totals();
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(),
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
value.plannedArrivalAt(), active);
}
private ReceivingContainerSession requireSigned(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, true);
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
@@ -858,5 +965,10 @@ public class ReceivingContainerService {
private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { }
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record ContainerReadiness(String sourceContainerBookingId, String containerNo,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
boolean allPlansReady, boolean operable, String blockReason,
LocalDateTime plannedArrivalAt, String factHash, Totals totals) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { }
}
@@ -3,7 +3,8 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -13,35 +14,69 @@ import java.util.List;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String PENDING_CONTAINER_FILTER = " FROM ll_order o " +
String CONTAINER_PROGRESS_FILTER = " 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 IS NOT NULL AND o.source_container_booking_id&lt;&gt;'' " +
"<if test='q.containerNo != null and q.containerNo != \"\"'>AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') </if>" +
"<if test='q.createdFrom != null'>AND o.created_at &gt;=#{q.createdFrom} </if>" +
"<if test='q.createdTo != null'>AND o.created_at &lt;=#{q.createdTo} </if>" +
"GROUP BY o.source_container_booking_id " +
"HAVING SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 1 ELSE 0 END)&gt;0 ";
"GROUP BY o.source_container_booking_id ";
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.container_no,''))=1 THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"COUNT(*) AS orderCount,COUNT(DISTINCT o.inbound_code) AS inboundCodeCount," +
"COUNT(DISTINCT o.customer_id) AS customerCount,COALESCE(SUM(o.package_count),0) AS totalPackageCount," +
"COALESCE(SUM(o.pallet_count),0) AS totalPalletCount,COALESCE(SUM(o.volume_cbm),0) AS totalVolumeCbm," +
"CASE WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)=0 " +
String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"COUNT(*) AS totalOrderCount,COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) AS inboundCodeCount," +
"SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END) AS missingInboundCodeCount," +
"COUNT(DISTINCT p.id) AS planCount," +
"COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," +
"SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END) AS pendingPlanCount," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " +
"AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," +
"CASE WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)=0 " +
"AND SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END)=0 AND COUNT(DISTINCT p.id)=COUNT(*) " +
"AND COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN TRUE ELSE FALSE END AS eligible," +
"THEN TRUE ELSE FALSE END AS allPlansReady," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))&lt;&gt;COUNT(*) " +
"OR COUNT(DISTINCT NULLIF(o.container_no,''))&lt;&gt;1 THEN '柜号缺失或不一致' " +
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)&gt;0 " +
"THEN '柜内存在已生成计划或非待分配订单' ELSE NULL END AS ineligibleReason" +
PENDING_CONTAINER_FILTER +
"ORDER BY MAX(o.created_at) DESC,o.source_container_booking_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PendingContainerSummary> pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
"WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)&gt;0 " +
"THEN CONCAT('存在',SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END),'票缺少入仓码') " +
"WHEN SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END)&gt;0 THEN CONCAT('存在',SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL " +
"OR NULLIF(TRIM(p.status),'') IS NULL THEN 1 ELSE 0 END),'票尚未生成有效入库计划') " +
"WHEN COUNT(DISTINCT p.id)&lt;&gt;COUNT(*) THEN '入库计划与尾程订单关联不一致' ELSE NULL END AS blockingReason ";
@Select("<script>SELECT progress.* FROM (" + CONTAINER_PROGRESS_COLUMNS + CONTAINER_PROGRESS_FILTER +
") progress ORDER BY CASE WHEN progress.plannedArrivalAt IS NULL THEN 1 ELSE 0 END," +
"progress.plannedArrivalAt ASC,progress.sourceContainerBookingId ASC LIMIT #{limit} OFFSET #{offset}</script>")
List<ContainerPlanProgressSummary> pageContainerProgress(
@Param("q") LastlegOrderRequests.ContainerProgressQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT o.source_container_booking_id" +
PENDING_CONTAINER_FILTER + ") pending_containers</script>")
long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query);
CONTAINER_PROGRESS_FILTER + ") container_progress</script>")
long countContainerProgress(@Param("q") LastlegOrderRequests.ContainerProgressQuery query);
@Select("<script>" + CONTAINER_PROGRESS_COLUMNS + " 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} GROUP BY o.source_container_booking_id</script>")
ContainerPlanProgressSummary findContainerProgress(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.order_no AS orderNo,o.inbound_code AS inboundCode," +
"o.current_status AS orderStatus,p.id AS planId,p.plan_no AS planNo,p.status AS planStatus," +
"CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN TRUE ELSE FALSE END AS planValid," +
"o.estimated_warehouse_at AS plannedArrivalAt 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 CASE WHEN o.estimated_warehouse_at IS NULL THEN 1 ELSE 0 END," +
"o.estimated_warehouse_at ASC,o.customer_id ASC,o.id ASC")
List<ContainerPlanProgressDetail.Item> findContainerProgressItems(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Objects;
/** 通过国内 Warehouse Service 权威主档校验尾程转仓 FBA 目的仓。 */
@Component
public class FbaWarehouseValidator {
private static final String INVALID_MESSAGE = "所选FBA仓不存在或已停用";
@Resource private DomesticWarehouseDirectoryClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public VerifiedFbaWarehouse required(String requestedId, String requestedCode, String requestedName) {
long warehouseId = parseId(requestedId);
var response = lookup(warehouseId);
if (response == null || !response.successful() || response.data() == null
|| response.data().warehouse() == null) {
throw invalid();
}
DomesticWarehouseDirectoryClient.Warehouse warehouse = response.data().warehouse();
if (!Objects.equals(warehouse.id(), warehouseId)
|| !Objects.equals(warehouse.tenantId(), 0L)
|| !"FBA".equals(warehouse.warehouseType())
|| !"ACTIVE".equals(warehouse.status())
|| !same(requestedCode, warehouse.warehouseCode())
|| !same(requestedName, warehouse.warehouseName())) {
throw invalid();
}
String region = firstRegion(response.data().addresses());
return new VerifiedFbaWarehouse(warehouse.id(), warehouse.warehouseCode().trim(),
warehouse.warehouseName().trim(), trimToNull(warehouse.countryCode()), region);
}
private com.baiyue.common.core.api.ApiResponse<DomesticWarehouseDirectoryClient.Detail> lookup(long warehouseId) {
try {
return client.activeWarehouse(warehouseId, internalToken);
} catch (FeignException exception) {
if (exception.status() >= 400 && exception.status() < 500) throw invalid();
throw unavailable(exception);
} catch (ResponseStatusException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable(exception);
}
}
private long parseId(String value) {
try {
long id = Long.parseLong(value == null ? "" : value.trim());
if (id <= 0) throw invalid();
return id;
} catch (NumberFormatException exception) {
throw invalid();
}
}
private boolean same(String requested, String actual) {
return StringUtils.hasText(requested) && StringUtils.hasText(actual)
&& requested.trim().equals(actual.trim());
}
private String firstRegion(List<DomesticWarehouseDirectoryClient.Address> addresses) {
if (addresses == null) return null;
return addresses.stream().filter(Objects::nonNull)
.map(DomesticWarehouseDirectoryClient.Address::stateProvince)
.map(FbaWarehouseValidator::trimToNull).filter(Objects::nonNull).findFirst().orElse(null);
}
private ResponseStatusException invalid() {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, INVALID_MESSAGE);
}
private ResponseStatusException unavailable(RuntimeException cause) {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内FBA仓主数据暂不可用", cause);
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public record VerifiedFbaWarehouse(long id, String code, String name, String countryCode, String region) { }
}
@@ -6,7 +6,8 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -39,6 +40,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -48,20 +50,33 @@ public class LastlegOrderService {
}
@Transactional(readOnly = true)
public PageResult<PendingContainerSummary> pendingContainerPage(
LastlegOrderRequests.PendingContainerQuery query) {
public PageResult<ContainerPlanProgressSummary> containerProgressPage(
LastlegOrderRequests.ContainerProgressQuery query) {
if (query.createdFrom() != null && query.createdTo() != null
&& query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"createdFrom must not be after createdTo");
}
long total = orderMapper.countPendingContainers(query);
List<PendingContainerSummary> records = total == 0 ? List.of()
: orderMapper.pagePendingContainers(query, (query.page() - 1) * query.size(), query.size());
long total = orderMapper.countContainerProgress(query);
List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@Transactional(readOnly = true)
public ContainerPlanProgressDetail containerProgressDetail(String sourceContainerBookingId) {
String bookingId = sourceContainerBookingId == null ? null : sourceContainerBookingId.trim();
if (!StringUtils.hasText(bookingId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空");
}
ContainerPlanProgressSummary summary = orderMapper.findContainerProgress(bookingId);
if (summary == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
}
return new ContainerPlanProgressDetail(summary, orderMapper.findContainerProgressItems(bookingId));
}
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
@@ -83,6 +98,9 @@ public class LastlegOrderService {
if (ids.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
}
FbaWarehouseValidator.VerifiedFbaWarehouse target = fbaWarehouseValidator.required(
request.warehouseId(), request.warehouseCode(), request.warehouseName());
String warehouseId = Long.toString(target.id());
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
@@ -95,25 +113,25 @@ public class LastlegOrderService {
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
String fromStatus = order.getCurrentStatus();
order.setTransferWarehouseId(request.warehouseId());
order.setTransferWarehouseCode(request.warehouseCode());
order.setTransferWarehouseName(request.warehouseName());
order.setTransferWarehouseId(warehouseId);
order.setTransferWarehouseCode(target.code());
order.setTransferWarehouseName(target.name());
order.setCurrentStatus(DESTINATION_ASSIGNED);
order.setUpdatedAt(now);
orderMapper.updateById(order);
LastlegOrderDetail detailUpdate = new LastlegOrderDetail();
detailUpdate.setDestinationWarehouseId(request.warehouseId());
detailUpdate.setDestinationWarehouseCode(request.warehouseCode());
detailUpdate.setDestinationWarehouseName(request.warehouseName());
detailUpdate.setDestinationWarehouseId(warehouseId);
detailUpdate.setDestinationWarehouseCode(target.code());
detailUpdate.setDestinationWarehouseName(target.name());
detailMapper.update(detailUpdate, new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())
.eq(LastlegOrderDetail::getOrderId, order.getId()));
InboundPlanCarton cartonUpdate = new InboundPlanCarton();
cartonUpdate.setDestinationWarehouseId(request.warehouseId());
cartonUpdate.setDestinationWarehouseCode(request.warehouseCode());
cartonUpdate.setDestinationWarehouseName(request.warehouseName());
cartonUpdate.setDestinationWarehouseId(warehouseId);
cartonUpdate.setDestinationWarehouseCode(target.code());
cartonUpdate.setDestinationWarehouseName(target.name());
cartonMapper.update(cartonUpdate, new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, order.getCustomerId())
.eq(InboundPlanCarton::getOrderId, order.getId()));
@@ -124,7 +142,7 @@ public class LastlegOrderService {
history.setFromStatus(fromStatus);
history.setToStatus(DESTINATION_ASSIGNED);
history.setRemark(StringUtils.hasText(request.remark())
? request.remark() : "Destination warehouse assigned: " + request.warehouseCode());
? request.remark() : "Destination warehouse assigned: " + target.code());
history.setCreatedAt(now);
historyMapper.insert(history);
}
@@ -190,6 +190,7 @@ public class LoadingCompletedConsumer {
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
@@ -237,6 +238,15 @@ public class LoadingCompletedConsumer {
}
}
private LocalDateTime parsePlannedArrival(String value) {
if (value == null || value.isBlank()) return null;
try {
return LocalDateTime.parse(value.trim());
} catch (java.time.format.DateTimeParseException exception) {
throw new IllegalArgumentException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception);
}
}
private void createInboundOrderSnapshot(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order,
String orderHash) {