Files
2026-06-26 10:29:23 +02:00

429 lines
21 KiB
C#

using adas_core.Application.Exceptions;
using adas_core.Application.Repositories.Interfaces;
using adas_core.Application.Services.Interfaces;
using adas_core.Domain.Enums;
using adas_core.Domain.Models;
using adas_core.Domain.Models.AppSettings;
using adas_core.Domain.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using Patient = adas_core.Domain.Models.MongoModels.Patient;
namespace adas_core.Application.Services;
public class AppointmentService(
IAppointmentRepository appointmentRepository,
IAppointmentArchiveRepository appointmentArchiveRepository,
Lazy<IPatientService> patientService,
Lazy<IObservationService> observationService,
IDiagnosisService diagnosisService,
IUnitService unitService,
IOptions<ApiSettings> apiSettings,
IOptions<CacheSettings> cacheSettings,
ILogger<AppointmentService> logger,
IHttpContextAccessor httpContextAccessor,
ILocalAuditService auditService,
IPointOfCareService pointOfCareService,
ISubscribersService subscribersService,
IClientMessageService clientMessageService,
ICacheService cacheService)
: IAppointmentService
{
private readonly bool _createPatientWithSiu = apiSettings.Value.CreatePatientWithSiu;
private readonly CacheSettings? _cacheSettings = cacheSettings.Value;
/// <summary>
/// Saves an API request by validating the patient, resolving or creating the patient record, processing the request, and handling associated observations and diagnoses.
/// </summary>
/// <param name="apiRequest">The API request containing the patient number, location, type, observations, diagnosis, and related data to be processed.</param>
/// <exception cref="ApiRequestException">Thrown when the <paramref name="apiRequest"/> has a null or empty patient number.</exception>
public async Task SaveRequest(ApiRequest apiRequest)
{
if (string.IsNullOrEmpty(apiRequest.PatientNumber))
{
logger.LogDebug("Patient is null");
throw new ApiRequestException("Patient number is null");
}
logger.LogDebug("patientNumber: {apiRequestPatientNumber} location: {apiRequestLocation}",
apiRequest.PatientNumber, apiRequest.Location);
logger.LogDebug("RequestType: {apiRequestType}", apiRequest.Type);
var patient = await patientService.Value.FindByPatientNumber(apiRequest.PatientNumber);
if (patient == null)
{
// PATIENT NOT FOUND
logger.LogWarning("Patient not Found. {patientNumber}", apiRequest.PatientNumber);
if (!_createPatientWithSiu) return; // IGNORE
patient = await patientService.Value.CreatePatientFromRequest(apiRequest, true);
}
await ProcessApiRequest(apiRequest, patient);
if (patient != null)
{
if (apiRequest is { Observations: not null, ObservationData: not null })
observationService.Value.ProcessObservations(apiRequest.Observations, patient,
apiRequest.MessageTime, apiRequest.ObservationData);
else
logger.LogWarning("Observations not Found. {patientNumber} ", patient.PatientNumber);
if (apiRequest.Diagnosis != null)
_ = diagnosisService.ProcessDiagnosis(apiRequest.Diagnosis, patient, apiRequest.MessageTime);
else
logger.LogWarning("Diagnosis not Found. {patientNumber}", patient.PatientNumber);
}
}
/// <summary>
/// Processes an incoming API request for a patient, handling HL7 SIU message types to create, update, or cancel appointments.
/// Validates the patient and auto-ADT configuration, then routes the request to the appropriate handler based on message type: SIU_S12-S14 and SIU_S18-S22 (booking/rescheduling/modification), SIU_S15-S17 (cancellation), and other types (blocked slots / no-show), persisting changes, refreshing cache, creating audit logs, and emitting broadcasts.
/// </summary>
/// <param name="apiRequest">The API request containing the HL7 message type, timestamp, and appointment payload to process.</param>
/// <param name="patient">The patient associated with the request; if null, the method returns without processing.</param>
public async Task ProcessApiRequest(ApiRequest apiRequest, Patient? patient)
{
if (patient == null)
return;
var unitConfig = await unitService.FindById(patient.UnitId);
if (!Hl7Utils.ManageAutoAdt(unitConfig, null, logger, "ORU")) return;
apiRequest.Appointments ??= [];
if (apiRequest.Appointment != null) apiRequest.Appointments.Add(apiRequest.Appointment);
//apiRequest.appointments.ForEach(ap =>
foreach (var ap in apiRequest.Appointments)
{
if (ap.VisitNumber == null || patient.Person == null) return;
ap.PatientId = patient.Id;
ap.Patient = patient.Person;
var apdb = ap.VisitNumber != null
? await appointmentRepository.FindByPatientAndVisitNumber(ap.PatientId, ap.VisitNumber)
: null;
apdb ??= await appointmentRepository.FindByPatientAndReason(ap.PatientId, ap.AppointmentReason);
if (apdb != null && DateTime.Compare(apdb.UpdateTime, apiRequest.MessageTime) > 0) return;
// TODO: Aux
var oldAp = await auditService.DeepCopyAsync(ap);
switch (apiRequest.Type)
{
//* SIU_S12 - Notification of new appointment booking
//* SIU_S13 - Notification of Appointment Rescheduling
//* SIU_S14 - Notification of Appointment Modification
//* SIU_S18 - Notification of Addition of Service/Resource on Appointment
//* SIU_S19 - Notification of Modification of Service/Resource on Appointment
//* SIU_S20 - Notification of Cancellation of Service/Resource on Appointment
//* SIU_S21 - Notification of Discontinuation of Service/Resource on Appointment
//* SIU_S22 - Notification of Deletion of Service/Resource on Appointment
case "SIU_S12":
case "SIU_S13":
case "SIU_S14":
case "SIU_S18":
case "SIU_S19":
case "SIU_S20":
case "SIU_S21":
case "SIU_S22":
ap.UpdateTime = apiRequest.MessageTime;
if (apdb != null)
{
ap.Id = apdb.Id;
ap.CreateTime = apdb.CreateTime;
ap.AppointmentOperationType = OperationType.UpdatedAppointment;
ap.ApplyResourceGroups(apdb);
await appointmentRepository.Update(ap);
// Invalidar CACHE (colección completa)
await cacheService.DeleteObjectAsync(CacheKeys.PatientAppointmentsToday(ap.PatientId));
//SendBroadcast(ap, OperationType.updatedAppointment);
}
else
{
ap.CreateTime = apiRequest.MessageTime;
ap.AppointmentOperationType = OperationType.NewAppointment;
ap.ApplyResourceGroups();
await appointmentRepository.InsertOneAsync(ap);
//SendBroadcast(ap, OperationType.newAppointment);
}
break;
//* SIU_S15 - Notification of Appointment Cancellation
//* SIU_S16 - Notification of Appointment Discontinuation
//* SIU_S17 - Notification of Appointment Deletion
case "SIU_S15":
case "SIU_S16":
case "SIU_S17":
ap.UpdateTime = apiRequest.MessageTime;
if (apdb != null)
{
ap.Id = apdb.Id;
ap.CreateTime = apdb.CreateTime;
ap.AppointmentOperationType = OperationType.CanceledAppointment;
ap.ApplyResourceGroups(apdb);
await appointmentRepository.Update(ap);
await cacheService.DeleteObjectAsync(CacheKeys.PatientAppointmentsToday(ap.PatientId));
}
else
{
ap.CreateTime = apiRequest.MessageTime;
ap.AppointmentOperationType = OperationType.CanceledAppointment;
ap.ApplyResourceGroups();
await appointmentRepository.InsertOneAsync(ap);
}
//SendBroadcast(ap, OperationType.canceledAppointment);
break;
//* SIU_S23 - Notification of Blocked Schedule Time Slot(S)
//* SIU_S24 - Notification of Opened (un-blocked) Schedule Time Slot(s)
//* SIU_S26 - Notification That Patient Did Not Show Up for Scheduled Appointment
default:
ap.UpdateTime = apiRequest.MessageTime;
if (apdb != null)
{
ap.Id = apdb.Id;
ap.CreateTime = apdb.CreateTime;
//ap.appointmentOperationType = OperationType.updatedAppointment;
ap.ApplyResourceGroups(apdb);
await appointmentRepository.Update(ap);
await cacheService.DeleteObjectAsync(CacheKeys.PatientAppointmentsToday(ap.PatientId));
//SendBroadcast(ap, ap.appointmentOperationType);
}
else
{
ap.CreateTime = apiRequest.MessageTime;
//ap.appointmentOperationType = OperationType.newAppointment;
ap.ApplyResourceGroups();
await appointmentRepository.InsertOneAsync(ap);
//SendBroadcast(ap, ap.appointmentOperationType);
}
break;
}
if (apdb != null)
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, apdb, ap);
else await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, null, ap);
if (ap.AppointmentOperationType.HasValue)
await SendBroadcast(ap, ap.AppointmentOperationType);
}
}
/// <summary>
/// Asynchronously saves the provided API request by running the save operation on a background task.
/// </summary>
/// <param name="apiRequest">The API request to be saved.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public Task SaveRequestAsync(ApiRequest apiRequest)
{
return Task.FromResult(Task.Run(async () => { await SaveRequest(apiRequest); }));
}
/// <summary>
/// Archives the specified patient by delegating the operation to <see cref="ArchiveByPatientId"/> using the patient's identifier.
/// </summary>
/// <param name="patient">The patient to be archived.</param>
public async Task Archive(Patient patient)
{
await ArchiveByPatientId(patient.Id);
}
/// <summary>
/// Archives all appointments associated with the specified patient by copying them to the appointment archive repository and then deleting them from the source collection.
/// </summary>
/// <param name="id">The unique identifier of the patient whose appointments should be archived.</param>
public async Task ArchiveByPatientId(ObjectId id)
{
logger.LogDebug("Archive Appointments by patientId {id}", id);
using (var cursor = await FindByPatientIdAsync(id))
{
while (await cursor.MoveNextAsync())
foreach (var current in cursor.Current)
await appointmentArchiveRepository.InsertOneAsync(current);
}
await DeleteByPatientId(id);
}
/// <summary>
/// Retrieves a list of patient appointments associated with the specified patient identifier by delegating to the appointment repository.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose appointments are being requested.</param>
/// <returns>A task that represents the asynchronous operation, containing a list of <see cref="PatientAppointment"/> records for the given patient.</returns>
public async Task<List<PatientAppointment>> GetByPatient(ObjectId patientId)
{
return await appointmentRepository.GetByPatient(patientId);
}
/// <summary>
/// Retrieves the list of appointments scheduled for today (UTC) for the specified patient, using a cache-aside pattern to avoid repeated database queries.
/// The full list of patient appointments is fetched from cache (or loaded from the repository on a cache miss) and then filtered locally to include only those whose start time falls on the current UTC date.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose appointments are being queried.</param>
/// <param name="ct">Cancellation token used to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to a list of <see cref="PatientAppointment"/> instances scheduled for today; an empty list is returned when no appointments match.</returns>
public async Task<List<PatientAppointment>> GetTodayByPatient(
ObjectId patientId,
CancellationToken ct = default)
{
// Obtener clave + TTL según CacheSettings
var (key, ttl) = CacheKeys.PatientAppointmentsTodayKeyWithTtl(_cacheSettings, patientId);
// Cachear la lista RAW de citas del paciente (sin filtrar)
var allAppointments = await cacheService.GetOrSetObjectAsync(
key,
async () =>
{
// 1 - Consultar todas las citas del paciente
var list = await appointmentRepository.GetByPatient(patientId);
// 2 - Devuelve RAW (List<PatientAppointment>), nada filtrado
return list;
},
ttl,
ct);
// 3 - Ahora filtramos solo las de "hoy"
var today = DateTime.UtcNow.Date;
var todayAppointments = allAppointments
.Where(a => a.Timings.Any(t =>
t.StartTime.HasValue &&
t.StartTime.Value.Date == today))
.ToList();
return todayAppointments;
}
/// <summary>
/// Retrieves the patient appointments scheduled for today at the specified point of care. Uses a cache to store the full appointment list for the point of care and filters it by today's date; returns an empty list if the point of care is not found.
/// </summary>
/// <param name="pocId">The identifier of the point of care whose appointments should be retrieved.</param>
/// <param name="ct">A cancellation token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation, containing the list of patient appointments scheduled for today at the specified point of care.</returns>
public async Task<List<PatientAppointment>> GetTodayByPoc(
ObjectId pocId,
CancellationToken ct = default)
{
var poc = await pointOfCareService.FindById(pocId);
if (poc == null) return [];
// Obtener clave + TTL según CacheSettings
var (key, ttl) = CacheKeys.PocAppointmentsTodayKeyWithTtl(_cacheSettings, pocId);
// Cachear la lista RAW de citas del pointOfCare (sin filtrar)
var allAppointments = await cacheService.GetOrSetObjectAsync(
key,
async () =>
{
// 1 - Consultar todas las citas del pointOfCare
var list = await appointmentRepository.FindByPoC(poc);
// 2 - Devuelve RAW (List<PatientAppointment>), nada filtrado
return list;
},
ttl,
ct);
// 3 - Ahora filtramos solo las de "hoy"
var today = DateTime.UtcNow.Date;
var todayAppointments = allAppointments
.Where(a => a.Timings.Any(t =>
t.StartTime.HasValue &&
t.StartTime.Value.Date == today))
.ToList();
return todayAppointments;
}
/// <summary>
/// Asynchronously retrieves all patient appointments associated with the specified patient identifier by delegating to the appointment repository.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose appointments are to be retrieved.</param>
/// <returns>A task that represents the asynchronous operation, containing an async cursor over the matching <see cref="PatientAppointment"/> documents.</returns>
public Task<IAsyncCursor<PatientAppointment>> FindByPatientIdAsync(ObjectId patientId)
{
return appointmentRepository.FindByPatientIdAsync(patientId);
}
/// <summary>
/// Retrieves all patient appointments associated with the specified location.
/// </summary>
/// <param name="location">The location used to filter the patient appointments.</param>
/// <returns>A task that represents the asynchronous operation, containing a list of patient appointments for the specified location.</returns>
public async Task<List<PatientAppointment>> FindByLocation(PatientLocation location)
{
return await appointmentRepository.FindByLocation(location);
}
/// <summary>
/// Deletes all appointments associated with the specified patient identifier, invalidates the appointments cache, and records the action in the audit log.
/// </summary>
/// <param name="id">The unique identifier of the patient whose appointments will be deleted.</param>
public async Task DeleteByPatientId(ObjectId id)
{
var patientApp = await FindByPatientIdAsync(id);
logger.LogDebug("Delete Appointments by Patient Id {id}", id);
await appointmentRepository.DeleteByPatientId(id);
// Invalidar CACHE (colección completa)
await cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.Appointments));
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, patientApp, null);
}
/// <summary>
/// Updates multiple appointment records by replacing the specified <paramref name="oldId"/> with the new <paramref name="id"/>, scoped by the given <paramref name="nameId"/>. Delegates the operation to the underlying appointment repository.
/// </summary>
/// <param name="nameId">The identifier used to scope which appointment records are affected by the update.</param>
/// <param name="id">The new ObjectId that will replace the existing one in the matching records.</param>
/// <param name="oldId">The current ObjectId to be replaced in the matching records.</param>
public async Task UpdateManyObjectId(string nameId, ObjectId id, ObjectId oldId)
{
await appointmentRepository.UpdateManyObjectId(nameId, id, oldId);
}
/// <summary>
/// Broadcasts a patient appointment operation to all subscribers associated with the appointment's locations. Iterates through each resource group and location, resolving the unit and point of care, and dispatches a fire-and-forget message to every matching subscriber. Skips locations with missing unit/bed data and silently ignores unresolved unit or point-of-care lookups.
/// </summary>
/// <param name="appointment">The patient appointment whose resource groups and locations will be broadcast to subscribers.</param>
/// <param name="operationType">The optional operation type describing the change performed on the appointment; passed along to the subscriber message.</param>
private async Task SendBroadcast(PatientAppointment appointment, OperationType? operationType)
{
//RECORRE LOS DIFERENTES LOCATIONS DE LA CITA
foreach (var resourceGroup in appointment.ResourceGroups)
if (resourceGroup.Locations != null)
foreach (var location in resourceGroup.Locations)
// TODO
if (!string.IsNullOrEmpty(location.UnitName) && !string.IsNullOrEmpty(location.Bed))
{
var unit = await unitService.FindByName(location.UnitName);
if (unit == null) continue;
var poc = await pointOfCareService.FindByBedAndUnitId(location.Bed, unit.Id);
if (poc == null) continue;
var subscribers = subscribersService.GetSubscribers().Where(s =>
!s.LocationIds.IsNullOrEmpty() && s.LocationIds.Any(c =>
c == poc.Id
)).ToList();
foreach (var subscriber in subscribers)
_ = clientMessageService.SendAsync(subscriber.Id, operationType, appointment);
}
}
}