using System; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using PayRollBLL.Employee; using PayRollDAL.DTO.Employee; using RecruitmentBLL.Application; using RecruitmentBLL.Candidate; using RecruitmentBLL.Integration; using RecruitmentBLL.JobRequisition; using RecruitmentBLL.SelectionProcess; using RecruitmentDAL.CustomeCode.Candidate; using RecruitmentDAL.DTO.Application; using RecruitmentDAL.DTO.Candidate; using RecruitmentDAL.DTO.CandidateConversion; using RecruitmentDAL.DTO.JobRequisition; using RecruitmentDAL.DTO.SelectionProcess; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.CandidateConversion { // The Candidate->Employee "final joining event" (Phase 3 of the Recruitment build-out plan, // see /Users/venkatv/.claude/plans/can-you-check-the-precious-sunbeam.md). Deliberately an // orchestrating BLL, not an entity-save BLL: it does not own a table of its own, so there is // no ExecuteSaveAsync call here — it composes three existing, independently-owned save/read // paths (Application/JobRequisition/SelectionProcess reads, PayRollBLL.Employee.SaveEmployee's // own transaction, and a narrow non-transactional Candidate.EmployeeId follow-up), the same // shape as JobOfferBLL's own ECP-integration composition. // // ── "Genuine Hired terminal stage" — a real, only-partially-closed schema gap ────────────── // MSELECTIONPROCESSSTAGE.STAGECATEGORY has value 7 = "Terminal" (see Phase 1's schema // migration comment), and ISTERMINAL is a separate bit flag — but neither one distinguishes a // *successful* terminal stage (Hired) from an *unsuccessful* one (Rejected, or a // PreBoarding-stage drop-off): a template author could name two different StageCategory=7/ // IsTerminal=1 rows "Hired" and "Rejected" and nothing in the schema tells them apart // structurally — StageName is free text. This was flagged as a possible gap by the prior // research pass, and verification here confirms it: no MEVENTTYPE/StageCategory value or // other column carries a "this terminal stage IS a hire" signal. // // Rather than guess via StageName string-matching (fragile — a template author could name a // stage anything), this BLL treats the ApplicationStageHistory's OUTCOME field (Phase 3's own // 20260903_Recruitment_Phase3_PostOfferTracking migration: 1=Pending 2=Passed 3=Failed // 4=Skipped 5=Delayed 6=DroppedOff) as the authoritative structural signal for "genuinely // hired": the current stage must be Terminal (StageCategory=7, IsTerminal=1) AND the most // recent stage-history row for that (Application, Stage) pair must have Outcome=Passed(2). // This is still not airtight (a template could technically log Outcome=Passed at a // Category=7 stage named "Rejected"), but it is the strongest signal the current schema // actually carries, and it fails safe: Pending/Failed/Skipped/Delayed/DroppedOff/missing // history all block the conversion rather than silently allow a wrong one through. A genuine // fix (e.g. a dedicated MSELECTIONPROCESSSTAGE.ISSUCCESSOUTCOME flag) is a schema change this // task was explicitly told not to make (no migrations to run against a live DB) — flagged // here for a future migration instead of guessed at. public class CandidateConversionBLL : ICandidateConversionBLL { private readonly ICandidateBLL _CandidateBLL; private readonly ICandidateDAL _CandidateDAL; private readonly IApplicationBLL _ApplicationBLL; private readonly IJobRequisitionBLL _JobRequisitionBLL; private readonly ISelectionProcessBLL _SelectionProcessBLL; private readonly IEcpIntegrationService _EcpIntegrationService; private readonly IOkrIntegrationService _OkrIntegrationService; private readonly IEmployeeBLL _EmployeeBLL; private readonly KeyInvalidate _KeyInvalidate; private readonly EventLogPublish _EventLog; private readonly ILogger _Logger; // MSELECTIONPROCESSSTAGE.STAGECATEGORY — see SelectionProcessStageDTO's doc comment. private const byte STAGECATEGORY_TERMINAL = 7; // TAPPLICATIONSTAGEHISTORY.OUTCOME — see ApplicationStageHistoryDTO's doc comment // (extended by 20260903_Recruitment_Phase3_PostOfferTracking_Schema_*.sql). private const byte OUTCOME_PASSED = 2; // Loose match against a recruiter's TGOAL.KRAName/Name — see // IOkrIntegrationService.FindGoalForRecruiterAsync's doc comment. private const string OKR_KRA_HINT_TIME_TO_FILL = "Time to Fill"; public CandidateConversionBLL( ICandidateBLL candidateBLL, ICandidateDAL candidateDAL, IApplicationBLL applicationBLL, IJobRequisitionBLL jobRequisitionBLL, ISelectionProcessBLL selectionProcessBLL, IEcpIntegrationService ecpIntegrationService, IOkrIntegrationService okrIntegrationService, IEmployeeBLL employeeBLL, KeyInvalidate keyInvalidate, EventLogPublish eventLog, ILogger logger) { _CandidateBLL = candidateBLL; _CandidateDAL = candidateDAL; _ApplicationBLL = applicationBLL; _JobRequisitionBLL = jobRequisitionBLL; _SelectionProcessBLL = selectionProcessBLL; _EcpIntegrationService = ecpIntegrationService; _OkrIntegrationService = okrIntegrationService; _EmployeeBLL = employeeBLL; _KeyInvalidate = keyInvalidate; _EventLog = eventLog; _Logger = logger; } public async Task ConvertCandidateToEmployeeAsync( CandidateConversionRequestDTO request, LoginDTO login, CancellationToken ct) { if (request == null) throw new ArgumentNullException(nameof(request)); try { GB5Trace.Step("validate-candidate-conversion", new { request.CandidateId, request.ApplicationId }); // ── HR-supplied field presence — GB5's own "-1 = unset" sentinel convention // (see CandidateConversionRequestDTO's doc comment for why these four are // genuinely FK-enforced, not just "nice to have") ── if (request.CandidateId <= 0) throw new ValidationException($"{nameof(request.CandidateId)} is required."); if (request.ApplicationId <= 0) throw new ValidationException($"{nameof(request.ApplicationId)} is required."); if (request.WorkOUId == -1) throw new ValidationException($"{nameof(request.WorkOUId)} is required."); if (request.DepartmentId == -1) throw new ValidationException($"{nameof(request.DepartmentId)} is required."); if (request.CostCenterId == -1) throw new ValidationException($"{nameof(request.CostCenterId)} is required."); if (request.DesignationId == -1) throw new ValidationException($"{nameof(request.DesignationId)} is required."); if (request.EmployeeCodeDefineId == -1) throw new ValidationException($"{nameof(request.EmployeeCodeDefineId)} is required."); if (request.DateOfJoining == null || request.DateOfJoining.Value == default) throw new ValidationException($"{nameof(request.DateOfJoining)} is required."); // ── Load the Candidate ──────────────────────────────────────────────────── var candidateJson = await _CandidateBLL.GetCandidate(request.CandidateId, login, ct).ConfigureAwait(false); var candidate = JsonConvert.DeserializeObject(candidateJson ?? string.Empty); if (candidate is null || candidate.CandidateId <= 0) throw new ValidationException($"Candidate {request.CandidateId} was not found."); // CandidateDTO.EmployeeId defaults to -1 ("not yet converted" — see that DTO's // doc comment). Any other value means a prior conversion already ran. if (candidate.EmployeeId != -1) throw new ValidationException( $"Candidate {request.CandidateId} has already been converted to Employee {candidate.EmployeeId}."); // ── Load the Application and confirm it belongs to this Candidate ──────── var applicationJson = await _ApplicationBLL.GetApplication(request.ApplicationId, login, ct).ConfigureAwait(false); var application = JsonConvert.DeserializeObject(applicationJson ?? string.Empty); if (application is null || application.ApplicationId <= 0) throw new ValidationException($"Application {request.ApplicationId} was not found."); if (application.CandidateId != request.CandidateId) throw new ValidationException( $"Application {request.ApplicationId} does not belong to Candidate {request.CandidateId}."); // ── Load the JobRequisition (needed for the stage-template lookup below and // for the requester-notification step at the end) ──────────────────────── var jobRequisitionJson = await _JobRequisitionBLL.GetJobRequisition(application.JobRequisitionId, login, ct).ConfigureAwait(false); var jobRequisition = JsonConvert.DeserializeObject(jobRequisitionJson ?? string.Empty); if (jobRequisition is null || jobRequisition.JobRequisitionId <= 0) throw new ValidationException($"JobRequisition {application.JobRequisitionId} was not found."); // ── Resolve the Application's current stage and confirm it is Terminal ────── var stagesJson = await _SelectionProcessBLL .GetSelectionProcessStage(jobRequisition.SelectionProcessTemplateId, login, ct) .ConfigureAwait(false); var stages = JsonConvert.DeserializeObject>(stagesJson ?? string.Empty) ?? new System.Collections.Generic.List(); var currentStage = stages.FirstOrDefault(s => s.SelectionProcessStageId == application.CurrentStageId); if (currentStage is null) throw new ValidationException( $"Application {request.ApplicationId}'s current stage {application.CurrentStageId} could not be resolved against its SelectionProcessTemplate."); if (currentStage.StageCategory != STAGECATEGORY_TERMINAL || !currentStage.IsTerminal) throw new ValidationException( $"Application {request.ApplicationId} is at stage '{currentStage.StageName}', which is not a Terminal stage — cannot convert to Employee."); // ── Terminal alone doesn't mean Hired (see class doc comment) — confirm via // the stage-history Outcome, the strongest structural signal this schema has. ── var historyJson = await _ApplicationBLL .GetSelectListApplicationStageHistory(new CriteriaDTO { Id = application.ApplicationId }, login, ct) .ConfigureAwait(false); var history = JsonConvert.DeserializeObject>(historyJson ?? string.Empty) ?? new System.Collections.Generic.List(); var currentStageHistory = history .Where(h => h.SelectionProcessStageId == currentStage.SelectionProcessStageId) .OrderByDescending(h => h.StageEnteredOn) .FirstOrDefault(); if (currentStageHistory is null) throw new ValidationException( $"No ApplicationStageHistory row exists for Application {request.ApplicationId}'s current terminal stage — " + "cannot confirm a Hired outcome. Record a stage-history entry with Outcome=Passed before converting."); if (currentStageHistory.Outcome != OUTCOME_PASSED) throw new ValidationException( $"Application {request.ApplicationId}'s terminal stage outcome is not Passed (Outcome={currentStageHistory.Outcome}) — " + "this looks like a Rejected/DroppedOff terminal stage, not a Hire. Conversion refused."); // ── Build the EmployeeDTO from the Candidate's own fields + HR-supplied input ── var employeeDto = new EmployeeDTO { EmployeeId = 0, // new EmployeeName = $"{candidate.FirstName} {candidate.LastName}".Trim(), EmployeePrimaryEMailId = candidate.Email, EmployeePrimaryMobile = candidate.Phone, WorkOUId = request.WorkOUId, DepartmentId = request.DepartmentId, CostCenterId = request.CostCenterId, DesignationId = request.DesignationId, EmployeeCodeDefineId = request.EmployeeCodeDefineId, EmployeeDateOfJoining = request.DateOfJoining!.Value }; GB5Trace.Step("save-employee-from-candidate", new { request.CandidateId, request.ApplicationId }); // languageId "" -> EmployeeBLL.SaveEmployee falls back to the system default // language for a brand-new employee (see that method's own null/whitespace // handling) — mirrors every other same-host caller of this API. await _EmployeeBLL.SaveEmployee(employeeDto, string.Empty, login).ConfigureAwait(false); if (employeeDto.EmployeeId <= 0) throw new Exception("PayRollBLL.Employee.IEmployeeBLL.SaveEmployee did not return a new EmployeeId."); // ── Stamp the new EmployeeId back onto the Candidate (non-transactional // follow-up — see CandidateQB.UPDATE_CANDIDATE_EMPLOYEEID doc comment) ── await _CandidateDAL.UpdateCandidateEmployeeId(candidate.CandidateId, employeeDto.EmployeeId, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.UPDATERECRUITMENTCANDIDATEEVENTTYPEID }); await _EventLog.PublishEventLogAsync( $"Candidate {candidate.CandidateId} converted to Employee {employeeDto.EmployeeId}", new { candidate.CandidateId, employeeDto.EmployeeId, request.ApplicationId }, EventTypeConstant.UPDATERECRUITMENTCANDIDATEEVENTTYPEID, candidate.CandidateId, login, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( candidate.CandidateId, EntityConstant.OBJECTRECRUITMENTCANDIDATE, CacheKeyLevel.CLIENT_LEVEL, login); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); // ── Notify the original JobRequisition requester — non-blocking, mirrors // JobOfferBLL's ECP offer-letter send: an ECP outage must never fail a // conversion that has already committed. IEcpIntegrationService. // NotifyRequesterOnHireAsync already degrades internally (see its class doc // comment); this try/catch is belt-and-braces only. ── try { GB5Trace.Step("ecp-notify-requester-on-hire", new { jobRequisition.JobRequisitionId }); var notifyOutcome = await _EcpIntegrationService .NotifyRequesterOnHireAsync(jobRequisition, employeeDto.EmployeeName, login, ct) .ConfigureAwait(false); if (!notifyOutcome.Success) _Logger.LogWarning( "ECP NotifyRequesterOnHireAsync did not succeed for JobRequisitionId {JobRequisitionId}: {Error}", jobRequisition.JobRequisitionId, notifyOutcome.Error); } catch (Exception ex) { GB5Trace.MarkFailed("ecp-notify-requester-on-hire-failed", ex); _Logger.LogWarning(ex, "ECP requester-on-hire notification failed for JobRequisitionId {JobRequisitionId}", jobRequisition.JobRequisitionId); } // ── OKR recruiter-KPI push: "time to fill" ──────────────────────────────── // Same non-blocking posture as the ECP notify block above — conversion has // already committed; an OKR outage must never fail it. See // IOkrIntegrationService's class doc comment. try { await PushTimeToFillKpiAsync(jobRequisition, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("okr-time-to-fill-kpi-failed", ex); _Logger.LogWarning(ex, "OKR time-to-fill KPI push failed for JobRequisitionId {JobRequisitionId}", jobRequisition.JobRequisitionId); } _Logger.LogInformation( "Candidate {CandidateId} converted to Employee {EmployeeId} (Application {ApplicationId}) by user {UserId}", candidate.CandidateId, employeeDto.EmployeeId, request.ApplicationId, login.UserId); return $"{SuccessResponse.SaveSuccessMessage} {employeeDto.EmployeeId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("candidate-conversion-failed", vex); _Logger.LogError(vex, "ConvertCandidateToEmployeeAsync validation failed for CandidateId {CandidateId}", request?.CandidateId); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("candidate-conversion-failed", ex); _Logger.LogError(ex, "ConvertCandidateToEmployeeAsync failed for CandidateId {CandidateId}", request?.CandidateId); throw; } } // ── OKR recruiter-KPI push: "time to fill" ────────────────────────────────────────── // // Feeds Phase 5 of the Recruitment build-out plan: recruiter/team KPIs live in the same // PERM/OKR KRA framework as every other role. Time-to-fill is measured from // JobRequisition.CreatedOn (the plan's own documented default, absent a Phase 5 // cycle-time report already defining this differently) to now — the moment the // Candidate->Employee conversion (the "final joining event") completes. private async Task PushTimeToFillKpiAsync(JobRequisitionDTO jobRequisition, LoginDTO login, CancellationToken ct) { var recruiterUserId = RecruiterKpiHelper.ResolveAssignedRecruiterId(jobRequisition); if (recruiterUserId <= 0) { _Logger.LogInformation( "OKR time-to-fill KPI push skipped for JobRequisitionId {JobRequisitionId} — no AssignedRecruiterId " + "set on this requisition. Expected for requisitions with no recruiter assigned yet, not a warning.", jobRequisition.JobRequisitionId); return; } var daysToFill = (decimal)Math.Max(0, (DateTime.UtcNow - jobRequisition.CreatedOn).TotalDays); GB5Trace.Step("okr-time-to-fill-kpi", new { jobRequisition.JobRequisitionId, recruiterUserId, daysToFill }); var goal = await _OkrIntegrationService .FindGoalForRecruiterAsync(recruiterUserId, OKR_KRA_HINT_TIME_TO_FILL, login, ct) .ConfigureAwait(false); if (goal is null) return; // FindGoalForRecruiterAsync already logged the reason (Info-level, expected) // Time-to-fill is a "lower is better" metric, unlike the count-style Goals in // JobRequisitionBLL/JobOfferBLL — TargetValue is read as the target number of days. // Progress is scaled as target/actual (capped at 100%): meeting or beating the target // days reads as 100%, taking longer scales progress down proportionally. When no // target is set, only CurrentValue moves and progress is left as-is. var newProgress = goal.TargetValue > 0 && daysToFill > 0 ? Math.Min(100m, goal.TargetValue / daysToFill * 100m) : goal.CurrentProgress; var outcome = await _OkrIntegrationService .PushGoalProgressAsync( goal.GoalId, daysToFill, newProgress, $"JobRequisition {jobRequisition.JobRequisitionId} filled in {daysToFill:0.#} day(s)", login, ct) .ConfigureAwait(false); if (!outcome.Success) _Logger.LogWarning( "OKR PushGoalProgressAsync did not succeed for GoalId {GoalId} (JobRequisitionId {JobRequisitionId}): {Error}", goal.GoalId, jobRequisition.JobRequisitionId, outcome.Error); } } }