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 _logger; public GestionaService( HttpClient http, IOptions optsAccessor, ILogger 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 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 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 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> 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 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 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 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 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 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 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 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 { ["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> 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(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(StringComparer.Ordinal); using var doc = JsonDocument.Parse(body); var set = new HashSet(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 AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty) { if (thirdParty is null) throw new ArgumentNullException(nameof(thirdParty)); thirdParty = NormalizeThirdParty(thirdParty); var warnings = new List(); 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> BuildThirdPartyDifferenceWarningsAsync( string thirdSelfHref, ThirdPartyIdentityData thirdParty) { var warnings = new List(); 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 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 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 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; } /// /// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas. /// public async Task 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(); } /// /// Busca un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD". /// public async Task 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 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 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 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> 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(); 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 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 { ["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 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?> 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 { ["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 { ["rel"] = "town", ["href"] = townHref } }; } else if (!string.IsNullOrWhiteSpace(address.Municipality)) { payload["zone"] = address.Municipality.Trim(); } return payload; } private async Task 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 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)); } } }