diff --git a/src/Somnia.GuestLog/GuestConsts.cs b/src/Somnia.GuestLog/GuestConsts.cs index 601e684..ec4c06c 100644 --- a/src/Somnia.GuestLog/GuestConsts.cs +++ b/src/Somnia.GuestLog/GuestConsts.cs @@ -32,6 +32,10 @@ public static class GuestConsts public const string DocumentType_NIE = "NIE"; public const string DocumentType_PASSPORT = "PASSPORT"; + public const string GenderOptions_MALE = "M"; + public const string GenderOptions_FEMALE = "F"; + public const string GenderOptions_OTHER = "O"; + public const int GuestDocument_FirstVersion = 1; diff --git a/src/Somnia.GuestLog/Models/Configuration/StayExportSpainParteViajerosConfig.cs b/src/Somnia.GuestLog/Models/Configuration/StayExportSpainParteViajerosConfig.cs new file mode 100644 index 0000000..28e9393 --- /dev/null +++ b/src/Somnia.GuestLog/Models/Configuration/StayExportSpainParteViajerosConfig.cs @@ -0,0 +1,9 @@ +namespace Somnia.GuestLog.Models.Configuration; + +public class StayExportSpainParteViajerosConfig +{ + public const string SectionName = "StayExportSpainParteViajeros"; + + public bool InternetAvailable { get; set; } = false; + public string? CodigoEstablecimiento { get; set; } +} diff --git a/src/Somnia.GuestLog/Program.cs b/src/Somnia.GuestLog/Program.cs index 134890f..44b56c4 100644 --- a/src/Somnia.GuestLog/Program.cs +++ b/src/Somnia.GuestLog/Program.cs @@ -79,6 +79,8 @@ builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(AddressQueryConfig.SectionName)); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(StayExportConfig.SectionName)); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(StayExportSpainParteViajerosConfig.SectionName)); var app = builder.Build(); diff --git a/src/Somnia.GuestLog/Services/SpainCodigoMunicipio/SpainCodigoMunicipioCatastroService.cs b/src/Somnia.GuestLog/Services/SpainCodigoMunicipio/SpainCodigoMunicipioCatastroService.cs index 6058a5e..c7109ee 100644 --- a/src/Somnia.GuestLog/Services/SpainCodigoMunicipio/SpainCodigoMunicipioCatastroService.cs +++ b/src/Somnia.GuestLog/Services/SpainCodigoMunicipio/SpainCodigoMunicipioCatastroService.cs @@ -13,12 +13,26 @@ public class SpainCodigoMunicipioCatastroService( private const string Api_Param_Provincia = "Provincia"; private const string Api_Param_Municipio = "Municipio"; + + //Result Cache. + readonly Dictionary<(string provincia, string municipio), string> codigoMunicipioCache = []; + + public async Task GetCodigoMunicipioAsync(string municipio, string province) { + var cacheKey = (province.ToUpper(), municipio.ToUpper()); + + //Try get from cache. + if (codigoMunicipioCache.TryGetValue(cacheKey, out string? cacheCode)) + { + logger.LogInformation("SpainCodigoMunicipioCatastroService - Municipio found in cache for: '{key}'", cacheKey); + return cacheCode; + } + try { string uri = GetRequestUri(municipio, province); - logger.LogInformation("SpainCodigoMunicipioCatastroService - Getting municipios with query: '{addr}'", uri); + logger.LogInformation("SpainCodigoMunicipioCatastroService - Getting municipios from API with query: '{addr}'", uri); var httpClient = httpFactory.CreateClient(); using var rsStream = await httpClient.GetStreamAsync(uri); @@ -49,7 +63,10 @@ public class SpainCodigoMunicipioCatastroService( return null; } - return $"{codIne.codigoProvincia}{codIne.codigoMunicipio}"; + string finalCode = $"{codIne.codigoProvincia}{codIne.codigoMunicipio}"; + + codigoMunicipioCache.Add(cacheKey, finalCode); + return finalCode; } catch (Exception ex) { diff --git a/src/Somnia.GuestLog/Services/StayExport/StayExportProviderSpainParteViajeros.cs b/src/Somnia.GuestLog/Services/StayExport/StayExportProviderSpainParteViajeros.cs index 15c47ee..68fc1be 100644 --- a/src/Somnia.GuestLog/Services/StayExport/StayExportProviderSpainParteViajeros.cs +++ b/src/Somnia.GuestLog/Services/StayExport/StayExportProviderSpainParteViajeros.cs @@ -1,20 +1,399 @@ +using System.Diagnostics; +using System.Xml.Serialization; +using FluentValidation; +using Microsoft.Extensions.Options; using Somnia.GuestLog.Enums.Models; +using Somnia.GuestLog.Models.Configuration; using Somnia.GuestLog.Models.DTOs; +using Somnia.GuestLog.Services.SpainCodigoMunicipio; namespace Somnia.GuestLog.Services.StayExport; public class StayExportProviderSpainParteViajeros( - + ILogger logger, + ISpainCodigoMunicipioService codigoMunicipioService, + IOptions configOptions ) : IStayExportFormatProvider { private const string fileExtension = "xml"; + private const string PersonRole = "VI"; + private const string PersonRelation = "OT"; + + private const string PersonTipoDoc_NIF = "NIF"; + private const string PersonTipoDoc_NIE = "NIE"; + private const string PersonTipoDoc_PAS = "PAS"; + private const string PersonTipoDoc_OTRO = "OTRO"; + + private const string PersonDireccionPais_ESP = "ESP"; + + private const string XmlNamespace_Peticion = "http://www.neg.hospedajes.mir.es/altaParteHospedaje"; + + private readonly StayExportSpainParteViajerosConfig config = configOptions.Value; + public StayExportFormat GetFormat() => StayExportFormat.SpainParteViajeros; public string GetFilename(DateTime exportTime) => $"{Enum.GetName(GetFormat())}_{exportTime.ToFileTimeUtc()}.{fileExtension}"; - public Task ExportDataAsync(IEnumerable stayList, Stream dataStream) + public async Task ExportDataAsync(IEnumerable stayList, Stream dataStream) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(config.CodigoEstablecimiento)) + { + logger.LogError("StayExportProviderSpainParteViajeros - Config error: CodigoEstablecimiento is not configured!"); + return StayExportFileResult.ExportError; + } + + if (!stayList.Any()) + { + return StayExportFileResult.NoData; + } + + ComunicacionValidator comunicacionValidator = new(); + + List comunicacionsList = []; + foreach (StayEntryDTO stay in stayList) + { + (Comunicacion? comunicacion, string code) = await GetComunicacionFromStay(stay); + if (comunicacion == null) + { + logger.LogWarning("StayExportProviderSpainParteViajeros - Stay (ID: {id}) can't be exported: Code: {code}", stay.Id, code); + continue; + } + + var errors = comunicacionValidator.Validate(comunicacion); + if (!errors.IsValid) + { + logger.LogWarning("StayExportProviderSpainParteViajeros - Stay (ID: {id}) is invalid: Errors: {errors}", stay.Id, errors.Errors); + continue; + } + + comunicacionsList.Add(comunicacion); + logger.LogInformation("StayExportProviderSpainParteViajeros - Stay (ID: {id}) Exported OK", stay.Id); + } + + Peticion peticion = new() + { + solicitud = new() + { + codigoEstablecimiento = config.CodigoEstablecimiento, + comunicacion = comunicacionsList, + } + }; + + XmlSerializer serializer = new(typeof(Peticion)); + serializer.Serialize(dataStream, peticion); + + return StayExportFileResult.OK; } + + #region Internal Methods + + private async Task<(Comunicacion? comunicacion, string code)> GetComunicacionFromStay(StayEntryDTO stay) + { + Debug.Assert(stay.Id != null + && stay.DateIn != null + && stay.DateOut != null + && stay.GuestDocuments != null + ); + + //Call Fn to validate Stay. + + Contrato contrato = new() + { + referencia = stay.Id.Value.ToString(), + fechaContrato = stay.DateIn.Value.Date, + fechaEntrada = stay.DateIn.Value, + fechaSalida = stay.DateOut.Value, + internet = config.InternetAvailable, + numHabitaciones = stay.RoomCount ?? -1, + numPersonas = stay.GuestDocuments.Count, + pago = new() + { + tipoPago = GetTipoPagoFromStay(stay)! + } + }; + + List personasList = []; + foreach (GuestDocumentDTO doc in stay.GuestDocuments) + { + Direccion dir = new() + { + direccion = doc.Address!, + codigoPostal = doc.ZipCode!, + pais = doc.AddressCountry!, + }; + + //Get Municipio given current country. + if (doc.AddressCountry != PersonDireccionPais_ESP) + { + List addressParts = [doc.AddressCity, doc.AddressProvince, doc.AddressState]; + dir.nombreMunicipio = string.Join(", ", addressParts.Where(p => !string.IsNullOrEmpty(p))); + } + if (doc.AddressCountry == PersonDireccionPais_ESP) + { + dir.codigoMunicipio = await GetMunicipioFromGuestAddressAsync(doc); + if (dir.codigoMunicipio == null) + { + return ( + comunicacion: null, + code: "NoCodigoMunicipio" + ); + } + } + + Persona persona = new() + { + rol = PersonRole, + nombre = doc.Name!, + apellido1 = doc.Surname!, + apellido2 = doc.Surname2, + tipoDocumento = GetTipoDocumentoFromGuest(doc)!, + numeroDocumento = doc.DocumentNum!, + soporteDocumento = doc.SupportNum, + fechaNacimiento = doc.BirthDate?.Date ?? DateTime.MaxValue, + nacionalidad = doc.Nationality!, + sexo = GetSexoFromGuest(doc)!, + telefono = doc.PhoneNumber, + correo = doc.EmailAddress, + parentesco = PersonRelation, + direccion = dir, + }; + personasList.Add(persona); + } + + return ( + comunicacion: new() + { + contrato = contrato, + persona = personasList + }, + code: "OK"); + } + + private static string? GetTipoDocumentoFromGuest(GuestDocumentDTO doc) + { + return doc.DocumentType switch + { + GuestConsts.DocumentType_DNI => PersonTipoDoc_NIF, + GuestConsts.DocumentType_NIE => PersonTipoDoc_NIE, + GuestConsts.DocumentType_PASSPORT => PersonTipoDoc_PAS, + null => null, + _ => PersonTipoDoc_OTRO + }; + } + + private static string? GetSexoFromGuest(GuestDocumentDTO doc) + { + return doc.Gender switch + { + GuestConsts.GenderOptions_MALE => "H", + GuestConsts.GenderOptions_FEMALE => "F", + null => null, + _ => "O" + }; + } + + private static string? GetTipoPagoFromStay(StayEntryDTO stay) + { + return stay.PaymentMethod; + } + + private async Task GetMunicipioFromGuestAddressAsync(GuestDocumentDTO doc) + { + if (string.IsNullOrEmpty(doc.AddressCity) || string.IsNullOrEmpty(doc.AddressProvince)) + { + return null; + } + + //Get codMunicipio from API. + return await codigoMunicipioService.GetCodigoMunicipioAsync(doc.AddressCity, doc.AddressProvince); + } + + #endregion + + #region Validator + + private sealed class ComunicacionValidator : AbstractValidator + { + //TODO: Validate values in list for: Pago.tipoPago, Persona.sexo, Persona.tipoDocumento + + public ComunicacionValidator() + { + RuleFor(comm => comm.contrato) + .NotNull() + .ChildRules(contrato => + { + contrato.RuleFor(p => p.referencia) + .NotEmpty() + .MaximumLength(50); + + contrato.RuleFor(p => p.fechaEntrada) + .LessThan(p => p.fechaSalida); + + contrato.RuleFor(p => p.fechaSalida) + .GreaterThan(p => p.fechaEntrada); + + contrato.RuleFor(p => p.numHabitaciones) + .GreaterThanOrEqualTo(0); + + //BLOQUE PAGO + contrato.RuleFor(p => p.pago) + .ChildRules(e => + { + e.RuleFor(p => p.tipoPago) + .NotEmpty(); + }); + }); + + RuleForEach(comm => comm.persona) + .ChildRules(person => + { + person.RuleFor(p => p.rol) + .Equal(PersonRole); + person.RuleFor(p => p.nombre) + .NotEmpty() + .MaximumLength(50); + person.RuleFor(p => p.apellido1) + .NotEmpty() + .MaximumLength(50); + person.RuleFor(p => p.apellido2) + .MaximumLength(50); + person.RuleFor(p => p.tipoDocumento) + .NotEmpty() + .MaximumLength(5); + person.RuleFor(p => p.numeroDocumento) + .NotEmpty() + .MaximumLength(15); + person.RuleFor(p => p.soporteDocumento) + .NotEmpty() + .When(p => p.tipoDocumento == PersonTipoDoc_NIF || p.tipoDocumento == PersonTipoDoc_NIE); + person.RuleFor(p => p.fechaNacimiento) + .NotEmpty(); + person.RuleFor(p => p.sexo) + .MaximumLength(1); + person.RuleFor(p => p.telefono) + .MaximumLength(20); + person.RuleFor(p => p.correo) + .MaximumLength(250); + + person.RuleFor(p => p.direccion) + .ChildRules(d => + { + d.RuleFor(p => p.direccion) + .NotEmpty() + .MaximumLength(100); + d.RuleFor(p => p.codigoMunicipio) + .NotEmpty() + .When(p => p.pais == PersonDireccionPais_ESP) + .MaximumLength(5); + d.RuleFor(p => p.nombreMunicipio) + .NotEmpty() + .When(p => p.pais != PersonDireccionPais_ESP) + .MaximumLength(100); + d.RuleFor(p => p.codigoMunicipio) + .NotEmpty() + .MaximumLength(20); + d.RuleFor(p => p.pais) + .NotEmpty() + .Length(3); + }); + }); + + RuleForEach(comm => comm.persona) + .Must(p => !string.IsNullOrEmpty(p.telefono) || !string.IsNullOrEmpty(p.correo)) + .WithMessage($"It's required to have at least one of the following fields: '{nameof(Persona.telefono)}', '{nameof(Persona.correo)}'"); + } + } + + #endregion + + #region Export Data Classes + + [XmlRoot(ElementName = "peticion", Namespace = XmlNamespace_Peticion)] + public class Peticion + { + public required Solicitud solicitud; + } + + [XmlRoot(ElementName = "solicitud")] + public class Solicitud + { + public required string codigoEstablecimiento; + public required List comunicacion; + } + + [XmlRoot(ElementName = "comunicacion")] + public class Comunicacion + { + public required Contrato contrato; + public required List persona; + } + + [XmlRoot(ElementName = "contrato")] + public class Contrato + { + public required string referencia; + + [XmlElement(DataType = "date")] + public required DateTime fechaContrato; + + [XmlElement(DataType = "dateTime")] + public required DateTime fechaEntrada; + + [XmlElement(DataType = "dateTime")] + public required DateTime fechaSalida; + + public required int numPersonas; + public int? numHabitaciones; + public bool? internet; + + //BLOQUE PAGO + public required Pago pago; + } + + [XmlRoot(ElementName = "persona")] + public class Persona + { + public required string rol; + public required string nombre; + public required string apellido1; + public string? apellido2; + public required string tipoDocumento; + public required string numeroDocumento; + public string? soporteDocumento; + + [XmlElement(DataType = "date")] + public required DateTime fechaNacimiento; + public required string nacionalidad; + public required string sexo; + + public string? telefono; + public string? correo; + public required string parentesco; + + // BLOQUE DIRECCION + public required Direccion direccion; + } + + public class Pago + { + public required string tipoPago; + + // [XmlElement(DataType = "date")] + // public required DateTime fechaPago; + public string? medioPago; + public string? titular; + public string? caducidadTarjeta; + } + + public class Direccion + { + public required string direccion; + public string? codigoMunicipio; + public string? nombreMunicipio; + public required string codigoPostal; + public required string pais; + } + + #endregion } \ No newline at end of file diff --git a/src/Somnia.GuestLog/appsettings.json b/src/Somnia.GuestLog/appsettings.json index ac9b3f2..f557d9d 100644 --- a/src/Somnia.GuestLog/appsettings.json +++ b/src/Somnia.GuestLog/appsettings.json @@ -25,5 +25,9 @@ "EnableStayExport": true, "ExportStorageConnectionString": "disk://path=/SomniaGuestLog/StayExportFiles", "UseSubdirectoryByFormat": true + }, + "StayExportSpainParteViajeros": { + "InternetAvailable": true, + "CodigoEstablecimiento": "" } } \ No newline at end of file