Subversion Repositories SmartDukaan

Rev

Details | 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
        }
296
        // Owner names in one batch.
297
        java.util.Set<Integer> ownerIds = new java.util.HashSet<>();
298
        for (Lead l : leadById.values()) {
299
            if (l.getAssignTo() > 0) {
300
                ownerIds.add(l.getAssignTo());
301
            }
302
        }
303
        Map<Integer, AuthUser> owners = new HashMap<>();
304
        if (!ownerIds.isEmpty()) {
305
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ownerIds))) {
306
                owners.put(u.getId(), u);
307
            }
308
        }
309
 
310
        for (Integer id : ids) {
311
            Lead l = leadById.get(id);
312
            if (l == null) {
313
                continue;
314
            }
315
            LeadStage eff = l.getEffectiveStage();
316
            AuthUser owner = owners.get(l.getAssignTo());
317
            Map<String, Object> r = new LinkedHashMap<>();
318
            r.put("id", l.getId());
319
            r.put("lmsCode", l.getLmsCode() != null ? l.getLmsCode() : ("Lead #" + l.getId()));
320
            r.put("name", trimName(l.getFirstName(), l.getLastName()));
321
            r.put("outlet", l.getOutLetName());
322
            r.put("path", l.getCreationPath());
323
            r.put("stage", eff != null ? eff.name() : null);
324
            r.put("stageLabel", pretty(eff));
325
            r.put("owner", owner != null ? owner.getFullName() : (l.getAssignTo() > 0 ? "#" + l.getAssignTo() : "—"));
326
            r.put("slaState", lmsAssignmentService.slaState(l));
327
            r.put("value", l.getPotential());
328
            out.add(r);
329
        }
330
        return out;
331
    }
332
 
333
    // ---- Lead Record (native, sts-mockup styled — read-only JSON) ---------------------------
334
 
335
    /** Full record for one lead: details + derived stage + SLA + append-only trail. Null if not found. */
336
    public Map<String, Object> record(int leadId) {
337
        Lead l = leadRepository.selectById(leadId);
338
        if (l == null) {
339
            return null;
340
        }
341
        LeadStage eff = l.getEffectiveStage();
342
        int stageIndex = -1;
343
        for (int i = 0; i < FUNNEL.length; i++) {
344
            if (FUNNEL[i] == eff) {
345
                stageIndex = i;
346
            }
347
        }
348
        boolean terminal = eff == LeadStage.NOT_INTERESTED || eff == LeadStage.DROPPED;
349
 
350
        AuthUser owner = l.getAssignTo() > 0 ? authRepository.selectById(l.getAssignTo()) : null;
351
        AuthUser bm = (l.getOwnerBmId() != null && l.getOwnerBmId() > 0) ? authRepository.selectById(l.getOwnerBmId()) : null;
352
 
353
        // Append-only trail, newest first.
354
        List<LeadActivity> acts = leadActivityRepository.selectBYLeadId(leadId);
355
        acts.sort((a, b) -> {
356
            LocalDateTime ta = a.getCreatedTimestamp(), tb = b.getCreatedTimestamp();
357
            if (ta == null && tb == null) return 0;
358
            if (ta == null) return 1;
359
            if (tb == null) return -1;
360
            return tb.compareTo(ta);
361
        });
362
        // Actor names in one batch.
363
        java.util.Set<Integer> ids = new java.util.HashSet<>();
364
        for (LeadActivity a : acts) {
365
            if (a.getAuthId() > 0) ids.add(a.getAuthId());
366
        }
367
        Map<Integer, AuthUser> actors = new HashMap<>();
368
        if (!ids.isEmpty()) {
369
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
370
                actors.put(u.getId(), u);
371
            }
372
        }
373
        List<Map<String, Object>> trail = new ArrayList<>();
374
        for (LeadActivity a : acts) {
375
            AuthUser act = actors.get(a.getAuthId());
376
            Map<String, Object> t = new LinkedHashMap<>();
377
            t.put("when", a.getCreatedTimestamp() == null ? null : a.getCreatedTimestamp().toString());
378
            t.put("actor", act != null ? act.getFullName() : (a.getAuthId() > 0 ? "#" + a.getAuthId() : "System"));
379
            t.put("type", a.getCommunicationType() == null ? null : a.getCommunicationType().name());
380
            t.put("body", a.getRemark());
381
            t.put("scheduled", a.getSchelduleTimestamp() == null ? null : a.getSchelduleTimestamp().toString());
382
            trail.add(t);
383
        }
384
 
385
        Map<String, Object> m = new LinkedHashMap<>();
386
        m.put("id", l.getId());
387
        m.put("lmsCode", l.getLmsCode());
388
        m.put("name", trimName(l.getFirstName(), l.getLastName()));
389
        m.put("outlet", l.getOutLetName());
390
        m.put("stage", eff != null ? eff.name() : null);
391
        m.put("stageLabel", pretty(eff));
392
        m.put("stageIndex", stageIndex);
393
        m.put("terminal", terminal);
394
        m.put("created", l.getCreatedTimestamp() == null ? null : l.getCreatedTimestamp().toString());
395
        m.put("createdBy", owner != null ? owner.getFullName() : null); // best-effort
396
        m.put("path", l.getCreationPath());
397
        m.put("contact", l.getLeadMobile());
398
        m.put("location", joinLoc(l.getAddress(), l.getCity(), l.getState()));
399
        m.put("regionCode", l.getRegionCode());
400
        m.put("value", l.getPotential());
401
        m.put("source", l.getSource());
402
        m.put("disposition", l.getDisposition() == null ? null : l.getDisposition().name());
403
        m.put("ownerBm", bm != null ? bm.getFullName() : null);
404
        m.put("owner", owner != null ? owner.getFullName() : null);
405
        m.put("assignmentStatus", l.getAssignmentStatus());
406
        m.put("slaState", lmsAssignmentService.slaState(l));
407
        m.put("firstContactDue", l.getFirstContactDue() == null ? null : l.getFirstContactDue().toString());
408
        m.put("firstContactedAt", l.getFirstContactedAt() == null ? null : l.getFirstContactedAt().toString());
409
        m.put("unreachableCount", l.getUnreachableCount());
410
        m.put("trail", trail);
411
        return m;
412
    }
413
 
414
    private String joinLoc(String a, String c, String s) {
415
        StringBuilder sb = new StringBuilder();
416
        for (String p : new String[]{a, c, s}) {
417
            if (p != null && !p.trim().isEmpty()) {
418
                if (sb.length() > 0) sb.append(", ");
419
                sb.append(p.trim());
420
            }
421
        }
422
        return sb.toString();
423
    }
424
 
425
    // ---- helpers ----------------------------------------------------------------------------
426
 
427
    private long reachedAtLeast(Map<String, Long> stage, LeadStage at) {
428
        long sum = 0;
429
        boolean counting = false;
430
        for (LeadStage s : FUNNEL) {
431
            if (s == at) {
432
                counting = true;
433
            }
434
            if (counting) {
435
                sum += stage.getOrDefault(s.name(), 0L);
436
            }
437
        }
438
        return sum;
439
    }
440
 
441
    private Map<String, Long> toCountMap(List<Object[]> rows) {
442
        Map<String, Long> m = new HashMap<>();
443
        for (Object[] r : rows) {
444
            if (r[0] == null) {
445
                continue;
446
            }
447
            m.merge(String.valueOf(r[0]), ((Number) r[1]).longValue(), Long::sum);
448
        }
449
        return m;
450
    }
451
 
452
    private Map<String, Double> toSumMap(List<Object[]> rows) {
453
        Map<String, Double> m = new HashMap<>();
454
        for (Object[] r : rows) {
455
            if (r[0] == null) {
456
                continue;
457
            }
458
            double v = r[1] == null ? 0d : ((Number) r[1]).doubleValue();
459
            m.merge(String.valueOf(r[0]), v, Double::sum);
460
        }
461
        return m;
462
    }
463
 
464
    private long num(Object[] row, int i) {
465
        if (row == null || i >= row.length || row[i] == null) {
466
            return 0L;
467
        }
468
        return ((Number) row[i]).longValue();
469
    }
470
 
471
    private long pct(long part, long whole) {
472
        if (whole <= 0) {
473
            return 0;
474
        }
475
        return Math.round((part * 100.0) / whole);
476
    }
477
 
478
    private String str(Object o) {
479
        return o == null ? "" : String.valueOf(o);
480
    }
481
 
482
    private Map<String, Object> kpi(String key, String label, long value, String foot, String tone) {
483
        Map<String, Object> m = new LinkedHashMap<>();
484
        m.put("key", key);
485
        m.put("label", label);
486
        m.put("value", value);
487
        m.put("foot", foot);
488
        m.put("tone", tone);
489
        return m;
490
    }
491
 
492
    private Map<String, Object> funnelStep(String stage, String label, long count, long pct) {
493
        Map<String, Object> m = new LinkedHashMap<>();
494
        m.put("stage", stage);
495
        m.put("label", label);
496
        m.put("count", count);
497
        m.put("pct", pct);
498
        return m;
499
    }
500
 
501
    private Map<String, Object> attn(String key, String title, String detail, long count, String tone) {
502
        Map<String, Object> m = new LinkedHashMap<>();
503
        m.put("key", key);
504
        m.put("title", title);
505
        m.put("detail", detail);
506
        m.put("count", count);
507
        m.put("tone", tone);
508
        return m;
509
    }
510
 
511
    private String trimName(String first, String last) {
512
        String f = first == null ? "" : first.trim();
513
        String l = last == null ? "" : last.trim();
514
        return (f + " " + l).trim();
515
    }
516
 
517
    private LeadStage safeStage(String s) {
518
        try {
519
            return LeadStage.valueOf(s);
520
        } catch (Exception e) {
521
            return null;
522
        }
523
    }
524
 
525
    /** "NEW" → "New", "BEAT_PLANNED" → "Beat planned". */
526
    private String pretty(LeadStage s) {
527
        if (s == null) {
528
            return "—";
529
        }
530
        String[] parts = s.name().toLowerCase().split("_");
531
        StringBuilder sb = new StringBuilder();
532
        for (int i = 0; i < parts.length; i++) {
533
            if (parts[i].isEmpty()) {
534
                continue;
535
            }
536
            if (i == 0) {
537
                sb.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
538
            } else {
539
                sb.append(' ').append(parts[i]);
540
            }
541
        }
542
        return sb.toString();
543
    }
544
 
545
    /** ISO yyyyww (YEARWEEK mode 3) → a short "w<week>" label. */
546
    private String weekLabel(int isoYearWeek) {
547
        int week = isoYearWeek % 100;
548
        return "W" + week;
549
    }
550
}