Files
baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
T
2026-10-26 13:41:43 +08:00

773 lines
48 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
import com.baiyue.lastleg.client.OrderPreDepartureClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import com.baiyue.lastleg.model.LastlegOrderStatus;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class LastlegOrderService {
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED";
public static final String INBOUND_PLANNED = "INBOUND_PLANNED";
public static final String PALLET_MERGE_REQUIRED = "REQUIRED";
public static final String PALLET_MERGED = "MERGED";
private static final List<String> MANUAL_NODE_CODES = List.of(
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
@Resource private LastlegOrderMapper orderMapper;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private OverseasFeeSourceService feeSources;
@Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees;
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private ContainerWarehouseArrivalService warehouseArrival;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Resource private OrderPreDepartureClient orderPreDepartureClient;
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
}
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
Long customer = parseLong(customerId);
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
query.page().intValue(), query.size().intValue());
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
}
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
return new PageResult<>(nativeSummaries(page.getRecords()),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
/** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */
private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
returnOrderReferences.populateForRead(orders);
refreshReturnVolumes(orders);
Map<String, String> containerNodes = containerNodes(orders);
Map<Long, String> dispatchStatuses = dispatchStatuses(orders);
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(orders);
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(orders);
Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(orders);
var quantities = transferQuantities.forOrders(orders);
return orders.stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()),
relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList();
}
/** 全部页签合并订单服务的未开船头程数据与本服务已有尾程单,尾程记录优先覆盖同一来源订单。 */
private PageResult<LastlegOrderSummary> aggregateAllPage(String customerId, LastlegOrderRequests.Query query) {
var source = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId), query.orderNo(), query.inboundCode(), 1, 2000);
if (source == null || source.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrder> existing = orderMapper.selectList(filter(customerId, query, null)
.orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
Map<String, LastlegOrderSummary> merged = new LinkedHashMap<>();
source.data().records().forEach(row -> {
var summary = preDepartureSummary(row);
merged.put(orderKey(summary.customerId(), summary.sourceOrderNo()), summary);
});
for (LastlegOrderSummary order : nativeSummaries(existing)) {
String key = orderKey(order.customerId(), StringUtils.hasText(order.sourceOrderNo()) ? order.sourceOrderNo() : order.orderNo());
merged.put(key, order);
}
List<LastlegOrderSummary> rows = merged.values().stream().sorted(java.util.Comparator.comparing(LastlegOrderSummary::createdAt,
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))).toList();
int from = Math.min((query.page().intValue() - 1) * query.size().intValue(), rows.size());
int to = Math.min(from + query.size().intValue(), rows.size());
long total = rows.size();
return new PageResult<>(rows.subList(from, to), query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
private static String orderKey(String customerId, String orderNo) {
return String.valueOf(customerId) + ":" + String.valueOf(orderNo);
}
/** 将订单服务权威头程摘要投影为只读尾程列表行;尚未生成尾程单的字段保持为空。 */
private LastlegOrderSummary preDepartureSummary(OrderPreDepartureClient.Row row) {
LastlegOrder order = new LastlegOrder();
// 头程聚合行尚未生成尾程单,尾程订单号必须保持为空;来源头程订单号单独保留。
order.setId(row.orderId()); order.setOrderNo(null); order.setOrderType("FIRST_LEG");
order.setInboundCode(row.inboundCode()); order.setLxCode(row.lxCode()); order.setSourceOrderNo(row.orderNo());
order.setCustomerId(row.customerId() == null ? null : String.valueOf(row.customerId())); order.setCustomerName(row.customerName());
order.setSourceWarehouseName(row.receivingWarehouseName()); order.setOverseasWarehouseName(row.receivingWarehouseName());
order.setFbaWarehouseCode(row.destinationCode());
order.setContainerNo(row.containerNo()); order.setPackageCount(row.packageCount()); order.setVolumeCbm(row.volumeCbm());
order.setEstimatedArrivalAt(row.expectedArrivalAt()); order.setCurrentStatus(row.mainStatus());
order.setCreatedAt(row.createdAt()); order.setUpdatedAt(row.updatedAt()); order.setVersion(0);
return summary(order, List.of(), null, "UNSCHEDULED", null, List.of(), null, null);
}
@Transactional(readOnly = true)
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.countContainerProgress(query);
List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size())
.stream().map(this::containerSummaryForRead).toList();
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(containerSummaryForRead(summary),
orderMapper.findContainerProgressItems(bookingId));
}
/** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */
@Transactional(readOnly = true)
public List<ContainerArrivalTimeView> containerArrivalTimes(
LastlegOrderRequests.ContainerArrivalTimeQuery request) {
LinkedHashSet<String> bookingIds = new LinkedHashSet<>();
for (String value : request.sourceContainerBookingIds()) bookingIds.add(normalizeBookingId(value));
if (bookingIds.size() != request.sourceContainerBookingIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复");
}
return warehouseArrival.resolve(List.copyOf(bookingIds));
}
@Transactional
public ContainerArrivalTimeView changeExpectedWarehouseArrival(String sourceContainerBookingId,
String idempotencyKey, LastlegOrderRequests.ChangeExpectedWarehouseArrival request,
String operatorName) {
String bookingId = normalizeBookingId(sourceContainerBookingId);
String key = normalizeIdempotencyKey(idempotencyKey);
String requestHash = hash(bookingId + "\n" + request.estimatedWarehouseArrivalAt() + "\n"
+ request.expectedVersion() + "\n" + Objects.toString(request.remark(), ""));
ContainerArrivalTimeOperation replay = containerArrivalTimeOperations.findByKey(key);
if (replay != null) {
if (!bookingId.equals(replay.getSourceContainerBookingId())
|| !requestHash.equals(replay.getRequestHash())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Idempotency-Key 已用于其他预计到仓时间修改请求");
}
return requireContainerArrivalTime(bookingId);
}
List<LastlegOrder> orders = orderMapper.lockContainerOrders(bookingId);
if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
int version = requireSingleArrivalTimeVersion(orders);
if (version != request.expectedVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "预计到仓时间版本已变化,请刷新后重试");
}
if (orderMapper.countContainerPlans(bookingId) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜已生成入库计划,预计到仓时间不可修改");
}
int updated = orderMapper.updateContainerExpectedWarehouseArrival(bookingId,
request.estimatedWarehouseArrivalAt(), version);
if (updated != orders.size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间发生并发变化");
}
ContainerArrivalTimeOperation operation = new ContainerArrivalTimeOperation();
operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId);
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(version + 1); operation.setOperatorName(operatorName);
operation.setRemark(request.remark() == null ? null : request.remark().trim());
operation.setCreatedAt(LocalDateTime.now()); containerArrivalTimeOperations.insert(operation);
return requireContainerArrivalTime(bookingId);
}
/** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */
private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) {
List<ContainerArrivalTimeView> values = warehouseArrival.resolve(List.of(bookingId));
if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
if (values.get(0).version() < 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
}
return values.get(0);
}
private int requireSingleArrivalTimeVersion(List<LastlegOrder> orders) {
Set<Integer> versions = orders.stream().map(LastlegOrder::getArrivalTimeVersion)
.map(value -> value == null ? 0 : value).collect(Collectors.toSet());
if (versions.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
}
return versions.iterator().next();
}
@Transactional
public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId,
String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) {
String bookingId = normalizeBookingId(sourceContainerBookingId);
String key = normalizeIdempotencyKey(idempotencyKey);
String requestHash = hash(bookingId + "\n" + request.overseasWarehouseId() + "\n"
+ request.expectedVersion() + "\n" + Objects.toString(request.remark(), ""));
ReceivingWarehouseOperation replay = receivingWarehouseOperations.findByKey(key);
if (replay != null) {
if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Idempotency-Key 已用于其他接柜仓修改请求");
}
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
}
List<LastlegOrder> orders = orderMapper.lockContainerOrders(bookingId);
if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
int version = requireSingleAssignmentVersion(orders);
if (version != request.expectedVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓分配版本已变化,请刷新后重试");
}
if (orderMapper.countReceivingSessions(bookingId) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"海外PDA已建立接柜会话,接柜仓已永久锁定");
}
WarehouseResourceGateway.WarehouseReference warehouse =
warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId());
for (LastlegOrder order : orders) {
inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(),
warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key);
}
int updated = orderMapper.updateContainerReceivingWarehouse(bookingId,
Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version);
if (updated != orders.size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配发生并发变化");
}
orderMapper.updateContainerPlanReceivingWarehouse(bookingId, warehouse.warehouseId());
ReceivingWarehouseOperation operation = new ReceivingWarehouseOperation();
operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId);
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(version + 1); operation.setOperatorName(operatorName);
operation.setRemark(request.remark() == null ? null : request.remark().trim());
operation.setCreatedAt(LocalDateTime.now()); receivingWarehouseOperations.insert(operation);
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
}
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
if (summary == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
String issue = containerSnapshotIssue(summary);
if (issue != null) throw new ResponseStatusException(HttpStatus.CONFLICT, issue);
return summary;
}
private ContainerPlanProgressSummary containerSummaryForRead(ContainerPlanProgressSummary summary) {
String issue = containerSnapshotIssue(summary);
if (issue == null) return summary;
boolean invalidContainer = !hasCanonicalContainerNo(summary.containerNo());
return new ContainerPlanProgressSummary(summary.sourceContainerBookingId(), summary.containerNo(),
summary.overseasWarehouseId(), summary.overseasWarehouseCode(), summary.overseasWarehouseName(),
summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(),
invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(),
summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(),
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.resourcePendingPlanCount(), summary.plannedArrivalAt(),
false, issue);
}
private String containerSnapshotIssue(ContainerPlanProgressSummary summary) {
if (!hasCanonicalContainerNo(summary.containerNo())) {
return "来源柜真实集装箱号缺失、格式无效或存在漂移";
}
if (!StringUtils.hasText(summary.overseasWarehouseId())
|| !StringUtils.hasText(summary.overseasWarehouseCode())
|| !StringUtils.hasText(summary.overseasWarehouseName()) || summary.assignmentVersion() < 0) {
return "柜内接柜仓业务快照不一致";
}
return null;
}
private boolean hasCanonicalContainerNo(String containerNo) {
try {
ContainerNumbers.requireCanonical(containerNo);
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
private String normalizeBookingId(String value) {
String result = value == null ? null : value.trim();
if (!StringUtils.hasText(result) || result.length() > 128) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空");
}
return result;
}
private String normalizeIdempotencyKey(String value) {
String result = value == null ? null : value.trim();
if (!StringUtils.hasText(result) || result.length() > 255) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必填且不得超过255字符");
}
return result;
}
private int requireSingleAssignmentVersion(List<LastlegOrder> orders) {
Integer version = orders.get(0).getReceivingWarehouseVersion();
if (version == null || orders.stream().anyMatch(order -> !Objects.equals(version,
order.getReceivingWarehouseVersion()) || !StringUtils.hasText(order.getOverseasWarehouseId())
|| !StringUtils.hasText(order.getOverseasWarehouseCode())
|| !StringUtils.hasText(order.getOverseasWarehouseName()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配事实不一致");
}
return version;
}
private String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
/** 返回订单详情及统一费用审核、余额锁扣状态。 */
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
returnOrderReferences.populateForRead(List.of(order));
refreshReturnVolumes(List.of(order));
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderStatusHistory> history = historyMapper.selectList(
new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
List<OverseasFeeSource> orderFees = new java.util.ArrayList<>(
feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId));
// 换标费用以申请为主业务,履约详情同时保留旧订单费用和新申请费用。
if ("RELABEL_APPLICATION".equals(order.getOrderType()))
orderFees.addAll(manualFees.relabelFeesForOrder(order.getCustomerId(), orderId));
List<OverseasFeeSource> fees = manualFees.describe(orderFees);
List<LastlegOrderOperationLog> operations = operationLogMapper.selectList(
new LambdaQueryWrapper<LastlegOrderOperationLog>()
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
.eq(LastlegOrderOperationLog::getOrderId, orderId)
.orderByAsc(LastlegOrderOperationLog::getCreatedAt));
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes = manualNodeMapper.selectList(
new LambdaQueryWrapper<LastlegOrderManualNode>()
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
.eq(LastlegOrderManualNode::getOrderId, orderId)
.orderByAsc(LastlegOrderManualNode::getId)).stream()
.map(value -> new LastlegOrderCommandDtos.ManualNodeView(
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"),
orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()),
businessSources(List.of(order)).get(order.getId()), transferQuantities.forOrders(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList(),
"RETURN_RELABEL".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null);
}
/** 订单详情统一委托人工费用服务,头程待审核入账,海外业务先锁款。 */
@Transactional
public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
String operatorName, String idempotencyKey) {
LastlegOrder order = findOwned(customerId, orderId);
LocalDateTime now = LocalDateTime.now();
OverseasFeeSource fee = manualFees.create(operatorName, idempotencyKey,
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
"OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId,
null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()),
order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(),
request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(),
request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(),
request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(),
request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(),
request.unitPrice(), request.periodStart(), request.periodEnd()));
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode("新增费用");
operation.setOperatorName(operatorName);
operation.setResult("新增" + fee.getFeeItemName() + ":" + fee.getOriginalCurrency() + " "
+ fee.getOriginalAmount().toPlainString() + ",已提交财务审核");
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
return fee;
}
@Transactional
public void setPalletMergeRequired(String customerId, long orderId,
LastlegOrderRequests.SetPalletMergeRequired request,
String operatorName) {
LastlegOrder current = findOwned(customerId, orderId);
LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId);
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Completed inbound orders cannot change the pallet merge mark");
}
if (PALLET_MERGED.equals(order.getPalletMergeFlag())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"A completed pallet merge cannot be unmarked");
}
String target = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null;
if (Objects.equals(target, order.getPalletMergeFlag())) return;
LocalDateTime now = LocalDateTime.now();
if (orderMapper.updatePalletMergeFlag(order.getCustomerId(), orderId, target, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge mark was not updated");
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode(request.palletMergeRequired() ? "标记合托" : "取消合托标记");
operation.setOperatorName(operatorName);
operation.setResult(request.palletMergeRequired()
? "PDA扫描本单箱唛时提示需要合托" : "已取消PDA合托提示");
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
}
@Transactional
public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request,
String operatorName) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer");
}
if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode())
? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse");
}
if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus())
|| PALLET_MERGED.equals(order.getPalletMergeFlag()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed");
}
String batchId = request.palletMergeRequired()
? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null;
String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null;
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated");
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId());
operation.setOrderId(order.getId());
operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记");
operation.setOperatorName(operatorName);
operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组");
operation.setCreatedAt(now); operationLogMapper.insert(operation);
}
}
@Transactional
public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
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"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
for (LastlegOrder order : orders) {
ensureTransferable(order);
}
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
String fromStatus = order.getCurrentStatus();
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(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(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()));
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(order.getCustomerId());
history.setOrderId(order.getId());
history.setFromStatus(fromStatus);
history.setToStatus(DESTINATION_ASSIGNED);
history.setRemark(StringUtils.hasText(request.remark())
? request.remark() : "Destination warehouse assigned: " + target.code());
history.setCreatedAt(now);
historyMapper.insert(history);
}
return orders.size();
}
/** 与主列表复用同一筛选,汇总只纳入仓储转运且不使用分页参数。 */
public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals transferQuantityTotals(
String customerId, LastlegOrderRequests.Query query, String relabelNo) {
return transferQuantities.totals(filter(customerId, query, relabelNo).eq(LastlegOrder::getOrderType,"WAREHOUSE_TRANSFER"));
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
return filter(customerId, query, null);
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
return com.baiyue.lastleg.query.LastlegOrderFilter.create(customerId, query, relabelNo);
}
private LastlegOrder findOwned(String customerId, long orderId) {
LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getId, orderId));
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
return order;
}
private void ensureTransferable(LastlegOrder order) {
if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Destination warehouse cannot be changed after inbound plan creation: " + order.getId());
}
}
private Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> palletMergeRelations(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> result = new LinkedHashMap<>();
List<LastlegOrderMapper.PalletMergeRelationRow> rows = orderMapper.findPalletMergeRelations(
orders.stream().map(LastlegOrder::getId).toList());
if (rows == null) return Map.of();
for (LastlegOrderMapper.PalletMergeRelationRow row : rows) {
result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row);
}
return result;
}
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
orders.stream().map(LastlegOrder::getId).toList());
if (rows == null) return Map.of();
return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.DispatchStatusRow::orderId,
LastlegOrderMapper.DispatchStatusRow::dispatchStatus));
}
private void refreshReturnVolumes(List<LastlegOrder> orders) {
List<Long> ids = orders.stream().filter(order -> "RETURN_RELABEL".equals(order.getOrderType())).map(LastlegOrder::getId).toList();
if (ids.isEmpty()) return;
Map<Long, BigDecimal> volumes = orderMapper.findReturnVolumes(ids).stream()
.collect(Collectors.toMap(LastlegOrderMapper.ReturnVolumeRow::orderId, LastlegOrderMapper.ReturnVolumeRow::volumeCbm));
for (LastlegOrder order : orders) if (volumes.containsKey(order.getId())) order.setVolumeCbm(volumes.get(order.getId()));
}
private Map<Long, LastlegOrderMapper.OrderWeightRow> orderWeights(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.OrderWeightRow> rows = orderMapper.findOrderWeights(
orders.stream().map(LastlegOrder::getId).toList());
if (rows == null) return Map.of();
return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.OrderWeightRow::orderId, row -> row));
}
private Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabelSubOrders(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
var rows = orderMapper.findRelabelSubOrders(orders.stream().map(LastlegOrder::getId).toList());
return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId));
}
private Map<Long, LastlegOrderSummary.BusinessSource> businessSources(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
return orderMapper.findBusinessSources(orders.stream().map(LastlegOrder::getId).toList()).stream()
.collect(Collectors.toMap(LastlegOrderMapper.BusinessSourceRow::orderId, row -> {
String label = "US_TRUCK".equals(row.businessType()) ? "本土卡车" : LastlegOrderType.labelOf(row.businessType());
if (row.poolSource()) label += " · 拼车需求池";
else if ("LOCAL_LOOSEBOARD".equals(row.businessType())) label += " · 独立下单";
else if (StringUtils.hasText(row.externalBookingNo())) label += " · 外接来货";
return new LastlegOrderSummary.BusinessSource(row.businessType(), label, row.businessOrderNo(), row.externalBookingNo());
}));
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String containerNode, String dispatchStatus,
LastlegOrderMapper.OrderWeightRow weight,
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications,
LastlegOrderSummary.BusinessSource businessSource,
com.baiyue.lastleg.transfer.dto.TransferQuantityView transferQuantity) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(),
o.getSourceContainerBookingId(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(),
o.getReceivedPackageCount(), packageDifference(o), weight == null ? BigDecimal.ZERO : weight.grossWeightKg(),
weight == null ? BigDecimal.ZERO : weight.netWeightKg(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource, transferQuantity);
}
private static Integer packageDifference(LastlegOrder order) {
if (order.getReceivedPackageCount() == null) return null;
return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0);
}
static String displayOrderNo(LastlegOrder order) {
return ("WAREHOUSE_TRANSFER".equals(order.getOrderType()) || "RETURN_RELABEL".equals(order.getOrderType())) && StringUtils.hasText(order.getSourceOrderNo())
? order.getSourceOrderNo() : order.getOrderNo();
}
private String containerNode(LastlegOrder order) {
if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null;
ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId());
return gate == null ? null : gate.getStandardNode();
}
private Map<String, String> containerNodes(List<LastlegOrder> orders) {
List<String> bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId)
.filter(StringUtils::hasText).distinct().toList();
if (bookingIds.isEmpty()) return Map.of();
Map<String, String> result = new LinkedHashMap<>();
containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> {
if (StringUtils.hasText(gate.getStandardNode())) {
result.put(gate.getContainerBookingId(), gate.getStandardNode());
}
});
return result;
}
private static String displayInboundCode(LastlegOrder order) {
String orderType = order.getOrderType();
return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType)
? null : order.getInboundCode();
}
private static String relabelBusinessStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus;
return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus)
? "COMPLETED" : "PENDING_PROCESSING";
}
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
}
private static String childKey(String key, String suffix) {
String prefix = key == null ? "" : key.trim();
int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
}