Blame | 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.repository.cs.RegionRepository;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.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 LeadDndRepository leadDndRepository;@Autowiredprivate AuthRepository authRepository;@Autowiredprivate RegionRepository regionRepository;@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("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, "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);lead.setDisposition(disposition);lead.setDispositionSubReason(trimToNull(body.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,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(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 = trimToNull(body.retailerName);if (name == null) {return responseSender.badRequest("Retailer name is required");}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(' ');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 = 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 = trimToEmpty(body.address);String city = trimToEmpty(body.city);String state = 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(), 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. Blank before/after read as an em dash, not as nothing. */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()) ? "—" : s.trim();}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) {return "₹" + String.format("%.0f", value) + "/mo";}/*** 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 = 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(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(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(), 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);}/** Append a trail entry. Never fatal — a lost note must not fail the lead. */private void trail(int leadId, int authId, String remark) {try {LeadActivity activity = new LeadActivity();activity.setLeadId(leadId);activity.setAuthId(authId);activity.setRemark(remark);activity.setCreatedTimestamp(LocalDateTime.now());leadActivityRepository.persist(activity);} catch (Exception e) {LOGGER.warn("Could not write the trail entry for lead {}", leadId, e);}}/*** {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;}}