diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java index ef95bc1..56825cd 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryDashboardService.java @@ -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 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java index 81dd91e..7a1fdcc 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java @@ -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; /** * 看板实时聚合实现(无快照依赖)。 *

- * 计算口径对齐后台原型:询价总数、待处理、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> trend = new ArrayList<>(); - DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd"); + // 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count + Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant()); + List weekTickets = ticketMapper.selectList(new LambdaQueryWrapper() + .ge(InquiryTicket::getCreateTime, weekFrom) + .le(InquiryTicket::getCreateTime, to)); + + List trendDates = new ArrayList<>(7); + List> trend = new ArrayList<>(7); + Map 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() - .ge(InquiryTicket::getCreateTime, dFrom) - .le(InquiryTicket::getCreateTime, dTo)); + dayIndex.put(d, trendDates.size()); + String label = d.format(DAY_FMT); + trendDates.add(label); Map 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 p = trend.get(idx); + p.put("value", ((Number) p.get("value")).longValue() + 1L); + } - // 人员排名:按销售名聚合询价量 - Map bySales = tickets.stream() - .filter(t -> t.getSalesName() != null && !t.getSalesName().isEmpty()) - .collect(Collectors.groupingBy(InquiryTicket::getSalesName, Collectors.counting())); - Map staffByName = staffRoleMapper.selectList(null).stream() - .collect(Collectors.toMap(InquiryStaffRole::getName, s -> s, (a, b) -> a)); - List> rankings = bySales.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) - .limit(20) - .map(e -> { - Map 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 staffRows = staffRoleMapper.selectList(null); + Map staffByWecom = new HashMap<>(); + Map 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> rankings = buildRankings(tickets, staffByWecom, staffByName); + List> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates); Map 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> buildRankings(List tickets, + Map staffByWecom, + Map staffByName) { + class Acc { + String name; + InquiryStaffRole staff; + long volume; + } + Map 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 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> buildCostRoutes(List weekTickets, + Map dayIndex, + List 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 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> top = byRoute.entrySet().stream() + .sorted(Comparator.comparingInt((Map.Entry e) -> e.getValue().samples).reversed()) + .limit(COST_ROUTE_LIMIT) + .collect(Collectors.toList()); + + List> routes = new ArrayList<>(); + if (allSamples > 0) { + routes.add(toCostRouteRow("quanxian", "全线", COST_COLORS[0], allLine, days)); + } + int colorIdx = 1; + for (Map.Entry 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 toCostRouteRow(String id, String label, String color, DaySum[] daysArr, int days) { + List 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 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"; } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java new file mode 100644 index 0000000..b5b6145 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java @@ -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)); + } +} diff --git a/inquiry-backend/src/views/inquiry/dashboard/index.vue b/inquiry-backend/src/views/inquiry/dashboard/index.vue index 6d04d8a..b2df78e 100644 --- a/inquiry-backend/src/views/inquiry/dashboard/index.vue +++ b/inquiry-backend/src/views/inquiry/dashboard/index.vue @@ -1,7 +1,7 @@