Subversion Repositories SmartDukaan

Rev

Rev 37737 | Rev 37760 | Go to most recent revision | Details | Compare with Previous | 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;
37737 vikas 18
import com.spice.profitmandi.dao.entity.cs.Position;
19
import com.spice.profitmandi.dao.entity.cs.Region;
20
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
21
import com.spice.profitmandi.dao.repository.cs.PositionRepository;
37651 vikas 22
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
37737 vikas 23
import com.spice.profitmandi.dao.entity.user.LeadCallAnswer;
24
import com.spice.profitmandi.dao.entity.user.LeadCallQuestion;
25
import com.spice.profitmandi.dao.repository.dtr.LeadCallChecklistRepository;
37651 vikas 26
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
37749 vikas 27
import com.spice.profitmandi.dao.model.lms.LeadDispositionRequest;
37651 vikas 28
import com.spice.profitmandi.service.LmsAssignmentService;
37749 vikas 29
import com.spice.profitmandi.service.lms.LmsDispositionService;
30
import com.spice.profitmandi.service.lms.LmsOutcome;
37651 vikas 31
import com.spice.profitmandi.web.model.LoginDetails;
32
import com.spice.profitmandi.web.util.CookiesProcessor;
33
import org.apache.logging.log4j.LogManager;
34
import org.apache.logging.log4j.Logger;
35
import org.springframework.beans.factory.annotation.Autowired;
36
import org.springframework.http.MediaType;
37
import org.springframework.http.ResponseEntity;
38
import org.springframework.stereotype.Controller;
39
import org.springframework.transaction.annotation.Transactional;
40
import org.springframework.ui.Model;
41
import org.springframework.web.bind.annotation.RequestBody;
42
import org.springframework.web.bind.annotation.RequestMapping;
43
import org.springframework.web.bind.annotation.RequestMethod;
44
import org.springframework.web.bind.annotation.RequestParam;
45
import org.springframework.web.bind.annotation.ResponseBody;
46
 
47
import javax.servlet.http.HttpServletRequest;
48
import java.time.LocalDateTime;
49
import java.time.format.DateTimeFormatter;
50
import java.util.ArrayList;
37737 vikas 51
import java.util.Arrays;
37651 vikas 52
import java.util.HashMap;
53
import java.util.HashSet;
54
import java.util.List;
55
import com.spice.profitmandi.dao.model.LeadBrandModel;
56
import com.spice.profitmandi.dao.model.LeadDetailModel;
57
import org.springframework.beans.factory.annotation.Value;
58
import org.springframework.http.HttpStatus;
59
import java.util.LinkedHashMap;
60
import java.util.Map;
61
import java.util.Set;
62
 
63
/**
64
 * LMS operating core — the per-lead screen and the two mutations it drives (SOP §8/§10/§12).
65
 *
66
 * <ul>
67
 *   <li><b>{@code GET /leadRecord}</b> — full-page lead record fragment ({@code lead-record.vm}),
68
 *       loaded into {@code #main-content} and also opened from the standalone dashboard.</li>
69
 *   <li><b>{@code POST /lms/advanceStage}</b> — forward-only stage move with a mandatory comment.</li>
70
 *   <li><b>{@code POST /lms/disposition}</b> — call outcome; drives the stage transition, stamps
71
 *       first contact (closing the SLA) and appends to the trail.</li>
72
 *   <li><b>{@code GET /lms/resolve-region}</b> — region &rarr; owning BM/RSM preview, used by the
73
 *       Create-Lead form and the dashboard's Auto-Assignment tab.</li>
74
 * </ul>
75
 *
76
 * Every mutation appends an immutable {@link LeadActivity} row and keeps the legacy
77
 * {@code Lead.status} in sync via {@link LeadStage#toLegacyStatus()}, so the existing lead lists,
78
 * exports and beat flows keep working untouched. Read-side aggregation lives in
79
 * {@code LmsDashboardService}; assignment/SLA rules live in {@link LmsAssignmentService}.
80
 */
81
@Controller
82
@Transactional(rollbackFor = Throwable.class)
83
public class LmsLeadController {
84
 
85
    private static final Logger LOGGER = LogManager.getLogger(LmsLeadController.class);
86
 
87
    /**
88
     * How far back to look when binding a disposition to a call the browser did not name. Long
89
     * enough to cover a call plus the agent typing up the outcome; short enough that this morning's
90
     * call never gets attached to this afternoon's disposition.
91
     */
92
    private static final int CALL_BIND_WINDOW_MINUTES = 30;
93
 
94
    /** Trail/record timestamp format, shared by every date the record page prints. */
95
    private static final DateTimeFormatter RECORD_FORMAT = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm");
96
 
97
    @Autowired
98
    private LeadRepository leadRepository;
99
 
100
    @Autowired
101
    private LeadActivityRepository leadActivityRepository;
102
 
37749 vikas 103
    /**
104
     * The disposition write — shared with the field app's post-call sheet in profitmandi-web. It is
105
     * the most consequential write in the LMS (SLA stamp, stage, trail, DND), so it lives in one
106
     * place rather than once per front end.
107
     */
37651 vikas 108
    @Autowired
37749 vikas 109
    private LmsDispositionService lmsDispositionService;
110
 
111
    @Autowired
37651 vikas 112
    private LeadLiveLocationRepository leadLiveLocationRepository;
113
 
114
    @Autowired
115
    private LeadCallRepository leadCallRepository;
116
 
117
    @Autowired
37737 vikas 118
    private LeadCallChecklistRepository leadCallChecklistRepository;
119
 
120
    @Autowired
37651 vikas 121
    private LeadDndRepository leadDndRepository;
122
 
123
    @Autowired
124
    private AuthRepository authRepository;
125
 
126
    @Autowired
127
    private RegionRepository regionRepository;
128
 
129
    @Autowired
37737 vikas 130
    private PositionRepository positionRepository;
131
 
132
    @Autowired
37651 vikas 133
    private LmsAssignmentService lmsAssignmentService;
134
 
135
    @Autowired
136
    private CookiesProcessor cookiesProcessor;
137
 
138
    @Autowired
139
    private ResponseSender<?> responseSender;
140
 
141
    // ---- Record page ------------------------------------------------------------------------
142
 
143
    /**
144
     * Full lead record: details, stepper, append-only trail, SLA card and the act-on-it controls.
145
     * Returned as a fragment — the caller drops it into {@code #main-content}.
146
     */
147
    // Same property the Leads screen's Generate Link button uses, so both produce an identical URL.
148
    @Value("${lead.geo.public.base-url:}")
149
    private String leadGeoPublicBaseUrl;
150
 
151
    @RequestMapping(value = "/leadRecord", method = RequestMethod.GET)
152
    public String leadRecord(@RequestParam(name = "leadId") int leadId, Model model) {
153
        Lead lead = leadRepository.selectById(leadId);
154
        if (lead == null) {
155
            // Fragment contract: whatever comes back is dropped straight into #main-content.
156
            model.addAttribute("response1",
157
                    "<div class=\"alert alert-warning\">Lead #" + leadId + " not found.</div>");
158
            return "response";
159
        }
160
 
161
        LeadStage effectiveStage = lead.getEffectiveStage();
162
        model.addAttribute("lead", lead);
163
        model.addAttribute("effectiveStage", effectiveStage);
164
        model.addAttribute("stageIndex", happyPathIndex(effectiveStage));
165
        model.addAttribute("terminal", isTerminal(effectiveStage));
166
        model.addAttribute("stages", LeadStage.HAPPY_PATH);
167
        model.addAttribute("dispositions", LeadDisposition.values());
168
        model.addAttribute("slaState", lmsAssignmentService.slaState(lead));
37692 vikas 169
        model.addAttribute("businessValue", lakhLabel(lead.getPotential()));
170
        model.addAttribute("businessValueInput", lakhInput(lead.getPotential()));
37651 vikas 171
        model.addAttribute("dateTimeFormatter", RECORD_FORMAT);
172
 
173
        // Region picker in the edit modal — the same list the Create-Lead form offers.
174
        model.addAttribute("regions", regionRepository.selectAll());
175
 
176
        model.addAttribute("owner", lead.getAssignTo() > 0 ? authRepository.selectById(lead.getAssignTo()) : null);
177
        model.addAttribute("bm", (lead.getOwnerBmId() != null && lead.getOwnerBmId() > 0)
178
                ? authRepository.selectById(lead.getOwnerBmId()) : null);
179
 
180
        LeadLiveLocation geo = leadLiveLocationRepository.selectByLeadId(leadId);
181
        model.addAttribute("geo", geo);
182
 
183
        // A blocked number must be visible before the agent reaches for the call button, not only
184
        // discovered when the dial is refused.
185
        model.addAttribute("dnd", leadDndRepository.selectByMobile(lead.getLeadMobile()));
186
        List<LeadCall> calls = leadCallRepository.selectByLeadId(leadId);
187
        model.addAttribute("calls", calls);
188
        // Keyed by id so a disposition in the trail can show the recording of the call it came from.
189
        // A disposition is the agent's account of the conversation; having to hunt for the audio in a
190
        // separate list to check it against the recording is the friction worth removing here.
191
        Map<Integer, LeadCall> callById = new HashMap<>();
192
        if (calls != null) {
193
            for (LeadCall c : calls) {
194
                callById.put(c.getId(), c);
195
            }
196
        }
197
        model.addAttribute("callById", callById);
198
 
199
        // Append-only trail, newest first, with actor names resolved in one batch.
200
        List<LeadActivity> trail = leadActivityRepository.selectBYLeadId(leadId);
201
        if (trail == null) {
202
            trail = new ArrayList<>();
203
        }
204
        trail.sort((a, b) -> {
205
            LocalDateTime ta = a.getCreatedTimestamp();
206
            LocalDateTime tb = b.getCreatedTimestamp();
207
            if (ta == null && tb == null) {
208
                return 0;
209
            }
210
            if (ta == null) {
211
                return 1;
212
            }
213
            if (tb == null) {
214
                return -1;
215
            }
216
            return tb.compareTo(ta);
217
        });
218
        model.addAttribute("trail", trail);
219
        model.addAttribute("authUserMap", actorsOf(trail));
220
 
221
        return "lead-record";
222
    }
223
 
224
    // ---- Mutations --------------------------------------------------------------------------
225
 
226
    /**
227
     * Move a lead forward along the happy path (or to a terminal state). Forward-only: the enum
228
     * decides what is legal, so a stale page cannot walk a lead backwards. Comment is mandatory —
229
     * it becomes the trail entry.
230
     */
231
    @RequestMapping(value = "/lms/advanceStage", method = RequestMethod.POST)
232
    @ResponseBody
233
    public ResponseEntity<?> advanceStage(HttpServletRequest request,
234
                                          @RequestParam(name = "leadId") int leadId,
235
                                          @RequestParam(name = "toStage") String toStage,
236
                                          @RequestParam(name = "comment") String comment) {
237
        if (comment == null || comment.trim().isEmpty()) {
238
            return responseSender.badRequest("A comment is required to change the stage");
239
        }
240
        Lead lead = leadRepository.selectById(leadId);
241
        if (lead == null) {
242
            return responseSender.notFound("Lead not found");
243
        }
244
 
245
        LeadStage target;
246
        try {
247
            target = LeadStage.valueOf(toStage);
248
        } catch (IllegalArgumentException e) {
249
            return responseSender.badRequest("Unknown stage: " + toStage);
250
        }
251
 
252
        LeadStage current = lead.getEffectiveStage();
253
        if (isTerminal(current)) {
254
            return responseSender.badRequest(pretty(current) + " is a terminal state — the lead cannot be moved on");
255
        }
256
        if (!current.canAdvanceTo(target)) {
257
            return responseSender.badRequest("Cannot move from " + pretty(current) + " to " + pretty(target));
258
        }
259
 
260
        AuthUser actor = currentUser(request);
261
        applyStage(lead, target);
262
        leadRepository.persist(lead);
37692 vikas 263
        appendTrail(leadId, actor,
264
                latin1Safe("Stage " + pretty(current) + " -> " + pretty(target) + ": " + comment.trim()),
265
                null, null);
37651 vikas 266
 
267
        LOGGER.info("LMS stage advanced: lead {} {} -> {} by {}", leadId, current, target,
268
                actor != null ? actor.getId() : 0);
269
        return responseSender.ok(stateOf(lead));
270
    }
271
 
272
    /**
273
     * Record a call outcome (SOP §12.2). The disposition drives the stage transition; the first
274
     * dispositioned contact stamps {@code firstContactedAt}, which is what closes the 5-hr SLA.
275
     */
276
    @RequestMapping(value = "/lms/disposition", method = RequestMethod.POST,
277
            consumes = MediaType.APPLICATION_JSON_VALUE)
278
    @ResponseBody
279
    public ResponseEntity<?> disposition(HttpServletRequest request,
37749 vikas 280
                                         @RequestBody LeadDispositionRequest body) {
281
        LmsOutcome outcome = lmsDispositionService.apply(currentUser(request), body);
282
        switch (outcome.status) {
283
            case OK:
284
                return responseSender.ok(stateOf((Lead) outcome.data.get("lead")));
285
            case NOT_FOUND:
286
                return responseSender.notFound(outcome.message);
287
            default:
288
                return responseSender.badRequest(outcome.message);
37651 vikas 289
        }
290
    }
291
 
292
    /**
293
     * Correct the record's own fields (SOP §6).
294
     *
295
     * <p>Everything else on this screen either moves the lead forward or logs a conversation;
296
     * nothing could fix a misheard shop name or a location that was never captured. Between the
297
     * create form (which writes {@code address}/{@code city}/{@code state} empty and leaves the
298
     * geo link to supply the real location) and the legacy Leads edit (which only touches status,
299
     * assignee, city and state, and is gated behind an approved geo-pin), a lead's business name,
300
     * address and region were unreachable once created.
301
     *
302
     * <p>What stays immutable is what the SOP makes immutable (§7.1): the LMS id, the created
303
     * stamp, the creation path, the stage and the SLA clock. Region is editable but is not a plain
304
     * field edit — it re-resolves the owning BM/RSM, so it is handled as a re-assignment below.
305
     *
306
     * <p>Every accepted change writes its own trail entry. One entry per field rather than one per
307
     * save, so "who changed this number, and when" is answerable without diffing two saves.
308
     */
309
    @RequestMapping(value = "/lms/updateLead", method = RequestMethod.POST,
310
            consumes = MediaType.APPLICATION_JSON_VALUE)
311
    @ResponseBody
312
    public ResponseEntity<?> updateLead(HttpServletRequest request, @RequestBody UpdateLeadRequest body) {
313
        if (body == null) {
314
            return responseSender.badRequest("Nothing to update");
315
        }
316
        Lead lead = leadRepository.selectById(body.leadId);
317
        if (lead == null) {
318
            return responseSender.notFound("Lead not found");
319
        }
320
        AuthUser me = currentUser(request);
321
        if (me == null) {
322
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
323
        }
324
 
37692 vikas 325
        String name = latin1Safe(trimToNull(body.retailerName));
37651 vikas 326
        if (name == null) {
327
            return responseSender.badRequest("Retailer name is required");
328
        }
37692 vikas 329
 
330
        // These columns are narrow (city and state are varchar(30)) and the server runs
331
        // STRICT_TRANS_TABLES, so an over-long value is a 500 at flush rather than a rejected field.
332
        // Check here and name the field, so the agent can shorten it instead of losing the edit.
333
        String tooLong = firstTooLong(
334
                "Retailer name", name, 128,
335
                "Business name", body.businessName, 100,
336
                "Address", body.address, 128,
337
                "City", body.city, 30,
338
                "State", body.state, 30);
339
        if (tooLong != null) {
340
            return responseSender.badRequest(tooLong);
341
        }
37651 vikas 342
        String mobile = digitsOnly(body.mobile);
343
        if (mobile == null || mobile.length() < 10) {
344
            return responseSender.badRequest("A valid 10-digit contact number is required");
345
        }
346
        mobile = mobile.substring(mobile.length() - 10);
347
 
348
        // A number change is the one edit that can quietly undo a compliance decision, so it gets
349
        // the same two checks the create form runs rather than being trusted as a typo fix.
350
        boolean mobileChanged = !mobile.equals(lead.getLeadMobile());
351
        if (mobileChanged) {
352
            if (leadDndRepository.selectByMobile(mobile) != null) {
353
                return responseSender.badRequest("That number is on the do-not-call register.");
354
            }
355
            Lead other = leadRepository.selectByMobileNumber(mobile);
356
            if (other != null && other.getId() != lead.getId()) {
357
                return responseSender.badRequest("Lead #" + other.getId() + " already exists for " + mobile);
358
            }
359
        }
360
 
361
        List<String> entries = new ArrayList<>();
362
 
363
        String beforeName = joinName(lead.getFirstName(), lead.getLastName());
364
        if (!name.equals(beforeName)) {
365
            int space = name.lastIndexOf(' ');
37692 vikas 366
            // Both halves are varchar(64); a long single-token name would otherwise fail at flush.
367
            if ((space > 0 ? name.substring(0, space) : name).length() > 64
368
                    || (space > 0 ? name.substring(space + 1) : "").length() > 64) {
369
                return responseSender.badRequest(
370
                        "Retailer name is too long - keep each part to 64 characters");
371
            }
37651 vikas 372
            lead.setFirstName(space > 0 ? name.substring(0, space) : name);
373
            // user.lead.last_name is NOT NULL — a single-word name keeps an empty surname, not null.
374
            lead.setLastName(space > 0 ? name.substring(space + 1) : "");
375
            entries.add(fieldChange("Retailer", beforeName, name));
376
        }
377
        if (mobileChanged) {
378
            entries.add(fieldChange("Contact", lead.getLeadMobile(), mobile));
379
            lead.setLeadMobile(mobile);
380
        }
381
 
37692 vikas 382
        String outlet = latin1Safe(trimToNull(body.businessName));
37651 vikas 383
        if (!same(lead.getOutLetName(), outlet)) {
384
            entries.add(fieldChange("Business", lead.getOutLetName(), outlet));
385
            lead.setOutLetName(outlet == null ? "" : outlet);
386
        }
387
 
388
        // address / city / state are NOT NULL — blanked fields are written empty, never null.
37692 vikas 389
        String address = latin1Safe(trimToEmpty(body.address));
390
        String city = latin1Safe(trimToEmpty(body.city));
391
        String state = latin1Safe(trimToEmpty(body.state));
37651 vikas 392
        String beforeLocation = locationOf(lead.getAddress(), lead.getCity(), lead.getState());
393
        String afterLocation = locationOf(address, city, state);
394
        if (!beforeLocation.equals(afterLocation)) {
395
            lead.setAddress(address);
396
            lead.setCity(city);
397
            lead.setState(state);
398
            entries.add(fieldChange("Location", beforeLocation, afterLocation));
399
        }
400
 
401
        if (body.potential != null && body.potential >= 0 && body.potential != lead.getPotential()) {
402
            entries.add(fieldChange("Business value",
403
                    lead.getPotential() > 0 ? money(lead.getPotential()) : null, money(body.potential)));
404
            lead.setPotential(body.potential);
405
        }
406
 
407
        // Region last: it is a re-assignment, and it reads better in the trail after the field edits.
408
        if (body.regionId != null && body.regionId > 0 && !body.regionId.equals(lead.getRegionId())) {
409
            LmsAssignmentService.Assignment a = lmsAssignmentService.resolve(body.regionId);
410
            if (a.regionId == null) {
411
                return responseSender.badRequest("Unknown region");
412
            }
413
            Integer previousBm = lead.getOwnerBmId();
414
            String beforeRegion = lead.getRegionCode();
415
            lead.setRegionId(a.regionId);
416
            lead.setRegionCode(a.regionCode);
417
            lead.setAssignmentStatus(a.assignmentStatus);
418
            lead.setOwnerBmId(a.bm != null ? a.bm.getId() : null);
419
 
420
            // Follow the region only while the lead is still sitting with whoever the engine picked.
421
            // Once a BM has handed it to a named ASM, that mapping is a decision — moving the region
422
            // must not silently undo it.
423
            boolean stillAutoAssigned = lead.getAssignTo() <= 0
424
                    || (previousBm != null && lead.getAssignTo() == previousBm.intValue());
425
            if (stillAutoAssigned) {
426
                lead.setAssignTo(a.bm != null ? a.bm.getId() : 0);
427
            }
428
 
429
            StringBuilder move = new StringBuilder(fieldChange("Region", beforeRegion, a.regionCode));
430
            if (a.bm != null) {
431
                move.append(" · owner re-resolved to ").append(a.bm.getFullName());
432
                if (!stillAutoAssigned) {
37692 vikas 433
                    move.append(" (working owner left unchanged - the lead is mapped to a named user)");
37651 vikas 434
                }
435
            } else {
436
                move.append(" · no active BM/RSM for that region, moved to the HOLD queue");
437
            }
438
            entries.add(move.toString());
439
        }
440
 
441
        if (entries.isEmpty()) {
442
            return responseSender.badRequest("Nothing changed");
443
        }
444
 
445
        lead.setUpdatedTimestamp(LocalDateTime.now());
446
        leadRepository.persist(lead);
447
        for (String entry : entries) {
37692 vikas 448
            trail(lead.getId(), me.getId(), latin1Safe(entry));
37651 vikas 449
        }
450
 
451
        LOGGER.info("LMS lead {} edited by auth {} — {}", lead.getId(), me.getId(), entries);
452
        Map<String, Object> out = new LinkedHashMap<>();
453
        out.put("ok", true);
454
        out.put("leadId", lead.getId());
455
        out.put("changes", entries.size());
456
        return responseSender.ok(out);
457
    }
458
 
459
    /** Payload for {@link #updateLead}. Only the fields this screen is allowed to correct. */
460
    public static class UpdateLeadRequest {
461
        public int leadId;
462
        public String retailerName;
463
        public String businessName;
464
        public String mobile;
465
        public String address;
466
        public String city;
467
        public String state;
468
        public Integer regionId;
469
        public Double potential;
470
    }
471
 
37692 vikas 472
    /**
473
     * Trail line for one edited field.
474
     *
475
     * <p>ASCII only, deliberately. {@code user.lead_activity.remark} is latin1_swedish_ci, so an
476
     * arrow or an em dash makes the INSERT fail with "Incorrect string value" — and because the
477
     * failure surfaces at flush rather than at the write, it arrives as an unrelated-looking
478
     * "null id in LeadActivity entry" 500.
479
     */
37651 vikas 480
    private String fieldChange(String label, String before, String after) {
37692 vikas 481
        return label + " " + orDash(before) + " -> " + orDash(after);
37651 vikas 482
    }
483
 
484
    private String orDash(String s) {
37692 vikas 485
        return (s == null || s.trim().isEmpty()) ? "(not set)" : s.trim();
37651 vikas 486
    }
487
 
37692 vikas 488
    /**
489
     * First over-long field, as a message naming it and its limit; null when all fit.
490
     *
491
     * <p>Args come in (label, value, max) triples. Retailer name is checked against 128 because it
492
     * is split across first_name and last_name, which are varchar(64) each.
493
     */
494
    private String firstTooLong(Object... fields) {
495
        for (int i = 0; i + 2 < fields.length; i += 3) {
496
            String label = (String) fields[i];
497
            String value = (String) fields[i + 1];
498
            int max = (Integer) fields[i + 2];
499
            if (value != null && value.trim().length() > max) {
500
                return label + " is too long - keep it to " + max + " characters (currently "
501
                        + value.trim().length() + ")";
502
            }
503
        }
504
        return null;
505
    }
506
 
507
    /**
508
     * Make caller-supplied text safe for the latin1 columns this controller writes.
509
     *
510
     * <p>Every text column here — {@code lead_activity.remark}, {@code lead.disposition_sub_reason},
511
     * the address fields — is latin1_swedish_ci. Fixing our own literals was only half the job: an
512
     * agent pastes a note from WhatsApp or Word and it arrives full of smart quotes, en dashes and
513
     * ellipses, none of which exist in latin1. That INSERT fails, and because the failure surfaces
514
     * at flush it reads as "null id in LeadActivity entry" rather than as a bad character.
515
     *
516
     * <p>The common typographic characters are transliterated rather than dropped, because losing an
517
     * apostrophe out of "retailer's shop" is silent damage. Anything else outside latin1 (emoji,
518
     * Devanagari) becomes '?' — visible, so it is obvious the text was not stored verbatim.
519
     */
520
    private String latin1Safe(String s) {
521
        if (s == null || s.isEmpty()) {
522
            return s;
523
        }
524
        StringBuilder out = new StringBuilder(s.length());
525
        for (int i = 0; i < s.length(); i++) {
526
            char c = s.charAt(i);
527
            switch (c) {
528
                case '\u2018': case '\u2019': case '\u201B': out.append('\''); break;   // ' ' ‛
529
                case '\u201C': case '\u201D': case '\u201E': out.append('"'); break;    // " " „
530
                case '\u2013': case '\u2014': case '\u2212': out.append('-'); break;    // – — −
531
                case '\u2026': out.append("..."); break;                                // …
532
                case '\u2192': out.append("->"); break;                                 // →
533
                case '\u20B9': out.append("Rs"); break;                                 // ₹
534
                case '\u00A0': out.append(' '); break;                                  // nbsp
535
                default:
536
                    out.append(c <= 0xFF ? c : '?');
537
            }
538
        }
539
        return out.toString();
540
    }
541
 
37651 vikas 542
    private boolean same(String a, String b) {
543
        return orDash(a).equals(orDash(b));
544
    }
545
 
546
    private String trimToEmpty(String s) {
547
        return s == null ? "" : s.trim();
548
    }
549
 
550
    private String joinName(String first, String last) {
551
        String joined = ((first == null ? "" : first) + " " + (last == null ? "" : last)).trim();
552
        return joined.replaceAll("\\s+", " ");
553
    }
554
 
555
    /** "address, city, state" with the empty parts dropped — what the record row prints. */
556
    private String locationOf(String address, String city, String state) {
557
        StringBuilder sb = new StringBuilder();
558
        for (String part : new String[]{address, city, state}) {
559
            if (part == null || part.trim().isEmpty()) {
560
                continue;
561
            }
562
            if (sb.length() > 0) {
563
                sb.append(", ");
564
            }
565
            sb.append(part.trim());
566
        }
567
        return sb.toString();
568
    }
569
 
570
    private String money(double value) {
37692 vikas 571
        String label = lakhLabel(value);
572
        return label == null ? "(not set)" : label + "/mo";
37651 vikas 573
    }
574
 
575
    /**
37737 vikas 576
     * First-call checklist for a lead (SOP §9 Path A step 2).
577
     *
578
     * <p>Returns the active question set, any answers already recorded, and — the part the caller
579
     * cannot work out for itself — whether this is actually the lead's FIRST connected call.
580
     *
581
     * <p>"First" means no earlier <em>answered</em> call. A string of unanswered dials does not make
582
     * the next one a second call: as far as the retailer is concerned nobody has spoken to them yet,
583
     * and that is exactly when the checklist is worth asking.
584
     */
585
    @RequestMapping(value = "/lms/checklist", method = RequestMethod.GET)
586
    @ResponseBody
587
    public ResponseEntity<?> checklist(@RequestParam(name = "leadId") int leadId,
588
                                       @RequestParam(name = "leadCallId", required = false) Integer leadCallId) {
589
        Lead lead = leadRepository.selectById(leadId);
590
        if (lead == null) {
591
            return responseSender.notFound("Lead not found");
592
        }
593
 
594
        // Only the questions that belong at the lead's CURRENT stage. A question tagged
595
        // NEW|ASSIGNED is a first-call question by construction; one tagged VISITED is not, and
596
        // showing the whole bank on every call is how a checklist becomes something people skip.
597
        String stage = lead.getEffectiveStage() == null ? null : lead.getEffectiveStage().name();
598
        List<LeadCallAnswer> answers = leadCallChecklistRepository.selectAnswersByLeadId(leadId);
599
        Map<Integer, LeadCallAnswer> byQuestion = new HashMap<>();
600
        for (LeadCallAnswer a : answers) {
601
            byQuestion.put(a.getQuestionId(), a);
602
        }
603
 
604
        List<Map<String, Object>> out = new ArrayList<>();
605
        int availablePoints = 0;
606
        for (LeadCallQuestion q : leadCallChecklistRepository.selectActiveQuestions()) {
607
            if (!q.appliesTo(stage)) {
608
                continue;
609
            }
610
            LeadCallAnswer a = byQuestion.get(q.getId());
611
            Map<String, Object> m = new LinkedHashMap<>();
612
            m.put("id", q.getId());
613
            m.put("prompt", q.getPrompt());
614
            m.put("answerType", q.getAnswerType());
615
            m.put("choices", q.getChoices() == null || q.getChoices().trim().isEmpty()
616
                    ? new ArrayList<String>() : Arrays.asList(q.getChoices().split("\\|")));
617
            m.put("required", q.isRequired());
618
            m.put("points", q.getPoints());
619
            m.put("answer", a == null ? null : a.getAnswer());
620
            out.add(m);
621
            availablePoints += q.getPoints();
622
        }
623
 
624
        // Score is over EVERY answer the lead has, not just the ones showing now — a lead that
625
        // answered the early questions keeps those points when it moves on to a later stage.
626
        int score = 0;
627
        for (LeadCallAnswer a : answers) {
628
            score += a.getPointsAwarded();
629
        }
630
 
631
        Map<String, Object> res = new LinkedHashMap<>();
632
        res.put("leadId", leadId);
633
        res.put("stage", stage);
634
        res.put("questions", out);
635
        res.put("answered", !answers.isEmpty());
636
        res.put("score", score);
637
        res.put("availablePoints", availablePoints);
638
        res.put("firstCall", !leadCallChecklistRepository.hasEarlierConnectedCall(leadId, leadCallId));
639
        // Worth showing on the panel: the checklist is about this retailer, not a generic form.
640
        res.put("retailer", joinName(lead.getFirstName(), lead.getLastName()));
641
        return responseSender.ok(res);
642
    }
643
 
644
    /** Record (or correct) the checklist answers for a lead. */
645
    @RequestMapping(value = "/lms/checklist", method = RequestMethod.POST,
646
            consumes = MediaType.APPLICATION_JSON_VALUE)
647
    @ResponseBody
648
    public ResponseEntity<?> saveChecklist(HttpServletRequest request, @RequestBody ChecklistRequest body) {
649
        if (body == null || body.leadId <= 0) {
650
            return responseSender.badRequest("Nothing to save");
651
        }
652
        Lead lead = leadRepository.selectById(body.leadId);
653
        if (lead == null) {
654
            return responseSender.notFound("Lead not found");
655
        }
656
        AuthUser me = currentUser(request);
657
        if (me == null) {
658
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
659
        }
660
 
661
        Map<Integer, LeadCallQuestion> active = new HashMap<>();
662
        for (LeadCallQuestion q : leadCallChecklistRepository.selectActiveQuestions()) {
663
            active.put(q.getId(), q);
664
        }
665
 
666
        int saved = 0;
667
        int points = 0;
668
        List<String> summary = new ArrayList<>();
669
        if (body.answers != null) {
670
            for (ChecklistAnswer a : body.answers) {
671
                LeadCallQuestion q = active.get(a.questionId);
672
                // Ignore anything that is not a live question rather than storing an answer to
673
                // something the screen never asked.
674
                if (q == null) {
675
                    continue;
676
                }
677
                String value = trimToNull(a.answer);
678
                if (value == null) {
679
                    continue;
680
                }
681
                if (value.length() > 1000) {
682
                    value = value.substring(0, 1000);
683
                }
684
                LeadCallAnswer row = new LeadCallAnswer();
685
                row.setLeadId(body.leadId);
686
                row.setLeadCallId(body.leadCallId);
687
                row.setQuestionId(q.getId());
688
                row.setAuthId(me.getId());
689
                row.setAnswer(value);
690
                // Snapshot the points as priced today, so re-pricing the question later does not
691
                // retroactively change what this lead scored.
692
                row.setPointsAwarded(q.getPoints());
693
                leadCallChecklistRepository.saveAnswer(row);
694
                saved++;
695
                points += q.getPoints();
696
                summary.add(q.getPrompt() + ": " + value);
697
            }
698
        }
699
 
700
        if (saved > 0) {
701
            // The trail is where the lead's story lives; a checklist that only existed in its own
702
            // table would be invisible to anyone reading the record.
703
            trail(body.leadId, me.getId(), latin1Safe("Checklist recorded (" + saved + " answer"
704
                    + (saved == 1 ? "" : "s") + ", " + points + " pts) - " + String.join(" \u00b7 ", summary)));
705
        }
706
 
707
        Map<String, Object> res = new LinkedHashMap<>();
708
        res.put("ok", true);
709
        res.put("saved", saved);
710
        res.put("points", points);
711
        LOGGER.info("Checklist saved for lead {} ({} answers) by auth {}", body.leadId, saved, me.getId());
712
        return responseSender.ok(res);
713
    }
714
 
715
    // ---- Assignment -------------------------------------------------------------------------
716
 
717
    /** Sales category in {@code cs.position} — the same one auto-assignment resolves owners from. */
718
    private static final int SALES_CATEGORY = com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES;
719
 
720
    /** Tiers a lead can be assigned to. L6+ exist but are not lead owners. */
721
    private static final List<EscalationType> SALES_TIERS = Arrays.asList(
722
            EscalationType.L1, EscalationType.L2, EscalationType.L3, EscalationType.L4, EscalationType.L5);
723
 
724
    /**
725
     * Sales people a lead can be assigned to, grouped by escalation tier (L1–L5).
726
     *
727
     * <p>Read from {@code cs.position}, the same source auto-assignment resolves owners from, so the
728
     * manual list and the engine can never disagree about who is a sales owner.
729
     *
730
     * <p>{@code regionId} does not filter the list — it marks who covers that region, so an agent can
731
     * see the right owner first without being prevented from picking someone else. A lead in a
732
     * region with no active owner still has to go to somebody.
733
     */
734
    @RequestMapping(value = "/lms/salespeople", method = RequestMethod.GET)
735
    @ResponseBody
736
    public ResponseEntity<?> salespeople(@RequestParam(name = "regionId", required = false) Integer regionId) {
737
        List<Position> positions = positionRepository.selectPositionByCategoryId(SALES_CATEGORY);
738
        if (positions == null) {
739
            positions = new ArrayList<>();
740
        }
741
 
742
        Set<Integer> ids = new HashSet<>();
743
        for (Position p : positions) {
744
            if (SALES_TIERS.contains(p.getEscalationType())) {
745
                ids.add(p.getAuthUserId());
746
            }
747
        }
748
        Map<Integer, AuthUser> users = new HashMap<>();
749
        if (!ids.isEmpty()) {
750
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
751
                users.put(u.getId(), u);
752
            }
753
        }
754
 
755
        // One row per person per tier: somebody can hold an L4 in one region and an L5 in another,
756
        // and collapsing that would hide which hat they are being picked under.
757
        Map<String, Map<String, Object>> byKey = new LinkedHashMap<>();
758
        for (Position p : positions) {
759
            if (!SALES_TIERS.contains(p.getEscalationType())) {
760
                continue;
761
            }
762
            AuthUser u = users.get(p.getAuthUserId());
763
            if (u == null || !Boolean.TRUE.equals(u.isActive())) {
764
                continue;                       // never offer a departed colleague as an owner
765
            }
766
            String key = p.getAuthUserId() + ":" + p.getEscalationType().name();
767
            Map<String, Object> m = byKey.get(key);
768
            if (m == null) {
769
                m = new LinkedHashMap<>();
770
                m.put("authId", u.getId());
771
                m.put("name", u.getFullName());
772
                m.put("email", u.getEmailId());
773
                m.put("tier", p.getEscalationType().name());
774
                m.put("regions", new ArrayList<String>());
775
                m.put("coversRegion", false);
776
                byKey.put(key, m);
777
            }
778
            Region r = p.getRegionId() > 0 ? regionRepository.selectById(p.getRegionId()) : null;
779
            if (r != null) {
780
                @SuppressWarnings("unchecked")
781
                List<String> regions = (List<String>) m.get("regions");
782
                String code = r.getRegionCode() != null ? r.getRegionCode() : r.getName();
783
                if (code != null && !regions.contains(code)) {
784
                    regions.add(code);
785
                }
786
                if (regionId != null && regionId > 0 && r.getId() == regionId) {
787
                    m.put("coversRegion", true);
788
                }
789
            }
790
        }
791
 
792
        Map<String, Object> res = new LinkedHashMap<>();
793
        res.put("people", new ArrayList<>(byKey.values()));
794
        List<String> tiers = new ArrayList<>();
795
        for (EscalationType t : SALES_TIERS) {
796
            tiers.add(t.name());
797
        }
798
        res.put("tiers", tiers);
799
        return responseSender.ok(res);
800
    }
801
 
802
    /**
803
     * Assign a lead to a sales person.
804
     *
805
     * <p>Sets the working owner ({@code assign_to}) only. The auto-resolved {@code owner_bm_id} is
806
     * left alone: it records which BM the region routed to, and overwriting it on a manual hand-off
807
     * would erase the routing decision the SLA and the HOLD queue are measured against.
808
     */
809
    @RequestMapping(value = "/lms/assignLead", method = RequestMethod.POST)
810
    @ResponseBody
811
    public ResponseEntity<?> assignLead(HttpServletRequest request,
812
                                        @RequestParam(name = "leadId") int leadId,
813
                                        @RequestParam(name = "authId") int authId) {
814
        Lead lead = leadRepository.selectById(leadId);
815
        if (lead == null) {
816
            return responseSender.notFound("Lead not found");
817
        }
818
        AuthUser me = currentUser(request);
819
        if (me == null) {
820
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
821
        }
822
        AuthUser owner = authRepository.selectById(authId);
823
        if (owner == null || !Boolean.TRUE.equals(owner.isActive())) {
824
            return responseSender.badRequest("That person is not an active user");
825
        }
826
 
827
        // Must hold a sales position — otherwise any auth id could be made a lead owner, and the
828
        // pipeline would show owners the assignment engine has never heard of.
829
        EscalationType tier = null;
830
        List<Position> held = positionRepository.selectPositionByAuthId(authId);
831
        if (held != null) {
832
            for (Position p : held) {
833
                if (p.getCategoryId() == SALES_CATEGORY && SALES_TIERS.contains(p.getEscalationType())) {
834
                    tier = p.getEscalationType();
835
                    break;
836
                }
837
            }
838
        }
839
        if (tier == null) {
840
            return responseSender.badRequest(owner.getFullName() + " does not hold a sales position (L1-L5)");
841
        }
842
 
843
        AuthUser previous = lead.getAssignTo() > 0 ? authRepository.selectById(lead.getAssignTo()) : null;
844
        if (previous != null && previous.getId() == authId) {
845
            return responseSender.badRequest("Already assigned to " + owner.getFullName());
846
        }
847
 
848
        lead.setAssignTo(authId);
849
        // A lead sitting in HOLD because its region had no owner now has one.
850
        if ("HOLD".equalsIgnoreCase(lead.getAssignmentStatus())) {
851
            lead.setAssignmentStatus("ASSIGNED");
852
        }
853
        lead.setUpdatedTimestamp(LocalDateTime.now());
854
        leadRepository.persist(lead);
855
 
856
        trail(leadId, me.getId(), latin1Safe("Assigned to " + owner.getFullName() + " (" + tier.name() + ")"
857
                + (previous != null ? " from " + previous.getFullName() : "")));
858
 
859
        LOGGER.info("Lead {} assigned to auth {} ({}) by auth {}", leadId, authId, tier, me.getId());
860
        Map<String, Object> res = new LinkedHashMap<>();
861
        res.put("ok", true);
862
        res.put("leadId", leadId);
863
        res.put("owner", owner.getFullName());
864
        res.put("tier", tier.name());
865
        return responseSender.ok(res);
866
    }
867
 
868
    // ---- Checklist question management (CRUD) -----------------------------------------------
869
 
870
    /** Every question, active and inactive, for the management screen. */
871
    @RequestMapping(value = "/lms/checklist/questions", method = RequestMethod.GET)
872
    @ResponseBody
873
    public ResponseEntity<?> listQuestions() {
874
        List<Map<String, Object>> out = new ArrayList<>();
875
        for (LeadCallQuestion q : leadCallChecklistRepository.selectAllQuestions()) {
876
            out.add(questionJson(q));
877
        }
878
        Map<String, Object> res = new LinkedHashMap<>();
879
        res.put("questions", out);
880
        // The screen builds its stage picker from this rather than hardcoding the ladder, so a new
881
        // LeadStage shows up without a front-end change.
882
        List<String> stages = new ArrayList<>();
883
        for (LeadStage st : LeadStage.HAPPY_PATH) {
884
            stages.add(st.name());
885
        }
886
        res.put("stages", stages);
887
        res.put("answerTypes", Arrays.asList("TEXT", "NUMBER", "YESNO", "CHOICE"));
888
        return responseSender.ok(res);
889
    }
890
 
891
    /** Create or update a question. An id of 0 creates. */
892
    @RequestMapping(value = "/lms/checklist/questions/save", method = RequestMethod.POST,
893
            consumes = MediaType.APPLICATION_JSON_VALUE)
894
    @ResponseBody
895
    public ResponseEntity<?> saveQuestion(HttpServletRequest request, @RequestBody QuestionRequest body) {
896
        if (body == null) {
897
            return responseSender.badRequest("Nothing to save");
898
        }
899
        AuthUser me = currentUser(request);
900
        if (me == null) {
901
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
902
        }
903
        String prompt = trimToNull(body.prompt);
904
        if (prompt == null) {
905
            return responseSender.badRequest("The question text is required");
906
        }
907
        if (prompt.length() > 500) {
908
            return responseSender.badRequest("Keep the question under 500 characters");
909
        }
910
        String type = trimToNull(body.answerType) == null ? "TEXT" : body.answerType.trim().toUpperCase();
911
        if (!Arrays.asList("TEXT", "NUMBER", "YESNO", "CHOICE").contains(type)) {
912
            return responseSender.badRequest("Unknown answer type: " + type);
913
        }
914
        String choices = trimToNull(body.choices);
915
        if ("CHOICE".equals(type) && choices == null) {
916
            return responseSender.badRequest("A choice question needs at least one option");
917
        }
918
        if (body.points != null && body.points < 0) {
919
            return responseSender.badRequest("Points cannot be negative");
920
        }
921
        // Reject unknown stage names rather than storing a filter that silently matches nothing.
922
        String stages = trimToNull(body.stages);
923
        if (stages != null) {
924
            for (String st : stages.split("\\|")) {
925
                if (st.trim().isEmpty()) {
926
                    continue;
927
                }
928
                try {
929
                    LeadStage.valueOf(st.trim().toUpperCase());
930
                } catch (IllegalArgumentException e) {
931
                    return responseSender.badRequest("Unknown stage: " + st.trim());
932
                }
933
            }
934
        }
935
 
936
        LeadCallQuestion q;
937
        boolean creating = body.id <= 0;
938
        if (creating) {
939
            q = new LeadCallQuestion();
940
        } else {
941
            q = leadCallChecklistRepository.selectQuestionById(body.id);
942
            if (q == null) {
943
                return responseSender.notFound("Question not found");
944
            }
945
        }
946
        q.setPrompt(latin1Safe(prompt));
947
        q.setAnswerType(type);
948
        q.setChoices("CHOICE".equals(type) ? latin1Safe(choices) : null);
949
        q.setRequired(Boolean.TRUE.equals(body.required));
950
        q.setActive(body.active == null || Boolean.TRUE.equals(body.active));
951
        q.setPoints(body.points == null ? 0 : body.points);
952
        q.setStages(stages == null ? null : stages.toUpperCase());
953
        q.setSortOrder(body.sortOrder == null ? 0 : body.sortOrder);
954
        leadCallChecklistRepository.saveQuestion(q);
955
 
956
        LOGGER.info("Checklist question {} by auth {}: {}", creating ? "created" : "updated", me.getId(), q.getPrompt());
957
        Map<String, Object> res = new LinkedHashMap<>();
958
        res.put("ok", true);
959
        res.put("id", q.getId());
960
        res.put("created", creating);
961
        return responseSender.ok(res);
962
    }
963
 
964
    /**
965
     * Delete a question — but only one nobody has answered.
966
     *
967
     * <p>A question with answers is retired by clearing {@code active}, never deleted: the answers
968
     * reference it, and removing it would leave a lead's recorded history pointing at nothing.
969
     */
970
    @RequestMapping(value = "/lms/checklist/questions/delete", method = RequestMethod.POST)
971
    @ResponseBody
972
    public ResponseEntity<?> deleteQuestion(HttpServletRequest request,
973
                                            @RequestParam(name = "id") int id) {
974
        AuthUser me = currentUser(request);
975
        if (me == null) {
976
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
977
        }
978
        LeadCallQuestion q = leadCallChecklistRepository.selectQuestionById(id);
979
        if (q == null) {
980
            return responseSender.notFound("Question not found");
981
        }
982
        long answers = leadCallChecklistRepository.countAnswersForQuestion(id);
983
        if (answers > 0) {
984
            return responseSender.badRequest("This question has " + answers + " recorded answer"
985
                    + (answers == 1 ? "" : "s") + " - switch it off instead of deleting it, so the "
986
                    + "answers keep their question");
987
        }
988
        leadCallChecklistRepository.deleteQuestion(q);
989
        LOGGER.info("Checklist question {} deleted by auth {}", id, me.getId());
990
        Map<String, Object> res = new LinkedHashMap<>();
991
        res.put("ok", true);
992
        return responseSender.ok(res);
993
    }
994
 
995
    private Map<String, Object> questionJson(LeadCallQuestion q) {
996
        Map<String, Object> m = new LinkedHashMap<>();
997
        m.put("id", q.getId());
998
        m.put("sortOrder", q.getSortOrder());
999
        m.put("prompt", q.getPrompt());
1000
        m.put("answerType", q.getAnswerType());
1001
        m.put("choices", q.getChoices());
1002
        m.put("required", q.isRequired());
1003
        m.put("active", q.isActive());
1004
        m.put("points", q.getPoints());
1005
        m.put("stages", q.getStages());
1006
        m.put("answerCount", leadCallChecklistRepository.countAnswersForQuestion(q.getId()));
1007
        return m;
1008
    }
1009
 
1010
    /** Payload for {@link #saveQuestion}. */
1011
    public static class QuestionRequest {
1012
        public int id;
1013
        public String prompt;
1014
        public String answerType;
1015
        public String choices;
1016
        public Boolean required;
1017
        public Boolean active;
1018
        public Integer points;
1019
        public String stages;
1020
        public Integer sortOrder;
1021
    }
1022
 
1023
    /** Payload for {@link #saveChecklist}. */
1024
    public static class ChecklistRequest {
1025
        public int leadId;
1026
        public Integer leadCallId;
1027
        public List<ChecklistAnswer> answers;
1028
    }
1029
 
1030
    /** One answer in that payload. */
1031
    public static class ChecklistAnswer {
1032
        public int questionId;
1033
        public String answer;
1034
    }
1035
 
1036
    /**
37651 vikas 1037
     * Region &rarr; owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead
1038
     * assignment preview and the dashboard's routing table; {@code HOLD} means the region has no
1039
     * active owner and a lead created against it would wait in the HOLD queue.
1040
     */
1041
    @RequestMapping(value = "/lms/resolve-region", method = RequestMethod.GET)
1042
    @ResponseBody
1043
    public ResponseEntity<?> resolveRegion(@RequestParam(name = "regionId", required = false) Integer regionId) {
1044
        LmsAssignmentService.Assignment assignment = lmsAssignmentService.resolve(regionId);
1045
        Map<String, Object> out = new HashMap<>();
1046
        out.put("regionId", assignment.regionId);
1047
        out.put("regionCode", assignment.regionCode);
1048
        out.put("assignmentStatus", assignment.assignmentStatus);
1049
        out.put("bmId", assignment.bm != null ? assignment.bm.getId() : null);
1050
        out.put("bmName", assignment.bm != null ? assignment.bm.getFullName() : null);
1051
        return responseSender.ok(out);
1052
    }
1053
 
1054
    // ---- Stage rules ------------------------------------------------------------------------
1055
 
1056
    /** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */
1057
    private void applyStage(Lead lead, LeadStage stage) {
1058
        lead.setStage(stage);
1059
        lead.setStatus(stage.toLegacyStatus());
1060
        lead.setUpdatedTimestamp(LocalDateTime.now());
1061
    }
1062
 
1063
    /** Move to {@code stage} only if that is a forward move — a later call never rewinds the lead. */
1064
    private void advanceIfForward(Lead lead, LeadStage stage) {
1065
        LeadStage current = lead.getEffectiveStage();
1066
        if (current == stage || current.canAdvanceTo(stage)) {
1067
            applyStage(lead, stage);
1068
        } else {
1069
            lead.setUpdatedTimestamp(LocalDateTime.now());
1070
        }
1071
    }
1072
 
1073
    // ---- Helpers ----------------------------------------------------------------------------
1074
 
1075
    private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
1076
                                     LocalDateTime scheduled) {
1077
        return appendTrail(leadId, actor, remark, type, scheduled, null);
1078
    }
1079
 
1080
    /** As above, additionally linking the trail entry to the call that produced it. */
1081
    private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,
1082
                                     LocalDateTime scheduled, Integer leadCallId) {
1083
        LeadActivity activity = new LeadActivity();
1084
        activity.setLeadId(leadId);
1085
        activity.setRemark(remark);
1086
        activity.setAuthId(actor != null ? actor.getId() : 0);
1087
        activity.setCommunicationType(type);
1088
        activity.setSchelduleTimestamp(scheduled);
1089
        activity.setLeadCallId(leadCallId);
1090
        activity.setCreatedTimestamp(LocalDateTime.now());
1091
        leadActivityRepository.persist(activity);
1092
        return activity;
1093
    }
1094
 
1095
    /** What the caller needs to refresh its row without re-reading the whole record. */
1096
    private Map<String, Object> stateOf(Lead lead) {
1097
        LeadStage stage = lead.getEffectiveStage();
1098
        Map<String, Object> out = new HashMap<>();
1099
        out.put("leadId", lead.getId());
1100
        out.put("stage", stage.name());
1101
        out.put("stageLabel", pretty(stage));
1102
        out.put("terminal", isTerminal(stage));
1103
        out.put("slaState", lmsAssignmentService.slaState(lead));
1104
        return out;
1105
    }
1106
 
1107
    private Map<Integer, AuthUser> actorsOf(List<LeadActivity> trail) {
1108
        Set<Integer> ids = new HashSet<>();
1109
        for (LeadActivity a : trail) {
1110
            if (a.getAuthId() > 0) {
1111
                ids.add(a.getAuthId());
1112
            }
1113
        }
1114
        Map<Integer, AuthUser> actors = new HashMap<>();
1115
        if (!ids.isEmpty()) {
1116
            for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {
1117
                actors.put(u.getId(), u);
1118
            }
1119
        }
1120
        return actors;
1121
    }
1122
 
1123
    private AuthUser currentUser(HttpServletRequest request) {
1124
        try {
1125
            LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1126
            if (loginDetails != null && loginDetails.getEmailId() != null) {
1127
                return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1128
            }
1129
        } catch (Exception e) {
1130
            LOGGER.warn("Could not resolve the acting user for an LMS mutation", e);
1131
        }
1132
        return null;
1133
    }
1134
 
1135
    private int happyPathIndex(LeadStage stage) {
1136
        for (int i = 0; i < LeadStage.HAPPY_PATH.length; i++) {
1137
            if (LeadStage.HAPPY_PATH[i] == stage) {
1138
                return i;
1139
            }
1140
        }
1141
        return -1;
1142
    }
1143
 
1144
    private boolean isTerminal(LeadStage stage) {
1145
        return stage == LeadStage.NOT_INTERESTED || stage == LeadStage.DROPPED;
1146
    }
1147
 
1148
    private String pretty(LeadStage stage) {
37692 vikas 1149
        return stage == null ? "-" : pretty(stage.name());
37651 vikas 1150
    }
1151
 
1152
    private String pretty(String enumName) {
1153
        return enumName.replace('_', ' ');
1154
    }
1155
 
1156
    private String trimToNull(String s) {
1157
        return (s == null || s.trim().isEmpty()) ? null : s.trim();
1158
    }
1159
 
37749 vikas 1160
    private static boolean positive(Integer n) {
1161
        return n != null && n > 0;
1162
    }
1163
 
37651 vikas 1164
    private String digitsOnly(String s) {
1165
        return s == null ? null : s.replaceAll("\\D", "");
1166
    }
1167
 
1168
    /** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */
1169
    private LocalDateTime parseLocal(String s) {
1170
        if (s == null || s.trim().isEmpty()) {
1171
            return null;
1172
        }
1173
        try {
1174
            return LocalDateTime.parse(s.trim());
1175
        } catch (Exception e) {
1176
            LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);
1177
            return null;
1178
        }
1179
    }
1180
 
1181
    /**
1182
     * Create a lead from the LMS dashboard.
1183
     *
1184
     * <p><b>Deliberately not {@code /createLead}.</b> That endpoint is shared with the legacy Leads
1185
     * screen and must keep behaving exactly as it does. It also silently ignores the {@code regionId}
1186
     * and {@code creationPath} this dashboard has always sent — which is why no lead has ever had a
1187
     * stage, an LMS code or an SLA clock, while the UI cheerfully toasted "auto-assigned · SLA
1188
     * started". This endpoint is what makes that message true.
1189
     *
1190
     * <p>Asks for far less than the legacy form: no store photos, no counter size, no brand-wise
1191
     * values, no free-text address. The store-board photo and lat/lng arrive through the geo capture
1192
     * link (auto-generated below), and location is confirmed by that link or by manual verification —
1193
     * so an agent on a first call is no longer made to invent shop-audit data.
1194
     */
1195
    @RequestMapping(value = "/lms/createLead", method = RequestMethod.POST,
1196
            consumes = MediaType.APPLICATION_JSON_VALUE)
1197
    @ResponseBody
1198
    public ResponseEntity<?> createLead(HttpServletRequest request, @RequestBody CreateLeadRequest body) {
1199
        if (body == null) {
1200
            return responseSender.badRequest("Nothing to create");
1201
        }
37692 vikas 1202
        String name = latin1Safe(body.firstName == null ? "" : body.firstName.trim());
37651 vikas 1203
        if (name.isEmpty()) {
1204
            return responseSender.badRequest("Retailer name is required");
1205
        }
1206
        String mobile = body.mobile == null ? "" : body.mobile.replaceAll("\\D", "");
1207
        if (mobile.length() < 10) {
1208
            return responseSender.badRequest("A valid 10-digit contact number is required");
1209
        }
1210
        mobile = mobile.substring(mobile.length() - 10);
1211
        if (body.regionId == null || body.regionId <= 0) {
1212
            return responseSender.badRequest("Pick a region — it drives auto-assignment");
1213
        }
37749 vikas 1214
        // Narrow columns + STRICT_TRANS_TABLES: name the field instead of failing at flush.
1215
        String tooLong = firstTooLong(
1216
                "Address", body.address, 128,
1217
                "City", body.city, 30,
1218
                "State", body.state, 30);
1219
        if (tooLong != null) {
1220
            return responseSender.badRequest(tooLong);
1221
        }
37651 vikas 1222
 
1223
        // Same rule the dialer uses: a number on the do-not-call register never becomes a lead.
1224
        if (leadDndRepository.selectByMobile(mobile) != null) {
1225
            return responseSender.badRequest("This number is on the do-not-call register.");
1226
        }
1227
 
1228
        Lead existing = leadRepository.selectByMobileNumber(mobile);
1229
        if (existing != null) {
1230
            return responseSender.badRequest("Lead #" + existing.getId() + " already exists for "
1231
                    + mobile + ", created by " + existing.getCreatedBy());
1232
        }
1233
 
1234
        AuthUser me = currentUser(request);
1235
        if (me == null) {
1236
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
1237
        }
1238
 
1239
        Lead lead = new Lead();
1240
        // Split on the last space so "Jitu Katara" keeps a surname; user.lead.last_name is NOT NULL.
1241
        int space = name.lastIndexOf(' ');
1242
        lead.setFirstName(space > 0 ? name.substring(0, space) : name);
1243
        lead.setLastName(space > 0 ? name.substring(space + 1) : "");
1244
        lead.setLeadMobile(mobile);
37749 vikas 1245
        // address / city / state are NOT NULL. They are optional on this form (the geo link supplies
1246
        // the real location), so whatever was typed is kept and blanks are coalesced rather than
1247
        // left to fail the insert.
1248
        lead.setAddress(latin1Safe(body.address == null ? "" : body.address.trim()));
1249
        lead.setCity(latin1Safe(body.city == null ? "" : body.city.trim()));
1250
        lead.setState(latin1Safe(body.state == null ? "" : body.state.trim()));
1251
        // Legacy "conversion probability": the Leads screen colours a High lead Green, else Yellow.
1252
        lead.setColor(Boolean.TRUE.equals(body.conversionHigh) ? "Green" : "Yellow");
37692 vikas 1253
        lead.setSource(latin1Safe(body.source == null || body.source.trim().isEmpty()
1254
                ? "LMS Dashboard" : body.source.trim()));
37651 vikas 1255
        lead.setCreatedTimestamp(LocalDateTime.now());
1256
        lead.setUpdatedTimestamp(LocalDateTime.now());
1257
        lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
1258
        lead.setAuthId(me.getId());
1259
        // The record screen reads the business name off user.lead.outlet_name. Until now it was only
1260
        // written to lead_detail below — a call that throws for every lead created here, because the
1261
        // legacy detail path demands store photos this form deliberately does not collect — so the
1262
        // name the agent typed was silently dropped and the Business row rendered blank.
37692 vikas 1263
        lead.setOutLetName(latin1Safe(body.outletName == null ? "" : body.outletName.trim()));
37651 vikas 1264
        lead.setRegionId(body.regionId);
1265
        if (body.potential != null && body.potential > 0) {
1266
            lead.setPotential(body.potential);
1267
        }
1268
        // Path B is a field encounter, so the creator owns it; Path A goes to the region's BM.
1269
        if ("B".equalsIgnoreCase(body.creationPath)) {
1270
            lead.setAssignTo(me.getId());
1271
        }
1272
 
1273
        // THE point of this endpoint: region -> owner -> stage -> 5hr SLA, per SOP 9.
1274
        LmsAssignmentService.Assignment assignment = lmsAssignmentService.assign(lead, body.creationPath);
1275
        leadRepository.persist(lead);
1276
 
1277
        // lms_code needs the generated id, so it can only be stamped after the insert.
1278
        lead.setLmsCode(lmsAssignmentService.generateLmsCode(lead));
1279
        leadRepository.persist(lead);
1280
 
1281
        boolean hasBrands = body.leadBrands != null && !body.leadBrands.isEmpty();
37749 vikas 1282
        boolean hasPhotos = positive(body.frontp) || positive(body.frontWithMarket) || positive(body.internalLongShot)
1283
                || positive(body.internalLeftWall) || positive(body.internalRightWall);
1284
        boolean hasCounter = positive(body.counterSize);
1285
        if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands || hasPhotos || hasCounter) {
37651 vikas 1286
            try {
1287
                LeadDetailModel detail = new LeadDetailModel();
1288
                detail.setLeadId(lead.getId());
1289
                detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());
37749 vikas 1290
                if (hasCounter) {
1291
                    detail.setCounterSize(body.counterSize);
1292
                }
1293
                // Whatever photos came with the form; none are required here (see persistLeadDetail's
1294
                // lenient overload) because this lead is usually created on a call, before a visit.
1295
                if (positive(body.frontp)) detail.setFrontp(body.frontp);
1296
                if (positive(body.frontWithMarket)) detail.setFrontWithMarket(body.frontWithMarket);
1297
                if (positive(body.internalLongShot)) detail.setInternalLongShot(body.internalLongShot);
1298
                if (positive(body.internalLeftWall)) detail.setInternalLeftWall(body.internalLeftWall);
1299
                if (positive(body.internalRightWall)) detail.setInternalRightWall(body.internalRightWall);
37651 vikas 1300
                if (hasBrands) {
1301
                    List<LeadBrandModel> brands = new ArrayList<>();
1302
                    for (BrandValue bv : body.leadBrands) {
1303
                        if (bv == null || bv.brand == null || bv.brand.trim().isEmpty() || bv.value == null
1304
                                || bv.value <= 0) {
1305
                            // Only brands the retailer actually stocks. Persisting zeroes for the rest
1306
                            // is what made the legacy brand table useless for reporting.
1307
                            continue;
1308
                        }
1309
                        LeadBrandModel brand = new LeadBrandModel();
1310
                        brand.setBrand(bv.brand.trim());
1311
                        brand.setValue(bv.value.intValue());
1312
                        brands.add(brand);
1313
                    }
1314
                    detail.setLeadBrands(brands);
1315
                }
37749 vikas 1316
                leadRepository.persistLeadDetail(detail, me, false);
37651 vikas 1317
            } catch (Exception e) {
1318
                // Shop name and brand split are not worth losing an otherwise-good lead over.
1319
                LOGGER.warn("Could not save the lead detail for lead {}", lead.getId(), e);
1320
            }
1321
        }
1322
 
1323
        StringBuilder created = new StringBuilder("Lead created from the LMS dashboard");
1324
        if (hasBrands) {
1325
            created.append(" · brand-wise value: ");
1326
            boolean first = true;
1327
            for (BrandValue bv : body.leadBrands) {
1328
                if (bv == null || bv.brand == null || bv.value == null || bv.value <= 0) {
1329
                    continue;
1330
                }
1331
                if (!first) {
1332
                    created.append(", ");
1333
                }
1334
                created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
1335
                first = false;
1336
            }
1337
        }
37749 vikas 1338
        if (hasPhotos) {
1339
            created.append(" · store photos attached");
1340
        }
1341
        String remark = body.remark == null ? "" : body.remark.trim();
1342
        if (!remark.isEmpty()) {
1343
            created.append(" · remark: ").append(remark.length() > 500 ? remark.substring(0, 500) : remark);
1344
        }
37692 vikas 1345
        trail(lead.getId(), me.getId(), latin1Safe(created.toString()
37651 vikas 1346
                + (assignment != null && "HOLD".equals(assignment.assignmentStatus)
37692 vikas 1347
                   ? " - region has no active BM/RSM, parked in the HOLD queue" : "")));
37651 vikas 1348
 
1349
        // Auto-generate the geo capture link so the agent can send it during the same call.
1350
        String geoLink = buildGeoCaptureLink(lead.getId());
1351
        if (geoLink != null) {
1352
            trail(lead.getId(), me.getId(), "Geolocation link generated for lead");
1353
        }
1354
 
1355
        Map<String, Object> out = new LinkedHashMap<>();
1356
        out.put("ok", true);
1357
        out.put("leadId", lead.getId());
1358
        out.put("lmsCode", lead.getLmsCode());
1359
        out.put("assignmentStatus", lead.getAssignmentStatus());
1360
        out.put("ownerName", assignment != null && assignment.bm != null
1361
                ? assignment.bm.getFirstName() + " " + assignment.bm.getLastName() : null);
1362
        out.put("geoLink", geoLink);
1363
        LOGGER.info("LMS lead {} created ({}) by auth {} — region {} status {}",
1364
                lead.getId(), lead.getLmsCode(), me.getId(), lead.getRegionCode(), lead.getAssignmentStatus());
1365
        return responseSender.ok(out);
1366
    }
1367
 
37692 vikas 1368
    /**
1369
     * Business value on the lakh scale.
1370
     *
1371
     * <p>Two conventions live in {@code user.lead.potential}: the forms capture LAKHS (rows hold
1372
     * 1&ndash;99) but about 1,355 older rows were entered in RUPEES (1,000 upwards). No row sits
1373
     * between 100 and 999, so that gap is a safe separator — anything from {@value #LAKH_CUTOFF} up
1374
     * is read as rupees and brought onto the lakh scale. Read-side only: no stored value is
1375
     * rewritten, so a row keeps whatever it holds until someone edits it.
1376
     */
1377
    private static final double LAKH_CUTOFF = 1000d;
1378
 
1379
    private double toLakh(double v) {
1380
        return v >= LAKH_CUTOFF ? v / 100000d : v;
1381
    }
1382
 
1383
    /** "12L" / "5.2L" / "0.01L" — a sub-lakh value needs two decimals or it prints as 0L. */
1384
    private String lakhLabel(double potential) {
1385
        if (potential <= 0) {
1386
            return null;
37651 vikas 1387
        }
37692 vikas 1388
        double l = toLakh(potential);
1389
        String n = (l < 1) ? trimZeros(String.format("%.2f", l)) : trimZeros(String.format("%.1f", l));
1390
        return n + "L";
37651 vikas 1391
    }
1392
 
37692 vikas 1393
    /** The same number, unsuffixed, for the edit form — so saving cannot re-store rupees. */
1394
    private String lakhInput(double potential) {
1395
        if (potential <= 0) {
1396
            return "";
1397
        }
1398
        double l = toLakh(potential);
1399
        return (l < 1) ? trimZeros(String.format("%.2f", l)) : trimZeros(String.format("%.1f", l));
1400
    }
1401
 
1402
    private String trimZeros(String s) {
1403
        return s.contains(".") ? s.replaceAll("0+$", "").replaceAll("\\.$", "") : s;
1404
    }
1405
 
37651 vikas 1406
    /**
37692 vikas 1407
     * Append a trail entry.
1408
     *
1409
     * <p>Deliberately NOT caught. It used to swallow the exception on the grounds that "a lost note
1410
     * must not fail the lead", but inside a {@code @Transactional} method that is worse than
1411
     * useless: the failed entity stays in the Hibernate session with a null id, the request carries
1412
     * on, and the commit-time flush dies with "null id in LeadActivity entry" — an error that names
1413
     * neither the lead nor the real cause. An edit that cannot be recorded should fail loudly and
1414
     * roll back; SOP §6 wants the trail append-only, and a change with no trail entry is worse than
1415
     * a change that did not happen.
1416
     */
1417
    private void trail(int leadId, int authId, String remark) {
1418
        LeadActivity activity = new LeadActivity();
1419
        activity.setLeadId(leadId);
1420
        activity.setAuthId(authId);
1421
        activity.setRemark(remark);
1422
        activity.setCreatedTimestamp(LocalDateTime.now());
1423
        leadActivityRepository.persist(activity);
1424
    }
1425
 
1426
    /**
37651 vikas 1427
     * {base}/lead-geo/{leadId} — the same URL the Leads screen's Generate Link button produces.
1428
     * Null when the base URL is unconfigured, which must not stop a lead being created.
1429
     */
1430
    private String buildGeoCaptureLink(int leadId) {
1431
        String base = leadGeoPublicBaseUrl;
1432
        if (base == null || base.trim().isEmpty()) {
1433
            return null;
1434
        }
1435
        base = base.trim();
1436
        while (base.endsWith("/")) {
1437
            base = base.substring(0, base.length() - 1);
1438
        }
1439
        return base + "/lead-geo/" + leadId;
1440
    }
1441
 
1442
    /** Payload for {@link #createLead}. */
1443
    public static class CreateLeadRequest {
1444
        public String firstName;      // retailer contact name, split into first/last
1445
        public String mobile;
1446
        public String outletName;     // business / shop name
1447
        public String source;
1448
        public Integer regionId;      // drives auto-assignment
1449
        public String creationPath;   // A = BGC outbound, B = field encounter
1450
        public Double potential;      // monthly business value — the sum of leadBrands
1451
        /** Per-brand monthly business. What a BM actually needs to size the counter. */
1452
        public List<BrandValue> leadBrands;
37749 vikas 1453
        // Shop details the legacy Leads screen also captures. All optional here.
1454
        public String address;
1455
        public String city;
1456
        public String state;
1457
        public String remark;         // becomes the first trail entry's note
1458
        public Boolean conversionHigh; // legacy "conversion probability": High -> Green, else Yellow
1459
        public Integer counterSize;   // user.lead_detail.counter_size
1460
        // Store photos as document ids from /document-upload (user.lead_detail columns).
1461
        public Integer frontp;
1462
        public Integer frontWithMarket;
1463
        public Integer internalLongShot;
1464
        public Integer internalLeftWall;
1465
        public Integer internalRightWall;
37651 vikas 1466
    }
1467
 
1468
    /** One brand's monthly business value. */
1469
    public static class BrandValue {
1470
        public String brand;
1471
        public Double value;
1472
    }
1473
}