From b1eba3facc2198e0fee185908e00e8078760726a Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Mon, 14 Sep 2026 21:24:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=B7=A5=E5=8D=95=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=8E=A7=E4=BB=B6=E5=AF=B9=E9=BD=90=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=EF=BC=9A=E5=8F=AF=E8=A7=81=E6=96=87=E6=A1=88=E3=80=81?= =?UTF-8?q?=E7=82=B9=E9=80=89=E5=8D=B3=E5=BC=B9=E6=97=A5=E5=8E=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../views/inquiry/styles/admin-fidelity.css | 83 +++++++++++++++---- .../src/views/inquiry/workOrder/index.vue | 55 +++++++++++- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css index b8a8e28..099cf96 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css @@ -284,41 +284,94 @@ body { box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important; } +/* 日期范围:与 select 同高同边框;文案可见,原生 date 透明铺满,点文字即弹日历 */ .toolbar-date-range { display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; height: 38px; box-sizing: border-box; - padding: 0 8px; + padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; background: #ffffff; flex-shrink: 0; - max-width: 220px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } -.toolbar-date-range input[type="date"] { - width: 92px; - min-width: 0; - max-width: 92px; +.toolbar-date-range:hover { + border-color: #98a2b3; +} + +.toolbar-date-range:focus-within { + border-color: #155eef; + box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12); +} + +.toolbar-date-icon { + color: #98a2b3; + flex-shrink: 0; + pointer-events: none; +} + +.toolbar-date-part { + position: relative; + display: inline-flex; + align-items: center; + min-width: 84px; height: 28px; - border: none; - background: transparent; - padding: 0; - font-size: 10px; - font-weight: 600; + padding: 0 2px; + cursor: pointer; +} + +.toolbar-date-label { + font-size: 11px; + font-weight: 700; color: #344054; - outline: none; + white-space: nowrap; + line-height: 1; + pointer-events: none; + user-select: none; +} + +.toolbar-date-label.is-placeholder { + color: #98a2b3; + font-weight: 600; +} + +.toolbar-date-native { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + cursor: pointer; + font-size: 16px; + background: transparent; +} + +/* WebKit:把日历图标热区拉满整格,点文字区域也能出 picker */ +.toolbar-date-native::-webkit-calendar-picker-indicator { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + opacity: 0; cursor: pointer; } .toolbar-date-sep { - font-size: 10px; - font-weight: 700; + font-size: 11px; + font-weight: 600; color: #98a2b3; flex-shrink: 0; line-height: 1; + user-select: none; } .toolbar .toolbar-actions .btn { diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 4786e52..074dd7c 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -67,6 +67,30 @@ const closeOpen = ref(false); const current = ref(null); const closeReason = ref(""); +/** 创建时间起止:原生 date 叠在文案上,点文字也能弹出日历 */ +const dateFromRef = ref(null); +const dateToRef = ref(null); + +/** + * 打开系统日期面板。 + * 点「开始/结束日期」文案时调用;优先 HTMLInputElement.showPicker。 + * 不在此处再 el.click(),避免与 input 的 click 互相重入。 + * WebKit 另靠 CSS 把 calendar-picker-indicator 铺满热区兜底。 + */ +function openDatePicker(which: "from" | "to") { + const el = which === "from" ? dateFromRef.value : dateToRef.value; + if (!el) return; + try { + if (typeof (el as HTMLInputElement & { showPicker?: () => void }).showPicker === "function") { + (el as HTMLInputElement & { showPicker: () => void }).showPicker(); + } else { + el.focus(); + } + } catch { + el.focus(); + } +} + function dash(v: any) { const s = (v ?? "").toString().trim(); return s ? s : "-"; @@ -414,9 +438,34 @@ function refresh() {
- - – - + +
+ + {{ filters.dateFrom || "开始日期" }} + + +
+ 至 +
+ + {{ filters.dateTo || "结束日期" }} + + +
From 07c988e61f72ec4630f60daad597d65c5d0e7152 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Mon, 14 Sep 2026 21:27:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=94=B9=E7=94=A8=20Ant=20RangePicker?= =?UTF-8?q?=EF=BC=8C=E4=B8=80=E6=AC=A1=E5=BC=B9=E5=B1=82=E9=80=89=E8=B5=B7?= =?UTF-8?q?=E6=AD=A2=E5=B9=B6=E4=B8=8E=E7=AD=9B=E9=80=89=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../views/inquiry/styles/admin-fidelity.css | 104 ++++++------------ .../src/views/inquiry/workOrder/index.vue | 76 +++++-------- 2 files changed, 62 insertions(+), 118 deletions(-) diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css index 099cf96..087003a 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css @@ -284,94 +284,62 @@ body { box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important; } -/* 日期范围:与 select 同高同边框;文案可见,原生 date 透明铺满,点文字即弹日历 */ +/* 日期范围:Ant RangePicker,一次弹层点选起止;外观对齐工具栏 select */ .toolbar-date-range { display: inline-flex; align-items: center; - gap: 6px; + flex-shrink: 0; height: 38px; - box-sizing: border-box; - padding: 0 12px; - border: 1px solid var(--line); - border-radius: 8px; - background: #ffffff; - flex-shrink: 0; - transition: border-color 0.15s ease, box-shadow 0.15s ease; } -.toolbar-date-range:hover { - border-color: #98a2b3; +.toolbar-date-range .toolbar-range-picker.ant-picker, +.toolbar-date-range .ant-picker { + height: 38px !important; + padding: 0 12px !important; + border: 1px solid var(--line) !important; + border-radius: 8px !important; + background: #ffffff !important; + box-shadow: none !important; + transition: border-color 0.15s ease, box-shadow 0.15s ease !important; } -.toolbar-date-range:focus-within { - border-color: #155eef; - box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12); +.toolbar-date-range .ant-picker:hover { + border-color: #98a2b3 !important; } -.toolbar-date-icon { - color: #98a2b3; - flex-shrink: 0; - pointer-events: none; +.toolbar-date-range .ant-picker-focused, +.toolbar-date-range .ant-picker:focus, +.toolbar-date-range .ant-picker.ant-picker-focused { + border-color: #155eef !important; + box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important; } -.toolbar-date-part { - position: relative; - display: inline-flex; - align-items: center; - min-width: 84px; - height: 28px; - padding: 0 2px; - cursor: pointer; +.toolbar-date-range .ant-picker-input > input { + font-size: 11px !important; + font-weight: 700 !important; + color: #344054 !important; + cursor: pointer !important; } -.toolbar-date-label { - font-size: 11px; - font-weight: 700; - color: #344054; - white-space: nowrap; - line-height: 1; - pointer-events: none; - user-select: none; +.toolbar-date-range .ant-picker-input > input::placeholder { + color: #98a2b3 !important; + font-weight: 600 !important; } -.toolbar-date-label.is-placeholder { - color: #98a2b3; - font-weight: 600; +.toolbar-date-range .ant-picker-separator { + color: #98a2b3 !important; + font-size: 11px !important; + font-weight: 600 !important; + padding: 0 4px !important; } -.toolbar-date-native { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - padding: 0; - border: 0; - opacity: 0; - cursor: pointer; - font-size: 16px; - background: transparent; +.toolbar-date-range .ant-picker-suffix { + color: #98a2b3 !important; + margin-left: 4px !important; } -/* WebKit:把日历图标热区拉满整格,点文字区域也能出 picker */ -.toolbar-date-native::-webkit-calendar-picker-indicator { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - padding: 0; - opacity: 0; - cursor: pointer; -} - -.toolbar-date-sep { - font-size: 11px; - font-weight: 600; - color: #98a2b3; - flex-shrink: 0; - line-height: 1; - user-select: none; +.toolbar-date-range .ant-picker-clear { + background: #ffffff !important; } .toolbar .toolbar-actions .btn { diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 074dd7c..856ed5c 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -5,7 +5,7 @@ * 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible, * v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。 */ -import { onMounted, reactive, ref, watch } from "vue"; +import { computed, onMounted, reactive, ref, watch } from "vue"; import { message, Modal } from "ant-design-vue"; import { getWorkOrderDetail, @@ -67,29 +67,20 @@ const closeOpen = ref(false); const current = ref(null); const closeReason = ref(""); -/** 创建时间起止:原生 date 叠在文案上,点文字也能弹出日历 */ -const dateFromRef = ref(null); -const dateToRef = ref(null); - /** - * 打开系统日期面板。 - * 点「开始/结束日期」文案时调用;优先 HTMLInputElement.showPicker。 - * 不在此处再 el.click(),避免与 input 的 click 互相重入。 - * WebKit 另靠 CSS 把 calendar-picker-indicator 铺满热区兜底。 + * 创建时间范围:与 filters.dateFrom/dateTo 双向同步。 + * RangePicker 一次弹层点选起止两天;valueFormat 固定 YYYY-MM-DD 给列表 API。 */ -function openDatePicker(which: "from" | "to") { - const el = which === "from" ? dateFromRef.value : dateToRef.value; - if (!el) return; - try { - if (typeof (el as HTMLInputElement & { showPicker?: () => void }).showPicker === "function") { - (el as HTMLInputElement & { showPicker: () => void }).showPicker(); - } else { - el.focus(); - } - } catch { - el.focus(); - } -} +const dateRange = computed({ + get(): [string, string] | undefined { + if (!filters.dateFrom && !filters.dateTo) return undefined; + return [filters.dateFrom || "", filters.dateTo || ""]; + }, + set(v: [string, string] | null | undefined) { + filters.dateFrom = v?.[0] ? String(v[0]) : ""; + filters.dateTo = v?.[1] ? String(v[1]) : ""; + }, +}); function dash(v: any) { const s = (v ?? "").toString().trim(); @@ -438,34 +429,19 @@ function refresh() {
- -
- - {{ filters.dateFrom || "开始日期" }} - - -
- 至 -
- - {{ filters.dateTo || "结束日期" }} - - -
+ + +
From 9d4889c4826d3328136e402e64565c7d9dc38cf4 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Mon, 14 Sep 2026 21:49:48 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=88=86=E6=9E=90=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=88=90=E6=9C=AC=E8=B6=8B=E5=8A=BF=E4=B8=8E?= =?UTF-8?q?=E6=8E=92=E5=90=8D=EF=BC=9A=E4=B8=BB=E8=B4=A6=E8=81=9A=E5=90=88?= =?UTF-8?q?=E8=BF=917=E6=97=A5=E7=BA=BF=E8=B7=AF=E5=9D=87=E4=BB=B7?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E5=8F=B0=E6=8C=89=E7=9C=9F=E5=AE=9E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=98=E5=9B=BE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../service/IInquiryDashboardService.java | 3 +- .../impl/InquiryDashboardServiceImpl.java | 346 ++++++++++++++++-- .../impl/InquiryDashboardServiceImplTest.java | 33 ++ .../src/views/inquiry/dashboard/index.vue | 137 ++++++- .../src/views/inquiry/mock/dashboard.ts | 32 +- 5 files changed, 484 insertions(+), 67 deletions(-) create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java 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 @@