Subversion Repositories SmartDukaan

Rev

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