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;
}
}
}