using System.Linq; using System.Text.Json; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; namespace RecruitmentBLL.Integration { /// public class OkrIntegrationService : IOkrIntegrationService { private readonly IHttpClientFactory _HttpClientFactory; private readonly ILogger _Logger; private static readonly JsonSerializerOptions LenientJsonOptions = new() { PropertyNameCaseInsensitive = true }; public OkrIntegrationService( IHttpClientFactory httpClientFactory, ILogger logger) { _HttpClientFactory = httpClientFactory; _Logger = logger; } public async Task FindGoalForRecruiterAsync( int recruiterUserId, string kraNameHint, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("okr-find-goal-for-recruiter", new { recruiterUserId, kraNameHint }); if (recruiterUserId <= 0) { _Logger.LogInformation( "OKR FindGoalForRecruiterAsync skipped — no RecruiterUserId supplied."); return null; } var client = BuildClient(login); // GetGoalList requires a real OKRCycleId (no "0 = any cycle" bypass on that // column — see GoalQB.GET_GOAL_LIST), so resolve every currently-Active cycle // first (MOKRCYCLE.STATUS: 1 = Active). var cycleResponse = await client .GetAsync("Cycle/GetOKRCycleList?Status=1&Page=1&PageSize=20", ct) .ConfigureAwait(false); if (!cycleResponse.IsSuccessStatusCode) { _Logger.LogInformation( "OKR Cycle/GetOKRCycleList returned {StatusCode} — treating as no Active OKR cycle available; " + "recruiter-KPI push skipped for RecruiterUserId {RecruiterUserId}.", cycleResponse.StatusCode, recruiterUserId); return null; } var cycleBody = await ExtractBodyAsync(cycleResponse, ct).ConfigureAwait(false); var cycles = DeserializeLenient>(cycleBody); if (cycles is null || cycles.Count == 0) { _Logger.LogInformation( "No Active OKR cycle found — recruiter-KPI push skipped for RecruiterUserId {RecruiterUserId}. " + "This is expected until PERM/OKR admin sets up an Active cycle + Goal for recruiters.", recruiterUserId); return null; } foreach (var cycle in cycles) { var goalResponse = await client .GetAsync($"Goal/GetGoalList?OKRCycleId={cycle.OKRCycleId}&EmployeeId={recruiterUserId}&Status=-1&Page=1&PageSize=200", ct) .ConfigureAwait(false); if (!goalResponse.IsSuccessStatusCode) { _Logger.LogInformation( "OKR Goal/GetGoalList returned {StatusCode} for OKRCycleId {OKRCycleId}, RecruiterUserId {RecruiterUserId} — skipping this cycle.", goalResponse.StatusCode, cycle.OKRCycleId, recruiterUserId); continue; } var goalBody = await ExtractBodyAsync(goalResponse, ct).ConfigureAwait(false); var goals = DeserializeLenient>(goalBody); if (goals is null || goals.Count == 0) continue; // Loose match on KRA name or Goal name — a recruitment-specific KRA/Goal // isn't guaranteed to exist for this recruiter (expected common case), and // when it does exist there is no fixed naming convention enforced anywhere // in OKR admin setup, so substring/case-insensitive is the best available // match without inventing a stricter contract PERM/OKR doesn't have. var match = goals.FirstOrDefault(g => g.Status != 5 && // 5 = Cancelled — never push progress onto a cancelled goal !string.IsNullOrWhiteSpace(kraNameHint) && ((g.KRAName?.Contains(kraNameHint, StringComparison.OrdinalIgnoreCase) ?? false) || (g.Name?.Contains(kraNameHint, StringComparison.OrdinalIgnoreCase) ?? false))); if (match is not null) { _Logger.LogInformation( "OKR Goal {GoalId} ('{GoalName}') found for RecruiterUserId {RecruiterUserId} matching hint '{KraNameHint}'.", match.GoalId, match.Name, recruiterUserId, kraNameHint); return new OkrGoalLookupResult { GoalId = match.GoalId, Name = match.Name ?? string.Empty, KRAName = match.KRAName ?? string.Empty, CurrentValue = match.CurrentValue, CurrentProgress = match.CurrentProgress, TargetValue = match.TargetValue }; } } _Logger.LogInformation( "No OKR Goal found for RecruiterUserId {RecruiterUserId} matching KRA hint '{KraNameHint}' — " + "recruiter-KPI push skipped. This is the expected, common case for recruiters who have not had " + "a recruitment-specific Goal provisioned yet, not an error.", recruiterUserId, kraNameHint); return null; } catch (Exception ex) { GB5Trace.MarkFailed("okr-find-goal-for-recruiter-failed", ex); _Logger.LogWarning(ex, "OKR FindGoalForRecruiterAsync failed for RecruiterUserId {RecruiterUserId} — degrading gracefully (no Goal push).", recruiterUserId); return null; } } public async Task PushGoalProgressAsync( int goalId, decimal currentValue, decimal progressPercent, string remarks, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("okr-push-goal-progress", new { goalId, currentValue, progressPercent }); if (goalId <= 0) return new OkrCallOutcome { Success = false, Error = "GoalId is required" }; var clampedProgress = progressPercent < 0 ? 0 : progressPercent > 100 ? 100 : progressPercent; var client = BuildClient(login); // UpdateGoalProgress.Params binds every field via [QueryParam] — not [FromBody] — // so this is a query-string POST with an empty body (see // OKRSL.Endpoints.Goal.UpdateGoalProgress). ConfidenceLevel is left at that // endpoint's own default (1) — Recruitment has no independent confidence signal // to report. var query = $"Goal/UpdateGoalProgress" + $"?GoalId={goalId}" + $"&ProgressPercent={Uri.EscapeDataString(clampedProgress.ToString(System.Globalization.CultureInfo.InvariantCulture))}" + $"&CurrentValue={Uri.EscapeDataString(currentValue.ToString(System.Globalization.CultureInfo.InvariantCulture))}" + $"&Remarks={Uri.EscapeDataString(remarks ?? string.Empty)}"; var response = await client.PostAsync(query, content: null, ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); _Logger.LogWarning( "OKR UpdateGoalProgress returned {StatusCode} for GoalId {GoalId}: {Body}", response.StatusCode, goalId, body); return new OkrCallOutcome { Success = false, Error = $"OKR returned {(int)response.StatusCode}" }; } return new OkrCallOutcome { Success = true }; } catch (Exception ex) { GB5Trace.MarkFailed("okr-push-goal-progress-failed", ex); _Logger.LogWarning(ex, "OKR PushGoalProgressAsync failed for GoalId {GoalId}", goalId); return new OkrCallOutcome { Success = false, Error = ex.Message }; } } // ── Helpers ────────────────────────────────────────────────────── private HttpClient BuildClient(LoginDTO login) { var client = _HttpClientFactory.CreateClient("OKRModule"); client.DefaultRequestHeaders.Remove("Login"); client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(login)); return client; } /// OKR's ResponseStandardDTO<object>.Body carries the BLL's own /// JSON-serialized string result (Response.CreateSuccessResponse assigns Body = data as-is /// — see GB5Shared/ResponseStandard/Response.cs), so this reads it permissively the same /// way EcpIntegrationService.ExtractBodyAsync does. private static async Task ExtractBodyAsync(HttpResponseMessage response, CancellationToken ct) { try { var raw = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(raw); if (doc.RootElement.TryGetProperty("Body", out var bodyElement)) { return bodyElement.ValueKind == JsonValueKind.String ? bodyElement.GetString() : bodyElement.GetRawText(); } return null; } catch { // best effort — a malformed/unexpected response body degrades to "no data" rather // than throwing back into the caller. return null; } } private static T? DeserializeLenient(string? json) where T : class { if (string.IsNullOrWhiteSpace(json)) return null; try { return JsonSerializer.Deserialize(json, LenientJsonOptions); } catch { return null; } } // Minimal shapes matching OKRCycleDTO/GoalDTO's own JSON property names (OKRCycleBLL/ // GoalBLL serialize with System.Text.Json's default naming, i.e. exact PascalCase — see // OKRCycleQB/GoalQB's SELECT aliases) — only the fields this service actually needs. private sealed class OkrCycleLite { public int OKRCycleId { get; set; } } private sealed class OkrGoalLite { public int GoalId { get; set; } public string? Name { get; set; } public string? KRAName { get; set; } public decimal CurrentValue { get; set; } public decimal CurrentProgress { get; set; } public decimal TargetValue { get; set; } public short Status { get; set; } } } }