Rev 37760 | 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.dtr.RoleRepository;import com.spice.profitmandi.dao.entity.dtr.Role;import com.spice.profitmandi.service.authentication.RoleManager;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.dao.model.lms.LeadDispositionRequest;import com.spice.profitmandi.service.LmsAssignmentService;import com.spice.profitmandi.service.lms.LmsDispositionService;import com.spice.profitmandi.service.lms.LmsOutcome;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;/*** The disposition write — shared with the field app's post-call sheet in profitmandi-web. It is* the most consequential write in the LMS (SLA stamp, stage, trail, DND), so it lives in one* place rather than once per front end.*/@Autowiredprivate LmsDispositionService lmsDispositionService;@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 RoleManager roleManager;@Autowiredprivate RoleRepository roleRepository;@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 LeadDispositionRequest body) {LmsOutcome outcome = lmsDispositionService.apply(currentUser(request), body);switch (outcome.status) {case OK:return responseSender.ok(stateOf((Lead) outcome.data.get("lead")));case NOT_FOUND:return responseSender.notFound(outcome.message);default:return responseSender.badRequest(outcome.message);}}/*** 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.*//*** Assign or change the BM/RSM who owns a region. Writes the region's Sales L4 position in* {@code cs.position} -- the row the assignment engine reads first (L4 before L5 before L3) --* so the routing table, the Create-Lead preview and every future lead pick the new owner at* once. An existing L4 row is re-pointed (the region keeps one owner), otherwise one is created.* Leads already assigned are untouched; re-route those with {@code /lms/assignLead}.** <p>Allowed for FOFO admins, the "Sales Admin" role, and anyone holding a Sales position at L5* or above (a sales head). A BM cannot hand a region to themselves.*/@RequestMapping(value = "/lms/region-owner", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> setRegionOwner(HttpServletRequest request,@RequestParam(name = "regionId") int regionId,@RequestParam(name = "authId") int authId) {AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}if (!canManageRegionOwners(request, me)) {return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Only an admin, a Sales Admin or a sales head (L5+) can change a region's owner");}Region region = regionRepository.selectById(regionId);if (region == null) {return responseSender.notFound("Region not found");}AuthUser owner = authRepository.selectById(authId);if (owner == null || !Boolean.TRUE.equals(owner.isActive())) {return responseSender.badRequest("That person is not an active user");}List<Position> l4 = positionRepository.selectPositionbyCategoryIdAndEscalationType(SALES_CATEGORY, EscalationType.L4, regionId);Position target = (l4 == null || l4.isEmpty()) ? null : l4.get(0);AuthUser previous = null;if (target != null) {if (target.getAuthUserId() == authId) {return responseSender.badRequest(owner.getFullName() + " already owns this region");}previous = authRepository.selectById(target.getAuthUserId());target.setAuthUserId(authId);} else {target = new Position();target.setAuthUserId(authId);target.setCategoryId(SALES_CATEGORY);target.setEscalationType(EscalationType.L4);target.setRegionId(regionId);target.setCreateTimestamp(LocalDateTime.now());target.setTicketAssignee(false);}positionRepository.persist(target);// Report what the engine now resolves for the region, not just what was written.LmsAssignmentService.Assignment now = lmsAssignmentService.resolve(regionId);LOGGER.info("LMS region {} ({}) owner set to auth {} by auth {}{}", regionId, now.regionCode, authId,me.getId(), previous != null ? " (was " + previous.getId() + ")" : " (new L4 position)");Map<String, Object> res = new LinkedHashMap<>();res.put("ok", true);res.put("regionId", regionId);res.put("regionCode", now.regionCode);res.put("bmName", now.bm != null ? now.bm.getFullName() : null);res.put("assignmentStatus", now.assignmentStatus);res.put("previous", previous != null ? previous.getFullName() : null);res.put("created", previous == null);return responseSender.ok(res);}/*** Who may create leads from the LMS dashboard: anyone holding a position (any category) at L2* or above. The position decides -- an L1 agent is refused even with FOFO_ADMIN on the login.* Only a user with no position at all falls back to the admin role. Mirrors the* {@code canCreateLead} flag that hides the tab.*/private boolean canCreateLeads(HttpServletRequest request, AuthUser me) {List<Position> held = positionRepository.selectPositionByAuthId(me.getId());if (held != null && !held.isEmpty()) {for (Position p : held) {if (p.getEscalationType() != null && p.getEscalationType().ordinal() >= EscalationType.L2.ordinal()) {return true;}}return false;}try {LoginDetails login = cookiesProcessor.getCookiesObject(request);return login != null && login.getRoleIds() != null && roleManager.isAdmin(login.getRoleIds());} catch (Exception e) {LOGGER.warn("Could not read roles for auth {}", me.getId(), e);return false;}}/** FOFO admin, the "Sales Admin" role, or a Sales position at L5 or above. */private boolean canManageRegionOwners(HttpServletRequest request, AuthUser me) {try {LoginDetails login = cookiesProcessor.getCookiesObject(request);Set<Integer> roleIds = login != null && login.getRoleIds() != null ? login.getRoleIds() : new HashSet<>();if (roleManager.isAdmin(roleIds)) {return true;}if (!roleIds.isEmpty()) {for (Role r : roleRepository.selectByIds(roleIds)) {if (r.getName() != null && "Sales Admin".equalsIgnoreCase(r.getName().trim())) {return true;}}}} catch (Exception e) {LOGGER.warn("Could not read roles for auth {}", me.getId(), e);}List<Position> held = positionRepository.selectPositionByAuthId(me.getId());if (held != null) {for (Position p : held) {if (p.getCategoryId() == SALES_CATEGORY && p.getEscalationType() != null&& p.getEscalationType().ordinal() >= EscalationType.L5.ordinal()) {return true;}}}return false;}@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 ------------------------------------------------------------------------/** 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;}/** 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 static boolean positive(Integer n) {return n != null && n > 0;}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;}}/*** 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");}// Narrow columns + STRICT_TRANS_TABLES: name the field instead of failing at flush.String tooLong = firstTooLong("Address", body.address, 128,"City", body.city, 30,"State", body.state, 30);if (tooLong != null) {return responseSender.badRequest(tooLong);}// 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();}if (!canCreateLeads(request, me)) {return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Creating leads here needs a position at L2 or above");}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. They are optional on this form (the geo link supplies// the real location), so whatever was typed is kept and blanks are coalesced rather than// left to fail the insert.lead.setAddress(latin1Safe(body.address == null ? "" : body.address.trim()));lead.setCity(latin1Safe(body.city == null ? "" : body.city.trim()));lead.setState(latin1Safe(body.state == null ? "" : body.state.trim()));// Legacy "conversion probability": the Leads screen colours a High lead Green, else Yellow.lead.setColor(Boolean.TRUE.equals(body.conversionHigh) ? "Green" : "Yellow");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();boolean hasPhotos = positive(body.frontp) || positive(body.frontWithMarket) || positive(body.internalLongShot)|| positive(body.internalLeftWall) || positive(body.internalRightWall);boolean hasCounter = positive(body.counterSize);if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands || hasPhotos || hasCounter) {try {LeadDetailModel detail = new LeadDetailModel();detail.setLeadId(lead.getId());detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());if (hasCounter) {detail.setCounterSize(body.counterSize);}// Whatever photos came with the form; none are required here (see persistLeadDetail's// lenient overload) because this lead is usually created on a call, before a visit.if (positive(body.frontp)) detail.setFrontp(body.frontp);if (positive(body.frontWithMarket)) detail.setFrontWithMarket(body.frontWithMarket);if (positive(body.internalLongShot)) detail.setInternalLongShot(body.internalLongShot);if (positive(body.internalLeftWall)) detail.setInternalLeftWall(body.internalLeftWall);if (positive(body.internalRightWall)) detail.setInternalRightWall(body.internalRightWall);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, false);} 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;}}if (hasPhotos) {created.append(" · store photos attached");}String remark = body.remark == null ? "" : body.remark.trim();if (!remark.isEmpty()) {created.append(" · remark: ").append(remark.length() > 500 ? remark.substring(0, 500) : remark);}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;// Shop details the legacy Leads screen also captures. All optional here.public String address;public String city;public String state;public String remark; // becomes the first trail entry's notepublic Boolean conversionHigh; // legacy "conversion probability": High -> Green, else Yellowpublic Integer counterSize; // user.lead_detail.counter_size// Store photos as document ids from /document-upload (user.lead_detail columns).public Integer frontp;public Integer frontWithMarket;public Integer internalLongShot;public Integer internalLeftWall;public Integer internalRightWall;}/** One brand's monthly business value. */public static class BrandValue {public String brand;public Double value;}}