| Line 5... |
Line 5... |
| 5 |
import com.spice.profitmandi.dao.entity.user.Lead;
|
5 |
import com.spice.profitmandi.dao.entity.user.Lead;
|
| 6 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;
|
6 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;
|
| 7 |
import com.spice.profitmandi.dao.entity.user.LeadActivity;
|
7 |
import com.spice.profitmandi.dao.entity.user.LeadActivity;
|
| 8 |
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
|
8 |
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
|
| 9 |
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
|
9 |
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
|
| - |
|
10 |
import com.spice.profitmandi.dao.entity.user.LeadDnd;
|
| - |
|
11 |
import com.spice.profitmandi.dao.entity.user.LeadLiveLocation;
|
| 10 |
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
|
12 |
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
|
| - |
|
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;
|
| 11 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
17 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
| 12 |
import org.apache.logging.log4j.LogManager;
|
18 |
import org.apache.logging.log4j.LogManager;
|
| 13 |
import org.apache.logging.log4j.Logger;
|
19 |
import org.apache.logging.log4j.Logger;
|
| 14 |
import org.springframework.beans.factory.annotation.Autowired;
|
20 |
import org.springframework.beans.factory.annotation.Autowired;
|
| 15 |
import org.springframework.stereotype.Service;
|
21 |
import org.springframework.stereotype.Service;
|
| Line 38... |
Line 44... |
| 38 |
private static final Logger LOGGER = LogManager.getLogger(LmsDashboardService.class);
|
44 |
private static final Logger LOGGER = LogManager.getLogger(LmsDashboardService.class);
|
| 39 |
|
45 |
|
| 40 |
/** Ordered happy-path for cumulative "reached" funnel math. */
|
46 |
/** Ordered happy-path for cumulative "reached" funnel math. */
|
| 41 |
private static final LeadStage[] FUNNEL = LeadStage.HAPPY_PATH;
|
47 |
private static final LeadStage[] FUNNEL = LeadStage.HAPPY_PATH;
|
| 42 |
|
48 |
|
| - |
|
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 |
|
| 43 |
/** Leads not touched for this many days count as "ageing" (SOP §18.2). */
|
52 |
/** Leads not touched for this many days count as "ageing" (SOP §18.2). */
|
| 44 |
private static final int AGEING_DAYS = 7;
|
53 |
private static final int AGEING_DAYS = 7;
|
| 45 |
|
54 |
|
| 46 |
/** Most-recent weeks shown in the SLA owner heat-map. */
|
55 |
/** Most-recent weeks shown in the SLA owner heat-map. */
|
| 47 |
private static final int HEATMAP_WEEKS = 6;
|
56 |
private static final int HEATMAP_WEEKS = 6;
|
| Line 62... |
Line 71... |
| 62 |
private LmsAssignmentService lmsAssignmentService;
|
71 |
private LmsAssignmentService lmsAssignmentService;
|
| 63 |
|
72 |
|
| 64 |
@Autowired
|
73 |
@Autowired
|
| 65 |
private LeadActivityRepository leadActivityRepository;
|
74 |
private LeadActivityRepository leadActivityRepository;
|
| 66 |
|
75 |
|
| - |
|
76 |
@Autowired
|
| - |
|
77 |
private LeadCallRepository leadCallRepository;
|
| - |
|
78 |
|
| - |
|
79 |
@Autowired
|
| - |
|
80 |
private LeadDndRepository leadDndRepository;
|
| - |
|
81 |
|
| - |
|
82 |
@Autowired
|
| - |
|
83 |
private LeadLiveLocationRepository leadLiveLocationRepository;
|
| - |
|
84 |
|
| 67 |
// ---- Command Center + shared summary ----------------------------------------------------
|
85 |
// ---- Command Center + shared summary ----------------------------------------------------
|
| 68 |
|
86 |
|
| 69 |
/** KPIs + lifecycle funnel + attention queue for the given region / created-date window. */
|
87 |
/** KPIs + lifecycle funnel + attention queue for the given region / created-date window. */
|
| 70 |
public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {
|
88 |
public Map<String, Object> summary(Integer regionId, LocalDateTime from, LocalDateTime to) {
|
| 71 |
Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));
|
89 |
Map<String, Long> stage = toCountMap(leadRepository.countByDerivedStage(regionId, from, to));
|
| Line 302... |
Line 320... |
| 302 |
}
|
320 |
}
|
| 303 |
return toRows(ordered);
|
321 |
return toRows(ordered);
|
| 304 |
}
|
322 |
}
|
| 305 |
|
323 |
|
| 306 |
/**
|
324 |
/**
|
| - |
|
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 |
/**
|
| 307 |
* Free-text lookup for the dashboard search box: LMS id / retailer / business / mobile / city.
|
355 |
* Free-text lookup for the dashboard search box: LMS id / retailer / business / mobile / city.
|
| 308 |
* Same row shape as {@link #drill}, so results render in the same drawer table.
|
356 |
* Same row shape as {@link #drill}, so results render in the same drawer table.
|
| 309 |
*/
|
357 |
*/
|
| 310 |
public List<Map<String, Object>> search(String term) {
|
358 |
public List<Map<String, Object>> search(String term) {
|
| 311 |
if (term == null || term.trim().isEmpty()) {
|
359 |
if (term == null || term.trim().isEmpty()) {
|
| Line 346... |
Line 394... |
| 346 |
r.put("stage", eff != null ? eff.name() : null);
|
394 |
r.put("stage", eff != null ? eff.name() : null);
|
| 347 |
r.put("stageLabel", pretty(eff));
|
395 |
r.put("stageLabel", pretty(eff));
|
| 348 |
r.put("owner", owner != null ? owner.getFullName() : (l.getAssignTo() > 0 ? "#" + l.getAssignTo() : "—"));
|
396 |
r.put("owner", owner != null ? owner.getFullName() : (l.getAssignTo() > 0 ? "#" + l.getAssignTo() : "—"));
|
| 349 |
r.put("slaState", lmsAssignmentService.slaState(l));
|
397 |
r.put("slaState", lmsAssignmentService.slaState(l));
|
| 350 |
r.put("value", l.getPotential());
|
398 |
r.put("value", l.getPotential());
|
| - |
|
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());
|
| 351 |
out.add(r);
|
402 |
out.add(r);
|
| 352 |
}
|
403 |
}
|
| 353 |
return out;
|
404 |
return out;
|
| 354 |
}
|
405 |
}
|
| 355 |
|
406 |
|
| Line 391... |
Line 442... |
| 391 |
if (!ids.isEmpty()) {
|
442 |
if (!ids.isEmpty()) {
|
| 392 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
|
443 |
for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
|
| 393 |
actors.put(u.getId(), u);
|
444 |
actors.put(u.getId(), u);
|
| 394 |
}
|
445 |
}
|
| 395 |
}
|
446 |
}
|
| - |
|
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 |
|
| 396 |
List<Map<String, Object>> trail = new ArrayList<>();
|
458 |
List<Map<String, Object>> trail = new ArrayList<>();
|
| 397 |
for (LeadActivity a : acts) {
|
459 |
for (LeadActivity a : acts) {
|
| 398 |
AuthUser act = actors.get(a.getAuthId());
|
460 |
AuthUser act = actors.get(a.getAuthId());
|
| 399 |
Map<String, Object> t = new LinkedHashMap<>();
|
461 |
Map<String, Object> t = new LinkedHashMap<>();
|
| 400 |
t.put("when", a.getCreatedTimestamp() == null ? null : a.getCreatedTimestamp().toString());
|
462 |
t.put("when", a.getCreatedTimestamp() == null ? null : a.getCreatedTimestamp().toString());
|
| 401 |
t.put("actor", act != null ? act.getFullName() : (a.getAuthId() > 0 ? "#" + a.getAuthId() : "System"));
|
463 |
t.put("actor", act != null ? act.getFullName() : (a.getAuthId() > 0 ? "#" + a.getAuthId() : "System"));
|
| 402 |
t.put("type", a.getCommunicationType() == null ? null : a.getCommunicationType().name());
|
464 |
t.put("type", a.getCommunicationType() == null ? null : a.getCommunicationType().name());
|
| 403 |
t.put("body", a.getRemark());
|
465 |
t.put("body", a.getRemark());
|
| 404 |
t.put("scheduled", a.getSchelduleTimestamp() == null ? null : a.getSchelduleTimestamp().toString());
|
466 |
t.put("scheduled", a.getSchelduleTimestamp() == null ? null : a.getSchelduleTimestamp().toString());
|
| - |
|
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 |
}
|
| 405 |
trail.add(t);
|
478 |
trail.add(t);
|
| 406 |
}
|
479 |
}
|
| 407 |
|
480 |
|
| 408 |
Map<String, Object> m = new LinkedHashMap<>();
|
481 |
Map<String, Object> m = new LinkedHashMap<>();
|
| 409 |
m.put("id", l.getId());
|
482 |
m.put("id", l.getId());
|
| Line 428... |
Line 501... |
| 428 |
m.put("assignmentStatus", l.getAssignmentStatus());
|
501 |
m.put("assignmentStatus", l.getAssignmentStatus());
|
| 429 |
m.put("slaState", lmsAssignmentService.slaState(l));
|
502 |
m.put("slaState", lmsAssignmentService.slaState(l));
|
| 430 |
m.put("firstContactDue", l.getFirstContactDue() == null ? null : l.getFirstContactDue().toString());
|
503 |
m.put("firstContactDue", l.getFirstContactDue() == null ? null : l.getFirstContactDue().toString());
|
| 431 |
m.put("firstContactedAt", l.getFirstContactedAt() == null ? null : l.getFirstContactedAt().toString());
|
504 |
m.put("firstContactedAt", l.getFirstContactedAt() == null ? null : l.getFirstContactedAt().toString());
|
| 432 |
m.put("unreachableCount", l.getUnreachableCount());
|
505 |
m.put("unreachableCount", l.getUnreachableCount());
|
| - |
|
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 |
|
| 433 |
m.put("trail", trail);
|
536 |
m.put("trail", trail);
|
| 434 |
return m;
|
537 |
return m;
|
| 435 |
}
|
538 |
}
|
| 436 |
|
539 |
|
| 437 |
private String joinLoc(String a, String c, String s) {
|
540 |
private String joinLoc(String a, String c, String s) {
|