Subversion Repositories SmartDukaan

Rev

Rev 36913 | Rev 36931 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
36877 vikas 1
package com.spice.profitmandi.dao.service;
2
 
3
import org.hibernate.Session;
4
import org.hibernate.SessionFactory;
5
import org.hibernate.query.NativeQuery;
6
import org.springframework.beans.factory.annotation.Autowired;
7
import org.springframework.stereotype.Service;
8
 
9
import java.util.*;
10
 
11
/**
12
 * Period analytics for the Beat Journey console — read-only native SQL, scoped to
36897 vikas 13
 * the caller's downline via the auth-id / dtr-id sets, with {@code IN (:ids)} scope
14
 * filters and blocks for KPIs, funnel, scorecard, coverage, utilization, deferrals,
15
 * travel, outcomes, and findings.
36877 vikas 16
 *
17
 * Empty scope is handled by substituting a sentinel id (-1) so every {@code IN}
18
 * clause is valid and simply returns zeros rather than throwing.
19
 */
20
@Service
21
public class BeatJourneyServiceImpl implements BeatJourneyService {
22
 
23
    @Autowired
24
    private SessionFactory sessionFactory;
25
 
26
    @Override
27
    public Map<String, Object> periodAnalytics(String start, String end, String today,
28
                                               List<Integer> authIds, List<Integer> dtrIds) {
29
        Session s = sessionFactory.getCurrentSession();
30
        List<Integer> auth = safeIds(authIds);
31
        List<Integer> dtr = safeIds(dtrIds);
32
 
33
        Map<String, Object> out = new LinkedHashMap<>();
34
        Map<String, Object> range = new LinkedHashMap<>();
35
        range.put("start", start);
36
        range.put("end", end);
37
        range.put("today", today);
38
        out.put("range", range);
39
        out.put("scopeCount", authIds == null ? 0 : authIds.size());
40
 
41
        List<Map<String, Object>> coverage = coverageByLevel(s, start, end, auth);
42
        Map<String, Object> funnel = funnel(s, start, end, auth, dtr);
36929 vikas 43
        Map<String, Object> summary = journeySummary(s, start, end, dtr, funnel);
36877 vikas 44
        Map<String, Object> deferral = deferral(s, start, end, auth);
45
        Map<String, Object> utilization = utilization(s, start, end, dtr);
46
        Map<String, Object> travel = travel(s, start, end, auth, dtr);
47
        Map<String, Object> outcomes = outcomes(s, start, end, auth);
48
        List<Map<String, Object>> scorecard = scorecard(s, start, end, auth, dtr);
49
        Map<String, Object> kpis = kpis(s, start, end, auth, dtr, coverage, funnel, deferral);
50
 
51
        out.put("kpis", kpis);
52
        out.put("funnel", funnel);
36929 vikas 53
        out.put("summary", summary);
36877 vikas 54
        out.put("coverageByLevel", coverage);
55
        out.put("scorecard", scorecard);
56
        out.put("utilization", utilization);
57
        out.put("deferral", deferral);
58
        out.put("travel", travel);
59
        out.put("outcomes", outcomes);
60
        out.put("findings", findings(kpis, funnel, deferral, utilization, travel, outcomes, coverage, scorecard));
36913 vikas 61
        // drill-down lists
62
        out.put("coverageDetail", coverageDetail(s, start, end, auth));
63
        out.put("deferralsList", deferralsList(s, start, end, auth));
64
        out.put("leadsList", leadsList(s, start, end, auth));
36877 vikas 65
        return out;
66
    }
67
 
68
    // ---- KPIs ---------------------------------------------------------------
69
 
70
    private Map<String, Object> kpis(Session s, String ms, String me, List<Integer> auth, List<Integer> dtr,
71
                                     List<Map<String, Object>> coverage, Map<String, Object> funnel, Map<String, Object> deferral) {
72
        Map<String, Object> m = new LinkedHashMap<>();
73
 
74
        long planned = asLong(funnel.get("plannedStops"));
36929 vikas 75
        // Adherence is planned-visit completion: the numerator counts only completed
76
        // PLANNED visits (partner franchisee + approved leads), not every check-out
77
        // (ad-hoc office/warehouse stops don't count toward planned-visit adherence).
78
        // Self-assigned stops are excluded: task_name is pipe-delimited and ends with
79
        // a "… | SELF" marker for self-assigned visits, which aren't part of the plan.
80
        long done = countVisits(s, ms, me, dtr, "task_type IN ('franchisee-visit','lead') AND COALESCE(task_name,'') NOT LIKE '%SELF%' AND mark_type LIKE '%CHECKOUT%'");
36877 vikas 81
        m.put("plannedStops", planned);
82
        m.put("doneStops", done);
83
        m.put("adherencePct", planned == 0 ? 0 : Math.round(100.0 * done / planned));
84
 
85
        // L1 coverage row
86
        long l1With = 0, l1Total = 0;
87
        for (Map<String, Object> c : coverage) {
88
            if ("L1".equalsIgnoreCase(asStr(c.get("level")))) {
89
                l1With = asLong(c.get("withPjp"));
90
                l1Total = asLong(c.get("totalUsers"));
91
            }
92
        }
93
        m.put("coverageL1With", l1With);
94
        m.put("coverageL1Total", l1Total);
95
        m.put("coverageL1Pct", l1Total == 0 ? 0 : Math.round(100.0 * l1With / l1Total));
96
 
97
        // avg visits / active (punched-in) day, and total punch-in days (journeys)
36913 vikas 98
        // A "punch-in day" is the attendance row (task_id=0) with a real check-in
99
        // time, matching the Today lens. (Literal mark_type='PUNCHIN' rows barely
100
        // exist in the data, so relying on them under-counts drastically.)
36877 vikas 101
        NativeQuery<?> q = s.createNativeQuery(
102
            "SELECT COUNT(DISTINCT CONCAT(user_id,'-',task_date)) FROM auth.location_tracking" +
36913 vikas 103
            " WHERE task_date>=:ms AND task_date<=:me AND user_id IN (:dtr)" +
104
            "   AND (mark_type='PUNCHIN' OR (task_id=0 AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00'))");
36877 vikas 105
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
106
        long journeys = asLong(q.uniqueResult());
107
        m.put("journeys", journeys);
108
        m.put("avgVisitsPerActiveDay", journeys == 0 ? 0 : round1((double) asLong(funnel.get("checkedIn")) / journeys));
36929 vikas 109
        // partner (franchisee) visits per active day — the subset that are partner
110
        // store visits, shown alongside the all-types visit average.
111
        long partnerCheckins = countVisits(s, ms, me, dtr, "task_type='franchisee-visit' AND mark_type LIKE 'CHECKIN%'");
112
        m.put("avgPartnersPerActiveDay", journeys == 0 ? 0 : round1((double) partnerCheckins / journeys));
113
        // lead visits per active day, split into planned (on the PJP / lead_route,
114
        // no SELF marker) vs unplanned (self-assigned ad-hoc leads, task_name carries
115
        // the pipe-delimited "SELF" tag).
116
        long plannedLeadCheckins   = countVisits(s, ms, me, dtr, "task_type='lead' AND COALESCE(task_name,'') NOT LIKE '%SELF%' AND mark_type LIKE 'CHECKIN%'");
117
        long unplannedLeadCheckins = countVisits(s, ms, me, dtr, "task_type='lead' AND COALESCE(task_name,'') LIKE '%SELF%' AND mark_type LIKE 'CHECKIN%'");
118
        m.put("avgPlannedLeadsPerActiveDay",   journeys == 0 ? 0 : round1((double) plannedLeadCheckins / journeys));
119
        m.put("avgUnplannedLeadsPerActiveDay", journeys == 0 ? 0 : round1((double) unplannedLeadCheckins / journeys));
36877 vikas 120
 
36929 vikas 121
        // avg in-store discussion minutes — overall, plus partner (franchisee) and
122
        // lead split so the card can show each separately.
123
        m.put("avgDiscussionMin", avgDiscussionMin(s, ms, me, dtr, null));
124
        m.put("avgDiscussionPartnerMin", avgDiscussionMin(s, ms, me, dtr, "franchisee-visit"));
125
        m.put("avgDiscussionLeadMin", avgDiscussionMin(s, ms, me, dtr, "lead"));
36877 vikas 126
 
36929 vikas 127
        // Auto "missed" = situations where planned execution broke down, split into
128
        // three causes: execs with no PJP at all, scheduled beat-days with the agenda
129
        // left unfilled, and scheduled beat-days the owner never punched in on.
130
        Map<String, Object> mb = missedBreakdown(s, ms, me, auth, coverage);
131
        m.put("autoMissedUnscheduled", mb.get("unscheduled"));
132
        m.put("autoMissedUnfilledAgenda", mb.get("unfilledAgenda"));
133
        m.put("autoMissedMissedPunchin", mb.get("missedPunchin"));
134
        m.put("autoMissed", mb.get("total"));
36877 vikas 135
        m.put("autoMissedSystemPct", asLong(deferral.get("systemPct")));
136
 
137
        // leads created on route
138
        NativeQuery<?> lq = s.createNativeQuery(
139
            "SELECT COUNT(*) FROM `user`.lead WHERE created_timestamp>=:ms AND created_timestamp<=CONCAT(:me,' 23:59:59') AND auth_id IN (:auth)");
140
        lq.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
141
        m.put("leadsCreated", asLong(lq.uniqueResult()));
142
        return m;
143
    }
144
 
145
    // ---- Funnel -------------------------------------------------------------
146
 
147
    private Map<String, Object> funnel(Session s, String ms, String me, List<Integer> auth, List<Integer> dtr) {
148
        Map<String, Object> m = new LinkedHashMap<>();
149
 
36913 vikas 150
        // Planned stops = partner stops (beat_route) + lead stops (lead_route, APPROVED)
151
        // on each scheduled beat-day, over active beats — exactly how the Today lens
152
        // (getAllScheduledBeats) counts, so Period reconciles with the day views.
153
        long planned = plannedCount(s, ms, me, auth, dtr, false, false)
154
                     + plannedCount(s, ms, me, auth, dtr, true, false);
155
        // ...of those, the planned stops that fall on a day the owner punched in
156
        long punchedIn = plannedCount(s, ms, me, auth, dtr, false, true)
157
                       + plannedCount(s, ms, me, auth, dtr, true, true);
36877 vikas 158
 
159
        long checkedIn = countVisits(s, ms, me, dtr, "mark_type LIKE 'CHECKIN%'");
160
        long checkedOut = countVisits(s, ms, me, dtr, "(mark_type LIKE '%CHECKOUT%')");
161
 
162
        m.put("plannedStops", planned);
163
        m.put("punchedIn", punchedIn);
164
        m.put("checkedIn", checkedIn);
165
        m.put("checkedOut", checkedOut);
166
        m.put("dropPunch", planned == 0 ? 0 : Math.round(100.0 * (planned - punchedIn) / planned));
167
        m.put("dropCheckin", punchedIn == 0 ? 0 : Math.round(100.0 * (punchedIn - checkedIn) / punchedIn));
168
        m.put("dropCheckout", checkedIn == 0 ? 0 : Math.round(100.0 * (checkedIn - checkedOut) / checkedIn));
169
        return m;
170
    }
171
 
36913 vikas 172
    // Planned stop count over scheduled beat-days of active beats. lead=false counts
173
    // partner stops (beat_route by day_number); lead=true counts approved lead stops
174
    // (lead_route by schedule_date). onlyPunchInDays restricts to days the owner
175
    // actually punched in (the funnel's "planned on punch-in days" stage).
176
    private long plannedCount(Session s, String ms, String me, List<Integer> auth,
177
                              List<Integer> dtr, boolean lead, boolean onlyPunchInDays) {
178
        String stopJoin = lead
179
            ? " JOIN `user`.lead_route x ON x.beat_id=sc.beat_id AND x.schedule_date=sc.start_date AND x.status='APPROVED'"
180
            : " JOIN `user`.beat_route x ON x.beat_id=sc.beat_id AND x.day_number=sc.day_number";
181
        String punchJoin = onlyPunchInDays
182
            ? " JOIN auth.auth_user au ON au.id=b.auth_user_id" +
183
              " JOIN dtr.users du ON du.email=au.email_id" +
184
              " JOIN (SELECT DISTINCT user_id, task_date FROM auth.location_tracking" +
185
              "        WHERE task_date>=:ms AND task_date<=:me AND user_id IN (:dtr)" +
186
              "          AND (mark_type='PUNCHIN' OR (task_id=0 AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00'))) p" +
187
              "   ON p.user_id=du.id AND p.task_date=sc.start_date"
188
            : "";
189
        NativeQuery<?> q = s.createNativeQuery(
190
            "SELECT COUNT(*) FROM `user`.beat_schedule sc" +
191
            " JOIN `user`.beat b ON b.id=sc.beat_id AND b.active=1 AND b.auth_user_id IN (:auth)" +
192
            stopJoin + punchJoin +
193
            " WHERE sc.start_date>=:ms AND sc.start_date<=:me");
194
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
195
        if (onlyPunchInDays) q.setParameterList("dtr", dtr);
196
        return asLong(q.uniqueResult());
197
    }
198
 
199
    // Counts visits across ALL task types (franchisee + lead + office) — task_id<>0
200
    // excludes the attendance/punch row — matching the Today lens, which tallies
201
    // every check-in/out regardless of type.
36877 vikas 202
    private long countVisits(Session s, String ms, String me, List<Integer> dtr, String markPredicate) {
203
        NativeQuery<?> q = s.createNativeQuery(
204
            "SELECT COUNT(*) FROM auth.location_tracking" +
36913 vikas 205
            " WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND " + markPredicate +
36877 vikas 206
            " AND user_id IN (:dtr)");
207
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
208
        return asLong(q.uniqueResult());
209
    }
210
 
36929 vikas 211
    // Avg in-store discussion minutes over checked-out visits. taskType null =
212
    // all visit types; otherwise restricted to that task_type (e.g. franchisee / lead).
213
    private long avgDiscussionMin(Session s, String ms, String me, List<Integer> dtr, String taskType) {
214
        NativeQuery<?> q = s.createNativeQuery(
215
            "SELECT ROUND(AVG(TIME_TO_SEC(check_out_time)-TIME_TO_SEC(check_in_time))/60,0) FROM auth.location_tracking" +
216
            " WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND mark_type LIKE '%CHECKOUT%'" +
217
            " AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00'" +
218
            " AND check_out_time IS NOT NULL AND check_out_time<>'00:00:00'" +
219
            " AND TIME_TO_SEC(check_out_time) > TIME_TO_SEC(check_in_time)" +
220
            (taskType != null ? " AND task_type=:tt" : "") +
221
            " AND user_id IN (:dtr)");
222
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
223
        if (taskType != null) q.setParameter("tt", taskType);
224
        return asLong(q.uniqueResult());
225
    }
226
 
227
    // ---- Journey summary table ---------------------------------------------
228
    // A flat per-period scorecard split into Planned (on the PJP / lead_route, no
229
    // SELF marker) vs Self Assigned (the ad-hoc "… | SELF" visits the exec added):
230
    //   Total Visits   — planned = scheduled planned stops; self = self-assigned check-ins
231
    //   Avg Time Spent — avg (check-out − check-in) minutes, each side
232
    //   Idle/Break     — average idle minutes per active day (single value)
233
    //   Deferred       — total − completed, each side (what was never closed out)
234
    //   Completed      — checkouts, each side
235
    private Map<String, Object> journeySummary(Session s, String ms, String me, List<Integer> dtr,
236
                                               Map<String, Object> funnel) {
237
        long plannedVisits   = asLong(funnel.get("plannedStops"));
238
        long completedPlanned = countVisits(s, ms, me, dtr, "task_type IN ('franchisee-visit','lead') AND COALESCE(task_name,'') NOT LIKE '%SELF%' AND mark_type LIKE '%CHECKOUT%'");
239
        long selfVisits      = countVisits(s, ms, me, dtr, "COALESCE(task_name,'') LIKE '%SELF%' AND mark_type LIKE 'CHECKIN%'");
240
        long completedSelf   = countVisits(s, ms, me, dtr, "COALESCE(task_name,'') LIKE '%SELF%' AND mark_type LIKE '%CHECKOUT%'");
241
 
242
        // idle = working − in-store − travel, averaged over active (punch-in) days
243
        NativeQuery<?> uq = s.createNativeQuery(
244
            "SELECT" +
245
            " (SELECT COALESCE(SUM(TIME_TO_SEC(time_spent)),0) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND user_id IN (:dtr)) instore," +
246
            " (SELECT COALESCE(SUM(TIME_TO_SEC(transit_time)),0) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND user_id IN (:dtr)) travel," +
247
            " (SELECT COALESCE(SUM(GREATEST(TIME_TO_SEC(check_out_time)-TIME_TO_SEC(check_in_time),0)),0) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_id=0 AND check_out_time IS NOT NULL AND check_out_time<>'00:00:00' AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00' AND user_id IN (:dtr)) working," +
248
            " (SELECT COUNT(DISTINCT CONCAT(user_id,'-',task_date)) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND user_id IN (:dtr) AND (mark_type='PUNCHIN' OR (task_id=0 AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00'))) journeys");
249
        uq.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
250
        Object[] ur = (Object[]) uq.uniqueResult();
251
        double instore = asDouble(ur[0]), travel = asDouble(ur[1]), working = asDouble(ur[2]);
252
        long journeys = asLong(ur[3]);
253
        if (working < instore + travel) working = instore + travel;
254
        double idle = Math.max(working - instore - travel, 0);
255
        long idleAvgMin = journeys == 0 ? 0 : Math.round(idle / journeys / 60.0);
256
 
257
        Map<String, Object> m = new LinkedHashMap<>();
258
        m.put("plannedVisits", plannedVisits);
259
        m.put("selfVisits", selfVisits);
260
        m.put("avgTimePlannedMin", avgTimeSpentMin(s, ms, me, dtr, false));
261
        m.put("avgTimeSelfMin", avgTimeSpentMin(s, ms, me, dtr, true));
262
        m.put("idleAvgMin", idleAvgMin);
263
        m.put("deferredPlanned", Math.max(plannedVisits - completedPlanned, 0));
264
        m.put("deferredSelf", Math.max(selfVisits - completedSelf, 0));
265
        m.put("completedPlanned", completedPlanned);
266
        m.put("completedSelf", completedSelf);
267
        return m;
268
    }
269
 
270
    // Avg (check-out − check-in) minutes over checked-out visits, split by whether
271
    // the visit carries the pipe-delimited "… | SELF" self-assigned marker.
272
    private long avgTimeSpentMin(Session s, String ms, String me, List<Integer> dtr, boolean self) {
273
        NativeQuery<?> q = s.createNativeQuery(
274
            "SELECT ROUND(AVG(TIME_TO_SEC(check_out_time)-TIME_TO_SEC(check_in_time))/60,0) FROM auth.location_tracking" +
275
            " WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND mark_type LIKE '%CHECKOUT%'" +
276
            " AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00'" +
277
            " AND check_out_time IS NOT NULL AND check_out_time<>'00:00:00'" +
278
            " AND TIME_TO_SEC(check_out_time) > TIME_TO_SEC(check_in_time)" +
279
            (self ? " AND COALESCE(task_name,'') LIKE '%SELF%'" : " AND COALESCE(task_name,'') NOT LIKE '%SELF%'") +
280
            " AND user_id IN (:dtr)");
281
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
282
        return asLong(q.uniqueResult());
283
    }
284
 
36877 vikas 285
    // ---- Coverage by level --------------------------------------------------
286
 
287
    private List<Map<String, Object>> coverageByLevel(Session s, String ms, String me, List<Integer> auth) {
288
        NativeQuery<?> q = s.createNativeQuery(
289
            "SELECT p.escalation_type lvl, COUNT(DISTINCT p.auth_user_id) total_users," +
290
            " COUNT(DISTINCT CASE WHEN sc.id IS NOT NULL THEN p.auth_user_id END) with_pjp" +
291
            " FROM cs.position p" +
292
            " LEFT JOIN `user`.beat b ON b.auth_user_id=p.auth_user_id AND b.active=1" +
293
            " LEFT JOIN `user`.beat_schedule sc ON sc.beat_id=b.id AND sc.start_date>=:ms AND sc.start_date<=:me" +
294
            " WHERE p.category_id=4 AND p.auth_user_id IN (:auth) GROUP BY p.escalation_type ORDER BY p.escalation_type");
295
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
296
        List<Map<String, Object>> list = new ArrayList<>();
297
        for (Object o : q.getResultList()) {
298
            Object[] r = (Object[]) o;
299
            long total = asLong(r[1]), with = asLong(r[2]);
300
            Map<String, Object> m = new LinkedHashMap<>();
301
            m.put("level", asStr(r[0]));
302
            m.put("totalUsers", total);
303
            m.put("withPjp", with);
304
            m.put("coveragePct", total == 0 ? 0 : Math.round(100.0 * with / total));
305
            list.add(m);
306
        }
307
        return list;
308
    }
309
 
310
    // ---- Per-executive scorecard -------------------------------------------
311
 
312
    private List<Map<String, Object>> scorecard(Session s, String ms, String me, List<Integer> auth, List<Integer> dtr) {
313
        NativeQuery<?> q = s.createNativeQuery(
314
            "SELECT au.id uid, TRIM(CONCAT(au.first_name,' ',COALESCE(au.last_name,''))) nm, lvl.lvl," +
36913 vikas 315
            " COALESCE(pl.planned,0)+COALESCE(plL.planned,0) planned, COALESCE(ac.done,0) done," +
36877 vikas 316
            " COALESCE(ds.disc_min,0) disc_min, COALESCE(wk.work_sec,0) work_sec," +
36929 vikas 317
            " COALESCE(ld.leads,0) leads, du.id duid, COALESCE(acp.donep,0) donep" +
36877 vikas 318
            " FROM (SELECT DISTINCT auth_user_id FROM `user`.beat WHERE active=1 AND auth_user_id IN (:auth)) o" +
319
            " JOIN auth.auth_user au ON au.id=o.auth_user_id" +
320
            " LEFT JOIN dtr.users du ON du.email=au.email_id" +
321
            " LEFT JOIN (SELECT auth_user_id, MIN(escalation_type) lvl FROM cs.position WHERE category_id=4 GROUP BY auth_user_id) lvl ON lvl.auth_user_id=au.id" +
322
            " LEFT JOIN (SELECT b.auth_user_id uid, COUNT(*) planned FROM `user`.beat_schedule sc" +
36913 vikas 323
            "   JOIN `user`.beat b ON b.id=sc.beat_id AND b.active=1 JOIN `user`.beat_route r ON r.beat_id=sc.beat_id AND r.day_number=sc.day_number" +
36877 vikas 324
            "   WHERE sc.start_date>=:ms AND sc.start_date<=:me GROUP BY b.auth_user_id) pl ON pl.uid=au.id" +
36913 vikas 325
            " LEFT JOIN (SELECT b.auth_user_id uid, COUNT(*) planned FROM `user`.beat_schedule sc" +
326
            "   JOIN `user`.beat b ON b.id=sc.beat_id AND b.active=1 JOIN `user`.lead_route lr ON lr.beat_id=sc.beat_id AND lr.schedule_date=sc.start_date AND lr.status='APPROVED'" +
327
            "   WHERE sc.start_date>=:ms AND sc.start_date<=:me GROUP BY b.auth_user_id) plL ON plL.uid=au.id" +
36877 vikas 328
            " LEFT JOIN (SELECT user_id uid, COUNT(*) done FROM auth.location_tracking" +
36913 vikas 329
            "   WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND mark_type LIKE 'CHECKIN%' GROUP BY user_id) ac ON ac.uid=du.id" +
36929 vikas 330
            // Adherence numerator: completed PLANNED visits only (partner franchisee +
331
            // approved leads), excluding ad-hoc office/warehouse stops and self-assigned
332
            // stops (task_name carries a "… | SELF" marker, which aren't part of the plan).
333
            " LEFT JOIN (SELECT user_id uid, COUNT(*) donep FROM auth.location_tracking" +
334
            "   WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND task_type IN ('franchisee-visit','lead') AND COALESCE(task_name,'') NOT LIKE '%SELF%' AND mark_type LIKE '%CHECKOUT%' GROUP BY user_id) acp ON acp.uid=du.id" +
36877 vikas 335
            " LEFT JOIN (SELECT user_id uid, ROUND(AVG(NULLIF(TIME_TO_SEC(time_spent),0))/60,0) disc_min FROM auth.location_tracking" +
36913 vikas 336
            "   WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND mark_type LIKE '%CHECKOUT%' GROUP BY user_id) ds ON ds.uid=du.id" +
36877 vikas 337
            " LEFT JOIN (SELECT user_id uid, SUM(GREATEST(TIME_TO_SEC(check_out_time)-TIME_TO_SEC(check_in_time),0)) work_sec FROM auth.location_tracking" +
338
            "   WHERE task_date>=:ms AND task_date<=:me AND task_id=0 AND check_out_time IS NOT NULL AND check_out_time<>'00:00:00' AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00' GROUP BY user_id) wk ON wk.uid=du.id" +
339
            " LEFT JOIN (SELECT auth_id uid, COUNT(*) leads FROM `user`.lead WHERE created_timestamp>=:ms AND created_timestamp<=CONCAT(:me,' 23:59:59') GROUP BY auth_id) ld ON ld.uid=au.id" +
340
            " ORDER BY done DESC, planned DESC");
341
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
342
 
343
        // Geofence-offsite check-ins per user are computed in Java (not SQL): the
344
        // coords are stored as "lat,lng" strings, and the inline parsing/haversine
345
        // expression trips Hibernate's native-query parameter parser.
346
        Map<Integer, Integer> geoByUser = geoOffsiteByUser(s, ms, me, dtr);
347
 
348
        List<Map<String, Object>> list = new ArrayList<>();
349
        for (Object o : q.getResultList()) {
350
            Object[] r = (Object[]) o;
36929 vikas 351
            long planned = asLong(r[3]), done = asLong(r[4]), donePlanned = asLong(r[9]);
36913 vikas 352
            Integer duid = (r[8] == null) ? null : ((Number) r[8]).intValue();
36877 vikas 353
            Map<String, Object> m = new LinkedHashMap<>();
36913 vikas 354
            m.put("authUserId", asLong(r[0]));
355
            m.put("userId", duid == null ? 0 : duid);
36877 vikas 356
            m.put("name", asStr(r[1]));
357
            m.put("level", r[2] == null ? "-" : asStr(r[2]));
358
            m.put("planned", planned);
359
            m.put("done", done);
36929 vikas 360
            // Adherence = completed PLANNED visits ÷ planned visits (not all visits).
361
            m.put("adherencePct", planned == 0 ? 0 : Math.round(100.0 * donePlanned / planned));
36877 vikas 362
            m.put("discussionMin", asLong(r[5]));
363
            m.put("workingHrs", round1(asDouble(r[6]) / 3600.0));
364
            m.put("leads", asLong(r[7]));
365
            m.put("geoFlags", duid == null ? 0 : geoByUser.getOrDefault(duid, 0));
366
            list.add(m);
367
        }
368
        return list;
369
    }
370
 
371
    // Per dtr-user count of check-ins recorded >50 m from the partner's saved
372
    // location. Pulls the raw "lat,lng" strings and does the haversine in Java.
373
    private Map<Integer, Integer> geoOffsiteByUser(Session s, String ms, String me, List<Integer> dtr) {
374
        NativeQuery<?> q = s.createNativeQuery(
375
            "SELECT user_id, checkin_lat_lng, visit_location FROM auth.location_tracking" +
376
            " WHERE task_date>=:ms AND task_date<=:me AND task_type='franchisee-visit' AND mark_type LIKE 'CHECKIN%'" +
377
            " AND checkin_lat_lng LIKE '%,%' AND visit_location LIKE '%,%' AND user_id IN (:dtr)");
378
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
379
        Map<Integer, Integer> out = new HashMap<>();
380
        for (Object o : q.getResultList()) {
381
            Object[] r = (Object[]) o;
382
            if (r[0] == null) continue;
383
            double[] cin = parseLatLng(asStr(r[1]));
384
            double[] vis = parseLatLng(asStr(r[2]));
385
            if (cin == null || vis == null) continue;
386
            if (haversineMeters(cin[0], cin[1], vis[0], vis[1]) > 50.0) {
387
                int uid = ((Number) r[0]).intValue();
388
                out.merge(uid, 1, Integer::sum);
389
            }
390
        }
391
        return out;
392
    }
393
 
394
    private static double[] parseLatLng(String s) {
395
        if (s == null || !s.contains(",")) return null;
396
        String[] parts = s.split(",");
397
        if (parts.length != 2) return null;
398
        try {
399
            double a = Double.parseDouble(parts[0].trim());
400
            double b = Double.parseDouble(parts[1].trim());
401
            if (a == 0 && b == 0) return null;
402
            return new double[]{a, b};
403
        } catch (NumberFormatException e) {
404
            return null;
405
        }
406
    }
407
 
408
    private static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
409
        double R = 6371000d;
410
        double dLat = Math.toRadians(lat2 - lat1);
411
        double dLng = Math.toRadians(lng2 - lng1);
412
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
413
                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
414
                * Math.sin(dLng / 2) * Math.sin(dLng / 2);
415
        return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
416
    }
417
 
418
    // ---- Working-hour utilization ------------------------------------------
419
 
420
    private Map<String, Object> utilization(Session s, String ms, String me, List<Integer> dtr) {
421
        NativeQuery<?> q = s.createNativeQuery(
422
            "SELECT" +
36913 vikas 423
            " (SELECT COALESCE(SUM(TIME_TO_SEC(time_spent)),0) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND user_id IN (:dtr)) instore," +
424
            " (SELECT COALESCE(SUM(TIME_TO_SEC(transit_time)),0) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_id<>0 AND user_id IN (:dtr)) travel," +
36877 vikas 425
            " (SELECT COALESCE(SUM(GREATEST(TIME_TO_SEC(check_out_time)-TIME_TO_SEC(check_in_time),0)),0) FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_id=0 AND check_out_time IS NOT NULL AND check_out_time<>'00:00:00' AND check_in_time IS NOT NULL AND check_in_time<>'00:00:00' AND user_id IN (:dtr)) working");
426
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
427
        Object[] r = (Object[]) q.uniqueResult();
428
        double instore = asDouble(r[0]), travel = asDouble(r[1]), working = asDouble(r[2]);
429
        if (working < instore + travel) working = instore + travel; // guard: working must cover its parts
430
        double idle = Math.max(working - instore - travel, 0);
431
        Map<String, Object> m = new LinkedHashMap<>();
432
        m.put("inStoreHrs", round1(instore / 3600.0));
433
        m.put("travelHrs", round1(travel / 3600.0));
434
        m.put("idleHrs", round1(idle / 3600.0));
435
        m.put("workingHrs", round1(working / 3600.0));
436
        m.put("inStorePct", working == 0 ? 0 : (int) Math.round(100.0 * instore / working));
437
        m.put("travelPct", working == 0 ? 0 : (int) Math.round(100.0 * travel / working));
438
        m.put("idlePct", working == 0 ? 0 : (int) Math.round(100.0 * idle / working));
439
        return m;
440
    }
441
 
442
    // ---- Deferrals & SLA ----------------------------------------------------
443
 
444
    private Map<String, Object> deferral(Session s, String ms, String me, List<Integer> auth) {
445
        NativeQuery<?> q = s.createNativeQuery(
446
            "SELECT COUNT(*) total," +
447
            " SUM(status='RESCHEDULED') rescheduled, SUM(status='CANCELLED') cancelled," +
448
            " SUM(action_by IS NOT NULL) actioned," +
449
            " SUM(reason LIKE 'Beat missed%' OR reason LIKE 'Not marked%') system_gen," +
450
            " ROUND(AVG(CASE WHEN action_by IS NOT NULL THEN TIMESTAMPDIFF(HOUR, created_timestamp, updated_timestamp)/24.0 END),1) avg_days" +
451
            " FROM `user`.beat_deferred_visit WHERE deferred_date>=:ms AND deferred_date<=:me AND auth_user_id IN (:auth)");
452
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
453
        Object[] r = (Object[]) q.uniqueResult();
454
        long total = asLong(r[0]), system = asLong(r[4]);
455
        long human = total - system;
456
        Map<String, Object> m = new LinkedHashMap<>();
457
        m.put("total", total);
458
        m.put("rescheduled", asLong(r[1]));
459
        m.put("cancelled", asLong(r[2]));
460
        m.put("actioned", asLong(r[3]));
461
        m.put("systemGenerated", system);
462
        m.put("human", human);
463
        m.put("avgDaysToAction", asDouble(r[5]));
464
        m.put("systemPct", total == 0 ? 0 : Math.round(100.0 * system / total));
465
        m.put("humanPct", total == 0 ? 0 : Math.round(100.0 * human / total));
466
        return m;
467
    }
468
 
36929 vikas 469
    // ---- Auto "missed" breakdown -------------------------------------------
470
    // Three distinct execution gaps behind the "Auto missed" card:
471
    //   unscheduled    — executives (sales positions) with no PJP at all in range
472
    //   unfilledAgenda — scheduled beat-days whose agenda was never filled
473
    //   missedPunchin  — scheduled beat-days the owner never punched in on
474
    private Map<String, Object> missedBreakdown(Session s, String ms, String me,
475
                                                List<Integer> auth, List<Map<String, Object>> coverage) {
476
        // unscheduled execs: total at each level minus those with a plan (reuse coverage)
477
        long unscheduled = 0;
478
        for (Map<String, Object> c : coverage) {
479
            unscheduled += Math.max(asLong(c.get("totalUsers")) - asLong(c.get("withPjp")), 0);
480
        }
481
 
482
        NativeQuery<?> ua = s.createNativeQuery(
483
            "SELECT COUNT(*) FROM `user`.beat_schedule sc" +
484
            " JOIN `user`.beat b ON b.id=sc.beat_id AND b.active=1 AND b.auth_user_id IN (:auth)" +
485
            " WHERE sc.start_date>=:ms AND sc.start_date<=:me AND sc.agenda_filled_timestamp IS NULL");
486
        ua.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
487
        long unfilledAgenda = asLong(ua.uniqueResult());
488
 
489
        NativeQuery<?> mp = s.createNativeQuery(
490
            "SELECT COUNT(*) FROM `user`.beat_schedule sc" +
491
            " JOIN `user`.beat b ON b.id=sc.beat_id AND b.active=1 AND b.auth_user_id IN (:auth)" +
492
            " JOIN auth.auth_user au ON au.id=b.auth_user_id" +
493
            " JOIN dtr.users du ON du.email=au.email_id" +
494
            " WHERE sc.start_date>=:ms AND sc.start_date<=:me AND NOT EXISTS (" +
495
            "   SELECT 1 FROM auth.location_tracking lt WHERE lt.user_id=du.id AND lt.task_date=sc.start_date" +
496
            "     AND (lt.mark_type='PUNCHIN' OR (lt.task_id=0 AND lt.check_in_time IS NOT NULL AND lt.check_in_time<>'00:00:00')))");
497
        mp.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
498
        long missedPunchin = asLong(mp.uniqueResult());
499
 
500
        Map<String, Object> m = new LinkedHashMap<>();
501
        m.put("unscheduled", unscheduled);
502
        m.put("unfilledAgenda", unfilledAgenda);
503
        m.put("missedPunchin", missedPunchin);
504
        m.put("total", unscheduled + unfilledAgenda + missedPunchin);
505
        return m;
506
    }
507
 
36913 vikas 508
    // ---- Drill-down lists ---------------------------------------------------
509
 
510
    // Per-executive PJP coverage (with/without a plan in range) — powers the
511
    // PJP coverage KPI and the coverage-by-level bar drill-downs.
512
    private List<Map<String, Object>> coverageDetail(Session s, String ms, String me, List<Integer> auth) {
513
        NativeQuery<?> q = s.createNativeQuery(
514
            "SELECT au.id uid, TRIM(CONCAT(au.first_name,' ',COALESCE(au.last_name,''))) nm, p.lvl," +
515
            " MAX(CASE WHEN sc.id IS NOT NULL THEN 1 ELSE 0 END) has_plan" +
516
            " FROM (SELECT auth_user_id, MIN(escalation_type) lvl FROM cs.position WHERE category_id=4 AND auth_user_id IN (:auth) GROUP BY auth_user_id) p" +
517
            " JOIN auth.auth_user au ON au.id=p.auth_user_id" +
518
            " LEFT JOIN `user`.beat b ON b.auth_user_id=p.auth_user_id AND b.active=1" +
519
            " LEFT JOIN `user`.beat_schedule sc ON sc.beat_id=b.id AND sc.start_date>=:ms AND sc.start_date<=:me" +
520
            " GROUP BY au.id, nm, p.lvl ORDER BY p.lvl, nm");
521
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
522
        List<Map<String, Object>> list = new ArrayList<>();
523
        for (Object o : q.getResultList()) {
524
            Object[] r = (Object[]) o;
525
            Map<String, Object> m = new LinkedHashMap<>();
526
            m.put("authUserId", asLong(r[0]));
527
            m.put("name", asStr(r[1]));
528
            m.put("level", asStr(r[2]));
529
            m.put("hasPlan", asLong(r[3]) > 0);
530
            list.add(m);
531
        }
532
        return list;
533
    }
534
 
535
    // Every deferral in range (exec, partner, reason, date, status, system flag).
536
    private List<Map<String, Object>> deferralsList(Session s, String ms, String me, List<Integer> auth) {
537
        NativeQuery<?> q = s.createNativeQuery(
538
            "SELECT TRIM(CONCAT(au.first_name,' ',COALESCE(au.last_name,''))) nm, d.display_name, d.reason," +
539
            " d.deferred_date, d.status, d.task_type," +
540
            " (d.reason LIKE 'Beat missed%' OR d.reason LIKE 'Not marked%') sys" +
541
            " FROM `user`.beat_deferred_visit d JOIN auth.auth_user au ON au.id=d.auth_user_id" +
542
            " WHERE d.deferred_date>=:ms AND d.deferred_date<=:me AND d.auth_user_id IN (:auth)" +
543
            " ORDER BY d.deferred_date, nm");
544
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
545
        List<Map<String, Object>> list = new ArrayList<>();
546
        for (Object o : q.getResultList()) {
547
            Object[] r = (Object[]) o;
548
            Map<String, Object> m = new LinkedHashMap<>();
549
            m.put("exec", asStr(r[0]));
550
            m.put("partner", asStr(r[1]));
551
            m.put("reason", asStr(r[2]));
552
            m.put("date", asStr(r[3]));
553
            m.put("status", asStr(r[4]));
554
            m.put("taskType", asStr(r[5]));
555
            m.put("system", asLong(r[6]) > 0);
556
            list.add(m);
557
        }
558
        return list;
559
    }
560
 
561
    // Every lead created in range (exec, outlet, city, date, status).
562
    private List<Map<String, Object>> leadsList(Session s, String ms, String me, List<Integer> auth) {
563
        NativeQuery<?> q = s.createNativeQuery(
564
            "SELECT TRIM(CONCAT(au.first_name,' ',COALESCE(au.last_name,''))) nm," +
565
            " COALESCE(NULLIF(l.outlet_name,''), TRIM(CONCAT(COALESCE(l.first_name,''),' ',COALESCE(l.last_name,'')))) lead," +
566
            " l.city, DATE(l.created_timestamp) dt, l.status" +
567
            " FROM `user`.lead l JOIN auth.auth_user au ON au.id=l.auth_id" +
568
            " WHERE l.created_timestamp>=:ms AND l.created_timestamp<=CONCAT(:me,' 23:59:59') AND l.auth_id IN (:auth)" +
569
            " ORDER BY dt, nm");
570
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
571
        List<Map<String, Object>> list = new ArrayList<>();
572
        for (Object o : q.getResultList()) {
573
            Object[] r = (Object[]) o;
574
            Map<String, Object> m = new LinkedHashMap<>();
575
            m.put("exec", asStr(r[0]));
576
            m.put("lead", asStr(r[1]));
577
            m.put("city", asStr(r[2]));
578
            m.put("date", asStr(r[3]));
579
            m.put("status", asStr(r[4]));
580
            list.add(m);
581
        }
582
        return list;
583
    }
584
 
36877 vikas 585
    // ---- Travel & route quality --------------------------------------------
586
 
587
    private Map<String, Object> travel(Session s, String ms, String me, List<Integer> auth, List<Integer> dtr) {
588
        Map<String, Object> m = new LinkedHashMap<>();
589
 
590
        String legFrom =
591
            " FROM `user`.beat_route a" +
592
            " JOIN `user`.beat bt ON bt.id=a.beat_id AND bt.auth_user_id IN (:auth)" +
593
            " JOIN `user`.beat_route b ON b.beat_id=a.beat_id AND b.day_number=a.day_number AND b.sequence_order=a.sequence_order+1 AND b.active=1" +
594
            " JOIN (SELECT id, CAST(latitude AS DECIMAL(10,7)) lat, CAST(longitude AS DECIMAL(10,7)) lng FROM fofo.fofo_store WHERE latitude REGEXP '^[0-9.]+$') al ON al.id=a.fofo_id" +
595
            " JOIN (SELECT id, CAST(latitude AS DECIMAL(10,7)) lat, CAST(longitude AS DECIMAL(10,7)) lng FROM fofo.fofo_store WHERE latitude REGEXP '^[0-9.]+$') bl ON bl.id=b.fofo_id" +
596
            " WHERE a.active=1";
597
        String legKm =
598
            "6371*2*ASIN(SQRT(POWER(SIN(RADIANS(bl.lat-al.lat)/2),2)+COS(RADIANS(al.lat))*COS(RADIANS(bl.lat))*POWER(SIN(RADIANS(bl.lng-al.lng)/2),2)))";
599
 
600
        NativeQuery<?> bq = s.createNativeQuery(
601
            "SELECT bucket, COUNT(*) n FROM (" +
602
            "  SELECT CASE WHEN km<2 THEN '0-2 km' WHEN km<5 THEN '2-5 km' WHEN km<15 THEN '5-15 km'" +
603
            "    WHEN km<50 THEN '15-50 km' ELSE '50 km+' END bucket," +
604
            "  CASE WHEN km<2 THEN 1 WHEN km<5 THEN 2 WHEN km<15 THEN 3 WHEN km<50 THEN 4 ELSE 5 END ord" +
605
            "  FROM (SELECT " + legKm + " km" + legFrom + ") l" +
606
            " ) z GROUP BY bucket, ord ORDER BY ord");
607
        bq.setParameterList("auth", auth);
608
        List<Map<String, Object>> buckets = new ArrayList<>();
609
        for (Object o : bq.getResultList()) {
610
            Object[] r = (Object[]) o;
611
            Map<String, Object> b = new LinkedHashMap<>();
612
            b.put("bucket", asStr(r[0]));
613
            b.put("count", asLong(r[1]));
614
            buckets.add(b);
615
        }
616
        m.put("legBuckets", buckets);
617
 
618
        NativeQuery<?> sq = s.createNativeQuery(
619
            "SELECT ROUND(AVG(km),1) avg_leg, ROUND(MAX(km),0) max_leg, SUM(km>15) over15, COUNT(*) total" +
620
            " FROM (SELECT " + legKm + " km" + legFrom + ") l");
621
        sq.setParameterList("auth", auth);
622
        Object[] sr = (Object[]) sq.uniqueResult();
623
        long totalLegs = asLong(sr[3]), over15 = asLong(sr[2]);
624
        m.put("avgLegKm", asDouble(sr[0]));
625
        m.put("maxLegKm", asDouble(sr[1]));
626
        m.put("pctOver15", totalLegs == 0 ? 0 : Math.round(100.0 * over15 / totalLegs));
627
 
628
        NativeQuery<?> tq = s.createNativeQuery(
629
            "SELECT ROUND(SUM(TIME_TO_SEC(transit_time))/3600,1) transit_hrs, ROUND(SUM(TIME_TO_SEC(estimated_time))/3600,1) est_hrs" +
630
            " FROM auth.location_tracking WHERE task_date>=:ms AND task_date<=:me AND task_type='franchisee-visit' AND user_id IN (:dtr)");
631
        tq.setParameter("ms", ms).setParameter("me", me).setParameterList("dtr", dtr);
632
        Object[] tr = (Object[]) tq.uniqueResult();
633
        double transit = asDouble(tr[0]), est = asDouble(tr[1]);
634
        m.put("actualVsEstimateRatio", est == 0 ? 0 : round1(transit / est));
635
        return m;
636
    }
637
 
638
    // ---- Outcomes & visit quality ------------------------------------------
639
 
640
    private Map<String, Object> outcomes(Session s, String ms, String me, List<Integer> auth) {
641
        NativeQuery<?> q = s.createNativeQuery(
642
            "SELECT COUNT(*) total," +
643
            " SUM(partner_remark IS NOT NULL AND TRIM(partner_remark)<>'') remark," +
644
            " ROUND(AVG(NULLIF(rbm_rating,0)),1) avg_rating," +
645
            " SUM(instore_visibility IS NOT NULL AND TRIM(instore_visibility)<>'') audit," +
646
            " SUM(franchise_activity_id>0) nextv" +
647
            " FROM `user`.my_franchisee_visit" +
648
            " WHERE auth_id IN (:auth) AND COALESCE(visit_timestamp, created_timestamp)>=:ms" +
649
            " AND COALESCE(visit_timestamp, created_timestamp)<=CONCAT(:me,' 23:59:59')");
650
        q.setParameter("ms", ms).setParameter("me", me).setParameterList("auth", auth);
651
        Object[] r = (Object[]) q.uniqueResult();
652
        long total = asLong(r[0]);
653
        Map<String, Object> m = new LinkedHashMap<>();
654
        m.put("completedVisits", total);
655
        m.put("remarkFilledPct", total == 0 ? 0 : Math.round(100.0 * asLong(r[1]) / total));
656
        m.put("avgRating", asDouble(r[2]));
657
        m.put("auditDonePct", total == 0 ? 0 : Math.round(100.0 * asLong(r[3]) / total));
658
        m.put("nextVisitPct", total == 0 ? 0 : Math.round(100.0 * asLong(r[4]) / total));
659
        return m;
660
    }
661
 
662
    // ---- "What to fix" findings (computed in Java) --------------------------
663
 
664
    private List<Map<String, Object>> findings(Map<String, Object> kpis, Map<String, Object> funnel,
665
                                               Map<String, Object> deferral, Map<String, Object> util,
666
                                               Map<String, Object> travel, Map<String, Object> outcomes,
667
                                               List<Map<String, Object>> coverage, List<Map<String, Object>> scorecard) {
668
        List<Map<String, Object>> f = new ArrayList<>();
669
 
670
        long avgDisc = asLong(kpis.get("avgDiscussionMin"));
671
        long rushed = scorecard.stream().filter(r -> asLong(r.get("discussionMin")) > 0 && asLong(r.get("discussionMin")) < 10).count();
672
        if (avgDisc > 0 && (avgDisc < 30 || rushed > 0)) {
673
            f.add(finding("high", "Rushed partner visits",
674
                "Avg discussion is " + avgDisc + " min vs a 30–45 min target" + (rushed > 0 ? ", and " + rushed + " executive(s) average under 10 min" : "") + " — too short for a real outlet conversation.",
675
                "Set a minimum on-store time and review visit quality."));
676
        }
677
 
678
        long geo = scorecard.stream().mapToLong(r -> asLong(r.get("geoFlags"))).sum();
679
        if (geo > 0) {
680
            f.add(finding("high", "Fake-visit risk",
681
                geo + " check-in(s) were >50 m from the store (check-in GPS vs the partner's saved location).",
682
                "Enforce geofenced check-in and flag these journeys for audit."));
683
        }
684
 
685
        for (Map<String, Object> c : coverage) {
686
            if ("L1".equalsIgnoreCase(asStr(c.get("level")))) {
687
                long total = asLong(c.get("totalUsers")), with = asLong(c.get("withPjp"));
688
                if (total - with > 0) {
689
                    f.add(finding("med", "Coverage gap",
690
                        (total - with) + " of " + total + " L1 executives have no monthly PJP — they run no planned beat this period.",
691
                        "Make a monthly PJP mandatory and approved before month start."));
692
                }
693
            }
694
        }
695
 
696
        long sysPct = asLong(deferral.get("systemPct"));
697
        double sla = asDouble(deferral.get("avgDaysToAction"));
698
        if (asLong(deferral.get("total")) > 0 && (sysPct >= 50 || sla >= 2)) {
699
            f.add(finding("med", "Deferral leakage",
700
                sysPct + "% of deferrals are system auto-flags" + (sla > 0 ? ", and genuine deferrals sit " + sla + " days before a head acts" : "") + ".",
701
                "Require a reason on real deferrals and add a manager-action SLA."));
702
        }
703
 
704
        long idlePct = asLong(util.get("idlePct"));
705
        if (idlePct >= 20) {
706
            f.add(finding("med", "A large part of the day is idle",
707
                idlePct + "% of the working day (" + util.get("idleHrs") + " h) is neither travel nor in-store.",
708
                "Investigate idle windows; tighten beat sequencing to cut dead time."));
709
        }
710
 
711
        long over15 = asLong(travel.get("pctOver15"));
712
        if (over15 >= 15) {
713
            f.add(finding("med", "Route inefficiency",
714
                over15 + "% of legs exceed 15 km — consecutive stops aren't geographically clustered.",
715
                "Re-sequence beats by proximity and cap daily travel."));
716
        }
717
 
718
        long journeys = asLong(kpis.get("journeys")), leads = asLong(kpis.get("leadsCreated"));
719
        if (journeys > 0 && leads < journeys * 0.25) {
720
            f.add(finding("low", "Lead capture is low",
721
                "Only " + leads + " leads across " + journeys + " journeys — executives aren't logging the new shops they pass.",
722
                "Add a per-beat lead nudge; recognise top loggers."));
723
        }
724
        return f;
725
    }
726
 
727
    private static Map<String, Object> finding(String sev, String title, String detail, String action) {
728
        Map<String, Object> m = new LinkedHashMap<>();
729
        m.put("severity", sev);
730
        m.put("title", title);
731
        m.put("detail", detail);
732
        m.put("action", action);
733
        return m;
734
    }
735
 
736
    // ---- helpers ------------------------------------------------------------
737
 
738
    private static List<Integer> safeIds(List<Integer> ids) {
739
        if (ids == null || ids.isEmpty()) return Collections.singletonList(-1);
740
        return ids;
741
    }
742
 
743
    private static long asLong(Object o) {
744
        return o == null ? 0L : ((Number) o).longValue();
745
    }
746
 
747
    private static double asDouble(Object o) {
748
        return o == null ? 0.0 : ((Number) o).doubleValue();
749
    }
750
 
751
    private static String asStr(Object o) {
752
        return o == null ? "" : o.toString();
753
    }
754
 
755
    private static double round1(double v) {
756
        return Math.round(v * 10.0) / 10.0;
757
    }
758
}