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; }
}
}
}