| 37125 |
vikas |
1 |
package com.spice.profitmandi.service;
|
|
|
2 |
|
|
|
3 |
import com.spice.profitmandi.dao.entity.auth.AuthUser;
|
|
|
4 |
import com.spice.profitmandi.dao.entity.cs.Region;
|
|
|
5 |
import com.spice.profitmandi.dao.entity.user.Lead;
|
|
|
6 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;
|
|
|
7 |
import com.spice.profitmandi.dao.entity.user.LeadActivity;
|
|
|
8 |
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
|
|
|
9 |
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
|
| 37692 |
vikas |
10 |
import com.spice.profitmandi.dao.entity.user.LeadDnd;
|
|
|
11 |
import com.spice.profitmandi.dao.entity.user.LeadLiveLocation;
|
| 37125 |
vikas |
12 |
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
|
| 37692 |
vikas |
13 |
import com.spice.profitmandi.dao.entity.user.LeadCall;
|
|
|
14 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
|
|
|
15 |
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
|
| 37737 |
vikas |
16 |
import com.spice.profitmandi.dao.entity.auth.VonageAgent;
|
|
|
17 |
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
|
|
|
18 |
import com.spice.profitmandi.dao.repository.dtr.LeadRecordingAccessRepository;
|
| 37692 |
vikas |
19 |
import com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository;
|
| 37125 |
vikas |
20 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
|
|
21 |
import org.apache.logging.log4j.LogManager;
|
|
|
22 |
import org.apache.logging.log4j.Logger;
|
|
|
23 |
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
24 |
import org.springframework.stereotype.Service;
|
|
|
25 |
|
|
|
26 |
import java.time.LocalDateTime;
|
|
|
27 |
import java.util.ArrayList;
|
|
|
28 |
import java.util.HashMap;
|
|
|
29 |
import java.util.LinkedHashMap;
|
|
|
30 |
import java.util.List;
|
|
|
31 |
import java.util.Map;
|
|
|
32 |
|
|
|
33 |
/**
|
|
|
34 |
* Read-only aggregation for the LMS management dashboards (SOP §18) — Command Center + Dashboards.
|
|
|
35 |
* Buckets EVERY lead: rows with a real {@link LeadStage} use it; legacy rows derive one in SQL
|
|
|
36 |
* (see {@link LeadRepository#countByDerivedStage}). Returns plain JSON-ready Maps so the controller
|
|
|
37 |
* can {@code gson.toJson(...)} them straight into the page / AJAX endpoints. No mutation, no schema change.
|
|
|
38 |
*
|
|
|
39 |
* <p>Coverage caveat (SOP §18.2): call/recording/order metrics (BGC call volume, RBM servicing, DND) are
|
|
|
40 |
* NOT in the {@code Lead} schema, so those dashboards are surfaced as placeholders by the views. What this
|
|
|
41 |
* service computes is everything derivable from the lead record: stage funnel, SLA compliance, ageing,
|
|
|
42 |
* attention queues and the roll-up KPIs.
|
|
|
43 |
*/
|
|
|
44 |
@Service
|
|
|
45 |
public class LmsDashboardService {
|
|
|
46 |
|
|
|
47 |
private static final Logger LOGGER = LogManager.getLogger(LmsDashboardService.class);
|
|
|
48 |
|
|
|
49 |
/** Ordered happy-path for cumulative "reached" funnel math. */
|
|
|
50 |
private static final LeadStage[] FUNNEL = LeadStage.HAPPY_PATH;
|
|
|
51 |
|
| 37692 |
vikas |
52 |
/** Hard ceiling on one page of the pipeline table — a caller asking for 10,000 rows gets 500. */
|
|
|
53 |
private static final int MAX_PAGE_SIZE = 500;
|
|
|
54 |
|
| 37125 |
vikas |
55 |
/** Leads not touched for this many days count as "ageing" (SOP §18.2). */
|
|
|
56 |
private static final int AGEING_DAYS = 7;
|
|
|
57 |
|
|
|
58 |
/** Most-recent weeks shown in the SLA owner heat-map. */
|
|
|
59 |
private static final int HEATMAP_WEEKS = 6;
|
|
|
60 |
|
| 37743 |
vikas |
61 |
/** Cap on rows returned to a drill-down drawer. Public so the drawer can say it is showing a slice. */
|
| 37749 |
vikas |
62 |
/** Default drawer page size (drill) and the hard cap on search results. */
|
| 37743 |
vikas |
63 |
public static final int DRILL_LIMIT = 100;
|
| 37125 |
vikas |
64 |
|
|
|
65 |
@Autowired
|
|
|
66 |
private LeadRepository leadRepository;
|
|
|
67 |
|
|
|
68 |
@Autowired
|
|
|
69 |
private RegionRepository regionRepository;
|
|
|
70 |
|
|
|
71 |
@Autowired
|
|
|
72 |
private AuthRepository authRepository;
|
|
|
73 |
|
|
|
74 |
@Autowired
|
|
|
75 |
private LmsAssignmentService lmsAssignmentService;
|
|
|
76 |
|
|
|
77 |
@Autowired
|
|
|
78 |
private LeadActivityRepository leadActivityRepository;
|
|
|
79 |
|
| 37692 |
vikas |
80 |
@Autowired
|
|
|
81 |
private LeadCallRepository leadCallRepository;
|
|
|
82 |
|
|
|
83 |
@Autowired
|
|
|
84 |
private LeadDndRepository leadDndRepository;
|
|
|
85 |
|
|
|
86 |
@Autowired
|
| 37737 |
vikas |
87 |
private LeadRecordingAccessRepository leadRecordingAccessRepository;
|
|
|
88 |
|
|
|
89 |
@Autowired
|
|
|
90 |
private VonageAgentRepository vonageAgentRepository;
|
|
|
91 |
|
|
|
92 |
@Autowired
|
| 37692 |
vikas |
93 |
private LeadLiveLocationRepository leadLiveLocationRepository;
|
|
|
94 |
|
| 37125 |
vikas |
95 |
// ---- Command Center + shared summary ----------------------------------------------------
|
|
|
96 |
|
| 37749 |
vikas |
97 |
/**
|
|
|
98 |
* KPIs + lifecycle funnel + attention queue for the given region / created-date window. Stages come
|
|
|
99 |
* from LeadRepository's evidence-aware expression: BEAT_PLANNED / VISITED / ONBOARDED are read off
|
|
|
100 |
* lead_route, location_tracking and LOI + stock payment, never off the hand-set stage column.
|
|
|
101 |
*/
|
| 37125 |
vikas |
102 |
public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {
|
|
|
103 |
Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));
|
|
|
104 |
long total = 0;
|
|
|
105 |
for (Long v : stage.values()) {
|
|
|
106 |
total += v;
|
|
|
107 |
}
|
|
|
108 |
|
|
|
109 |
// Cumulative "reached at least this stage" along the happy path (monotonic, drives the funnel bars).
|
|
|
110 |
long[] reached = new long[FUNNEL.length];
|
|
|
111 |
for (int i = 0; i < FUNNEL.length; i++) {
|
|
|
112 |
long sum = 0;
|
|
|
113 |
for (int j = i; j < FUNNEL.length; j++) {
|
|
|
114 |
sum += stage.getOrDefault(FUNNEL[j].name(), 0L);
|
|
|
115 |
}
|
|
|
116 |
reached[i] = sum;
|
|
|
117 |
}
|
|
|
118 |
|
|
|
119 |
LocalDateTime now = LocalDateTime.now();
|
| 37749 |
vikas |
120 |
Object[] att = leadRepository.attentionCounts(regionId, now, now.plusHours(1), from, to);
|
| 37125 |
vikas |
121 |
long breachedNoContact = num(att, 0);
|
|
|
122 |
long hold = num(att, 1);
|
|
|
123 |
long slaUnder1h = num(att, 2);
|
|
|
124 |
long missingDisp = num(att, 3);
|
|
|
125 |
long qualifiedNoBeat = num(att, 4);
|
|
|
126 |
long onboardedPending = num(att, 5);
|
|
|
127 |
|
|
|
128 |
// Onboarded value (SOP §18.2) = summed potential of ONBOARDED + ACTIVE leads.
|
|
|
129 |
Map<String, Double> potByStage = toSumMap(leadRepository.sumPotentialByDerivedStage(regionId, from, to));
|
|
|
130 |
double onboardedValue = potByStage.getOrDefault("ONBOARDED", 0d) + potByStage.getOrDefault("ACTIVE", 0d);
|
|
|
131 |
|
|
|
132 |
long onboarded = stage.getOrDefault("ONBOARDED", 0L) + stage.getOrDefault("ACTIVE", 0L);
|
|
|
133 |
long assigned = stage.getOrDefault("ASSIGNED", 0L);
|
|
|
134 |
long contacted = stage.getOrDefault("CONTACTED", 0L);
|
|
|
135 |
long qualified = stage.getOrDefault("QUALIFIED", 0L);
|
|
|
136 |
long newCount = stage.getOrDefault("NEW", 0L);
|
|
|
137 |
|
|
|
138 |
// KPI tiles (Command Center).
|
|
|
139 |
List<Map<String, Object>> kpis = new ArrayList<>();
|
|
|
140 |
kpis.add(kpi("new", "New", newCount, "in selected period", "navy"));
|
|
|
141 |
kpis.add(kpi("awaiting", "Awaiting 1st contact", assigned, "SLA 3–5 hrs running", "amber"));
|
|
|
142 |
kpis.add(kpi("sla", "SLA at risk / breached", breachedNoContact + slaUnder1h,
|
|
|
143 |
breachedNoContact + " breached · " + slaUnder1h + " < 1 hr left", "red"));
|
|
|
144 |
kpis.add(kpi("contacted", "Contacted", contacted, "dispositioned", "navy"));
|
|
|
145 |
kpis.add(kpi("qualified", "Qualified", qualified, "value validated", "navy"));
|
| 37749 |
vikas |
146 |
kpis.add(kpi("onboarded", "Onboarded", onboarded, "LOI + stock payment · of " + total + " in scope", "good"));
|
| 37125 |
vikas |
147 |
|
|
|
148 |
// Lifecycle funnel.
|
|
|
149 |
List<Map<String, Object>> funnel = new ArrayList<>();
|
|
|
150 |
long topN = total > 0 ? total : 1;
|
|
|
151 |
// Top of funnel = every lead in scope.
|
|
|
152 |
funnel.add(funnelStep("ALL", "All leads", total, pct(total, topN)));
|
|
|
153 |
for (int i = 1; i < FUNNEL.length; i++) { // skip NEW itself; ALL is the 100% anchor
|
|
|
154 |
LeadStage s = FUNNEL[i];
|
|
|
155 |
funnel.add(funnelStep(s.name(), pretty(s), reached[i], pct(reached[i], topN)));
|
|
|
156 |
}
|
|
|
157 |
|
|
|
158 |
// Attention queue.
|
|
|
159 |
List<Map<String, Object>> attention = new ArrayList<>();
|
|
|
160 |
attention.add(attn("BREACHED_NO_CONTACT", "SLA breached — no first contact", "First contact overdue", breachedNoContact, "red"));
|
|
|
161 |
attention.add(attn("HOLD", "Assigned but BM/RSM not mapped", "Region has no active owner", hold, "red"));
|
|
|
162 |
attention.add(attn("SLA_UNDER_1H", "SLA < 1 hr remaining", "First contact due soon", slaUnder1h, "amber"));
|
|
|
163 |
attention.add(attn("MISSING_DISP", "Calls missing a disposition", "Contacted, not dispositioned", missingDisp, "amber"));
|
|
|
164 |
attention.add(attn("QUALIFIED_NO_BEAT", "Qualified, no beat plan yet", "Awaiting an ASM beat stop", qualifiedNoBeat, "navy"));
|
|
|
165 |
attention.add(attn("ONBOARDED_PENDING", "Onboarded — pending RBM handover", "Handover to post-sales", onboardedPending, "violet"));
|
|
|
166 |
|
|
|
167 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
168 |
out.put("total", total);
|
|
|
169 |
out.put("onboardedValue", onboardedValue);
|
|
|
170 |
out.put("conversionPct", pct(onboarded, topN));
|
|
|
171 |
out.put("kpis", kpis);
|
|
|
172 |
out.put("funnel", funnel);
|
|
|
173 |
out.put("attention", attention);
|
|
|
174 |
return out;
|
|
|
175 |
}
|
|
|
176 |
|
|
|
177 |
// ---- Dashboards: state funnel -----------------------------------------------------------
|
|
|
178 |
|
|
|
179 |
/** One row per region: reached counts across the funnel, conversion % and SLA %. */
|
|
|
180 |
public Map<String, Object> stateFunnel(LocalDateTime from, LocalDateTime to) {
|
|
|
181 |
// regionCode -> (stage -> count)
|
|
|
182 |
Map<String, Map<String, Long>> byRegion = new LinkedHashMap<>();
|
|
|
183 |
for (Object[] row : leadRepository.countByRegionAndDerivedStage(from, to)) {
|
|
|
184 |
String rc = str(row[0]);
|
|
|
185 |
String s = str(row[1]);
|
|
|
186 |
long c = ((Number) row[2]).longValue();
|
|
|
187 |
byRegion.computeIfAbsent(rc, k -> new HashMap<>()).merge(s, c, Long::sum);
|
|
|
188 |
}
|
|
|
189 |
// regionCode -> sla %
|
|
|
190 |
Map<String, Integer> slaByRegion = new HashMap<>();
|
|
|
191 |
for (Object[] row : leadRepository.slaComplianceByRegion(from, to)) {
|
|
|
192 |
String rc = str(row[0]);
|
|
|
193 |
long totalDue = ((Number) row[1]).longValue();
|
|
|
194 |
long met = ((Number) row[2]).longValue();
|
|
|
195 |
slaByRegion.put(rc, (int) pct(met, totalDue));
|
|
|
196 |
}
|
|
|
197 |
// Friendly names from cs.region (keyed by upper region_code).
|
|
|
198 |
Map<String, String> nameByCode = new HashMap<>();
|
|
|
199 |
for (Region r : regionRepository.selectAll()) {
|
|
|
200 |
if (r.getRegionCode() != null && !r.getRegionCode().trim().isEmpty()) {
|
|
|
201 |
nameByCode.put(r.getRegionCode().trim().toUpperCase(), r.getName());
|
|
|
202 |
}
|
|
|
203 |
}
|
|
|
204 |
|
|
|
205 |
List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
206 |
for (Map.Entry<String, Map<String, Long>> e : byRegion.entrySet()) {
|
|
|
207 |
String code = e.getKey();
|
|
|
208 |
Map<String, Long> stage = e.getValue();
|
|
|
209 |
long total = 0;
|
|
|
210 |
for (Long v : stage.values()) {
|
|
|
211 |
total += v;
|
|
|
212 |
}
|
|
|
213 |
long contacted = reachedAtLeast(stage, LeadStage.CONTACTED);
|
|
|
214 |
long qualified = reachedAtLeast(stage, LeadStage.QUALIFIED);
|
| 37749 |
vikas |
215 |
long beatPlanned = reachedAtLeast(stage, LeadStage.BEAT_PLANNED);
|
| 37125 |
vikas |
216 |
long visited = reachedAtLeast(stage, LeadStage.VISITED);
|
|
|
217 |
long onboarded = reachedAtLeast(stage, LeadStage.ONBOARDED);
|
|
|
218 |
|
|
|
219 |
Map<String, Object> r = new LinkedHashMap<>();
|
|
|
220 |
r.put("regionCode", code);
|
|
|
221 |
r.put("regionName", nameByCode.getOrDefault(code, "UNMAPPED".equals(code) ? "Unmapped" : code));
|
|
|
222 |
r.put("newCount", total);
|
|
|
223 |
r.put("contacted", contacted);
|
|
|
224 |
r.put("qualified", qualified);
|
| 37749 |
vikas |
225 |
r.put("beatPlanned", beatPlanned);
|
| 37125 |
vikas |
226 |
r.put("visited", visited);
|
|
|
227 |
r.put("onboarded", onboarded);
|
|
|
228 |
r.put("convPct", pct(onboarded, total));
|
|
|
229 |
r.put("slaPct", slaByRegion.getOrDefault(code, 0));
|
|
|
230 |
rows.add(r);
|
|
|
231 |
}
|
|
|
232 |
rows.sort((a, b) -> Long.compare(((Number) b.get("newCount")).longValue(), ((Number) a.get("newCount")).longValue()));
|
|
|
233 |
|
|
|
234 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
235 |
out.put("rows", rows);
|
|
|
236 |
return out;
|
|
|
237 |
}
|
|
|
238 |
|
|
|
239 |
// ---- Dashboards: SLA owner heat-map -----------------------------------------------------
|
|
|
240 |
|
|
|
241 |
/** BM × recent-week on-time %, for the SLA compliance heat-map. */
|
|
|
242 |
public Map<String, Object> slaHeatmap(LocalDateTime from, LocalDateTime to) {
|
|
|
243 |
// bmId -> (isoYearWeek -> pct)
|
|
|
244 |
Map<Integer, Map<Integer, Integer>> byBm = new LinkedHashMap<>();
|
|
|
245 |
java.util.TreeSet<Integer> weekSet = new java.util.TreeSet<>();
|
|
|
246 |
for (Object[] row : leadRepository.slaComplianceByBmAndWeek(from, to)) {
|
|
|
247 |
Integer bmId = ((Number) row[0]).intValue();
|
|
|
248 |
Integer wk = ((Number) row[1]).intValue();
|
|
|
249 |
long totalDue = ((Number) row[2]).longValue();
|
|
|
250 |
long met = ((Number) row[3]).longValue();
|
|
|
251 |
weekSet.add(wk);
|
|
|
252 |
byBm.computeIfAbsent(bmId, k -> new HashMap<>()).put(wk, (int) pct(met, totalDue));
|
|
|
253 |
}
|
|
|
254 |
// Keep the most recent HEATMAP_WEEKS weeks.
|
|
|
255 |
List<Integer> allWeeks = new ArrayList<>(weekSet);
|
|
|
256 |
List<Integer> weeks = allWeeks.size() > HEATMAP_WEEKS
|
|
|
257 |
? allWeeks.subList(allWeeks.size() - HEATMAP_WEEKS, allWeeks.size())
|
|
|
258 |
: allWeeks;
|
|
|
259 |
|
|
|
260 |
List<Map<String, Object>> bms = new ArrayList<>();
|
|
|
261 |
for (Map.Entry<Integer, Map<Integer, Integer>> e : byBm.entrySet()) {
|
|
|
262 |
AuthUser bm = authRepository.selectById(e.getKey());
|
|
|
263 |
List<Object> cells = new ArrayList<>();
|
|
|
264 |
for (Integer wk : weeks) {
|
|
|
265 |
Integer p = e.getValue().get(wk);
|
|
|
266 |
Map<String, Object> cell = new LinkedHashMap<>();
|
|
|
267 |
cell.put("week", weekLabel(wk));
|
|
|
268 |
cell.put("pct", p); // null → no data that week
|
|
|
269 |
cells.add(cell);
|
|
|
270 |
}
|
|
|
271 |
Map<String, Object> r = new LinkedHashMap<>();
|
|
|
272 |
r.put("bmId", e.getKey());
|
|
|
273 |
r.put("bmName", bm != null ? bm.getFullName() : ("BM #" + e.getKey()));
|
|
|
274 |
r.put("cells", cells);
|
|
|
275 |
bms.add(r);
|
|
|
276 |
}
|
|
|
277 |
|
|
|
278 |
List<String> weekLabels = new ArrayList<>();
|
|
|
279 |
for (Integer wk : weeks) {
|
|
|
280 |
weekLabels.add(weekLabel(wk));
|
|
|
281 |
}
|
|
|
282 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
283 |
out.put("weeks", weekLabels);
|
|
|
284 |
out.put("bms", bms);
|
|
|
285 |
return out;
|
|
|
286 |
}
|
|
|
287 |
|
|
|
288 |
// ---- Dashboards: ageing -----------------------------------------------------------------
|
|
|
289 |
|
|
|
290 |
/** Open leads stuck (untouched > {@value #AGEING_DAYS} days) bucketed by stage. */
|
|
|
291 |
public Map<String, Object> ageing() {
|
|
|
292 |
LocalDateTime olderThan = LocalDateTime.now().minusDays(AGEING_DAYS);
|
|
|
293 |
List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
294 |
long total = 0;
|
|
|
295 |
for (Object[] row : leadRepository.ageingByDerivedStage(olderThan)) {
|
|
|
296 |
String s = str(row[0]);
|
|
|
297 |
long c = ((Number) row[1]).longValue();
|
|
|
298 |
total += c;
|
|
|
299 |
Map<String, Object> r = new LinkedHashMap<>();
|
|
|
300 |
r.put("stage", s);
|
|
|
301 |
r.put("label", pretty(safeStage(s)));
|
|
|
302 |
r.put("count", c);
|
|
|
303 |
rows.add(r);
|
|
|
304 |
}
|
|
|
305 |
rows.sort((a, b) -> Long.compare(((Number) b.get("count")).longValue(), ((Number) a.get("count")).longValue()));
|
|
|
306 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
307 |
out.put("days", AGEING_DAYS);
|
|
|
308 |
out.put("total", total);
|
|
|
309 |
out.put("rows", rows);
|
|
|
310 |
return out;
|
|
|
311 |
}
|
|
|
312 |
|
| 37737 |
vikas |
313 |
// ---- Standard report suite (SOP §18.2) --------------------------------------------------
|
|
|
314 |
|
|
|
315 |
/**
|
|
|
316 |
* BGC Daily: calls made, connect rate, average talk time and dispositions — per agent, per day.
|
|
|
317 |
*
|
|
|
318 |
* <p>Real telephony, not the {@code contacted / days} proxy the tile used to show. That proxy
|
|
|
319 |
* was invented before {@code user.lead_call} existed and reported a plausible-looking number
|
|
|
320 |
* that no call had ever contributed to.
|
|
|
321 |
*/
|
|
|
322 |
public Map<String, Object> bgcDaily(LocalDateTime from, LocalDateTime to) {
|
|
|
323 |
List<Object[]> rows = leadCallRepository.countDailyByAgent(from, to);
|
|
|
324 |
java.util.Set<Integer> authIds = new java.util.HashSet<>();
|
|
|
325 |
for (Object[] r : rows) {
|
|
|
326 |
if (r[0] != null) authIds.add(((Number) r[0]).intValue());
|
|
|
327 |
}
|
|
|
328 |
Map<Integer, AuthUser> agents = new HashMap<>();
|
|
|
329 |
if (!authIds.isEmpty()) {
|
|
|
330 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(authIds))) agents.put(u.getId(), u);
|
|
|
331 |
}
|
|
|
332 |
|
|
|
333 |
List<Map<String, Object>> out = new ArrayList<>();
|
|
|
334 |
long totCalls = 0, totConn = 0, totTalk = 0;
|
|
|
335 |
for (Object[] r : rows) {
|
|
|
336 |
int authId = r[0] == null ? 0 : ((Number) r[0]).intValue();
|
|
|
337 |
long calls = ((Number) r[2]).longValue();
|
|
|
338 |
long conn = ((Number) r[3]).longValue();
|
|
|
339 |
long talk = ((Number) r[4]).longValue();
|
|
|
340 |
totCalls += calls; totConn += conn; totTalk += talk;
|
|
|
341 |
AuthUser a = agents.get(authId);
|
|
|
342 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
343 |
m.put("authId", authId);
|
|
|
344 |
m.put("agent", a != null ? a.getFullName() : ("#" + authId));
|
|
|
345 |
m.put("day", r[1] == null ? null : r[1].toString());
|
|
|
346 |
m.put("calls", calls);
|
|
|
347 |
m.put("connected", conn);
|
|
|
348 |
m.put("connectPct", calls > 0 ? Math.round(conn * 1000.0 / calls) / 10.0 : 0);
|
|
|
349 |
// Average over CONNECTED calls: dividing by every dial would make a day of unanswered
|
|
|
350 |
// ringing look like short conversations rather than no conversations.
|
|
|
351 |
m.put("avgTalkSeconds", conn > 0 ? Math.round(talk / (double) conn) : 0);
|
|
|
352 |
m.put("longestSeconds", ((Number) r[5]).longValue());
|
|
|
353 |
out.add(m);
|
|
|
354 |
}
|
|
|
355 |
|
|
|
356 |
Map<String, Object> res = new LinkedHashMap<>();
|
|
|
357 |
res.put("rows", out);
|
|
|
358 |
res.put("calls", totCalls);
|
|
|
359 |
res.put("connected", totConn);
|
|
|
360 |
res.put("connectPct", totCalls > 0 ? Math.round(totConn * 1000.0 / totCalls) / 10.0 : 0);
|
|
|
361 |
res.put("avgTalkSeconds", totConn > 0 ? Math.round(totTalk / (double) totConn) : 0);
|
|
|
362 |
res.put("talkMinutes", Math.round(totTalk / 60.0));
|
|
|
363 |
return res;
|
|
|
364 |
}
|
|
|
365 |
|
|
|
366 |
/**
|
|
|
367 |
* Audit Hygiene (SOP §17): the breaches an audit review actually looks for.
|
|
|
368 |
*
|
|
|
369 |
* <p>Each figure is either measured or reported as unavailable — never estimated. A hygiene
|
|
|
370 |
* report that guesses is worse than one that admits a gap, because the gap is the finding.
|
|
|
371 |
*/
|
|
|
372 |
public Map<String, Object> auditHygiene(LocalDateTime from, LocalDateTime to) {
|
|
|
373 |
LocalDateTime now = LocalDateTime.now();
|
|
|
374 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
375 |
|
| 37749 |
vikas |
376 |
// Same source and same created-date window as the Command Center's attention tiles, so the
|
|
|
377 |
// two cannot disagree.
|
|
|
378 |
Object[] att = leadRepository.attentionCounts(null, now, now.plusHours(1), from, to);
|
| 37737 |
vikas |
379 |
m.put("slaBreached", num(att, 0));
|
|
|
380 |
m.put("hold", num(att, 1));
|
|
|
381 |
m.put("missingDisposition", num(att, 3));
|
|
|
382 |
|
|
|
383 |
m.put("dndRegister", leadDndRepository.count());
|
|
|
384 |
List<Object[]> breaches = leadCallRepository.selectDndBreaches(from, to, 50);
|
|
|
385 |
List<Map<String, Object>> bl = new ArrayList<>();
|
|
|
386 |
for (Object[] b : breaches) {
|
|
|
387 |
Map<String, Object> row = new LinkedHashMap<>();
|
|
|
388 |
row.put("callId", ((Number) b[0]).intValue());
|
|
|
389 |
row.put("leadId", ((Number) b[1]).intValue());
|
|
|
390 |
row.put("authId", b[2] == null ? 0 : ((Number) b[2]).intValue());
|
|
|
391 |
row.put("mobile", b[3] == null ? null : b[3].toString());
|
|
|
392 |
row.put("when", b[4] == null ? null : b[4].toString());
|
|
|
393 |
bl.add(row);
|
|
|
394 |
}
|
|
|
395 |
m.put("dndBreaches", bl);
|
|
|
396 |
m.put("dndBreachCount", bl.size());
|
|
|
397 |
|
|
|
398 |
m.put("recordingOpens", leadRecordingAccessRepository.countInWindow(from, to, true));
|
|
|
399 |
m.put("recordingDenied", leadRecordingAccessRepository.countInWindow(from, to, false));
|
|
|
400 |
return m;
|
|
|
401 |
}
|
|
|
402 |
|
|
|
403 |
// ---- Live call board ---------------------------------------------------------------------
|
|
|
404 |
|
|
|
405 |
/**
|
|
|
406 |
* A non-terminal call older than this is not believed. Only the browser's 3-second poll
|
|
|
407 |
* terminalises a VBC call, so an agent who closed the tab mid-call leaves a row that is
|
|
|
408 |
* forever RINGING/ANSWERED with a null ended_at. Without this the board would show them On
|
|
|
409 |
* Call indefinitely — wrong in its single most-read column.
|
|
|
410 |
*/
|
|
|
411 |
private static final int LIVE_CALL_STALE_MINUTES = 30;
|
|
|
412 |
/** Just off a call and no outcome logged yet. */
|
|
|
413 |
private static final int WRAP_UP_MINUTES = 2;
|
|
|
414 |
/** No call for this long and the agent reads as idle rather than merely available. */
|
|
|
415 |
private static final int IDLE_MINUTES = 15;
|
|
|
416 |
|
|
|
417 |
/**
|
|
|
418 |
* Per-agent live board (the LMS answer to the RBM Call Target Summary).
|
|
|
419 |
*
|
|
|
420 |
* <p>The roster is {@code auth.vonage_agent}, not every user with leads: an agent without a
|
|
|
421 |
* device mapping cannot dial at all, so they have no place on a call board.
|
|
|
422 |
*
|
|
|
423 |
* <p>Status is derived from call activity — Vonage exposes no presence of any kind, so there is
|
|
|
424 |
* deliberately no Login or Break here rather than a guess dressed up as one.
|
|
|
425 |
*/
|
|
|
426 |
public Map<String, Object> liveCallBoard() {
|
|
|
427 |
LocalDateTime now = LocalDateTime.now();
|
|
|
428 |
LocalDateTime dayStart = now.toLocalDate().atStartOfDay();
|
|
|
429 |
|
|
|
430 |
Map<Integer, Object[]> calls = byAuthId(leadCallRepository.selectLiveAgentBoard(dayStart, now));
|
|
|
431 |
Map<Integer, Object[]> leads = byAuthId(leadRepository.selectAgentLeadCounters(now));
|
|
|
432 |
Map<Integer, Object[]> acts = byAuthId(leadActivityRepository.countAgentActivity(dayStart, now));
|
|
|
433 |
|
|
|
434 |
List<VonageAgent> roster = vonageAgentRepository.selectAll();
|
|
|
435 |
java.util.Set<Integer> ids = new java.util.HashSet<>();
|
|
|
436 |
for (VonageAgent a : roster) {
|
|
|
437 |
ids.add(a.getAuthId());
|
|
|
438 |
}
|
|
|
439 |
Map<Integer, AuthUser> users = new HashMap<>();
|
|
|
440 |
if (!ids.isEmpty()) {
|
|
|
441 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
|
|
|
442 |
users.put(u.getId(), u);
|
|
|
443 |
}
|
|
|
444 |
}
|
|
|
445 |
|
|
|
446 |
List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
447 |
long totCalls = 0, totConn = 0, totTalk = 0, onCallNow = 0;
|
|
|
448 |
|
|
|
449 |
for (VonageAgent agent : roster) {
|
|
|
450 |
int authId = agent.getAuthId();
|
|
|
451 |
Object[] c = calls.get(authId);
|
|
|
452 |
Object[] l = leads.get(authId);
|
|
|
453 |
Object[] a = acts.get(authId);
|
|
|
454 |
AuthUser u = users.get(authId);
|
|
|
455 |
|
|
|
456 |
long cCalls = c == null ? 0 : num(c, 1);
|
|
|
457 |
long cConn = c == null ? 0 : num(c, 2);
|
|
|
458 |
long cTalk = c == null ? 0 : num(c, 3);
|
|
|
459 |
LocalDateTime lastStarted = c == null ? null : ts(c, 5);
|
|
|
460 |
LocalDateTime lastEnded = c == null ? null : ts(c, 6);
|
|
|
461 |
Integer liveCallId = c == null || c[7] == null ? null : ((Number) c[7]).intValue();
|
|
|
462 |
LocalDateTime liveStarted = c == null ? null : ts(c, 9);
|
|
|
463 |
|
|
|
464 |
// A live row past the staleness window is an abandoned tab, not a conversation.
|
|
|
465 |
boolean stale = liveStarted != null && liveStarted.isBefore(now.minusMinutes(LIVE_CALL_STALE_MINUTES));
|
|
|
466 |
boolean live = liveCallId != null && !stale;
|
|
|
467 |
|
|
|
468 |
String status;
|
|
|
469 |
LocalDateTime since;
|
|
|
470 |
if (!agent.isActive()) {
|
|
|
471 |
status = "—";
|
|
|
472 |
since = null;
|
|
|
473 |
} else if (live) {
|
|
|
474 |
status = "On Call";
|
|
|
475 |
since = liveStarted;
|
|
|
476 |
onCallNow++;
|
|
|
477 |
} else if (lastEnded != null && lastEnded.isAfter(now.minusMinutes(WRAP_UP_MINUTES))) {
|
|
|
478 |
status = "Wrap Up";
|
|
|
479 |
since = lastEnded;
|
|
|
480 |
} else if (lastEnded != null && lastEnded.isBefore(now.minusMinutes(IDLE_MINUTES))) {
|
|
|
481 |
status = "Idle";
|
|
|
482 |
since = lastEnded;
|
|
|
483 |
} else if (lastEnded != null) {
|
|
|
484 |
status = "Available";
|
|
|
485 |
since = lastEnded;
|
|
|
486 |
} else {
|
|
|
487 |
status = "Available";
|
|
|
488 |
since = null;
|
|
|
489 |
}
|
|
|
490 |
|
|
|
491 |
Map<String, Object> r = new LinkedHashMap<>();
|
|
|
492 |
r.put("authId", authId);
|
|
|
493 |
r.put("agent", u != null ? u.getFullName() : agent.getVonageUserName());
|
|
|
494 |
r.put("extension", agent.getVbcExtension());
|
|
|
495 |
r.put("status", status);
|
|
|
496 |
// Computed, never stored — the RBM board does the same rather than persisting a clock.
|
|
|
497 |
r.put("sinceSeconds", since == null ? null : java.time.Duration.between(since, now).getSeconds());
|
|
|
498 |
r.put("staleCall", stale);
|
|
|
499 |
r.put("liveCallId", live ? liveCallId : null);
|
|
|
500 |
r.put("liveLeadId", live && c[8] != null ? ((Number) c[8]).intValue() : null);
|
|
|
501 |
r.put("calls", cCalls);
|
|
|
502 |
r.put("connected", cConn);
|
|
|
503 |
r.put("connectPct", cCalls > 0 ? Math.round(cConn * 1000.0 / cCalls) / 10.0 : 0);
|
|
|
504 |
r.put("talkSeconds", cTalk);
|
|
|
505 |
r.put("longestSeconds", c == null ? 0 : num(c, 4));
|
|
|
506 |
r.put("lastCallAt", lastStarted == null ? null : lastStarted.toString());
|
|
|
507 |
r.put("assigned", l == null ? 0 : num(l, 1));
|
|
|
508 |
r.put("slaBreached", l == null ? 0 : num(l, 2));
|
|
|
509 |
r.put("touched", a == null ? 0 : num(a, 1));
|
|
|
510 |
r.put("dispositioned", a == null ? 0 : num(a, 2));
|
|
|
511 |
rows.add(r);
|
|
|
512 |
|
|
|
513 |
totCalls += cCalls; totConn += cConn; totTalk += cTalk;
|
|
|
514 |
}
|
|
|
515 |
|
|
|
516 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
517 |
out.put("rows", rows);
|
|
|
518 |
out.put("agents", rows.size());
|
|
|
519 |
out.put("onCallNow", onCallNow);
|
|
|
520 |
out.put("calls", totCalls);
|
|
|
521 |
out.put("connected", totConn);
|
|
|
522 |
out.put("connectPct", totCalls > 0 ? Math.round(totConn * 1000.0 / totCalls) / 10.0 : 0);
|
|
|
523 |
out.put("talkMinutes", Math.round(totTalk / 60.0));
|
|
|
524 |
out.put("asOf", now.toString());
|
|
|
525 |
return out;
|
|
|
526 |
}
|
|
|
527 |
|
|
|
528 |
/**
|
|
|
529 |
* The full call log for a window — every call, with ring time, talk time, outcome and recording.
|
|
|
530 |
*
|
|
|
531 |
* <p>The agent's extension comes from {@code auth.vonage_agent}, not from the call's
|
|
|
532 |
* {@code from_number}: click2dial addresses the agent by DEVICE, so that column holds an opaque
|
|
|
533 |
* id like {@code VH7NdQbBHcmsR8wFLsiU}, which means nothing to anyone reading a report.
|
|
|
534 |
*/
|
|
|
535 |
public Map<String, Object> callLog(LocalDateTime from, LocalDateTime to, int page, int pageSize) {
|
|
|
536 |
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
|
|
537 |
int p = Math.max(page, 1);
|
|
|
538 |
long total = leadCallRepository.countCallLog(from, to);
|
|
|
539 |
List<Object[]> rows = leadCallRepository.selectCallLog(from, to, (p - 1) * size, size);
|
|
|
540 |
|
|
|
541 |
java.util.Set<Integer> authIds = new java.util.HashSet<>();
|
|
|
542 |
for (Object[] r : rows) {
|
|
|
543 |
if (r[7] != null) {
|
|
|
544 |
authIds.add(((Number) r[7]).intValue());
|
|
|
545 |
}
|
|
|
546 |
}
|
|
|
547 |
Map<Integer, AuthUser> users = new HashMap<>();
|
|
|
548 |
Map<Integer, String> extByAuth = new HashMap<>();
|
|
|
549 |
if (!authIds.isEmpty()) {
|
|
|
550 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(authIds))) {
|
|
|
551 |
users.put(u.getId(), u);
|
|
|
552 |
}
|
|
|
553 |
for (VonageAgent a : vonageAgentRepository.selectAll()) {
|
|
|
554 |
extByAuth.put(a.getAuthId(), a.getVbcExtension());
|
|
|
555 |
}
|
|
|
556 |
}
|
|
|
557 |
|
|
|
558 |
List<Map<String, Object>> out = new ArrayList<>();
|
|
|
559 |
for (Object[] r : rows) {
|
|
|
560 |
int authId = r[7] == null ? 0 : ((Number) r[7]).intValue();
|
|
|
561 |
AuthUser u = users.get(authId);
|
|
|
562 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
563 |
m.put("callId", ((Number) r[0]).intValue());
|
|
|
564 |
m.put("startedAt", str(ts(r, 1)));
|
|
|
565 |
m.put("answeredAt", str(ts(r, 2)));
|
|
|
566 |
m.put("endedAt", str(ts(r, 3)));
|
|
|
567 |
m.put("status", r[4] == null ? null : r[4].toString());
|
|
|
568 |
// Negative or null when never answered — normalise to null so the UI shows a dash.
|
|
|
569 |
Long ring = r[5] == null ? null : ((Number) r[5]).longValue();
|
|
|
570 |
m.put("ringSeconds", ring != null && ring >= 0 ? ring : null);
|
|
|
571 |
m.put("talkSeconds", num(r, 6));
|
|
|
572 |
m.put("authId", authId);
|
|
|
573 |
m.put("agent", u != null ? u.getFullName() : (authId > 0 ? "#" + authId : "—"));
|
|
|
574 |
m.put("extension", extByAuth.get(authId));
|
|
|
575 |
m.put("leadId", r[8] == null ? null : ((Number) r[8]).intValue());
|
|
|
576 |
m.put("toNumber", r[9] == null ? null : r[9].toString());
|
|
|
577 |
m.put("direction", r[10] == null ? null : r[10].toString());
|
|
|
578 |
m.put("lmsCode", r[11] == null ? null : r[11].toString());
|
|
|
579 |
m.put("retailer", r[12] == null ? null : r[12].toString().trim());
|
|
|
580 |
m.put("outlet", r[13] == null ? null : r[13].toString());
|
|
|
581 |
m.put("recording", r[14] == null ? "none" : r[14].toString());
|
|
|
582 |
m.put("disposition", r[15] == null ? null : r[15].toString());
|
|
|
583 |
out.add(m);
|
|
|
584 |
}
|
|
|
585 |
|
|
|
586 |
Map<String, Object> res = new LinkedHashMap<>();
|
|
|
587 |
res.put("rows", out);
|
|
|
588 |
res.put("total", total);
|
|
|
589 |
res.put("page", p);
|
|
|
590 |
res.put("pageSize", size);
|
|
|
591 |
res.put("pages", (int) Math.ceil(total / (double) size));
|
|
|
592 |
return res;
|
|
|
593 |
}
|
|
|
594 |
|
|
|
595 |
private String str(LocalDateTime t) {
|
|
|
596 |
return t == null ? null : t.toString();
|
|
|
597 |
}
|
|
|
598 |
|
|
|
599 |
/** Index an aggregate result by its first column (the auth id). */
|
|
|
600 |
private Map<Integer, Object[]> byAuthId(List<Object[]> rows) {
|
|
|
601 |
Map<Integer, Object[]> m = new HashMap<>();
|
|
|
602 |
if (rows != null) {
|
|
|
603 |
for (Object[] r : rows) {
|
|
|
604 |
if (r[0] != null) {
|
|
|
605 |
m.put(((Number) r[0]).intValue(), r);
|
|
|
606 |
}
|
|
|
607 |
}
|
|
|
608 |
}
|
|
|
609 |
return m;
|
|
|
610 |
}
|
|
|
611 |
|
|
|
612 |
/** Native queries hand back java.sql.Timestamp; the entity side works in LocalDateTime. */
|
|
|
613 |
private LocalDateTime ts(Object[] row, int i) {
|
|
|
614 |
Object v = row[i];
|
|
|
615 |
if (v == null) {
|
|
|
616 |
return null;
|
|
|
617 |
}
|
|
|
618 |
if (v instanceof java.sql.Timestamp) {
|
|
|
619 |
return ((java.sql.Timestamp) v).toLocalDateTime();
|
|
|
620 |
}
|
|
|
621 |
return v instanceof LocalDateTime ? (LocalDateTime) v : null;
|
|
|
622 |
}
|
|
|
623 |
|
| 37125 |
vikas |
624 |
// ---- Drill-down -------------------------------------------------------------------------
|
|
|
625 |
|
| 37749 |
vikas |
626 |
/**
|
|
|
627 |
* One page of lead rows for a drill bucket, shaped for the drawer table, plus the true total so
|
|
|
628 |
* the drawer can page through every lead behind a bar instead of stopping at the first 100.
|
|
|
629 |
*/
|
|
|
630 |
public Map<String, Object> drill(String bucketType, String bucketKey, Integer regionId,
|
|
|
631 |
LocalDateTime from, LocalDateTime to, int page, int pageSize) {
|
|
|
632 |
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
|
|
633 |
int p = Math.max(page, 1);
|
| 37743 |
vikas |
634 |
String type = bucketType == null ? "" : bucketType.trim().toUpperCase();
|
|
|
635 |
String key = bucketKey;
|
|
|
636 |
// The funnel asks for a stage cumulatively -- "reached CONTACTED" means CONTACTED or anything
|
|
|
637 |
// beyond it, which is exactly how the bar was counted. Expand it here, where the happy path
|
|
|
638 |
// already lives, rather than teaching the repository the order of the lifecycle.
|
|
|
639 |
if ("STAGE_REACHED".equals(type)) {
|
|
|
640 |
type = "STAGE_IN";
|
|
|
641 |
key = stagesFrom(bucketKey);
|
|
|
642 |
}
|
| 37125 |
vikas |
643 |
LocalDateTime now = LocalDateTime.now();
|
| 37749 |
vikas |
644 |
long total = leadRepository.countLeadsForBucket(type, key, regionId, now, now.plusHours(1), from, to);
|
| 37743 |
vikas |
645 |
List<Integer> ids = leadRepository.selectLeadIdsForBucket(type, key, regionId,
|
| 37749 |
vikas |
646 |
now, now.plusHours(1), from, to, (p - 1) * size, size);
|
|
|
647 |
List<Map<String, Object>> rows = new ArrayList<>();
|
|
|
648 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
649 |
out.put("leads", rows);
|
|
|
650 |
out.put("total", total);
|
|
|
651 |
out.put("page", p);
|
|
|
652 |
out.put("pageSize", size);
|
|
|
653 |
out.put("pages", (int) Math.ceil(total / (double) size));
|
| 37125 |
vikas |
654 |
if (ids == null || ids.isEmpty()) {
|
|
|
655 |
return out;
|
|
|
656 |
}
|
|
|
657 |
// selectAllByIds returns id-desc; preserve the bucket's newest-first order.
|
|
|
658 |
Map<Integer, Lead> leadById = new HashMap<>();
|
|
|
659 |
for (Lead l : leadRepository.selectAllByIds(ids)) {
|
|
|
660 |
leadById.put(l.getId(), l);
|
|
|
661 |
}
|
| 37651 |
vikas |
662 |
List<Lead> ordered = new ArrayList<>(ids.size());
|
|
|
663 |
for (Integer id : ids) {
|
|
|
664 |
Lead l = leadById.get(id);
|
|
|
665 |
if (l != null) {
|
|
|
666 |
ordered.add(l);
|
|
|
667 |
}
|
|
|
668 |
}
|
| 37749 |
vikas |
669 |
rows.addAll(toRows(ordered));
|
|
|
670 |
return out;
|
| 37651 |
vikas |
671 |
}
|
|
|
672 |
|
|
|
673 |
/**
|
| 37743 |
vikas |
674 |
* The given stage and every stage after it on the happy path, comma-separated -- the set the
|
|
|
675 |
* cumulative funnel bar counted. An unrecognised stage (a terminal one such as NOT_INTERESTED,
|
|
|
676 |
* which is not on the path) passes through unchanged and matches only itself.
|
|
|
677 |
*/
|
|
|
678 |
private String stagesFrom(String stage) {
|
|
|
679 |
String wanted = stage == null ? "" : stage.trim();
|
|
|
680 |
int idx = -1;
|
|
|
681 |
for (int i = 0; i < FUNNEL.length; i++) {
|
|
|
682 |
if (FUNNEL[i].name().equalsIgnoreCase(wanted)) {
|
|
|
683 |
idx = i;
|
|
|
684 |
break;
|
|
|
685 |
}
|
|
|
686 |
}
|
|
|
687 |
if (idx < 0) {
|
|
|
688 |
return wanted;
|
|
|
689 |
}
|
|
|
690 |
StringBuilder sb = new StringBuilder();
|
|
|
691 |
for (int i = idx; i < FUNNEL.length; i++) {
|
|
|
692 |
if (sb.length() > 0) {
|
|
|
693 |
sb.append(',');
|
|
|
694 |
}
|
|
|
695 |
sb.append(FUNNEL[i].name());
|
|
|
696 |
}
|
|
|
697 |
return sb.toString();
|
|
|
698 |
}
|
|
|
699 |
|
|
|
700 |
/**
|
| 37692 |
vikas |
701 |
* The Lead-pipeline table: every matching lead, not a client-side slice of the first page.
|
|
|
702 |
*
|
|
|
703 |
* <p>Returns the page plus the true total, so the table can report "50 of 229" rather than
|
|
|
704 |
* implying the twelve rows it drew are all there are. A non-empty {@code q} searches across all
|
|
|
705 |
* leads and ignores the date window — see {@code LeadRepository.selectPipeline}.
|
|
|
706 |
*/
|
|
|
707 |
public Map<String, Object> pipeline(String q, Integer regionId, LocalDateTime from, LocalDateTime to,
|
|
|
708 |
String sort, String dir, int page, int pageSize) {
|
|
|
709 |
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
|
|
710 |
int p = Math.max(page, 1);
|
|
|
711 |
boolean searching = q != null && !q.trim().isEmpty();
|
|
|
712 |
|
|
|
713 |
long total = leadRepository.countPipeline(q, regionId, from, to);
|
|
|
714 |
List<Lead> leads = leadRepository.selectPipeline(q, regionId, from, to, sort, dir, (p - 1) * size, size);
|
|
|
715 |
|
|
|
716 |
Map<String, Object> out = new LinkedHashMap<>();
|
|
|
717 |
out.put("rows", toRows(leads));
|
|
|
718 |
out.put("total", total);
|
|
|
719 |
out.put("page", p);
|
|
|
720 |
out.put("pageSize", size);
|
|
|
721 |
out.put("pages", (int) Math.ceil(total / (double) size));
|
|
|
722 |
out.put("sort", sort);
|
|
|
723 |
out.put("dir", dir);
|
|
|
724 |
// The caller renders a different caption when the date filter is not in play, so it has to
|
|
|
725 |
// be told which mode the server actually used rather than inferring it.
|
|
|
726 |
out.put("searching", searching);
|
|
|
727 |
return out;
|
|
|
728 |
}
|
|
|
729 |
|
|
|
730 |
/**
|
| 37651 |
vikas |
731 |
* Free-text lookup for the dashboard search box: LMS id / retailer / business / mobile / city.
|
|
|
732 |
* Same row shape as {@link #drill}, so results render in the same drawer table.
|
|
|
733 |
*/
|
|
|
734 |
public List<Map<String, Object>> search(String term) {
|
|
|
735 |
if (term == null || term.trim().isEmpty()) {
|
|
|
736 |
return new ArrayList<>();
|
|
|
737 |
}
|
|
|
738 |
return toRows(leadRepository.selectByGlobalSearch(term.trim(), DRILL_LIMIT));
|
|
|
739 |
}
|
|
|
740 |
|
|
|
741 |
/** Shared drawer-row mapping for {@link #drill} and {@link #search}; owner names fetched in one batch. */
|
|
|
742 |
private List<Map<String, Object>> toRows(List<Lead> leads) {
|
|
|
743 |
List<Map<String, Object>> out = new ArrayList<>();
|
|
|
744 |
if (leads == null || leads.isEmpty()) {
|
|
|
745 |
return out;
|
|
|
746 |
}
|
| 37125 |
vikas |
747 |
// Owner names in one batch.
|
|
|
748 |
java.util.Set<Integer> ownerIds = new java.util.HashSet<>();
|
| 37651 |
vikas |
749 |
for (Lead l : leads) {
|
| 37125 |
vikas |
750 |
if (l.getAssignTo() > 0) {
|
|
|
751 |
ownerIds.add(l.getAssignTo());
|
|
|
752 |
}
|
|
|
753 |
}
|
|
|
754 |
Map<Integer, AuthUser> owners = new HashMap<>();
|
|
|
755 |
if (!ownerIds.isEmpty()) {
|
|
|
756 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ownerIds))) {
|
|
|
757 |
owners.put(u.getId(), u);
|
|
|
758 |
}
|
|
|
759 |
}
|
| 37749 |
vikas |
760 |
// Evidence-aware stage in one batch, so a row says the same thing as the bar it came from.
|
|
|
761 |
List<Integer> leadIds = new ArrayList<>(leads.size());
|
|
|
762 |
for (Lead l : leads) {
|
|
|
763 |
leadIds.add(l.getId());
|
|
|
764 |
}
|
|
|
765 |
Map<Integer, String> stages = leadRepository.selectEffectiveStages(leadIds);
|
| 37125 |
vikas |
766 |
|
| 37651 |
vikas |
767 |
for (Lead l : leads) {
|
| 37749 |
vikas |
768 |
LeadStage eff = effectiveStage(l, stages);
|
| 37125 |
vikas |
769 |
AuthUser owner = owners.get(l.getAssignTo());
|
|
|
770 |
Map<String, Object> r = new LinkedHashMap<>();
|
|
|
771 |
r.put("id", l.getId());
|
|
|
772 |
r.put("lmsCode", l.getLmsCode() != null ? l.getLmsCode() : ("Lead #" + l.getId()));
|
|
|
773 |
r.put("name", trimName(l.getFirstName(), l.getLastName()));
|
|
|
774 |
r.put("outlet", l.getOutLetName());
|
|
|
775 |
r.put("path", l.getCreationPath());
|
|
|
776 |
r.put("stage", eff != null ? eff.name() : null);
|
|
|
777 |
r.put("stageLabel", pretty(eff));
|
|
|
778 |
r.put("owner", owner != null ? owner.getFullName() : (l.getAssignTo() > 0 ? "#" + l.getAssignTo() : "—"));
|
|
|
779 |
r.put("slaState", lmsAssignmentService.slaState(l));
|
|
|
780 |
r.put("value", l.getPotential());
|
| 37692 |
vikas |
781 |
// Both are sortable columns on the pipeline table, so the row has to carry them.
|
|
|
782 |
r.put("city", l.getCity());
|
|
|
783 |
r.put("createdBy", l.getCreatedBy());
|
| 37737 |
vikas |
784 |
// Lets the assign picker surface whoever covers this lead's region first.
|
|
|
785 |
r.put("regionId", l.getRegionId());
|
| 37125 |
vikas |
786 |
out.add(r);
|
|
|
787 |
}
|
|
|
788 |
return out;
|
|
|
789 |
}
|
|
|
790 |
|
|
|
791 |
// ---- Lead Record (native, sts-mockup styled — read-only JSON) ---------------------------
|
|
|
792 |
|
|
|
793 |
/** Full record for one lead: details + derived stage + SLA + append-only trail. Null if not found. */
|
|
|
794 |
public Map<String, Object> record(int leadId) {
|
|
|
795 |
Lead l = leadRepository.selectById(leadId);
|
|
|
796 |
if (l == null) {
|
|
|
797 |
return null;
|
|
|
798 |
}
|
| 37749 |
vikas |
799 |
LeadStage eff = effectiveStage(l, leadRepository.selectEffectiveStages(java.util.Collections.singletonList(leadId)));
|
| 37125 |
vikas |
800 |
int stageIndex = -1;
|
|
|
801 |
for (int i = 0; i < FUNNEL.length; i++) {
|
|
|
802 |
if (FUNNEL[i] == eff) {
|
|
|
803 |
stageIndex = i;
|
|
|
804 |
}
|
|
|
805 |
}
|
|
|
806 |
boolean terminal = eff == LeadStage.NOT_INTERESTED || eff == LeadStage.DROPPED;
|
|
|
807 |
|
|
|
808 |
AuthUser owner = l.getAssignTo() > 0 ? authRepository.selectById(l.getAssignTo()) : null;
|
|
|
809 |
AuthUser bm = (l.getOwnerBmId() != null && l.getOwnerBmId() > 0) ? authRepository.selectById(l.getOwnerBmId()) : null;
|
|
|
810 |
|
|
|
811 |
// Append-only trail, newest first.
|
|
|
812 |
List<LeadActivity> acts = leadActivityRepository.selectBYLeadId(leadId);
|
|
|
813 |
acts.sort((a, b) -> {
|
|
|
814 |
LocalDateTime ta = a.getCreatedTimestamp(), tb = b.getCreatedTimestamp();
|
|
|
815 |
if (ta == null && tb == null) return 0;
|
|
|
816 |
if (ta == null) return 1;
|
|
|
817 |
if (tb == null) return -1;
|
|
|
818 |
return tb.compareTo(ta);
|
|
|
819 |
});
|
|
|
820 |
// Actor names in one batch.
|
|
|
821 |
java.util.Set<Integer> ids = new java.util.HashSet<>();
|
|
|
822 |
for (LeadActivity a : acts) {
|
|
|
823 |
if (a.getAuthId() > 0) ids.add(a.getAuthId());
|
|
|
824 |
}
|
|
|
825 |
Map<Integer, AuthUser> actors = new HashMap<>();
|
|
|
826 |
if (!ids.isEmpty()) {
|
|
|
827 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
|
|
|
828 |
actors.put(u.getId(), u);
|
|
|
829 |
}
|
|
|
830 |
}
|
| 37692 |
vikas |
831 |
// Calls keyed by id, so a disposition in the trail can carry the recording of the call it came
|
|
|
832 |
// from. A disposition is the agent's account of the conversation; having to hunt for the audio
|
|
|
833 |
// in a separate list to check it against the recording is the friction worth removing.
|
|
|
834 |
Map<Integer, LeadCall> callById = new HashMap<>();
|
|
|
835 |
List<LeadCall> calls = leadCallRepository.selectByLeadId(leadId);
|
|
|
836 |
if (calls != null) {
|
|
|
837 |
for (LeadCall c : calls) {
|
|
|
838 |
callById.put(c.getId(), c);
|
|
|
839 |
}
|
|
|
840 |
}
|
|
|
841 |
|
| 37125 |
vikas |
842 |
List<Map<String, Object>> trail = new ArrayList<>();
|
|
|
843 |
for (LeadActivity a : acts) {
|
|
|
844 |
AuthUser act = actors.get(a.getAuthId());
|
|
|
845 |
Map<String, Object> t = new LinkedHashMap<>();
|
|
|
846 |
t.put("when", a.getCreatedTimestamp() == null ? null : a.getCreatedTimestamp().toString());
|
|
|
847 |
t.put("actor", act != null ? act.getFullName() : (a.getAuthId() > 0 ? "#" + a.getAuthId() : "System"));
|
|
|
848 |
t.put("type", a.getCommunicationType() == null ? null : a.getCommunicationType().name());
|
|
|
849 |
t.put("body", a.getRemark());
|
|
|
850 |
t.put("scheduled", a.getSchelduleTimestamp() == null ? null : a.getSchelduleTimestamp().toString());
|
| 37692 |
vikas |
851 |
|
|
|
852 |
// Either handle is enough to play: the gated endpoint serves our archived copy when there
|
|
|
853 |
// is one and proxies the vendor's otherwise, so an agent is not made to wait on the
|
|
|
854 |
// archival sweep to hear a call. The URL is never the raw vendor one — playback goes
|
|
|
855 |
// through /lms/recording/{id}, which enforces the §15 matrix and logs the open.
|
|
|
856 |
LeadCall call = a.getLeadCallId() == null ? null : callById.get(a.getLeadCallId());
|
|
|
857 |
if (call != null && (call.getRecordingObjectKey() != null || call.getRecordingUuid() != null)) {
|
|
|
858 |
t.put("callId", call.getId());
|
|
|
859 |
t.put("durationSeconds", call.getDurationSeconds());
|
|
|
860 |
t.put("archived", call.getRecordingObjectKey() != null);
|
|
|
861 |
}
|
| 37125 |
vikas |
862 |
trail.add(t);
|
|
|
863 |
}
|
|
|
864 |
|
|
|
865 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
866 |
m.put("id", l.getId());
|
|
|
867 |
m.put("lmsCode", l.getLmsCode());
|
|
|
868 |
m.put("name", trimName(l.getFirstName(), l.getLastName()));
|
|
|
869 |
m.put("outlet", l.getOutLetName());
|
|
|
870 |
m.put("stage", eff != null ? eff.name() : null);
|
|
|
871 |
m.put("stageLabel", pretty(eff));
|
|
|
872 |
m.put("stageIndex", stageIndex);
|
|
|
873 |
m.put("terminal", terminal);
|
|
|
874 |
m.put("created", l.getCreatedTimestamp() == null ? null : l.getCreatedTimestamp().toString());
|
|
|
875 |
m.put("createdBy", owner != null ? owner.getFullName() : null); // best-effort
|
|
|
876 |
m.put("path", l.getCreationPath());
|
|
|
877 |
m.put("contact", l.getLeadMobile());
|
|
|
878 |
m.put("location", joinLoc(l.getAddress(), l.getCity(), l.getState()));
|
|
|
879 |
m.put("regionCode", l.getRegionCode());
|
|
|
880 |
m.put("value", l.getPotential());
|
|
|
881 |
m.put("source", l.getSource());
|
|
|
882 |
m.put("disposition", l.getDisposition() == null ? null : l.getDisposition().name());
|
|
|
883 |
m.put("ownerBm", bm != null ? bm.getFullName() : null);
|
|
|
884 |
m.put("owner", owner != null ? owner.getFullName() : null);
|
|
|
885 |
m.put("assignmentStatus", l.getAssignmentStatus());
|
|
|
886 |
m.put("slaState", lmsAssignmentService.slaState(l));
|
|
|
887 |
m.put("firstContactDue", l.getFirstContactDue() == null ? null : l.getFirstContactDue().toString());
|
|
|
888 |
m.put("firstContactedAt", l.getFirstContactedAt() == null ? null : l.getFirstContactedAt().toString());
|
|
|
889 |
m.put("unreachableCount", l.getUnreachableCount());
|
| 37692 |
vikas |
890 |
|
|
|
891 |
// The dashboard's record tab acts on the lead rather than only displaying it, so it needs
|
|
|
892 |
// the same three things the in-app screen reads before it offers a control:
|
|
|
893 |
// - the parts of the address, not just the joined string, or an edit would have to
|
|
|
894 |
// re-split "addr, city, state" and would corrupt any value containing a comma;
|
|
|
895 |
// - the region id, so the edit form can preselect it (regionCode alone cannot);
|
|
|
896 |
// - whether the number is blocked and whether the geo-pin is approved, so the call and
|
|
|
897 |
// beat controls can say why they are unavailable instead of failing on submit.
|
|
|
898 |
m.put("regionId", l.getRegionId());
|
|
|
899 |
m.put("address", l.getAddress());
|
|
|
900 |
m.put("city", l.getCity());
|
|
|
901 |
m.put("state", l.getState());
|
|
|
902 |
|
|
|
903 |
LeadDnd dnd = leadDndRepository.selectByMobile(l.getLeadMobile());
|
|
|
904 |
m.put("dnd", dnd != null);
|
|
|
905 |
m.put("dndReason", dnd == null ? null : dnd.getReason());
|
|
|
906 |
|
|
|
907 |
// The pin itself, not just a yes/no: the record card offers a map link when it is approved
|
|
|
908 |
// and pre-fills the manual-verify form with the existing coords when it is not, so nudging
|
|
|
909 |
// a slightly-off pin does not mean typing it from scratch.
|
|
|
910 |
LeadLiveLocation geo = leadLiveLocationRepository.selectByLeadId(leadId);
|
|
|
911 |
m.put("geoApproved", geo != null && geo.getStatus() != null
|
|
|
912 |
&& "APPROVED".equalsIgnoreCase(geo.getStatus().toString()));
|
|
|
913 |
m.put("geoStatus", geo == null || geo.getStatus() == null ? null : geo.getStatus().toString());
|
|
|
914 |
if (geo != null && (geo.getLatitude() != 0 || geo.getLongitude() != 0)) {
|
|
|
915 |
m.put("geoLat", geo.getLatitude());
|
|
|
916 |
m.put("geoLng", geo.getLongitude());
|
|
|
917 |
}
|
|
|
918 |
m.put("geoPhotoId", geo == null ? 0 : geo.getImageDocumentId());
|
|
|
919 |
|
| 37125 |
vikas |
920 |
m.put("trail", trail);
|
|
|
921 |
return m;
|
|
|
922 |
}
|
|
|
923 |
|
|
|
924 |
private String joinLoc(String a, String c, String s) {
|
|
|
925 |
StringBuilder sb = new StringBuilder();
|
|
|
926 |
for (String p : new String[]{a, c, s}) {
|
|
|
927 |
if (p != null && !p.trim().isEmpty()) {
|
|
|
928 |
if (sb.length() > 0) sb.append(", ");
|
|
|
929 |
sb.append(p.trim());
|
|
|
930 |
}
|
|
|
931 |
}
|
|
|
932 |
return sb.toString();
|
|
|
933 |
}
|
|
|
934 |
|
|
|
935 |
// ---- helpers ----------------------------------------------------------------------------
|
|
|
936 |
|
|
|
937 |
private long reachedAtLeast(Map<String, Long> stage, LeadStage at) {
|
|
|
938 |
long sum = 0;
|
|
|
939 |
boolean counting = false;
|
|
|
940 |
for (LeadStage s : FUNNEL) {
|
|
|
941 |
if (s == at) {
|
|
|
942 |
counting = true;
|
|
|
943 |
}
|
|
|
944 |
if (counting) {
|
|
|
945 |
sum += stage.getOrDefault(s.name(), 0L);
|
|
|
946 |
}
|
|
|
947 |
}
|
|
|
948 |
return sum;
|
|
|
949 |
}
|
|
|
950 |
|
|
|
951 |
private Map<String, Long> toCountMap(List<Object[]> rows) {
|
|
|
952 |
Map<String, Long> m = new HashMap<>();
|
|
|
953 |
for (Object[] r : rows) {
|
|
|
954 |
if (r[0] == null) {
|
|
|
955 |
continue;
|
|
|
956 |
}
|
|
|
957 |
m.merge(String.valueOf(r[0]), ((Number) r[1]).longValue(), Long::sum);
|
|
|
958 |
}
|
|
|
959 |
return m;
|
|
|
960 |
}
|
|
|
961 |
|
|
|
962 |
private Map<String, Double> toSumMap(List<Object[]> rows) {
|
|
|
963 |
Map<String, Double> m = new HashMap<>();
|
|
|
964 |
for (Object[] r : rows) {
|
|
|
965 |
if (r[0] == null) {
|
|
|
966 |
continue;
|
|
|
967 |
}
|
|
|
968 |
double v = r[1] == null ? 0d : ((Number) r[1]).doubleValue();
|
|
|
969 |
m.merge(String.valueOf(r[0]), v, Double::sum);
|
|
|
970 |
}
|
|
|
971 |
return m;
|
|
|
972 |
}
|
|
|
973 |
|
|
|
974 |
private long num(Object[] row, int i) {
|
|
|
975 |
if (row == null || i >= row.length || row[i] == null) {
|
|
|
976 |
return 0L;
|
|
|
977 |
}
|
|
|
978 |
return ((Number) row[i]).longValue();
|
|
|
979 |
}
|
|
|
980 |
|
|
|
981 |
private long pct(long part, long whole) {
|
|
|
982 |
if (whole <= 0) {
|
|
|
983 |
return 0;
|
|
|
984 |
}
|
|
|
985 |
return Math.round((part * 100.0) / whole);
|
|
|
986 |
}
|
|
|
987 |
|
|
|
988 |
private String str(Object o) {
|
|
|
989 |
return o == null ? "" : String.valueOf(o);
|
|
|
990 |
}
|
|
|
991 |
|
|
|
992 |
private Map<String, Object> kpi(String key, String label, long value, String foot, String tone) {
|
|
|
993 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
994 |
m.put("key", key);
|
|
|
995 |
m.put("label", label);
|
|
|
996 |
m.put("value", value);
|
|
|
997 |
m.put("foot", foot);
|
|
|
998 |
m.put("tone", tone);
|
|
|
999 |
return m;
|
|
|
1000 |
}
|
|
|
1001 |
|
|
|
1002 |
private Map<String, Object> funnelStep(String stage, String label, long count, long pct) {
|
|
|
1003 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
1004 |
m.put("stage", stage);
|
|
|
1005 |
m.put("label", label);
|
|
|
1006 |
m.put("count", count);
|
|
|
1007 |
m.put("pct", pct);
|
|
|
1008 |
return m;
|
|
|
1009 |
}
|
|
|
1010 |
|
|
|
1011 |
private Map<String, Object> attn(String key, String title, String detail, long count, String tone) {
|
|
|
1012 |
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
1013 |
m.put("key", key);
|
|
|
1014 |
m.put("title", title);
|
|
|
1015 |
m.put("detail", detail);
|
|
|
1016 |
m.put("count", count);
|
|
|
1017 |
m.put("tone", tone);
|
|
|
1018 |
return m;
|
|
|
1019 |
}
|
|
|
1020 |
|
|
|
1021 |
private String trimName(String first, String last) {
|
|
|
1022 |
String f = first == null ? "" : first.trim();
|
|
|
1023 |
String l = last == null ? "" : last.trim();
|
|
|
1024 |
return (f + " " + l).trim();
|
|
|
1025 |
}
|
|
|
1026 |
|
| 37749 |
vikas |
1027 |
/**
|
|
|
1028 |
* The dashboard's stage for a lead: the evidence-aware SQL value when the batch has it, else the
|
|
|
1029 |
* column-only fallback (a lead deleted between the two queries, or an unparseable value).
|
|
|
1030 |
*/
|
|
|
1031 |
private LeadStage effectiveStage(Lead l, Map<Integer, String> stages) {
|
|
|
1032 |
LeadStage fromSql = stages == null ? null : safeStage(stages.get(l.getId()));
|
|
|
1033 |
return fromSql != null ? fromSql : l.getEffectiveStage();
|
|
|
1034 |
}
|
|
|
1035 |
|
| 37125 |
vikas |
1036 |
private LeadStage safeStage(String s) {
|
| 37749 |
vikas |
1037 |
if (s == null) {
|
|
|
1038 |
return null;
|
|
|
1039 |
}
|
| 37125 |
vikas |
1040 |
try {
|
|
|
1041 |
return LeadStage.valueOf(s);
|
|
|
1042 |
} catch (Exception e) {
|
|
|
1043 |
return null;
|
|
|
1044 |
}
|
|
|
1045 |
}
|
|
|
1046 |
|
|
|
1047 |
/** "NEW" → "New", "BEAT_PLANNED" → "Beat planned". */
|
|
|
1048 |
private String pretty(LeadStage s) {
|
|
|
1049 |
if (s == null) {
|
|
|
1050 |
return "—";
|
|
|
1051 |
}
|
|
|
1052 |
String[] parts = s.name().toLowerCase().split("_");
|
|
|
1053 |
StringBuilder sb = new StringBuilder();
|
|
|
1054 |
for (int i = 0; i < parts.length; i++) {
|
|
|
1055 |
if (parts[i].isEmpty()) {
|
|
|
1056 |
continue;
|
|
|
1057 |
}
|
|
|
1058 |
if (i == 0) {
|
|
|
1059 |
sb.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
|
|
|
1060 |
} else {
|
|
|
1061 |
sb.append(' ').append(parts[i]);
|
|
|
1062 |
}
|
|
|
1063 |
}
|
|
|
1064 |
return sb.toString();
|
|
|
1065 |
}
|
|
|
1066 |
|
|
|
1067 |
/** ISO yyyyww (YEARWEEK mode 3) → a short "w<week>" label. */
|
|
|
1068 |
private String weekLabel(int isoYearWeek) {
|
|
|
1069 |
int week = isoYearWeek % 100;
|
|
|
1070 |
return "W" + week;
|
|
|
1071 |
}
|
|
|
1072 |
}
|