Subversion Repositories SmartDukaan

Rev

Rev 37125 | Rev 37692 | 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;
10
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
11
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
12
import org.apache.logging.log4j.LogManager;
13
import org.apache.logging.log4j.Logger;
14
import org.springframework.beans.factory.annotation.Autowired;
15
import org.springframework.stereotype.Service;
16
 
17
import java.time.LocalDateTime;
18
import java.util.ArrayList;
19
import java.util.HashMap;
20
import java.util.LinkedHashMap;
21
import java.util.List;
22
import java.util.Map;
23
 
24
/**
25
 * Read-only aggregation for the LMS management dashboards (SOP §18) — Command Center + Dashboards.
26
 * Buckets EVERY lead: rows with a real {@link LeadStage} use it; legacy rows derive one in SQL
27
 * (see {@link LeadRepository#countByDerivedStage}). Returns plain JSON-ready Maps so the controller
28
 * can {@code gson.toJson(...)} them straight into the page / AJAX endpoints. No mutation, no schema change.
29
 *
30
 * <p>Coverage caveat (SOP §18.2): call/recording/order metrics (BGC call volume, RBM servicing, DND) are
31
 * NOT in the {@code Lead} schema, so those dashboards are surfaced as placeholders by the views. What this
32
 * service computes is everything derivable from the lead record: stage funnel, SLA compliance, ageing,
33
 * attention queues and the roll-up KPIs.
34
 */
35
@Service
36
public class LmsDashboardService {
37
 
38
    private static final Logger LOGGER = LogManager.getLogger(LmsDashboardService.class);
39
 
40
    /** Ordered happy-path for cumulative "reached" funnel math. */
41
    private static final LeadStage[] FUNNEL = LeadStage.HAPPY_PATH;
42
 
43
    /** Leads not touched for this many days count as "ageing" (SOP §18.2). */
44
    private static final int AGEING_DAYS = 7;
45
 
46
    /** Most-recent weeks shown in the SLA owner heat-map. */
47
    private static final int HEATMAP_WEEKS = 6;
48
 
49
    /** Cap on rows returned to a drill-down drawer. */
50
    private static final int DRILL_LIMIT = 100;
51
 
52
    @Autowired
53
    private LeadRepository leadRepository;
54
 
55
    @Autowired
56
    private RegionRepository regionRepository;
57
 
58
    @Autowired
59
    private AuthRepository authRepository;
60
 
61
    @Autowired
62
    private LmsAssignmentService lmsAssignmentService;
63
 
64
    @Autowired
65
    private LeadActivityRepository leadActivityRepository;
66
 
67
    // ---- Command Center + shared summary ----------------------------------------------------
68
 
69
    /** KPIs + lifecycle funnel + attention queue for the given region / created-date window. */
70
    public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {
71
        Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));
72
        long total = 0;
73
        for (Long v : stage.values()) {
74
            total += v;
75
        }
76
 
77
        // Cumulative "reached at least this stage" along the happy path (monotonic, drives the funnel bars).
78
        long[] reached = new long[FUNNEL.length];
79
        for (int i = 0; i < FUNNEL.length; i++) {
80
            long sum = 0;
81
            for (int j = i; j < FUNNEL.length; j++) {
82
                sum += stage.getOrDefault(FUNNEL[j].name(), 0L);
83
            }
84
            reached[i] = sum;
85
        }
86
 
87
        LocalDateTime now = LocalDateTime.now();
88
        Object[] att = leadRepository.attentionCounts(regionId, now, now.plusHours(1));
89
        long breachedNoContact = num(att, 0);
90
        long hold = num(att, 1);
91
        long slaUnder1h = num(att, 2);
92
        long missingDisp = num(att, 3);
93
        long qualifiedNoBeat = num(att, 4);
94
        long onboardedPending = num(att, 5);
95
 
96
        // Onboarded value (SOP §18.2) = summed potential of ONBOARDED + ACTIVE leads.
97
        Map<String, Double> potByStage = toSumMap(leadRepository.sumPotentialByDerivedStage(regionId, from, to));
98
        double onboardedValue = potByStage.getOrDefault("ONBOARDED", 0d) + potByStage.getOrDefault("ACTIVE", 0d);
99
 
100
        long onboarded = stage.getOrDefault("ONBOARDED", 0L) + stage.getOrDefault("ACTIVE", 0L);
101
        long assigned = stage.getOrDefault("ASSIGNED", 0L);
102
        long contacted = stage.getOrDefault("CONTACTED", 0L);
103
        long qualified = stage.getOrDefault("QUALIFIED", 0L);
104
        long newCount = stage.getOrDefault("NEW", 0L);
105
 
106
        // KPI tiles (Command Center).
107
        List<Map<String, Object>> kpis = new ArrayList<>();
108
        kpis.add(kpi("new", "New", newCount, "in selected period", "navy"));
109
        kpis.add(kpi("awaiting", "Awaiting 1st contact", assigned, "SLA 3–5 hrs running", "amber"));
110
        kpis.add(kpi("sla", "SLA at risk / breached", breachedNoContact + slaUnder1h,
111
                breachedNoContact + " breached · " + slaUnder1h + " < 1 hr left", "red"));
112
        kpis.add(kpi("contacted", "Contacted", contacted, "dispositioned", "navy"));
113
        kpis.add(kpi("qualified", "Qualified", qualified, "value validated", "navy"));
114
        kpis.add(kpi("onboarded", "Onboarded", onboarded, "of " + total + " in scope", "good"));
115
 
116
        // Lifecycle funnel.
117
        List<Map<String, Object>> funnel = new ArrayList<>();
118
        long topN = total > 0 ? total : 1;
119
        // Top of funnel = every lead in scope.
120
        funnel.add(funnelStep("ALL", "All leads", total, pct(total, topN)));
121
        for (int i = 1; i < FUNNEL.length; i++) { // skip NEW itself; ALL is the 100% anchor
122
            LeadStage s = FUNNEL[i];
123
            funnel.add(funnelStep(s.name(), pretty(s), reached[i], pct(reached[i], topN)));
124
        }
125
 
126
        // Attention queue.
127
        List<Map<String, Object>> attention = new ArrayList<>();
128
        attention.add(attn("BREACHED_NO_CONTACT", "SLA breached — no first contact", "First contact overdue", breachedNoContact, "red"));
129
        attention.add(attn("HOLD", "Assigned but BM/RSM not mapped", "Region has no active owner", hold, "red"));
130
        attention.add(attn("SLA_UNDER_1H", "SLA < 1 hr remaining", "First contact due soon", slaUnder1h, "amber"));
131
        attention.add(attn("MISSING_DISP", "Calls missing a disposition", "Contacted, not dispositioned", missingDisp, "amber"));
132
        attention.add(attn("QUALIFIED_NO_BEAT", "Qualified, no beat plan yet", "Awaiting an ASM beat stop", qualifiedNoBeat, "navy"));
133
        attention.add(attn("ONBOARDED_PENDING", "Onboarded — pending RBM handover", "Handover to post-sales", onboardedPending, "violet"));
134
 
135
        Map<String, Object> out = new LinkedHashMap<>();
136
        out.put("total", total);
137
        out.put("onboardedValue", onboardedValue);
138
        out.put("conversionPct", pct(onboarded, topN));
139
        out.put("kpis", kpis);
140
        out.put("funnel", funnel);
141
        out.put("attention", attention);
142
        return out;
143
    }
144
 
145
    // ---- Dashboards: state funnel -----------------------------------------------------------
146
 
147
    /** One row per region: reached counts across the funnel, conversion % and SLA %. */
148
    public Map<String, Object> stateFunnel(LocalDateTime from, LocalDateTime to) {
149
        // regionCode -> (stage -> count)
150
        Map<String, Map<String, Long>> byRegion = new LinkedHashMap<>();
151
        for (Object[] row : leadRepository.countByRegionAndDerivedStage(from, to)) {
152
            String rc = str(row[0]);
153
            String s = str(row[1]);
154
            long c = ((Number) row[2]).longValue();
155
            byRegion.computeIfAbsent(rc, k -> new HashMap<>()).merge(s, c, Long::sum);
156
        }
157
        // regionCode -> sla %
158
        Map<String, Integer> slaByRegion = new HashMap<>();
159
        for (Object[] row : leadRepository.slaComplianceByRegion(from, to)) {
160
            String rc = str(row[0]);
161
            long totalDue = ((Number) row[1]).longValue();
162
            long met = ((Number) row[2]).longValue();
163
            slaByRegion.put(rc, (int) pct(met, totalDue));
164
        }
165
        // Friendly names from cs.region (keyed by upper region_code).
166
        Map<String, String> nameByCode = new HashMap<>();
167
        for (Region r : regionRepository.selectAll()) {
168
            if (r.getRegionCode() != null && !r.getRegionCode().trim().isEmpty()) {
169
                nameByCode.put(r.getRegionCode().trim().toUpperCase(), r.getName());
170
            }
171
        }
172
 
173
        List<Map<String, Object>> rows = new ArrayList<>();
174
        for (Map.Entry<String, Map<String, Long>> e : byRegion.entrySet()) {
175
            String code = e.getKey();
176
            Map<String, Long> stage = e.getValue();
177
            long total = 0;
178
            for (Long v : stage.values()) {
179
                total += v;
180
            }
181
            long contacted = reachedAtLeast(stage, LeadStage.CONTACTED);
182
            long qualified = reachedAtLeast(stage, LeadStage.QUALIFIED);
183
            long visited = reachedAtLeast(stage, LeadStage.VISITED);
184
            long onboarded = reachedAtLeast(stage, LeadStage.ONBOARDED);
185
 
186
            Map<String, Object> r = new LinkedHashMap<>();
187
            r.put("regionCode", code);
188
            r.put("regionName", nameByCode.getOrDefault(code, "UNMAPPED".equals(code) ? "Unmapped" : code));
189
            r.put("newCount", total);
190
            r.put("contacted", contacted);
191
            r.put("qualified", qualified);
192
            r.put("visited", visited);
193
            r.put("onboarded", onboarded);
194
            r.put("convPct", pct(onboarded, total));
195
            r.put("slaPct", slaByRegion.getOrDefault(code, 0));
196
            rows.add(r);
197
        }
198
        rows.sort((a, b) -> Long.compare(((Number) b.get("newCount")).longValue(), ((Number) a.get("newCount")).longValue()));
199
 
200
        Map<String, Object> out = new LinkedHashMap<>();
201
        out.put("rows", rows);
202
        return out;
203
    }
204
 
205
    // ---- Dashboards: SLA owner heat-map -----------------------------------------------------
206
 
207
    /** BM × recent-week on-time %, for the SLA compliance heat-map. */
208
    public Map<String, Object> slaHeatmap(LocalDateTime from, LocalDateTime to) {
209
        // bmId -> (isoYearWeek -> pct)
210
        Map<Integer, Map<Integer, Integer>> byBm = new LinkedHashMap<>();
211
        java.util.TreeSet<Integer> weekSet = new java.util.TreeSet<>();
212
        for (Object[] row : leadRepository.slaComplianceByBmAndWeek(from, to)) {
213
            Integer bmId = ((Number) row[0]).intValue();
214
            Integer wk = ((Number) row[1]).intValue();
215
            long totalDue = ((Number) row[2]).longValue();
216
            long met = ((Number) row[3]).longValue();
217
            weekSet.add(wk);
218
            byBm.computeIfAbsent(bmId, k -> new HashMap<>()).put(wk, (int) pct(met, totalDue));
219
        }
220
        // Keep the most recent HEATMAP_WEEKS weeks.
221
        List<Integer> allWeeks = new ArrayList<>(weekSet);
222
        List<Integer> weeks = allWeeks.size() > HEATMAP_WEEKS
223
                ? allWeeks.subList(allWeeks.size() - HEATMAP_WEEKS, allWeeks.size())
224
                : allWeeks;
225
 
226
        List<Map<String, Object>> bms = new ArrayList<>();
227
        for (Map.Entry<Integer, Map<Integer, Integer>> e : byBm.entrySet()) {
228
            AuthUser bm = authRepository.selectById(e.getKey());
229
            List<Object> cells = new ArrayList<>();
230
            for (Integer wk : weeks) {
231
                Integer p = e.getValue().get(wk);
232
                Map<String, Object> cell = new LinkedHashMap<>();
233
                cell.put("week", weekLabel(wk));
234
                cell.put("pct", p); // null → no data that week
235
                cells.add(cell);
236
            }
237
            Map<String, Object> r = new LinkedHashMap<>();
238
            r.put("bmId", e.getKey());
239
            r.put("bmName", bm != null ? bm.getFullName() : ("BM #" + e.getKey()));
240
            r.put("cells", cells);
241
            bms.add(r);
242
        }
243
 
244
        List<String> weekLabels = new ArrayList<>();
245
        for (Integer wk : weeks) {
246
            weekLabels.add(weekLabel(wk));
247
        }
248
        Map<String, Object> out = new LinkedHashMap<>();
249
        out.put("weeks", weekLabels);
250
        out.put("bms", bms);
251
        return out;
252
    }
253
 
254
    // ---- Dashboards: ageing -----------------------------------------------------------------
255
 
256
    /** Open leads stuck (untouched > {@value #AGEING_DAYS} days) bucketed by stage. */
257
    public Map<String, Object> ageing() {
258
        LocalDateTime olderThan = LocalDateTime.now().minusDays(AGEING_DAYS);
259
        List<Map<String, Object>> rows = new ArrayList<>();
260
        long total = 0;
261
        for (Object[] row : leadRepository.ageingByDerivedStage(olderThan)) {
262
            String s = str(row[0]);
263
            long c = ((Number) row[1]).longValue();
264
            total += c;
265
            Map<String, Object> r = new LinkedHashMap<>();
266
            r.put("stage", s);
267
            r.put("label", pretty(safeStage(s)));
268
            r.put("count", c);
269
            rows.add(r);
270
        }
271
        rows.sort((a, b) -> Long.compare(((Number) b.get("count")).longValue(), ((Number) a.get("count")).longValue()));
272
        Map<String, Object> out = new LinkedHashMap<>();
273
        out.put("days", AGEING_DAYS);
274
        out.put("total", total);
275
        out.put("rows", rows);
276
        return out;
277
    }
278
 
279
    // ---- Drill-down -------------------------------------------------------------------------
280
 
281
    /** Lead rows for a drill bucket, shaped for the drawer table. */
282
    public List<Map<String, Object>> drill(String bucketType, String bucketKey, Integer regionId,
283
                                            LocalDateTime from, LocalDateTime to) {
284
        LocalDateTime now = LocalDateTime.now();
285
        List<Integer> ids = leadRepository.selectLeadIdsForBucket(bucketType, bucketKey, regionId,
286
                now, now.plusHours(1), from, to, DRILL_LIMIT);
287
        List<Map<String, Object>> out = new ArrayList<>();
288
        if (ids == null || ids.isEmpty()) {
289
            return out;
290
        }
291
        // selectAllByIds returns id-desc; preserve the bucket's newest-first order.
292
        Map<Integer, Lead> leadById = new HashMap<>();
293
        for (Lead l : leadRepository.selectAllByIds(ids)) {
294
            leadById.put(l.getId(), l);
295
        }
37651 vikas 296
        List<Lead> ordered = new ArrayList<>(ids.size());
297
        for (Integer id : ids) {
298
            Lead l = leadById.get(id);
299
            if (l != null) {
300
                ordered.add(l);
301
            }
302
        }
303
        return toRows(ordered);
304
    }
305
 
306
    /**
307
     * Free-text lookup for the dashboard search box: LMS id / retailer / business / mobile / city.
308
     * Same row shape as {@link #drill}, so results render in the same drawer table.
309
     */
310
    public List<Map<String, Object>> search(String term) {
311
        if (term == null || term.trim().isEmpty()) {
312
            return new ArrayList<>();
313
        }
314
        return toRows(leadRepository.selectByGlobalSearch(term.trim(), DRILL_LIMIT));
315
    }
316
 
317
    /** Shared drawer-row mapping for {@link #drill} and {@link #search}; owner names fetched in one batch. */
318
    private List<Map<String, Object>> toRows(List<Lead> leads) {
319
        List<Map<String, Object>> out = new ArrayList<>();
320
        if (leads == null || leads.isEmpty()) {
321
            return out;
322
        }
37125 vikas 323
        // Owner names in one batch.
324
        java.util.Set<Integer> ownerIds = new java.util.HashSet<>();
37651 vikas 325
        for (Lead l : leads) {
37125 vikas 326
            if (l.getAssignTo() > 0) {
327
                ownerIds.add(l.getAssignTo());
328
            }
329
        }
330
        Map<Integer, AuthUser> owners = new HashMap<>();
331
        if (!ownerIds.isEmpty()) {
332
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ownerIds))) {
333
                owners.put(u.getId(), u);
334
            }
335
        }
336
 
37651 vikas 337
        for (Lead l : leads) {
37125 vikas 338
            LeadStage eff = l.getEffectiveStage();
339
            AuthUser owner = owners.get(l.getAssignTo());
340
            Map<String, Object> r = new LinkedHashMap<>();
341
            r.put("id", l.getId());
342
            r.put("lmsCode", l.getLmsCode() != null ? l.getLmsCode() : ("Lead #" + l.getId()));
343
            r.put("name", trimName(l.getFirstName(), l.getLastName()));
344
            r.put("outlet", l.getOutLetName());
345
            r.put("path", l.getCreationPath());
346
            r.put("stage", eff != null ? eff.name() : null);
347
            r.put("stageLabel", pretty(eff));
348
            r.put("owner", owner != null ? owner.getFullName() : (l.getAssignTo() > 0 ? "#" + l.getAssignTo() : "—"));
349
            r.put("slaState", lmsAssignmentService.slaState(l));
350
            r.put("value", l.getPotential());
351
            out.add(r);
352
        }
353
        return out;
354
    }
355
 
356
    // ---- Lead Record (native, sts-mockup styled — read-only JSON) ---------------------------
357
 
358
    /** Full record for one lead: details + derived stage + SLA + append-only trail. Null if not found. */
359
    public Map<String, Object> record(int leadId) {
360
        Lead l = leadRepository.selectById(leadId);
361
        if (l == null) {
362
            return null;
363
        }
364
        LeadStage eff = l.getEffectiveStage();
365
        int stageIndex = -1;
366
        for (int i = 0; i < FUNNEL.length; i++) {
367
            if (FUNNEL[i] == eff) {
368
                stageIndex = i;
369
            }
370
        }
371
        boolean terminal = eff == LeadStage.NOT_INTERESTED || eff == LeadStage.DROPPED;
372
 
373
        AuthUser owner = l.getAssignTo() > 0 ? authRepository.selectById(l.getAssignTo()) : null;
374
        AuthUser bm = (l.getOwnerBmId() != null && l.getOwnerBmId() > 0) ? authRepository.selectById(l.getOwnerBmId()) : null;
375
 
376
        // Append-only trail, newest first.
377
        List<LeadActivity> acts = leadActivityRepository.selectBYLeadId(leadId);
378
        acts.sort((a, b) -> {
379
            LocalDateTime ta = a.getCreatedTimestamp(), tb = b.getCreatedTimestamp();
380
            if (ta == null && tb == null) return 0;
381
            if (ta == null) return 1;
382
            if (tb == null) return -1;
383
            return tb.compareTo(ta);
384
        });
385
        // Actor names in one batch.
386
        java.util.Set<Integer> ids = new java.util.HashSet<>();
387
        for (LeadActivity a : acts) {
388
            if (a.getAuthId() > 0) ids.add(a.getAuthId());
389
        }
390
        Map<Integer, AuthUser> actors = new HashMap<>();
391
        if (!ids.isEmpty()) {
392
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
393
                actors.put(u.getId(), u);
394
            }
395
        }
396
        List<Map<String, Object>> trail = new ArrayList<>();
397
        for (LeadActivity a : acts) {
398
            AuthUser act = actors.get(a.getAuthId());
399
            Map<String, Object> t = new LinkedHashMap<>();
400
            t.put("when", a.getCreatedTimestamp() == null ? null : a.getCreatedTimestamp().toString());
401
            t.put("actor", act != null ? act.getFullName() : (a.getAuthId() > 0 ? "#" + a.getAuthId() : "System"));
402
            t.put("type", a.getCommunicationType() == null ? null : a.getCommunicationType().name());
403
            t.put("body", a.getRemark());
404
            t.put("scheduled", a.getSchelduleTimestamp() == null ? null : a.getSchelduleTimestamp().toString());
405
            trail.add(t);
406
        }
407
 
408
        Map<String, Object> m = new LinkedHashMap<>();
409
        m.put("id", l.getId());
410
        m.put("lmsCode", l.getLmsCode());
411
        m.put("name", trimName(l.getFirstName(), l.getLastName()));
412
        m.put("outlet", l.getOutLetName());
413
        m.put("stage", eff != null ? eff.name() : null);
414
        m.put("stageLabel", pretty(eff));
415
        m.put("stageIndex", stageIndex);
416
        m.put("terminal", terminal);
417
        m.put("created", l.getCreatedTimestamp() == null ? null : l.getCreatedTimestamp().toString());
418
        m.put("createdBy", owner != null ? owner.getFullName() : null); // best-effort
419
        m.put("path", l.getCreationPath());
420
        m.put("contact", l.getLeadMobile());
421
        m.put("location", joinLoc(l.getAddress(), l.getCity(), l.getState()));
422
        m.put("regionCode", l.getRegionCode());
423
        m.put("value", l.getPotential());
424
        m.put("source", l.getSource());
425
        m.put("disposition", l.getDisposition() == null ? null : l.getDisposition().name());
426
        m.put("ownerBm", bm != null ? bm.getFullName() : null);
427
        m.put("owner", owner != null ? owner.getFullName() : null);
428
        m.put("assignmentStatus", l.getAssignmentStatus());
429
        m.put("slaState", lmsAssignmentService.slaState(l));
430
        m.put("firstContactDue", l.getFirstContactDue() == null ? null : l.getFirstContactDue().toString());
431
        m.put("firstContactedAt", l.getFirstContactedAt() == null ? null : l.getFirstContactedAt().toString());
432
        m.put("unreachableCount", l.getUnreachableCount());
433
        m.put("trail", trail);
434
        return m;
435
    }
436
 
437
    private String joinLoc(String a, String c, String s) {
438
        StringBuilder sb = new StringBuilder();
439
        for (String p : new String[]{a, c, s}) {
440
            if (p != null && !p.trim().isEmpty()) {
441
                if (sb.length() > 0) sb.append(", ");
442
                sb.append(p.trim());
443
            }
444
        }
445
        return sb.toString();
446
    }
447
 
448
    // ---- helpers ----------------------------------------------------------------------------
449
 
450
    private long reachedAtLeast(Map<String, Long> stage, LeadStage at) {
451
        long sum = 0;
452
        boolean counting = false;
453
        for (LeadStage s : FUNNEL) {
454
            if (s == at) {
455
                counting = true;
456
            }
457
            if (counting) {
458
                sum += stage.getOrDefault(s.name(), 0L);
459
            }
460
        }
461
        return sum;
462
    }
463
 
464
    private Map<String, Long> toCountMap(List<Object[]> rows) {
465
        Map<String, Long> m = new HashMap<>();
466
        for (Object[] r : rows) {
467
            if (r[0] == null) {
468
                continue;
469
            }
470
            m.merge(String.valueOf(r[0]), ((Number) r[1]).longValue(), Long::sum);
471
        }
472
        return m;
473
    }
474
 
475
    private Map<String, Double> toSumMap(List<Object[]> rows) {
476
        Map<String, Double> m = new HashMap<>();
477
        for (Object[] r : rows) {
478
            if (r[0] == null) {
479
                continue;
480
            }
481
            double v = r[1] == null ? 0d : ((Number) r[1]).doubleValue();
482
            m.merge(String.valueOf(r[0]), v, Double::sum);
483
        }
484
        return m;
485
    }
486
 
487
    private long num(Object[] row, int i) {
488
        if (row == null || i >= row.length || row[i] == null) {
489
            return 0L;
490
        }
491
        return ((Number) row[i]).longValue();
492
    }
493
 
494
    private long pct(long part, long whole) {
495
        if (whole <= 0) {
496
            return 0;
497
        }
498
        return Math.round((part * 100.0) / whole);
499
    }
500
 
501
    private String str(Object o) {
502
        return o == null ? "" : String.valueOf(o);
503
    }
504
 
505
    private Map<String, Object> kpi(String key, String label, long value, String foot, String tone) {
506
        Map<String, Object> m = new LinkedHashMap<>();
507
        m.put("key", key);
508
        m.put("label", label);
509
        m.put("value", value);
510
        m.put("foot", foot);
511
        m.put("tone", tone);
512
        return m;
513
    }
514
 
515
    private Map<String, Object> funnelStep(String stage, String label, long count, long pct) {
516
        Map<String, Object> m = new LinkedHashMap<>();
517
        m.put("stage", stage);
518
        m.put("label", label);
519
        m.put("count", count);
520
        m.put("pct", pct);
521
        return m;
522
    }
523
 
524
    private Map<String, Object> attn(String key, String title, String detail, long count, String tone) {
525
        Map<String, Object> m = new LinkedHashMap<>();
526
        m.put("key", key);
527
        m.put("title", title);
528
        m.put("detail", detail);
529
        m.put("count", count);
530
        m.put("tone", tone);
531
        return m;
532
    }
533
 
534
    private String trimName(String first, String last) {
535
        String f = first == null ? "" : first.trim();
536
        String l = last == null ? "" : last.trim();
537
        return (f + " " + l).trim();
538
    }
539
 
540
    private LeadStage safeStage(String s) {
541
        try {
542
            return LeadStage.valueOf(s);
543
        } catch (Exception e) {
544
            return null;
545
        }
546
    }
547
 
548
    /** "NEW" → "New", "BEAT_PLANNED" → "Beat planned". */
549
    private String pretty(LeadStage s) {
550
        if (s == null) {
551
            return "—";
552
        }
553
        String[] parts = s.name().toLowerCase().split("_");
554
        StringBuilder sb = new StringBuilder();
555
        for (int i = 0; i < parts.length; i++) {
556
            if (parts[i].isEmpty()) {
557
                continue;
558
            }
559
            if (i == 0) {
560
                sb.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
561
            } else {
562
                sb.append(' ').append(parts[i]);
563
            }
564
        }
565
        return sb.toString();
566
    }
567
 
568
    /** ISO yyyyww (YEARWEEK mode 3) → a short "w<week>" label. */
569
    private String weekLabel(int isoYearWeek) {
570
        int week = isoYearWeek % 100;
571
        return "W" + week;
572
    }
573
}