Subversion Repositories SmartDukaan

Rev

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