| 37759 |
vikas |
1 |
package com.spice.profitmandi.service.lms;
|
|
|
2 |
|
|
|
3 |
import com.spice.profitmandi.dao.entity.auth.AuthUser;
|
|
|
4 |
import com.spice.profitmandi.dao.entity.user.Lead;
|
|
|
5 |
import com.spice.profitmandi.dao.entity.user.LeadActivity;
|
|
|
6 |
import com.spice.profitmandi.dao.entity.user.LeadCall;
|
|
|
7 |
import com.spice.profitmandi.dao.entity.user.LeadDnd;
|
|
|
8 |
import com.spice.profitmandi.dao.enumuration.dtr.CommunicationType;
|
|
|
9 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadDisposition;
|
|
|
10 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;
|
|
|
11 |
import com.spice.profitmandi.dao.model.lms.LeadDispositionRequest;
|
|
|
12 |
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
|
|
|
13 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
|
|
|
14 |
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
|
|
|
15 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
|
|
16 |
import com.spice.profitmandi.service.LmsAssignmentService;
|
|
|
17 |
import org.apache.logging.log4j.LogManager;
|
|
|
18 |
import org.apache.logging.log4j.Logger;
|
|
|
19 |
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
20 |
import org.springframework.stereotype.Service;
|
|
|
21 |
|
|
|
22 |
import java.time.LocalDateTime;
|
|
|
23 |
|
|
|
24 |
/**
|
|
|
25 |
* What happens to a lead when a call ends: the disposition, the stage it moves to, the first-contact
|
|
|
26 |
* SLA stamp, the trail entry, and the do-not-call block.
|
|
|
27 |
*
|
|
|
28 |
* <p><b>Why this is a service.</b> This is the most consequential write in the LMS and it is now
|
|
|
29 |
* reachable from two places — the desk console's disposition modal and the field app's post-call
|
|
|
30 |
* sheet. Every one of the effects below is load-bearing for reporting or compliance, and a second
|
|
|
31 |
* implementation that quietly forgot one of them would corrupt the numbers without failing:
|
|
|
32 |
*
|
|
|
33 |
* <ul>
|
|
|
34 |
* <li>{@code first_contacted_at} is what closes the 5-hour SLA — miss it and every lead a field
|
|
|
35 |
* rep contacts reads as breached forever.</li>
|
|
|
36 |
* <li>{@code stage} and the legacy {@code status} column must move together, or the LMS funnel
|
|
|
37 |
* and the old lead lists disagree about the same lead.</li>
|
|
|
38 |
* <li>DO_NOT_CALL must reach {@code user.lead_dnd} — otherwise the retailer is re-dialled, which
|
|
|
39 |
* is a regulatory problem, not a UX one.</li>
|
|
|
40 |
* <li>The trail entry and the {@code lead_call.lead_activity_id} back-reference are what make a
|
|
|
41 |
* call auditable in both directions.</li>
|
|
|
42 |
* </ul>
|
|
|
43 |
*
|
|
|
44 |
* <p>Callers pass an already-resolved {@link AuthUser}; the lead is returned in the outcome so each
|
|
|
45 |
* controller can render its own view of the new state.
|
|
|
46 |
*/
|
|
|
47 |
@Service
|
|
|
48 |
public class LmsDispositionService {
|
|
|
49 |
|
|
|
50 |
private static final Logger LOGGER = LogManager.getLogger(LmsDispositionService.class);
|
|
|
51 |
|
|
|
52 |
/**
|
|
|
53 |
* How far back an un-dispositioned call may be silently bound to this outcome. Deliberately
|
|
|
54 |
* narrow — binding the wrong call is worse than binding none, because it would attach someone
|
|
|
55 |
* else's recording to this lead's trail.
|
|
|
56 |
*/
|
|
|
57 |
private static final int CALL_BIND_WINDOW_MINUTES = 30;
|
|
|
58 |
|
|
|
59 |
@Autowired
|
|
|
60 |
private LeadRepository leadRepository;
|
|
|
61 |
|
|
|
62 |
@Autowired
|
|
|
63 |
private LeadCallRepository leadCallRepository;
|
|
|
64 |
|
|
|
65 |
@Autowired
|
|
|
66 |
private LeadActivityRepository leadActivityRepository;
|
|
|
67 |
|
|
|
68 |
@Autowired
|
|
|
69 |
private LeadDndRepository leadDndRepository;
|
|
|
70 |
|
|
|
71 |
/**
|
|
|
72 |
* Record the outcome of a call.
|
|
|
73 |
*
|
|
|
74 |
* <p>On success the outcome carries {@code lead} (the updated entity), {@code leadCallId} (the
|
|
|
75 |
* call it was bound to, or null) and {@code stage}.
|
|
|
76 |
*/
|
|
|
77 |
public LmsOutcome apply(AuthUser actor, LeadDispositionRequest body) {
|
|
|
78 |
if (body == null || body.disposition == null || body.disposition.trim().isEmpty()) {
|
|
|
79 |
return LmsOutcome.invalid("A disposition is required");
|
|
|
80 |
}
|
|
|
81 |
Lead lead = leadRepository.selectById(body.leadId);
|
|
|
82 |
if (lead == null) {
|
|
|
83 |
return LmsOutcome.notFound("Lead not found");
|
|
|
84 |
}
|
|
|
85 |
|
|
|
86 |
LeadDisposition disposition;
|
|
|
87 |
try {
|
|
|
88 |
disposition = LeadDisposition.valueOf(body.disposition);
|
|
|
89 |
} catch (IllegalArgumentException e) {
|
|
|
90 |
return LmsOutcome.invalid("Unknown disposition: " + body.disposition);
|
|
|
91 |
}
|
|
|
92 |
if (disposition == LeadDisposition.INTERESTED && (body.value == null || body.value <= 0)) {
|
|
|
93 |
return LmsOutcome.invalid("Business value is required on an INTERESTED disposition");
|
|
|
94 |
}
|
|
|
95 |
|
|
|
96 |
LeadStage before = lead.getEffectiveStage();
|
|
|
97 |
LeadCall call = resolveCall(body, lead, actor);
|
|
|
98 |
|
|
|
99 |
String subReason = latin1Safe(trimToNull(body.subReason));
|
|
|
100 |
if (subReason != null && subReason.length() > 64) {
|
|
|
101 |
return LmsOutcome.invalid("Sub-reason is too long - keep it to 64 characters");
|
|
|
102 |
}
|
|
|
103 |
lead.setDisposition(disposition);
|
|
|
104 |
lead.setDispositionSubReason(subReason);
|
|
|
105 |
// Interim manual path — drops out when the dialer starts filling recordings from the webhook.
|
|
|
106 |
if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {
|
|
|
107 |
lead.setRecordingUrl(body.recordingUrl.trim());
|
|
|
108 |
}
|
|
|
109 |
// Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody
|
|
|
110 |
// answered) and WRONG_NUMBER (not the retailer) are not contact: they must not satisfy the SLA,
|
|
|
111 |
// and they must not disturb a stamp an earlier real contact already set.
|
|
|
112 |
if (lead.getFirstContactedAt() == null && countsAsContact(disposition)) {
|
|
|
113 |
// Prefer the moment the retailer actually picked up over "now" — the agent may sit on the
|
|
|
114 |
// disposition form for minutes, and that gap would silently eat into the 5-hr SLA.
|
|
|
115 |
LocalDateTime contactedAt = (call != null && call.getAnsweredAt() != null)
|
|
|
116 |
? call.getAnsweredAt() : LocalDateTime.now();
|
|
|
117 |
lead.setFirstContactedAt(contactedAt);
|
|
|
118 |
}
|
|
|
119 |
|
|
|
120 |
LocalDateTime scheduled = parseLocal(body.callbackAt != null ? body.callbackAt : body.followUpAt);
|
|
|
121 |
applyDispositionStage(lead, disposition, body);
|
|
|
122 |
leadRepository.persist(lead);
|
|
|
123 |
|
|
|
124 |
CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)
|
|
|
125 |
? CommunicationType.VISIT : CommunicationType.TELEPHONIC;
|
|
|
126 |
LeadActivity activity = appendTrail(body.leadId, actor,
|
|
|
127 |
latin1Safe(dispositionRemark(disposition, before, lead, body)), type, scheduled,
|
|
|
128 |
call != null ? call.getId() : null);
|
|
|
129 |
|
|
|
130 |
// Bind both ways: the trail entry knows its call (for duration + a recording link), and the
|
|
|
131 |
// call knows the disposition it produced (so an unactioned call is findable).
|
|
|
132 |
if (call != null) {
|
|
|
133 |
call.setLeadActivityId(activity.getId());
|
|
|
134 |
leadCallRepository.persist(call);
|
|
|
135 |
}
|
|
|
136 |
|
|
|
137 |
if (disposition == LeadDisposition.DO_NOT_CALL) {
|
|
|
138 |
blockFurtherCalls(lead, actor, body);
|
|
|
139 |
}
|
|
|
140 |
|
|
|
141 |
LOGGER.info("LMS disposition {} on lead {} ({} -> {}) by {}, call {}", disposition, body.leadId,
|
|
|
142 |
before, lead.getEffectiveStage(), actor != null ? actor.getId() : 0,
|
|
|
143 |
call != null ? call.getId() : null);
|
|
|
144 |
|
|
|
145 |
return LmsOutcome.ok()
|
|
|
146 |
.with("lead", lead)
|
|
|
147 |
.with("leadCallId", call != null ? call.getId() : null)
|
|
|
148 |
.with("stage", lead.getEffectiveStage().name());
|
|
|
149 |
}
|
|
|
150 |
|
|
|
151 |
/**
|
|
|
152 |
* Which call this disposition is about. The client normally passes {@code leadCallId} straight
|
|
|
153 |
* from the dialer; when it cannot (agent dialled from their own handset, or the page reloaded
|
|
|
154 |
* mid-call) we fall back to this agent's most recent call on this lead that no disposition has
|
|
|
155 |
* claimed yet.
|
|
|
156 |
*/
|
|
|
157 |
private LeadCall resolveCall(LeadDispositionRequest body, Lead lead, AuthUser actor) {
|
|
|
158 |
if (body.leadCallId != null && body.leadCallId > 0) {
|
|
|
159 |
LeadCall call = leadCallRepository.selectById(body.leadCallId);
|
|
|
160 |
if (call != null && call.getLeadId() == lead.getId()) {
|
|
|
161 |
return call;
|
|
|
162 |
}
|
|
|
163 |
LOGGER.warn("Ignoring leadCallId {} on lead {} — missing or belongs to another lead",
|
|
|
164 |
body.leadCallId, lead.getId());
|
|
|
165 |
return null;
|
|
|
166 |
}
|
|
|
167 |
if (actor == null) {
|
|
|
168 |
return null;
|
|
|
169 |
}
|
|
|
170 |
LeadCall latest = leadCallRepository.selectLatestByLeadIdAndAuthId(lead.getId(), actor.getId());
|
|
|
171 |
if (latest == null || latest.getLeadActivityId() != null) {
|
|
|
172 |
return null;
|
|
|
173 |
}
|
|
|
174 |
LocalDateTime startedAt = latest.getStartedAt() != null
|
|
|
175 |
? latest.getStartedAt() : latest.getCreatedTimestamp();
|
|
|
176 |
if (startedAt == null || startedAt.isBefore(LocalDateTime.now().minusMinutes(CALL_BIND_WINDOW_MINUTES))) {
|
|
|
177 |
return null;
|
|
|
178 |
}
|
|
|
179 |
return latest;
|
|
|
180 |
}
|
|
|
181 |
|
|
|
182 |
/**
|
|
|
183 |
* DO_NOT_CALL means never ring this retailer again (SOP §17). Blocks the number, not the lead —
|
|
|
184 |
* the same person recurs as several leads and a per-lead block would not hold.
|
|
|
185 |
*/
|
|
|
186 |
private void blockFurtherCalls(Lead lead, AuthUser actor, LeadDispositionRequest body) {
|
|
|
187 |
LeadDnd dnd = new LeadDnd();
|
|
|
188 |
dnd.setMobile(lead.getLeadMobile());
|
|
|
189 |
dnd.setLeadId(lead.getId());
|
|
|
190 |
dnd.setAuthId(actor != null ? actor.getId() : null);
|
|
|
191 |
dnd.setSource("DISPOSITION");
|
|
|
192 |
dnd.setReason(latin1Safe(trimToNull(body.note) != null
|
|
|
193 |
? trimToNull(body.note) : "Retailer asked not to be contacted"));
|
|
|
194 |
leadDndRepository.block(dnd);
|
|
|
195 |
LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);
|
|
|
196 |
}
|
|
|
197 |
|
|
|
198 |
/**
|
|
|
199 |
* Stage effect of each disposition (SOP §12.2). Only INTERESTED moves the lead forward; the
|
|
|
200 |
* negative outcomes are terminal, and NOT_REACHABLE drops the lead once the retry budget is
|
|
|
201 |
* spent. Anything else just records that contact happened.
|
|
|
202 |
*/
|
|
|
203 |
private void applyDispositionStage(Lead lead, LeadDisposition disposition, LeadDispositionRequest body) {
|
|
|
204 |
switch (disposition) {
|
|
|
205 |
case INTERESTED:
|
|
|
206 |
if (body.value != null && body.value > 0) {
|
|
|
207 |
lead.setPotential(body.value);
|
|
|
208 |
}
|
|
|
209 |
advanceIfForward(lead, LeadStage.QUALIFIED);
|
|
|
210 |
break;
|
|
|
211 |
case NOT_INTERESTED:
|
|
|
212 |
case DO_NOT_CALL:
|
|
|
213 |
applyStage(lead, LeadStage.NOT_INTERESTED);
|
|
|
214 |
break;
|
|
|
215 |
case WRONG_NUMBER:
|
|
|
216 |
// A corrected number keeps the lead alive at its current stage — the retailer still
|
|
|
217 |
// has not been spoken to, so nothing advances. A blank one closes the lead.
|
|
|
218 |
String corrected = digitsOnly(body.correctedNumber);
|
|
|
219 |
if (corrected != null && corrected.length() == 10) {
|
|
|
220 |
lead.setLeadMobile(corrected);
|
|
|
221 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
|
|
222 |
} else {
|
|
|
223 |
applyStage(lead, LeadStage.DROPPED);
|
|
|
224 |
}
|
|
|
225 |
break;
|
|
|
226 |
case NOT_REACHABLE:
|
|
|
227 |
int tries = (lead.getUnreachableCount() == null ? 0 : lead.getUnreachableCount()) + 1;
|
|
|
228 |
lead.setUnreachableCount(tries);
|
|
|
229 |
if (tries >= LmsAssignmentService.MAX_UNREACHABLE) {
|
|
|
230 |
applyStage(lead, LeadStage.DROPPED);
|
|
|
231 |
}
|
|
|
232 |
break;
|
|
|
233 |
case CALLBACK:
|
|
|
234 |
case FOLLOW_UP:
|
|
|
235 |
default:
|
|
|
236 |
advanceIfForward(lead, LeadStage.CONTACTED);
|
|
|
237 |
break;
|
|
|
238 |
}
|
|
|
239 |
}
|
|
|
240 |
|
|
|
241 |
/** Did the retailer actually get spoken to? Only those outcomes may close the first-contact SLA. */
|
|
|
242 |
private boolean countsAsContact(LeadDisposition disposition) {
|
|
|
243 |
return disposition != LeadDisposition.NOT_REACHABLE && disposition != LeadDisposition.WRONG_NUMBER;
|
|
|
244 |
}
|
|
|
245 |
|
|
|
246 |
/** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */
|
|
|
247 |
private void applyStage(Lead lead, LeadStage stage) {
|
|
|
248 |
lead.setStage(stage);
|
|
|
249 |
lead.setStatus(stage.toLegacyStatus());
|
|
|
250 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
|
|
251 |
}
|
|
|
252 |
|
|
|
253 |
/** Move to {@code stage} only if that is a forward move — a later call never rewinds the lead. */
|
|
|
254 |
private void advanceIfForward(Lead lead, LeadStage stage) {
|
|
|
255 |
LeadStage current = lead.getEffectiveStage();
|
|
|
256 |
if (current == stage || current.canAdvanceTo(stage)) {
|
|
|
257 |
applyStage(lead, stage);
|
|
|
258 |
} else {
|
|
|
259 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
|
|
260 |
}
|
|
|
261 |
}
|
|
|
262 |
|
|
|
263 |
/** Trail entry, linked to the call that produced it. */
|
|
|
264 |
private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
|
|
|
265 |
LocalDateTime scheduled, Integer leadCallId) {
|
|
|
266 |
LeadActivity activity = new LeadActivity();
|
|
|
267 |
activity.setLeadId(leadId);
|
|
|
268 |
activity.setRemark(remark);
|
|
|
269 |
activity.setAuthId(actor != null ? actor.getId() : 0);
|
|
|
270 |
activity.setCommunicationType(type);
|
|
|
271 |
activity.setSchelduleTimestamp(scheduled);
|
|
|
272 |
activity.setLeadCallId(leadCallId);
|
|
|
273 |
activity.setCreatedTimestamp(LocalDateTime.now());
|
|
|
274 |
leadActivityRepository.persist(activity);
|
|
|
275 |
return activity;
|
|
|
276 |
}
|
|
|
277 |
|
|
|
278 |
/** Human-readable trail line for a disposition, including the stage move it caused. */
|
|
|
279 |
private String dispositionRemark(LeadDisposition disposition, LeadStage before, Lead lead,
|
|
|
280 |
LeadDispositionRequest body) {
|
|
|
281 |
StringBuilder sb = new StringBuilder(pretty(disposition.name()));
|
|
|
282 |
if (body.subReason != null && !body.subReason.trim().isEmpty()) {
|
|
|
283 |
sb.append(" · ").append(body.subReason.trim());
|
|
|
284 |
}
|
|
|
285 |
if (disposition == LeadDisposition.NOT_REACHABLE) {
|
|
|
286 |
sb.append(" · attempt ").append(lead.getUnreachableCount());
|
|
|
287 |
if (body.retrySchedule != null && !body.retrySchedule.trim().isEmpty()) {
|
|
|
288 |
sb.append(", retry ").append(body.retrySchedule.trim());
|
|
|
289 |
}
|
|
|
290 |
}
|
|
|
291 |
LeadStage after = lead.getEffectiveStage();
|
|
|
292 |
if (after != before) {
|
|
|
293 |
sb.append(" · stage ").append(pretty(before)).append(" -> ").append(pretty(after));
|
|
|
294 |
}
|
|
|
295 |
if (body.note != null && !body.note.trim().isEmpty()) {
|
|
|
296 |
sb.append(" - ").append(body.note.trim());
|
|
|
297 |
}
|
|
|
298 |
return sb.toString();
|
|
|
299 |
}
|
|
|
300 |
|
|
|
301 |
// ---- Small helpers -------------------------------------------------------------------------
|
|
|
302 |
|
|
|
303 |
private String pretty(LeadStage stage) {
|
|
|
304 |
return stage == null ? "-" : pretty(stage.name());
|
|
|
305 |
}
|
|
|
306 |
|
|
|
307 |
private String pretty(String enumName) {
|
|
|
308 |
return enumName.replace('_', ' ');
|
|
|
309 |
}
|
|
|
310 |
|
|
|
311 |
private String trimToNull(String s) {
|
|
|
312 |
return (s == null || s.trim().isEmpty()) ? null : s.trim();
|
|
|
313 |
}
|
|
|
314 |
|
|
|
315 |
private String digitsOnly(String s) {
|
|
|
316 |
return s == null ? null : s.replaceAll("\\D", "");
|
|
|
317 |
}
|
|
|
318 |
|
|
|
319 |
/** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */
|
|
|
320 |
private LocalDateTime parseLocal(String s) {
|
|
|
321 |
if (s == null || s.trim().isEmpty()) {
|
|
|
322 |
return null;
|
|
|
323 |
}
|
|
|
324 |
try {
|
|
|
325 |
return LocalDateTime.parse(s.trim());
|
|
|
326 |
} catch (Exception e) {
|
|
|
327 |
LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);
|
|
|
328 |
return null;
|
|
|
329 |
}
|
|
|
330 |
}
|
|
|
331 |
|
|
|
332 |
/**
|
|
|
333 |
* Transliterate the characters a phone keyboard produces into the latin1 the LMS text columns
|
|
|
334 |
* hold. Without this a smart quote or a rupee sign in a note takes the whole write down with
|
|
|
335 |
* "Incorrect string value", naming neither the lead nor the cause.
|
|
|
336 |
*/
|
|
|
337 |
private String latin1Safe(String s) {
|
|
|
338 |
if (s == null || s.isEmpty()) {
|
|
|
339 |
return s;
|
|
|
340 |
}
|
|
|
341 |
StringBuilder out = new StringBuilder(s.length());
|
|
|
342 |
for (int i = 0; i < s.length(); i++) {
|
|
|
343 |
char c = s.charAt(i);
|
|
|
344 |
switch (c) {
|
|
|
345 |
case '‘': case '’': case '‛': out.append('\''); break; // ‘ ’ ‛
|
|
|
346 |
case '“': case '”': case '„': out.append('"'); break; // “ ” „
|
|
|
347 |
case '–': case '—': case '−': out.append('-'); break; // – — −
|
|
|
348 |
case '…': out.append("..."); break; // …
|
|
|
349 |
case '→': out.append("->"); break; // →
|
|
|
350 |
case '₹': out.append("Rs"); break; // ₹
|
|
|
351 |
case ' ': out.append(' '); break; // nbsp
|
|
|
352 |
default:
|
|
|
353 |
out.append(c <= 0xFF ? c : '?');
|
|
|
354 |
}
|
|
|
355 |
}
|
|
|
356 |
return out.toString();
|
|
|
357 |
}
|
|
|
358 |
}
|