Subversion Repositories SmartDukaan

Rev

Rev 37743 | Rev 37759 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
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
}