Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
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
}