Rev 37692 | Blame | Compare with Previous | Last modification | View Log | RSS feed
package com.spice.profitmandi.web.controller;import com.spice.profitmandi.common.web.util.ResponseSender;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.user.Lead;import com.spice.profitmandi.dao.entity.user.LeadActivity;import com.spice.profitmandi.dao.entity.user.LeadCall;import com.spice.profitmandi.dao.entity.user.LeadDnd;import com.spice.profitmandi.dao.entity.user.LeadLiveLocation;import com.spice.profitmandi.dao.enumuration.dtr.CommunicationType;import com.spice.profitmandi.dao.enumuration.dtr.LeadDisposition;import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;import com.spice.profitmandi.dao.repository.auth.AuthRepository;import com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository;import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;import com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository;import com.spice.profitmandi.dao.entity.cs.Position;import com.spice.profitmandi.dao.entity.cs.Region;import com.spice.profitmandi.dao.enumuration.cs.EscalationType;import com.spice.profitmandi.dao.repository.cs.PositionRepository;import com.spice.profitmandi.dao.repository.cs.RegionRepository;import com.spice.profitmandi.dao.entity.user.LeadCallAnswer;import com.spice.profitmandi.dao.entity.user.LeadCallQuestion;import com.spice.profitmandi.dao.repository.dtr.LeadCallChecklistRepository;import com.spice.profitmandi.dao.repository.dtr.LeadRepository;import com.spice.profitmandi.service.LmsAssignmentService;import com.spice.profitmandi.web.model.LoginDetails;import com.spice.profitmandi.web.util.CookiesProcessor;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.http.MediaType;import org.springframework.http.ResponseEntity;import org.springframework.stereotype.Controller;import org.springframework.transaction.annotation.Transactional;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestMethod;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.ResponseBody;import javax.servlet.http.HttpServletRequest;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;import java.util.ArrayList;import java.util.Arrays;import java.util.HashMap;import java.util.HashSet;import java.util.List;import com.spice.profitmandi.dao.model.LeadBrandModel;import com.spice.profitmandi.dao.model.LeadDetailModel;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.HttpStatus;import java.util.LinkedHashMap;import java.util.Map;import java.util.Set;/*** LMS operating core — the per-lead screen and the two mutations it drives (SOP §8/§10/§12).** <ul>* <li><b>{@code GET /leadRecord}</b> — full-page lead record fragment ({@code lead-record.vm}),* loaded into {@code #main-content} and also opened from the standalone dashboard.</li>* <li><b>{@code POST /lms/advanceStage}</b> — forward-only stage move with a mandatory comment.</li>* <li><b>{@code POST /lms/disposition}</b> — call outcome; drives the stage transition, stamps* first contact (closing the SLA) and appends to the trail.</li>* <li><b>{@code GET /lms/resolve-region}</b> — region → owning BM/RSM preview, used by the* Create-Lead form and the dashboard's Auto-Assignment tab.</li>* </ul>** Every mutation appends an immutable {@link LeadActivity} row and keeps the legacy* {@code Lead.status} in sync via {@link LeadStage#toLegacyStatus()}, so the existing lead lists,* exports and beat flows keep working untouched. Read-side aggregation lives in* {@code LmsDashboardService}; assignment/SLA rules live in {@link LmsAssignmentService}.*/@Controller@Transactional(rollbackFor = Throwable.class)public class LmsLeadController {private static final Logger LOGGER = LogManager.getLogger(LmsLeadController.class);/*** How far back to look when binding a disposition to a call the browser did not name. Long* enough to cover a call plus the agent typing up the outcome; short enough that this morning's* call never gets attached to this afternoon's disposition.*/private static final int CALL_BIND_WINDOW_MINUTES = 30;/** Trail/record timestamp format, shared by every date the record page prints. */private static final DateTimeFormatter RECORD_FORMAT = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm");@Autowiredprivate LeadRepository leadRepository;@Autowiredprivate LeadActivityRepository leadActivityRepository;@Autowiredprivate LeadLiveLocationRepository leadLiveLocationRepository;@Autowiredprivate LeadCallRepository leadCallRepository;@Autowiredprivate LeadCallChecklistRepository leadCallChecklistRepository;@Autowiredprivate LeadDndRepository leadDndRepository;@Autowiredprivate AuthRepository authRepository;@Autowiredprivate RegionRepository regionRepository;@Autowiredprivate PositionRepository positionRepository;@Autowiredprivate LmsAssignmentService lmsAssignmentService;@Autowiredprivate CookiesProcessor cookiesProcessor;@Autowiredprivate ResponseSender<?> responseSender;// ---- Record page ------------------------------------------------------------------------/*** Full lead record: details, stepper, append-only trail, SLA card and the act-on-it controls.* Returned as a fragment — the caller drops it into {@code #main-content}.*/// Same property the Leads screen's Generate Link button uses, so both produce an identical URL.@Value("${lead.geo.public.base-url:}")private String leadGeoPublicBaseUrl;@RequestMapping(value = "/leadRecord", method = RequestMethod.GET)public String leadRecord(@RequestParam(name = "leadId") int leadId, Model model) {Lead lead = leadRepository.selectById(leadId);if (lead == null) {// Fragment contract: whatever comes back is dropped straight into #main-content.model.addAttribute("response1","<div class=\"alert alert-warning\">Lead #" + leadId + " not found.</div>");return "response";}LeadStage effectiveStage = lead.getEffectiveStage();model.addAttribute("lead", lead);model.addAttribute("effectiveStage", effectiveStage);model.addAttribute("stageIndex", happyPathIndex(effectiveStage));model.addAttribute("terminal", isTerminal(effectiveStage));model.addAttribute("stages", LeadStage.HAPPY_PATH);model.addAttribute("dispositions", LeadDisposition.values());model.addAttribute("slaState", lmsAssignmentService.slaState(lead));model.addAttribute("businessValue", lakhLabel(lead.getPotential()));model.addAttribute("businessValueInput", lakhInput(lead.getPotential()));model.addAttribute("dateTimeFormatter", RECORD_FORMAT);// Region picker in the edit modal — the same list the Create-Lead form offers.model.addAttribute("regions", regionRepository.selectAll());model.addAttribute("owner", lead.getAssignTo() > 0 ? authRepository.selectById(lead.getAssignTo()) : null);model.addAttribute("bm", (lead.getOwnerBmId() != null && lead.getOwnerBmId() > 0)? authRepository.selectById(lead.getOwnerBmId()) : null);LeadLiveLocation geo = leadLiveLocationRepository.selectByLeadId(leadId);model.addAttribute("geo", geo);// A blocked number must be visible before the agent reaches for the call button, not only// discovered when the dial is refused.model.addAttribute("dnd", leadDndRepository.selectByMobile(lead.getLeadMobile()));List<LeadCall> calls = leadCallRepository.selectByLeadId(leadId);model.addAttribute("calls", calls);// Keyed by id so a disposition in the trail can show the recording of the call it came from.// A disposition is the agent's account of the conversation; having to hunt for the audio in a// separate list to check it against the recording is the friction worth removing here.Map<Integer, LeadCall> callById = new HashMap<>();if (calls != null) {for (LeadCall c : calls) {callById.put(c.getId(), c);}}model.addAttribute("callById", callById);// Append-only trail, newest first, with actor names resolved in one batch.List<LeadActivity> trail = leadActivityRepository.selectBYLeadId(leadId);if (trail == null) {trail = new ArrayList<>();}trail.sort((a, b) -> {LocalDateTime ta = a.getCreatedTimestamp();LocalDateTime tb = b.getCreatedTimestamp();if (ta == null && tb == null) {return 0;}if (ta == null) {return 1;}if (tb == null) {return -1;}return tb.compareTo(ta);});model.addAttribute("trail", trail);model.addAttribute("authUserMap", actorsOf(trail));return "lead-record";}// ---- Mutations --------------------------------------------------------------------------/*** Move a lead forward along the happy path (or to a terminal state). Forward-only: the enum* decides what is legal, so a stale page cannot walk a lead backwards. Comment is mandatory —* it becomes the trail entry.*/@RequestMapping(value = "/lms/advanceStage", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> advanceStage(HttpServletRequest request,@RequestParam(name = "leadId") int leadId,@RequestParam(name = "toStage") String toStage,@RequestParam(name = "comment") String comment) {if (comment == null || comment.trim().isEmpty()) {return responseSender.badRequest("A comment is required to change the stage");}Lead lead = leadRepository.selectById(leadId);if (lead == null) {return responseSender.notFound("Lead not found");}LeadStage target;try {target = LeadStage.valueOf(toStage);} catch (IllegalArgumentException e) {return responseSender.badRequest("Unknown stage: " + toStage);}LeadStage current = lead.getEffectiveStage();if (isTerminal(current)) {return responseSender.badRequest(pretty(current) + " is a terminal state — the lead cannot be moved on");}if (!current.canAdvanceTo(target)) {return responseSender.badRequest("Cannot move from " + pretty(current) + " to " + pretty(target));}AuthUser actor = currentUser(request);applyStage(lead, target);leadRepository.persist(lead);appendTrail(leadId, actor,latin1Safe("Stage " + pretty(current) + " -> " + pretty(target) + ": " + comment.trim()),null, null);LOGGER.info("LMS stage advanced: lead {} {} -> {} by {}", leadId, current, target,actor != null ? actor.getId() : 0);return responseSender.ok(stateOf(lead));}/*** Record a call outcome (SOP §12.2). The disposition drives the stage transition; the first* dispositioned contact stamps {@code firstContactedAt}, which is what closes the 5-hr SLA.*/@RequestMapping(value = "/lms/disposition", method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_VALUE)@ResponseBodypublic ResponseEntity<?> disposition(HttpServletRequest request,@RequestBody DispositionRequest body) {if (body == null || body.disposition == null || body.disposition.trim().isEmpty()) {return responseSender.badRequest("A disposition is required");}Lead lead = leadRepository.selectById(body.leadId);if (lead == null) {return responseSender.notFound("Lead not found");}LeadDisposition disposition;try {disposition = LeadDisposition.valueOf(body.disposition);} catch (IllegalArgumentException e) {return responseSender.badRequest("Unknown disposition: " + body.disposition);}if (disposition == LeadDisposition.INTERESTED && (body.value == null || body.value <= 0)) {return responseSender.badRequest("Business value is required on an INTERESTED disposition");}AuthUser actor = currentUser(request);LeadStage before = lead.getEffectiveStage();LeadCall call = resolveCall(body, lead, actor);String subReason = latin1Safe(trimToNull(body.subReason));if (subReason != null && subReason.length() > 64) {return responseSender.badRequest("Sub-reason is too long - keep it to 64 characters");}lead.setDisposition(disposition);lead.setDispositionSubReason(subReason);// Interim manual path — drops out when the dialer starts filling recordings from the webhook.if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {lead.setRecordingUrl(body.recordingUrl.trim());}// Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody// answered) and WRONG_NUMBER (not the retailer) are not contact: they must not satisfy the SLA,// and they must not disturb a stamp an earlier real contact already set.if (lead.getFirstContactedAt() == null && countsAsContact(disposition)) {// Prefer the moment the retailer actually picked up over "now" — the agent may sit on the// disposition modal for minutes, and that gap would silently eat into the 5-hr SLA.LocalDateTime contactedAt = (call != null && call.getAnsweredAt() != null)? call.getAnsweredAt() : LocalDateTime.now();lead.setFirstContactedAt(contactedAt);}LocalDateTime scheduled = parseLocal(body.callbackAt != null ? body.callbackAt : body.followUpAt);applyDispositionStage(lead, disposition, body);leadRepository.persist(lead);CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)? CommunicationType.VISIT : CommunicationType.TELEPHONIC;LeadActivity activity = appendTrail(body.leadId, actor,latin1Safe(dispositionRemark(disposition, before, lead, body)), type, scheduled,call != null ? call.getId() : null);// Bind both ways: the trail entry knows its call (for duration + a recording link), and the// call knows the disposition it produced (so an unactioned call is findable).if (call != null) {call.setLeadActivityId(activity.getId());leadCallRepository.persist(call);}if (disposition == LeadDisposition.DO_NOT_CALL) {blockFurtherCalls(lead, actor, body);}LOGGER.info("LMS disposition {} on lead {} ({} -> {}) by {}, call {}", disposition, body.leadId, before,lead.getEffectiveStage(), actor != null ? actor.getId() : 0, call != null ? call.getId() : null);return responseSender.ok(stateOf(lead));}/*** Which call this disposition is about. The browser normally passes {@code leadCallId} straight* from the dialer; when it cannot (agent dialled from their own handset, or the page reloaded* mid-call) we fall back to this agent's most recent call on this lead that no disposition has* claimed yet. Deliberately narrow — binding the wrong call is worse than binding none, because* it would attach someone else's recording to this lead's trail.*/private LeadCall resolveCall(DispositionRequest body, Lead lead, AuthUser actor) {if (body.leadCallId != null && body.leadCallId > 0) {LeadCall call = leadCallRepository.selectById(body.leadCallId);if (call != null && call.getLeadId() == lead.getId()) {return call;}LOGGER.warn("Ignoring leadCallId {} on lead {} — missing or belongs to another lead",body.leadCallId, lead.getId());return null;}if (actor == null) {return null;}LeadCall latest = leadCallRepository.selectLatestByLeadIdAndAuthId(lead.getId(), actor.getId());if (latest == null || latest.getLeadActivityId() != null) {return null;}LocalDateTime startedAt = latest.getStartedAt() != null ? latest.getStartedAt() : latest.getCreatedTimestamp();if (startedAt == null || startedAt.isBefore(LocalDateTime.now().minusMinutes(CALL_BIND_WINDOW_MINUTES))) {return null;}return latest;}/*** DO_NOT_CALL means never ring this retailer again (SOP §17). Blocks the number, not the lead —* the same person recurs as several leads and a per-lead block would not hold. Before this the* disposition only moved the stage, and nothing stopped a re-dial.*/private void blockFurtherCalls(Lead lead, AuthUser actor, DispositionRequest body) {LeadDnd dnd = new LeadDnd();dnd.setMobile(lead.getLeadMobile());dnd.setLeadId(lead.getId());dnd.setAuthId(actor != null ? actor.getId() : null);dnd.setSource("DISPOSITION");dnd.setReason(latin1Safe(trimToNull(body.note) != null? trimToNull(body.note) : "Retailer asked not to be contacted"));leadDndRepository.block(dnd);LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);}/*** Correct the record's own fields (SOP §6).** <p>Everything else on this screen either moves the lead forward or logs a conversation;* nothing could fix a misheard shop name or a location that was never captured. Between the* create form (which writes {@code address}/{@code city}/{@code state} empty and leaves the* geo link to supply the real location) and the legacy Leads edit (which only touches status,* assignee, city and state, and is gated behind an approved geo-pin), a lead's business name,* address and region were unreachable once created.** <p>What stays immutable is what the SOP makes immutable (§7.1): the LMS id, the created* stamp, the creation path, the stage and the SLA clock. Region is editable but is not a plain* field edit — it re-resolves the owning BM/RSM, so it is handled as a re-assignment below.** <p>Every accepted change writes its own trail entry. One entry per field rather than one per* save, so "who changed this number, and when" is answerable without diffing two saves.*/@RequestMapping(value = "/lms/updateLead", method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_VALUE)@ResponseBodypublic ResponseEntity<?> updateLead(HttpServletRequest request, @RequestBody UpdateLeadRequest body) {if (body == null) {return responseSender.badRequest("Nothing to update");}Lead lead = leadRepository.selectById(body.leadId);if (lead == null) {return responseSender.notFound("Lead not found");}AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}String name = latin1Safe(trimToNull(body.retailerName));if (name == null) {return responseSender.badRequest("Retailer name is required");}// These columns are narrow (city and state are varchar(30)) and the server runs// STRICT_TRANS_TABLES, so an over-long value is a 500 at flush rather than a rejected field.// Check here and name the field, so the agent can shorten it instead of losing the edit.String tooLong = firstTooLong("Retailer name", name, 128,"Business name", body.businessName, 100,"Address", body.address, 128,"City", body.city, 30,"State", body.state, 30);if (tooLong != null) {return responseSender.badRequest(tooLong);}String mobile = digitsOnly(body.mobile);if (mobile == null || mobile.length() < 10) {return responseSender.badRequest("A valid 10-digit contact number is required");}mobile = mobile.substring(mobile.length() - 10);// A number change is the one edit that can quietly undo a compliance decision, so it gets// the same two checks the create form runs rather than being trusted as a typo fix.boolean mobileChanged = !mobile.equals(lead.getLeadMobile());if (mobileChanged) {if (leadDndRepository.selectByMobile(mobile) != null) {return responseSender.badRequest("That number is on the do-not-call register.");}Lead other = leadRepository.selectByMobileNumber(mobile);if (other != null && other.getId() != lead.getId()) {return responseSender.badRequest("Lead #" + other.getId() + " already exists for " + mobile);}}List<String> entries = new ArrayList<>();String beforeName = joinName(lead.getFirstName(), lead.getLastName());if (!name.equals(beforeName)) {int space = name.lastIndexOf(' ');// Both halves are varchar(64); a long single-token name would otherwise fail at flush.if ((space > 0 ? name.substring(0, space) : name).length() > 64|| (space > 0 ? name.substring(space + 1) : "").length() > 64) {return responseSender.badRequest("Retailer name is too long - keep each part to 64 characters");}lead.setFirstName(space > 0 ? name.substring(0, space) : name);// user.lead.last_name is NOT NULL — a single-word name keeps an empty surname, not null.lead.setLastName(space > 0 ? name.substring(space + 1) : "");entries.add(fieldChange("Retailer", beforeName, name));}if (mobileChanged) {entries.add(fieldChange("Contact", lead.getLeadMobile(), mobile));lead.setLeadMobile(mobile);}String outlet = latin1Safe(trimToNull(body.businessName));if (!same(lead.getOutLetName(), outlet)) {entries.add(fieldChange("Business", lead.getOutLetName(), outlet));lead.setOutLetName(outlet == null ? "" : outlet);}// address / city / state are NOT NULL — blanked fields are written empty, never null.String address = latin1Safe(trimToEmpty(body.address));String city = latin1Safe(trimToEmpty(body.city));String state = latin1Safe(trimToEmpty(body.state));String beforeLocation = locationOf(lead.getAddress(), lead.getCity(), lead.getState());String afterLocation = locationOf(address, city, state);if (!beforeLocation.equals(afterLocation)) {lead.setAddress(address);lead.setCity(city);lead.setState(state);entries.add(fieldChange("Location", beforeLocation, afterLocation));}if (body.potential != null && body.potential >= 0 && body.potential != lead.getPotential()) {entries.add(fieldChange("Business value",lead.getPotential() > 0 ? money(lead.getPotential()) : null, money(body.potential)));lead.setPotential(body.potential);}// Region last: it is a re-assignment, and it reads better in the trail after the field edits.if (body.regionId != null && body.regionId > 0 && !body.regionId.equals(lead.getRegionId())) {LmsAssignmentService.Assignment a = lmsAssignmentService.resolve(body.regionId);if (a.regionId == null) {return responseSender.badRequest("Unknown region");}Integer previousBm = lead.getOwnerBmId();String beforeRegion = lead.getRegionCode();lead.setRegionId(a.regionId);lead.setRegionCode(a.regionCode);lead.setAssignmentStatus(a.assignmentStatus);lead.setOwnerBmId(a.bm != null ? a.bm.getId() : null);// Follow the region only while the lead is still sitting with whoever the engine picked.// Once a BM has handed it to a named ASM, that mapping is a decision — moving the region// must not silently undo it.boolean stillAutoAssigned = lead.getAssignTo() <= 0|| (previousBm != null && lead.getAssignTo() == previousBm.intValue());if (stillAutoAssigned) {lead.setAssignTo(a.bm != null ? a.bm.getId() : 0);}StringBuilder move = new StringBuilder(fieldChange("Region", beforeRegion, a.regionCode));if (a.bm != null) {move.append(" · owner re-resolved to ").append(a.bm.getFullName());if (!stillAutoAssigned) {move.append(" (working owner left unchanged - the lead is mapped to a named user)");}} else {move.append(" · no active BM/RSM for that region, moved to the HOLD queue");}entries.add(move.toString());}if (entries.isEmpty()) {return responseSender.badRequest("Nothing changed");}lead.setUpdatedTimestamp(LocalDateTime.now());leadRepository.persist(lead);for (String entry : entries) {trail(lead.getId(), me.getId(), latin1Safe(entry));}LOGGER.info("LMS lead {} edited by auth {} — {}", lead.getId(), me.getId(), entries);Map<String, Object> out = new LinkedHashMap<>();out.put("ok", true);out.put("leadId", lead.getId());out.put("changes", entries.size());return responseSender.ok(out);}/** Payload for {@link #updateLead}. Only the fields this screen is allowed to correct. */public static class UpdateLeadRequest {public int leadId;public String retailerName;public String businessName;public String mobile;public String address;public String city;public String state;public Integer regionId;public Double potential;}/*** Trail line for one edited field.** <p>ASCII only, deliberately. {@code user.lead_activity.remark} is latin1_swedish_ci, so an* arrow or an em dash makes the INSERT fail with "Incorrect string value" — and because the* failure surfaces at flush rather than at the write, it arrives as an unrelated-looking* "null id in LeadActivity entry" 500.*/private String fieldChange(String label, String before, String after) {return label + " " + orDash(before) + " -> " + orDash(after);}private String orDash(String s) {return (s == null || s.trim().isEmpty()) ? "(not set)" : s.trim();}/*** First over-long field, as a message naming it and its limit; null when all fit.** <p>Args come in (label, value, max) triples. Retailer name is checked against 128 because it* is split across first_name and last_name, which are varchar(64) each.*/private String firstTooLong(Object... fields) {for (int i = 0; i + 2 < fields.length; i += 3) {String label = (String) fields[i];String value = (String) fields[i + 1];int max = (Integer) fields[i + 2];if (value != null && value.trim().length() > max) {return label + " is too long - keep it to " + max + " characters (currently "+ value.trim().length() + ")";}}return null;}/*** Make caller-supplied text safe for the latin1 columns this controller writes.** <p>Every text column here — {@code lead_activity.remark}, {@code lead.disposition_sub_reason},* the address fields — is latin1_swedish_ci. Fixing our own literals was only half the job: an* agent pastes a note from WhatsApp or Word and it arrives full of smart quotes, en dashes and* ellipses, none of which exist in latin1. That INSERT fails, and because the failure surfaces* at flush it reads as "null id in LeadActivity entry" rather than as a bad character.** <p>The common typographic characters are transliterated rather than dropped, because losing an* apostrophe out of "retailer's shop" is silent damage. Anything else outside latin1 (emoji,* Devanagari) becomes '?' — visible, so it is obvious the text was not stored verbatim.*/private String latin1Safe(String s) {if (s == null || s.isEmpty()) {return s;}StringBuilder out = new StringBuilder(s.length());for (int i = 0; i < s.length(); i++) {char c = s.charAt(i);switch (c) {case '\u2018': case '\u2019': case '\u201B': out.append('\''); break; // ' ' ‛case '\u201C': case '\u201D': case '\u201E': out.append('"'); break; // " " „case '\u2013': case '\u2014': case '\u2212': out.append('-'); break; // – — −case '\u2026': out.append("..."); break; // …case '\u2192': out.append("->"); break; // →case '\u20B9': out.append("Rs"); break; // ₹case '\u00A0': out.append(' '); break; // nbspdefault:out.append(c <= 0xFF ? c : '?');}}return out.toString();}private boolean same(String a, String b) {return orDash(a).equals(orDash(b));}private String trimToEmpty(String s) {return s == null ? "" : s.trim();}private String joinName(String first, String last) {String joined = ((first == null ? "" : first) + " " + (last == null ? "" : last)).trim();return joined.replaceAll("\\s+", " ");}/** "address, city, state" with the empty parts dropped — what the record row prints. */private String locationOf(String address, String city, String state) {StringBuilder sb = new StringBuilder();for (String part : new String[]{address, city, state}) {if (part == null || part.trim().isEmpty()) {continue;}if (sb.length() > 0) {sb.append(", ");}sb.append(part.trim());}return sb.toString();}private String money(double value) {String label = lakhLabel(value);return label == null ? "(not set)" : label + "/mo";}/*** First-call checklist for a lead (SOP §9 Path A step 2).** <p>Returns the active question set, any answers already recorded, and — the part the caller* cannot work out for itself — whether this is actually the lead's FIRST connected call.** <p>"First" means no earlier <em>answered</em> call. A string of unanswered dials does not make* the next one a second call: as far as the retailer is concerned nobody has spoken to them yet,* and that is exactly when the checklist is worth asking.*/@RequestMapping(value = "/lms/checklist", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> checklist(@RequestParam(name = "leadId") int leadId,@RequestParam(name = "leadCallId", required = false) Integer leadCallId) {Lead lead = leadRepository.selectById(leadId);if (lead == null) {return responseSender.notFound("Lead not found");}// Only the questions that belong at the lead's CURRENT stage. A question tagged// NEW|ASSIGNED is a first-call question by construction; one tagged VISITED is not, and// showing the whole bank on every call is how a checklist becomes something people skip.String stage = lead.getEffectiveStage() == null ? null : lead.getEffectiveStage().name();List<LeadCallAnswer> answers = leadCallChecklistRepository.selectAnswersByLeadId(leadId);Map<Integer, LeadCallAnswer> byQuestion = new HashMap<>();for (LeadCallAnswer a : answers) {byQuestion.put(a.getQuestionId(), a);}List<Map<String, Object>> out = new ArrayList<>();int availablePoints = 0;for (LeadCallQuestion q : leadCallChecklistRepository.selectActiveQuestions()) {if (!q.appliesTo(stage)) {continue;}LeadCallAnswer a = byQuestion.get(q.getId());Map<String, Object> m = new LinkedHashMap<>();m.put("id", q.getId());m.put("prompt", q.getPrompt());m.put("answerType", q.getAnswerType());m.put("choices", q.getChoices() == null || q.getChoices().trim().isEmpty()? new ArrayList<String>() : Arrays.asList(q.getChoices().split("\\|")));m.put("required", q.isRequired());m.put("points", q.getPoints());m.put("answer", a == null ? null : a.getAnswer());out.add(m);availablePoints += q.getPoints();}// Score is over EVERY answer the lead has, not just the ones showing now — a lead that// answered the early questions keeps those points when it moves on to a later stage.int score = 0;for (LeadCallAnswer a : answers) {score += a.getPointsAwarded();}Map<String, Object> res = new LinkedHashMap<>();res.put("leadId", leadId);res.put("stage", stage);res.put("questions", out);res.put("answered", !answers.isEmpty());res.put("score", score);res.put("availablePoints", availablePoints);res.put("firstCall", !leadCallChecklistRepository.hasEarlierConnectedCall(leadId, leadCallId));// Worth showing on the panel: the checklist is about this retailer, not a generic form.res.put("retailer", joinName(lead.getFirstName(), lead.getLastName()));return responseSender.ok(res);}/** Record (or correct) the checklist answers for a lead. */@RequestMapping(value = "/lms/checklist", method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_VALUE)@ResponseBodypublic ResponseEntity<?> saveChecklist(HttpServletRequest request, @RequestBody ChecklistRequest body) {if (body == null || body.leadId <= 0) {return responseSender.badRequest("Nothing to save");}Lead lead = leadRepository.selectById(body.leadId);if (lead == null) {return responseSender.notFound("Lead not found");}AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}Map<Integer, LeadCallQuestion> active = new HashMap<>();for (LeadCallQuestion q : leadCallChecklistRepository.selectActiveQuestions()) {active.put(q.getId(), q);}int saved = 0;int points = 0;List<String> summary = new ArrayList<>();if (body.answers != null) {for (ChecklistAnswer a : body.answers) {LeadCallQuestion q = active.get(a.questionId);// Ignore anything that is not a live question rather than storing an answer to// something the screen never asked.if (q == null) {continue;}String value = trimToNull(a.answer);if (value == null) {continue;}if (value.length() > 1000) {value = value.substring(0, 1000);}LeadCallAnswer row = new LeadCallAnswer();row.setLeadId(body.leadId);row.setLeadCallId(body.leadCallId);row.setQuestionId(q.getId());row.setAuthId(me.getId());row.setAnswer(value);// Snapshot the points as priced today, so re-pricing the question later does not// retroactively change what this lead scored.row.setPointsAwarded(q.getPoints());leadCallChecklistRepository.saveAnswer(row);saved++;points += q.getPoints();summary.add(q.getPrompt() + ": " + value);}}if (saved > 0) {// The trail is where the lead's story lives; a checklist that only existed in its own// table would be invisible to anyone reading the record.trail(body.leadId, me.getId(), latin1Safe("Checklist recorded (" + saved + " answer"+ (saved == 1 ? "" : "s") + ", " + points + " pts) - " + String.join(" \u00b7 ", summary)));}Map<String, Object> res = new LinkedHashMap<>();res.put("ok", true);res.put("saved", saved);res.put("points", points);LOGGER.info("Checklist saved for lead {} ({} answers) by auth {}", body.leadId, saved, me.getId());return responseSender.ok(res);}// ---- Assignment -------------------------------------------------------------------------/** Sales category in {@code cs.position} — the same one auto-assignment resolves owners from. */private static final int SALES_CATEGORY = com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES;/** Tiers a lead can be assigned to. L6+ exist but are not lead owners. */private static final List<EscalationType> SALES_TIERS = Arrays.asList(EscalationType.L1, EscalationType.L2, EscalationType.L3, EscalationType.L4, EscalationType.L5);/*** Sales people a lead can be assigned to, grouped by escalation tier (L1–L5).** <p>Read from {@code cs.position}, the same source auto-assignment resolves owners from, so the* manual list and the engine can never disagree about who is a sales owner.** <p>{@code regionId} does not filter the list — it marks who covers that region, so an agent can* see the right owner first without being prevented from picking someone else. A lead in a* region with no active owner still has to go to somebody.*/@RequestMapping(value = "/lms/salespeople", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> salespeople(@RequestParam(name = "regionId", required = false) Integer regionId) {List<Position> positions = positionRepository.selectPositionByCategoryId(SALES_CATEGORY);if (positions == null) {positions = new ArrayList<>();}Set<Integer> ids = new HashSet<>();for (Position p : positions) {if (SALES_TIERS.contains(p.getEscalationType())) {ids.add(p.getAuthUserId());}}Map<Integer, AuthUser> users = new HashMap<>();if (!ids.isEmpty()) {for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {users.put(u.getId(), u);}}// One row per person per tier: somebody can hold an L4 in one region and an L5 in another,// and collapsing that would hide which hat they are being picked under.Map<String, Map<String, Object>> byKey = new LinkedHashMap<>();for (Position p : positions) {if (!SALES_TIERS.contains(p.getEscalationType())) {continue;}AuthUser u = users.get(p.getAuthUserId());if (u == null || !Boolean.TRUE.equals(u.isActive())) {continue; // never offer a departed colleague as an owner}String key = p.getAuthUserId() + ":" + p.getEscalationType().name();Map<String, Object> m = byKey.get(key);if (m == null) {m = new LinkedHashMap<>();m.put("authId", u.getId());m.put("name", u.getFullName());m.put("email", u.getEmailId());m.put("tier", p.getEscalationType().name());m.put("regions", new ArrayList<String>());m.put("coversRegion", false);byKey.put(key, m);}Region r = p.getRegionId() > 0 ? regionRepository.selectById(p.getRegionId()) : null;if (r != null) {@SuppressWarnings("unchecked")List<String> regions = (List<String>) m.get("regions");String code = r.getRegionCode() != null ? r.getRegionCode() : r.getName();if (code != null && !regions.contains(code)) {regions.add(code);}if (regionId != null && regionId > 0 && r.getId() == regionId) {m.put("coversRegion", true);}}}Map<String, Object> res = new LinkedHashMap<>();res.put("people", new ArrayList<>(byKey.values()));List<String> tiers = new ArrayList<>();for (EscalationType t : SALES_TIERS) {tiers.add(t.name());}res.put("tiers", tiers);return responseSender.ok(res);}/*** Assign a lead to a sales person.** <p>Sets the working owner ({@code assign_to}) only. The auto-resolved {@code owner_bm_id} is* left alone: it records which BM the region routed to, and overwriting it on a manual hand-off* would erase the routing decision the SLA and the HOLD queue are measured against.*/@RequestMapping(value = "/lms/assignLead", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> assignLead(HttpServletRequest request,@RequestParam(name = "leadId") int leadId,@RequestParam(name = "authId") int authId) {Lead lead = leadRepository.selectById(leadId);if (lead == null) {return responseSender.notFound("Lead not found");}AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}AuthUser owner = authRepository.selectById(authId);if (owner == null || !Boolean.TRUE.equals(owner.isActive())) {return responseSender.badRequest("That person is not an active user");}// Must hold a sales position — otherwise any auth id could be made a lead owner, and the// pipeline would show owners the assignment engine has never heard of.EscalationType tier = null;List<Position> held = positionRepository.selectPositionByAuthId(authId);if (held != null) {for (Position p : held) {if (p.getCategoryId() == SALES_CATEGORY && SALES_TIERS.contains(p.getEscalationType())) {tier = p.getEscalationType();break;}}}if (tier == null) {return responseSender.badRequest(owner.getFullName() + " does not hold a sales position (L1-L5)");}AuthUser previous = lead.getAssignTo() > 0 ? authRepository.selectById(lead.getAssignTo()) : null;if (previous != null && previous.getId() == authId) {return responseSender.badRequest("Already assigned to " + owner.getFullName());}lead.setAssignTo(authId);// A lead sitting in HOLD because its region had no owner now has one.if ("HOLD".equalsIgnoreCase(lead.getAssignmentStatus())) {lead.setAssignmentStatus("ASSIGNED");}lead.setUpdatedTimestamp(LocalDateTime.now());leadRepository.persist(lead);trail(leadId, me.getId(), latin1Safe("Assigned to " + owner.getFullName() + " (" + tier.name() + ")"+ (previous != null ? " from " + previous.getFullName() : "")));LOGGER.info("Lead {} assigned to auth {} ({}) by auth {}", leadId, authId, tier, me.getId());Map<String, Object> res = new LinkedHashMap<>();res.put("ok", true);res.put("leadId", leadId);res.put("owner", owner.getFullName());res.put("tier", tier.name());return responseSender.ok(res);}// ---- Checklist question management (CRUD) -----------------------------------------------/** Every question, active and inactive, for the management screen. */@RequestMapping(value = "/lms/checklist/questions", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> listQuestions() {List<Map<String, Object>> out = new ArrayList<>();for (LeadCallQuestion q : leadCallChecklistRepository.selectAllQuestions()) {out.add(questionJson(q));}Map<String, Object> res = new LinkedHashMap<>();res.put("questions", out);// The screen builds its stage picker from this rather than hardcoding the ladder, so a new// LeadStage shows up without a front-end change.List<String> stages = new ArrayList<>();for (LeadStage st : LeadStage.HAPPY_PATH) {stages.add(st.name());}res.put("stages", stages);res.put("answerTypes", Arrays.asList("TEXT", "NUMBER", "YESNO", "CHOICE"));return responseSender.ok(res);}/** Create or update a question. An id of 0 creates. */@RequestMapping(value = "/lms/checklist/questions/save", method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_VALUE)@ResponseBodypublic ResponseEntity<?> saveQuestion(HttpServletRequest request, @RequestBody QuestionRequest body) {if (body == null) {return responseSender.badRequest("Nothing to save");}AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}String prompt = trimToNull(body.prompt);if (prompt == null) {return responseSender.badRequest("The question text is required");}if (prompt.length() > 500) {return responseSender.badRequest("Keep the question under 500 characters");}String type = trimToNull(body.answerType) == null ? "TEXT" : body.answerType.trim().toUpperCase();if (!Arrays.asList("TEXT", "NUMBER", "YESNO", "CHOICE").contains(type)) {return responseSender.badRequest("Unknown answer type: " + type);}String choices = trimToNull(body.choices);if ("CHOICE".equals(type) && choices == null) {return responseSender.badRequest("A choice question needs at least one option");}if (body.points != null && body.points < 0) {return responseSender.badRequest("Points cannot be negative");}// Reject unknown stage names rather than storing a filter that silently matches nothing.String stages = trimToNull(body.stages);if (stages != null) {for (String st : stages.split("\\|")) {if (st.trim().isEmpty()) {continue;}try {LeadStage.valueOf(st.trim().toUpperCase());} catch (IllegalArgumentException e) {return responseSender.badRequest("Unknown stage: " + st.trim());}}}LeadCallQuestion q;boolean creating = body.id <= 0;if (creating) {q = new LeadCallQuestion();} else {q = leadCallChecklistRepository.selectQuestionById(body.id);if (q == null) {return responseSender.notFound("Question not found");}}q.setPrompt(latin1Safe(prompt));q.setAnswerType(type);q.setChoices("CHOICE".equals(type) ? latin1Safe(choices) : null);q.setRequired(Boolean.TRUE.equals(body.required));q.setActive(body.active == null || Boolean.TRUE.equals(body.active));q.setPoints(body.points == null ? 0 : body.points);q.setStages(stages == null ? null : stages.toUpperCase());q.setSortOrder(body.sortOrder == null ? 0 : body.sortOrder);leadCallChecklistRepository.saveQuestion(q);LOGGER.info("Checklist question {} by auth {}: {}", creating ? "created" : "updated", me.getId(), q.getPrompt());Map<String, Object> res = new LinkedHashMap<>();res.put("ok", true);res.put("id", q.getId());res.put("created", creating);return responseSender.ok(res);}/*** Delete a question — but only one nobody has answered.** <p>A question with answers is retired by clearing {@code active}, never deleted: the answers* reference it, and removing it would leave a lead's recorded history pointing at nothing.*/@RequestMapping(value = "/lms/checklist/questions/delete", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> deleteQuestion(HttpServletRequest request,@RequestParam(name = "id") int id) {AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}LeadCallQuestion q = leadCallChecklistRepository.selectQuestionById(id);if (q == null) {return responseSender.notFound("Question not found");}long answers = leadCallChecklistRepository.countAnswersForQuestion(id);if (answers > 0) {return responseSender.badRequest("This question has " + answers + " recorded answer"+ (answers == 1 ? "" : "s") + " - switch it off instead of deleting it, so the "+ "answers keep their question");}leadCallChecklistRepository.deleteQuestion(q);LOGGER.info("Checklist question {} deleted by auth {}", id, me.getId());Map<String, Object> res = new LinkedHashMap<>();res.put("ok", true);return responseSender.ok(res);}private Map<String, Object> questionJson(LeadCallQuestion q) {Map<String, Object> m = new LinkedHashMap<>();m.put("id", q.getId());m.put("sortOrder", q.getSortOrder());m.put("prompt", q.getPrompt());m.put("answerType", q.getAnswerType());m.put("choices", q.getChoices());m.put("required", q.isRequired());m.put("active", q.isActive());m.put("points", q.getPoints());m.put("stages", q.getStages());m.put("answerCount", leadCallChecklistRepository.countAnswersForQuestion(q.getId()));return m;}/** Payload for {@link #saveQuestion}. */public static class QuestionRequest {public int id;public String prompt;public String answerType;public String choices;public Boolean required;public Boolean active;public Integer points;public String stages;public Integer sortOrder;}/** Payload for {@link #saveChecklist}. */public static class ChecklistRequest {public int leadId;public Integer leadCallId;public List<ChecklistAnswer> answers;}/** One answer in that payload. */public static class ChecklistAnswer {public int questionId;public String answer;}/*** Region → owning BM/RSM, resolved live from {@code cs.position}. Drives the Create-Lead* assignment preview and the dashboard's routing table; {@code HOLD} means the region has no* active owner and a lead created against it would wait in the HOLD queue.*/@RequestMapping(value = "/lms/resolve-region", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> resolveRegion(@RequestParam(name = "regionId", required = false) Integer regionId) {LmsAssignmentService.Assignment assignment = lmsAssignmentService.resolve(regionId);Map<String, Object> out = new HashMap<>();out.put("regionId", assignment.regionId);out.put("regionCode", assignment.regionCode);out.put("assignmentStatus", assignment.assignmentStatus);out.put("bmId", assignment.bm != null ? assignment.bm.getId() : null);out.put("bmName", assignment.bm != null ? assignment.bm.getFullName() : null);return responseSender.ok(out);}// ---- Stage rules ------------------------------------------------------------------------/*** Stage effect of each disposition (SOP §12.2). Only INTERESTED moves the lead forward; the* negative outcomes are terminal, and NOT_REACHABLE drops the lead once the retry budget is* spent. Anything else just records that contact happened.*/private void applyDispositionStage(Lead lead, LeadDisposition disposition, DispositionRequest body) {switch (disposition) {case INTERESTED:if (body.value != null && body.value > 0) {lead.setPotential(body.value);}advanceIfForward(lead, LeadStage.QUALIFIED);break;case NOT_INTERESTED:case DO_NOT_CALL:applyStage(lead, LeadStage.NOT_INTERESTED);break;case WRONG_NUMBER:// A corrected number keeps the lead alive at its current stage — the retailer still// has not been spoken to, so nothing advances. A blank one closes the lead.String corrected = digitsOnly(body.correctedNumber);if (corrected != null && corrected.length() == 10) {lead.setLeadMobile(corrected);lead.setUpdatedTimestamp(LocalDateTime.now());} else {applyStage(lead, LeadStage.DROPPED);}break;case NOT_REACHABLE:int tries = (lead.getUnreachableCount() == null ? 0 : lead.getUnreachableCount()) + 1;lead.setUnreachableCount(tries);if (tries >= LmsAssignmentService.MAX_UNREACHABLE) {applyStage(lead, LeadStage.DROPPED);}break;case CALLBACK:case FOLLOW_UP:default:advanceIfForward(lead, LeadStage.CONTACTED);break;}}/** Did the retailer actually get spoken to? Only those outcomes may close the first-contact SLA. */private boolean countsAsContact(LeadDisposition disposition) {return disposition != LeadDisposition.NOT_REACHABLE && disposition != LeadDisposition.WRONG_NUMBER;}/** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */private void applyStage(Lead lead, LeadStage stage) {lead.setStage(stage);lead.setStatus(stage.toLegacyStatus());lead.setUpdatedTimestamp(LocalDateTime.now());}/** Move to {@code stage} only if that is a forward move — a later call never rewinds the lead. */private void advanceIfForward(Lead lead, LeadStage stage) {LeadStage current = lead.getEffectiveStage();if (current == stage || current.canAdvanceTo(stage)) {applyStage(lead, stage);} else {lead.setUpdatedTimestamp(LocalDateTime.now());}}// ---- Helpers ----------------------------------------------------------------------------private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,LocalDateTime scheduled) {return appendTrail(leadId, actor, remark, type, scheduled, null);}/** As above, additionally linking the trail entry to the call that produced it. */private LeadActivity appendTrail(int leadId, AuthUser actor, String remark, CommunicationType type,LocalDateTime scheduled, Integer leadCallId) {LeadActivity activity = new LeadActivity();activity.setLeadId(leadId);activity.setRemark(remark);activity.setAuthId(actor != null ? actor.getId() : 0);activity.setCommunicationType(type);activity.setSchelduleTimestamp(scheduled);activity.setLeadCallId(leadCallId);activity.setCreatedTimestamp(LocalDateTime.now());leadActivityRepository.persist(activity);return activity;}/** Human-readable trail line for a disposition, including the stage move it caused. */private String dispositionRemark(LeadDisposition disposition, LeadStage before, Lead lead,DispositionRequest body) {StringBuilder sb = new StringBuilder(pretty(disposition.name()));if (body.subReason != null && !body.subReason.trim().isEmpty()) {sb.append(" · ").append(body.subReason.trim());}if (disposition == LeadDisposition.NOT_REACHABLE) {sb.append(" · attempt ").append(lead.getUnreachableCount());if (body.retrySchedule != null && !body.retrySchedule.trim().isEmpty()) {sb.append(", retry ").append(body.retrySchedule.trim());}}LeadStage after = lead.getEffectiveStage();if (after != before) {sb.append(" · stage ").append(pretty(before)).append(" -> ").append(pretty(after));}if (body.note != null && !body.note.trim().isEmpty()) {sb.append(" - ").append(body.note.trim());}return sb.toString();}/** What the caller needs to refresh its row without re-reading the whole record. */private Map<String, Object> stateOf(Lead lead) {LeadStage stage = lead.getEffectiveStage();Map<String, Object> out = new HashMap<>();out.put("leadId", lead.getId());out.put("stage", stage.name());out.put("stageLabel", pretty(stage));out.put("terminal", isTerminal(stage));out.put("slaState", lmsAssignmentService.slaState(lead));return out;}private Map<Integer, AuthUser> actorsOf(List<LeadActivity> trail) {Set<Integer> ids = new HashSet<>();for (LeadActivity a : trail) {if (a.getAuthId() > 0) {ids.add(a.getAuthId());}}Map<Integer, AuthUser> actors = new HashMap<>();if (!ids.isEmpty()) {for (AuthUser u : authRepository.selectByIds(new ArrayList<>(ids))) {actors.put(u.getId(), u);}}return actors;}private AuthUser currentUser(HttpServletRequest request) {try {LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);if (loginDetails != null && loginDetails.getEmailId() != null) {return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());}} catch (Exception e) {LOGGER.warn("Could not resolve the acting user for an LMS mutation", e);}return null;}private int happyPathIndex(LeadStage stage) {for (int i = 0; i < LeadStage.HAPPY_PATH.length; i++) {if (LeadStage.HAPPY_PATH[i] == stage) {return i;}}return -1;}private boolean isTerminal(LeadStage stage) {return stage == LeadStage.NOT_INTERESTED || stage == LeadStage.DROPPED;}private String pretty(LeadStage stage) {return stage == null ? "-" : pretty(stage.name());}private String pretty(String enumName) {return enumName.replace('_', ' ');}private String trimToNull(String s) {return (s == null || s.trim().isEmpty()) ? null : s.trim();}private String digitsOnly(String s) {return s == null ? null : s.replaceAll("\\D", "");}/** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */private LocalDateTime parseLocal(String s) {if (s == null || s.trim().isEmpty()) {return null;}try {return LocalDateTime.parse(s.trim());} catch (Exception e) {LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);return null;}}/** JSON body of the disposition modal — one flat shape, sub-fields set per disposition. */public static class DispositionRequest {public int leadId;public String disposition;public String note;/** The {@code user.lead_call} row this outcome belongs to, supplied by the dialer. */public Integer leadCallId;/*** Interim: the free-text recording URL agents paste today. Superseded by {@code leadCallId}* once the dialer captures recordings automatically — kept until then so removing the box* does not leave leads with no recording path at all.*/public String recordingUrl;public Double value; // INTERESTEDpublic String callbackAt; // CALLBACKpublic String retrySchedule; // NOT_REACHABLEpublic String correctedNumber;// WRONG_NUMBERpublic String followUpType; // FOLLOW_UP — CALL | MEETINGpublic String followUpAt; // FOLLOW_UPpublic String subReason; // NOT_INTERESTED}/*** Create a lead from the LMS dashboard.** <p><b>Deliberately not {@code /createLead}.</b> That endpoint is shared with the legacy Leads* screen and must keep behaving exactly as it does. It also silently ignores the {@code regionId}* and {@code creationPath} this dashboard has always sent — which is why no lead has ever had a* stage, an LMS code or an SLA clock, while the UI cheerfully toasted "auto-assigned · SLA* started". This endpoint is what makes that message true.** <p>Asks for far less than the legacy form: no store photos, no counter size, no brand-wise* values, no free-text address. The store-board photo and lat/lng arrive through the geo capture* link (auto-generated below), and location is confirmed by that link or by manual verification —* so an agent on a first call is no longer made to invent shop-audit data.*/@RequestMapping(value = "/lms/createLead", method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_VALUE)@ResponseBodypublic ResponseEntity<?> createLead(HttpServletRequest request, @RequestBody CreateLeadRequest body) {if (body == null) {return responseSender.badRequest("Nothing to create");}String name = latin1Safe(body.firstName == null ? "" : body.firstName.trim());if (name.isEmpty()) {return responseSender.badRequest("Retailer name is required");}String mobile = body.mobile == null ? "" : body.mobile.replaceAll("\\D", "");if (mobile.length() < 10) {return responseSender.badRequest("A valid 10-digit contact number is required");}mobile = mobile.substring(mobile.length() - 10);if (body.regionId == null || body.regionId <= 0) {return responseSender.badRequest("Pick a region — it drives auto-assignment");}// Same rule the dialer uses: a number on the do-not-call register never becomes a lead.if (leadDndRepository.selectByMobile(mobile) != null) {return responseSender.badRequest("This number is on the do-not-call register.");}Lead existing = leadRepository.selectByMobileNumber(mobile);if (existing != null) {return responseSender.badRequest("Lead #" + existing.getId() + " already exists for "+ mobile + ", created by " + existing.getCreatedBy());}AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}Lead lead = new Lead();// Split on the last space so "Jitu Katara" keeps a surname; user.lead.last_name is NOT NULL.int space = name.lastIndexOf(' ');lead.setFirstName(space > 0 ? name.substring(0, space) : name);lead.setLastName(space > 0 ? name.substring(space + 1) : "");lead.setLeadMobile(mobile);// address / city / state are NOT NULL but are no longer asked for: the geo link supplies the// real location. Coalesced rather than left to fail the insert.lead.setAddress("");lead.setCity("");lead.setState("");lead.setSource(latin1Safe(body.source == null || body.source.trim().isEmpty()? "LMS Dashboard" : body.source.trim()));lead.setCreatedTimestamp(LocalDateTime.now());lead.setUpdatedTimestamp(LocalDateTime.now());lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());lead.setAuthId(me.getId());// The record screen reads the business name off user.lead.outlet_name. Until now it was only// written to lead_detail below — a call that throws for every lead created here, because the// legacy detail path demands store photos this form deliberately does not collect — so the// name the agent typed was silently dropped and the Business row rendered blank.lead.setOutLetName(latin1Safe(body.outletName == null ? "" : body.outletName.trim()));lead.setRegionId(body.regionId);if (body.potential != null && body.potential > 0) {lead.setPotential(body.potential);}// Path B is a field encounter, so the creator owns it; Path A goes to the region's BM.if ("B".equalsIgnoreCase(body.creationPath)) {lead.setAssignTo(me.getId());}// THE point of this endpoint: region -> owner -> stage -> 5hr SLA, per SOP 9.LmsAssignmentService.Assignment assignment = lmsAssignmentService.assign(lead, body.creationPath);leadRepository.persist(lead);// lms_code needs the generated id, so it can only be stamped after the insert.lead.setLmsCode(lmsAssignmentService.generateLmsCode(lead));leadRepository.persist(lead);boolean hasBrands = body.leadBrands != null && !body.leadBrands.isEmpty();if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands) {try {LeadDetailModel detail = new LeadDetailModel();detail.setLeadId(lead.getId());detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());if (hasBrands) {List<LeadBrandModel> brands = new ArrayList<>();for (BrandValue bv : body.leadBrands) {if (bv == null || bv.brand == null || bv.brand.trim().isEmpty() || bv.value == null|| bv.value <= 0) {// Only brands the retailer actually stocks. Persisting zeroes for the rest// is what made the legacy brand table useless for reporting.continue;}LeadBrandModel brand = new LeadBrandModel();brand.setBrand(bv.brand.trim());brand.setValue(bv.value.intValue());brands.add(brand);}detail.setLeadBrands(brands);}leadRepository.persistLeadDetail(detail, me);} catch (Exception e) {// Shop name and brand split are not worth losing an otherwise-good lead over.LOGGER.warn("Could not save the lead detail for lead {}", lead.getId(), e);}}StringBuilder created = new StringBuilder("Lead created from the LMS dashboard");if (hasBrands) {created.append(" · brand-wise value: ");boolean first = true;for (BrandValue bv : body.leadBrands) {if (bv == null || bv.brand == null || bv.value == null || bv.value <= 0) {continue;}if (!first) {created.append(", ");}created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());first = false;}}trail(lead.getId(), me.getId(), latin1Safe(created.toString()+ (assignment != null && "HOLD".equals(assignment.assignmentStatus)? " - region has no active BM/RSM, parked in the HOLD queue" : "")));// Auto-generate the geo capture link so the agent can send it during the same call.String geoLink = buildGeoCaptureLink(lead.getId());if (geoLink != null) {trail(lead.getId(), me.getId(), "Geolocation link generated for lead");}Map<String, Object> out = new LinkedHashMap<>();out.put("ok", true);out.put("leadId", lead.getId());out.put("lmsCode", lead.getLmsCode());out.put("assignmentStatus", lead.getAssignmentStatus());out.put("ownerName", assignment != null && assignment.bm != null? assignment.bm.getFirstName() + " " + assignment.bm.getLastName() : null);out.put("geoLink", geoLink);LOGGER.info("LMS lead {} created ({}) by auth {} — region {} status {}",lead.getId(), lead.getLmsCode(), me.getId(), lead.getRegionCode(), lead.getAssignmentStatus());return responseSender.ok(out);}/*** Business value on the lakh scale.** <p>Two conventions live in {@code user.lead.potential}: the forms capture LAKHS (rows hold* 1–99) but about 1,355 older rows were entered in RUPEES (1,000 upwards). No row sits* between 100 and 999, so that gap is a safe separator — anything from {@value #LAKH_CUTOFF} up* is read as rupees and brought onto the lakh scale. Read-side only: no stored value is* rewritten, so a row keeps whatever it holds until someone edits it.*/private static final double LAKH_CUTOFF = 1000d;private double toLakh(double v) {return v >= LAKH_CUTOFF ? v / 100000d : v;}/** "12L" / "5.2L" / "0.01L" — a sub-lakh value needs two decimals or it prints as 0L. */private String lakhLabel(double potential) {if (potential <= 0) {return null;}double l = toLakh(potential);String n = (l < 1) ? trimZeros(String.format("%.2f", l)) : trimZeros(String.format("%.1f", l));return n + "L";}/** The same number, unsuffixed, for the edit form — so saving cannot re-store rupees. */private String lakhInput(double potential) {if (potential <= 0) {return "";}double l = toLakh(potential);return (l < 1) ? trimZeros(String.format("%.2f", l)) : trimZeros(String.format("%.1f", l));}private String trimZeros(String s) {return s.contains(".") ? s.replaceAll("0+$", "").replaceAll("\\.$", "") : s;}/*** Append a trail entry.** <p>Deliberately NOT caught. It used to swallow the exception on the grounds that "a lost note* must not fail the lead", but inside a {@code @Transactional} method that is worse than* useless: the failed entity stays in the Hibernate session with a null id, the request carries* on, and the commit-time flush dies with "null id in LeadActivity entry" — an error that names* neither the lead nor the real cause. An edit that cannot be recorded should fail loudly and* roll back; SOP §6 wants the trail append-only, and a change with no trail entry is worse than* a change that did not happen.*/private void trail(int leadId, int authId, String remark) {LeadActivity activity = new LeadActivity();activity.setLeadId(leadId);activity.setAuthId(authId);activity.setRemark(remark);activity.setCreatedTimestamp(LocalDateTime.now());leadActivityRepository.persist(activity);}/*** {base}/lead-geo/{leadId} — the same URL the Leads screen's Generate Link button produces.* Null when the base URL is unconfigured, which must not stop a lead being created.*/private String buildGeoCaptureLink(int leadId) {String base = leadGeoPublicBaseUrl;if (base == null || base.trim().isEmpty()) {return null;}base = base.trim();while (base.endsWith("/")) {base = base.substring(0, base.length() - 1);}return base + "/lead-geo/" + leadId;}/** Payload for {@link #createLead}. */public static class CreateLeadRequest {public String firstName; // retailer contact name, split into first/lastpublic String mobile;public String outletName; // business / shop namepublic String source;public Integer regionId; // drives auto-assignmentpublic String creationPath; // A = BGC outbound, B = field encounterpublic Double potential; // monthly business value — the sum of leadBrands/** Per-brand monthly business. What a BM actually needs to size the counter. */public List<BrandValue> leadBrands;}/** One brand's monthly business value. */public static class BrandValue {public String brand;public Double value;}}