using System.Net.Http.Json;
using System.Text.Json;
using BizProcessBLL.Interfaces;
using GB5Shared.DTO.Framework.Criteria;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.Telemetry;
using Microsoft.Extensions.Logging;
using static GB5Shared.DTO.WorkFlow.ContractsDTO;
namespace BizProcessBLL.Implementations
{
///
/// GB5 implementation of — reaches the Framework
/// host's real, existing WorkFlow engine over HTTP (named client "framework", registered in
/// Program.cs), not a direct C# project reference. BizProcessSL and FrameworkSL are separate
/// deployed hosts (see feedback_cross_host_di_boundary) — the same IHttpClientFactory
/// named-client pattern already used by DXP→MM (MmIntegrationService.cs) and
/// Framework→Entitlement (EntitlementGateClient.cs) is followed here, not Dapr service
/// invocation (confirmed via research: every actively-maintained cross-host call in this
/// repo uses this pattern; the one DaprClient.InvokeMethodAsync call found elsewhere is
/// leftover example code, not a followed convention).
///
/// EntityId resolution: GetWorkflowConfigAsync fetches the referenced MBIZTRANSACTIONCLASS
/// record (also over HTTP, on the same "framework" client — GetBIZTransactionClass is
/// Framework-hosted too) purely to read its ENTITYID column and forward it into
/// WorkflowCheckContext. This turned out to matter in practice, not just in principle: every
/// real MWORKFLOWCONFIG row on GB5DEMO is entity-scoped (ENTITYID != -1), so skipping this
/// resolution and passing -1 would make the lookup silently never match anything real.
///
/// GetWorkflow's response body is a JSON string with real property names still carrying the
/// "Workflow" prefix from FrameworkDAL's WorkflowDetailDTO (WorkflowApprovalLevel,
/// WorkflowDisplayName, etc., inside a WorkflowDetailArray) — that DTO is Framework-private,
/// so this deserializes via JsonDocument rather than taking a compile-time dependency on it.
///
public class Gb5ProcessOperationalDataSource : IProcessOperationalDataSource
{
private readonly IHttpClientFactory _HttpClientFactory;
private readonly ILogger _Logger;
public Gb5ProcessOperationalDataSource(IHttpClientFactory httpClientFactory, ILogger logger)
{
_HttpClientFactory = httpClientFactory;
_Logger = logger;
}
public async Task GetWorkflowConfigAsync(
int bizTransactionClassId, int bizTransactionId, LoginDTO login, CancellationToken ct)
{
try
{
var client = _HttpClientFactory.CreateClient("framework");
client.DefaultRequestHeaders.Remove("Login");
client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(login));
var entityId = await ResolveEntityIdAsync(client, bizTransactionClassId, ct).ConfigureAwait(false);
var context = new WorkflowCheckContext
{
EntityId = entityId,
OUId = -1,
BizTransactionClassId = bizTransactionClassId,
BizTransactionId = bizTransactionId,
};
var response = await client.PostAsJsonAsync("WorkFlow/CheckApplicability", context, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false);
var bodyRaw = envelope?.RootElement.GetProperty("Body").GetString();
if (string.IsNullOrWhiteSpace(bodyRaw)) return null;
// CheckWorkflowApplicability double-encodes its body (JsonConvert.SerializeObject
// into the Body string) — same quirk already confirmed for GetBIZTransactionClass.
var result = JsonSerializer.Deserialize(bodyRaw, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (result is null || !result.IsEnabled) return null;
return new WorkflowLookupResult { IsEnabled = true, WorkFlowId = result.WorkFlowId };
}
catch (Exception ex)
{
// A workflow-lookup failure must never block rendering the process map itself.
GB5Trace.MarkFailed("get-workflow-config-failed", ex);
_Logger.LogWarning(ex, "GetWorkflowConfigAsync failed for BizTransactionClassId {ClassId} BizTransactionId {TransId}", bizTransactionClassId, bizTransactionId);
return null;
}
}
///
/// -1 (wildcard) when bizTransactionClassId itself is -1 (unlinked stage), or on any
/// failure to reach/parse the class record — a resolution failure here should degrade to
/// "no entity scoping" rather than blocking the workflow lookup entirely.
///
private async Task ResolveEntityIdAsync(HttpClient client, int bizTransactionClassId, CancellationToken ct)
{
if (bizTransactionClassId == -1) return -1;
try
{
var response = await client.GetAsync($"BIZTransactionClass/GetBIZTransactionClass?BizTransactionClassId={bizTransactionClassId}", ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return -1;
var envelope = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false);
if (!envelope!.RootElement.TryGetProperty("Body", out var bodyEl)) return -1;
// GetBIZTransactionClass double-encodes its Body as a JSON string — same quirk
// already confirmed live from the FE side of this same lookup.
var classEl = bodyEl.ValueKind == JsonValueKind.String
? JsonDocument.Parse(bodyEl.GetString()!).RootElement
: bodyEl;
return classEl.TryGetProperty("EntityId", out var eid) ? eid.GetInt32() : -1;
}
catch (Exception ex)
{
GB5Trace.MarkFailed("resolve-entityid-failed", ex);
_Logger.LogWarning(ex, "ResolveEntityIdAsync failed for BizTransactionClassId {ClassId}", bizTransactionClassId);
return -1;
}
}
public async Task> GetWorkflowStepsAsync(int workflowId, LoginDTO login, CancellationToken ct)
{
var steps = new List();
try
{
var client = _HttpClientFactory.CreateClient("framework");
client.DefaultRequestHeaders.Remove("Login");
client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(login));
var response = await client.GetAsync($"Workflow/GetWorkflow?WorkflowId={workflowId}", ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return steps;
var envelope = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false);
if (!envelope!.RootElement.TryGetProperty("Body", out var bodyEl)) return steps;
// GetWorkflow's Body is the real (non-double-encoded) object here, but its exact
// element kind depends on the string-vs-object quirk already seen elsewhere in
// this codebase — handle both defensively rather than assuming.
var workflowEl = bodyEl.ValueKind == JsonValueKind.String
? JsonDocument.Parse(bodyEl.GetString()!).RootElement
: bodyEl;
if (!workflowEl.TryGetProperty("WorkflowDetailArray", out var detailArray) || detailArray.ValueKind != JsonValueKind.Array)
return steps;
foreach (var d in detailArray.EnumerateArray())
{
steps.Add(new WorkflowStepSummaryDTO
{
ApprovalLevel = d.TryGetProperty("WorkflowApprovalLevel", out var lvl) ? lvl.GetInt32() : 0,
DisplayName = d.TryGetProperty("WorkflowDisplayName", out var dn) ? dn.GetString() : null,
StepType = d.TryGetProperty("WorkflowStepType", out var st) ? st.GetInt32() : 0,
ParallelGroupKey = d.TryGetProperty("WorkflowParallelGroupKey", out var pgk) ? pgk.GetString() : null,
EvalCondition = d.TryGetProperty("WorkflowEvalCondition", out var ec) ? ec.GetString() : null,
});
}
}
catch (Exception ex)
{
GB5Trace.MarkFailed("get-workflow-steps-failed", ex);
_Logger.LogWarning(ex, "GetWorkflowStepsAsync failed for WorkflowId {WorkflowId}", workflowId);
}
return steps;
}
///
/// Reaches MM's existing GetDocumentTraceability endpoint (a separate host, named client
/// "MMModule" — same cross-host HTTP pattern as "framework", registered in Program.cs and
/// already proven by DXP's MmIntegrationService.cs). The endpoint's own pivot-parsing
/// (ParsePivotAllocationId) reads AllocationId off a CriteriaDTO's first section/attribute,
/// so that's the exact shape built here rather than a simpler query param.
///
public async Task> GetDocumentTraceAsync(int pivotAllocationId, LoginDTO login, CancellationToken ct)
{
var rows = new List();
try
{
var client = _HttpClientFactory.CreateClient("MMModule");
client.DefaultRequestHeaders.Remove("Login");
client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(login));
var criteria = new CriteriaDTO
{
SectionCriteriaList = new List
{
new SectionCriteriaDTO
{
AttributesCriteriaList = new List
{
new AttributesCriteriaDTO { FieldName = "AllocationId", FieldValue = pivotAllocationId }
}
}
}
};
var response = await client.PostAsJsonAsync(
"Register/DocumentTraceability?FirstNumber=0&MaxResult=1000", criteria, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return rows;
var envelope = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false);
if (!envelope!.RootElement.TryGetProperty("Body", out var bodyEl)) return rows;
var traceEl = bodyEl.ValueKind == JsonValueKind.String
? JsonDocument.Parse(bodyEl.GetString()!).RootElement
: bodyEl;
if (traceEl.ValueKind != JsonValueKind.Array) return rows;
// Property names below match DocumentTraceabilityDTO's literal C# names exactly
// (GB5's global JSON naming policy is null -> PascalCase, no camelCasing) --
// including its "ParentAllocationid" and "BIZTransactionTypeId" quirky casing.
foreach (var r in traceEl.EnumerateArray())
{
rows.Add(new DocumentTraceRowDTO
{
AllocationId = r.TryGetProperty("AllocationId", out var aid) ? aid.GetInt32() : 0,
ParentAllocationId = r.TryGetProperty("ParentAllocationid", out var pid) ? pid.GetInt32() : -1,
DocumentLevel = r.TryGetProperty("DocumentLevel", out var lvl) ? lvl.GetInt32() : 0,
BizTransactionTypeId = r.TryGetProperty("BIZTransactionTypeId", out var btt) ? btt.GetInt32() : -1,
DocumentNumber = r.TryGetProperty("DocumentNumber", out var dn) ? dn.GetString() : null,
DocumentDate = r.TryGetProperty("DocumentDate", out var dd) && dd.ValueKind != JsonValueKind.Null ? dd.GetDateTime() : default,
ObjectHeaderTypeId = r.TryGetProperty("ObjectHeaderTypeId", out var oht) ? oht.GetInt32() : -1,
ObjectHeaderId = r.TryGetProperty("ObjectHeaderId", out var ohi) ? ohi.GetInt32() : -1,
});
}
}
catch (Exception ex)
{
GB5Trace.MarkFailed("get-document-trace-failed", ex);
_Logger.LogWarning(ex, "GetDocumentTraceAsync failed for PivotAllocationId {PivotAllocationId}", pivotAllocationId);
}
return rows;
}
///
/// Reaches MM's new GetPendingCountsByTransactionType endpoint (same "MMModule" named
/// client Phase 7 already registered) -- deliberately NOT GetMMPendingStatusReport, which
/// groups by BizTransactionClassId and can't answer "how many open documents for
/// transaction type X" (see that new MM endpoint's own doc comment for the full reason).
///
public async Task> GetOpenCountsAsync(IEnumerable bizTransactionTypeIds, LoginDTO login, CancellationToken ct)
{
var counts = new List();
var ids = bizTransactionTypeIds.Distinct().ToList();
if (ids.Count == 0) return counts;
try
{
var client = _HttpClientFactory.CreateClient("MMModule");
client.DefaultRequestHeaders.Remove("Login");
client.DefaultRequestHeaders.Add("Login", JsonSerializer.Serialize(login));
var idsParam = string.Join(",", ids);
var response = await client.GetAsync(
$"MMReports/GetPendingCountsByTransactionType?BizTransactionTypeIds={idsParam}", ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return counts;
var envelope = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false);
if (!envelope!.RootElement.TryGetProperty("Body", out var bodyEl)) return counts;
var arrEl = bodyEl.ValueKind == JsonValueKind.String
? JsonDocument.Parse(bodyEl.GetString()!).RootElement
: bodyEl;
if (arrEl.ValueKind != JsonValueKind.Array) return counts;
foreach (var r in arrEl.EnumerateArray())
{
counts.Add(new StageOpenCountDTO
{
BizTransactionTypeId = r.TryGetProperty("BizTransactionTypeId", out var btt) ? btt.GetInt32() : -1,
OpenCount = r.TryGetProperty("OpenCount", out var oc) ? oc.GetInt32() : 0,
});
}
}
catch (Exception ex)
{
GB5Trace.MarkFailed("get-open-counts-failed", ex);
_Logger.LogWarning(ex, "GetOpenCountsAsync failed for {Count} BizTransactionTypeIds", ids.Count);
}
return counts;
}
}
}