优化
This commit is contained in:
+26
@@ -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) { }
|
||||
}
|
||||
+23
-6
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+19
@@ -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
-2
@@ -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) { }
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+129
-17
@@ -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<>'' " +
|
||||
"<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 >=#{q.createdFrom} </if>" +
|
||||
"<if test='q.createdTo != null'>AND o.created_at <=#{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)>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,''))<>COUNT(*) " +
|
||||
"OR COUNT(DISTINCT NULLIF(o.container_no,''))<>1 THEN '柜号缺失或不一致' " +
|
||||
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)>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)>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)>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)<>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);
|
||||
}
|
||||
|
||||
+10
@@ -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) {
|
||||
|
||||
@@ -101,7 +101,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
KEY idx_ll_order_group(customer_id,order_no),
|
||||
KEY idx_ll_order_container(customer_id,container_no),
|
||||
KEY idx_ll_order_list(customer_id,current_status,created_at),
|
||||
KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)
|
||||
KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单主表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_detail (
|
||||
@@ -199,8 +199,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等
|
||||
ALTER TABLE ll_order COMMENT='尾程订单主表';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)'));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1'));
|
||||
PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)'));
|
||||
PREPARE lastleg_order_container_progress_idx_stmt FROM @ddl; EXECUTE lastleg_order_container_progress_idx_stmt; DEALLOCATE PREPARE lastleg_order_container_progress_idx_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||
PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
||||
@@ -708,6 +710,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
|
||||
@@ -158,8 +158,8 @@ paths:
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
|
||||
/api/platform/v1/last-leg/orders/container-summaries:
|
||||
get:
|
||||
summary: 分页查询待分配柜级摘要
|
||||
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 聚合。仅返回至少有一票待分配的柜;eligible=false 表示柜内状态或柜号不一致,禁止转换。
|
||||
summary: 分页查询柜级入库计划进度
|
||||
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
@@ -185,7 +185,58 @@ paths:
|
||||
pages: { type: integer, format: int64 }
|
||||
records:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/PendingContainerSummary' }
|
||||
items: { $ref: '#/components/schemas/ContainerPlanProgressSummary' }
|
||||
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:
|
||||
get:
|
||||
summary: 查询柜级入库计划进度详情
|
||||
description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
responses:
|
||||
'200':
|
||||
description: 统一 ApiResponse 包装的柜级进度详情
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string, enum: ['200'] }
|
||||
data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' }
|
||||
'404': { description: 稳定柜 ID 不存在 }
|
||||
/api/platform/v1/last-leg/orders/{id}/destination:
|
||||
put:
|
||||
summary: 将单票尾程订单转入 FBA 目的仓
|
||||
description: 权限 platform:overseas-warehouse:order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/FbaTransferDestination' }
|
||||
responses:
|
||||
'200': { description: 统一 ApiResponse 包装的更新订单数 }
|
||||
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
|
||||
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
|
||||
/api/platform/v1/last-leg/orders/destination:
|
||||
put:
|
||||
summary: 批量将尾程订单转入 FBA 目的仓
|
||||
description: 权限 platform:overseas-warehouse:order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/FbaBatchTransferDestination' }
|
||||
responses:
|
||||
'200': { description: 统一 ApiResponse 包装的更新订单数 }
|
||||
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
|
||||
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
|
||||
/api/platform/v1/last-leg/inbound-plans/convert:
|
||||
post:
|
||||
summary: 平台生成入库计划
|
||||
@@ -592,20 +643,63 @@ paths:
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
||||
components:
|
||||
schemas:
|
||||
PendingContainerSummary:
|
||||
FbaTransferDestination:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible]
|
||||
required: [warehouseId, warehouseCode, warehouseName]
|
||||
properties:
|
||||
warehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 国内 Warehouse Service 平台公共 FBA 仓 ID }
|
||||
warehouseCode: { type: string, minLength: 1, maxLength: 128 }
|
||||
warehouseName: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 500 }
|
||||
FbaBatchTransferDestination:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/FbaTransferDestination' }
|
||||
- type: object
|
||||
required: [orderIds]
|
||||
properties:
|
||||
orderIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
ContainerPlanProgressSummary:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string }
|
||||
containerNo: { type: string, nullable: true }
|
||||
orderCount: { type: integer, format: int64 }
|
||||
totalOrderCount: { type: integer, format: int64 }
|
||||
inboundCodeCount: { type: integer, format: int64 }
|
||||
customerCount: { type: integer, format: int64 }
|
||||
totalPackageCount: { type: integer, format: int64 }
|
||||
totalPalletCount: { type: integer, format: int64 }
|
||||
totalVolumeCbm: { type: number }
|
||||
eligible: { type: boolean }
|
||||
ineligibleReason: { type: string, nullable: true }
|
||||
missingInboundCodeCount: { type: integer, format: int64 }
|
||||
planCount: { type: integer, format: int64 }
|
||||
plannedInboundCodeCount: { type: integer, format: int64 }
|
||||
pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 }
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
|
||||
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
|
||||
blockingReason: { type: string, nullable: true }
|
||||
ContainerPlanProgressDetail:
|
||||
type: object
|
||||
required: [summary, orders]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/ContainerPlanProgressSummary' }
|
||||
orders:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ContainerPlanProgressItem' }
|
||||
ContainerPlanProgressItem:
|
||||
type: object
|
||||
required: [orderId, customerId, orderStatus, planValid]
|
||||
properties:
|
||||
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
customerId: { type: string }
|
||||
orderNo: { type: string, nullable: true }
|
||||
inboundCode: { type: string, nullable: true }
|
||||
orderStatus: { type: string }
|
||||
planId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$' }
|
||||
planNo: { type: string, nullable: true }
|
||||
planStatus: { type: string, nullable: true }
|
||||
planValid: { type: boolean }
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: ll_order.estimated_warehouse_at }
|
||||
PdaWarehouseScan:
|
||||
type: object
|
||||
required: [code]
|
||||
@@ -773,16 +867,25 @@ components:
|
||||
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
|
||||
ReceivingCandidate:
|
||||
type: object
|
||||
required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount]
|
||||
required: [sourceContainerBookingId, containerNo, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
|
||||
containerNo: { type: string }
|
||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||
totalOrderCount: { type: integer, minimum: 1 }
|
||||
missingInboundCodeCount: { type: integer, minimum: 0 }
|
||||
planCount: { type: integer }
|
||||
inboundCodeCount: { type: integer }
|
||||
plannedInboundCodeCount: { type: integer, minimum: 0 }
|
||||
pendingPlanCount: { type: integer, minimum: 0 }
|
||||
destinationCodes: { type: array, uniqueItems: true, items: { type: string } }
|
||||
plannedPalletCount: { type: integer }
|
||||
totalPackages: { type: integer }
|
||||
receivedPackages: { type: integer }
|
||||
operable: { type: boolean }
|
||||
allPlansReady: { type: boolean }
|
||||
blockReason: { type: string, nullable: true }
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true }
|
||||
currentAccount: { type: boolean }
|
||||
ReceivingSession:
|
||||
type: object
|
||||
|
||||
Reference in New Issue
Block a user