using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using RecruitmentDAL.DTO.Referral; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using PayRollBLL.Employee; using PayRollDAL.DTO.Employee; using RecruitmentBLL.Candidate; using RecruitmentBLL.JobRequisition; using RecruitmentDAL.CustomeCode.Referral; using RecruitmentDAL.DTO.Candidate; using RecruitmentDAL.DTO.JobRequisition; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.Referral { // Entity CRUD for TREFERRAL — Recruitment Phase 6 "Employee referrals" (see // /Users/venkatv/.claude/plans/can-you-check-the-precious-sunbeam.md, Phase 6). Single-entity // save pipeline (ExecuteSaveAsync, GB5Trace, ILogger, i18n, tenant filtering), same shape as // BgVerificationBLL. Injects PayRollBLL.Employee.IEmployeeBLL directly — RecruitmentSL and // PayRollSL are both HostedBy HRFinanceHost (already established and verified by // CandidateConversionBLL / RecruitmentBLL.csproj's own ProjectReference comment), so this is // same-process, same-host DI, not a cross-host HTTP call. // // Scope note: bonus PAYOUT itself (actually paying the referring employee) is a separate, // manual admin action (UpdateReferralBonusStatus) with no payroll integration — out of scope // for this pass, per the Phase 6 plan. public class ReferralBLL : IReferralBLL { private readonly IReferralDAL _ReferralDAL; private readonly IEmployeeBLL _EmployeeBLL; private readonly ICandidateBLL _CandidateBLL; private readonly IJobRequisitionBLL _JobRequisitionBLL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly EventLogPublish _EventLog; private readonly ILogger _Logger; public ReferralBLL( IReferralDAL referralDAL, IEmployeeBLL employeeBLL, ICandidateBLL candidateBLL, IJobRequisitionBLL jobRequisitionBLL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, EventLogPublish eventLog, ILogger logger) { _ReferralDAL = referralDAL; _EmployeeBLL = employeeBLL; _CandidateBLL = candidateBLL; _JobRequisitionBLL = jobRequisitionBLL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _EventLog = eventLog; _Logger = logger; } #region Get public async Task GetReferral(int ReferralId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ReferralDAL.GetReferral(ReferralId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-referral-failed", ex); _Logger.LogError(ex, "GetReferral failed for ReferralId {ReferralId}", ReferralId); throw; } } #endregion #region Select List public async Task GetSelectListReferral(int ReferringEmployeeId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _ReferralDAL.GetSelectListReferral(ReferringEmployeeId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-referral-failed", ex); _Logger.LogError(ex, "GetSelectListReferral failed for ReferringEmployeeId {ReferringEmployeeId}", ReferringEmployeeId); throw; } } #endregion #region Save public async Task SaveReferral(ReferralDTO ReferralDTO, LoginDTO LoginDTO, CancellationToken ct) { if (ReferralDTO == null) throw new ArgumentNullException(nameof(ReferralDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-referral", new { ReferralDTO.ReferralId }); if (ReferralDTO.ReferringEmployeeId <= 0) throw new ValidationException($"{nameof(ReferralDTO.ReferringEmployeeId)} is required."); if (ReferralDTO.CandidateId <= 0) throw new ValidationException($"{nameof(ReferralDTO.CandidateId)} is required."); if (ReferralDTO.BonusStatus < ReferralBonusStatus.Pending || ReferralDTO.BonusStatus > ReferralBonusStatus.Forfeited) throw new ValidationException($"BonusStatus must be 1-4. Received: {ReferralDTO.BonusStatus}."); // ── Confirm the referring employee genuinely exists (read-only, same-host DI — // see class doc comment) ── var employeeJson = await _EmployeeBLL.GetEmployee(ReferralDTO.ReferringEmployeeId, string.Empty, LoginDTO) .ConfigureAwait(false); var employee = string.IsNullOrWhiteSpace(employeeJson) ? null : JsonConvert.DeserializeObject(employeeJson); if (employee is null || employee.EmployeeId <= 0) throw new ValidationException($"Employee {ReferralDTO.ReferringEmployeeId} was not found."); // ── Confirm the Candidate genuinely exists — CandidateId is never created here, // see ReferralDTO's class doc comment ── var candidateJson = await _CandidateBLL.GetCandidate(ReferralDTO.CandidateId, LoginDTO, ct) .ConfigureAwait(false); var candidate = string.IsNullOrWhiteSpace(candidateJson) ? null : JsonConvert.DeserializeObject(candidateJson); if (candidate is null || candidate.CandidateId <= 0) throw new ValidationException($"Candidate {ReferralDTO.CandidateId} was not found."); // ── JobRequisitionId is optional (-1 sentinel = general referral, no specific // requisition targeted yet) — only validated when actually supplied ── if (ReferralDTO.JobRequisitionId != -1) { var jobRequisitionJson = await _JobRequisitionBLL .GetJobRequisition(ReferralDTO.JobRequisitionId, LoginDTO, ct) .ConfigureAwait(false); var jobRequisition = string.IsNullOrWhiteSpace(jobRequisitionJson) ? null : JsonConvert.DeserializeObject(jobRequisitionJson); if (jobRequisition is null || jobRequisition.JobRequisitionId <= 0) throw new ValidationException($"JobRequisition {ReferralDTO.JobRequisitionId} was not found."); } ReferralDTO.TenantId = LoginDTO.ClientId; ReferralDTO.ModifiedById = LoginDTO.UserId; ReferralDTO.ModifiedOn = DateTime.UtcNow; bool isNew = ReferralDTO.ReferralId == 0; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "REFERRAL", LoginDTO).ConfigureAwait(false); ReferralDTO.ReferralId = autoNumberDTO.StartNumber; ReferralDTO.CreatedById = LoginDTO.UserId; ReferralDTO.CreatedOn = DateTime.UtcNow; if (ReferralDTO.ReferralDate == default) ReferralDTO.ReferralDate = DateTime.UtcNow; } GB5Trace.Step("save-referral", new { ReferralDTO.ReferralId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTREFERRAL, isNew ? EventTypeConstant.SAVERECRUITMENTREFERRALEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTREFERRALEVENTTYPEID, ReferralDTO, LoginDTO, async tx => { _ = isNew ? await _ReferralDAL.SaveReferral(ReferralDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _ReferralDAL.UpdateReferral(ReferralDTO, LoginDTO, tx, ct).ConfigureAwait(false); return ReferralDTO.ReferralId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTREFERRALEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTREFERRALEVENTTYPEID }); await _EventLog.PublishEventLogAsync( isNew ? $"Referral {ReferralDTO.ReferralId} submitted by Employee {ReferralDTO.ReferringEmployeeId} for Candidate {ReferralDTO.CandidateId}" : $"Referral {ReferralDTO.ReferralId} updated", new { ReferralDTO.ReferralId, ReferralDTO.ReferringEmployeeId, ReferralDTO.CandidateId, ReferralDTO.BonusStatus }, isNew ? EventTypeConstant.SAVERECRUITMENTREFERRALEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTREFERRALEVENTTYPEID, ReferralDTO.ReferralId, LoginDTO, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( ReferralDTO.ReferralId, EntityConstant.OBJECTRECRUITMENTREFERRAL, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "Referral {ReferralId} {Action} by user {UserId}", ReferralDTO.ReferralId, isNew ? "saved" : "updated", LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {ReferralDTO.ReferralId}" : SuccessResponse.UpdateSuccess; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-referral-failed", vex); if (autoNumberDTO != null && ReferralDTO.ReferralId > 0) await _AutoNumber.RollbackAutoNumber("REFERRAL", ReferralDTO.ReferralId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-referral-failed", ex); _Logger.LogError(ex, "SaveReferral failed for ReferralId {ReferralId}", ReferralDTO.ReferralId); if (autoNumberDTO != null && ReferralDTO.ReferralId > 0) { await _AutoNumber.RollbackAutoNumber( "REFERRAL", ReferralDTO.ReferralId, LoginDTO).ConfigureAwait(false); } throw; } } #endregion #region Delete public async Task DeleteReferral(int ReferralId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-referral", new { ReferralId }); var result = await _ReferralDAL.DeleteReferral(ReferralId, LoginDTO, ct) .ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( ReferralId, EntityConstant.OBJECTRECRUITMENTREFERRAL, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); await _EventLog.PublishEventLogAsync( $"Referral {ReferralId} deleted", new { ReferralId }, EventTypeConstant.DELETERECRUITMENTREFERRALEVENTTYPEID, ReferralId, LoginDTO, ct: ct).ConfigureAwait(false); _Logger.LogInformation("Referral {ReferralId} deleted by user {UserId}", ReferralId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-referral-failed", ex); _Logger.LogError(ex, "DeleteReferral failed for ReferralId {ReferralId}", ReferralId); throw; } } #endregion #region Bonus Status // Manual admin action — see IReferralBLL's doc comment. Re-loads the existing row and // routes back through SaveReferral so the transition still goes through the full // ExecuteSaveAsync pipeline (event publish, cache invalidation, audit trail) rather than a // narrow direct UPDATE. public async Task UpdateReferralBonusStatus(UpdateReferralBonusStatusDTO UpdateReferralBonusStatusDTO, LoginDTO LoginDTO, CancellationToken ct) { if (UpdateReferralBonusStatusDTO == null) throw new ArgumentNullException(nameof(UpdateReferralBonusStatusDTO)); try { GB5Trace.Step("validate-update-referral-bonusstatus", new { UpdateReferralBonusStatusDTO.ReferralId }); if (UpdateReferralBonusStatusDTO.ReferralId <= 0) throw new ValidationException($"{nameof(UpdateReferralBonusStatusDTO.ReferralId)} is required."); if (UpdateReferralBonusStatusDTO.BonusStatus < ReferralBonusStatus.Pending || UpdateReferralBonusStatusDTO.BonusStatus > ReferralBonusStatus.Forfeited) throw new ValidationException($"BonusStatus must be 1-4. Received: {UpdateReferralBonusStatusDTO.BonusStatus}."); var json = await GetReferral(UpdateReferralBonusStatusDTO.ReferralId, LoginDTO, ct).ConfigureAwait(false); var existing = string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); if (existing is null || existing.ReferralId <= 0) throw new ValidationException($"Referral {UpdateReferralBonusStatusDTO.ReferralId} was not found."); existing.BonusStatus = UpdateReferralBonusStatusDTO.BonusStatus; if (UpdateReferralBonusStatusDTO.BonusAmount.HasValue) existing.BonusAmount = UpdateReferralBonusStatusDTO.BonusAmount.Value; if (UpdateReferralBonusStatusDTO.BonusStatus == ReferralBonusStatus.Paid) existing.PayoutDate = DateTime.UtcNow; await SaveReferral(existing, LoginDTO, ct).ConfigureAwait(false); _Logger.LogInformation( "Referral {ReferralId} BonusStatus set to {BonusStatus} by user {UserId}", existing.ReferralId, existing.BonusStatus, LoginDTO.UserId); return SuccessResponse.UpdateSuccess; } catch (ValidationException vex) { GB5Trace.MarkFailed("update-referral-bonusstatus-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("update-referral-bonusstatus-failed", ex); _Logger.LogError(ex, "UpdateReferralBonusStatus failed for ReferralId {ReferralId}", UpdateReferralBonusStatusDTO.ReferralId); throw; } } #endregion #region Hired Hook // The Recruitment Phase 6 "genuine Hired" hook — see IReferralBLL's doc comment for the // caller (ApplicationBLL.SaveApplicationStageHistory) and idempotency contract. Never // throws on "no referral to update" — that is the expected, common case (most hires are // not referrals). public async Task MarkEligibleOnHireAsync(int CandidateId, LoginDTO LoginDTO, CancellationToken ct) { if (CandidateId <= 0) return; try { var referral = await _ReferralDAL.GetLatestPendingReferralByCandidate(CandidateId, LoginDTO, ct) .ConfigureAwait(false); if (referral is null || referral.ReferralId <= 0) { _Logger.LogInformation( "MarkEligibleOnHireAsync: no Pending Referral found for Candidate {CandidateId} — no-op (expected for a non-referred hire)", CandidateId); return; } GB5Trace.Step("referral-mark-eligible", new { referral.ReferralId, CandidateId }); referral.BonusStatus = ReferralBonusStatus.Eligible; await SaveReferral(referral, LoginDTO, ct).ConfigureAwait(false); _Logger.LogInformation( "Referral {ReferralId} marked Eligible — Candidate {CandidateId} reached a genuine Hired terminal stage", referral.ReferralId, CandidateId); } catch (Exception ex) { GB5Trace.MarkFailed("referral-mark-eligible-failed", ex); _Logger.LogError(ex, "MarkEligibleOnHireAsync failed for CandidateId {CandidateId}", CandidateId); throw; } } #endregion } }