Nombres de circuitos actualizados

controlador de datos de OPE estructurado
This commit is contained in:
2026-09-14 14:26:43 +02:00
parent 14fffb1501
commit 7f3780497f
19 changed files with 321 additions and 198 deletions

View File

@@ -336,7 +336,23 @@ public sealed class DenunciasController : ControllerBase
SexoDenunciante: ResolveReportField(denuncia, denuncia.Sexo, "sexo"), SexoDenunciante: ResolveReportField(denuncia, denuncia.Sexo, "sexo"),
AutorizaRemisionDenuncia: ResolveReportField(denuncia, denuncia.AutorizaRemision, "autorizacion para remitir su denuncia", "autoriza remision de la denuncia"), AutorizaRemisionDenuncia: ResolveReportField(denuncia, denuncia.AutorizaRemision, "autorizacion para remitir su denuncia", "autoriza remision de la denuncia"),
AutorizaNotificacionesViaSms: sms, AutorizaNotificacionesViaSms: sms,
PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento)); PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento),
DetalleDenunciado: ResolveReportField(denuncia, denuncia.DenunciadoDetalle, "especifique a quien denuncia", "detalle de la persona u organismo denunciado"),
PreferenciaNotificacion: preferenciaNotificacion,
AutorizaNotificacionesElectronicas: ResolveReportField(
denuncia,
denuncia.Notificacion_Electronica,
"notificaciones electronicas",
"notificacion electronica",
"autorizacion para notificaciones electronicas"),
PreferenciaNotificacionPostal: ResolveReportField(
denuncia,
denuncia.NotificacionPostal,
"autorizo recibir notificaciones via correo postal",
"notificaciones via correo postal",
"correo postal"),
PreferenciaSeguimientoOnline: seguimiento,
ComentariosComunicaciones: denuncia.Comments?.Trim() ?? string.Empty);
} }
private static GestionaExternalFieldsResponse ToExternalGestionaFields(GestionaComplaintFieldsResponse source) private static GestionaExternalFieldsResponse ToExternalGestionaFields(GestionaComplaintFieldsResponse source)
@@ -355,7 +371,13 @@ public sealed class DenunciasController : ControllerBase
["sexoDenunciante"] = StringField(source.SexoDenunciante), ["sexoDenunciante"] = StringField(source.SexoDenunciante),
["autorizaRemisionDenuncia"] = StringField(source.AutorizaRemisionDenuncia), ["autorizaRemisionDenuncia"] = StringField(source.AutorizaRemisionDenuncia),
["autorizaNotificacionesViaSms"] = StringField(source.AutorizaNotificacionesViaSms), ["autorizaNotificacionesViaSms"] = StringField(source.AutorizaNotificacionesViaSms),
["preferenciaNotificacionSeguimientoDenuncia"] = StringField(source.PreferenciaNotificacionSeguimientoDenuncia) ["preferenciaNotificacionSeguimientoDenuncia"] = StringField(source.PreferenciaNotificacionSeguimientoDenuncia),
["detalleDenunciado"] = StringField(source.DetalleDenunciado),
["preferenciaNotificacion"] = StringField(source.PreferenciaNotificacion),
["autorizaNotificacionesElectronicas"] = StringField(source.AutorizaNotificacionesElectronicas),
["preferenciaNotificacionPostal"] = StringField(source.PreferenciaNotificacionPostal),
["preferenciaSeguimientoOnline"] = StringField(source.PreferenciaSeguimientoOnline),
["comentariosComunicaciones"] = StringField(source.ComentariosComunicaciones)
}); });
} }

View File

@@ -36,16 +36,16 @@
"Gestiona": { "Gestiona": {
"ApiBase": "https://02.g3stiona.com", "ApiBase": "https://02.g3stiona.com",
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c", "AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
"ProcedureName": "Procedimiento test 2", "ProcedureName": "Procedimiento Genérico",
"ExternalProcedureName": "", "ExternalProcedureName": "Denuncia Pruebas V2",
"ExternalProcedureSiaCode": "3109963", "ExternalProcedureSiaCode": "",
"ManagementUnitGroupCode": "700", "ManagementUnitGroupCode": "700",
"CircuitTemplateName": "Firma automatizada", "CircuitTemplateName": "Firma automatizada",
"CircuitNewComplaintTemplateName": "CT-Nueva denuncia", "CircuitNewComplaintTemplateName": "CT-Nueva denuncia buzon",
"CircuitUpdateSajTemplateName": "CT-Actualización denuncia SAJ", "CircuitUpdateSajTemplateName": "CT-Actualización denuncia Buzon SAJ",
"CircuitUpdateSdiTemplateName": "CT-Actualización denuncia SDI", "CircuitUpdateSdiTemplateName": "CT-Actualización denuncia buzon SDI",
"CircuitCommunicationSajTemplateName": "CT-Comunicación SAJ a denunciante", "CircuitCommunicationSajTemplateName": "CT-Comunicación SAJ buzon a denunciante",
"CircuitCommunicationSdiTemplateName": "CT-Comunicación SDI a denunciante", "CircuitCommunicationSdiTemplateName": "CT-Comunicación SDI buzon a denunciante",
"CircuitSignerStampTitle": "oaaf-complaints-tramit", "CircuitSignerStampTitle": "oaaf-complaints-tramit",
"CircuitVersion": "2", "CircuitVersion": "2",
"DocumentMetadataLanguage": "es", "DocumentMetadataLanguage": "es",

View File

@@ -1,46 +1,63 @@
using ApiOPE.Configuration;
using ApiOPE.Contracts; using ApiOPE.Contracts;
using ApiOPE.Services; using ApiOPE.Services;
using Microsoft.Extensions.Options;
namespace ApiOPE.Tests; namespace ApiOPE.Tests;
public sealed class OpeFieldMapperTests public sealed class OpeFieldMapperTests
{ {
[Fact] [Fact]
public void Map_UsesConfiguredOrderAndFieldNames() public void MapBlock_MapsFirstBlockInAgreedOrder()
{ {
var options = Options.Create(new OpeOptions var mapper = CreateMapper();
{ var source = CreateBlockSource();
OutputFields = ["numeroDenunciaCanal", "fechaDenuncia", "resumenDenuncia"]
});
var mapper = new OpeFieldMapper(options);
var source = new InternalGestionaFieldsResponse(
new Dictionary<string, OpeFieldValue>
{
["fechaDenuncia"] = new("STRING", "2026-07-10"),
["numeroDenunciaCanal"] = new("STRING", "116"),
["resumenDenuncia"] = new("STRING", "Prueba")
});
var result = mapper.Map(source); var result = mapper.MapBlock(source, 1, belongsToComplaintsChannel: true);
Assert.Equal("116", result.Data["FIELD_0"].Value); Assert.Equal("SI", result.Data["FIELD_0"].Value);
Assert.Equal("2026-07-10", result.Data["FIELD_1"].Value); Assert.Equal("2026-09-11", result.Data["FIELD_1"].Value);
Assert.Equal("Prueba", result.Data["FIELD_2"].Value); Assert.Equal("123", result.Data["FIELD_2"].Value);
Assert.Equal("Ambito", result.Data["FIELD_9"].Value);
Assert.Equal(10, result.Data.Count);
} }
[Fact] [Fact]
public void Map_RejectsMissingInternalField() public void MapBlock_MapsSecondBlockAndLeavesLastTwoFieldsEmpty()
{ {
var mapper = new OpeFieldMapper(Options.Create(new OpeOptions var mapper = CreateMapper();
{ var source = CreateBlockSource();
OutputFields = ["fechaDenuncia"]
}));
var source = new InternalGestionaFieldsResponse(
new Dictionary<string, OpeFieldValue>());
Assert.Throws<InvalidDataException>(() => mapper.Map(source)); var result = mapper.MapBlock(source, 2, belongsToComplaintsChannel: true);
Assert.Equal("Ayuntamiento", result.Data["FIELD_0"].Value);
Assert.Equal("Comentarios", result.Data["FIELD_7"].Value);
Assert.Equal(string.Empty, result.Data["FIELD_8"].Value);
Assert.Equal(string.Empty, result.Data["FIELD_9"].Value);
}
[Fact]
public void MapBlock_ReturnsNoAndEmptyFieldsForUnknownExpedient()
{
var mapper = CreateMapper();
var firstBlock = mapper.MapBlock(null, 1, belongsToComplaintsChannel: false);
var secondBlock = mapper.MapBlock(null, 2, belongsToComplaintsChannel: false);
Assert.Equal("NO", firstBlock.Data["FIELD_0"].Value);
Assert.All(firstBlock.Data.Where(field => field.Key != "FIELD_0"), field => Assert.Empty(field.Value.Value));
Assert.All(secondBlock.Data, field => Assert.Empty(field.Value.Value));
}
[Fact]
public void MapBlock_ReturnsYesAndEmptyDataForPurgedChannelExpedient()
{
var mapper = CreateMapper();
var firstBlock = mapper.MapBlock(null, 1, belongsToComplaintsChannel: true);
var secondBlock = mapper.MapBlock(null, 2, belongsToComplaintsChannel: true);
Assert.Equal("SI", firstBlock.Data["FIELD_0"].Value);
Assert.All(firstBlock.Data.Where(field => field.Key != "FIELD_0"), field => Assert.Empty(field.Value.Value));
Assert.All(secondBlock.Data, field => Assert.Empty(field.Value.Value));
} }
[Theory] [Theory]
@@ -54,39 +71,28 @@ public sealed class OpeFieldMapperTests
Assert.Equal(expectedPath, lookup!.RelativePath); Assert.Equal(expectedPath, lookup!.RelativePath);
} }
[Theory] private static OpeFieldMapper CreateMapper()
[InlineData(0)] => new();
[InlineData(11)]
public void OptionsValidator_RejectsInvalidOutputCount(int count)
{
var options = new OpeOptions
{
ClientToken = "client",
SecretKey = "test-secret-key-with-more-than-32-characters",
PublicBaseUrl = "https://ope.example.test",
OrganizationId = "organization",
OrganizationDir3 = "dir3",
OrganizationCif = "cif",
OutputFields = Enumerable.Repeat("fechaDenuncia", count).ToList()
};
Assert.False(OpeOptionsValidator.IsValid(options, isDevelopment: false)); private static InternalGestionaFieldsResponse CreateBlockSource()
} => new(new Dictionary<string, OpeFieldValue>
[Fact]
public void OptionsValidator_AcceptsTenUniqueConfiguredFields()
{ {
var options = new OpeOptions ["fechaDenuncia"] = new("STRING", "2026-09-11"),
{ ["numeroDenunciaCanal"] = new("STRING", "123"),
ClientToken = "client", ["sexoDenunciante"] = new("STRING", "Mujer"),
SecretKey = "test-secret-key-with-more-than-32-characters", ["preferenciaNotificacion"] = new("STRING", "Electronica"),
PublicBaseUrl = "https://ope.example.test", ["autorizaNotificacionesElectronicas"] = new("STRING", "Si"),
OrganizationId = "organization", ["autorizaNotificacionesViaSms"] = new("STRING", "No"),
OrganizationDir3 = "dir3", ["preferenciaNotificacionPostal"] = new("STRING", "No"),
OrganizationCif = "cif", ["preferenciaSeguimientoOnline"] = new("STRING", "Si"),
OutputFields = OpeFieldMapper.SupportedInternalFields.Take(10).ToList() ["ambitoCompetencias"] = new("STRING", "Ambito"),
}; ["aQuienDenuncia"] = new("STRING", "Ayuntamiento"),
["detalleDenunciado"] = new("STRING", "Detalle"),
Assert.True(OpeOptionsValidator.IsValid(options, isDevelopment: false)); ["resumenDenuncia"] = new("STRING", "Resumen"),
} ["solicitaProteccion"] = new("STRING", "Si"),
["lugarHechos"] = new("STRING", "Cadiz"),
["fechaHechos"] = new("STRING", "10/09/2026"),
["autorizaRemisionDenuncia"] = new("STRING", "Si"),
["comentariosComunicaciones"] = new("STRING", "Comentarios")
});
} }

View File

@@ -44,7 +44,7 @@ public sealed class OpeProtocolTests
var context = CreateHttpContext( var context = CreateHttpContext(
"application/vnd.generic-operation-request+json; charset=utf-8", "application/vnd.generic-operation-request+json; charset=utf-8",
""" """
{"data":{"FIELD_0":{"type":"STRING","value":"53/2026"}}} {"data":{"FIELD_0":{"type":"STRING","value":"53/2026"},"FIELD_1":{"type":"STRING","value":"1"}}}
"""); """);
var result = await GenericOperationRequestReader.ReadAsync( var result = await GenericOperationRequestReader.ReadAsync(
@@ -53,6 +53,7 @@ public sealed class OpeProtocolTests
Assert.True(result.IsValid); Assert.True(result.IsValid);
Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value); Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value);
Assert.Equal("1", result.Request.Data["FIELD_1"].Value);
} }
[Fact] [Fact]
@@ -61,7 +62,7 @@ public sealed class OpeProtocolTests
var context = CreateHttpContext( var context = CreateHttpContext(
OpeMediaTypes.GenericOperationRequest, OpeMediaTypes.GenericOperationRequest,
""" """
{"data":{"FIELD_0":{"type":"STRING","value":"68/2026"}},"links":null} {"data":{"FIELD_0":{"type":"STRING","value":"68/2026"},"FIELD_1":{"type":"STRING","value":"2"}},"links":null}
"""); """);
var result = await GenericOperationRequestReader.ReadAsync( var result = await GenericOperationRequestReader.ReadAsync(
@@ -78,7 +79,7 @@ public sealed class OpeProtocolTests
var context = CreateHttpContext( var context = CreateHttpContext(
OpeMediaTypes.GenericOperationRequest, OpeMediaTypes.GenericOperationRequest,
""" """
{"data":{"FIELD_1":{"type":"STRING","value":"unexpected"}}} {"data":{"FIELD_2":{"type":"STRING","value":"unexpected"}}}
"""); """);
var result = await GenericOperationRequestReader.ReadAsync( var result = await GenericOperationRequestReader.ReadAsync(
@@ -86,8 +87,26 @@ public sealed class OpeProtocolTests
CancellationToken.None); CancellationToken.None);
Assert.False(result.IsValid); Assert.False(result.IsValid);
Assert.Equal(OpeFieldErrors.NotExpected, result.Errors["FIELD_1"]); Assert.Equal(OpeFieldErrors.NotExpected, result.Errors["FIELD_2"]);
Assert.Equal(OpeFieldErrors.Expected, result.Errors["FIELD_0"]); Assert.Equal(OpeFieldErrors.Expected, result.Errors["FIELD_0"]);
Assert.Equal(OpeFieldErrors.Expected, result.Errors["FIELD_1"]);
}
[Fact]
public async Task RequestReader_RejectsUnknownBlock()
{
var context = CreateHttpContext(
OpeMediaTypes.GenericOperationRequest,
"""
{"data":{"FIELD_0":{"type":"STRING","value":"68/2026"},"FIELD_1":{"type":"STRING","value":"3"}}}
""");
var result = await GenericOperationRequestReader.ReadAsync(
context.Request,
CancellationToken.None);
Assert.False(result.IsValid);
Assert.Equal(OpeFieldErrors.UnexpectedFormat, result.Errors["FIELD_1"]);
} }
private static DefaultHttpContext CreateHttpContext(string contentType, string body) private static DefaultHttpContext CreateHttpContext(string contentType, string body)

View File

@@ -34,6 +34,10 @@ X-Organization-CIF: {{OrganizationCif}}
"FIELD_0": { "FIELD_0": {
"type": "STRING", "type": "STRING",
"value": "53/2026" "value": "53/2026"
},
"FIELD_1": {
"type": "STRING",
"value": "1"
} }
} }
} }

View File

@@ -1,5 +1,3 @@
using ApiOPE.Services;
namespace ApiOPE.Configuration; namespace ApiOPE.Configuration;
public sealed class OpeOptions public sealed class OpeOptions
@@ -25,8 +23,6 @@ public sealed class OpeOptions
public string OrganizationDir3 { get; set; } = string.Empty; public string OrganizationDir3 { get; set; } = string.Empty;
public string OrganizationCif { get; set; } = string.Empty; public string OrganizationCif { get; set; } = string.Empty;
public List<string> OutputFields { get; set; } = [];
} }
public static class OpeOptionsValidator public static class OpeOptionsValidator
@@ -41,11 +37,7 @@ public static class OpeOptionsValidator
string.IsNullOrWhiteSpace(options.OrganizationDir3) || string.IsNullOrWhiteSpace(options.OrganizationDir3) ||
string.IsNullOrWhiteSpace(options.OrganizationCif) || string.IsNullOrWhiteSpace(options.OrganizationCif) ||
options.TokenMaxAgeSeconds is < 1 or > 900 || options.TokenMaxAgeSeconds is < 1 or > 900 ||
options.ClockSkewSeconds is < 0 or > 300 || options.ClockSkewSeconds is < 0 or > 300)
options.OutputFields is null ||
options.OutputFields.Count is < 1 or > 10 ||
options.OutputFields.Distinct(StringComparer.Ordinal).Count() != options.OutputFields.Count ||
options.OutputFields.Any(field => !OpeFieldMapper.SupportedInternalFields.Contains(field)))
{ {
return false; return false;
} }

View File

@@ -15,13 +15,19 @@ public sealed class OpeController : ControllerBase
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly OpeOptions _options; private readonly OpeOptions _options;
private readonly InternalDenunciasClient _internalApi;
private readonly OpeFieldMapper _fieldMapper;
private readonly ILogger<OpeController> _logger; private readonly ILogger<OpeController> _logger;
public OpeController( public OpeController(
IOptions<OpeOptions> options, IOptions<OpeOptions> options,
InternalDenunciasClient internalApi,
OpeFieldMapper fieldMapper,
ILogger<OpeController> logger) ILogger<OpeController> logger)
{ {
_options = options.Value; _options = options.Value;
_internalApi = internalApi;
_fieldMapper = fieldMapper;
_logger = logger; _logger = logger;
} }
@@ -65,6 +71,7 @@ public sealed class OpeController : ControllerBase
} }
var identifier = parsedRequest.Request!.Data["FIELD_0"].Value; var identifier = parsedRequest.Request!.Data["FIELD_0"].Value;
var block = int.Parse(parsedRequest.Request.Data["FIELD_1"].Value, System.Globalization.CultureInfo.InvariantCulture);
if (!DenunciaLookupParser.TryParse(identifier, out var lookup) || lookup is null) if (!DenunciaLookupParser.TryParse(identifier, out var lookup) || lookup is null)
{ {
return Error( return Error(
@@ -76,25 +83,72 @@ public sealed class OpeController : ControllerBase
}); });
} }
// Respuesta temporal de pruebas: no consulta la API interna ni exige try
// que el expediente exista. FIELD_0 conserva exactamente el valor {
// recibido y el resto de campos devuelve un valor ficticio. var result = await _internalApi.GetFieldsAsync(
lookup,
requestContext.TransactionId,
cancellationToken);
if (result.Status == InternalLookupStatus.NotFound)
{
_logger.LogInformation( _logger.LogInformation(
"Respuesta OPE de prueba para el expediente solicitado {Expediente}. TransactionId={TransactionId}", "El expediente {Expediente} no pertenece al canal de denuncias. Bloque={Block}; TransactionId={TransactionId}",
identifier, identifier,
block,
requestContext.TransactionId); requestContext.TransactionId);
return Json(
var testData = new Dictionary<string, OpeFieldValue>(StringComparer.Ordinal) _fieldMapper.MapBlock(null, block, belongsToComplaintsChannel: false),
{ OpeMediaTypes.GenericOperationResponse);
["FIELD_0"] = new OpeFieldValue("STRING", identifier)
};
for (var index = 1; index < _options.OutputFields.Count; index++)
{
testData[$"FIELD_{index}"] = new OpeFieldValue("STRING", "dato de prueba");
} }
return Json(new OpeDataEnvelope(testData), OpeMediaTypes.GenericOperationResponse); if (result.Status == InternalLookupStatus.DataUnavailable || result.Fields is null)
{
_logger.LogInformation(
"El expediente {Expediente} pertenece al canal, pero sus datos ya no estan disponibles. Bloque={Block}; TransactionId={TransactionId}",
identifier,
block,
requestContext.TransactionId);
return Json(
_fieldMapper.MapBlock(null, block, belongsToComplaintsChannel: true),
OpeMediaTypes.GenericOperationResponse);
}
return Json(
_fieldMapper.MapBlock(result.Fields, block, belongsToComplaintsChannel: true),
OpeMediaTypes.GenericOperationResponse);
}
catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested)
{
_logger.LogWarning(
"Timeout consultando ApiDenuncias para una OPE. TransactionId={TransactionId}",
requestContext.TransactionId);
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
}
catch (HttpRequestException exception)
{
_logger.LogWarning(
exception,
"No se ha podido conectar con ApiDenuncias. TransactionId={TransactionId}",
requestContext.TransactionId);
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
}
catch (InternalApiException exception)
{
_logger.LogWarning(
exception,
"ApiDenuncias ha devuelto una respuesta no utilizable. TransactionId={TransactionId}",
requestContext.TransactionId);
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
}
catch (InvalidDataException exception)
{
_logger.LogError(
exception,
"El contrato entre ApiOPE y ApiDenuncias no coincide. TransactionId={TransactionId}",
requestContext.TransactionId);
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
}
} }
private static ContentResult Error( private static ContentResult Error(

View File

@@ -43,6 +43,12 @@
"value": "REEMPLAZAR_EXPEDIENTE_NO_PURGADO", "value": "REEMPLAZAR_EXPEDIENTE_NO_PURGADO",
"type": "default", "type": "default",
"enabled": true "enabled": true
},
{
"key": "bloque_datos",
"value": "1",
"type": "default",
"enabled": true
} }
], ],
"_postman_variable_scope": "environment", "_postman_variable_scope": "environment",

View File

@@ -125,6 +125,10 @@
" if (!expediente || expediente.startsWith('REEMPLAZAR_')) {", " if (!expediente || expediente.startsWith('REEMPLAZAR_')) {",
" throw new Error('Debes indicar un expediente no purgado en expediente_gestiona');", " throw new Error('Debes indicar un expediente no purgado en expediente_gestiona');",
" }", " }",
" const bloque = pm.variables.get('bloque_datos');",
" if (bloque !== '1' && bloque !== '2') {",
" throw new Error('bloque_datos debe valer 1 o 2');",
" }",
"}", "}",
"", "",
"if (current.resetTransaction || !pm.collectionVariables.get('ope_transaction_id')) {", "if (current.resetTransaction || !pm.collectionVariables.get('ope_transaction_id')) {",
@@ -338,7 +342,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"data\": {\n \"FIELD_0\": {\n \"type\": \"STRING\",\n \"value\": \"{{expediente_gestiona}}\"\n }\n }\n}", "raw": "{\n \"data\": {\n \"FIELD_0\": {\n \"type\": \"STRING\",\n \"value\": \"{{expediente_gestiona}}\"\n },\n \"FIELD_1\": {\n \"type\": \"STRING\",\n \"value\": \"{{bloque_datos}}\"\n }\n },\n \"links\": null\n}",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@@ -346,7 +350,7 @@
} }
}, },
"url": "{{base_url}}/genericoperations", "url": "{{base_url}}/genericoperations",
"description": "Tercera llamada de Gestiona. FIELD_0 contiene el numero de expediente, por ejemplo 53/2026." "description": "Tercera llamada de Gestiona. FIELD_0 contiene el numero de expediente y FIELD_1 selecciona el bloque de datos 1 o 2."
}, },
"response": [] "response": []
} }

View File

@@ -21,6 +21,7 @@ Variables que debe rellenar cada probador
- organization_dir3: codigo DIR3 de la organizacion remitente. - organization_dir3: codigo DIR3 de la organizacion remitente.
- organization_cif: CIF de la organizacion remitente. - organization_cif: CIF de la organizacion remitente.
- expediente_gestiona: numero de un expediente de Gestiona que exista y cuyos datos diarios sigan disponibles, por ejemplo 53/2026. - expediente_gestiona: numero de un expediente de Gestiona que exista y cuyos datos diarios sigan disponibles, por ejemplo 53/2026.
- bloque_datos: bloque que se desea recuperar. Debe valer 1 o 2.
Las credenciales no se incluyen en este paquete. Deben facilitarse por un canal seguro y guardarse solo como valores locales del entorno de Postman. Las credenciales no se incluyen en este paquete. Deben facilitarse por un canal seguro y guardarse solo como valores locales del entorno de Postman.
@@ -38,6 +39,6 @@ Resultado esperado
------------------ ------------------
- Las tres respuestas devuelven HTTP 200. - Las tres respuestas devuelven HTTP 200.
- Los tests de Postman aparecen en verde. - Los tests de Postman aparecen en verde.
- La tercera respuesta contiene los campos disponibles del expediente solicitado. - La tercera respuesta contiene siempre FIELD_0 a FIELD_9 para el bloque solicitado.
Si la peticion no llega al servidor, comprobar que el equipo resuelve gesbuzpre.antifraudeandalucia.es y tiene acceso al puerto 7094. Si la peticion no llega al servidor, comprobar que el equipo resuelve gesbuzpre.antifraudeandalucia.es y tiene acceso al puerto 7094.

View File

@@ -9,8 +9,7 @@ permanecer en la red interna.
1. Gestiona consulta `GET /versions/current`. 1. Gestiona consulta `GET /versions/current`.
2. Gestiona consulta `GET /` y obtiene el bookmark de la operacion generica. 2. Gestiona consulta `GET /` y obtiene el bookmark de la operacion generica.
3. Gestiona llama a `POST /genericoperations` con el numero de expediente 3. Gestiona llama a `POST /genericoperations` con el numero de expediente
(`53/2026`) en `FIELD_0`. Para pruebas tambien se admite el identificador de (`53/2026`) en `FIELD_0` y el bloque solicitado (`1` o `2`) en `FIELD_1`.
denuncia (`116`).
4. `ApiOPE` consulta el endpoint protegido correspondiente de `ApiDenuncias`. 4. `ApiOPE` consulta el endpoint protegido correspondiente de `ApiDenuncias`.
5. `ApiOPE` transforma la respuesta interna al formato `FIELD_0`...`FIELD_9` y 5. `ApiOPE` transforma la respuesta interna al formato `FIELD_0`...`FIELD_9` y
la devuelve a Gestiona. la devuelve a Gestiona.
@@ -52,7 +51,6 @@ Ope__OrganizationDir3
Ope__OrganizationCif Ope__OrganizationCif
Ope__TokenMaxAgeSeconds Ope__TokenMaxAgeSeconds
Ope__ClockSkewSeconds Ope__ClockSkewSeconds
Ope__OutputFields__0 ... Ope__OutputFields__9
InternalApi__BaseUrl InternalApi__BaseUrl
InternalApi__ApiKey InternalApi__ApiKey
@@ -71,8 +69,8 @@ OpeBridge__ApiKeyHeaderName=X-ApiOPE-Key
incluido cualquier path base. El bookmark se construye a partir de ese valor. incluido cualquier path base. El bookmark se construye a partir de ese valor.
La aplicacion valida la configuracion al arrancar. Si faltan el secreto, los La aplicacion valida la configuracion al arrancar. Si faltan el secreto, los
datos de organizacion, la URL publica o el mapa de salida, no inicia para evitar datos de organizacion o la URL publica, no inicia para evitar publicar un
publicar un conector incompleto. conector incompleto.
### Prueba temporal de preproduccion ### Prueba temporal de preproduccion
@@ -96,26 +94,15 @@ volver a usar un entorno de produccion.
## Campos de salida ## Campos de salida
La API interna ofrece estos 12 campos: La operacion siempre devuelve `FIELD_0` a `FIELD_9` como valores `STRING`.
`FIELD_1=1` solicita el primer bloque: pertenencia al canal (`SI` o `NO`), fecha,
numero de denuncia, sexo, preferencias de notificacion y ambito. `FIELD_1=2`
solicita el segundo bloque: denunciado, detalle, resumen, proteccion, lugar,
fecha de los hechos, autorizacion de remision y comentarios; sus dos ultimas
posiciones quedan vacias.
```text Si el expediente no pertenece al canal, el primer bloque devuelve `NO` y el
fechaDenuncia resto vacio. Para el segundo bloque se devuelven los diez campos vacios.
numeroDenunciaCanal
aQuienDenuncia
resumenDenuncia
fechaHechos
lugarHechos
ambitoCompetencias
solicitaProteccion
sexoDenunciante
autorizaRemisionDenuncia
autorizaNotificacionesViaSms
preferenciaNotificacionSeguimientoDenuncia
```
OPE 1.0 admite como maximo 10 campos por operacion. Antes de configurar PRE hay
que acordar cuales diez se publican o dividir la consulta en dos operaciones.
El orden de `Ope__OutputFields__N` determina la correspondencia con `FIELD_N`.
## Publicacion ## Publicacion
@@ -129,8 +116,8 @@ El orden de `Ope__OutputFields__N` determina la correspondencia con `FIELD_N`.
5. Validar consecutivamente version, bookmarks y operacion generica, incluida 5. Validar consecutivamente version, bookmarks y operacion generica, incluida
la cabecera `Signature` de cada respuesta. la cabecera `Signature` de cada respuesta.
Los logs identifican fallos mediante `transaction_id`, pero nunca registran el El registro diagnostico de PRE incluye cabeceras y cuerpos de peticion y
JWT, el secreto compartido, la clave interna ni el contenido de la denuncia. respuesta. El JWT, las cookies y los secretos se ocultan siempre.
## Simulacion desde Postman ## Simulacion desde Postman

View File

@@ -14,7 +14,8 @@ public sealed record GenericOperationRequestResult(
public static class GenericOperationRequestReader public static class GenericOperationRequestReader
{ {
private const string ExpectedField = "FIELD_0"; private const string ExpedienteField = "FIELD_0";
private const string BlockField = "FIELD_1";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{ {
@@ -35,7 +36,7 @@ public static class GenericOperationRequestReader
OpeMediaTypes.GenericOperationRequest, OpeMediaTypes.GenericOperationRequest,
StringComparison.OrdinalIgnoreCase)) StringComparison.OrdinalIgnoreCase))
{ {
errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat; errors[ExpedienteField] = OpeFieldErrors.UnexpectedFormat;
return new GenericOperationRequestResult(null, errors); return new GenericOperationRequestResult(null, errors);
} }
@@ -49,19 +50,21 @@ public static class GenericOperationRequestReader
} }
catch (JsonException) catch (JsonException)
{ {
errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat; errors[ExpedienteField] = OpeFieldErrors.UnexpectedFormat;
return new GenericOperationRequestResult(null, errors); return new GenericOperationRequestResult(null, errors);
} }
if (envelope?.Data is null) if (envelope?.Data is null)
{ {
errors[ExpectedField] = OpeFieldErrors.Expected; errors[ExpedienteField] = OpeFieldErrors.Expected;
errors[BlockField] = OpeFieldErrors.Expected;
return new GenericOperationRequestResult(null, errors); return new GenericOperationRequestResult(null, errors);
} }
foreach (var field in envelope.Data) foreach (var field in envelope.Data)
{ {
if (!string.Equals(field.Key, ExpectedField, StringComparison.Ordinal)) if (!string.Equals(field.Key, ExpedienteField, StringComparison.Ordinal) &&
!string.Equals(field.Key, BlockField, StringComparison.Ordinal))
{ {
errors[field.Key] = OpeFieldErrors.NotExpected; errors[field.Key] = OpeFieldErrors.NotExpected;
continue; continue;
@@ -70,22 +73,26 @@ public static class GenericOperationRequestReader
if (field.Value is null || if (field.Value is null ||
!string.Equals(field.Value.Type, "STRING", StringComparison.Ordinal) || !string.Equals(field.Value.Type, "STRING", StringComparison.Ordinal) ||
string.IsNullOrWhiteSpace(field.Value.Value)) string.IsNullOrWhiteSpace(field.Value.Value))
{
errors[field.Key] = OpeFieldErrors.UnexpectedFormat;
continue;
}
if (string.Equals(field.Key, BlockField, StringComparison.Ordinal) &&
field.Value.Value.Trim() is not ("1" or "2"))
{ {
errors[field.Key] = OpeFieldErrors.UnexpectedFormat; errors[field.Key] = OpeFieldErrors.UnexpectedFormat;
} }
} }
if (!envelope.Data.ContainsKey(ExpectedField)) if (!envelope.Data.ContainsKey(ExpedienteField))
{ {
errors[ExpectedField] = OpeFieldErrors.Expected; errors[ExpedienteField] = OpeFieldErrors.Expected;
} }
if (envelope.Data.Count > 10) if (!envelope.Data.ContainsKey(BlockField))
{ {
foreach (var field in envelope.Data.Keys.Where(key => key != ExpectedField)) errors[BlockField] = OpeFieldErrors.Expected;
{
errors[field] = OpeFieldErrors.NotExpected;
}
} }
return new GenericOperationRequestResult(envelope, errors); return new GenericOperationRequestResult(envelope, errors);

View File

@@ -7,7 +7,8 @@ namespace ApiOPE.Services;
public enum InternalLookupStatus public enum InternalLookupStatus
{ {
Success, Success,
NotFound NotFound,
DataUnavailable
} }
public sealed record InternalLookupResult( public sealed record InternalLookupResult(
@@ -57,11 +58,16 @@ public sealed class InternalDenunciasClient
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
cancellationToken); cancellationToken);
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) if (response.StatusCode is HttpStatusCode.NotFound)
{ {
return new InternalLookupResult(InternalLookupStatus.NotFound, null); return new InternalLookupResult(InternalLookupStatus.NotFound, null);
} }
if (response.StatusCode is HttpStatusCode.Gone)
{
return new InternalLookupResult(InternalLookupStatus.DataUnavailable, null);
}
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
_logger.LogWarning( _logger.LogWarning(

View File

@@ -1,49 +1,71 @@
using ApiOPE.Configuration;
using ApiOPE.Contracts; using ApiOPE.Contracts;
using Microsoft.Extensions.Options;
namespace ApiOPE.Services; namespace ApiOPE.Services;
public sealed class OpeFieldMapper public sealed class OpeFieldMapper
{ {
public static readonly IReadOnlySet<string> SupportedInternalFields = new HashSet<string>( public OpeDataEnvelope MapBlock(
[ InternalGestionaFieldsResponse? source,
"fechaDenuncia", int block,
"numeroDenunciaCanal", bool belongsToComplaintsChannel)
"aQuienDenuncia",
"resumenDenuncia",
"fechaHechos",
"lugarHechos",
"ambitoCompetencias",
"solicitaProteccion",
"sexoDenunciante",
"autorizaRemisionDenuncia",
"autorizaNotificacionesViaSms",
"preferenciaNotificacionSeguimientoDenuncia"
],
StringComparer.Ordinal);
private readonly string[] _outputFields;
public OpeFieldMapper(IOptions<OpeOptions> options)
{ {
_outputFields = options.Value.OutputFields.ToArray(); if (!belongsToComplaintsChannel || source is null)
{
return EmptyBlock(block, belongsToComplaintsChannel);
} }
public OpeDataEnvelope Map(InternalGestionaFieldsResponse source) return block switch
{
1 => Envelope(
"SI",
Value(source, "fechaDenuncia"),
Value(source, "numeroDenunciaCanal"),
Value(source, "sexoDenunciante"),
Value(source, "preferenciaNotificacion"),
Value(source, "autorizaNotificacionesElectronicas"),
Value(source, "autorizaNotificacionesViaSms"),
Value(source, "preferenciaNotificacionPostal"),
Value(source, "preferenciaSeguimientoOnline"),
Value(source, "ambitoCompetencias")),
2 => Envelope(
Value(source, "aQuienDenuncia"),
Value(source, "detalleDenunciado"),
Value(source, "resumenDenuncia"),
Value(source, "solicitaProteccion"),
Value(source, "lugarHechos"),
Value(source, "fechaHechos"),
Value(source, "autorizaRemisionDenuncia"),
Value(source, "comentariosComunicaciones"),
string.Empty,
string.Empty),
_ => throw new ArgumentOutOfRangeException(nameof(block))
};
}
private static OpeDataEnvelope EmptyBlock(int block, bool belongsToComplaintsChannel)
{
var values = Enumerable.Repeat(string.Empty, 10).ToArray();
if (block == 1)
{
values[0] = belongsToComplaintsChannel ? "SI" : "NO";
}
return Envelope(values);
}
private static string Value(InternalGestionaFieldsResponse source, string name)
=> source.Data.TryGetValue(name, out var value)
? value.Value?.Trim() ?? string.Empty
: string.Empty;
private static OpeDataEnvelope Envelope(params string[] values)
{ {
var output = new Dictionary<string, OpeFieldValue>(StringComparer.Ordinal); var output = new Dictionary<string, OpeFieldValue>(StringComparer.Ordinal);
for (var index = 0; index < 10; index++)
for (var index = 0; index < _outputFields.Length; index++)
{ {
var internalField = _outputFields[index]; output[$"FIELD_{index}"] = new OpeFieldValue(
if (!source.Data.TryGetValue(internalField, out var value)) "STRING",
{ index < values.Length ? values[index] : string.Empty);
throw new InvalidDataException(
$"La API interna no ha devuelto el campo configurado '{internalField}'.");
}
output[$"FIELD_{index}"] = new OpeFieldValue("STRING", value.Value?.Trim() ?? string.Empty);
} }
return new OpeDataEnvelope(output); return new OpeDataEnvelope(output);

View File

@@ -14,19 +14,7 @@
"ClockSkewSeconds": 60, "ClockSkewSeconds": 60,
"OrganizationId": "00000000-0000-0000-0000-000000000001", "OrganizationId": "00000000-0000-0000-0000-000000000001",
"OrganizationDir3": "DEV000001", "OrganizationDir3": "DEV000001",
"OrganizationCif": "DEV000001", "OrganizationCif": "DEV000001"
"OutputFields": [
"fechaDenuncia",
"numeroDenunciaCanal",
"aQuienDenuncia",
"resumenDenuncia",
"fechaHechos",
"lugarHechos",
"ambitoCompetencias",
"solicitaProteccion",
"sexoDenunciante",
"autorizaRemisionDenuncia"
]
}, },
"InternalApi": { "InternalApi": {
"BaseUrl": "http://localhost:7093", "BaseUrl": "http://localhost:7093",

View File

@@ -17,8 +17,7 @@
"ClockSkewSeconds": 60, "ClockSkewSeconds": 60,
"OrganizationId": "", "OrganizationId": "",
"OrganizationDir3": "", "OrganizationDir3": "",
"OrganizationCif": "", "OrganizationCif": ""
"OutputFields": []
}, },
"InternalApi": { "InternalApi": {
"BaseUrl": "http://localhost:7093", "BaseUrl": "http://localhost:7093",

View File

@@ -145,7 +145,13 @@ public sealed record GestionaComplaintFieldsResponse(
string SexoDenunciante, string SexoDenunciante,
string AutorizaRemisionDenuncia, string AutorizaRemisionDenuncia,
string AutorizaNotificacionesViaSms, string AutorizaNotificacionesViaSms,
string PreferenciaNotificacionSeguimientoDenuncia); string PreferenciaNotificacionSeguimientoDenuncia,
string DetalleDenunciado,
string PreferenciaNotificacion,
string AutorizaNotificacionesElectronicas,
string PreferenciaNotificacionPostal,
string PreferenciaSeguimientoOnline,
string ComentariosComunicaciones);
public sealed record GestionaExternalFieldValue( public sealed record GestionaExternalFieldValue(
string Type, string Type,

View File

@@ -914,7 +914,7 @@ else
var createdFile = await ApiDenuncias.CreateGestionaFileAsync( var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
nuevoAsunto, nuevoAsunto,
"RQ2ZLC - Expediente de Denuncias", "RQ2ZLC - Expediente de Denuncias",
"3109963" string.Empty
); );
fileUrl = createdFile.FileUrl; fileUrl = createdFile.FileUrl;
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8"); Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8");

View File

@@ -1005,7 +1005,7 @@ else
var createdFile = await ApiDenuncias.CreateGestionaFileAsync( var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
nuevoAsunto, nuevoAsunto,
"RQ2ZLC - Expediente de Denuncias", "RQ2ZLC - Expediente de Denuncias",
"3109963" string.Empty
); );
fileUrl = createdFile.FileUrl; fileUrl = createdFile.FileUrl;
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 7"); Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 7");