using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using RecruitmentDAL.DTO.JobRequisition; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RecruitmentBLL.Integration; using RecruitmentDAL.CustomeCode.JobRequisition; using RecruitmentDAL.CustomeCode.ManpowerBudget; using RecruitmentDAL.DTO.ManpowerBudget; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.JobRequisition { public class JobRequisitionBLL : IJobRequisitionBLL { // REQUISITIONTYPE (TJOBREQUISITION, migration 20260901_Recruitment_Phase1_CoreATS_Schema): // 1=Replacement 2=Additional 3=AdHoc private const byte REQUISITIONTYPE_REPLACEMENT = 1; private const byte REQUISITIONTYPE_ADDITIONAL = 2; private const byte REQUISITIONTYPE_ADHOC = 3; // REQUISITIONSTATUS: 1=Draft 2=PendingApproval 3=Approved 4=Rejected 5=Published 6=OnHold 7=Closed private const byte STATUS_DRAFT = 1; private const byte STATUS_PENDINGAPPROVAL = 2; private const byte STATUS_APPROVED = 3; private const byte STATUS_REJECTED = 4; private const byte STATUS_CLOSED = 7; // Loose match against a recruiter's TGOAL.KRAName/Name — see // IOkrIntegrationService.FindGoalForRecruiterAsync's doc comment for why a substring // match is the best available contract (no fixed KRA-naming convention exists in // PERM/OKR admin setup). private const string OKR_KRA_HINT_REQUISITIONS_CLOSED = "Requisitions Closed"; private readonly IJobRequisitionDAL _JobRequisitionDAL; private readonly IManpowerBudgetDAL _ManpowerBudgetDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly IOkrIntegrationService _OkrIntegrationService; private readonly ILogger _Logger; public JobRequisitionBLL( IJobRequisitionDAL jobRequisitionDAL, IManpowerBudgetDAL manpowerBudgetDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, IOkrIntegrationService okrIntegrationService, ILogger logger) { _JobRequisitionDAL = jobRequisitionDAL; _ManpowerBudgetDAL = manpowerBudgetDAL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _OkrIntegrationService = okrIntegrationService; _Logger = logger; } public async Task GetJobRequisition(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _JobRequisitionDAL.GetJobRequisition(JobRequisitionId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-jobrequisition-failed", ex); _Logger.LogError(ex, "GetJobRequisition failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } public async Task SaveJobRequisition(JobRequisitionDTO JobRequisitionDTO, LoginDTO LoginDTO, CancellationToken ct) { if (JobRequisitionDTO == null) throw new ArgumentNullException(nameof(JobRequisitionDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-jobrequisition", new { JobRequisitionDTO.JobRequisitionId }); if (JobRequisitionDTO.RequisitionType != REQUISITIONTYPE_REPLACEMENT && JobRequisitionDTO.RequisitionType != REQUISITIONTYPE_ADDITIONAL && JobRequisitionDTO.RequisitionType != REQUISITIONTYPE_ADHOC) { throw new ValidationException( $"RequisitionType must be 1 (Replacement), 2 (Additional) or 3 (AdHoc). Received: {JobRequisitionDTO.RequisitionType}."); } if (JobRequisitionDTO.NoOfOpenings <= 0) { throw new ValidationException( $"NoOfOpenings must be greater than zero. Received: {JobRequisitionDTO.NoOfOpenings}."); } bool isNew = JobRequisitionDTO.JobRequisitionId == 0; // Default to Draft on create when the caller didn't supply a status. if (isNew && JobRequisitionDTO.RequisitionStatus == 0) JobRequisitionDTO.RequisitionStatus = STATUS_DRAFT; // Captured before the update persists — needed below to detect a genuine // "transitions to Closed" edge (not just "is already Closed" on every subsequent // save of an already-closed requisition), for the OKR recruiter-KPI push. Best // effort: a failed read here just means the KPI push below is skipped, it never // blocks the save itself. byte previousStatus = 0; if (!isNew) { try { var existingJson = await _JobRequisitionDAL.GetJobRequisition(JobRequisitionDTO.JobRequisitionId, LoginDTO, ct).ConfigureAwait(false); var existing = JsonConvert.DeserializeObject(existingJson); previousStatus = existing?.RequisitionStatus ?? 0; } catch (Exception ex) { _Logger.LogWarning(ex, "Could not load prior RequisitionStatus for JobRequisitionId {JobRequisitionId} — " + "OKR recruiter-KPI push (if this save closes the requisition) will be skipped.", JobRequisitionDTO.JobRequisitionId); } } JobRequisitionDTO.ModifiedById = LoginDTO.UserId; JobRequisitionDTO.ModifiedOn = DateTime.UtcNow; JobRequisitionDTO.TenantId = LoginDTO.ClientId; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "JOBREQUISITION", LoginDTO).ConfigureAwait(false); JobRequisitionDTO.JobRequisitionId = autoNumberDTO.StartNumber; JobRequisitionDTO.CreatedById = LoginDTO.UserId; JobRequisitionDTO.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-jobrequisition", new { JobRequisitionDTO.JobRequisitionId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTJOBREQUISITION, isNew ? EventTypeConstant.SAVERECRUITMENTJOBREQUISITIONEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTJOBREQUISITIONEVENTTYPEID, JobRequisitionDTO, LoginDTO, async tx => { _ = isNew ? await _JobRequisitionDAL.SaveJobRequisition(JobRequisitionDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _JobRequisitionDAL.UpdateJobRequisition(JobRequisitionDTO, LoginDTO, tx, ct).ConfigureAwait(false); return JobRequisitionDTO.JobRequisitionId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTJOBREQUISITIONEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTJOBREQUISITIONEVENTTYPEID }); var cacheKey = new CacheKeyGeneration().KeyGeneration( JobRequisitionDTO.JobRequisitionId, EntityConstant.OBJECTRECRUITMENTJOBREQUISITION, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "JobRequisition {JobRequisitionId} {Action} by user {UserId}", JobRequisitionDTO.JobRequisitionId, isNew ? "saved" : "updated", LoginDTO.UserId); // ── OKR recruiter-KPI push: "requisitions closed" ───────────────────────── // Only on a genuine Draft/... -> Closed(7) edge, never on a re-save of an // already-closed requisition. Deliberately AFTER the save has already committed // and fully non-blocking — mirrors JobOfferBLL's ECP offer-letter send: an OKR // outage (or, currently, OKR simply not being wired into any host — see // IOkrIntegrationService's class doc comment) must never fail a JobRequisition // save that has already succeeded. if (!isNew && previousStatus != STATUS_CLOSED && JobRequisitionDTO.RequisitionStatus == STATUS_CLOSED) { try { await PushRequisitionClosedKpiAsync(JobRequisitionDTO, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { // Belt-and-braces: OkrIntegrationService already catches/degrades // internally, but the requisition save must survive regardless. GB5Trace.MarkFailed("okr-requisition-closed-kpi-failed", ex); _Logger.LogWarning(ex, "OKR requisitions-closed KPI push failed for JobRequisitionId {JobRequisitionId}", JobRequisitionDTO.JobRequisitionId); } } return isNew ? $"{SuccessResponse.SaveSuccessMessage} {JobRequisitionDTO.JobRequisitionId}" : $"{SuccessResponse.UpdateSuccessMessage} {JobRequisitionDTO.JobRequisitionId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-jobrequisition-failed", vex); if (autoNumberDTO != null && JobRequisitionDTO.JobRequisitionId > 0) await _AutoNumber.RollbackAutoNumber("JOBREQUISITION", JobRequisitionDTO.JobRequisitionId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-jobrequisition-failed", ex); _Logger.LogError(ex, "SaveJobRequisition failed for JobRequisitionId {JobRequisitionId}", JobRequisitionDTO.JobRequisitionId); if (autoNumberDTO != null && JobRequisitionDTO.JobRequisitionId > 0) { await _AutoNumber.RollbackAutoNumber( "JOBREQUISITION", JobRequisitionDTO.JobRequisitionId, LoginDTO).ConfigureAwait(false); } throw; } } public async Task GetSelectListJobRequisition(CriteriaDTO criteriaDTO, LoginDTO LoginDTO, CancellationToken ct) { try { return await _JobRequisitionDAL.GetSelectListJobRequisition(criteriaDTO, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-jobrequisition-failed", ex); _Logger.LogError(ex, "GetSelectListJobRequisition failed"); throw; } } public async Task DeleteJobRequisition(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-jobrequisition", new { JobRequisitionId }); var result = await _JobRequisitionDAL.DeleteJobRequisition(JobRequisitionId, LoginDTO, ct) .ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( JobRequisitionId, EntityConstant.OBJECTRECRUITMENTJOBREQUISITION, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("JobRequisition {JobRequisitionId} deleted by user {UserId}", JobRequisitionId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-jobrequisition-failed", ex); _Logger.LogError(ex, "DeleteJobRequisition failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } // ── Approval state-machine transitions ───────────────────────────────────── // // NOTE — workflow-engine integration status (corrected 2026-09-03; see // DB/Migrations/20260903_Recruitment_JobRequisition_Workflow_Seed_SqlServer.sql for full // detail — this superseded an earlier, mistaken version of this comment): // // No BLL anywhere needs to call IWorkFlowEngine directly — PersistTransition below already // routes every transition through BaseEntityAppService.ExecuteSaveAsync // (GB5Shared/EntityHandler/EventHandler.cs), which itself calls // IWorkFlowEngine.CheckWorkFlowApplicability/StartWorkflowAsync against MWORKFLOWCONFIG on // every save, for every entity, with zero BLL-side wiring required. That gate was a no-op // for JobRequisition purely because no MWORKFLOWCONFIG row existed for // EntityConstant.OBJECTRECRUITMENTJOBREQUISITION — the migration above now adds one. // // Trigger-point design: that row's EVALCONDITION is 'DTO.RequisitionStatus == 2', so the // engine engages specifically on SubmitJobRequisitionForApproval's save (the one that sets // RequisitionStatus to PendingApproval) — draft creates/updates and the Approve/Reject // transitions below don't match. This keeps the explicit, user-visible Draft -> // PendingApproval -> Approved/Rejected status transitions in this file as the source of // truth for the requisition's state (unchanged below), while the real engine now genuinely // creates a TWORKFLOWINSTANCE + approval task alongside it once activated. // // That MWORKFLOWCONFIG row is currently seeded INERT (WORKFLOWID = -1, the engine's own // documented "no workflow assigned" sentinel) because activating it needs data this session // could not safely fabricate without live DB access: a MENTITY row for // OBJECTRECRUITMENTJOBREQUISITION (FK to a correct DBOBJECT row for TJOBREQUISITION — a // wrong DBOBJECTID would make WorkFlowEngine.SetEntityStatusAsync silently update the wrong // table's STATUS column on approval completion), and a verified real approver identity for // MWORKFLOWASSIGNMENT (StrategyType 0/1/2/3 all need a concrete UserId/RoleId/GroupId or a // tested query — not something to guess). See the migration file's activation checklist. // // Also note for whoever activates this: WorkFlowEngine's own REGULAR-mode completion // (WorkFlowEngine.CompleteEntityStatusAsync) only ever flips a generic STATUS column via a // dynamically-resolved table/PK — it does NOT know about REQUISITIONSTATUS's specific // Approved(3)/Rejected(4) values, and there is currently no consumer anywhere in this repo // of IWorkFlowEngine.HandleActionsAsync (no generic workflow-inbox endpoint exists yet) that // could resolve the engine's own TWORKFLOWTASK when ApproveJobRequisition/RejectJobRequisition // below run. So even after activation, these two methods remain the sole, authoritative way // an approval/rejection is recorded on TJOBREQUISITION — they do not yet reach into the // engine's own task/instance rows. Bridging that (e.g. looking up the pending task for this // JobRequisitionId and calling HandleActionsAsync alongside the status transition here) is // deferred: IWorkFlowRunTime's only EntityId-based instance lookup // (GetInstanceAsync(clientId, entityName, entityId, ...)) does not filter by ObjectId, so it // cannot safely resolve "the one pending instance for JobRequisitionId=X" as-is — fixing that // is a GB5Shared framework change, out of this task's scope of touching JobRequisition only. public async Task SubmitJobRequisitionForApproval(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("submit-jobrequisition-for-approval", new { JobRequisitionId }); var dto = await LoadForTransition(JobRequisitionId, LoginDTO, ct).ConfigureAwait(false); if (dto.RequisitionStatus != STATUS_DRAFT) { throw new ValidationException( $"JobRequisition {JobRequisitionId} cannot be submitted for approval from status {dto.RequisitionStatus}; only Draft (1) requisitions can be submitted."); } dto.RequisitionStatus = STATUS_PENDINGAPPROVAL; return await PersistTransition(dto, LoginDTO, ct, "submit-jobrequisition-for-approval-failed") .ConfigureAwait(false); } catch (ValidationException vex) { GB5Trace.MarkFailed("submit-jobrequisition-for-approval-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("submit-jobrequisition-for-approval-failed", ex); _Logger.LogError(ex, "SubmitJobRequisitionForApproval failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } public async Task ApproveJobRequisition(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("approve-jobrequisition", new { JobRequisitionId }); var dto = await LoadForTransition(JobRequisitionId, LoginDTO, ct).ConfigureAwait(false); if (dto.RequisitionStatus != STATUS_PENDINGAPPROVAL) { throw new ValidationException( $"JobRequisition {JobRequisitionId} cannot be approved from status {dto.RequisitionStatus}; only PendingApproval (2) requisitions can be approved."); } // Manpower-budget gate — Additional/new-headcount requisitions only; Replacement // and AdHoc stay lighter-approval by design (Phase 1 plan's stated intent). Throws // ValidationException (caught below, same as every other gate in this method) when // no active budget can be resolved, or when it would be exceeded. Returns the // resolved budget row (or null for Replacement/AdHoc) so the consumed-counter // increment below doesn't have to re-resolve it a second time. var manpowerBudget = await ValidateManpowerBudgetForApprovalAsync(dto, LoginDTO, ct) .ConfigureAwait(false); dto.RequisitionStatus = STATUS_APPROVED; dto.ApprovedById = LoginDTO.UserId; dto.ApprovalDate = DateTime.UtcNow; var result = await PersistTransition(dto, LoginDTO, ct, "approve-jobrequisition-failed") .ConfigureAwait(false); // Consume the reserved headcount/budget now that the approval has committed. // Standalone follow-up call — NOT part of the ExecuteSaveAsync transition above; // ManpowerBudget is a different entity/DTO with its own // BaseEntityAppService pipeline, so folding this into // JobRequisition's own ExecuteSaveAsync call would require a hand-rolled shared // transaction (see ManpowerBudgetQB.INCREMENT_MANPOWERBUDGET_CONSUMED's doc // comment). Best-effort and non-blocking, same philosophy as // PushRequisitionClosedKpiAsync below: a failure here must never turn an // already-committed approval into a reported failure to the caller — worst case is // a real Approved requisition whose budget consumption hasn't caught up yet // (recoverable via manual reconciliation), not data corruption of the requisition. if (manpowerBudget != null) { try { GB5Trace.Step("increment-manpowerbudget-consumed", new { manpowerBudget.ManpowerBudgetId, dto.JobRequisitionId, dto.NoOfOpenings }); var estimatedCost = manpowerBudget.ApprovedHeadcount > 0 ? (manpowerBudget.ApprovedBudgetAmount / manpowerBudget.ApprovedHeadcount) * dto.NoOfOpenings : 0m; await _ManpowerBudgetDAL.IncrementManpowerBudgetConsumed( manpowerBudget.ManpowerBudgetId, dto.NoOfOpenings, estimatedCost, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("increment-manpowerbudget-consumed-failed", ex); _Logger.LogWarning(ex, "Failed to increment ManpowerBudget {ManpowerBudgetId} consumed counters after " + "approving JobRequisitionId {JobRequisitionId} — the approval already committed; " + "budget consumption needs manual reconciliation.", manpowerBudget.ManpowerBudgetId, dto.JobRequisitionId); } } return result; } catch (ValidationException vex) { GB5Trace.MarkFailed("approve-jobrequisition-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("approve-jobrequisition-failed", ex); _Logger.LogError(ex, "ApproveJobRequisition failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } // Resolves and validates the ManpowerBudget row that gates approval of an // Additional/new-headcount JobRequisition — see the Phase 1 plan's stated intent: "a // replacement typically needs lighter approval than a new/additional headcount request, // which should check against approved manpower budget/headcount before approval can // complete." Returns null (no gate applied) for Replacement/AdHoc requisition types. // // Resolution order: // 1. dto.BudgetReferenceId, when set (> -1) — the Phase 1 placeholder field on // TJOBREQUISITION this Phase 6 slice now actually wires up, letting a caller pin a // requisition to one specific ManpowerBudget row. // 2. Fallback: an active ManpowerBudget row for the requisition's DepartmentId, matched // against the current fiscal year/quarter at approval time (JobRequisitionDTO carries // no fiscal-period field of its own to match on instead). // // Cost-estimate caveat: neither JobRequisitionDTO nor JobPositionDTO/JobProfileDTO carries // a per-requisition salary/cost figure today, so the budget-amount check below uses the // resolved ManpowerBudget row's own average cost-per-head // (ApprovedBudgetAmount / ApprovedHeadcount) x NoOfOpenings as the estimate. This is a // deliberate, documented approximation — see this task's final report for the same note — // not a precision costing model; a future phase that adds a real per-position cost field // should replace it with an exact figure. private async Task ValidateManpowerBudgetForApprovalAsync( JobRequisitionDTO dto, LoginDTO loginDTO, CancellationToken ct) { if (dto.RequisitionType != REQUISITIONTYPE_ADDITIONAL) return null; GB5Trace.Step("validate-manpowerbudget-for-requisition", new { dto.JobRequisitionId, dto.DepartmentId, dto.BudgetReferenceId }); ManpowerBudgetDTO? budget = null; if (dto.BudgetReferenceId > 0) { var directJson = await _ManpowerBudgetDAL .GetManpowerBudget(dto.BudgetReferenceId, loginDTO, ct).ConfigureAwait(false); var direct = JsonConvert.DeserializeObject(directJson); if (direct != null && direct.ManpowerBudgetId != 0) budget = direct; } if (budget == null) { var now = DateTime.UtcNow; var fiscalYear = (short)now.Year; var fiscalQuarter = (byte)(((now.Month - 1) / 3) + 1); var fallbackJson = await _ManpowerBudgetDAL .GetActiveManpowerBudgetForDepartmentPeriod(dto.DepartmentId, fiscalYear, fiscalQuarter, loginDTO, ct) .ConfigureAwait(false); var fallback = JsonConvert.DeserializeObject(fallbackJson); if (fallback != null && fallback.ManpowerBudgetId != 0) budget = fallback; } if (budget == null) { throw new ValidationException( $"JobRequisition {dto.JobRequisitionId} is RequisitionType=Additional (new headcount) but no " + $"active ManpowerBudget was found for DepartmentId {dto.DepartmentId} (checked BudgetReferenceId " + $"{dto.BudgetReferenceId} and the current fiscal period) — approval cannot complete without an " + "approved manpower budget/headcount reference."); } var remainingHeadcount = budget.ApprovedHeadcount - budget.ConsumedHeadcount; if (dto.NoOfOpenings > remainingHeadcount) { throw new ValidationException( $"JobRequisition {dto.JobRequisitionId} requests {dto.NoOfOpenings} opening(s) but ManpowerBudget " + $"{budget.ManpowerBudgetId} has only {remainingHeadcount} headcount remaining " + $"({budget.ConsumedHeadcount}/{budget.ApprovedHeadcount} consumed) — approval cannot complete."); } if (budget.ApprovedHeadcount > 0) { var estimatedCostPerHead = budget.ApprovedBudgetAmount / budget.ApprovedHeadcount; var estimatedRequisitionCost = estimatedCostPerHead * dto.NoOfOpenings; var remainingBudget = budget.ApprovedBudgetAmount - budget.ConsumedBudgetAmount; if (estimatedRequisitionCost > remainingBudget) { throw new ValidationException( $"JobRequisition {dto.JobRequisitionId}'s estimated cost ({estimatedRequisitionCost:0.00}, " + $"based on ManpowerBudget {budget.ManpowerBudgetId}'s average cost-per-head) exceeds the " + $"{remainingBudget:0.00} budget remaining ({budget.ConsumedBudgetAmount:0.00}/" + $"{budget.ApprovedBudgetAmount:0.00} consumed) — approval cannot complete."); } } return budget; } public async Task RejectJobRequisition(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("reject-jobrequisition", new { JobRequisitionId }); var dto = await LoadForTransition(JobRequisitionId, LoginDTO, ct).ConfigureAwait(false); if (dto.RequisitionStatus != STATUS_PENDINGAPPROVAL) { throw new ValidationException( $"JobRequisition {JobRequisitionId} cannot be rejected from status {dto.RequisitionStatus}; only PendingApproval (2) requisitions can be rejected."); } dto.RequisitionStatus = STATUS_REJECTED; dto.ApprovedById = LoginDTO.UserId; dto.ApprovalDate = DateTime.UtcNow; return await PersistTransition(dto, LoginDTO, ct, "reject-jobrequisition-failed") .ConfigureAwait(false); } catch (ValidationException vex) { GB5Trace.MarkFailed("reject-jobrequisition-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("reject-jobrequisition-failed", ex); _Logger.LogError(ex, "RejectJobRequisition failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } private async Task LoadForTransition(int jobRequisitionId, LoginDTO loginDTO, CancellationToken ct) { var json = await _JobRequisitionDAL.GetJobRequisition(jobRequisitionId, loginDTO, ct).ConfigureAwait(false); var dto = JsonConvert.DeserializeObject(json); if (dto == null || dto.JobRequisitionId == 0) throw new ValidationException($"JobRequisition {jobRequisitionId} was not found."); return dto; } private async Task PersistTransition( JobRequisitionDTO dto, LoginDTO loginDTO, CancellationToken ct, string failureTag) { dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTJOBREQUISITION, EventTypeConstant.UPDATERECRUITMENTJOBREQUISITIONEVENTTYPEID, dto, loginDTO, async tx => { _ = await _JobRequisitionDAL.UpdateJobRequisition(dto, loginDTO, tx, ct).ConfigureAwait(false); return dto.JobRequisitionId; }, null, -1, -1, null, false).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( dto.JobRequisitionId, EntityConstant.OBJECTRECRUITMENTJOBREQUISITION, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "JobRequisition {JobRequisitionId} transitioned to status {RequisitionStatus} by user {UserId}", dto.JobRequisitionId, dto.RequisitionStatus, loginDTO.UserId); return $"{SuccessResponse.UpdateSuccessMessage} {dto.JobRequisitionId}"; } // ── OKR recruiter-KPI push: "requisitions closed" ───────────────────────────────── // // Feeds Phase 5 of the Recruitment build-out plan: recruiter/team KPIs live in the same // PERM/OKR KRA framework as every other role, instead of a recruitment-only scorecard. // See RecruitmentBLL.Integration.IOkrIntegrationService for the full graceful-degradation // contract this composes with. private async Task PushRequisitionClosedKpiAsync(JobRequisitionDTO dto, LoginDTO login, CancellationToken ct) { var recruiterUserId = RecruiterKpiHelper.ResolveAssignedRecruiterId(dto); if (recruiterUserId <= 0) { _Logger.LogInformation( "OKR requisitions-closed KPI push skipped for JobRequisitionId {JobRequisitionId} — no AssignedRecruiterId " + "set on this requisition. Expected for requisitions with no recruiter assigned yet, not a warning.", dto.JobRequisitionId); return; } GB5Trace.Step("okr-requisitions-closed-kpi", new { dto.JobRequisitionId, recruiterUserId }); var goal = await _OkrIntegrationService .FindGoalForRecruiterAsync(recruiterUserId, OKR_KRA_HINT_REQUISITIONS_CLOSED, login, ct) .ConfigureAwait(false); if (goal is null) return; // FindGoalForRecruiterAsync already logged the reason (Info-level, expected) // Simple increment-style count: this requisition closing adds 1 to whatever count the // Goal already carries. TargetValue is treated as "requisitions closed target" for // this cycle when set; otherwise progress is left unchanged (no target to measure // against) and only the raw count moves. var newValue = goal.CurrentValue + 1; var newProgress = goal.TargetValue > 0 ? Math.Min(100m, newValue / goal.TargetValue * 100m) : goal.CurrentProgress; var outcome = await _OkrIntegrationService .PushGoalProgressAsync( goal.GoalId, newValue, newProgress, $"JobRequisition {dto.JobRequisitionId} closed by user {login.UserId}", login, ct) .ConfigureAwait(false); if (!outcome.Success) _Logger.LogWarning( "OKR PushGoalProgressAsync did not succeed for GoalId {GoalId} (JobRequisitionId {JobRequisitionId}): {Error}", goal.GoalId, dto.JobRequisitionId, outcome.Error); } } }