Subversion Repositories SmartDukaan

Rev

Go to most recent revision | Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37651 vikas 1
package com.spice.profitmandi.web.controller;
2
 
3
import com.spice.profitmandi.common.web.util.ResponseSender;
4
import com.spice.profitmandi.dao.entity.auth.AuthUser;
5
import com.spice.profitmandi.dao.entity.user.Lead;
6
import com.spice.profitmandi.dao.entity.user.LeadActivity;
7
import com.spice.profitmandi.dao.entity.user.LeadCall;
8
import com.spice.profitmandi.dao.entity.user.LeadDnd;
9
import com.spice.profitmandi.dao.entity.user.LeadLiveLocation;
10
import com.spice.profitmandi.dao.enumuration.dtr.CommunicationType;
11
import com.spice.profitmandi.dao.enumuration.dtr.LeadDisposition;
12
import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;
13
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
14
import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;
15
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
16
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
17
import com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository;
18
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
19
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
20
import com.spice.profitmandi.service.LmsAssignmentService;
21
import com.spice.profitmandi.web.model.LoginDetails;
22
import com.spice.profitmandi.web.util.CookiesProcessor;
23
import org.apache.logging.log4j.LogManager;
24
import org.apache.logging.log4j.Logger;
25
import org.springframework.beans.factory.annotation.Autowired;
26
import org.springframework.http.MediaType;
27
import org.springframework.http.ResponseEntity;
28
import org.springframework.stereotype.Controller;
29
import org.springframework.transaction.annotation.Transactional;
30
import org.springframework.ui.Model;
31
import org.springframework.web.bind.annotation.RequestBody;
32
import org.springframework.web.bind.annotation.RequestMapping;
33
import org.springframework.web.bind.annotation.RequestMethod;
34
import org.springframework.web.bind.annotation.RequestParam;
35
import org.springframework.web.bind.annotation.ResponseBody;
36
 
37
import javax.servlet.http.HttpServletRequest;
38
import java.time.LocalDateTime;
39
import java.time.format.DateTimeFormatter;
40
import java.util.ArrayList;
41
import java.util.HashMap;
42
import java.util.HashSet;
43
import java.util.List;
44
import com.spice.profitmandi.dao.model.LeadBrandModel;
45
import com.spice.profitmandi.dao.model.LeadDetailModel;
46
import org.springframework.beans.factory.annotation.Value;
47
import org.springframework.http.HttpStatus;
48
import java.util.LinkedHashMap;
49
import java.util.Map;
50
import java.util.Set;
51
 
52
/**
53
 * LMS operating core — the per-lead screen and the two mutations it drives (SOP §8/§10/§12).
54
 *
55
 * <ul>
56
 *   <li><b>{@code GET /leadRecord}</b> — full-page lead record fragment ({@code lead-record.vm}),
57
 *       loaded into {@code #main-content} and also opened from the standalone dashboard.</li>
58
 *   <li><b>{@code POST /lms/advanceStage}</b> — forward-only stage move with a mandatory comment.</li>
59
 *   <li><b>{@code POST /lms/disposition}</b> — call outcome; drives the stage transition, stamps
60
 *       first contact (closing the SLA) and appends to the trail.</li>
61
 *   <li><b>{@code GET /lms/resolve-region}</b> — region &rarr; owning BM/RSM preview, used by the
62
 *       Create-Lead form and the dashboard's Auto-Assignment tab.</li>
63
 * </ul>
64
 *
65
 * Every mutation appends an immutable {@link LeadActivity} row and keeps the legacy
66
 * {@code Lead.status} in sync via {@link LeadStage#toLegacyStatus()}, so the existing lead lists,
67
 * exports and beat flows keep working untouched. Read-side aggregation lives in
68
 * {@code LmsDashboardService}; assignment/SLA rules live in {@link LmsAssignmentService}.
69
 */
70
@Controller
71
@Transactional(rollbackFor = Throwable.class)
72
public class LmsLeadController {
73
 
74
    private static final Logger LOGGER = LogManager.getLogger(LmsLeadController.class);
75
 
76
    /**
77
     * How far back to look when binding a disposition to a call the browser did not name. Long
78
     * enough to cover a call plus the agent typing up the outcome; short enough that this morning's
79
     * call never gets attached to this afternoon's disposition.
80
     */
81
    private static final int CALL_BIND_WINDOW_MINUTES = 30;
82
 
83
    /** Trail/record timestamp format, shared by every date the record page prints. */
84
    private static final DateTimeFormatter RECORD_FORMAT = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm");
85
 
86
    @Autowired
87
    private LeadRepository leadRepository;
88
 
89
    @Autowired
90
    private LeadActivityRepository leadActivityRepository;
91
 
92
    @Autowired
93
    private LeadLiveLocationRepository leadLiveLocationRepository;
94
 
95
    @Autowired
96
    private LeadCallRepository leadCallRepository;
97
 
98
    @Autowired
99
    private LeadDndRepository leadDndRepository;
100
 
101
    @Autowired
102
    private AuthRepository authRepository;
103
 
104
    @Autowired
105
    private RegionRepository regionRepository;
106
 
107
    @Autowired
108
    private LmsAssignmentService lmsAssignmentService;
109
 
110
    @Autowired
111
    private CookiesProcessor cookiesProcessor;
112
 
113
    @Autowired
114
    private ResponseSender<?> responseSender;
115
 
116
    // ---- Record page ------------------------------------------------------------------------
117
 
118
    /**
119
     * Full lead record: details, stepper, append-only trail, SLA card and the act-on-it controls.
120
     * Returned as a fragment — the caller drops it into {@code #main-content}.
121
     */
122
    // Same property the Leads screen's Generate Link button uses, so both produce an identical URL.
123
    @Value("${lead.geo.public.base-url:}")
124
    private String leadGeoPublicBaseUrl;
125
 
126
    @RequestMapping(value = "/leadRecord", method = RequestMethod.GET)
127
    public String leadRecord(@RequestParam(name = "leadId") int leadId, Model model) {
128
        Lead lead = leadRepository.selectById(leadId);
129
        if (lead == null) {
130
            // Fragment contract: whatever comes back is dropped straight into #main-content.
131
            model.addAttribute("response1",
132
                    "<div class=\"alert alert-warning\">Lead #" + leadId + " not found.</div>");
133
            return "response";
134
        }
135
 
136
        LeadStage effectiveStage = lead.getEffectiveStage();
137
        model.addAttribute("lead", lead);
138
        model.addAttribute("effectiveStage", effectiveStage);
139
        model.addAttribute("stageIndex", happyPathIndex(effectiveStage));
140
        model.addAttribute("terminal", isTerminal(effectiveStage));
141
        model.addAttribute("stages", LeadStage.HAPPY_PATH);
142
        model.addAttribute("dispositions", LeadDisposition.values());
143
        model.addAttribute("slaState", lmsAssignmentService.slaState(lead));
144
        model.addAttribute("dateTimeFormatter", RECORD_FORMAT);
145
 
146
        // Region picker in the edit modal — the same list the Create-Lead form offers.
147
        model.addAttribute("regions", regionRepository.selectAll());
148
 
149
        model.addAttribute("owner", lead.getAssignTo() > 0 ? authRepository.selectById(lead.getAssignTo()) : null);
150
        model.addAttribute("bm", (lead.getOwnerBmId() != null && lead.getOwnerBmId() > 0)
151
                ? authRepository.selectById(lead.getOwnerBmId()) : null);
152
 
153
        LeadLiveLocation geo = leadLiveLocationRepository.selectByLeadId(leadId);
154
        model.addAttribute("geo", geo);
155
 
156
        // A blocked number must be visible before the agent reaches for the call button, not only
157
        // discovered when the dial is refused.
158
        model.addAttribute("dnd", leadDndRepository.selectByMobile(lead.getLeadMobile()));
159
        List<LeadCall> calls = leadCallRepository.selectByLeadId(leadId);
160
        model.addAttribute("calls", calls);
161
        // Keyed by id so a disposition in the trail can show the recording of the call it came from.
162
        // A disposition is the agent's account of the conversation; having to hunt for the audio in a
163
        // separate list to check it against the recording is the friction worth removing here.
164
        Map<Integer, LeadCall> callById = new HashMap<>();
165
        if (calls != null) {
166
            for (LeadCall c : calls) {
167
                callById.put(c.getId(), c);
168
            }
169
        }
170
        model.addAttribute("callById", callById);
171
 
172
        // Append-only trail, newest first, with actor names resolved in one batch.
173
        List<LeadActivity> trail = leadActivityRepository.selectBYLeadId(leadId);
174
        if (trail == null) {
175
            trail = new ArrayList<>();
176
        }
177
        trail.sort((a, b) -> {
178
            LocalDateTime ta = a.getCreatedTimestamp();
179
            LocalDateTime tb = b.getCreatedTimestamp();
180
            if (ta == null && tb == null) {
181
                return 0;
182
            }
183
            if (ta == null) {
184
                return 1;
185
            }
186
            if (tb == null) {
187
                return -1;
188
            }
189
            return tb.compareTo(ta);
190
        });
191
        model.addAttribute("trail", trail);
192
        model.addAttribute("authUserMap", actorsOf(trail));
193
 
194
        return "lead-record";
195
    }
196
 
197
    // ---- Mutations --------------------------------------------------------------------------
198
 
199
    /**
200
     * Move a lead forward along the happy path (or to a terminal state). Forward-only: the enum
201
     * decides what is legal, so a stale page cannot walk a lead backwards. Comment is mandatory —
202
     * it becomes the trail entry.
203
     */
204
    @RequestMapping(value = "/lms/advanceStage", method = RequestMethod.POST)
205
    @ResponseBody
206
    public ResponseEntity<?> advanceStage(HttpServletRequest request,
207
                                          @RequestParam(name = "leadId") int leadId,
208
                                          @RequestParam(name = "toStage") String toStage,
209
                                          @RequestParam(name = "comment") String comment) {
210
        if (comment == null || comment.trim().isEmpty()) {
211
            return responseSender.badRequest("A comment is required to change the stage");
212
        }
213
        Lead lead = leadRepository.selectById(leadId);
214
        if (lead == null) {
215
            return responseSender.notFound("Lead not found");
216
        }
217
 
218
        LeadStage target;
219
        try {
220
            target = LeadStage.valueOf(toStage);
221
        } catch (IllegalArgumentException e) {
222
            return responseSender.badRequest("Unknown stage: " + toStage);
223
        }
224
 
225
        LeadStage current = lead.getEffectiveStage();
226
        if (isTerminal(current)) {
227
            return responseSender.badRequest(pretty(current) + " is a terminal state — the lead cannot be moved on");
228
        }
229
        if (!current.canAdvanceTo(target)) {
230
            return responseSender.badRequest("Cannot move from " + pretty(current) + " to " + pretty(target));
231
        }
232
 
233
        AuthUser actor = currentUser(request);
234
        applyStage(lead, target);
235
        leadRepository.persist(lead);
236
        appendTrail(leadId, actor, "Stage " + pretty(current) + " → " + pretty(target) + ": " + comment.trim(), null, null);
237
 
238
        LOGGER.info("LMS stage advanced: lead {} {} -> {} by {}", leadId, current, target,
239
                actor != null ? actor.getId() : 0);
240
        return responseSender.ok(stateOf(lead));
241
    }
242
 
243
    /**
244
     * Record a call outcome (SOP §12.2). The disposition drives the stage transition; the first
245
     * dispositioned contact stamps {@code firstContactedAt}, which is what closes the 5-hr SLA.
246
     */
247
    @RequestMapping(value = "/lms/disposition", method = RequestMethod.POST,
248
            consumes = MediaType.APPLICATION_JSON_VALUE)
249
    @ResponseBody
250
    public ResponseEntity<?> disposition(HttpServletRequest request,
251
                                         @RequestBody DispositionRequest body) {
252
        if (body == null || body.disposition == null || body.disposition.trim().isEmpty()) {
253
            return responseSender.badRequest("A disposition is required");
254
        }
255
        Lead lead = leadRepository.selectById(body.leadId);
256
        if (lead == null) {
257
            return responseSender.notFound("Lead not found");
258
        }
259
 
260
        LeadDisposition disposition;
261
        try {
262
            disposition = LeadDisposition.valueOf(body.disposition);
263
        } catch (IllegalArgumentException e) {
264
            return responseSender.badRequest("Unknown disposition: " + body.disposition);
265
        }
266
        if (disposition == LeadDisposition.INTERESTED && (body.value == null || body.value <= 0)) {
267
            return responseSender.badRequest("Business value is required on an INTERESTED disposition");
268
        }
269
 
270
        AuthUser actor = currentUser(request);
271
        LeadStage before = lead.getEffectiveStage();
272
        LeadCall call = resolveCall(body, lead, actor);
273
 
274
        lead.setDisposition(disposition);
275
        lead.setDispositionSubReason(trimToNull(body.subReason));
276
        // Interim manual path — drops out when the dialer starts filling recordings from the webhook.
277
        if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {
278
            lead.setRecordingUrl(body.recordingUrl.trim());
279
        }
280
        // Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody
281
        // answered) and WRONG_NUMBER (not the retailer) are not contact: they must not satisfy the SLA,
282
        // and they must not disturb a stamp an earlier real contact already set.
283
        if (lead.getFirstContactedAt() == null && countsAsContact(disposition)) {
284
            // Prefer the moment the retailer actually picked up over "now" — the agent may sit on the
285
            // disposition modal for minutes, and that gap would silently eat into the 5-hr SLA.
286
            LocalDateTime contactedAt = (call != null && call.getAnsweredAt() != null)
287
                    ? call.getAnsweredAt() : LocalDateTime.now();
288
            lead.setFirstContactedAt(contactedAt);
289
        }
290
 
291
        LocalDateTime scheduled = parseLocal(body.callbackAt != null ? body.callbackAt : body.followUpAt);
292
        applyDispositionStage(lead, disposition, body);
293
        leadRepository.persist(lead);
294
 
295
        CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)
296
                ? CommunicationType.VISIT : CommunicationType.TELEPHONIC;
297
        LeadActivity activity = appendTrail(body.leadId, actor,
298
                dispositionRemark(disposition, before, lead, body), type, scheduled,
299
                call != null ? call.getId() : null);
300
 
301
        // Bind both ways: the trail entry knows its call (for duration + a recording link), and the
302
        // call knows the disposition it produced (so an unactioned call is findable).
303
        if (call != null) {
304
            call.setLeadActivityId(activity.getId());
305
            leadCallRepository.persist(call);
306
        }
307
 
308
        if (disposition == LeadDisposition.DO_NOT_CALL) {
309
            blockFurtherCalls(lead, actor, body);
310
        }
311
 
312
        LOGGER.info("LMS disposition {} on lead {} ({} -> {}) by {}, call {}", disposition, body.leadId, before,
313
                lead.getEffectiveStage(), actor != null ? actor.getId() : 0, call != null ? call.getId() : null);
314
        return responseSender.ok(stateOf(lead));
315
    }
316
 
317
    /**
318
     * Which call this disposition is about. The browser normally passes {@code leadCallId} straight
319
     * from the dialer; when it cannot (agent dialled from their own handset, or the page reloaded
320
     * mid-call) we fall back to this agent's most recent call on this lead that no disposition has
321
     * claimed yet. Deliberately narrow — binding the wrong call is worse than binding none, because
322
     * it would attach someone else's recording to this lead's trail.
323
     */
324
    private LeadCall resolveCall(DispositionRequest body, Lead lead, AuthUser actor) {
325
        if (body.leadCallId != null && body.leadCallId > 0) {
326
            LeadCall call = leadCallRepository.selectById(body.leadCallId);
327
            if (call != null && call.getLeadId() == lead.getId()) {
328
                return call;
329
            }
330
            LOGGER.warn("Ignoring leadCallId {} on lead {} — missing or belongs to another lead",
331
                    body.leadCallId, lead.getId());
332
            return null;
333
        }
334
        if (actor == null) {
335
            return null;
336
        }
337
        LeadCall latest = leadCallRepository.selectLatestByLeadIdAndAuthId(lead.getId(), actor.getId());
338
        if (latest == null || latest.getLeadActivityId() != null) {
339
            return null;
340
        }
341
        LocalDateTime startedAt = latest.getStartedAt() != null ? latest.getStartedAt() : latest.getCreatedTimestamp();
342
        if (startedAt == null || startedAt.isBefore(LocalDateTime.now().minusMinutes(CALL_BIND_WINDOW_MINUTES))) {
343
            return null;
344
        }
345
        return latest;
346
    }
347
 
348
    /**
349
     * DO_NOT_CALL means never ring this retailer again (SOP §17). Blocks the number, not the lead —
350
     * the same person recurs as several leads and a per-lead block would not hold. Before this the
351
     * disposition only moved the stage, and nothing stopped a re-dial.
352
     */
353
    private void blockFurtherCalls(Lead lead, AuthUser actor, DispositionRequest body) {
354
        LeadDnd dnd = new LeadDnd();
355
        dnd.setMobile(lead.getLeadMobile());
356
        dnd.setLeadId(lead.getId());
357
        dnd.setAuthId(actor != null ? actor.getId() : null);
358
        dnd.setSource("DISPOSITION");
359
        dnd.setReason(trimToNull(body.note) != null
360
                ? trimToNull(body.note) : "Retailer asked not to be contacted");
361
        leadDndRepository.block(dnd);
362
        LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);
363
    }
364
 
365
    /**
366
     * Correct the record's own fields (SOP §6).
367
     *
368
     * <p>Everything else on this screen either moves the lead forward or logs a conversation;
369
     * nothing could fix a misheard shop name or a location that was never captured. Between the
370
     * create form (which writes {@code address}/{@code city}/{@code state} empty and leaves the
371
     * geo link to supply the real location) and the legacy Leads edit (which only touches status,
372
     * assignee, city and state, and is gated behind an approved geo-pin), a lead's business name,
373
     * address and region were unreachable once created.
374
     *
375
     * <p>What stays immutable is what the SOP makes immutable (§7.1): the LMS id, the created
376
     * stamp, the creation path, the stage and the SLA clock. Region is editable but is not a plain
377
     * field edit — it re-resolves the owning BM/RSM, so it is handled as a re-assignment below.
378
     *
379
     * <p>Every accepted change writes its own trail entry. One entry per field rather than one per
380
     * save, so "who changed this number, and when" is answerable without diffing two saves.
381
     */
382
    @RequestMapping(value = "/lms/updateLead", method = RequestMethod.POST,
383
            consumes = MediaType.APPLICATION_JSON_VALUE)
384
    @ResponseBody
385
    public ResponseEntity<?> updateLead(HttpServletRequest request, @RequestBody UpdateLeadRequest body) {
386
        if (body == null) {
387
            return responseSender.badRequest("Nothing to update");
388
        }
389
        Lead lead = leadRepository.selectById(body.leadId);
390
        if (lead == null) {
391
            return responseSender.notFound("Lead not found");
392
        }
393
        AuthUser me = currentUser(request);
394
        if (me == null) {
395
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
396
        }
397
 
398
        String name = trimToNull(body.retailerName);
399
        if (name == null) {
400
            return responseSender.badRequest("Retailer name is required");
401
        }
402
        String mobile = digitsOnly(body.mobile);
403
        if (mobile == null || mobile.length() < 10) {
404
            return responseSender.badRequest("A valid 10-digit contact number is required");
405
        }
406
        mobile = mobile.substring(mobile.length() - 10);
407
 
408
        // A number change is the one edit that can quietly undo a compliance decision, so it gets
409
        // the same two checks the create form runs rather than being trusted as a typo fix.
410
        boolean mobileChanged = !mobile.equals(lead.getLeadMobile());
411
        if (mobileChanged) {
412
            if (leadDndRepository.selectByMobile(mobile) != null) {
413
                return responseSender.badRequest("That number is on the do-not-call register.");
414
            }
415
            Lead other = leadRepository.selectByMobileNumber(mobile);
416
            if (other != null && other.getId() != lead.getId()) {
417
                return responseSender.badRequest("Lead #" + other.getId() + " already exists for " + mobile);
418
            }
419
        }
420
 
421
        List<String> entries = new ArrayList<>();
422
 
423
        String beforeName = joinName(lead.getFirstName(), lead.getLastName());
424
        if (!name.equals(beforeName)) {
425
            int space = name.lastIndexOf(' ');
426
            lead.setFirstName(space > 0 ? name.substring(0, space) : name);
427
            // user.lead.last_name is NOT NULL — a single-word name keeps an empty surname, not null.
428
            lead.setLastName(space > 0 ? name.substring(space + 1) : "");
429
            entries.add(fieldChange("Retailer", beforeName, name));
430
        }
431
        if (mobileChanged) {
432
            entries.add(fieldChange("Contact", lead.getLeadMobile(), mobile));
433
            lead.setLeadMobile(mobile);
434
        }
435
 
436
        String outlet = trimToNull(body.businessName);
437
        if (!same(lead.getOutLetName(), outlet)) {
438
            entries.add(fieldChange("Business", lead.getOutLetName(), outlet));
439
            lead.setOutLetName(outlet == null ? "" : outlet);
440
        }
441
 
442
        // address / city / state are NOT NULL — blanked fields are written empty, never null.
443
        String address = trimToEmpty(body.address);
444
        String city = trimToEmpty(body.city);
445
        String state = trimToEmpty(body.state);
446
        String beforeLocation = locationOf(lead.getAddress(), lead.getCity(), lead.getState());
447
        String afterLocation = locationOf(address, city, state);
448
        if (!beforeLocation.equals(afterLocation)) {
449
            lead.setAddress(address);
450
            lead.setCity(city);
451
            lead.setState(state);
452
            entries.add(fieldChange("Location", beforeLocation, afterLocation));
453
        }
454
 
455
        if (body.potential != null && body.potential >= 0 && body.potential != lead.getPotential()) {
456
            entries.add(fieldChange("Business value",
457
                    lead.getPotential() > 0 ? money(lead.getPotential()) : null, money(body.potential)));
458
            lead.setPotential(body.potential);
459
        }
460
 
461
        // Region last: it is a re-assignment, and it reads better in the trail after the field edits.
462
        if (body.regionId != null && body.regionId > 0 && !body.regionId.equals(lead.getRegionId())) {
463
            LmsAssignmentService.Assignment a = lmsAssignmentService.resolve(body.regionId);
464
            if (a.regionId == null) {
465
                return responseSender.badRequest("Unknown region");
466
            }
467
            Integer previousBm = lead.getOwnerBmId();
468
            String beforeRegion = lead.getRegionCode();
469
            lead.setRegionId(a.regionId);
470
            lead.setRegionCode(a.regionCode);
471
            lead.setAssignmentStatus(a.assignmentStatus);
472
            lead.setOwnerBmId(a.bm != null ? a.bm.getId() : null);
473
 
474
            // Follow the region only while the lead is still sitting with whoever the engine picked.
475
            // Once a BM has handed it to a named ASM, that mapping is a decision — moving the region
476
            // must not silently undo it.
477
            boolean stillAutoAssigned = lead.getAssignTo() <= 0
478
                    || (previousBm != null && lead.getAssignTo() == previousBm.intValue());
479
            if (stillAutoAssigned) {
480
                lead.setAssignTo(a.bm != null ? a.bm.getId() : 0);
481
            }
482
 
483
            StringBuilder move = new StringBuilder(fieldChange("Region", beforeRegion, a.regionCode));
484
            if (a.bm != null) {
485
                move.append(" · owner re-resolved to ").append(a.bm.getFullName());
486
                if (!stillAutoAssigned) {
487
                    move.append(" (working owner left unchanged — the lead is mapped to a named user)");
488
                }
489
            } else {
490
                move.append(" · no active BM/RSM for that region, moved to the HOLD queue");
491
            }
492
            entries.add(move.toString());
493
        }
494
 
495
        if (entries.isEmpty()) {
496
            return responseSender.badRequest("Nothing changed");
497
        }
498
 
499
        lead.setUpdatedTimestamp(LocalDateTime.now());
500
        leadRepository.persist(lead);
501
        for (String entry : entries) {
502
            trail(lead.getId(), me.getId(), entry);
503
        }
504
 
505
        LOGGER.info("LMS lead {} edited by auth {} — {}", lead.getId(), me.getId(), entries);
506
        Map<String, Object> out = new LinkedHashMap<>();
507
        out.put("ok", true);
508
        out.put("leadId", lead.getId());
509
        out.put("changes", entries.size());
510
        return responseSender.ok(out);
511
    }
512
 
513
    /** Payload for {@link #updateLead}. Only the fields this screen is allowed to correct. */
514
    public static class UpdateLeadRequest {
515
        public int leadId;
516
        public String retailerName;
517
        public String businessName;
518
        public String mobile;
519
        public String address;
520
        public String city;
521
        public String state;
522
        public Integer regionId;
523
        public Double potential;
524
    }
525
 
526
    /** Trail line for one edited field. Blank before/after read as an em dash, not as nothing. */
527
    private String fieldChange(String label, String before, String after) {
528
        return label + " " + orDash(before) + " → " + orDash(after);
529
    }
530
 
531
    private String orDash(String s) {
532
        return (s == null || s.trim().isEmpty()) ? "—" : s.trim();
533
    }
534
 
535
    private boolean same(String a, String b) {
536
        return orDash(a).equals(orDash(b));
537
    }
538
 
539
    private String trimToEmpty(String s) {
540
        return s == null ? "" : s.trim();
541
    }
542
 
543
    private String joinName(String first, String last) {
544
        String joined = ((first == null ? "" : first) + " " + (last == null ? "" : last)).trim();
545
        return joined.replaceAll("\\s+", " ");
546
    }
547
 
548
    /** "address, city, state" with the empty parts dropped — what the record row prints. */
549
    private String locationOf(String address, String city, String state) {
550
        StringBuilder sb = new StringBuilder();
551
        for (String part : new String[]{address, city, state}) {
552
            if (part == null || part.trim().isEmpty()) {
553
                continue;
554
            }
555
            if (sb.length() > 0) {
556
                sb.append(", ");
557
            }
558
            sb.append(part.trim());
559
        }
560
        return sb.toString();
561
    }
562
 
563
    private String money(double value) {
564
        return "₹" + String.format("%.0f", value) + "/mo";
565
    }
566
 
567
    /**
568
     * Region &rarr; owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead
569
     * assignment preview and the dashboard's routing table; {@code HOLD} means the region has no
570
     * active owner and a lead created against it would wait in the HOLD queue.
571
     */
572
    @RequestMapping(value = "/lms/resolve-region", method = RequestMethod.GET)
573
    @ResponseBody
574
    public ResponseEntity<?> resolveRegion(@RequestParam(name = "regionId", required = false) Integer regionId) {
575
        LmsAssignmentService.Assignment assignment = lmsAssignmentService.resolve(regionId);
576
        Map<String, Object> out = new HashMap<>();
577
        out.put("regionId", assignment.regionId);
578
        out.put("regionCode", assignment.regionCode);
579
        out.put("assignmentStatus", assignment.assignmentStatus);
580
        out.put("bmId", assignment.bm != null ? assignment.bm.getId() : null);
581
        out.put("bmName", assignment.bm != null ? assignment.bm.getFullName() : null);
582
        return responseSender.ok(out);
583
    }
584
 
585
    // ---- Stage rules ------------------------------------------------------------------------
586
 
587
    /**
588
     * Stage effect of each disposition (SOP §12.2). Only INTERESTED moves the lead forward; the
589
     * negative outcomes are terminal, and NOT_REACHABLE drops the lead once the retry budget is
590
     * spent. Anything else just records that contact happened.
591
     */
592
    private void applyDispositionStage(Lead lead, LeadDisposition disposition, DispositionRequest body) {
593
        switch (disposition) {
594
            case INTERESTED:
595
                if (body.value != null && body.value > 0) {
596
                    lead.setPotential(body.value);
597
                }
598
                advanceIfForward(lead, LeadStage.QUALIFIED);
599
                break;
600
            case NOT_INTERESTED:
601
            case DO_NOT_CALL:
602
                applyStage(lead, LeadStage.NOT_INTERESTED);
603
                break;
604
            case WRONG_NUMBER:
605
                // A corrected number keeps the lead alive at its current stage — the retailer still
606
                // has not been spoken to, so nothing advances. A blank one closes the lead.
607
                String corrected = digitsOnly(body.correctedNumber);
608
                if (corrected != null && corrected.length() == 10) {
609
                    lead.setLeadMobile(corrected);
610
                    lead.setUpdatedTimestamp(LocalDateTime.now());
611
                } else {
612
                    applyStage(lead, LeadStage.DROPPED);
613
                }
614
                break;
615
            case NOT_REACHABLE:
616
                int tries = (lead.getUnreachableCount() == null ? 0 : lead.getUnreachableCount()) + 1;
617
                lead.setUnreachableCount(tries);
618
                if (tries >= LmsAssignmentService.MAX_UNREACHABLE) {
619
                    applyStage(lead, LeadStage.DROPPED);
620
                }
621
                break;
622
            case CALLBACK:
623
            case FOLLOW_UP:
624
            default:
625
                advanceIfForward(lead, LeadStage.CONTACTED);
626
                break;
627
        }
628
    }
629
 
630
    /** Did the retailer actually get spoken to? Only those outcomes may close the first-contact SLA. */
631
    private boolean countsAsContact(LeadDisposition disposition) {
632
        return disposition != LeadDisposition.NOT_REACHABLE && disposition != LeadDisposition.WRONG_NUMBER;
633
    }
634
 
635
    /** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */
636
    private void applyStage(Lead lead, LeadStage stage) {
637
        lead.setStage(stage);
638
        lead.setStatus(stage.toLegacyStatus());
639
        lead.setUpdatedTimestamp(LocalDateTime.now());
640
    }
641
 
642
    /** Move to {@code stage} only if that is a forward move — a later call never rewinds the lead. */
643
    private void advanceIfForward(Lead lead, LeadStage stage) {
644
        LeadStage current = lead.getEffectiveStage();
645
        if (current == stage || current.canAdvanceTo(stage)) {
646
            applyStage(lead, stage);
647
        } else {
648
            lead.setUpdatedTimestamp(LocalDateTime.now());
649
        }
650
    }
651
 
652
    // ---- Helpers ----------------------------------------------------------------------------
653
 
654
    private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
655
                                     LocalDateTime scheduled) {
656
        return appendTrail(leadId, actor, remark, type, scheduled, null);
657
    }
658
 
659
    /** As above, additionally linking the trail entry to the call that produced it. */
660
    private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
661
                                     LocalDateTime scheduled, Integer leadCallId) {
662
        LeadActivity activity = new LeadActivity();
663
        activity.setLeadId(leadId);
664
        activity.setRemark(remark);
665
        activity.setAuthId(actor != null ? actor.getId() : 0);
666
        activity.setCommunicationType(type);
667
        activity.setSchelduleTimestamp(scheduled);
668
        activity.setLeadCallId(leadCallId);
669
        activity.setCreatedTimestamp(LocalDateTime.now());
670
        leadActivityRepository.persist(activity);
671
        return activity;
672
    }
673
 
674
    /** Human-readable trail line for a disposition, including the stage move it caused. */
675
    private String dispositionRemark(LeadDisposition disposition, LeadStage before, Lead lead,
676
                                     DispositionRequest body) {
677
        StringBuilder sb = new StringBuilder(pretty(disposition.name()));
678
        if (body.subReason != null && !body.subReason.trim().isEmpty()) {
679
            sb.append(" · ").append(body.subReason.trim());
680
        }
681
        if (disposition == LeadDisposition.NOT_REACHABLE) {
682
            sb.append(" · attempt ").append(lead.getUnreachableCount());
683
            if (body.retrySchedule != null && !body.retrySchedule.trim().isEmpty()) {
684
                sb.append(", retry ").append(body.retrySchedule.trim());
685
            }
686
        }
687
        LeadStage after = lead.getEffectiveStage();
688
        if (after != before) {
689
            sb.append(" · stage ").append(pretty(before)).append(" → ").append(pretty(after));
690
        }
691
        if (body.note != null && !body.note.trim().isEmpty()) {
692
            sb.append(" — ").append(body.note.trim());
693
        }
694
        return sb.toString();
695
    }
696
 
697
    /** What the caller needs to refresh its row without re-reading the whole record. */
698
    private Map<String, Object> stateOf(Lead lead) {
699
        LeadStage stage = lead.getEffectiveStage();
700
        Map<String, Object> out = new HashMap<>();
701
        out.put("leadId", lead.getId());
702
        out.put("stage", stage.name());
703
        out.put("stageLabel", pretty(stage));
704
        out.put("terminal", isTerminal(stage));
705
        out.put("slaState", lmsAssignmentService.slaState(lead));
706
        return out;
707
    }
708
 
709
    private Map<Integer, AuthUser> actorsOf(List<LeadActivity> trail) {
710
        Set<Integer> ids = new HashSet<>();
711
        for (LeadActivity a : trail) {
712
            if (a.getAuthId() > 0) {
713
                ids.add(a.getAuthId());
714
            }
715
        }
716
        Map<Integer, AuthUser> actors = new HashMap<>();
717
        if (!ids.isEmpty()) {
718
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
719
                actors.put(u.getId(), u);
720
            }
721
        }
722
        return actors;
723
    }
724
 
725
    private AuthUser currentUser(HttpServletRequest request) {
726
        try {
727
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
728
            if (loginDetails != null && loginDetails.getEmailId() != null) {
729
                return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
730
            }
731
        } catch (Exception e) {
732
            LOGGER.warn("Could not resolve the acting user for an LMS mutation", e);
733
        }
734
        return null;
735
    }
736
 
737
    private int happyPathIndex(LeadStage stage) {
738
        for (int i = 0; i < LeadStage.HAPPY_PATH.length; i++) {
739
            if (LeadStage.HAPPY_PATH[i] == stage) {
740
                return i;
741
            }
742
        }
743
        return -1;
744
    }
745
 
746
    private boolean isTerminal(LeadStage stage) {
747
        return stage == LeadStage.NOT_INTERESTED || stage == LeadStage.DROPPED;
748
    }
749
 
750
    private String pretty(LeadStage stage) {
751
        return stage == null ? "—" : pretty(stage.name());
752
    }
753
 
754
    private String pretty(String enumName) {
755
        return enumName.replace('_', ' ');
756
    }
757
 
758
    private String trimToNull(String s) {
759
        return (s == null || s.trim().isEmpty()) ? null : s.trim();
760
    }
761
 
762
    private String digitsOnly(String s) {
763
        return s == null ? null : s.replaceAll("\\D", "");
764
    }
765
 
766
    /** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */
767
    private LocalDateTime parseLocal(String s) {
768
        if (s == null || s.trim().isEmpty()) {
769
            return null;
770
        }
771
        try {
772
            return LocalDateTime.parse(s.trim());
773
        } catch (Exception e) {
774
            LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);
775
            return null;
776
        }
777
    }
778
 
779
    /** JSON body of the disposition modal — one flat shape, sub-fields set per disposition. */
780
    public static class DispositionRequest {
781
        public int leadId;
782
        public String disposition;
783
        public String note;
784
        /** The {@code user.lead_call} row this outcome belongs to, supplied by the dialer. */
785
        public Integer leadCallId;
786
        /**
787
         * Interim: the free-text recording URL agents paste today. Superseded by {@code leadCallId}
788
         * once the dialer captures recordings automatically — kept until then so removing the box
789
         * does not leave leads with no recording path at all.
790
         */
791
        public String recordingUrl;
792
        public Double value;          // INTERESTED
793
        public String callbackAt;     // CALLBACK
794
        public String retrySchedule;  // NOT_REACHABLE
795
        public String correctedNumber;// WRONG_NUMBER
796
        public String followUpType;   // FOLLOW_UP — CALL | MEETING
797
        public String followUpAt;     // FOLLOW_UP
798
        public String subReason;      // NOT_INTERESTED
799
    }
800
 
801
    /**
802
     * Create a lead from the LMS dashboard.
803
     *
804
     * <p><b>Deliberately not {@code /createLead}.</b> That endpoint is shared with the legacy Leads
805
     * screen and must keep behaving exactly as it does. It also silently ignores the {@code regionId}
806
     * and {@code creationPath} this dashboard has always sent — which is why no lead has ever had a
807
     * stage, an LMS code or an SLA clock, while the UI cheerfully toasted "auto-assigned · SLA
808
     * started". This endpoint is what makes that message true.
809
     *
810
     * <p>Asks for far less than the legacy form: no store photos, no counter size, no brand-wise
811
     * values, no free-text address. The store-board photo and lat/lng arrive through the geo capture
812
     * link (auto-generated below), and location is confirmed by that link or by manual verification —
813
     * so an agent on a first call is no longer made to invent shop-audit data.
814
     */
815
    @RequestMapping(value = "/lms/createLead", method = RequestMethod.POST,
816
            consumes = MediaType.APPLICATION_JSON_VALUE)
817
    @ResponseBody
818
    public ResponseEntity<?> createLead(HttpServletRequest request, @RequestBody CreateLeadRequest body) {
819
        if (body == null) {
820
            return responseSender.badRequest("Nothing to create");
821
        }
822
        String name = body.firstName == null ? "" : body.firstName.trim();
823
        if (name.isEmpty()) {
824
            return responseSender.badRequest("Retailer name is required");
825
        }
826
        String mobile = body.mobile == null ? "" : body.mobile.replaceAll("\\D", "");
827
        if (mobile.length() < 10) {
828
            return responseSender.badRequest("A valid 10-digit contact number is required");
829
        }
830
        mobile = mobile.substring(mobile.length() - 10);
831
        if (body.regionId == null || body.regionId <= 0) {
832
            return responseSender.badRequest("Pick a region — it drives auto-assignment");
833
        }
834
 
835
        // Same rule the dialer uses: a number on the do-not-call register never becomes a lead.
836
        if (leadDndRepository.selectByMobile(mobile) != null) {
837
            return responseSender.badRequest("This number is on the do-not-call register.");
838
        }
839
 
840
        Lead existing = leadRepository.selectByMobileNumber(mobile);
841
        if (existing != null) {
842
            return responseSender.badRequest("Lead #" + existing.getId() + " already exists for "
843
                    + mobile + ", created by " + existing.getCreatedBy());
844
        }
845
 
846
        AuthUser me = currentUser(request);
847
        if (me == null) {
848
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
849
        }
850
 
851
        Lead lead = new Lead();
852
        // Split on the last space so "Jitu Katara" keeps a surname; user.lead.last_name is NOT NULL.
853
        int space = name.lastIndexOf(' ');
854
        lead.setFirstName(space > 0 ? name.substring(0, space) : name);
855
        lead.setLastName(space > 0 ? name.substring(space + 1) : "");
856
        lead.setLeadMobile(mobile);
857
        // address / city / state are NOT NULL but are no longer asked for: the geo link supplies the
858
        // real location. Coalesced rather than left to fail the insert.
859
        lead.setAddress("");
860
        lead.setCity("");
861
        lead.setState("");
862
        lead.setSource(body.source == null || body.source.trim().isEmpty() ? "LMS Dashboard" : body.source.trim());
863
        lead.setCreatedTimestamp(LocalDateTime.now());
864
        lead.setUpdatedTimestamp(LocalDateTime.now());
865
        lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
866
        lead.setAuthId(me.getId());
867
        // The record screen reads the business name off user.lead.outlet_name. Until now it was only
868
        // written to lead_detail below — a call that throws for every lead created here, because the
869
        // legacy detail path demands store photos this form deliberately does not collect — so the
870
        // name the agent typed was silently dropped and the Business row rendered blank.
871
        lead.setOutLetName(body.outletName == null ? "" : body.outletName.trim());
872
        lead.setRegionId(body.regionId);
873
        if (body.potential != null && body.potential > 0) {
874
            lead.setPotential(body.potential);
875
        }
876
        // Path B is a field encounter, so the creator owns it; Path A goes to the region's BM.
877
        if ("B".equalsIgnoreCase(body.creationPath)) {
878
            lead.setAssignTo(me.getId());
879
        }
880
 
881
        // THE point of this endpoint: region -> owner -> stage -> 5hr SLA, per SOP 9.
882
        LmsAssignmentService.Assignment assignment = lmsAssignmentService.assign(lead, body.creationPath);
883
        leadRepository.persist(lead);
884
 
885
        // lms_code needs the generated id, so it can only be stamped after the insert.
886
        lead.setLmsCode(lmsAssignmentService.generateLmsCode(lead));
887
        leadRepository.persist(lead);
888
 
889
        boolean hasBrands = body.leadBrands != null && !body.leadBrands.isEmpty();
890
        if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands) {
891
            try {
892
                LeadDetailModel detail = new LeadDetailModel();
893
                detail.setLeadId(lead.getId());
894
                detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());
895
                if (hasBrands) {
896
                    List<LeadBrandModel> brands = new ArrayList<>();
897
                    for (BrandValue bv : body.leadBrands) {
898
                        if (bv == null || bv.brand == null || bv.brand.trim().isEmpty() || bv.value == null
899
                                || bv.value <= 0) {
900
                            // Only brands the retailer actually stocks. Persisting zeroes for the rest
901
                            // is what made the legacy brand table useless for reporting.
902
                            continue;
903
                        }
904
                        LeadBrandModel brand = new LeadBrandModel();
905
                        brand.setBrand(bv.brand.trim());
906
                        brand.setValue(bv.value.intValue());
907
                        brands.add(brand);
908
                    }
909
                    detail.setLeadBrands(brands);
910
                }
911
                leadRepository.persistLeadDetail(detail, me);
912
            } catch (Exception e) {
913
                // Shop name and brand split are not worth losing an otherwise-good lead over.
914
                LOGGER.warn("Could not save the lead detail for lead {}", lead.getId(), e);
915
            }
916
        }
917
 
918
        StringBuilder created = new StringBuilder("Lead created from the LMS dashboard");
919
        if (hasBrands) {
920
            created.append(" · brand-wise value: ");
921
            boolean first = true;
922
            for (BrandValue bv : body.leadBrands) {
923
                if (bv == null || bv.brand == null || bv.value == null || bv.value <= 0) {
924
                    continue;
925
                }
926
                if (!first) {
927
                    created.append(", ");
928
                }
929
                created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
930
                first = false;
931
            }
932
        }
933
        trail(lead.getId(), me.getId(), created.toString()
934
                + (assignment != null && "HOLD".equals(assignment.assignmentStatus)
935
                   ? " — region has no active BM/RSM, parked in the HOLD queue" : ""));
936
 
937
        // Auto-generate the geo capture link so the agent can send it during the same call.
938
        String geoLink = buildGeoCaptureLink(lead.getId());
939
        if (geoLink != null) {
940
            trail(lead.getId(), me.getId(), "Geolocation link generated for lead");
941
        }
942
 
943
        Map<String, Object> out = new LinkedHashMap<>();
944
        out.put("ok", true);
945
        out.put("leadId", lead.getId());
946
        out.put("lmsCode", lead.getLmsCode());
947
        out.put("assignmentStatus", lead.getAssignmentStatus());
948
        out.put("ownerName", assignment != null && assignment.bm != null
949
                ? assignment.bm.getFirstName() + " " + assignment.bm.getLastName() : null);
950
        out.put("geoLink", geoLink);
951
        LOGGER.info("LMS lead {} created ({}) by auth {} — region {} status {}",
952
                lead.getId(), lead.getLmsCode(), me.getId(), lead.getRegionCode(), lead.getAssignmentStatus());
953
        return responseSender.ok(out);
954
    }
955
 
956
    /** Append a trail entry. Never fatal — a lost note must not fail the lead. */
957
    private void trail(int leadId, int authId, String remark) {
958
        try {
959
            LeadActivity activity = new LeadActivity();
960
            activity.setLeadId(leadId);
961
            activity.setAuthId(authId);
962
            activity.setRemark(remark);
963
            activity.setCreatedTimestamp(LocalDateTime.now());
964
            leadActivityRepository.persist(activity);
965
        } catch (Exception e) {
966
            LOGGER.warn("Could not write the trail entry for lead {}", leadId, e);
967
        }
968
    }
969
 
970
    /**
971
     * {base}/lead-geo/{leadId} — the same URL the Leads screen's Generate Link button produces.
972
     * Null when the base URL is unconfigured, which must not stop a lead being created.
973
     */
974
    private String buildGeoCaptureLink(int leadId) {
975
        String base = leadGeoPublicBaseUrl;
976
        if (base == null || base.trim().isEmpty()) {
977
            return null;
978
        }
979
        base = base.trim();
980
        while (base.endsWith("/")) {
981
            base = base.substring(0, base.length() - 1);
982
        }
983
        return base + "/lead-geo/" + leadId;
984
    }
985
 
986
    /** Payload for {@link #createLead}. */
987
    public static class CreateLeadRequest {
988
        public String firstName;      // retailer contact name, split into first/last
989
        public String mobile;
990
        public String outletName;     // business / shop name
991
        public String source;
992
        public Integer regionId;      // drives auto-assignment
993
        public String creationPath;   // A = BGC outbound, B = field encounter
994
        public Double potential;      // monthly business value — the sum of leadBrands
995
        /** Per-brand monthly business. What a BM actually needs to size the counter. */
996
        public List<BrandValue> leadBrands;
997
    }
998
 
999
    /** One brand's monthly business value. */
1000
    public static class BrandValue {
1001
        public String brand;
1002
        public Double value;
1003
    }
1004
}