using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using CRMSalesDAL.CustomCode.LeadScoring; using CRMSalesDAL.CustomCode.LeadScoringConfig; namespace CRMSalesBLL.LeadScoring; /// /// SalesMind AI Lead Scoring engine. Composite 0-100 score across the reference material's 4 /// scoring dimensions (Demographic Fit / Behavioral Signals / Engagement Level / Deal Readiness) — /// but only 2 of the 4 have a real data source in this codebase today. This class computes those 2 /// honestly and renormalizes their configured weights against each other; it does NOT fabricate /// values for the other 2. See for the full rule. /// public class LeadScoringBLL : ILeadScoringBLL { private readonly ILeadScoringDAL _LeadScoringDAL; private readonly ILeadScoringConfigDAL _LeadScoringConfigDAL; private readonly ILogger _Logger; // Default weights (25/30/25/20 for Demographic/Behavioral/Engagement/DealReadiness) used only // when no MLEADSCORINGCONFIG row exists yet — matches this entity's documented defaults. private const decimal DefaultDemographicFitWeight = 25m; private const decimal DefaultDealReadinessWeight = 20m; public LeadScoringBLL( ILeadScoringDAL leadScoringDAL, ILeadScoringConfigDAL leadScoringConfigDAL, ILogger logger) { _LeadScoringDAL = leadScoringDAL; _LeadScoringConfigDAL = leadScoringConfigDAL; _Logger = logger; } /// /// Demographic Fit (REAL dimension): 100 if the Lead has a non-default SegmentId AND ICPId /// (both != -1), 50 if only one is set, 0 if neither. /// public static int ComputeDemographicFitScore(int segmentId, int icpId) { bool hasSegment = segmentId != -1; bool hasICP = icpId != -1; if (hasSegment && hasICP) return 100; if (hasSegment || hasICP) return 50; return 0; } /// /// Deal Readiness (REAL dimension): derived from the MAN score of the Lead's most recent OPEN /// Deal, if one exists — percentage of the 3 MAN criteria marked "Yes" (1): 0/33/67/100 for /// 0/1/2/3 "Yes" answers. 0 if no open Deal exists yet for this Lead (all 3 fields null). /// public static int ComputeDealReadinessScore(byte? manMoney, byte? manAuthority, byte? manNeed) { if (manMoney == null && manAuthority == null && manNeed == null) return 0; const byte yes = 1; int yesCount = (manMoney == yes ? 1 : 0) + (manAuthority == yes ? 1 : 0) + (manNeed == yes ? 1 : 0); return yesCount switch { 0 => 0, 1 => 33, 2 => 67, _ => 100 }; } /// /// Composite score rule: /// /// Demographic Fit and Deal Readiness are REAL — computed from live TLEAD/TDEAL data /// (see /). /// Behavioral Signals and Engagement Level are DEFERRED — Behavioral Signals has no /// website-visit/content-download tracking anywhere in this codebase yet; Engagement Level /// depends on the Interaction Journal, a future SalesMind pass not built yet. Both contribute /// 0, not a fabricated value. /// The two real dimensions' configured weights (from MLEADSCORINGCONFIG, or the /// 25/20 defaults if no config row exists) are RENORMALIZED against just their own combined /// weight — e.g. if Demographic=25 and DealReadiness=20 are the only real ones out of 100 /// total configured weight, effective score = 25/(25+20) * DemographicScore + /// 20/(25+20) * DealReadinessScore. This avoids silently zero-weight-averaging against all 4 /// configured weights, which would understate every real lead's score by design. /// /// public async Task RecalculateLeadScoreAsync(int leadId, LoginDTO loginDTO, CancellationToken ct) { try { GB5Trace.Step("recalculate-lead-score", new { leadId }); var input = await _LeadScoringDAL.GetLeadScoringInput(leadId, loginDTO, ct).ConfigureAwait(false); if (input == null) throw new Exception($"Lead {leadId} was not found."); var config = await _LeadScoringConfigDAL.GetLatestLeadScoringConfig(loginDTO, ct).ConfigureAwait(false); decimal demographicWeight = config?.DemographicFitWeightPercent ?? DefaultDemographicFitWeight; decimal dealReadinessWeight = config?.DealReadinessWeightPercent ?? DefaultDealReadinessWeight; int demographicScore = ComputeDemographicFitScore(input.SegmentId, input.ICPId); int dealReadinessScore = ComputeDealReadinessScore( input.LatestOpenDealMANMoney, input.LatestOpenDealMANAuthority, input.LatestOpenDealMANNeed); decimal totalRealWeight = demographicWeight + dealReadinessWeight; int compositeScore = totalRealWeight <= 0 ? 0 : (int)Math.Round( (demographicWeight / totalRealWeight) * demographicScore + (dealReadinessWeight / totalRealWeight) * dealReadinessScore, MidpointRounding.AwayFromZero); await _LeadScoringDAL.UpdateLeadScore(leadId, compositeScore, loginDTO, ct).ConfigureAwait(false); _Logger.LogInformation( "Lead {LeadId} score recalculated to {Score} (DemographicFit={DemographicScore}, DealReadiness={DealReadinessScore}) by user {UserId}", leadId, compositeScore, demographicScore, dealReadinessScore, loginDTO.UserId); return $"Lead {leadId} score recalculated: {compositeScore}"; } catch (Exception ex) { GB5Trace.MarkFailed("recalculate-lead-score-failed", ex); _Logger.LogError(ex, "RecalculateLeadScoreAsync failed for LeadId {LeadId}", leadId); throw; } } }