Subversion Repositories SmartDukaan

Rev

Rev 37651 | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 37651 Rev 37692
Line 139... Line 139...
139
        model.addAttribute("stageIndex", happyPathIndex(effectiveStage));
139
        model.addAttribute("stageIndex", happyPathIndex(effectiveStage));
140
        model.addAttribute("terminal", isTerminal(effectiveStage));
140
        model.addAttribute("terminal", isTerminal(effectiveStage));
141
        model.addAttribute("stages", LeadStage.HAPPY_PATH);
141
        model.addAttribute("stages", LeadStage.HAPPY_PATH);
142
        model.addAttribute("dispositions", LeadDisposition.values());
142
        model.addAttribute("dispositions", LeadDisposition.values());
143
        model.addAttribute("slaState", lmsAssignmentService.slaState(lead));
143
        model.addAttribute("slaState", lmsAssignmentService.slaState(lead));
-
 
144
        model.addAttribute("businessValue", lakhLabel(lead.getPotential()));
-
 
145
        model.addAttribute("businessValueInput", lakhInput(lead.getPotential()));
144
        model.addAttribute("dateTimeFormatter", RECORD_FORMAT);
146
        model.addAttribute("dateTimeFormatter", RECORD_FORMAT);
145
 
147
 
146
        // Region picker in the edit modal — the same list the Create-Lead form offers.
148
        // Region picker in the edit modal — the same list the Create-Lead form offers.
147
        model.addAttribute("regions", regionRepository.selectAll());
149
        model.addAttribute("regions", regionRepository.selectAll());
148
 
150
 
Line 231... Line 233...
231
        }
233
        }
232
 
234
 
233
        AuthUser actor = currentUser(request);
235
        AuthUser actor = currentUser(request);
234
        applyStage(lead, target);
236
        applyStage(lead, target);
235
        leadRepository.persist(lead);
237
        leadRepository.persist(lead);
-
 
238
        appendTrail(leadId, actor,
236
        appendTrail(leadId, actor, "Stage " + pretty(current) + " → " + pretty(target) + ": " + comment.trim(), null, null);
239
                latin1Safe("Stage " + pretty(current) + " -> " + pretty(target) + ": " + comment.trim()),
-
 
240
                null, null);
237
 
241
 
238
        LOGGER.info("LMS stage advanced: lead {} {} -> {} by {}", leadId, current, target,
242
        LOGGER.info("LMS stage advanced: lead {} {} -> {} by {}", leadId, current, target,
239
                actor != null ? actor.getId() : 0);
243
                actor != null ? actor.getId() : 0);
240
        return responseSender.ok(stateOf(lead));
244
        return responseSender.ok(stateOf(lead));
241
    }
245
    }
Line 269... Line 273...
269
 
273
 
270
        AuthUser actor = currentUser(request);
274
        AuthUser actor = currentUser(request);
271
        LeadStage before = lead.getEffectiveStage();
275
        LeadStage before = lead.getEffectiveStage();
272
        LeadCall call = resolveCall(body, lead, actor);
276
        LeadCall call = resolveCall(body, lead, actor);
273
 
277
 
-
 
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
        }
274
        lead.setDisposition(disposition);
282
        lead.setDisposition(disposition);
275
        lead.setDispositionSubReason(trimToNull(body.subReason));
283
        lead.setDispositionSubReason(subReason);
276
        // Interim manual path — drops out when the dialer starts filling recordings from the webhook.
284
        // Interim manual path — drops out when the dialer starts filling recordings from the webhook.
277
        if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {
285
        if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {
278
            lead.setRecordingUrl(body.recordingUrl.trim());
286
            lead.setRecordingUrl(body.recordingUrl.trim());
279
        }
287
        }
280
        // Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody
288
        // Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody
Line 293... Line 301...
293
        leadRepository.persist(lead);
301
        leadRepository.persist(lead);
294
 
302
 
295
        CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)
303
        CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)
296
                ? CommunicationType.VISIT : CommunicationType.TELEPHONIC;
304
                ? CommunicationType.VISIT : CommunicationType.TELEPHONIC;
297
        LeadActivity activity = appendTrail(body.leadId, actor,
305
        LeadActivity activity = appendTrail(body.leadId, actor,
298
                dispositionRemark(disposition, before, lead, body), type, scheduled,
306
                latin1Safe(dispositionRemark(disposition, before, lead, body)), type, scheduled,
299
                call != null ? call.getId() : null);
307
                call != null ? call.getId() : null);
300
 
308
 
301
        // Bind both ways: the trail entry knows its call (for duration + a recording link), and the
309
        // Bind both ways: the trail entry knows its call (for duration + a recording link), and the
302
        // call knows the disposition it produced (so an unactioned call is findable).
310
        // call knows the disposition it produced (so an unactioned call is findable).
303
        if (call != null) {
311
        if (call != null) {
Line 354... Line 362...
354
        LeadDnd dnd = new LeadDnd();
362
        LeadDnd dnd = new LeadDnd();
355
        dnd.setMobile(lead.getLeadMobile());
363
        dnd.setMobile(lead.getLeadMobile());
356
        dnd.setLeadId(lead.getId());
364
        dnd.setLeadId(lead.getId());
357
        dnd.setAuthId(actor != null ? actor.getId() : null);
365
        dnd.setAuthId(actor != null ? actor.getId() : null);
358
        dnd.setSource("DISPOSITION");
366
        dnd.setSource("DISPOSITION");
359
        dnd.setReason(trimToNull(body.note) != null
367
        dnd.setReason(latin1Safe(trimToNull(body.note) != null
360
                ? trimToNull(body.note) : "Retailer asked not to be contacted");
368
                ? trimToNull(body.note) : "Retailer asked not to be contacted"));
361
        leadDndRepository.block(dnd);
369
        leadDndRepository.block(dnd);
362
        LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);
370
        LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);
363
    }
371
    }
364
 
372
 
365
    /**
373
    /**
Line 393... Line 401...
393
        AuthUser me = currentUser(request);
401
        AuthUser me = currentUser(request);
394
        if (me == null) {
402
        if (me == null) {
395
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
403
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
396
        }
404
        }
397
 
405
 
398
        String name = trimToNull(body.retailerName);
406
        String name = latin1Safe(trimToNull(body.retailerName));
399
        if (name == null) {
407
        if (name == null) {
400
            return responseSender.badRequest("Retailer name is required");
408
            return responseSender.badRequest("Retailer name is required");
401
        }
409
        }
-
 
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
        }
402
        String mobile = digitsOnly(body.mobile);
423
        String mobile = digitsOnly(body.mobile);
403
        if (mobile == null || mobile.length() < 10) {
424
        if (mobile == null || mobile.length() < 10) {
404
            return responseSender.badRequest("A valid 10-digit contact number is required");
425
            return responseSender.badRequest("A valid 10-digit contact number is required");
405
        }
426
        }
406
        mobile = mobile.substring(mobile.length() - 10);
427
        mobile = mobile.substring(mobile.length() - 10);
Line 421... Line 442...
421
        List<String> entries = new ArrayList<>();
442
        List<String> entries = new ArrayList<>();
422
 
443
 
423
        String beforeName = joinName(lead.getFirstName(), lead.getLastName());
444
        String beforeName = joinName(lead.getFirstName(), lead.getLastName());
424
        if (!name.equals(beforeName)) {
445
        if (!name.equals(beforeName)) {
425
            int space = name.lastIndexOf(' ');
446
            int space = name.lastIndexOf(' ');
-
 
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
            }
426
            lead.setFirstName(space > 0 ? name.substring(0, space) : name);
453
            lead.setFirstName(space > 0 ? name.substring(0, space) : name);
427
            // user.lead.last_name is NOT NULL — a single-word name keeps an empty surname, not null.
454
            // user.lead.last_name is NOT NULL — a single-word name keeps an empty surname, not null.
428
            lead.setLastName(space > 0 ? name.substring(space + 1) : "");
455
            lead.setLastName(space > 0 ? name.substring(space + 1) : "");
429
            entries.add(fieldChange("Retailer", beforeName, name));
456
            entries.add(fieldChange("Retailer", beforeName, name));
430
        }
457
        }
431
        if (mobileChanged) {
458
        if (mobileChanged) {
432
            entries.add(fieldChange("Contact", lead.getLeadMobile(), mobile));
459
            entries.add(fieldChange("Contact", lead.getLeadMobile(), mobile));
433
            lead.setLeadMobile(mobile);
460
            lead.setLeadMobile(mobile);
434
        }
461
        }
435
 
462
 
436
        String outlet = trimToNull(body.businessName);
463
        String outlet = latin1Safe(trimToNull(body.businessName));
437
        if (!same(lead.getOutLetName(), outlet)) {
464
        if (!same(lead.getOutLetName(), outlet)) {
438
            entries.add(fieldChange("Business", lead.getOutLetName(), outlet));
465
            entries.add(fieldChange("Business", lead.getOutLetName(), outlet));
439
            lead.setOutLetName(outlet == null ? "" : outlet);
466
            lead.setOutLetName(outlet == null ? "" : outlet);
440
        }
467
        }
441
 
468
 
442
        // address / city / state are NOT NULL — blanked fields are written empty, never null.
469
        // address / city / state are NOT NULL — blanked fields are written empty, never null.
443
        String address = trimToEmpty(body.address);
470
        String address = latin1Safe(trimToEmpty(body.address));
444
        String city = trimToEmpty(body.city);
471
        String city = latin1Safe(trimToEmpty(body.city));
445
        String state = trimToEmpty(body.state);
472
        String state = latin1Safe(trimToEmpty(body.state));
446
        String beforeLocation = locationOf(lead.getAddress(), lead.getCity(), lead.getState());
473
        String beforeLocation = locationOf(lead.getAddress(), lead.getCity(), lead.getState());
447
        String afterLocation = locationOf(address, city, state);
474
        String afterLocation = locationOf(address, city, state);
448
        if (!beforeLocation.equals(afterLocation)) {
475
        if (!beforeLocation.equals(afterLocation)) {
449
            lead.setAddress(address);
476
            lead.setAddress(address);
450
            lead.setCity(city);
477
            lead.setCity(city);
Line 482... Line 509...
482
 
509
 
483
            StringBuilder move = new StringBuilder(fieldChange("Region", beforeRegion, a.regionCode));
510
            StringBuilder move = new StringBuilder(fieldChange("Region", beforeRegion, a.regionCode));
484
            if (a.bm != null) {
511
            if (a.bm != null) {
485
                move.append(" · owner re-resolved to ").append(a.bm.getFullName());
512
                move.append(" · owner re-resolved to ").append(a.bm.getFullName());
486
                if (!stillAutoAssigned) {
513
                if (!stillAutoAssigned) {
487
                    move.append(" (working owner left unchanged — the lead is mapped to a named user)");
514
                    move.append(" (working owner left unchanged - the lead is mapped to a named user)");
488
                }
515
                }
489
            } else {
516
            } else {
490
                move.append(" · no active BM/RSM for that region, moved to the HOLD queue");
517
                move.append(" · no active BM/RSM for that region, moved to the HOLD queue");
491
            }
518
            }
492
            entries.add(move.toString());
519
            entries.add(move.toString());
Line 497... Line 524...
497
        }
524
        }
498
 
525
 
499
        lead.setUpdatedTimestamp(LocalDateTime.now());
526
        lead.setUpdatedTimestamp(LocalDateTime.now());
500
        leadRepository.persist(lead);
527
        leadRepository.persist(lead);
501
        for (String entry : entries) {
528
        for (String entry : entries) {
502
            trail(lead.getId(), me.getId(), entry);
529
            trail(lead.getId(), me.getId(), latin1Safe(entry));
503
        }
530
        }
504
 
531
 
505
        LOGGER.info("LMS lead {} edited by auth {} — {}", lead.getId(), me.getId(), entries);
532
        LOGGER.info("LMS lead {} edited by auth {} — {}", lead.getId(), me.getId(), entries);
506
        Map<String, Object> out = new LinkedHashMap<>();
533
        Map<String, Object> out = new LinkedHashMap<>();
507
        out.put("ok", true);
534
        out.put("ok", true);
Line 521... Line 548...
521
        public String state;
548
        public String state;
522
        public Integer regionId;
549
        public Integer regionId;
523
        public Double potential;
550
        public Double potential;
524
    }
551
    }
525
 
552
 
-
 
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
526
    /** Trail line for one edited field. Blank before/after read as an em dash, not as nothing. */
558
     * failure surfaces at flush rather than at the write, it arrives as an unrelated-looking
-
 
559
     * "null id in LeadActivity entry" 500.
-
 
560
     */
527
    private String fieldChange(String label, String before, String after) {
561
    private String fieldChange(String label, String before, String after) {
528
        return label + " " + orDash(before) + " → " + orDash(after);
562
        return label + " " + orDash(before) + " -> " + orDash(after);
529
    }
563
    }
530
 
564
 
531
    private String orDash(String s) {
565
    private String orDash(String s) {
532
        return (s == null || s.trim().isEmpty()) ? "—" : s.trim();
566
        return (s == null || s.trim().isEmpty()) ? "(not set)" : s.trim();
-
 
567
    }
-
 
568
 
-
 
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();
533
    }
621
    }
534
 
622
 
535
    private boolean same(String a, String b) {
623
    private boolean same(String a, String b) {
536
        return orDash(a).equals(orDash(b));
624
        return orDash(a).equals(orDash(b));
537
    }
625
    }
Line 559... Line 647...
559
        }
647
        }
560
        return sb.toString();
648
        return sb.toString();
561
    }
649
    }
562
 
650
 
563
    private String money(double value) {
651
    private String money(double value) {
-
 
652
        String label = lakhLabel(value);
564
        return "₹" + String.format("%.0f", value) + "/mo";
653
        return label == null ? "(not set)" : label + "/mo";
565
    }
654
    }
566
 
655
 
567
    /**
656
    /**
568
     * Region &rarr; owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead
657
     * Region &rarr; owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead
569
     * assignment preview and the dashboard's routing table; {@code HOLD} means the region has no
658
     * assignment preview and the dashboard's routing table; {@code HOLD} means the region has no
Line 684... Line 773...
684
                sb.append(", retry ").append(body.retrySchedule.trim());
773
                sb.append(", retry ").append(body.retrySchedule.trim());
685
            }
774
            }
686
        }
775
        }
687
        LeadStage after = lead.getEffectiveStage();
776
        LeadStage after = lead.getEffectiveStage();
688
        if (after != before) {
777
        if (after != before) {
689
            sb.append(" · stage ").append(pretty(before)).append(" → ").append(pretty(after));
778
            sb.append(" · stage ").append(pretty(before)).append(" -> ").append(pretty(after));
690
        }
779
        }
691
        if (body.note != null && !body.note.trim().isEmpty()) {
780
        if (body.note != null && !body.note.trim().isEmpty()) {
692
            sb.append(" — ").append(body.note.trim());
781
            sb.append(" - ").append(body.note.trim());
693
        }
782
        }
694
        return sb.toString();
783
        return sb.toString();
695
    }
784
    }
696
 
785
 
697
    /** What the caller needs to refresh its row without re-reading the whole record. */
786
    /** What the caller needs to refresh its row without re-reading the whole record. */
Line 746... Line 835...
746
    private boolean isTerminal(LeadStage stage) {
835
    private boolean isTerminal(LeadStage stage) {
747
        return stage == LeadStage.NOT_INTERESTED || stage == LeadStage.DROPPED;
836
        return stage == LeadStage.NOT_INTERESTED || stage == LeadStage.DROPPED;
748
    }
837
    }
749
 
838
 
750
    private String pretty(LeadStage stage) {
839
    private String pretty(LeadStage stage) {
751
        return stage == null ? "—" : pretty(stage.name());
840
        return stage == null ? "-" : pretty(stage.name());
752
    }
841
    }
753
 
842
 
754
    private String pretty(String enumName) {
843
    private String pretty(String enumName) {
755
        return enumName.replace('_', ' ');
844
        return enumName.replace('_', ' ');
756
    }
845
    }
Line 817... Line 906...
817
    @ResponseBody
906
    @ResponseBody
818
    public ResponseEntity<?> createLead(HttpServletRequest request, @RequestBody CreateLeadRequest body) {
907
    public ResponseEntity<?> createLead(HttpServletRequest request, @RequestBody CreateLeadRequest body) {
819
        if (body == null) {
908
        if (body == null) {
820
            return responseSender.badRequest("Nothing to create");
909
            return responseSender.badRequest("Nothing to create");
821
        }
910
        }
822
        String name = body.firstName == null ? "" : body.firstName.trim();
911
        String name = latin1Safe(body.firstName == null ? "" : body.firstName.trim());
823
        if (name.isEmpty()) {
912
        if (name.isEmpty()) {
824
            return responseSender.badRequest("Retailer name is required");
913
            return responseSender.badRequest("Retailer name is required");
825
        }
914
        }
826
        String mobile = body.mobile == null ? "" : body.mobile.replaceAll("\\D", "");
915
        String mobile = body.mobile == null ? "" : body.mobile.replaceAll("\\D", "");
827
        if (mobile.length() < 10) {
916
        if (mobile.length() < 10) {
Line 857... Line 946...
857
        // address / city / state are NOT NULL but are no longer asked for: the geo link supplies the
946
        // address / city / state are NOT NULL but are no longer asked for: the geo link supplies the
858
        // real location. Coalesced rather than left to fail the insert.
947
        // real location. Coalesced rather than left to fail the insert.
859
        lead.setAddress("");
948
        lead.setAddress("");
860
        lead.setCity("");
949
        lead.setCity("");
861
        lead.setState("");
950
        lead.setState("");
862
        lead.setSource(body.source == null || body.source.trim().isEmpty() ? "LMS Dashboard" : body.source.trim());
951
        lead.setSource(latin1Safe(body.source == null || body.source.trim().isEmpty()
-
 
952
                ? "LMS Dashboard" : body.source.trim()));
863
        lead.setCreatedTimestamp(LocalDateTime.now());
953
        lead.setCreatedTimestamp(LocalDateTime.now());
864
        lead.setUpdatedTimestamp(LocalDateTime.now());
954
        lead.setUpdatedTimestamp(LocalDateTime.now());
865
        lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
955
        lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
866
        lead.setAuthId(me.getId());
956
        lead.setAuthId(me.getId());
867
        // The record screen reads the business name off user.lead.outlet_name. Until now it was only
957
        // The record screen reads the business name off user.lead.outlet_name. Until now it was only
868
        // written to lead_detail below — a call that throws for every lead created here, because the
958
        // written to lead_detail below — a call that throws for every lead created here, because the
869
        // legacy detail path demands store photos this form deliberately does not collect — so the
959
        // legacy detail path demands store photos this form deliberately does not collect — so the
870
        // name the agent typed was silently dropped and the Business row rendered blank.
960
        // name the agent typed was silently dropped and the Business row rendered blank.
871
        lead.setOutLetName(body.outletName == null ? "" : body.outletName.trim());
961
        lead.setOutLetName(latin1Safe(body.outletName == null ? "" : body.outletName.trim()));
872
        lead.setRegionId(body.regionId);
962
        lead.setRegionId(body.regionId);
873
        if (body.potential != null && body.potential > 0) {
963
        if (body.potential != null && body.potential > 0) {
874
            lead.setPotential(body.potential);
964
            lead.setPotential(body.potential);
875
        }
965
        }
876
        // Path B is a field encounter, so the creator owns it; Path A goes to the region's BM.
966
        // Path B is a field encounter, so the creator owns it; Path A goes to the region's BM.
Line 928... Line 1018...
928
                }
1018
                }
929
                created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
1019
                created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
930
                first = false;
1020
                first = false;
931
            }
1021
            }
932
        }
1022
        }
933
        trail(lead.getId(), me.getId(), created.toString()
1023
        trail(lead.getId(), me.getId(), latin1Safe(created.toString()
934
                + (assignment != null && "HOLD".equals(assignment.assignmentStatus)
1024
                + (assignment != null && "HOLD".equals(assignment.assignmentStatus)
935
                   ? " — region has no active BM/RSM, parked in the HOLD queue" : ""));
1025
                   ? " - region has no active BM/RSM, parked in the HOLD queue" : "")));
936
 
1026
 
937
        // Auto-generate the geo capture link so the agent can send it during the same call.
1027
        // Auto-generate the geo capture link so the agent can send it during the same call.
938
        String geoLink = buildGeoCaptureLink(lead.getId());
1028
        String geoLink = buildGeoCaptureLink(lead.getId());
939
        if (geoLink != null) {
1029
        if (geoLink != null) {
940
            trail(lead.getId(), me.getId(), "Geolocation link generated for lead");
1030
            trail(lead.getId(), me.getId(), "Geolocation link generated for lead");
Line 951... Line 1041...
951
        LOGGER.info("LMS lead {} created ({}) by auth {} — region {} status {}",
1041
        LOGGER.info("LMS lead {} created ({}) by auth {} — region {} status {}",
952
                lead.getId(), lead.getLmsCode(), me.getId(), lead.getRegionCode(), lead.getAssignmentStatus());
1042
                lead.getId(), lead.getLmsCode(), me.getId(), lead.getRegionCode(), lead.getAssignmentStatus());
953
        return responseSender.ok(out);
1043
        return responseSender.ok(out);
954
    }
1044
    }
955
 
1045
 
-
 
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
956
    /** Append a trail entry. Never fatal — a lost note must not fail the lead. */
1052
     * is read as rupees and brought onto the lakh scale. Read-side only: no stored value is
957
    private void trail(int leadId, int authId, String remark) {
1053
     * rewritten, so a row keeps whatever it holds until someone edits it.
958
        try {
1054
     */
-
 
1055
    private static final double LAKH_CUTOFF = 1000d;
-
 
1056
 
-
 
1057
    private double toLakh(double v) {
959
            LeadActivity activity = new LeadActivity();
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) {
960
            activity.setLeadId(leadId);
1063
        if (potential <= 0) {
961
            activity.setAuthId(authId);
1064
            return null;
-
 
1065
        }
962
            activity.setRemark(remark);
1066
        double l = toLakh(potential);
963
            activity.setCreatedTimestamp(LocalDateTime.now());
1067
        String n = (l < 1) ? trimZeros(String.format("%.2f", l)) : trimZeros(String.format("%.1f", l));
-
 
1068
        return n + "L";
-
 
1069
    }
-
 
1070
 
-
 
1071
    /** The same number, unsuffixed, for the edit form — so saving cannot re-store rupees. */
964
            leadActivityRepository.persist(activity);
1072
    private String lakhInput(double potential) {
965
        } catch (Exception e) {
1073
        if (potential <= 0) {
966
            LOGGER.warn("Could not write the trail entry for lead {}", leadId, e);
1074
            return "";
967
        }
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
 
-
 
1084
    /**
-
 
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);
968
    }
1102
    }
969
 
1103
 
970
    /**
1104
    /**
971
     * {base}/lead-geo/{leadId} — the same URL the Leads screen's Generate Link button produces.
1105
     * {base}/lead-geo/{leadId} — the same URL the Leads screen's Generate Link button produces.
972
     * Null when the base URL is unconfigured, which must not stop a lead being created.
1106
     * Null when the base URL is unconfigured, which must not stop a lead being created.