分析看板补齐成本趋势与排名:主账聚合近7日线路均价,后台按真实数据绘图。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -8,7 +8,8 @@ import java.util.Map;
|
||||
public interface IInquiryDashboardService {
|
||||
|
||||
/**
|
||||
* 返回看板指标:metrics 列表 + trend + rankings。
|
||||
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||
*
|
||||
* @param range today|7d|30d,默认 today
|
||||
*/
|
||||
|
||||
+308
-38
@@ -1,12 +1,14 @@
|
||||
package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -18,24 +20,41 @@ import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 看板实时聚合实现(无快照依赖)。
|
||||
* <p>
|
||||
* 计算口径对齐后台原型:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
|
||||
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
|
||||
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
|
||||
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
|
||||
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
|
||||
* 线程:只读查询,无写锁。
|
||||
*/
|
||||
@Service
|
||||
public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final int COST_ROUTE_LIMIT = 16;
|
||||
private static final String[] COST_COLORS = {
|
||||
"#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6",
|
||||
"#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c",
|
||||
"#d97706", "#65a30d", "#0d9488", "#059669", "#0284c7"
|
||||
};
|
||||
|
||||
/** 单日金额累加(成本趋势用)。 */
|
||||
private static final class DaySum {
|
||||
private BigDecimal sum = BigDecimal.ZERO;
|
||||
private int count;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private InquiryTicketMapper ticketMapper;
|
||||
@@ -91,59 +110,306 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
|
||||
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
||||
long feedbackNumer = deal + lost;
|
||||
// 反馈率口径:已成交+未成交(相对周期工单)
|
||||
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
||||
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
|
||||
|
||||
// 近 7 日趋势(不受 range 限制,便于折线)
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd");
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to));
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||
Map<LocalDate, Integer> dayIndex = new HashMap<>();
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate d = today.minusDays(i);
|
||||
Date dFrom = Date.from(d.atStartOfDay(ZONE).toInstant());
|
||||
Date dTo = Date.from(LocalDateTime.of(d, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
long cnt = ticketMapper.selectCount(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, dFrom)
|
||||
.le(InquiryTicket::getCreateTime, dTo));
|
||||
dayIndex.put(d, trendDates.size());
|
||||
String label = d.format(DAY_FMT);
|
||||
trendDates.add(label);
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("date", d.format(fmt));
|
||||
p.put("value", cnt);
|
||||
p.put("date", label);
|
||||
p.put("value", 0L);
|
||||
trend.add(p);
|
||||
}
|
||||
for (InquiryTicket t : weekTickets) {
|
||||
LocalDate d = toLocalDate(t.getCreateTime());
|
||||
Integer idx = d == null ? null : dayIndex.get(d);
|
||||
if (idx == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> p = trend.get(idx);
|
||||
p.put("value", ((Number) p.get("value")).longValue() + 1L);
|
||||
}
|
||||
|
||||
// 人员排名:按销售名聚合询价量
|
||||
Map<String, Long> bySales = tickets.stream()
|
||||
.filter(t -> t.getSalesName() != null && !t.getSalesName().isEmpty())
|
||||
.collect(Collectors.groupingBy(InquiryTicket::getSalesName, Collectors.counting()));
|
||||
Map<String, InquiryStaffRole> staffByName = staffRoleMapper.selectList(null).stream()
|
||||
.collect(Collectors.toMap(InquiryStaffRole::getName, s -> s, (a, b) -> a));
|
||||
List<Map<String, Object>> rankings = bySales.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
||||
.limit(20)
|
||||
.map(e -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("name", e.getKey());
|
||||
InquiryStaffRole staff = staffByName.get(e.getKey());
|
||||
if (staff != null) {
|
||||
row.put("role", mapRankRole(staff.getRoleCode()));
|
||||
row.put("dept", staff.getDepartName());
|
||||
} else {
|
||||
row.put("role", "sales");
|
||||
row.put("dept", "-");
|
||||
}
|
||||
row.put("volumeNum", e.getValue());
|
||||
return row;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
List<InquiryStaffRole> staffRows = staffRoleMapper.selectList(null);
|
||||
Map<String, InquiryStaffRole> staffByWecom = new HashMap<>();
|
||||
Map<String, InquiryStaffRole> staffByName = new HashMap<>();
|
||||
for (InquiryStaffRole s : staffRows) {
|
||||
if (s == null) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(s.getWecomId())) {
|
||||
staffByWecom.put(s.getWecomId().trim(), s);
|
||||
}
|
||||
if (StringUtils.isNotBlank(s.getName())) {
|
||||
staffByName.putIfAbsent(s.getName().trim(), s);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("range", range == null ? "today" : range);
|
||||
result.put("metrics", metricCards);
|
||||
result.put("trend", trend);
|
||||
result.put("rankings", rankings);
|
||||
result.put("costDates", trendDates);
|
||||
result.put("costRoutes", costRoutes);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员排名:与工单列表 enrichDisplayFields 同口径。
|
||||
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
|
||||
*/
|
||||
private static List<Map<String, Object>> buildRankings(List<InquiryTicket> tickets,
|
||||
Map<String, InquiryStaffRole> staffByWecom,
|
||||
Map<String, InquiryStaffRole> staffByName) {
|
||||
class Acc {
|
||||
String name;
|
||||
InquiryStaffRole staff;
|
||||
long volume;
|
||||
}
|
||||
Map<String, Acc> byKey = new LinkedHashMap<>();
|
||||
for (InquiryTicket t : tickets) {
|
||||
if (t == null) {
|
||||
continue;
|
||||
}
|
||||
String wecom = StringUtils.trimToEmpty(t.getSalesWecomId());
|
||||
InquiryStaffRole staff = StringUtils.isNotBlank(wecom) ? staffByWecom.get(wecom) : null;
|
||||
String staffName = staff != null ? staff.getName() : null;
|
||||
String display = InquiryTicketDisplaySupport.resolveSalesDisplayName(
|
||||
t.getSalesName(), t.getSalesWecomId(), staffName);
|
||||
if (StringUtils.isBlank(display)) {
|
||||
continue;
|
||||
}
|
||||
// 仅企微 userid、又无员工绑定时不当人名进榜(与列表「应显示中文名」一致)
|
||||
if (staff == null && InquiryTicketDisplaySupport.looksLikeWecomUserId(display)) {
|
||||
continue;
|
||||
}
|
||||
if (staff == null) {
|
||||
staff = staffByName.get(display.trim());
|
||||
}
|
||||
String key = StringUtils.isNotBlank(wecom) ? "w:" + wecom : "n:" + display.trim();
|
||||
Acc acc = byKey.get(key);
|
||||
if (acc == null) {
|
||||
acc = new Acc();
|
||||
acc.name = display.trim();
|
||||
acc.staff = staff;
|
||||
acc.volume = 0L;
|
||||
byKey.put(key, acc);
|
||||
}
|
||||
acc.volume++;
|
||||
if (acc.staff == null && staff != null) {
|
||||
acc.staff = staff;
|
||||
}
|
||||
// 有员工中文名时覆盖展示名,避免残留 userid
|
||||
if (acc.staff != null && StringUtils.isNotBlank(acc.staff.getName())) {
|
||||
acc.name = acc.staff.getName().trim();
|
||||
}
|
||||
}
|
||||
return byKey.values().stream()
|
||||
.sorted(Comparator.comparingLong((Acc a) -> a.volume).reversed()
|
||||
.thenComparing(a -> a.name, Comparator.nullsLast(String::compareTo)))
|
||||
.limit(20)
|
||||
.map(a -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("name", a.name);
|
||||
if (a.staff != null) {
|
||||
row.put("role", mapRankRole(a.staff.getRoleCode()));
|
||||
row.put("dept", StringUtils.defaultIfBlank(a.staff.getDepartName(), "-"));
|
||||
} else {
|
||||
row.put("role", "sales");
|
||||
row.put("dept", "-");
|
||||
}
|
||||
row.put("volumeNum", a.volume);
|
||||
return row;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 成本趋势:近 7 日按线路日均价。
|
||||
* 金额:cost_price 可解析则用;否则回退 sale_price(避免全空折线)。
|
||||
* 线路:起运地-目的地;缺省业务线标签。
|
||||
*/
|
||||
private static List<Map<String, Object>> buildCostRoutes(List<InquiryTicket> weekTickets,
|
||||
Map<LocalDate, Integer> dayIndex,
|
||||
List<String> trendDates) {
|
||||
int days = trendDates.size();
|
||||
class RouteAcc {
|
||||
String label;
|
||||
DaySum[] daysArr = new DaySum[days];
|
||||
int samples;
|
||||
|
||||
RouteAcc(String label) {
|
||||
this.label = label;
|
||||
for (int i = 0; i < days; i++) {
|
||||
daysArr[i] = new DaySum();
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, RouteAcc> byRoute = new LinkedHashMap<>();
|
||||
DaySum[] allLine = new DaySum[days];
|
||||
for (int i = 0; i < days; i++) {
|
||||
allLine[i] = new DaySum();
|
||||
}
|
||||
int allSamples = 0;
|
||||
|
||||
for (InquiryTicket t : weekTickets) {
|
||||
if (t == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal amount = parseMoney(t.getCostPrice());
|
||||
if (amount == null) {
|
||||
amount = parseMoney(t.getSalePrice());
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
LocalDate d = toLocalDate(t.getCreateTime());
|
||||
Integer idx = d == null ? null : dayIndex.get(d);
|
||||
if (idx == null) {
|
||||
continue;
|
||||
}
|
||||
String label = routeLabel(t);
|
||||
String id = routeId(label);
|
||||
RouteAcc acc = byRoute.get(id);
|
||||
if (acc == null) {
|
||||
acc = new RouteAcc(label);
|
||||
byRoute.put(id, acc);
|
||||
}
|
||||
DaySum ds = acc.daysArr[idx];
|
||||
ds.sum = ds.sum.add(amount);
|
||||
ds.count++;
|
||||
acc.samples++;
|
||||
allLine[idx].sum = allLine[idx].sum.add(amount);
|
||||
allLine[idx].count++;
|
||||
allSamples++;
|
||||
}
|
||||
|
||||
List<Map.Entry<String, RouteAcc>> top = byRoute.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt((Map.Entry<String, RouteAcc> e) -> e.getValue().samples).reversed())
|
||||
.limit(COST_ROUTE_LIMIT)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<Map<String, Object>> routes = new ArrayList<>();
|
||||
if (allSamples > 0) {
|
||||
routes.add(toCostRouteRow("quanxian", "全线", COST_COLORS[0], allLine, days));
|
||||
}
|
||||
int colorIdx = 1;
|
||||
for (Map.Entry<String, RouteAcc> e : top) {
|
||||
if ("quanxian".equals(e.getKey()) || "全线".equals(e.getValue().label)) {
|
||||
continue;
|
||||
}
|
||||
String color = COST_COLORS[colorIdx % COST_COLORS.length];
|
||||
colorIdx++;
|
||||
routes.add(toCostRouteRow(e.getKey(), e.getValue().label, color, e.getValue().daysArr, days));
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toCostRouteRow(String id, String label, String color, DaySum[] daysArr, int days) {
|
||||
List<Object> values = new ArrayList<>(days);
|
||||
for (int i = 0; i < days; i++) {
|
||||
DaySum ds = daysArr[i];
|
||||
if (ds == null || ds.count <= 0) {
|
||||
values.add(null);
|
||||
} else {
|
||||
values.add(ds.sum.divide(BigDecimal.valueOf(ds.count), 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", id);
|
||||
row.put("label", label);
|
||||
row.put("color", color);
|
||||
row.put("values", values);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 金额串:「CNY 1,234.5」「1234.5」→ BigDecimal;「-」/空 → null。 */
|
||||
static BigDecimal parseMoney(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String s = raw.trim();
|
||||
if (s.isEmpty() || "-".equals(s) || "—".equals(s)) {
|
||||
return null;
|
||||
}
|
||||
s = s.replace(",", "");
|
||||
StringBuilder num = new StringBuilder();
|
||||
boolean dot = false;
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c >= '0' && c <= '9') {
|
||||
num.append(c);
|
||||
} else if (c == '.' && !dot) {
|
||||
num.append(c);
|
||||
dot = true;
|
||||
} else if (c == '-' && num.length() == 0) {
|
||||
num.append(c);
|
||||
}
|
||||
}
|
||||
if (num.length() == 0 || "-".contentEquals(num) || ".".contentEquals(num)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(num.toString());
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String routeLabel(InquiryTicket t) {
|
||||
String o = StringUtils.trimToEmpty(t.getOrigin());
|
||||
String d = StringUtils.trimToEmpty(t.getDestination());
|
||||
if (StringUtils.isNotBlank(o) && StringUtils.isNotBlank(d)) {
|
||||
return o + "-" + d;
|
||||
}
|
||||
if (StringUtils.isNotBlank(t.getBizTypeLabel())) {
|
||||
return t.getBizTypeLabel().trim() + "全线";
|
||||
}
|
||||
String line = StringUtils.trimToEmpty(t.getBusinessLine()).toUpperCase(Locale.ROOT);
|
||||
if ("AIR".equals(line)) {
|
||||
return "空运全线";
|
||||
}
|
||||
if ("SEA".equals(line)) {
|
||||
return "海运全线";
|
||||
}
|
||||
if ("LAND".equals(line)) {
|
||||
return "陆运全线";
|
||||
}
|
||||
return "未分线路";
|
||||
}
|
||||
|
||||
static String routeId(String label) {
|
||||
String raw = StringUtils.trimToEmpty(label).toLowerCase(Locale.ROOT);
|
||||
String id = raw.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-");
|
||||
if (id.isEmpty()) {
|
||||
return "route";
|
||||
}
|
||||
return id.length() > 48 ? id.substring(0, 48) : id;
|
||||
}
|
||||
|
||||
private static LocalDate toLocalDate(Date time) {
|
||||
if (time == null) {
|
||||
return null;
|
||||
}
|
||||
return time.toInstant().atZone(ZONE).toLocalDate();
|
||||
}
|
||||
|
||||
private static boolean hasExc(String v) {
|
||||
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
|
||||
}
|
||||
@@ -166,9 +432,13 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 员工 roleCode → 看板筛选项 sales/route/product。 */
|
||||
private static String mapRankRole(String roleCode) {
|
||||
if ("air".equals(roleCode) || "sea".equals(roleCode) || "land".equals(roleCode)) {
|
||||
return "air".equals(roleCode) ? "route" : "product";
|
||||
if ("air".equals(roleCode)) {
|
||||
return "route";
|
||||
}
|
||||
if ("sea".equals(roleCode) || "land".equals(roleCode)) {
|
||||
return "product";
|
||||
}
|
||||
return "sales";
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 看板聚合辅助:金额解析与线路标签(不启 Spring)。
|
||||
*/
|
||||
public class InquiryDashboardServiceImplTest {
|
||||
|
||||
@Test
|
||||
public void parseMoneyAcceptsCurrencyPrefix() {
|
||||
Assert.assertEquals(new BigDecimal("14659.60"), InquiryDashboardServiceImpl.parseMoney("CNY 14659.60"));
|
||||
Assert.assertEquals(new BigDecimal("1234.5"), InquiryDashboardServiceImpl.parseMoney("1,234.5"));
|
||||
Assert.assertNull(InquiryDashboardServiceImpl.parseMoney("-"));
|
||||
Assert.assertNull(InquiryDashboardServiceImpl.parseMoney(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routeLabelPrefersOdPair() {
|
||||
InquiryTicket t = new InquiryTicket();
|
||||
t.setOrigin("阿特伯");
|
||||
t.setDestination("广州");
|
||||
Assert.assertEquals("阿特伯-广州", InquiryDashboardServiceImpl.routeLabel(t));
|
||||
|
||||
InquiryTicket air = new InquiryTicket();
|
||||
air.setBusinessLine("AIR");
|
||||
Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分析看板:对接主账 /inquiry/dashboard/metrics。
|
||||
* 指标 / 趋势 / 排名来自聚合接口;成本趋势本期主账未提供,展示空态不造假数。
|
||||
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
@@ -13,8 +13,12 @@ const todayMetrics = ref<MetricCard[]>([]);
|
||||
const inquiryTrend = ref<InquiryPoint[]>([]);
|
||||
const rankingPeople = ref<RankingPerson[]>([]);
|
||||
const rankingDepts = ref<string[]>([]);
|
||||
/** 成本趋势主账暂无接口,保持空数组避免 Mock 假线。 */
|
||||
/** 成本趋势:主账 costRoutes(近 7 日线路均价)。 */
|
||||
const costRoutes = ref<CostRoute[]>([]);
|
||||
const costDateLabels = ref<string[]>([]);
|
||||
|
||||
const COST_PLOT_TOP = 36;
|
||||
const COST_PLOT_BOTTOM = 186;
|
||||
|
||||
const now = new Date();
|
||||
const isoDay = now.toISOString().slice(0, 10);
|
||||
@@ -87,6 +91,15 @@ async function loadMetrics() {
|
||||
rankingDepts.value = Array.from(
|
||||
new Set(rankingPeople.value.map((p) => p.dept).filter((d) => d && d !== "-")),
|
||||
);
|
||||
|
||||
const dates: string[] = Array.isArray(data.costDates) && data.costDates.length
|
||||
? data.costDates.map((d: any) => String(d))
|
||||
: inquiryTrend.value.map((p) => p.date);
|
||||
costDateLabels.value = dates;
|
||||
const rawRoutes = (data.costRoutes || []) as any[];
|
||||
costRoutes.value = buildCostRouteViews(rawRoutes, dates);
|
||||
selectedRoutes.value = costRoutes.value.map((r) => r.id);
|
||||
|
||||
const t = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
updatedTime.value = `${pad(t.getHours())}:${pad(t.getMinutes())}`;
|
||||
@@ -97,6 +110,76 @@ async function loadMetrics() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把主账 costRoutes.values 转成 SVG path + 数据点。
|
||||
* 只有一天有金额时 path 只有 M 看不见线,必须画 points。
|
||||
*/
|
||||
function buildCostRouteViews(rawRoutes: any[], dates: string[]): CostRoute[] {
|
||||
const n = Math.max(dates.length, 1);
|
||||
const xs = dates.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
||||
const parsed = rawRoutes.map((r) => {
|
||||
const values = Array.isArray(r.values)
|
||||
? r.values.map((v: any) => (v == null || v === "" ? null : Number(v)))
|
||||
: dates.map(() => null);
|
||||
while (values.length < dates.length) values.push(null);
|
||||
return {
|
||||
id: String(r.id || r.label || "route"),
|
||||
label: String(r.label || r.id || "线路"),
|
||||
color: String(r.color || "#2563eb"),
|
||||
values: values.slice(0, dates.length) as Array<number | null>,
|
||||
};
|
||||
});
|
||||
const nums = parsed.flatMap((r) => r.values.filter((v): v is number => v != null && !Number.isNaN(v) && v > 0));
|
||||
const max = Math.max(...nums, 1);
|
||||
// 单点时 min==max 会压成一条横线贴顶/贴底,留 10% 边距更易看见
|
||||
let min = nums.length ? Math.min(...nums) : 0;
|
||||
if (nums.length && max - min < max * 0.05) {
|
||||
min = Math.max(0, min - max * 0.1);
|
||||
}
|
||||
const range = Math.max(max - min, 1);
|
||||
const plotH = COST_PLOT_BOTTOM - COST_PLOT_TOP;
|
||||
const yFor = (v: number) => COST_PLOT_BOTTOM - ((v - min) / range) * plotH;
|
||||
return parsed.map((r) => {
|
||||
const points: Array<{ x: number; y: number; index: number }> = [];
|
||||
r.values.forEach((v, i) => {
|
||||
if (v == null || Number.isNaN(v)) return;
|
||||
points.push({ x: xs[i], y: yFor(v), index: i });
|
||||
});
|
||||
return {
|
||||
...r,
|
||||
path: pathFromValues(r.values, xs, yFor),
|
||||
points,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 有值连线,无值断点(不造假插值)。 */
|
||||
function pathFromValues(
|
||||
values: Array<number | null>,
|
||||
xs: number[],
|
||||
yFor: (v: number) => number,
|
||||
): string {
|
||||
let d = "";
|
||||
let started = false;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v == null || Number.isNaN(v)) {
|
||||
started = false;
|
||||
continue;
|
||||
}
|
||||
const x = xs[i];
|
||||
const y = yFor(v);
|
||||
d += `${started ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)} `;
|
||||
started = true;
|
||||
}
|
||||
return d.trim();
|
||||
}
|
||||
|
||||
function formatCost(v: number | null | undefined) {
|
||||
if (v == null || Number.isNaN(v)) return "-";
|
||||
return v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
const inquirySvg = computed(() => {
|
||||
const points = inquiryTrend.value.length
|
||||
? inquiryTrend.value
|
||||
@@ -128,7 +211,30 @@ const rankingRows = computed(() => {
|
||||
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
|
||||
});
|
||||
|
||||
const costDates = computed(() => inquiryTrend.value.map((p) => p.date));
|
||||
const costDates = computed(() =>
|
||||
costDateLabels.value.length ? costDateLabels.value : inquiryTrend.value.map((p) => p.date),
|
||||
);
|
||||
|
||||
const costSampleDays = computed(() => {
|
||||
const set = new Set<string>();
|
||||
for (const r of costRoutes.value) {
|
||||
r.values.forEach((v, i) => {
|
||||
if (v != null && !Number.isNaN(v) && costDates.value[i]) set.add(costDates.value[i]);
|
||||
});
|
||||
}
|
||||
return [...set];
|
||||
});
|
||||
|
||||
const costFootnote = computed(() => {
|
||||
if (!costRoutes.value.length) {
|
||||
return "近 7 日暂无带金额的工单,有报价后将显示线路成本趋势";
|
||||
}
|
||||
const days = costSampleDays.value;
|
||||
if (!days.length) {
|
||||
return "已选线路在近 7 日无金额样本";
|
||||
}
|
||||
return `成本来自主账工单(成本价优先,缺省销售价);有金额日期:${days.join("、")},其余日悬停为 —`;
|
||||
});
|
||||
const costXs = computed(() => {
|
||||
const n = Math.max(costDates.value.length, 1);
|
||||
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
||||
@@ -180,17 +286,15 @@ function hideInquiryTip() {
|
||||
chartTip.value.visible = false;
|
||||
}
|
||||
|
||||
function showCostTip(date: string, x: number, wrapEl: HTMLElement | null) {
|
||||
function showCostTip(date: string, x: number, dateIndex: number, wrapEl: HTMLElement | null) {
|
||||
if (!wrapEl) return;
|
||||
const box = wrapEl.getBoundingClientRect();
|
||||
const routes = filteredRoutes.value;
|
||||
const lines =
|
||||
routes.length === 0
|
||||
? ["未选择线路"]
|
||||
: [
|
||||
...routes.slice(0, 3).map((r) => r.label),
|
||||
...(routes.length > 3 ? [`等 ${routes.length} 条`] : []),
|
||||
];
|
||||
: routes.slice(0, 6).map((r) => `${r.label}: ${formatCost(r.values[dateIndex])}`);
|
||||
if (routes.length > 6) lines.push(`等 ${routes.length} 条`);
|
||||
costTip.value = {
|
||||
visible: true,
|
||||
left: `${(x / 760) * box.width}px`,
|
||||
@@ -434,11 +538,24 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- 有金额的日子画点:近 7 日往往只有一两天有价,单点折线不可见 -->
|
||||
<g v-for="r in filteredRoutes" :key="'pts-' + r.id">
|
||||
<circle
|
||||
v-for="p in r.points"
|
||||
:key="r.id + '-' + p.index"
|
||||
:cx="p.x"
|
||||
:cy="p.y"
|
||||
r="4"
|
||||
:fill="r.color"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
v-for="(x, i) in costXs"
|
||||
:key="'cx-' + costDates[i]"
|
||||
class="cost-point"
|
||||
@mouseenter="showCostTip(costDates[i], x, costWrap)"
|
||||
@mouseenter="showCostTip(costDates[i], x, i, costWrap)"
|
||||
@mouseleave="hideCostTip"
|
||||
>
|
||||
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
|
||||
@@ -458,7 +575,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-cost-footnote">
|
||||
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
|
||||
{{ costFootnote }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,12 @@ export type CostRoute = {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
/** 与 costDates 对齐;某日无样本为 null */
|
||||
values: Array<number | null>;
|
||||
/** 前端根据 values 生成的 SVG path */
|
||||
path: string;
|
||||
/** 有金额的日子画点(仅一天有数时折线不可见,靠点可见) */
|
||||
points: Array<{ x: number; y: number; index: number }>;
|
||||
};
|
||||
|
||||
/** 今日视角九指标(与原型默认展示一致) */
|
||||
@@ -78,23 +83,14 @@ export const rankingDepts = [
|
||||
"陆运产品部",
|
||||
];
|
||||
|
||||
/** 成本趋势折线:路径坐标对齐原型 SVG */
|
||||
/** 成本趋势折线:路径坐标对齐原型 SVG(仅作样式参考;正式数据来自主账) */
|
||||
export const costRoutes: CostRoute[] = [
|
||||
{ id: "cgo-kul", label: "CGO-KUL", color: "#2563eb", path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36" },
|
||||
{ id: "szx-kul", label: "SZX-KUL", color: "#3b82f6", path: "M36 64 L145 59 L254 69 L363 54 L472 59 L581 49 L690 44" },
|
||||
{ id: "hkg-kul", label: "HKG-KUL", color: "#0891b2", path: "M36 71 L145 66 L254 75 L363 61 L472 66 L581 56 L690 51" },
|
||||
{ id: "nkg-kul", label: "NKG-KUL", color: "#0e7490", path: "M36 79 L145 74 L254 81 L363 69 L472 74 L581 64 L690 59" },
|
||||
{ id: "hkg-del", label: "HKG-DEL", color: "#6366f1", path: "M36 86 L145 81 L254 89 L363 76 L472 81 L581 71 L690 66" },
|
||||
{ id: "cgo-maa", label: "CGO-MAA", color: "#8b5cf6", path: "M36 94 L145 89 L254 96 L363 84 L472 89 L581 79 L690 74" },
|
||||
{ id: "cgo-blr", label: "CGO-BLR", color: "#a855f7", path: "M36 101 L145 96 L254 104 L363 91 L472 96 L581 86 L690 81" },
|
||||
{ id: "zuh-crk", label: "ZUH-CRK", color: "#c026d3", path: "M36 109 L145 104 L254 111 L363 99 L472 104 L581 94 L690 89" },
|
||||
{ id: "wnz-cgk", label: "WNZ-CGK", color: "#db2777", path: "M36 116 L145 111 L254 119 L363 106 L472 111 L581 101 L690 96" },
|
||||
{ id: "bkk", label: "BKK", color: "#e11d48", path: "M36 124 L145 119 L254 126 L363 114 L472 119 L581 109 L690 104" },
|
||||
{ id: "waicai", label: "外采-空运", color: "#dc2626", path: "M36 131 L145 126 L254 134 L363 121 L472 126 L581 116 L690 111" },
|
||||
{ id: "quanxian", label: "全线", color: "#ea580c", path: "M36 139 L145 134 L254 141 L363 129 L472 134 L581 124 L690 119" },
|
||||
{ id: "haiyun", label: "海运全线", color: "#d97706", path: "M36 150 L145 146 L254 154 L363 144 L472 141 L581 136 L690 131" },
|
||||
{ id: "zhonggang", label: "中港/中亚/中欧", color: "#65a30d", path: "M36 159 L145 156 L254 163 L363 154 L472 151 L581 146 L690 141" },
|
||||
{ id: "changtu", label: "国内长途/零担", color: "#0d9488", path: "M36 169 L145 166 L254 173 L363 164 L472 161 L581 159 L690 154" },
|
||||
{ id: "chengpei", label: "国内城配/拖车/打包", color: "#059669", path: "M36 179 L145 176 L254 181 L363 174 L472 171 L581 169 L690 164" },
|
||||
{ id: "dongnan", label: "东南亚", color: "#0284c7", path: "M36 186 L145 184 L254 189 L363 181 L472 179 L581 176 L690 171" },
|
||||
{
|
||||
id: "cgo-kul",
|
||||
label: "CGO-KUL",
|
||||
color: "#2563eb",
|
||||
values: [120, 118, 125, 110, 115, 105, 100],
|
||||
path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36",
|
||||
points: [],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user