using System; using System.ComponentModel.DataAnnotations; using System.Threading; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; 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 RecruitmentBLL.Application; using RecruitmentBLL.BgVerification; using RecruitmentBLL.Candidate; using RecruitmentBLL.Integration; using RecruitmentBLL.JobRequisition; using RecruitmentDAL.CustomeCode.JobOffer; using RecruitmentDAL.DTO.Application; using RecruitmentDAL.DTO.BgVerification; using RecruitmentDAL.DTO.Candidate; using RecruitmentDAL.DTO.JobOffer; using RecruitmentDAL.DTO.JobRequisition; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.JobOffer { public class JobOfferBLL : IJobOfferBLL { // Loose match against a recruiter's TGOAL.KRAName/Name — see // IOkrIntegrationService.FindGoalForRecruiterAsync's doc comment. private const string OKR_KRA_HINT_OFFER_ACCEPTANCE = "Offer Acceptance"; private readonly IJobOfferDAL _JobOfferDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly BaseEntityAppService _BaseEntityAppService; private readonly KeyInvalidate _KeyInvalidate; private readonly EventLogPublish _EventLog; private readonly IApplicationBLL _ApplicationBLL; private readonly ICandidateBLL _CandidateBLL; private readonly IJobRequisitionBLL _JobRequisitionBLL; private readonly IEcpIntegrationService _EcpIntegrationService; private readonly IOkrIntegrationService _OkrIntegrationService; private readonly IBgVerificationBLL _BgVerificationBLL; private readonly ILogger _Logger; public JobOfferBLL( IJobOfferDAL jobOfferDAL, AutoNumber autoNumber, IValidation validation, BaseEntityAppService baseEntityAppService, KeyInvalidate keyInvalidate, EventLogPublish eventLog, IApplicationBLL applicationBLL, ICandidateBLL candidateBLL, IJobRequisitionBLL jobRequisitionBLL, IEcpIntegrationService ecpIntegrationService, IOkrIntegrationService okrIntegrationService, IBgVerificationBLL bgVerificationBLL, ILogger logger) { _JobOfferDAL = jobOfferDAL; _AutoNumber = autoNumber; _Validation = validation; _BaseEntityAppService = baseEntityAppService; _KeyInvalidate = keyInvalidate; _EventLog = eventLog; _ApplicationBLL = applicationBLL; _CandidateBLL = candidateBLL; _JobRequisitionBLL = jobRequisitionBLL; _EcpIntegrationService = ecpIntegrationService; _OkrIntegrationService = okrIntegrationService; _BgVerificationBLL = bgVerificationBLL; _Logger = logger; } public async Task GetJobOffer(int JobOfferId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("get-joboffer", new { JobOfferId }); var result = await _JobOfferDAL.GetJobOffer(JobOfferId, LoginDTO, ct).ConfigureAwait(false); _Logger.LogInformation("JobOffer {JobOfferId} fetched by user {UserId}", JobOfferId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("get-joboffer-failed", ex); _Logger.LogError(ex, "GetJobOffer failed for JobOfferId {JobOfferId}", JobOfferId); throw; } } public async Task SaveJobOffer(JobOfferDTO JobOfferDTO, LoginDTO LoginDTO, CancellationToken ct) { if (JobOfferDTO == null) throw new ArgumentNullException(nameof(JobOfferDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-joboffer", new { JobOfferDTO.JobOfferId }); await _Validation .Range(0m, decimal.MaxValue, JobOfferDTO.CTC, nameof(JobOfferDTO.CTC)) .ConfigureAwait(false); JobOfferDTO.ModifiedById = LoginDTO.UserId; JobOfferDTO.ModifiedOn = DateTime.UtcNow; bool isNew = JobOfferDTO.JobOfferId == 0; if (isNew) { // Entity-code casing MUST match the uppercase MAUTONUMBER seed row // ("JOBOFFER") — Postgres AutoNumber lookups are case-sensitive, so the // previously-commented "JOBOffer" mixed-case literal would silently fail // to resolve a row on that provider. autoNumberDTO = await _AutoNumber .GetNumberAsync(1, "JOBOFFER", LoginDTO) .ConfigureAwait(false); JobOfferDTO.JobOfferId = autoNumberDTO.StartNumber; JobOfferDTO.CreatedById = LoginDTO.UserId; JobOfferDTO.CreatedOn = DateTime.UtcNow; } int eventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTJOBOFFEREVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTJOBOFFEREVENTTYPEID; GB5Trace.Step("save-joboffer", new { JobOfferDTO.JobOfferId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( JobOfferDTO.JobOfferId, eventTypeId, JobOfferDTO, LoginDTO, persistFunc: tx => isNew ? _JobOfferDAL.SaveJobOffer(JobOfferDTO, LoginDTO, tx, ct) : _JobOfferDAL.UpdateJobOffer(JobOfferDTO, LoginDTO, tx, ct), isNewEntity: isNew).ConfigureAwait(false); // ExecuteSaveAsync already performs the Dapr direct-publish + outbox write for // this eventTypeId internally (see BaseEntityAppService.ExecuteSaveAsync) — this // step just marks the point on the trace where that publish happened, it does not // duplicate it with a second EventLogPublish call. GB5Trace.Step("event-publish", new { EventTypeId = eventTypeId }); var cacheKey = new CacheKeyGeneration().KeyGeneration( JobOfferDTO.JobOfferId, EntityConstant.OBJECTRECRUITMENTJOBOFFER, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); // ── ECP integration: send the offer letter via Correspondence ───────────── // New offers only (per the plan's Phase 3 scope) — deliberately AFTER the offer // save has already committed and fully non-blocking: an ECP outage (or a // not-yet-configured CorrespondenceTemplate) must never fail a JobOffer save. See // IEcpIntegrationService's class doc comment for the graceful-degradation contract. if (isNew) { try { GB5Trace.Step("ecp-send-offer-letter", new { JobOfferDTO.JobOfferId, JobOfferDTO.ApplicationId }); var recipientEmail = await ResolveApplicantEmailAsync(JobOfferDTO.ApplicationId, LoginDTO, ct).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(recipientEmail)) { var sendOutcome = await _EcpIntegrationService .SendOfferLetterAsync(JobOfferDTO, recipientEmail, LoginDTO, ct) .ConfigureAwait(false); if (!sendOutcome.Success) _Logger.LogWarning( "ECP SendOfferLetterAsync did not succeed for JobOfferId {JobOfferId}: {Error}", JobOfferDTO.JobOfferId, sendOutcome.Error); } else { _Logger.LogWarning( "Offer letter not sent for JobOfferId {JobOfferId} — no applicant email resolved for ApplicationId {ApplicationId}", JobOfferDTO.JobOfferId, JobOfferDTO.ApplicationId); } } catch (Exception ex) { // Belt-and-braces: EcpIntegrationService already catches/degrades // internally, but the offer save must survive regardless. GB5Trace.MarkFailed("ecp-send-offer-letter-failed", ex); _Logger.LogWarning(ex, "ECP offer-letter send failed for JobOfferId {JobOfferId}", JobOfferDTO.JobOfferId); } } _Logger.LogInformation("JobOffer {JobOfferId} saved by user {UserId}", JobOfferDTO.JobOfferId, LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {JobOfferDTO.JobOfferId}" : $"{SuccessResponse.UpdateSuccessMessage} {JobOfferDTO.JobOfferId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-joboffer-failed", vex); _Logger.LogError(vex, "SaveJobOffer validation failed for JobOfferId {JobOfferId}", JobOfferDTO.JobOfferId); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-joboffer-failed", ex); _Logger.LogError(ex, "SaveJobOffer failed for JobOfferId {JobOfferId}", JobOfferDTO.JobOfferId); if (autoNumberDTO != null && JobOfferDTO.JobOfferId > 0) await _AutoNumber.RollbackAutoNumber("JOBOFFER", JobOfferDTO.JobOfferId, LoginDTO).ConfigureAwait(false); throw; } } public async Task GetSelectListJobOffer(CriteriaDTO criteriaDTO, LoginDTO LoginDTO, CancellationToken ct) { try { return await _JobOfferDAL.GetSelectListJobOffer(criteriaDTO, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-joboffer-failed", ex); _Logger.LogError(ex, "GetSelectListJobOffer failed"); throw; } } public async Task DeleteJobOffer(int JobOfferId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-joboffer", new { JobOfferId }); var result = await _JobOfferDAL.DeleteJobOffer(JobOfferId, LoginDTO, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.DELETERECRUITMENTJOBOFFEREVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Job Offer Deleted", new { JobOfferId }, EventTypeConstant.DELETERECRUITMENTJOBOFFEREVENTTYPEID, JobOfferId, LoginDTO, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( JobOfferId, EntityConstant.OBJECTRECRUITMENTJOBOFFER, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("JobOffer {JobOfferId} deleted by user {UserId}", JobOfferId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-joboffer-failed", ex); _Logger.LogError(ex, "DeleteJobOffer failed for JobOfferId {JobOfferId}", JobOfferId); throw; } } // Records the candidate's Accept/Decline response to an existing offer. Deliberately a // narrow, standalone follow-up update (JobOfferQB.UPDATE_JOBOFFER_RESPONSE) rather than a // full ExecuteSaveAsync round-trip through SaveJobOffer — same non-transactional-follow-up // pattern as IApplicationDAL.UpdateApplicationCurrentStage / IInterviewDAL // .UpdateInterviewEcpMeetingId. Called directly by the FLS-bridge offer-accept/decline flow. public async Task RecordOfferResponseAsync(int JobOfferId, byte OfferResponseStatus, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("validate-joboffer-response", new { JobOfferId, OfferResponseStatus }); if (OfferResponseStatus != OFFERRESPONSESTATUS_ACCEPTED && OfferResponseStatus != OFFERRESPONSESTATUS_DECLINED) { throw new ValidationException( $"OfferResponseStatus must be 1 (Accepted) or 2 (Declined). Received: {OfferResponseStatus}."); } var offerResponseDate = DateTime.UtcNow; GB5Trace.Step("save-joboffer-response", new { JobOfferId, OfferResponseStatus }); await _JobOfferDAL .UpdateJobOfferResponse(JobOfferId, OfferResponseStatus, offerResponseDate, LoginDTO, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.UPDATERECRUITMENTJOBOFFEREVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Job Offer Response Recorded", new { JobOfferId, OfferResponseStatus }, EventTypeConstant.UPDATERECRUITMENTJOBOFFEREVENTTYPEID, JobOfferId, LoginDTO, ct: ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( JobOfferId, EntityConstant.OBJECTRECRUITMENTJOBOFFER, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "JobOffer {JobOfferId} response recorded as {OfferResponseStatus} by user {UserId}", JobOfferId, OfferResponseStatus, LoginDTO.UserId); // ── OKR recruiter-KPI push: "offer acceptance" ──────────────────────────── // Deliberately AFTER the response has already committed and fully non-blocking — // same graceful-degradation contract as the ECP offer-letter send above. See // IOkrIntegrationService's class doc comment. try { await PushOfferAcceptanceKpiAsync(JobOfferId, OfferResponseStatus, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("okr-offer-acceptance-kpi-failed", ex); _Logger.LogWarning(ex, "OKR offer-acceptance KPI push failed for JobOfferId {JobOfferId}", JobOfferId); } // ── Phase 6: BG verification — triggered at offer stage ─────────────────── // Deliberately AFTER the response has already committed and fully non-blocking, // same graceful-degradation contract as the OKR push above — a BG-verification // initiation failure must never undo/fail an already-recorded offer acceptance. // Only fires on Accepted (not Decline) per the plan's "triggered at offer stage" // (i.e. offer-acceptance) intent. if (OfferResponseStatus == OFFERRESPONSESTATUS_ACCEPTED) { try { await TriggerBgVerificationOnAcceptanceAsync(JobOfferId, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("bg-verification-initiate-on-offer-accept-failed", ex); _Logger.LogWarning(ex, "BG verification initiation failed for JobOfferId {JobOfferId}", JobOfferId); } } return $"{SuccessResponse.UpdateSuccessMessage} {JobOfferId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-joboffer-response-failed", vex); _Logger.LogError(vex, "RecordOfferResponseAsync validation failed for JobOfferId {JobOfferId}", JobOfferId); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-joboffer-response-failed", ex); _Logger.LogError(ex, "RecordOfferResponseAsync failed for JobOfferId {JobOfferId}", JobOfferId); throw; } } // OFFERRESPONSESTATUS (MJOBOFFER): 0=Pending 1=Accepted 2=Declined 3=Expired private const byte OFFERRESPONSESTATUS_ACCEPTED = 1; private const byte OFFERRESPONSESTATUS_DECLINED = 2; // Resolves JobOfferDTO.ApplicationId -> ApplicationDTO.CandidateId -> CandidateDTO.Email // via the existing Application/Candidate BLLs (BLL-to-BLL, same host — mirrors // ApplicationBLL.GetRequiredQuizForStage's direct-TMS-BLL-call pattern). GetApplication/ // GetCandidate both return a JsonConvert.SerializeObject(...) string per their own DAL // implementations, so deserialize with Newtonsoft to match. Returns null (not throws) on // any missing link — the caller treats that as "email not resolved" and skips the send. private async Task ResolveApplicantEmailAsync(int applicationId, LoginDTO login, CancellationToken ct) { if (applicationId <= 0) return null; var applicationJson = await _ApplicationBLL.GetApplication(applicationId, login, ct).ConfigureAwait(false); var application = JsonConvert.DeserializeObject(applicationJson); if (application is null || application.CandidateId <= 0) return null; var candidateJson = await _CandidateBLL.GetCandidate(application.CandidateId, login, ct).ConfigureAwait(false); var candidate = JsonConvert.DeserializeObject(candidateJson); return candidate?.Email; } // ── Phase 6: BG verification — "triggered at offer stage" ─────────────────────────── // // Resolves JobOfferId -> ApplicationId (JobOfferDTO already carries it directly — no // extra join needed, unlike ResolveApplicantEmailAsync's Application->Candidate chain // above) and opens the standard set of concurrent BG checks (Education, Employment, // Criminal, Reference) via IBgVerificationBLL.InitiateBgVerification. Education/ // Employment/Criminal are best-effort routed through the vendor placeholder inside that // method; Reference is left Pending for a recruiter to dispatch via the existing FLS // bridge once a referee is identified — see BgVerificationBLL's own doc comments. private async Task TriggerBgVerificationOnAcceptanceAsync(int jobOfferId, LoginDTO login, CancellationToken ct) { var jobOfferJson = await GetJobOffer(jobOfferId, login, ct).ConfigureAwait(false); var jobOffer = JsonConvert.DeserializeObject(jobOfferJson); if (jobOffer is null || jobOffer.ApplicationId <= 0) { _Logger.LogWarning( "TriggerBgVerificationOnAcceptanceAsync: JobOfferId {JobOfferId} has no resolvable ApplicationId — skipping BG verification", jobOfferId); return; } await _BgVerificationBLL.InitiateBgVerification( new InitiateBgVerificationDTO { ApplicationId = jobOffer.ApplicationId }, login, ct).ConfigureAwait(false); } // ── OKR recruiter-KPI push: "offer acceptance" ────────────────────────────────────── // // Feeds Phase 5 of the Recruitment build-out plan: recruiter/team KPIs live in the same // PERM/OKR KRA framework as every other role. Resolves JobOfferId -> ApplicationId -> // JobRequisitionId -> AssignedRecruiterId (same chain ResolveApplicantEmailAsync above // already walks for JobOffer -> Application), then pushes an accepted/declined signal for // that recruiter's Goal. See IOkrIntegrationService for the full graceful-degradation // contract this composes with. private async Task PushOfferAcceptanceKpiAsync(int jobOfferId, byte offerResponseStatus, LoginDTO login, CancellationToken ct) { var offerJson = await _JobOfferDAL.GetJobOffer(jobOfferId, login, ct).ConfigureAwait(false); var offer = JsonConvert.DeserializeObject(offerJson ?? string.Empty); if (offer is null || offer.ApplicationId <= 0) { _Logger.LogInformation( "OKR offer-acceptance KPI push skipped for JobOfferId {JobOfferId} — ApplicationId not resolved.", jobOfferId); return; } var applicationJson = await _ApplicationBLL.GetApplication(offer.ApplicationId, login, ct).ConfigureAwait(false); var application = JsonConvert.DeserializeObject(applicationJson ?? string.Empty); if (application is null || application.JobRequisitionId <= 0) { _Logger.LogInformation( "OKR offer-acceptance KPI push skipped for JobOfferId {JobOfferId} — JobRequisitionId not resolved.", jobOfferId); return; } var jobRequisitionJson = await _JobRequisitionBLL.GetJobRequisition(application.JobRequisitionId, login, ct).ConfigureAwait(false); var jobRequisition = JsonConvert.DeserializeObject(jobRequisitionJson ?? string.Empty); var recruiterUserId = RecruiterKpiHelper.ResolveAssignedRecruiterId(jobRequisition); if (recruiterUserId <= 0) { _Logger.LogInformation( "OKR offer-acceptance KPI push skipped for JobOfferId {JobOfferId} — no AssignedRecruiterId set on the " + "underlying JobRequisition. Expected for requisitions with no recruiter assigned yet, not a warning.", jobOfferId); return; } GB5Trace.Step("okr-offer-acceptance-kpi", new { jobOfferId, recruiterUserId, offerResponseStatus }); var goal = await _OkrIntegrationService .FindGoalForRecruiterAsync(recruiterUserId, OKR_KRA_HINT_OFFER_ACCEPTANCE, login, ct) .ConfigureAwait(false); if (goal is null) return; // FindGoalForRecruiterAsync already logged the reason (Info-level, expected) var accepted = offerResponseStatus == OFFERRESPONSESTATUS_ACCEPTED; // Accepted offers increment the running count (and progress toward TargetValue, when // set); a Decline is still recorded as a GoalTrack audit entry via Remarks, but never // moves CurrentValue/Progress upward — it is a "signal" the recruiter/manager can see // in the goal's history, not a KPI improvement. var newValue = accepted ? goal.CurrentValue + 1 : goal.CurrentValue; var newProgress = accepted && goal.TargetValue > 0 ? Math.Min(100m, newValue / goal.TargetValue * 100m) : goal.CurrentProgress; var outcome = await _OkrIntegrationService .PushGoalProgressAsync( goal.GoalId, newValue, newProgress, $"JobOffer {jobOfferId} {(accepted ? "accepted" : "declined")} by candidate", login, ct) .ConfigureAwait(false); if (!outcome.Success) _Logger.LogWarning( "OKR PushGoalProgressAsync did not succeed for GoalId {GoalId} (JobOfferId {JobOfferId}): {Error}", goal.GoalId, jobOfferId, outcome.Error); } } }