Subversion Repositories SmartDukaan

Rev

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.
 */
@Service
public 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;

    @Autowired
    private LeadRepository leadRepository;

    @Autowired
    private LeadCallRepository leadCallRepository;

    @Autowired
    private LeadActivityRepository leadActivityRepository;

    @Autowired
    private 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;                                  // nbsp
                default:
                    out.append(c <= 0xFF ? c : '?');
            }
        }
        return out.toString();
    }
}