合并远程主仓:纳入工单日期筛选、服务端分页与分析看板,保留询价出单。

This commit is contained in:
2026-09-15 09:02:06 +08:00
10 changed files with 859 additions and 141 deletions
@@ -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
*/
@@ -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";
}
@@ -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));
}
}