Blame | Last modification | View Log | RSS feed
package com.spice.profitmandi.service;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.cs.Region;import com.spice.profitmandi.dao.entity.user.Lead;import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;import com.spice.profitmandi.dao.entity.user.LeadActivity;import com.spice.profitmandi.dao.repository.auth.AuthRepository;import com.spice.profitmandi.dao.repository.cs.RegionRepository;import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;import com.spice.profitmandi.dao.repository.dtr.LeadRepository;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.time.LocalDateTime;import java.util.ArrayList;import java.util.HashMap;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;/*** Read-only aggregation for the LMS management dashboards (SOP §18) — Command Center + Dashboards.* Buckets EVERY lead: rows with a real {@link LeadStage} use it; legacy rows derive one in SQL* (see {@link LeadRepository#countByDerivedStage}). Returns plain JSON-ready Maps so the controller* can {@code gson.toJson(...)} them straight into the page / AJAX endpoints. No mutation, no schema change.** <p>Coverage caveat (SOP §18.2): call/recording/order metrics (BGC call volume, RBM servicing, DND) are* NOT in the {@code Lead} schema, so those dashboards are surfaced as placeholders by the views. What this* service computes is everything derivable from the lead record: stage funnel, SLA compliance, ageing,* attention queues and the roll-up KPIs.*/@Servicepublic class LmsDashboardService {private static final Logger LOGGER = LogManager.getLogger(LmsDashboardService.class);/** Ordered happy-path for cumulative "reached" funnel math. */private static final LeadStage[] FUNNEL = LeadStage.HAPPY_PATH;/** Leads not touched for this many days count as "ageing" (SOP §18.2). */private static final int AGEING_DAYS = 7;/** Most-recent weeks shown in the SLA owner heat-map. */private static final int HEATMAP_WEEKS = 6;/** Cap on rows returned to a drill-down drawer. */private static final int DRILL_LIMIT = 100;@Autowiredprivate LeadRepository leadRepository;@Autowiredprivate RegionRepository regionRepository;@Autowiredprivate AuthRepository authRepository;@Autowiredprivate LmsAssignmentService lmsAssignmentService;@Autowiredprivate LeadActivityRepository leadActivityRepository;// ---- Command Center + shared summary ----------------------------------------------------/** KPIs + lifecycle funnel + attention queue for the given region / created-date window. */public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));long total = 0;for (Long v : stage.values()) {total += v;}// Cumulative "reached at least this stage" along the happy path (monotonic, drives the funnel bars).long[] reached = new long[FUNNEL.length];for (int i = 0; i < FUNNEL.length; i++) {long sum = 0;for (int j = i; j < FUNNEL.length; j++) {sum += stage.getOrDefault(FUNNEL[j].name(), 0L);}reached[i] = sum;}LocalDateTime now = LocalDateTime.now();Object[] att = leadRepository.attentionCounts(regionId, now, now.plusHours(1));long breachedNoContact = num(att, 0);long hold = num(att, 1);long slaUnder1h = num(att, 2);long missingDisp = num(att, 3);long qualifiedNoBeat = num(att, 4);long onboardedPending = num(att, 5);// Onboarded value (SOP §18.2) = summed potential of ONBOARDED + ACTIVE leads.Map<String, Double> potByStage = toSumMap(leadRepository.sumPotentialByDerivedStage(regionId, from, to));double onboardedValue = potByStage.getOrDefault("ONBOARDED", 0d) + potByStage.getOrDefault("ACTIVE", 0d);long onboarded = stage.getOrDefault("ONBOARDED", 0L) + stage.getOrDefault("ACTIVE", 0L);long assigned = stage.getOrDefault("ASSIGNED", 0L);long contacted = stage.getOrDefault("CONTACTED", 0L);long qualified = stage.getOrDefault("QUALIFIED", 0L);long newCount = stage.getOrDefault("NEW", 0L);// KPI tiles (Command Center).List<Map<String, Object>> kpis = new ArrayList<>();kpis.add(kpi("new", "New", newCount, "in selected period", "navy"));kpis.add(kpi("awaiting", "Awaiting 1st contact", assigned, "SLA 3–5 hrs running", "amber"));kpis.add(kpi("sla", "SLA at risk / breached", breachedNoContact + slaUnder1h,breachedNoContact + " breached · " + slaUnder1h + " < 1 hr left", "red"));kpis.add(kpi("contacted", "Contacted", contacted, "dispositioned", "navy"));kpis.add(kpi("qualified", "Qualified", qualified, "value validated", "navy"));kpis.add(kpi("onboarded", "Onboarded", onboarded, "of " + total + " in scope", "good"));// Lifecycle funnel.List<Map<String, Object>> funnel = new ArrayList<>();long topN = total > 0 ? total : 1;// Top of funnel = every lead in scope.funnel.add(funnelStep("ALL", "All leads", total, pct(total, topN)));for (int i = 1; i < FUNNEL.length; i++) { // skip NEW itself; ALL is the 100% anchorLeadStage s = FUNNEL[i];funnel.add(funnelStep(s.name(), pretty(s), reached[i], pct(reached[i], topN)));}// Attention queue.List<Map<String, Object>> attention = new ArrayList<>();attention.add(attn("BREACHED_NO_CONTACT", "SLA breached — no first contact", "First contact overdue", breachedNoContact, "red"));attention.add(attn("HOLD", "Assigned but BM/RSM not mapped", "Region has no active owner", hold, "red"));attention.add(attn("SLA_UNDER_1H", "SLA < 1 hr remaining", "First contact due soon", slaUnder1h, "amber"));attention.add(attn("MISSING_DISP", "Calls missing a disposition", "Contacted, not dispositioned", missingDisp, "amber"));attention.add(attn("QUALIFIED_NO_BEAT", "Qualified, no beat plan yet", "Awaiting an ASM beat stop", qualifiedNoBeat, "navy"));attention.add(attn("ONBOARDED_PENDING", "Onboarded — pending RBM handover", "Handover to post-sales", onboardedPending, "violet"));Map<String, Object> out = new LinkedHashMap<>();out.put("total", total);out.put("onboardedValue", onboardedValue);out.put("conversionPct", pct(onboarded, topN));out.put("kpis", kpis);out.put("funnel", funnel);out.put("attention", attention);return out;}// ---- Dashboards: state funnel -----------------------------------------------------------/** One row per region: reached counts across the funnel, conversion % and SLA %. */public Map<String, Object> stateFunnel(LocalDateTime from, LocalDateTime to) {// regionCode -> (stage -> count)Map<String, Map<String, Long>> byRegion = new LinkedHashMap<>();for (Object[] row : leadRepository.countByRegionAndDerivedStage(from, to)) {String rc = str(row[0]);String s = str(row[1]);long c = ((Number) row[2]).longValue();byRegion.computeIfAbsent(rc, k -> new HashMap<>()).merge(s, c, Long::sum);}// regionCode -> sla %Map<String, Integer> slaByRegion = new HashMap<>();for (Object[] row : leadRepository.slaComplianceByRegion(from, to)) {String rc = str(row[0]);long totalDue = ((Number) row[1]).longValue();long met = ((Number) row[2]).longValue();slaByRegion.put(rc, (int) pct(met, totalDue));}// Friendly names from cs.region (keyed by upper region_code).Map<String, String> nameByCode = new HashMap<>();for (Region r : regionRepository.selectAll()) {if (r.getRegionCode() != null && !r.getRegionCode().trim().isEmpty()) {nameByCode.put(r.getRegionCode().trim().toUpperCase(), r.getName());}}List<Map<String, Object>> rows = new ArrayList<>();for (Map.Entry<String, Map<String, Long>> e : byRegion.entrySet()) {String code = e.getKey();Map<String, Long> stage = e.getValue();long total = 0;for (Long v : stage.values()) {total += v;}long contacted = reachedAtLeast(stage, LeadStage.CONTACTED);long qualified = reachedAtLeast(stage, LeadStage.QUALIFIED);long visited = reachedAtLeast(stage, LeadStage.VISITED);long onboarded = reachedAtLeast(stage, LeadStage.ONBOARDED);Map<String, Object> r = new LinkedHashMap<>();r.put("regionCode", code);r.put("regionName", nameByCode.getOrDefault(code, "UNMAPPED".equals(code) ? "Unmapped" : code));r.put("newCount", total);r.put("contacted", contacted);r.put("qualified", qualified);r.put("visited", visited);r.put("onboarded", onboarded);r.put("convPct", pct(onboarded, total));r.put("slaPct", slaByRegion.getOrDefault(code, 0));rows.add(r);}rows.sort((a, b) -> Long.compare(((Number) b.get("newCount")).longValue(), ((Number) a.get("newCount")).longValue()));Map<String, Object> out = new LinkedHashMap<>();out.put("rows", rows);return out;}// ---- Dashboards: SLA owner heat-map -----------------------------------------------------/** BM × recent-week on-time %, for the SLA compliance heat-map. */public Map<String, Object> slaHeatmap(LocalDateTime from, LocalDateTime to) {// bmId -> (isoYearWeek -> pct)Map<Integer, Map<Integer, Integer>> byBm = new LinkedHashMap<>();java.util.TreeSet<Integer> weekSet = new java.util.TreeSet<>();for (Object[] row : leadRepository.slaComplianceByBmAndWeek(from, to)) {Integer bmId = ((Number) row[0]).intValue();Integer wk = ((Number) row[1]).intValue();long totalDue = ((Number) row[2]).longValue();long met = ((Number) row[3]).longValue();weekSet.add(wk);byBm.computeIfAbsent(bmId, k -> new HashMap<>()).put(wk, (int) pct(met, totalDue));}// Keep the most recent HEATMAP_WEEKS weeks.List<Integer> allWeeks = new ArrayList<>(weekSet);List<Integer> weeks = allWeeks.size() > HEATMAP_WEEKS? allWeeks.subList(allWeeks.size() - HEATMAP_WEEKS, allWeeks.size()): allWeeks;List<Map<String, Object>> bms = new ArrayList<>();for (Map.Entry<Integer, Map<Integer, Integer>> e : byBm.entrySet()) {AuthUser bm = authRepository.selectById(e.getKey());List<Object> cells = new ArrayList<>();for (Integer wk : weeks) {Integer p = e.getValue().get(wk);Map<String, Object> cell = new LinkedHashMap<>();cell.put("week", weekLabel(wk));cell.put("pct", p); // null → no data that weekcells.add(cell);}Map<String, Object> r = new LinkedHashMap<>();r.put("bmId", e.getKey());r.put("bmName", bm != null ? bm.getFullName() : ("BM #" + e.getKey()));r.put("cells", cells);bms.add(r);}List<String> weekLabels = new ArrayList<>();for (Integer wk : weeks) {weekLabels.add(weekLabel(wk));}Map<String, Object> out = new LinkedHashMap<>();out.put("weeks", weekLabels);out.put("bms", bms);return out;}// ---- Dashboards: ageing -----------------------------------------------------------------/** Open leads stuck (untouched > {@value #AGEING_DAYS} days) bucketed by stage. */public Map<String, Object> ageing() {LocalDateTime olderThan = LocalDateTime.now().minusDays(AGEING_DAYS);List<Map<String, Object>> rows = new ArrayList<>();long total = 0;for (Object[] row : leadRepository.ageingByDerivedStage(olderThan)) {String s = str(row[0]);long c = ((Number) row[1]).longValue();total += c;Map<String, Object> r = new LinkedHashMap<>();r.put("stage", s);r.put("label", pretty(safeStage(s)));r.put("count", c);rows.add(r);}rows.sort((a, b) -> Long.compare(((Number) b.get("count")).longValue(), ((Number) a.get("count")).longValue()));Map<String, Object> out = new LinkedHashMap<>();out.put("days", AGEING_DAYS);out.put("total", total);out.put("rows", rows);return out;}// ---- Drill-down -------------------------------------------------------------------------/** Lead rows for a drill bucket, shaped for the drawer table. */public List<Map<String, Object>> drill(String bucketType, String bucketKey, Integer regionId,LocalDateTime from, LocalDateTime to) {LocalDateTime now = LocalDateTime.now();List<Integer> ids = leadRepository.selectLeadIdsForBucket(bucketType, bucketKey, regionId,now, now.plusHours(1), from, to, DRILL_LIMIT);List<Map<String, Object>> out = new ArrayList<>();if (ids == null || ids.isEmpty()) {return out;}// selectAllByIds returns id-desc; preserve the bucket's newest-first order.Map<Integer, Lead> leadById = new HashMap<>();for (Lead l : leadRepository.selectAllByIds(ids)) {leadById.put(l.getId(), l);}// Owner names in one batch.java.util.Set<Integer> ownerIds = new java.util.HashSet<>();for (Lead l : leadById.values()) {if (l.getAssignTo() > 0) {ownerIds.add(l.getAssignTo());}}Map<Integer, AuthUser> owners = new HashMap<>();if (!ownerIds.isEmpty()) {for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ownerIds))) {owners.put(u.getId(), u);}}for (Integer id : ids) {Lead l = leadById.get(id);if (l == null) {continue;}LeadStage eff = l.getEffectiveStage();AuthUser owner = owners.get(l.getAssignTo());Map<String, Object> r = new LinkedHashMap<>();r.put("id", l.getId());r.put("lmsCode", l.getLmsCode() != null ? l.getLmsCode() : ("Lead #" + l.getId()));r.put("name", trimName(l.getFirstName(), l.getLastName()));r.put("outlet", l.getOutLetName());r.put("path", l.getCreationPath());r.put("stage", eff != null ? eff.name() : null);r.put("stageLabel", pretty(eff));r.put("owner", owner != null ? owner.getFullName() : (l.getAssignTo() > 0 ? "#" + l.getAssignTo() : "—"));r.put("slaState", lmsAssignmentService.slaState(l));r.put("value", l.getPotential());out.add(r);}return out;}// ---- Lead Record (native, sts-mockup styled — read-only JSON) ---------------------------/** Full record for one lead: details + derived stage + SLA + append-only trail. Null if not found. */public Map<String, Object> record(int leadId) {Lead l = leadRepository.selectById(leadId);if (l == null) {return null;}LeadStage eff = l.getEffectiveStage();int stageIndex = -1;for (int i = 0; i < FUNNEL.length; i++) {if (FUNNEL[i] == eff) {stageIndex = i;}}boolean terminal = eff == LeadStage.NOT_INTERESTED || eff == LeadStage.DROPPED;AuthUser owner = l.getAssignTo() > 0 ? authRepository.selectById(l.getAssignTo()) : null;AuthUser bm = (l.getOwnerBmId() != null && l.getOwnerBmId() > 0) ? authRepository.selectById(l.getOwnerBmId()) : null;// Append-only trail, newest first.List<LeadActivity> acts = leadActivityRepository.selectBYLeadId(leadId);acts.sort((a, b) -> {LocalDateTime ta = a.getCreatedTimestamp(), tb = b.getCreatedTimestamp();if (ta == null && tb == null) return 0;if (ta == null) return 1;if (tb == null) return -1;return tb.compareTo(ta);});// Actor names in one batch.java.util.Set<Integer> ids = new java.util.HashSet<>();for (LeadActivity a : acts) {if (a.getAuthId() > 0) ids.add(a.getAuthId());}Map<Integer, AuthUser> actors = new HashMap<>();if (!ids.isEmpty()) {for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {actors.put(u.getId(), u);}}List<Map<String, Object>> trail = new ArrayList<>();for (LeadActivity a : acts) {AuthUser act = actors.get(a.getAuthId());Map<String, Object> t = new LinkedHashMap<>();t.put("when", a.getCreatedTimestamp() == null ? null : a.getCreatedTimestamp().toString());t.put("actor", act != null ? act.getFullName() : (a.getAuthId() > 0 ? "#" + a.getAuthId() : "System"));t.put("type", a.getCommunicationType() == null ? null : a.getCommunicationType().name());t.put("body", a.getRemark());t.put("scheduled", a.getSchelduleTimestamp() == null ? null : a.getSchelduleTimestamp().toString());trail.add(t);}Map<String, Object> m = new LinkedHashMap<>();m.put("id", l.getId());m.put("lmsCode", l.getLmsCode());m.put("name", trimName(l.getFirstName(), l.getLastName()));m.put("outlet", l.getOutLetName());m.put("stage", eff != null ? eff.name() : null);m.put("stageLabel", pretty(eff));m.put("stageIndex", stageIndex);m.put("terminal", terminal);m.put("created", l.getCreatedTimestamp() == null ? null : l.getCreatedTimestamp().toString());m.put("createdBy", owner != null ? owner.getFullName() : null); // best-effortm.put("path", l.getCreationPath());m.put("contact", l.getLeadMobile());m.put("location", joinLoc(l.getAddress(), l.getCity(), l.getState()));m.put("regionCode", l.getRegionCode());m.put("value", l.getPotential());m.put("source", l.getSource());m.put("disposition", l.getDisposition() == null ? null : l.getDisposition().name());m.put("ownerBm", bm != null ? bm.getFullName() : null);m.put("owner", owner != null ? owner.getFullName() : null);m.put("assignmentStatus", l.getAssignmentStatus());m.put("slaState", lmsAssignmentService.slaState(l));m.put("firstContactDue", l.getFirstContactDue() == null ? null : l.getFirstContactDue().toString());m.put("firstContactedAt", l.getFirstContactedAt() == null ? null : l.getFirstContactedAt().toString());m.put("unreachableCount", l.getUnreachableCount());m.put("trail", trail);return m;}private String joinLoc(String a, String c, String s) {StringBuilder sb = new StringBuilder();for (String p : new String[]{a, c, s}) {if (p != null && !p.trim().isEmpty()) {if (sb.length() > 0) sb.append(", ");sb.append(p.trim());}}return sb.toString();}// ---- helpers ----------------------------------------------------------------------------private long reachedAtLeast(Map<String, Long> stage, LeadStage at) {long sum = 0;boolean counting = false;for (LeadStage s : FUNNEL) {if (s == at) {counting = true;}if (counting) {sum += stage.getOrDefault(s.name(), 0L);}}return sum;}private Map<String, Long> toCountMap(List<Object[]> rows) {Map<String, Long> m = new HashMap<>();for (Object[] r : rows) {if (r[0] == null) {continue;}m.merge(String.valueOf(r[0]), ((Number) r[1]).longValue(), Long::sum);}return m;}private Map<String, Double> toSumMap(List<Object[]> rows) {Map<String, Double> m = new HashMap<>();for (Object[] r : rows) {if (r[0] == null) {continue;}double v = r[1] == null ? 0d : ((Number) r[1]).doubleValue();m.merge(String.valueOf(r[0]), v, Double::sum);}return m;}private long num(Object[] row, int i) {if (row == null || i >= row.length || row[i] == null) {return 0L;}return ((Number) row[i]).longValue();}private long pct(long part, long whole) {if (whole <= 0) {return 0;}return Math.round((part * 100.0) / whole);}private String str(Object o) {return o == null ? "" : String.valueOf(o);}private Map<String, Object> kpi(String key, String label, long value, String foot, String tone) {Map<String, Object> m = new LinkedHashMap<>();m.put("key", key);m.put("label", label);m.put("value", value);m.put("foot", foot);m.put("tone", tone);return m;}private Map<String, Object> funnelStep(String stage, String label, long count, long pct) {Map<String, Object> m = new LinkedHashMap<>();m.put("stage", stage);m.put("label", label);m.put("count", count);m.put("pct", pct);return m;}private Map<String, Object> attn(String key, String title, String detail, long count, String tone) {Map<String, Object> m = new LinkedHashMap<>();m.put("key", key);m.put("title", title);m.put("detail", detail);m.put("count", count);m.put("tone", tone);return m;}private String trimName(String first, String last) {String f = first == null ? "" : first.trim();String l = last == null ? "" : last.trim();return (f + " " + l).trim();}private LeadStage safeStage(String s) {try {return LeadStage.valueOf(s);} catch (Exception e) {return null;}}/** "NEW" → "New", "BEAT_PLANNED" → "Beat planned". */private String pretty(LeadStage s) {if (s == null) {return "—";}String[] parts = s.name().toLowerCase().split("_");StringBuilder sb = new StringBuilder();for (int i = 0; i < parts.length; i++) {if (parts[i].isEmpty()) {continue;}if (i == 0) {sb.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));} else {sb.append(' ').append(parts[i]);}}return sb.toString();}/** ISO yyyyww (YEARWEEK mode 3) → a short "w<week>" label. */private String weekLabel(int isoYearWeek) {int week = isoYearWeek % 100;return "W" + week;}}