| Line 57... |
Line 57... |
| 57 |
|
57 |
|
| 58 |
/** Most-recent weeks shown in the SLA owner heat-map. */
|
58 |
/** Most-recent weeks shown in the SLA owner heat-map. */
|
| 59 |
private static final int HEATMAP_WEEKS = 6;
|
59 |
private static final int HEATMAP_WEEKS = 6;
|
| 60 |
|
60 |
|
| 61 |
/** Cap on rows returned to a drill-down drawer. Public so the drawer can say it is showing a slice. */
|
61 |
/** Cap on rows returned to a drill-down drawer. Public so the drawer can say it is showing a slice. */
|
| - |
|
62 |
/** Default drawer page size (drill) and the hard cap on search results. */
|
| 62 |
public static final int DRILL_LIMIT = 100;
|
63 |
public static final int DRILL_LIMIT = 100;
|
| 63 |
|
64 |
|
| 64 |
@Autowired
|
65 |
@Autowired
|
| 65 |
private LeadRepository leadRepository;
|
66 |
private LeadRepository leadRepository;
|
| 66 |
|
67 |
|
| Line 91... |
Line 92... |
| 91 |
@Autowired
|
92 |
@Autowired
|
| 92 |
private LeadLiveLocationRepository leadLiveLocationRepository;
|
93 |
private LeadLiveLocationRepository leadLiveLocationRepository;
|
| 93 |
|
94 |
|
| 94 |
// ---- Command Center + shared summary ----------------------------------------------------
|
95 |
// ---- Command Center + shared summary ----------------------------------------------------
|
| 95 |
|
96 |
|
| - |
|
97 |
/**
|
| 96 |
/** KPIs + lifecycle funnel + attention queue for the given region / created-date window. */
|
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 |
*/
|
| 97 |
public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {
|
102 |
public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {
|
| 98 |
Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));
|
103 |
Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));
|
| 99 |
long total = 0;
|
104 |
long total = 0;
|
| 100 |
for (Long v : stage.values()) {
|
105 |
for (Long v : stage.values()) {
|
| 101 |
total += v;
|
106 |
total += v;
|
| Line 110... |
Line 115... |
| 110 |
}
|
115 |
}
|
| 111 |
reached[i] = sum;
|
116 |
reached[i] = sum;
|
| 112 |
}
|
117 |
}
|
| 113 |
|
118 |
|
| 114 |
LocalDateTime now = LocalDateTime.now();
|
119 |
LocalDateTime now = LocalDateTime.now();
|
| 115 |
Object[] att = leadRepository.attentionCounts(regionId, now, now.plusHours(1));
|
120 |
Object[] att = leadRepository.attentionCounts(regionId, now, now.plusHours(1), from, to);
|
| 116 |
long breachedNoContact = num(att, 0);
|
121 |
long breachedNoContact = num(att, 0);
|
| 117 |
long hold = num(att, 1);
|
122 |
long hold = num(att, 1);
|
| 118 |
long slaUnder1h = num(att, 2);
|
123 |
long slaUnder1h = num(att, 2);
|
| 119 |
long missingDisp = num(att, 3);
|
124 |
long missingDisp = num(att, 3);
|
| 120 |
long qualifiedNoBeat = num(att, 4);
|
125 |
long qualifiedNoBeat = num(att, 4);
|
| Line 136... |
Line 141... |
| 136 |
kpis.add(kpi("awaiting", "Awaiting 1st contact", assigned, "SLA 3–5 hrs running", "amber"));
|
141 |
kpis.add(kpi("awaiting", "Awaiting 1st contact", assigned, "SLA 3–5 hrs running", "amber"));
|
| 137 |
kpis.add(kpi("sla", "SLA at risk / breached", breachedNoContact + slaUnder1h,
|
142 |
kpis.add(kpi("sla", "SLA at risk / breached", breachedNoContact + slaUnder1h,
|
| 138 |
breachedNoContact + " breached · " + slaUnder1h + " < 1 hr left", "red"));
|
143 |
breachedNoContact + " breached · " + slaUnder1h + " < 1 hr left", "red"));
|
| 139 |
kpis.add(kpi("contacted", "Contacted", contacted, "dispositioned", "navy"));
|
144 |
kpis.add(kpi("contacted", "Contacted", contacted, "dispositioned", "navy"));
|
| 140 |
kpis.add(kpi("qualified", "Qualified", qualified, "value validated", "navy"));
|
145 |
kpis.add(kpi("qualified", "Qualified", qualified, "value validated", "navy"));
|
| 141 |
kpis.add(kpi("onboarded", "Onboarded", onboarded, "of " + total + " in scope", "good"));
|
146 |
kpis.add(kpi("onboarded", "Onboarded", onboarded, "LOI + stock payment · of " + total + " in scope", "good"));
|
| 142 |
|
147 |
|
| 143 |
// Lifecycle funnel.
|
148 |
// Lifecycle funnel.
|
| 144 |
List<Map<String, Object>> funnel = new ArrayList<>();
|
149 |
List<Map<String, Object>> funnel = new ArrayList<>();
|
| 145 |
long topN = total > 0 ? total : 1;
|
150 |
long topN = total > 0 ? total : 1;
|
| 146 |
// Top of funnel = every lead in scope.
|
151 |
// Top of funnel = every lead in scope.
|
| Line 205... |
Line 210... |
| 205 |
for (Long v : stage.values()) {
|
210 |
for (Long v : stage.values()) {
|
| 206 |
total += v;
|
211 |
total += v;
|
| 207 |
}
|
212 |
}
|
| 208 |
long contacted = reachedAtLeast(stage, LeadStage.CONTACTED);
|
213 |
long contacted = reachedAtLeast(stage, LeadStage.CONTACTED);
|
| 209 |
long qualified = reachedAtLeast(stage, LeadStage.QUALIFIED);
|
214 |
long qualified = reachedAtLeast(stage, LeadStage.QUALIFIED);
|
| - |
|
215 |
long beatPlanned = reachedAtLeast(stage, LeadStage.BEAT_PLANNED);
|
| 210 |
long visited = reachedAtLeast(stage, LeadStage.VISITED);
|
216 |
long visited = reachedAtLeast(stage, LeadStage.VISITED);
|
| 211 |
long onboarded = reachedAtLeast(stage, LeadStage.ONBOARDED);
|
217 |
long onboarded = reachedAtLeast(stage, LeadStage.ONBOARDED);
|
| 212 |
|
218 |
|
| 213 |
Map<String, Object> r = new LinkedHashMap<>();
|
219 |
Map<String, Object> r = new LinkedHashMap<>();
|
| 214 |
r.put("regionCode", code);
|
220 |
r.put("regionCode", code);
|
| 215 |
r.put("regionName", nameByCode.getOrDefault(code, "UNMAPPED".equals(code) ? "Unmapped" : code));
|
221 |
r.put("regionName", nameByCode.getOrDefault(code, "UNMAPPED".equals(code) ? "Unmapped" : code));
|
| 216 |
r.put("newCount", total);
|
222 |
r.put("newCount", total);
|
| 217 |
r.put("contacted", contacted);
|
223 |
r.put("contacted", contacted);
|
| 218 |
r.put("qualified", qualified);
|
224 |
r.put("qualified", qualified);
|
| - |
|
225 |
r.put("beatPlanned", beatPlanned);
|
| 219 |
r.put("visited", visited);
|
226 |
r.put("visited", visited);
|
| 220 |
r.put("onboarded", onboarded);
|
227 |
r.put("onboarded", onboarded);
|
| 221 |
r.put("convPct", pct(onboarded, total));
|
228 |
r.put("convPct", pct(onboarded, total));
|
| 222 |
r.put("slaPct", slaByRegion.getOrDefault(code, 0));
|
229 |
r.put("slaPct", slaByRegion.getOrDefault(code, 0));
|
| 223 |
rows.add(r);
|
230 |
rows.add(r);
|
| Line 364... |
Line 371... |
| 364 |
*/
|
371 |
*/
|
| 365 |
public Map<String, Object> auditHygiene(LocalDateTime from, LocalDateTime to) {
|
372 |
public Map<String, Object> auditHygiene(LocalDateTime from, LocalDateTime to) {
|
| 366 |
LocalDateTime now = LocalDateTime.now();
|
373 |
LocalDateTime now = LocalDateTime.now();
|
| 367 |
Map<String, Object> m = new LinkedHashMap<>();
|
374 |
Map<String, Object> m = new LinkedHashMap<>();
|
| 368 |
|
375 |
|
| 369 |
// Same source the Command Center's attention tiles use, so the two cannot disagree.
|
376 |
// Same source and same created-date window as the Command Center's attention tiles, so the
|
| - |
|
377 |
// two cannot disagree.
|
| 370 |
Object[] att = leadRepository.attentionCounts(null, now, now.plusHours(1));
|
378 |
Object[] att = leadRepository.attentionCounts(null, now, now.plusHours(1), from, to);
|
| 371 |
m.put("slaBreached", num(att, 0));
|
379 |
m.put("slaBreached", num(att, 0));
|
| 372 |
m.put("hold", num(att, 1));
|
380 |
m.put("hold", num(att, 1));
|
| 373 |
m.put("missingDisposition", num(att, 3));
|
381 |
m.put("missingDisposition", num(att, 3));
|
| 374 |
|
382 |
|
| 375 |
m.put("dndRegister", leadDndRepository.count());
|
383 |
m.put("dndRegister", leadDndRepository.count());
|
| Line 613... |
Line 621... |
| 613 |
return v instanceof LocalDateTime ? (LocalDateTime) v : null;
|
621 |
return v instanceof LocalDateTime ? (LocalDateTime) v : null;
|
| 614 |
}
|
622 |
}
|
| 615 |
|
623 |
|
| 616 |
// ---- Drill-down -------------------------------------------------------------------------
|
624 |
// ---- Drill-down -------------------------------------------------------------------------
|
| 617 |
|
625 |
|
| - |
|
626 |
/**
|
| 618 |
/** Lead rows for a drill bucket, shaped for the drawer table. */
|
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 |
*/
|
| 619 |
public List<Map<String, Object>> drill(String bucketType, String bucketKey, Integer regionId,
|
630 |
public Map<String, Object> drill(String bucketType, String bucketKey, Integer regionId,
|
| 620 |
LocalDateTime from, LocalDateTime to) {
|
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);
|
| 621 |
String type = bucketType == null ? "" : bucketType.trim().toUpperCase();
|
634 |
String type = bucketType == null ? "" : bucketType.trim().toUpperCase();
|
| 622 |
String key = bucketKey;
|
635 |
String key = bucketKey;
|
| 623 |
// The funnel asks for a stage cumulatively -- "reached CONTACTED" means CONTACTED or anything
|
636 |
// The funnel asks for a stage cumulatively -- "reached CONTACTED" means CONTACTED or anything
|
| 624 |
// beyond it, which is exactly how the bar was counted. Expand it here, where the happy path
|
637 |
// beyond it, which is exactly how the bar was counted. Expand it here, where the happy path
|
| 625 |
// already lives, rather than teaching the repository the order of the lifecycle.
|
638 |
// already lives, rather than teaching the repository the order of the lifecycle.
|
| 626 |
if ("STAGE_REACHED".equals(type)) {
|
639 |
if ("STAGE_REACHED".equals(type)) {
|
| 627 |
type = "STAGE_IN";
|
640 |
type = "STAGE_IN";
|
| 628 |
key = stagesFrom(bucketKey);
|
641 |
key = stagesFrom(bucketKey);
|
| 629 |
}
|
642 |
}
|
| 630 |
LocalDateTime now = LocalDateTime.now();
|
643 |
LocalDateTime now = LocalDateTime.now();
|
| - |
|
644 |
long total = leadRepository.countLeadsForBucket(type, key, regionId, now, now.plusHours(1), from, to);
|
| 631 |
List<Integer> ids = leadRepository.selectLeadIdsForBucket(type, key, regionId,
|
645 |
List<Integer> ids = leadRepository.selectLeadIdsForBucket(type, key, regionId,
|
| 632 |
now, now.plusHours(1), from, to, DRILL_LIMIT);
|
646 |
now, now.plusHours(1), from, to, (p - 1) * size, size);
|
| 633 |
List<Map<String, Object>> out = new ArrayList<>();
|
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));
|
| 634 |
if (ids == null || ids.isEmpty()) {
|
654 |
if (ids == null || ids.isEmpty()) {
|
| 635 |
return out;
|
655 |
return out;
|
| 636 |
}
|
656 |
}
|
| 637 |
// selectAllByIds returns id-desc; preserve the bucket's newest-first order.
|
657 |
// selectAllByIds returns id-desc; preserve the bucket's newest-first order.
|
| 638 |
Map<Integer, Lead> leadById = new HashMap<>();
|
658 |
Map<Integer, Lead> leadById = new HashMap<>();
|
| Line 644... |
Line 664... |
| 644 |
Lead l = leadById.get(id);
|
664 |
Lead l = leadById.get(id);
|
| 645 |
if (l != null) {
|
665 |
if (l != null) {
|
| 646 |
ordered.add(l);
|
666 |
ordered.add(l);
|
| 647 |
}
|
667 |
}
|
| 648 |
}
|
668 |
}
|
| 649 |
return toRows(ordered);
|
669 |
rows.addAll(toRows(ordered));
|
| - |
|
670 |
return out;
|
| 650 |
}
|
671 |
}
|
| 651 |
|
672 |
|
| 652 |
/**
|
673 |
/**
|
| 653 |
* The given stage and every stage after it on the happy path, comma-separated -- the set the
|
674 |
* The given stage and every stage after it on the happy path, comma-separated -- the set the
|
| 654 |
* cumulative funnel bar counted. An unrecognised stage (a terminal one such as NOT_INTERESTED,
|
675 |
* cumulative funnel bar counted. An unrecognised stage (a terminal one such as NOT_INTERESTED,
|
| Line 734... |
Line 755... |
| 734 |
if (!ownerIds.isEmpty()) {
|
755 |
if (!ownerIds.isEmpty()) {
|
| 735 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ownerIds))) {
|
756 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ownerIds))) {
|
| 736 |
owners.put(u.getId(), u);
|
757 |
owners.put(u.getId(), u);
|
| 737 |
}
|
758 |
}
|
| 738 |
}
|
759 |
}
|
| - |
|
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);
|
| 739 |
|
766 |
|
| 740 |
for (Lead l : leads) {
|
767 |
for (Lead l : leads) {
|
| 741 |
LeadStage eff = l.getEffectiveStage();
|
768 |
LeadStage eff = effectiveStage(l, stages);
|
| 742 |
AuthUser owner = owners.get(l.getAssignTo());
|
769 |
AuthUser owner = owners.get(l.getAssignTo());
|
| 743 |
Map<String, Object> r = new LinkedHashMap<>();
|
770 |
Map<String, Object> r = new LinkedHashMap<>();
|
| 744 |
r.put("id", l.getId());
|
771 |
r.put("id", l.getId());
|
| 745 |
r.put("lmsCode", l.getLmsCode() != null ? l.getLmsCode() : ("Lead #" + l.getId()));
|
772 |
r.put("lmsCode", l.getLmsCode() != null ? l.getLmsCode() : ("Lead #" + l.getId()));
|
| 746 |
r.put("name", trimName(l.getFirstName(), l.getLastName()));
|
773 |
r.put("name", trimName(l.getFirstName(), l.getLastName()));
|
| Line 767... |
Line 794... |
| 767 |
public Map<String, Object> record(int leadId) {
|
794 |
public Map<String, Object> record(int leadId) {
|
| 768 |
Lead l = leadRepository.selectById(leadId);
|
795 |
Lead l = leadRepository.selectById(leadId);
|
| 769 |
if (l == null) {
|
796 |
if (l == null) {
|
| 770 |
return null;
|
797 |
return null;
|
| 771 |
}
|
798 |
}
|
| 772 |
LeadStage eff = l.getEffectiveStage();
|
799 |
LeadStage eff = effectiveStage(l, leadRepository.selectEffectiveStages(java.util.Collections.singletonList(leadId)));
|
| 773 |
int stageIndex = -1;
|
800 |
int stageIndex = -1;
|
| 774 |
for (int i = 0; i < FUNNEL.length; i++) {
|
801 |
for (int i = 0; i < FUNNEL.length; i++) {
|
| 775 |
if (FUNNEL[i] == eff) {
|
802 |
if (FUNNEL[i] == eff) {
|
| 776 |
stageIndex = i;
|
803 |
stageIndex = i;
|
| 777 |
}
|
804 |
}
|
| Line 995... |
Line 1022... |
| 995 |
String f = first == null ? "" : first.trim();
|
1022 |
String f = first == null ? "" : first.trim();
|
| 996 |
String l = last == null ? "" : last.trim();
|
1023 |
String l = last == null ? "" : last.trim();
|
| 997 |
return (f + " " + l).trim();
|
1024 |
return (f + " " + l).trim();
|
| 998 |
}
|
1025 |
}
|
| 999 |
|
1026 |
|
| - |
|
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 |
|
| 1000 |
private LeadStage safeStage(String s) {
|
1036 |
private LeadStage safeStage(String s) {
|
| - |
|
1037 |
if (s == null) {
|
| - |
|
1038 |
return null;
|
| - |
|
1039 |
}
|
| 1001 |
try {
|
1040 |
try {
|
| 1002 |
return LeadStage.valueOf(s);
|
1041 |
return LeadStage.valueOf(s);
|
| 1003 |
} catch (Exception e) {
|
1042 |
} catch (Exception e) {
|
| 1004 |
return null;
|
1043 |
return null;
|
| 1005 |
}
|
1044 |
}
|