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/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index 3cbc642..3f71393 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -18,9 +18,13 @@ import { } from "/@/views/inquiry/account/account.api"; import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue"; import AdminModal from "/@/views/inquiry/components/AdminModal.vue"; +import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue"; const rows = ref([]); const loading = ref(false); +const total = ref(0); +const pageNo = ref(1); +const pageSize = ref(10); const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]); const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" }); const modalOpen = ref(false); @@ -35,15 +39,6 @@ const form = reactive({ password: "", }); -const filtered = computed(() => - rows.value.filter((r) => { - const kw = filters.keyword.trim().toLowerCase(); - if (kw && !`${r.username}${r.name}${r.wecomUserId}${r.phoneMasked}`.toLowerCase().includes(kw)) return false; - if (filters.status && r.status !== filters.status) return false; - return true; - }), -); - const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号")); const modalSub = computed(() => editing.value ? `正在编辑:${editing.value.username}` : "创建账号并绑定角色", @@ -67,8 +62,28 @@ async function loadList() { loading.value = true; try { if (!roleOptions.value.length) await loadRoles(); - const res: any = await listUsers({ pageNo: 1, pageSize: 200 }); - const records = res?.records || res?.result?.records || []; + const kw = filters.keyword.trim(); + // Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。 + const params: Record = { + pageNo: pageNo.value, + pageSize: pageSize.value, + }; + if (kw) { + if (/[\u4e00-\u9fff]/.test(kw)) { + params.realname = `*${kw}*`; + } else if (/^\d{5,}$/.test(kw)) { + params.phone = `*${kw}*`; + } else { + params.username = `*${kw}*`; + } + } + if (filters.status === "active") params.status = 1; + if (filters.status === "disabled") params.status = 2; + + const res: any = await listUsers(params); + const page = res?.records ? res : res?.result || {}; + const records = page.records || []; + total.value = Number(page.total || records.length || 0); const mapped: AccountRow[] = []; for (const u of records as any[]) { let roleId = ""; @@ -89,6 +104,11 @@ async function loadList() { mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag)); } rows.value = mapped; + const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1); + if (pageNo.value > maxPage) { + pageNo.value = maxPage; + await loadList(); + } } catch (e: any) { message.error(e?.message || "加载账号失败"); } finally { @@ -96,6 +116,20 @@ async function loadList() { } } +let accountSearchTimer: ReturnType | null = null; +watch( + () => [filters.keyword, filters.status], + () => { + pageNo.value = 1; + if (accountSearchTimer) clearTimeout(accountSearchTimer); + accountSearchTimer = setTimeout(() => loadList(), 250); + }, +); + +function onPageChange() { + loadList(); +} + onMounted(() => loadList()); function openCreate() { @@ -225,13 +259,6 @@ function removeAccount(row: AccountRow) { }, }); } - -watch( - () => filters.keyword, - () => { - /* 本地过滤即可,账号量通常很小 */ - }, -);