using Dapr.Client; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using PERMBLL.Interfaces; using PERMDAL.DTOs; using PERMDAL.Interfaces; using System.Text.Json; using static GB5Shared.GB5Constant.Constant; namespace PERMBLL.Implementations; public class FinalizeAppraisalBLL : IFinalizeAppraisalBLL { private readonly IAppraisalDAL _appraisalDal; private readonly IAppraisalPlanDAL _appraisalPlanDal; private readonly IAppraisalEvalDAL _appraisalEvalDal; private readonly IScoringDAL _scoringDal; private readonly IRatingScaleDAL _ratingScaleDal; private readonly IFinalizeAppraisalDAL _finalizeDal; private readonly IPermConfigBLL _permConfig; private readonly EventLogPublish _eventLog; private readonly DaprClient _daprClient; private readonly ILogger _logger; public FinalizeAppraisalBLL( IAppraisalDAL appraisalDal, IAppraisalPlanDAL appraisalPlanDal, IAppraisalEvalDAL appraisalEvalDal, IScoringDAL scoringDal, IRatingScaleDAL ratingScaleDal, IFinalizeAppraisalDAL finalizeDal, IPermConfigBLL permConfig, EventLogPublish eventLog, DaprClient daprClient, ILogger logger) { _appraisalDal = appraisalDal; _appraisalPlanDal = appraisalPlanDal; _appraisalEvalDal = appraisalEvalDal; _scoringDal = scoringDal; _ratingScaleDal = ratingScaleDal; _finalizeDal = finalizeDal; _permConfig = permConfig; _eventLog = eventLog; _daprClient = daprClient; _logger = logger; } public async Task FinalizeAppraisalAsync(int appraisalId, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("finalize-appraisal-load", new { appraisalId }); var appraisal = await _appraisalDal.GetAppraisalAsync(appraisalId, login, ct).ConfigureAwait(false); if (appraisal is null) return "Appraisal not found."; if (appraisal.Status == 4) return "Appraisal is already finalized."; var plan = await _appraisalPlanDal.GetAppraisalPlanAsync(appraisal.AppraisalPlanId, login, ct).ConfigureAwait(false); if (plan is null) return "Appraisal plan not found."; // Load all evals with full KRA + detail hierarchy var evalList = (await _appraisalEvalDal.GetEvalListByAppraisalAsync(appraisalId, login, ct).ConfigureAwait(false)).ToList(); foreach (var eval in evalList) { var kras = (await _appraisalEvalDal.GetEvalKRAsAsync(eval.AppraisalEvalId, login, ct).ConfigureAwait(false)).ToList(); var details = (await _appraisalEvalDal.GetEvalDetailsAsync(eval.AppraisalEvalId, login, ct).ConfigureAwait(false)).ToList(); foreach (var kra in kras) kra.Details = details.Where(d => d.AppraisalEvalKRAId == kra.AppraisalEvalKRAId).ToList(); eval.KRAs = kras; } // Load KRA/Measure weights for the appraisal type var weights = (await _scoringDal.GetAppraisalWeightsAsync(plan.AppraisalTypeId, login, ct).ConfigureAwait(false)).ToList(); var kraWeights = weights.ToLookup(w => w.KRAId); GB5Trace.Step("finalize-appraisal-score", new { appraisalId, evalCount = evalList.Count }); // Calculate scores per evaluator decimal selfScore = 0m; decimal managerScore = 0m; var kraScoreMap = new Dictionary(); // KRAId → averaged KRA score across evals foreach (var eval in evalList) { decimal evalScore = CalculateEvalScore(eval, kraWeights); eval.OverallScore = evalScore; if (eval.EvalType == 0) selfScore = evalScore; if (eval.EvalType == 1) managerScore = evalScore; } // Build KRA score map for TNA gap detection (use manager eval if available, else self) var refEval = evalList.FirstOrDefault(e => e.EvalType == 1) ?? evalList.FirstOrDefault(e => e.EvalType == 0); if (refEval?.KRAs is not null) foreach (var kra in refEval.KRAs) kraScoreMap[kra.KRAId] = kra.KRAScore; // Consolidate final percentage decimal selfWeight = await _permConfig.GetSelfWeightAsync(login, ct).ConfigureAwait(false); decimal finalPercentage = selfScore * selfWeight + managerScore * (1m - selfWeight); finalPercentage = Math.Round(finalPercentage, 4); // Grade assignment var grade = await _ratingScaleDal.GetGradeForScoreAsync(finalPercentage, login, ct).ConfigureAwait(false); // PIP check decimal pipThreshold = await _permConfig.GetPIPThresholdAsync(login, ct).ConfigureAwait(false); bool activePipExists = await _finalizeDal.ActivePIPExistsAsync(appraisal.EmployeeId, login, ct).ConfigureAwait(false); bool shouldCreatePIP = finalPercentage < pipThreshold && !activePipExists; // TNA check: collect KRAs where score is below threshold decimal tnaThreshold = await _permConfig.GetTNAThresholdAsync(login, ct).ConfigureAwait(false); var tnaItems = kraScoreMap .Where(kv => kv.Value < tnaThreshold) .Select(kv => new TNACreateItem { KRAId = kv.Key, GapScore = kv.Value }) .ToList(); GB5Trace.Step("finalize-appraisal-persist", new { appraisalId, finalPercentage, shouldCreatePIP, tnaCount = tnaItems.Count }); var finalizeData = new FinalizeDataDTO { AppraisalId = appraisalId, EmployeeId = appraisal.EmployeeId, OverallMeasureScore = selfScore, OverallGoalScore = 0m, // OKR goal score wired in Phase 6 FinalPercentage = finalPercentage, TenantId = login.ClientId, ShouldCreatePIP = shouldCreatePIP, ManagerId = login.UserId, PIPEndDate = DateTime.UtcNow.AddDays(90), PIPReason = $"Performance below threshold ({finalPercentage:F1}%)", TNAItems = tnaItems }; await _finalizeDal.FinalizeAppraisalTransactionAsync(finalizeData, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.FINALIZEPERMAPPRAISALEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Appraisal Finalized", new { appraisalId, appraisal.EmployeeId, finalPercentage, GradeLetter = grade?.GradeLetter }, EventTypeConstant.FINALIZEPERMAPPRAISALEVENTTYPEID, appraisalId, login, ct: ct).ConfigureAwait(false); // Dapr — notify Analytics and CMGM (non-fatal: failures must not abort finalization) try { var scorePayload = new { AppraisalId = appraisalId, EmployeeId = appraisal.EmployeeId, OUId = appraisal.OUID, FinalPercentage = finalPercentage, GradeLetter = grade?.GradeLetter, PerformanceFrom = plan.PerformanceFrom, PerformanceTo = plan.PerformanceTo, TenantId = login.ClientId, DatabaseName = login.DatabaseName, ClientId = login.ClientId, UserId = login.UserId }; await _daprClient.PublishEventAsync("pubsub", "perm.score.finalized", scorePayload, ct).ConfigureAwait(false); await _daprClient.PublishEventAsync("pubsub", "perm.appraisal.finalized", scorePayload, ct).ConfigureAwait(false); } catch (Exception daprEx) { GB5Trace.MarkFailed("finalize-dapr-publish-failed", daprEx); _logger.LogWarning(daprEx, "Dapr publish for AppraisalId {AppraisalId} failed — finalization remains complete", appraisalId); } // PayRevision trigger — published via Dapr so PayRoll subscribes async. // Marked triggered before publish so a retry endpoint can skip duplicates. if (appraisal.PercentIncrease > 0 && !appraisal.PayRevisionTriggered) { try { GB5Trace.Step("payrevision-trigger", new { appraisalId, appraisal.PercentIncrease }); await _appraisalDal.MarkPayRevisionTriggeredAsync(appraisalId, login, ct).ConfigureAwait(false); await _eventLog.PublishEventLogAsync( "Pay Revision Triggered from Appraisal", new { appraisalId, appraisal.EmployeeId, appraisal.PercentIncrease, appraisal.AmountIncrease, appraisal.ToDesignationId, appraisal.ToGradeId }, EventTypeConstant.PAYREVISIONPERMTRIGGEREDEVENTTYPEID, appraisalId, login, "perm-payrevision-triggered", ct).ConfigureAwait(false); } catch (Exception payEx) { // Pay revision failure must not roll back the finalization — log and continue GB5Trace.MarkFailed("payrevision-trigger-failed", payEx); _logger.LogError(payEx, "PayRevision trigger failed for AppraisalId {AppraisalId} — finalization remains complete", appraisalId); } } return GB5Shared.Resource.Response.SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("finalize-appraisal-failed", ex); _logger.LogError(ex, "FinalizeAppraisal failed for AppraisalId {AppraisalId}", appraisalId); throw; } } // Pure in-memory weighted scoring. // KRAGoalScore is 0 until OKR module is wired in Phase 6. private static decimal CalculateEvalScore( AppraisalEvalDTO eval, ILookup kraWeights) { decimal total = 0m; decimal totalKRAWeight = kraWeights.Sum(g => g.First().KRAMeasureWeightage + g.First().KRAGoalWeightage); if (totalKRAWeight == 0m) return 0m; foreach (var kraGroup in kraWeights) { int kraId = kraGroup.Key; var kraEval = eval.KRAs?.FirstOrDefault(k => k.KRAId == kraId); if (kraEval is null) continue; decimal kraWeight = kraGroup.First().KRAMeasureWeightage + kraGroup.First().KRAGoalWeightage; decimal totalMeasureWeight = kraGroup.Sum(w => w.MeasureWeightage); decimal measureScore = 0m; if (totalMeasureWeight > 0m) { foreach (var mw in kraGroup) { var detail = kraEval.Details?.FirstOrDefault(d => d.MeasureId == mw.MeasureId); if (detail is not null) measureScore += detail.Score * (mw.MeasureWeightage / totalMeasureWeight); } } total += measureScore * (kraWeight / totalKRAWeight); } return Math.Round(total, 4); } }