| 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 → owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead
|
657 |
* Region → 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–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.
|