Subversion Repositories SmartDukaan

Rev

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