Blame | Last modification | View Log | RSS feed
package com.spice.profitmandi.service.lms;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.enumuration.dtr.CommunicationType;import com.spice.profitmandi.dao.enumuration.dtr.LeadDisposition;import com.spice.profitmandi.dao.enumuration.dtr.LeadStage;import com.spice.profitmandi.dao.model.lms.LeadDispositionRequest;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.LeadRepository;import com.spice.profitmandi.service.LmsAssignmentService;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.time.LocalDateTime;/*** What happens to a lead when a call ends: the disposition, the stage it moves to, the first-contact* SLA stamp, the trail entry, and the do-not-call block.** <p><b>Why this is a service.</b> This is the most consequential write in the LMS and it is now* reachable from two places — the desk console's disposition modal and the field app's post-call* sheet. Every one of the effects below is load-bearing for reporting or compliance, and a second* implementation that quietly forgot one of them would corrupt the numbers without failing:** <ul>* <li>{@code first_contacted_at} is what closes the 5-hour SLA — miss it and every lead a field* rep contacts reads as breached forever.</li>* <li>{@code stage} and the legacy {@code status} column must move together, or the LMS funnel* and the old lead lists disagree about the same lead.</li>* <li>DO_NOT_CALL must reach {@code user.lead_dnd} — otherwise the retailer is re-dialled, which* is a regulatory problem, not a UX one.</li>* <li>The trail entry and the {@code lead_call.lead_activity_id} back-reference are what make a* call auditable in both directions.</li>* </ul>** <p>Callers pass an already-resolved {@link AuthUser}; the lead is returned in the outcome so each* controller can render its own view of the new state.*/@Servicepublic class LmsDispositionService {private static final Logger LOGGER = LogManager.getLogger(LmsDispositionService.class);/*** How far back an un-dispositioned call may be silently bound to this outcome. 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 static final int CALL_BIND_WINDOW_MINUTES = 30;@Autowiredprivate LeadRepository leadRepository;@Autowiredprivate LeadCallRepository leadCallRepository;@Autowiredprivate LeadActivityRepository leadActivityRepository;@Autowiredprivate LeadDndRepository leadDndRepository;/*** Record the outcome of a call.** <p>On success the outcome carries {@code lead} (the updated entity), {@code leadCallId} (the* call it was bound to, or null) and {@code stage}.*/public LmsOutcome apply(AuthUser actor, LeadDispositionRequest body) {if (body == null || body.disposition == null || body.disposition.trim().isEmpty()) {return LmsOutcome.invalid("A disposition is required");}Lead lead = leadRepository.selectById(body.leadId);if (lead == null) {return LmsOutcome.notFound("Lead not found");}LeadDisposition disposition;try {disposition = LeadDisposition.valueOf(body.disposition);} catch (IllegalArgumentException e) {return LmsOutcome.invalid("Unknown disposition: " + body.disposition);}if (disposition == LeadDisposition.INTERESTED && (body.value == null || body.value <= 0)) {return LmsOutcome.invalid("Business value is required on an INTERESTED disposition");}LeadStage before = lead.getEffectiveStage();LeadCall call = resolveCall(body, lead, actor);String subReason = latin1Safe(trimToNull(body.subReason));if (subReason != null && subReason.length() > 64) {return LmsOutcome.invalid("Sub-reason is too long - keep it to 64 characters");}lead.setDisposition(disposition);lead.setDispositionSubReason(subReason);// Interim manual path — drops out when the dialer starts filling recordings from the webhook.if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {lead.setRecordingUrl(body.recordingUrl.trim());}// Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody// answered) and WRONG_NUMBER (not the retailer) are not contact: they must not satisfy the SLA,// and they must not disturb a stamp an earlier real contact already set.if (lead.getFirstContactedAt() == null && countsAsContact(disposition)) {// Prefer the moment the retailer actually picked up over "now" — the agent may sit on the// disposition form for minutes, and that gap would silently eat into the 5-hr SLA.LocalDateTime contactedAt = (call != null && call.getAnsweredAt() != null)? call.getAnsweredAt() : LocalDateTime.now();lead.setFirstContactedAt(contactedAt);}LocalDateTime scheduled = parseLocal(body.callbackAt != null ? body.callbackAt : body.followUpAt);applyDispositionStage(lead, disposition, body);leadRepository.persist(lead);CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)? CommunicationType.VISIT : CommunicationType.TELEPHONIC;LeadActivity activity = appendTrail(body.leadId, actor,latin1Safe(dispositionRemark(disposition, before, lead, body)), type, scheduled,call != null ? call.getId() : null);// Bind both ways: the trail entry knows its call (for duration + a recording link), and the// call knows the disposition it produced (so an unactioned call is findable).if (call != null) {call.setLeadActivityId(activity.getId());leadCallRepository.persist(call);}if (disposition == LeadDisposition.DO_NOT_CALL) {blockFurtherCalls(lead, actor, body);}LOGGER.info("LMS disposition {} on lead {} ({} -> {}) by {}, call {}", disposition, body.leadId,before, lead.getEffectiveStage(), actor != null ? actor.getId() : 0,call != null ? call.getId() : null);return LmsOutcome.ok().with("lead", lead).with("leadCallId", call != null ? call.getId() : null).with("stage", lead.getEffectiveStage().name());}/*** Which call this disposition is about. The client 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.*/private LeadCall resolveCall(LeadDispositionRequest 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.*/private void blockFurtherCalls(Lead lead, AuthUser actor, LeadDispositionRequest body) {LeadDnd dnd = new LeadDnd();dnd.setMobile(lead.getLeadMobile());dnd.setLeadId(lead.getId());dnd.setAuthId(actor != null ? actor.getId() : null);dnd.setSource("DISPOSITION");dnd.setReason(latin1Safe(trimToNull(body.note) != null? trimToNull(body.note) : "Retailer asked not to be contacted"));leadDndRepository.block(dnd);LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);}/*** 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, LeadDispositionRequest 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());}}/** Trail entry, linked 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,LeadDispositionRequest 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();}// ---- Small helpers -------------------------------------------------------------------------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;}}/*** Transliterate the characters a phone keyboard produces into the latin1 the LMS text columns* hold. Without this a smart quote or a rupee sign in a note takes the whole write down with* "Incorrect string value", naming neither the lead nor the cause.*/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 '‘': case '’': case '‛': out.append('\''); break; // ‘ ’ ‛case '“': case '”': case '„': out.append('"'); break; // “ ” „case '–': case '—': case '−': out.append('-'); break; // – — −case '…': out.append("..."); break; // …case '→': out.append("->"); break; // →case '₹': out.append("Rs"); break; // ₹case ' ': out.append(' '); break; // nbspdefault:out.append(c <= 0xFF ? c : '?');}}return out.toString();}}