Files
Antifraude.Net/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs
Pedro 2414fbe80c Mejoras en elevaciones, equilibrado y callejero inverso
Añadida visualización de pendientes en los tramos a pie con puntos coloreados: llano, subida y bajada.
Añadidos umbrales configurables para subida y bajada junto al interruptor de elevaciones.
Añadido callejero inverso en el detalle de la solución, mostrando el lugar del callejero más cercano al origen y al destino.
Ajustado el modo Equilib. A para limpiar la tarjeta visualmente y guardar/restaurar el factor usado desde el historial.
Mejoras menores en el refresco del resumen técnico de rutas y validación de compilación.
2026-07-10 14:00:22 +02:00

1942 lines
77 KiB
C#

using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ApiDenuncias.Services
{
public class GestionaService : IGestionaService
{
private readonly HttpClient _http;
private readonly GestionaOptions _opts;
private readonly ILogger<GestionaService> _logger;
public GestionaService(
HttpClient http,
IOptions<GestionaOptions> optsAccessor,
ILogger<GestionaService> logger)
{
_http = http;
_opts = optsAccessor.Value;
_logger = logger;
}
// =========================================================
// Helpers
// =========================================================
private static string BuildFilterViewParam(object filterView)
{
var json = JsonSerializer.Serialize(filterView);
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
return $"filter-view={Uri.EscapeDataString(b64)}";
}
private static string? GetLinkHref(JsonElement item, string rel)
{
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var link in links.EnumerateArray())
{
if (link.TryGetProperty("rel", out var relProp) &&
string.Equals(relProp.GetString(), rel, StringComparison.OrdinalIgnoreCase) &&
link.TryGetProperty("href", out var hrefProp) &&
hrefProp.ValueKind == JsonValueKind.String)
{
return hrefProp.GetString();
}
}
return null;
}
// Reemplaza este helper si quieres controlar la versión en Accept:
private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null)
{
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
if (string.IsNullOrWhiteSpace(version))
{
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(mediaType));
}
else
{
var mt = new MediaTypeWithQualityHeaderValue(mediaType);
mt.Parameters.Add(new NameValueHeaderValue("version", version));
req.Headers.Accept.Add(mt);
}
}
// Helper estilo Postman para /rest/files sin filter-view
private void AddBasicHeaders(HttpRequestMessage req)
{
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
{
_logger.LogWarning(
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
operation,
string.Join(" | ", deprecated));
}
}
// =========================================================
// Expedientes (file)
// =========================================================
public async Task<GestionaCreateFileResponse> CreateFileAsync(string subject, string documentSeries, string siaCode)
{
_ = subject;
_ = documentSeries;
var url = await ResolveExternalProcedureCreateFileUrlAsync(siaCode);
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-opening+json; version=1"));
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST create-file");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
using var doc = JsonDocument.Parse(body);
var fileUrl = GetLinkHref(doc.RootElement, "file")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file-open'.");
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
}
private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(string siaCode)
{
var proceduresUrl = await ResolveRootLinkHrefAsync("vnd.gestiona.catalog-2015")
?? "/rest/catalog-2015/procedures";
var procedures = await GetContentArrayAsync(
proceduresUrl,
"application/vnd.gestiona.procedures-2015-page+json",
"GET catalogo de procedimientos Gestiona");
var procedure = SelectProcedure(procedures, siaCode);
var externalProceduresUrl = GetLinkHref(procedure, "external-procedures")
?? throw new InvalidOperationException(
"El procedimiento seleccionado no contiene link 'external-procedures'.");
var externalProcedures = await GetContentArrayAsync(
externalProceduresUrl,
"application/vnd.gestiona.external-procedures-2015-page+json",
"GET procedimientos externos Gestiona");
var externalProcedure = SelectExternalProcedure(externalProcedures, procedure, siaCode);
return GetLinkHref(externalProcedure, "create-file")
?? throw new InvalidOperationException(
"El procedimiento externo seleccionado no contiene link 'create-file'.");
}
private async Task<string?> ResolveRootLinkHrefAsync(string rel)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest");
AddBasicHeaders(req);
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET /rest");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"ResolveRootLinkHrefAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
return ToAbsoluteGestionaHref(GetLinkHref(doc.RootElement, rel));
}
private async Task<List<JsonElement>> GetContentArrayAsync(string url, string accept, string operation)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(accept));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, operation);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"{operation}: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
var source = doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array
? content
: doc.RootElement;
if (source.ValueKind != JsonValueKind.Array)
{
return [];
}
return source.EnumerateArray().Select(item => item.Clone()).ToList();
}
private JsonElement SelectProcedure(IReadOnlyList<JsonElement> procedures, string siaCode)
{
if (procedures.Count == 0)
{
throw new InvalidOperationException("Gestiona no ha devuelto procedimientos en el catalogo.");
}
var configuredName = NormalizeKey(_opts.ProcedureName);
if (!string.IsNullOrWhiteSpace(configuredName))
{
var match = procedures.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "title")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "description")).Contains(configuredName, StringComparison.Ordinal));
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
throw new InvalidOperationException(
$"No se ha encontrado en Gestiona el procedimiento configurado '{_opts.ProcedureName}'. Procedimientos disponibles: {DescribeCatalogItems(procedures)}.");
}
var normalizedSia = NormalizeKey(siaCode);
if (!string.IsNullOrWhiteSpace(normalizedSia))
{
var match = procedures.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "sia_code")) == normalizedSia ||
NormalizeKey(GetJsonString(item, "code")) == normalizedSia);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
if (procedures.Count == 1)
{
return procedures[0];
}
throw new InvalidOperationException(
"Falta configurar Gestiona:ProcedureName para seleccionar el procedimiento sin usar IDs fijos.");
}
private JsonElement SelectExternalProcedure(
IReadOnlyList<JsonElement> externalProcedures,
JsonElement selectedProcedure,
string siaCode)
{
var withCreateFile = externalProcedures
.Where(item => !string.IsNullOrWhiteSpace(GetLinkHref(item, "create-file")))
.ToList();
if (withCreateFile.Count == 0)
{
throw new InvalidOperationException("El procedimiento seleccionado no tiene procedimientos externos con link 'create-file'.");
}
var configuredSia = NormalizeKey(
string.IsNullOrWhiteSpace(_opts.ExternalProcedureSiaCode)
? siaCode
: _opts.ExternalProcedureSiaCode);
if (!string.IsNullOrWhiteSpace(configuredSia))
{
var match = withCreateFile.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "sia_code")) == configuredSia ||
NormalizeKey(GetJsonString(item, "code")) == configuredSia);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
var configuredName = NormalizeKey(_opts.ExternalProcedureName);
if (!string.IsNullOrWhiteSpace(configuredName))
{
var match = withCreateFile.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "title")).Contains(configuredName, StringComparison.Ordinal));
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
var procedureId = GetJsonString(selectedProcedure, "id");
if (!string.IsNullOrWhiteSpace(procedureId))
{
var sameId = withCreateFile.FirstOrDefault(item =>
string.Equals(GetJsonString(item, "id"), procedureId, StringComparison.OrdinalIgnoreCase));
if (sameId.ValueKind != JsonValueKind.Undefined)
{
return sameId;
}
}
if (withCreateFile.Count == 1)
{
return withCreateFile[0];
}
throw new InvalidOperationException(
$"No se puede seleccionar de forma inequivoca el procedimiento externo. Configura Gestiona:ExternalProcedureName o Gestiona:ExternalProcedureSiaCode. Disponibles: {DescribeCatalogItems(withCreateFile)}.");
}
private async Task<string> ResolveAssignableGroupHrefAsync(string groupCode)
{
if (string.IsNullOrWhiteSpace(groupCode))
{
throw new InvalidOperationException("Debe indicarse el codigo funcional del grupo de Gestiona.");
}
var groupsUrl = await ResolveRootLinkHrefAsync("vnd.gestiona.files.assignees.groups")
?? "/rest/files/assignees/groups";
var groups = await GetContentArrayAsync(
groupsUrl,
"application/vnd.gestiona.groups-page+json",
"GET grupos asignables Gestiona");
var normalizedCode = NormalizeKey(groupCode);
foreach (var group in groups)
{
var code = NormalizeKey(GetJsonString(group, "code"));
var name = NormalizeKey(GetJsonString(group, "name"));
var title = NormalizeKey(GetJsonString(group, "title"));
if (code == normalizedCode ||
name == normalizedCode ||
title == normalizedCode ||
name.StartsWith(normalizedCode + " ", StringComparison.Ordinal) ||
title.StartsWith(normalizedCode + " ", StringComparison.Ordinal))
{
var href = ToAbsoluteGestionaHref(GetLinkHref(group, "self"));
if (!string.IsNullOrWhiteSpace(href))
{
return href!;
}
}
}
throw new InvalidOperationException(
$"No se ha encontrado en Gestiona el grupo asignable '{groupCode}'. Grupos disponibles: {DescribeCatalogItems(groups)}.");
}
private string? ToAbsoluteGestionaHref(string? href)
{
if (string.IsNullOrWhiteSpace(href))
{
return null;
}
if (Uri.TryCreate(href, UriKind.Absolute, out _))
{
return href;
}
return href.StartsWith("/", StringComparison.Ordinal)
? $"{_opts.ApiBase.TrimEnd('/')}{href}"
: href;
}
private static string DescribeCatalogItems(IEnumerable<JsonElement> items)
{
var values = items
.Select(item => FirstNonEmpty(
GetJsonString(item, "name"),
GetJsonString(item, "title"),
GetJsonString(item, "code"),
GetJsonString(item, "sia_code"),
GetJsonString(item, "id")))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Take(10)
.ToList();
return values.Count == 0 ? "(sin nombres disponibles)" : string.Join("; ", values);
}
public async Task OpenFileAsync(
string fileUrl,
string? fileOpenUrl,
string assignedGroupCode,
bool confidential,
string freeTitle)
{
if (string.IsNullOrWhiteSpace(fileOpenUrl))
{
throw new InvalidOperationException(
"OpenFileAsync: falta el link 'file-open' devuelto por Gestiona. No se usa el fallback /open para evitar la ruta deprecated.");
}
var url = fileOpenUrl;
var assignedGroupHref = await ResolveAssignableGroupHrefAsync(assignedGroupCode);
var managementGroupCode = string.IsNullOrWhiteSpace(_opts.ManagementUnitGroupCode)
? "700"
: _opts.ManagementUnitGroupCode.Trim();
var managementGroupHref = await ResolveAssignableGroupHrefAsync(managementGroupCode);
var payload = new
{
free_title = freeTitle,
entry_date = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
confidential,
initial_assignation = new[]
{
new { rel = "group", href = assignedGroupHref }
},
links = new[]
{
new { rel = "management-unit-group", href = managementGroupHref }
}
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.file-opening+json");
content.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue("version", "1"));
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
AddTokenAndAccept(req, "application/json");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST file-open");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
public async Task AssignFileAsync(string fileUrl, string assignedGroupCode)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
throw new InvalidOperationException("AssignFileAsync: falta la URL del expediente.");
}
if (string.IsNullOrWhiteSpace(assignedGroupCode))
{
throw new InvalidOperationException("AssignFileAsync: falta el grupo asignado.");
}
var assigneesUrl = await ResolveFileAssigneesUrlAsync(fileUrl);
var assignedGroupHref = await ResolveAssignableGroupHrefAsync(assignedGroupCode);
var payload = new
{
links = new[]
{
new { rel = "group", href = assignedGroupHref }
}
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.links");
using var req = new HttpRequestMessage(HttpMethod.Put, assigneesUrl)
{
Content = content
};
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.links"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "PUT file assignees");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"AssignFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
}
private async Task<string> ResolveFileAssigneesUrlAsync(string fileUrl)
{
var normalizedFileUrl = fileUrl.TrimEnd('/');
using var req = new HttpRequestMessage(HttpMethod.Get, normalizedFileUrl);
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {normalizedFileUrl}");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ResolveFileAssigneesUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
return ToAbsoluteGestionaHref(GetLinkHref(doc.RootElement, "assignees"))
?? $"{normalizedFileUrl}/assignees";
}
public async Task<Guid> CreateFolderAsync(string fileUrl, string folderName)
{
var endpoint = $"{fileUrl}/documents-and-folders";
var payload = new { name = folderName };
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.file-folder+json");
content.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue("version", "1"));
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content
};
req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST file-folder");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateFolderAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
var selfHref = TryGetSelfHref(body) ?? resp.Headers.Location?.ToString();
if (string.IsNullOrWhiteSpace(selfHref))
{
throw new InvalidOperationException("CreateFolderAsync: Gestiona no ha devuelto link self ni Location.");
}
return Guid.Parse(selfHref.TrimEnd('/').Split('/').Last());
}
public async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName)
{
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await _http.SendAsync(createReq);
LogDeprecatedHeaders(createResp, "POST /rest/uploads");
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
var uploadUri = createResp.Headers.Location?.ToString()
?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads");
string md5Hex;
using (var md5 = MD5.Create())
{
var hash = md5.ComputeHash(contentBytes);
md5Hex = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
using var putReq = new HttpRequestMessage(HttpMethod.Put, uploadUri);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex);
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
putReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
putReq.Content = new ByteArrayContent(contentBytes);
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var putResp = await _http.SendAsync(putReq);
LogDeprecatedHeaders(putResp, "PUT upload content");
var infoJson = await putResp.Content.ReadAsStringAsync();
if (!putResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
if (!string.IsNullOrWhiteSpace(infoJson))
{
using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.TryGetProperty("status", out var statusProp)
? statusProp.GetString()
: "READY";
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Upload no READY: {status}");
}
return uploadUri;
}
public async Task UploadDocumentAsync(string fileUrl, byte[] contentBytes, string fileName)
{
var uploadUri = await CreateUploadAsync(contentBytes, fileName);
var metaPayload = new
{
type = "DIGITAL",
name = fileName,
description = "Denuncia combinada",
elaboration_state = "EE01",
metadata_language = "ES",
links = new[] { new { rel = "content", href = uploadUri } }
};
var metaJson = JsonSerializer.Serialize(metaPayload);
using var metaContent = new StringContent(metaJson, Encoding.UTF8);
metaContent.Headers.ContentType =
MediaTypeHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4");
using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders")
{ Content = metaContent };
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear();
using var metaResp = await _http.SendAsync(metaReq);
LogDeprecatedHeaders(metaResp, "POST file document");
var body = await metaResp.Content.ReadAsStringAsync();
if (!metaResp.IsSuccessStatusCode)
throw new InvalidOperationException($"UploadDocumentAsync: {(int)metaResp.StatusCode} {metaResp.StatusCode}\n{body}");
}
private static string? TryGetSelfHref(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(body);
return GetLinkHref(doc.RootElement, "self");
}
catch
{
return null;
}
}
// =========================
// TERCEROS
// =========================
public async Task<(string Id, string SelfHref)> BuscarTerceroPorNifAsync(string nif)
{
var filtro = new
{
nif
};
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/thirds");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.thirds-page+json");
req.Content = new StringContent(
JsonSerializer.Serialize(filtro),
Encoding.UTF8,
"application/vnd.gestiona.filter.thirds+json");
using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
string.IsNullOrWhiteSpace(body))
{
return default;
}
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Error BuscarTerceroPorNifAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
return default;
var match = content.EnumerateArray().FirstOrDefault(e =>
e.TryGetProperty("nif", out var nifProp) && nifProp.GetString() == nif);
if (match.ValueKind == JsonValueKind.Undefined) return default;
var id = match.GetProperty("id").GetString()!;
var self = match.GetProperty("links").EnumerateArray()
.First(l => l.GetProperty("rel").GetString() == "self")
.GetProperty("href").GetString()!;
return (id, self);
}
public async Task<(string Id, string SelfHref)> CrearTerceroAsync(ThirdPartyIdentityData thirdParty)
{
if (thirdParty is null)
throw new ArgumentNullException(nameof(thirdParty));
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
throw new ArgumentException("Documento identificativo obligatorio.", nameof(thirdParty));
if (thirdParty.IsLegalEntity)
{
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty));
}
else
{
if (string.IsNullOrWhiteSpace(thirdParty.FirstName))
throw new ArgumentException("Nombre obligatorio.", nameof(thirdParty));
if (string.IsNullOrWhiteSpace(thirdParty.LastName))
throw new ArgumentException("Primer apellido obligatorio.", nameof(thirdParty));
}
var payload = new Dictionary<string, object?>
{
["nif_country"] = NormalizeCountryCode(
string.IsNullOrWhiteSpace(thirdParty.CountryCode)
? thirdParty.Address?.CountryCode
: thirdParty.CountryCode),
["nif"] = thirdParty.DocumentId.Trim(),
["type"] = thirdParty.IsLegalEntity ? "JURIDICAL" : "PHISIC",
["notification_channel"] = BuildNotificationChannel(thirdParty)
};
var nifType = GuessNifType(thirdParty.DocumentId, thirdParty.IsLegalEntity);
if (!string.IsNullOrWhiteSpace(nifType))
{
payload["nif_type"] = nifType;
}
if (thirdParty.IsLegalEntity)
{
payload["business_name"] = thirdParty.BusinessName.Trim();
}
else
{
var (firstSurname, secondSurname) = SplitSurnames(thirdParty.LastName);
payload["first_name"] = thirdParty.FirstName.Trim();
payload["first_surname"] = firstSurname;
if (!string.IsNullOrWhiteSpace(secondSurname))
payload["second_surname"] = secondSurname;
}
if (!string.IsNullOrWhiteSpace(thirdParty.Email))
payload["email"] = thirdParty.Email.Trim();
var jsonOpts = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var json = JsonSerializer.Serialize(payload, jsonOpts);
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.third+json");
content.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue("version", "3"));
using var req = new HttpRequestMessage(HttpMethod.Post, "/rest/thirds")
{
Content = content
};
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.gestiona.third+json"));
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Error CrearTerceroAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
using var doc = JsonDocument.Parse(body);
var id = doc.RootElement.GetProperty("id").GetString()!;
var selfHref = doc.RootElement.GetProperty("links").EnumerateArray()
.First(l => l.GetProperty("rel").GetString() == "self")
.GetProperty("href").GetString()!;
if (thirdParty.Address?.HasAnyValue == true)
{
await TryEnsureThirdAddressAsync(selfHref, thirdParty.Address);
}
return (id, selfHref);
}
public async Task<HashSet<string>> ObtenerTercerosEnlazadosAsync(string fileUrl)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{fileUrl}/thirdparties");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "*/*");
using var resp = await _http.SendAsync(req);
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
return new HashSet<string>(StringComparer.Ordinal);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Error ObtenerTercerosEnlazadosAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
if (string.IsNullOrWhiteSpace(body))
return new HashSet<string>(StringComparer.Ordinal);
using var doc = JsonDocument.Parse(body);
var set = new HashSet<string>(StringComparer.Ordinal);
if (doc.RootElement.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("links", out var links))
{
var third = links.EnumerateArray().FirstOrDefault(l => l.GetProperty("rel").GetString() == "third");
if (third.ValueKind != JsonValueKind.Undefined)
set.Add(third.GetProperty("href").GetString()!);
}
}
}
return set;
}
public async Task EnlazarTerceroExistenteAsync(string fileUrl, string thirdSelfHref)
{
var payload = new { href = thirdSelfHref };
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8);
content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/vnd.gestiona.third-link+json");
using var req = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/thirdparties");
req.Content = content;
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "*/*");
var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Error EnlazarTerceroExistenteAsync: {resp.StatusCode}\n{body}");
}
public async Task<GestionaEnsureThirdResponse> AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty)
{
if (thirdParty is null)
throw new ArgumentNullException(nameof(thirdParty));
thirdParty = NormalizeThirdParty(thirdParty);
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
{
return new GestionaEnsureThirdResponse(true, warnings);
}
var encontrado = await BuscarTerceroPorNifAsync(thirdParty.DocumentId);
if (string.IsNullOrEmpty(encontrado.SelfHref))
{
if (!CanCreateThirdParty(thirdParty))
{
_logger.LogWarning(
"Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.",
fileUrl);
return new GestionaEnsureThirdResponse(true, warnings);
}
encontrado = await CrearTerceroAsync(thirdParty);
}
else
{
warnings.AddRange(await BuildThirdPartyDifferenceWarningsAsync(encontrado.SelfHref, thirdParty));
if (thirdParty.Address?.HasAnyValue == true)
{
await TryEnsureThirdAddressAsync(encontrado.SelfHref, thirdParty.Address);
}
}
var yaEnlazados = await ObtenerTercerosEnlazadosAsync(fileUrl);
if (!yaEnlazados.Contains(encontrado.SelfHref))
await EnlazarTerceroExistenteAsync(fileUrl, encontrado.SelfHref);
return new GestionaEnsureThirdResponse(true, warnings);
}
private async Task<List<string>> BuildThirdPartyDifferenceWarningsAsync(
string thirdSelfHref,
ThirdPartyIdentityData thirdParty)
{
var warnings = new List<string>();
var details = await GetThirdPartyDetailsAsync(thirdSelfHref);
if (details is null)
{
return warnings;
}
var gestionaType = GetJsonString(details.Value, "type");
var expectedType = thirdParty.IsLegalEntity ? "JURIDICAL" : "PHISIC";
AddThirdDifferenceWarning(warnings, "tipo de tercero", expectedType, gestionaType, normalizeAsCode: true);
if (thirdParty.IsLegalEntity)
{
AddThirdDifferenceWarning(
warnings,
"razon social",
thirdParty.BusinessName,
GetJsonString(details.Value, "business_name"));
}
else
{
AddThirdDifferenceWarning(
warnings,
"nombre",
thirdParty.FirstName,
GetJsonString(details.Value, "first_name"));
var gestionaLastName = string.Join(
' ',
new[]
{
GetJsonString(details.Value, "first_surname"),
GetJsonString(details.Value, "second_surname")
}.Where(value => !string.IsNullOrWhiteSpace(value)));
AddThirdDifferenceWarning(warnings, "apellidos", thirdParty.LastName, gestionaLastName);
}
AddThirdDifferenceWarning(warnings, "email", thirdParty.Email, GetJsonString(details.Value, "email"));
AddThirdDifferenceWarning(
warnings,
"pais del documento",
NormalizeCountryCode(thirdParty.CountryCode),
GetJsonString(details.Value, "nif_country"),
normalizeAsCode: true);
AddThirdDifferenceWarning(
warnings,
"canal de notificacion",
BuildNotificationChannel(thirdParty),
GetJsonString(details.Value, "notification_channel"),
normalizeAsCode: true);
if (warnings.Count > 0)
{
warnings.Insert(
0,
"El tercero ya existia en Gestiona y no se han sustituido sus datos. Si alguno debe cambiarse, actualizalo manualmente en Gestiona.");
}
return warnings;
}
private async Task<JsonElement?> GetThirdPartyDetailsAsync(string thirdSelfHref)
{
if (string.IsNullOrWhiteSpace(thirdSelfHref))
{
return null;
}
using var req = new HttpRequestMessage(HttpMethod.Get, thirdSelfHref);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.third+json; version=3"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET third");
if (!resp.IsSuccessStatusCode)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
return doc.RootElement.Clone();
}
private static void AddThirdDifferenceWarning(
List<string> warnings,
string fieldName,
string? complaintValue,
string? gestionaValue,
bool normalizeAsCode = false)
{
if (string.IsNullOrWhiteSpace(complaintValue))
{
return;
}
var left = normalizeAsCode
? NormalizeKey(complaintValue)
: NormalizeForComparison(complaintValue);
var right = normalizeAsCode
? NormalizeKey(gestionaValue)
: NormalizeForComparison(gestionaValue);
if (string.Equals(left, right, StringComparison.Ordinal))
{
return;
}
warnings.Add(
$"El campo {fieldName} no coincide con Gestiona (denuncia: '{FormatWarningValue(complaintValue)}'; Gestiona: '{FormatWarningValue(gestionaValue)}').");
}
private static string NormalizeForComparison(string? value)
{
return string.Join(
' ',
(value ?? string.Empty)
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToUpperInvariant();
}
private static string FormatWarningValue(string? value)
{
var normalized = string.Join(
' ',
(value ?? string.Empty)
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
if (string.IsNullOrWhiteSpace(normalized))
{
return "sin dato";
}
const int maxLength = 80;
return normalized.Length <= maxLength
? normalized
: normalized[..maxLength] + "...";
}
private static ThirdPartyIdentityData NormalizeThirdParty(ThirdPartyIdentityData thirdParty)
{
if (!thirdParty.IsAnonymous)
{
var documentId = (thirdParty.DocumentId ?? string.Empty).Trim().ToUpperInvariant();
var firstName = (thirdParty.FirstName ?? string.Empty).Trim();
var lastName = (thirdParty.LastName ?? string.Empty).Trim();
var businessName = (thirdParty.BusinessName ?? string.Empty).Trim();
var isLegalEntity = thirdParty.IsLegalEntity ||
LooksLikeLegalEntityDocument(documentId) ||
(!string.IsNullOrWhiteSpace(businessName) &&
string.IsNullOrWhiteSpace(firstName));
return new ThirdPartyIdentityData
{
IsAnonymous = false,
IsLegalEntity = isLegalEntity,
DocumentId = documentId,
FirstName = isLegalEntity ? string.Empty : firstName,
LastName = isLegalEntity ? string.Empty : lastName,
BusinessName = businessName,
Email = (thirdParty.Email ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(),
NotificationPreference = thirdParty.NotificationPreference ?? string.Empty,
ElectronicNotification = thirdParty.ElectronicNotification ?? string.Empty,
PostalNotificationPreference = thirdParty.PostalNotificationPreference ?? string.Empty,
Address = thirdParty.Address
};
}
return new ThirdPartyIdentityData
{
IsAnonymous = true,
IsLegalEntity = false,
DocumentId = "00000000T",
FirstName = "Anonimo",
LastName = "-",
BusinessName = string.Empty,
Email = string.Empty,
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode,
NotificationPreference = thirdParty.NotificationPreference ?? string.Empty,
ElectronicNotification = thirdParty.ElectronicNotification ?? string.Empty,
PostalNotificationPreference = thirdParty.PostalNotificationPreference ?? string.Empty,
Address = null
};
}
private static bool CanCreateThirdParty(ThirdPartyIdentityData thirdParty)
{
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
{
return false;
}
return thirdParty.IsLegalEntity
? !string.IsNullOrWhiteSpace(thirdParty.BusinessName)
: !string.IsNullOrWhiteSpace(thirdParty.FirstName) &&
!string.IsNullOrWhiteSpace(thirdParty.LastName);
}
private static bool LooksLikeLegalEntityDocument(string documentId)
{
var value = (documentId ?? string.Empty).Trim().ToUpperInvariant();
return Regex.IsMatch(value, @"^[ABCDEFGHJKLMNPQRSUVW]\d{7}[A-Z0-9]$");
}
// --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) ---
private async Task<string> GetFilesAsync(object? filter = null)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files");
AddBasicHeaders(req);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.files-page+json"));
if (filter is not null)
{
var json = JsonSerializer.Serialize(filter);
req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files+json");
}
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET /rest/files");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return "{\"content\":[]}";
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"GET /rest/files: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
if (string.IsNullOrWhiteSpace(body))
{
return "{\"content\":[]}";
}
return body;
}
private static bool TryGetFilesContent(JsonDocument doc, out JsonElement content)
{
if (doc.RootElement.TryGetProperty("content", out var contentElement) &&
contentElement.ValueKind == JsonValueKind.Array)
{
content = contentElement;
return true;
}
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
content = doc.RootElement;
return true;
}
content = default;
return false;
}
/// <summary>
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas.
/// </summary>
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
{
_ = maxPages;
var json = await GetFilesAsync();
using var doc = JsonDocument.Parse(json);
if (!TryGetFilesContent(doc, out var content))
{
return "[]";
}
var sb = new StringBuilder();
sb.Append('[');
var first = true;
foreach (var item in content.EnumerateArray())
{
if (!first)
{
sb.Append(',');
}
sb.Append(item.GetRawText());
first = false;
}
sb.Append(']');
return sb.ToString();
}
/// <summary>
/// Busca un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD".
/// </summary>
public async Task<GestionaExpedienteInfo?> BuscarExpedientePorIdEnAsuntoAsync(int idDenuncia)
{
var needle = $"Denuncia {idDenuncia}-CD";
var json = await GetFilesAsync(new
{
subject = needle
});
using var doc = JsonDocument.Parse(json);
if (!TryGetFilesContent(doc, out var content) || content.GetArrayLength() == 0)
{
return null;
}
foreach (var item in content.EnumerateArray())
{
var expediente = BuildExpedienteInfo(item);
if (expediente is not null)
{
return expediente;
}
}
return null;
}
public async Task<GestionaExpedienteInfo?> BuscarExpedientePorCodigoAsync(string codigoExpediente)
{
if (string.IsNullOrWhiteSpace(codigoExpediente))
{
return null;
}
var normalizedCode = codigoExpediente.Trim();
var json = await GetFilesAsync(new
{
code = normalizedCode
});
using var doc = JsonDocument.Parse(json);
if (!TryGetFilesContent(doc, out var content) || content.GetArrayLength() == 0)
{
return null;
}
GestionaExpedienteInfo? first = null;
foreach (var item in content.EnumerateArray())
{
var expediente = BuildExpedienteInfo(item);
if (expediente is null)
{
continue;
}
if (first is null)
{
first = expediente;
}
if (string.Equals(
expediente.CodigoExpediente?.Trim(),
normalizedCode,
StringComparison.OrdinalIgnoreCase))
{
return expediente;
}
}
return first;
}
public async Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
return null;
}
using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl);
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {fileUrl}");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerExpedienteAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
return BuildExpedienteInfo(doc.RootElement, fileUrl);
}
private static GestionaExpedienteInfo? BuildExpedienteInfo(JsonElement item, string? fallbackFileUrl = null)
{
var code = GetJsonString(item, "code");
var freeTitle = GetJsonString(item, "free_title");
var subject = GetJsonString(item, "subject");
var selectableTitle = GetJsonString(item, "selectable_title");
var procedureName = GetJsonString(item, "procedure_name");
string? fileUrl = fallbackFileUrl;
if (item.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array)
{
foreach (var link in links.EnumerateArray())
{
if (link.TryGetProperty("rel", out var rel) &&
string.Equals(rel.GetString(), "file", StringComparison.OrdinalIgnoreCase) &&
link.TryGetProperty("href", out var href) &&
href.ValueKind == JsonValueKind.String)
{
fileUrl = href.GetString();
break;
}
}
}
if (string.IsNullOrWhiteSpace(fileUrl) &&
item.TryGetProperty("id", out var idProp) &&
idProp.ValueKind == JsonValueKind.String)
{
fileUrl = $"/rest/files/{idProp.GetString()}";
}
if (string.IsNullOrWhiteSpace(fileUrl))
{
return null;
}
return new GestionaExpedienteInfo
{
FileUrl = fileUrl,
CodigoExpediente = code,
FreeTitle = FirstNonEmpty(freeTitle, subject, selectableTitle, procedureName)
};
}
public async Task<GestionaAuditInfo?> ObtenerUltimaAuditoriaExpedienteAsync(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
return null;
}
var auditUrl = $"{fileUrl.TrimEnd('/')}/audit";
using var req = new HttpRequestMessage(HttpMethod.Get, auditUrl);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.audit.entries-page"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {auditUrl}");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerUltimaAuditoriaExpedienteAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) ||
content.ValueKind != JsonValueKind.Array ||
content.GetArrayLength() == 0)
{
return null;
}
GestionaAuditInfo? latest = null;
foreach (var item in content.EnumerateArray())
{
var entryDate = GetJsonDateTimeOffset(item, "entry_date");
if (entryDate is null)
{
continue;
}
if (latest?.Fecha is not null && entryDate.Value <= latest.Fecha.Value)
{
continue;
}
latest = new GestionaAuditInfo
{
Fecha = entryDate,
Mensaje = GetJsonString(item, "message")
};
}
return latest;
}
public async Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync(
string nif,
DateTimeOffset? desde = null,
DateTimeOffset? hasta = null,
int maxPages = 1,
int maxResults = 30,
int maxParallel = 6
)
{
if (string.IsNullOrWhiteSpace(nif))
throw new ArgumentException("NIF obligatorio.", nameof(nif));
nif = nif.Trim().ToUpperInvariant();
_ = maxPages;
var resultados = new List<ExpedienteTerceroDto>();
var json = await GetFilesAsync(new
{
dni = nif
});
using var doc = JsonDocument.Parse(json);
if (!TryGetFilesContent(doc, out var content) || content.GetArrayLength() == 0)
{
return resultados;
}
foreach (var item in content.EnumerateArray())
{
if (resultados.Count >= maxResults)
{
break;
}
var creation = GetJsonDateTimeOffset(item, "creation_date");
var updated = GetFirstJsonDateTimeOffset(
item,
"update_date",
"updated_at",
"modification_date",
"modified_at",
"last_update",
"last_modified");
if (desde.HasValue && creation.HasValue && creation.Value < desde.Value)
continue;
if (hasta.HasValue && creation.HasValue && creation.Value > hasta.Value)
continue;
string? fileUrl = null;
if (item.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array)
{
foreach (var l in links.EnumerateArray())
{
if (l.TryGetProperty("rel", out var relProp) &&
string.Equals(relProp.GetString(), "file", StringComparison.OrdinalIgnoreCase) &&
l.TryGetProperty("href", out var hrefProp))
{
fileUrl = hrefProp.GetString();
break;
}
}
}
if (string.IsNullOrWhiteSpace(fileUrl) &&
item.TryGetProperty("id", out var idProp) &&
idProp.ValueKind == JsonValueKind.String)
{
fileUrl = $"/rest/files/{idProp.GetString()}";
}
if (string.IsNullOrWhiteSpace(fileUrl))
continue;
var code = GetJsonString(item, "code");
var subject = GetJsonString(item, "subject");
var freeTitle = GetJsonString(item, "free_title");
var selectableTitle = GetJsonString(item, "selectable_title");
var procedureName = GetJsonString(item, "procedure_name");
var asunto = FirstNonEmpty(freeTitle, subject, selectableTitle, procedureName);
string? state = null;
if (item.TryGetProperty("state", out var pState) && pState.ValueKind == JsonValueKind.String)
state = pState.GetString();
else if (item.TryGetProperty("status", out var pStatus) && pStatus.ValueKind == JsonValueKind.String)
state = pStatus.GetString();
resultados.Add(new ExpedienteTerceroDto
{
FileUrl = fileUrl,
CodigoExpediente = code,
Asunto = asunto,
Procedimiento = procedureName,
FechaCreacion = creation,
FechaUltimaModificacion = updated ?? creation,
Estado = state
});
}
await EnrichExpedientesWithAuditAsync(resultados, maxParallel);
return resultados;
}
private async Task EnrichExpedientesWithAuditAsync(
IReadOnlyList<ExpedienteTerceroDto> expedientes,
int maxParallel)
{
if (expedientes.Count == 0)
{
return;
}
var parallelism = Math.Clamp(maxParallel, 1, 6);
using var gate = new SemaphoreSlim(parallelism, parallelism);
var tasks = expedientes
.Where(expediente => !string.IsNullOrWhiteSpace(expediente.FileUrl))
.Select(async expediente =>
{
await gate.WaitAsync();
try
{
var audit = await ObtenerUltimaAuditoriaExpedienteAsync(expediente.FileUrl);
if (audit is null)
{
return;
}
if (audit.Fecha is not null)
{
expediente.FechaUltimaModificacion = audit.Fecha;
}
if (!string.IsNullOrWhiteSpace(audit.Mensaje))
{
expediente.UltimaAuditoriaMensaje = audit.Mensaje;
}
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"No se ha podido consultar la auditoria del expediente {FileUrl}.",
expediente.FileUrl);
}
finally
{
gate.Release();
}
});
await Task.WhenAll(tasks);
}
private async Task TryEnsureThirdAddressAsync(string thirdSelfHref, ThirdPartyAddressData address)
{
if (!address.HasAnyValue)
{
return;
}
if (await ThirdHasAddressesAsync(thirdSelfHref))
{
return;
}
var addressPayload = await BuildAddressPayloadAsync(address);
if (addressPayload is null)
{
return;
}
var payload = new Dictionary<string, object?>
{
["content"] = new[] { addressPayload }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.third-addresses+json");
content.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue("version", "2"));
using var req = new HttpRequestMessage(HttpMethod.Put, $"{thirdSelfHref.TrimEnd('/')}/addresses")
{
Content = content
};
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{thirdSelfHref.TrimEnd('/')}/addresses");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var resp = await _http.SendAsync(req);
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return false;
}
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array &&
content.GetArrayLength() > 0;
}
private async Task<Dictionary<string, object?>?> BuildAddressPayloadAsync(ThirdPartyAddressData address)
{
var provinceCode = await ResolveProvinceCodeAsync(address.Province, NormalizeCountryCode(address.CountryCode));
var townHref = await ResolveTownHrefAsync(provinceCode, address.Municipality);
if (string.IsNullOrWhiteSpace(address.Street) &&
string.IsNullOrWhiteSpace(address.Municipality) &&
string.IsNullOrWhiteSpace(address.ZipCode))
{
return null;
}
var payload = new Dictionary<string, object?>
{
["address"] = address.Street,
["number"] = address.Number,
["floor"] = address.Floor,
["door"] = address.Door,
["block"] = address.Block,
["stair"] = address.Stair,
["zipcode"] = address.ZipCode,
["country"] = NormalizeCountryCode(address.CountryCode),
["province"] = provinceCode,
["type_of_road"] = string.IsNullOrWhiteSpace(address.RoadTypeCode) ? "CL" : address.RoadTypeCode,
["default_address"] = true,
};
if (!string.IsNullOrWhiteSpace(townHref))
{
payload["links"] = new[]
{
new Dictionary<string, string>
{
["rel"] = "town",
["href"] = townHref
}
};
}
else if (!string.IsNullOrWhiteSpace(address.Municipality))
{
payload["zone"] = address.Municipality.Trim();
}
return payload;
}
private async Task<string?> ResolveProvinceCodeAsync(string? province, string countryCode)
{
if (string.IsNullOrWhiteSpace(province))
{
return null;
}
var normalizedSearch = NormalizeKey(province);
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/provinces");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var collection = doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array
? content
: doc.RootElement;
if (collection.ValueKind != JsonValueKind.Array)
{
return province.Trim().ToUpperInvariant();
}
foreach (var item in collection.EnumerateArray())
{
var code = item.TryGetProperty("code", out var codeProp) ? codeProp.GetString() : null;
var name = item.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
var itemCountry = item.TryGetProperty("country_code", out var countryProp) ? countryProp.GetString() : null;
if (!string.IsNullOrWhiteSpace(itemCountry) &&
!string.Equals(NormalizeCountryCode(itemCountry), countryCode, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (NormalizeKey(code) == normalizedSearch || NormalizeKey(name) == normalizedSearch)
{
return code ?? province.Trim().ToUpperInvariant();
}
}
return province.Trim().ToUpperInvariant();
}
private async Task<string?> ResolveTownHrefAsync(string? provinceCode, string? municipality)
{
if (string.IsNullOrWhiteSpace(provinceCode) || string.IsNullOrWhiteSpace(municipality))
{
return null;
}
using var req = new HttpRequestMessage(HttpMethod.Get, $"/rest/provinces/{Uri.EscapeDataString(provinceCode)}/towns");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var resp = await _http.SendAsync(req);
if (!resp.IsSuccessStatusCode)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var collection = doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array
? content
: doc.RootElement;
if (collection.ValueKind != JsonValueKind.Array)
{
return null;
}
var normalizedSearch = NormalizeKey(municipality);
foreach (var item in collection.EnumerateArray())
{
var name = item.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
if (NormalizeKey(name) != normalizedSearch)
{
continue;
}
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var link in links.EnumerateArray())
{
if (link.TryGetProperty("rel", out var relProp) &&
string.Equals(relProp.GetString(), "self", StringComparison.OrdinalIgnoreCase) &&
link.TryGetProperty("href", out var hrefProp))
{
return hrefProp.GetString();
}
}
}
return null;
}
private static string? GetJsonString(JsonElement item, string propertyName)
{
return item.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static DateTimeOffset? GetFirstJsonDateTimeOffset(JsonElement item, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var value = GetJsonDateTimeOffset(item, propertyName);
if (value is not null)
{
return value;
}
}
return null;
}
private static DateTimeOffset? GetJsonDateTimeOffset(JsonElement item, string propertyName)
{
if (!item.TryGetProperty(propertyName, out var property))
{
return null;
}
if (property.ValueKind == JsonValueKind.Number &&
property.TryGetInt64(out var timestamp))
{
return DateTimeOffset.FromUnixTimeSeconds(timestamp);
}
if (property.ValueKind != JsonValueKind.String)
{
return null;
}
var raw = property.GetString();
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestampString))
{
return DateTimeOffset.FromUnixTimeSeconds(timestampString);
}
return DateTimeOffset.TryParse(
raw.Replace("Z", "+00:00", StringComparison.Ordinal),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed)
? parsed
: null;
}
private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static (string FirstSurname, string? SecondSurname) SplitSurnames(string apellidos)
{
var parts = (apellidos ?? string.Empty)
.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return ("-", null);
}
if (parts.Length == 1)
{
return (parts[0], null);
}
return (parts[0], string.Join(' ', parts.Skip(1)));
}
private static string BuildNotificationChannel(ThirdPartyIdentityData thirdParty)
{
var notificationText = NormalizeKey(string.Join(
' ',
thirdParty.NotificationPreference,
thirdParty.ElectronicNotification,
thirdParty.PostalNotificationPreference));
var requestsPostal =
notificationText.Contains("CORREO POSTAL", StringComparison.Ordinal) ||
notificationText.Contains("POSTAL", StringComparison.Ordinal);
var requestsElectronic =
notificationText.Contains("ELECTRONICA", StringComparison.Ordinal) ||
notificationText.Contains("TELEMATICA", StringComparison.Ordinal) ||
notificationText.Contains("ONLINE", StringComparison.Ordinal);
if (requestsPostal && thirdParty.Address?.HasAnyValue == true && !requestsElectronic)
{
return "PAPER";
}
return "TELEMATIC";
}
private static string? GuessNifType(string nif, bool isLegalEntity)
{
var value = (nif ?? string.Empty).Trim().ToUpperInvariant();
if (isLegalEntity && Regex.IsMatch(value, @"^[A-Z]\d{7}[A-Z0-9]$"))
{
return "CIF";
}
if (value.StartsWith("X", StringComparison.Ordinal) ||
value.StartsWith("Y", StringComparison.Ordinal) ||
value.StartsWith("Z", StringComparison.Ordinal))
{
return "NIE";
}
if (Regex.IsMatch(value, @"^\d{7,8}[A-Z0-9]$"))
{
return "NIF";
}
return null;
}
private static string NormalizeCountryCode(string? country)
{
var value = NormalizeKey(country);
return value switch
{
"" => "ESP",
"es" or "esp" or "espana" or "españa" or "spain" => "ESP",
"prt" or "pt" or "portugal" => "PRT",
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
_ => "ESP",
};
}
private static string NormalizeKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var character in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToUpperInvariant(character) : ' ');
}
return string.Join(' ', builder.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
}
}