321 lines
19 KiB
Java
321 lines
19 KiB
Java
package com.baiyue.firstleg.service;
|
|
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
import com.baiyue.common.core.api.PageResult;
|
|
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
|
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
|
|
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
|
|
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
|
|
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
|
import com.baiyue.firstleg.dto.CurrentContainerTransport;
|
|
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
|
|
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
|
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
|
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import jakarta.annotation.Resource;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.util.StringUtils;
|
|
|
|
import java.time.LocalDateTime;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.stream.Collectors;
|
|
|
|
/** Read-only adapter from internal inbound states to the product main statuses. */
|
|
@Service
|
|
public class UnifiedInboundQueryService {
|
|
private static final Set<String> MAIN_STATUSES = Set.of(
|
|
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
|
|
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
|
|
|
|
@Resource private InboundOrderRepository inbounds;
|
|
@Resource private FbaPricingRepository pricing;
|
|
@Resource private UnifiedInboundReadRepository operations;
|
|
@Resource private ObjectMapper json;
|
|
@Resource private com.baiyue.firstleg.workflow.WorkflowTabService workflowTabs;
|
|
|
|
/** 以稳定列表顺序返回去重履约状态,不触发逐票摘要及费用查询。 */
|
|
public List<StatusRow> statusRows(UnifiedInboundQuery query) {
|
|
validate(query);
|
|
Map<String, StatusRow> byOrder = new LinkedHashMap<>();
|
|
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
|
|
for (InboundOrderEntity inbound : matched) {
|
|
String status = mainStatus(inbound.getProcessStatus());
|
|
// 分片订单可同时命中不同履约页签;同一页签内每个订单只统计一次。
|
|
byOrder.putIfAbsent(inbound.getOrderId() + ":" + status, new StatusRow(inbound.getTenantId(),
|
|
inbound.getOrderId(), status));
|
|
}
|
|
workflowTabs.rows(matched).forEach(row -> byOrder.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row));
|
|
return List.copyOf(byOrder.values());
|
|
}
|
|
|
|
/** 只对当前页最多200个订单读取匹配摘要,选取规则与状态投影一致。 */
|
|
public List<UnifiedInboundSummary> statusSummaries(UnifiedInboundQuery query) {
|
|
validate(query);
|
|
if (query.candidateOrderIds() == null || query.candidateOrderIds().size() > 200) {
|
|
throw new com.baiyue.common.core.exception.BizException(
|
|
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "状态摘要必须提供最多200个候选订单");
|
|
}
|
|
Map<Long, InboundOrderEntity> byOrder = new LinkedHashMap<>();
|
|
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(query, internalStatuses(query.mainStatus()))) {
|
|
byOrder.putIfAbsent(inbound.getOrderId(), inbound);
|
|
}
|
|
return summaries(List.copyOf(byOrder.values()));
|
|
}
|
|
|
|
public record StatusRow(Long tenantId, Long orderId, String mainStatus) {}
|
|
|
|
public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
|
|
validate(query);
|
|
if ("PENDING_REVIEW".equals(query.mainStatus())
|
|
|| query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) {
|
|
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
|
}
|
|
if (StringUtils.hasText(query.mainStatus())
|
|
&& com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())) {
|
|
return workflowPage(query);
|
|
}
|
|
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
|
|
List<UnifiedInboundSummary> records = summaries(source.getRecords());
|
|
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
|
|
}
|
|
|
|
/** 独立事实页签先筛选完整来源再分页,内部查询也不能把业务页签解释成全部状态。 */
|
|
private PageResult<UnifiedInboundSummary> workflowPage(UnifiedInboundQuery query) {
|
|
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
|
|
Set<Long> matchingOrders = workflowTabs.rows(matched).stream()
|
|
.filter(row -> query.mainStatus().equals(row.mainStatus())).map(StatusRow::orderId)
|
|
.collect(Collectors.toSet());
|
|
Map<Long, InboundOrderEntity> unique = new LinkedHashMap<>();
|
|
// 轻量事实不能用于摘要,重新读取当前条件下的完整来源。
|
|
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(
|
|
withCandidates(query, matchingOrders.stream().toList()), List.of())) {
|
|
if (matchingOrders.contains(inbound.getOrderId())) unique.putIfAbsent(inbound.getOrderId(), inbound);
|
|
}
|
|
List<InboundOrderEntity> records = List.copyOf(unique.values());
|
|
long total = records.size();
|
|
long start = query.page() - 1 > Long.MAX_VALUE / query.size()
|
|
? total : Math.min((query.page() - 1) * query.size(), total);
|
|
var current = records.subList((int) start, (int) Math.min(start + query.size(), total));
|
|
return new PageResult<>(summaries(current), query.page(), query.size(), total,
|
|
total / query.size() + (total % query.size() == 0 ? 0 : 1));
|
|
}
|
|
|
|
/** 保留全部过滤条件,只收窄为业务事实已命中的订单。 */
|
|
private UnifiedInboundQuery withCandidates(UnifiedInboundQuery q, List<Long> candidates) {
|
|
return new UnifiedInboundQuery(q.tenantId(), q.keyword(), q.orderNo(), q.mainStatus(), q.customerId(),
|
|
q.receivingWarehouseId(), q.destinationWarehouseId(), q.inboundCode(), q.internalOrderStatus(),
|
|
q.expectedArrivalDate(), q.feeReviewStatus(), q.channelName(), q.containerNo(), q.cabinetNo(),
|
|
q.sailingDate(), q.loaded(), q.fullContainer(), q.measurementReviewStatus(), candidates,
|
|
q.createdFrom(), q.createdTo(), q.page(), q.size(), q.taxMode());
|
|
}
|
|
|
|
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
|
|
Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>();
|
|
for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds)))
|
|
byOrder.putIfAbsent(item.inbound().orderId(), item);
|
|
return orderIds.stream().distinct().map(byOrder::get).filter(java.util.Objects::nonNull).toList();
|
|
}
|
|
|
|
public UnifiedInboundDetail detail(long tenantId, long orderId) {
|
|
InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId);
|
|
if (inbound == null) return null;
|
|
applyConfirmedPalletCount(inbound, inbounds.confirmedPalletCountsMap(tenantId,
|
|
Map.of(inbound.getId(), inbound.getTenantId())));
|
|
InboundOrderDetail detail = inbounds.detail(tenantId, inbound);
|
|
String mainStatus = mainStatus(inbound.getProcessStatus());
|
|
boolean includePhaseThree = requiresPhaseThreeSummary(mainStatus);
|
|
List<UnifiedInboundDetail.TimelineEvent> timeline = detail.statusHistory().stream()
|
|
.map(item -> new UnifiedInboundDetail.TimelineEvent(
|
|
"FIRSTLEG", "STATUS", item.fromStatus(), item.toStatus(), item.action(), item.reason(),
|
|
item.operatorId(), item.operatorName(), item.occurredAt()))
|
|
.toList();
|
|
// Phase-three tables are optional during the phase-two measurement workflow.
|
|
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload = null;
|
|
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick = null;
|
|
List<CabinetLoadingEvidenceEntity> evidence = List.of();
|
|
LocalDateTime deliveryAt = null;
|
|
if (includePhaseThree) {
|
|
preload = operations.findPreload(tenantId, inbound.getId());
|
|
pick = operations.findPick(tenantId, inbound.getId());
|
|
evidence = operations.findEvidence(tenantId, inbound.getId());
|
|
deliveryAt = deliveryAt(detail.statusHistory());
|
|
}
|
|
CurrentContainerTransport transport = includePhaseThree
|
|
? operations.findTransport(tenantId, inbound.getId()) : null;
|
|
boolean customerFeeConfirmationRequired = pricing.customerFeeConfirmationRequiredMap(
|
|
tenantId, Map.of(inbound.getId(), inbound.getTenantId())).getOrDefault(inbound.getId(), false);
|
|
boolean hasTransportScope = includePhaseThree || inbounds.findByOrderIds(tenantId, List.of(orderId))
|
|
.stream().anyMatch(item -> requiresPhaseThreeSummary(mainStatus(item.getProcessStatus())));
|
|
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
|
|
transport, null, null, customerFeeConfirmationRequired), timeline,
|
|
hasTransportScope ? operations.findCustomerTransportFragments(tenantId, orderId) : List.of());
|
|
}
|
|
|
|
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
|
|
Map<Long, Long> phaseThreeOwners = records.stream()
|
|
.filter(inbound -> requiresPhaseThreeSummary(mainStatus(inbound.getProcessStatus())))
|
|
.collect(Collectors.toMap(
|
|
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
|
|
var preloads = operations.findPreloads(phaseThreeOwners);
|
|
var picks = operations.findPicks(phaseThreeOwners);
|
|
var evidences = operations.findEvidence(phaseThreeOwners);
|
|
var deliveries = operations.findDeliveryHistory(phaseThreeOwners);
|
|
var transports = operations.findTransports(phaseThreeOwners);
|
|
Map<Long, Long> owners = records.stream().collect(Collectors.toMap(
|
|
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
|
|
var confirmedPalletCounts = inbounds.confirmedPalletCountsMap(null, owners);
|
|
records.forEach(inbound -> applyConfirmedPalletCount(inbound, confirmedPalletCounts));
|
|
var locationCodes = inbounds.locationCodesMap(null, owners);
|
|
var pickups = inbounds.pickupMap(null, owners);
|
|
var customerFeeConfirmations = pricing.customerFeeConfirmationRequiredMap(null, owners);
|
|
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
|
|
picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()),
|
|
deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(),
|
|
transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()),
|
|
customerFeeConfirmations.getOrDefault(inbound.getId(), false))).toList();
|
|
}
|
|
|
|
private static void applyConfirmedPalletCount(InboundOrderEntity inbound,
|
|
Map<Long, Integer> confirmedPalletCounts) {
|
|
if ((inbound.getPalletCount() == null || inbound.getPalletCount() <= 0)
|
|
&& confirmedPalletCounts.getOrDefault(inbound.getId(), 0) > 0) {
|
|
inbound.setPalletCount(confirmedPalletCounts.get(inbound.getId()));
|
|
}
|
|
}
|
|
|
|
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
|
|
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
|
|
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
|
|
List<CabinetLoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
|
|
CurrentContainerTransport transport, String locationCodes,
|
|
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup,
|
|
boolean customerFeeConfirmationRequired) {
|
|
String mainStatus = mainStatus(inbound.getProcessStatus());
|
|
List<CabinetLoadingEvidenceEntity> activeEvidence = evidence.stream()
|
|
.filter(item -> item.getStatus() == null || "ACTIVE".equals(item.getStatus())).toList();
|
|
String sealNo = activeEvidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType()))
|
|
.map(CabinetLoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null);
|
|
var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary(
|
|
preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt());
|
|
var loadingSummary = pick == null && activeEvidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary(
|
|
pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(),
|
|
pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(),
|
|
pick == null ? null : pick.getPickedCount(), activeEvidence.size(), sealNo,
|
|
pick == null ? activeEvidence.stream().map(CabinetLoadingEvidenceEntity::getUpdatedAt)
|
|
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt());
|
|
var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus)
|
|
? new UnifiedInboundSummary.DeliverySummary(
|
|
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
|
|
inbound.getExpressTrackingNo()) : null;
|
|
var baseSummary = detail == null ? null : detail.summary();
|
|
var inboundSummary = com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport,
|
|
pickup == null && baseSummary != null && baseSummary.requestedPickupAt() != null
|
|
? pickupSnapshot(baseSummary.requestedPickupAt()) : pickup,
|
|
locationCodes == null && baseSummary != null ? baseSummary.locationCodes() : locationCodes);
|
|
return new UnifiedInboundSummary(inboundSummary, mainStatus,
|
|
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
|
|
customerFeeConfirmationRequired,
|
|
preloadSummary, loadingSummary, deliverySummary);
|
|
}
|
|
|
|
private com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickupSnapshot(LocalDateTime pickupAt) {
|
|
var snapshot = new com.baiyue.firstleg.entity.InboundPickupSnapshotEntity();
|
|
snapshot.setPickupAt(pickupAt);
|
|
return snapshot;
|
|
}
|
|
|
|
private LocalDateTime deliveryAt(List<InboundOrderDetail.StatusHistorySummary> history) {
|
|
return history.stream()
|
|
.filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus()))
|
|
.map(InboundOrderDetail.StatusHistorySummary::occurredAt)
|
|
.filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null);
|
|
}
|
|
|
|
static String mainStatus(String status) {
|
|
if (status == null) return null;
|
|
return switch (status) {
|
|
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
|
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE";
|
|
case "WAIT_SPLIT" -> "WAIT_SPLIT";
|
|
case "WAIT_PALLETIZE" -> "WAIT_PALLETIZE";
|
|
case "LOADING", "LOADED", "DELIVERED" -> status;
|
|
default -> null;
|
|
};
|
|
}
|
|
|
|
static String nextAction(String mainStatus, String processStatus, String preloadStatus) {
|
|
if (mainStatus == null) return null;
|
|
return switch (mainStatus) {
|
|
case "PENDING_REVIEW" -> "APPROVE_ORDER";
|
|
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
|
|
case "WAIT_PALLETIZE" -> "PALLETIZE_DROPSHIP";
|
|
case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
|
|
: "CONFIRMED".equals(preloadStatus) ? null
|
|
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
|
|
case "LOADING" -> "COMPLETE_LOADING";
|
|
case "LOADED" -> null;
|
|
case "DELIVERED" -> null;
|
|
default -> null;
|
|
};
|
|
}
|
|
|
|
static boolean requiresPhaseThreeSummary(String mainStatus) {
|
|
return mainStatus != null && Set.of("LOADING", "LOADED", "DELIVERED").contains(mainStatus);
|
|
}
|
|
|
|
List<String> internalStatuses(String mainStatus) {
|
|
if (!StringUtils.hasText(mainStatus) || com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(mainStatus)) return List.of();
|
|
return switch (mainStatus) {
|
|
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
|
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
|
|
case "WAIT_SPLIT" -> List.of("WAIT_SPLIT");
|
|
case "WAIT_PALLETIZE" -> List.of("WAIT_PALLETIZE");
|
|
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
|
|
default -> List.of("__NO_MATCH__");
|
|
};
|
|
}
|
|
|
|
private int itemCount(String itemsJson) {
|
|
if (!StringUtils.hasText(itemsJson)) return 0;
|
|
try {
|
|
JsonNode node = json.readTree(itemsJson);
|
|
return node.isArray() ? node.size() : 0;
|
|
} catch (Exception ignored) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private void validate(UnifiedInboundQuery query) {
|
|
if (query.taxMode() != null && !java.util.Set.of("TAX_REFUND", "BUY_DECLARATION").contains(query.taxMode())) {
|
|
throw new com.baiyue.common.core.exception.BizException(
|
|
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "taxMode仅支持TAX_REFUND或BUY_DECLARATION");
|
|
}
|
|
if (StringUtils.hasText(query.mainStatus())
|
|
&& !MAIN_STATUSES.contains(query.mainStatus())
|
|
&& !com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())
|
|
) {
|
|
throw new com.baiyue.common.core.exception.BizException(
|
|
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
|
|
}
|
|
if (query.createdFrom() != null && query.createdTo() != null
|
|
&& query.createdFrom().isAfter(query.createdTo())) {
|
|
throw new com.baiyue.common.core.exception.BizException(
|
|
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点");
|
|
}
|
|
if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) {
|
|
throw new com.baiyue.common.core.exception.BizException(
|
|
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "费用审核状态不合法");
|
|
}
|
|
if (StringUtils.hasText(query.measurementReviewStatus())
|
|
&& !REVIEW_STATUSES.contains(query.measurementReviewStatus())) {
|
|
throw new com.baiyue.common.core.exception.BizException(
|
|
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "测量审核状态不合法");
|
|
}
|
|
}
|
|
}
|