Files
adas-core/adas-core.Application/Services/PumpService.cs
2026-06-26 10:29:23 +02:00

749 lines
40 KiB
C#

using adas_core.Application.Exceptions;
using adas_core.Application.Repositories.Interfaces;
using adas_core.Application.Services.Interfaces;
using adas_core.Application.Subscriptions;
using adas_core.Domain.Enums;
using adas_core.Domain.Models;
using adas_core.Domain.Models.AppSettings;
using adas_core.Domain.Models.Filter;
using adas_core.Domain.Models.MongoModels;
using adas_core.Domain.Models.Pumps;
using adas_core.Domain.Models.Responses;
using adas_core.Domain.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using Patient = adas_core.Domain.Models.MongoModels.Patient;
namespace adas_core.Application.Services
{
/// <summary>
/// Servicio maestro de gestión de bombas
/// - Procesa ApiRequest (HL7/Alaris transformado)
/// - Histórico clínico (pump_observations)
/// - Histórico de alarmas (pump_alarm_events)
/// - Alarmas activas (pump_alarm_state)
/// - Snapshot (pump_state)
/// - Broadcast de snapshots (PumpState + PumpAlarmState)
/// </summary>
public class PumpService(
IPumpObservationRepository pumpObservationRepository,
IPumpStateRepository pumpStateRepo,
IPumpAlarmEventRepository alarmEventRepo,
IPumpAlarmStateRepository alarmStateRepo,
IPumpArchiveRepository pumpArchiveRepo,
IPatientService patientService,
IConfigPumpsService configPumpsService,
IOptions<ApiSettings> apiSettings,
ILogger<PumpService> logger,
ISubscribersService subscribersService,
IClientMessageService clientMessageService,
Lazy<ICalculatedObservationsService> calculatedObservationsService,
IHttpContextAccessor httpContextAccessor,
ILocalAuditService auditService,
IConfigUnitsService configUnitsService)
: IPumpService
{
// Settings
private readonly int _pumpExpiresSeconds = apiSettings.Value.PumpExpiresSeconds;
private readonly bool _sendPumpsZero = apiSettings.Value.SendPumpsZero;
// ======================================================================
// ENTRYPOINT
// ======================================================================
/// <summary>
/// Processes an incoming <see cref="ApiRequest"/> by normalizing its pump observations, resolving or creating the associated patient, and handling each observation according to its message type (HL7 PCD-01/04/10 or AlarisPump). For each observation, it dispatches to the appropriate observation or alarm pipeline, updates the pump state snapshot, broadcasts the resulting snapshots with any active device alarms, and applies retention rules on the historical observations. AlarisPump observations without a PatientId are discarded, and per-observation processing errors are logged without aborting the whole request.
/// </summary>
/// <param name="req">The API request containing the pump observations and message type to be persisted and broadcast.</param>
public async Task SaveRequest(ApiRequest req)
{
// Normalizar single vs list (Alaris puede mandar 1 sola)
if (req.PumpObservation != null && (req.PumpObservations == null || req.PumpObservations.Count == 0))
req.PumpObservations = [req.PumpObservation];
if (req.PumpObservations == null || req.PumpObservations.Count == 0)
{
logger.LogWarning("ApiRequest contains 0 PumpObservations");
return;
}
// Busca paciente (lookup/create) a partir de PatientNumber / Patient / PatientId string
var resolvedPatient = await patientService.FindPatientByApiRequest(req);
var foundPatientId = resolvedPatient?.Id;
foreach (var pobs in req.PumpObservations)
{
UpdatePatientFromRequest(req, pobs, foundPatientId);
// Regla Alaris: si el origen es AlarisPump y no hay PatientId -> descartar
if (string.Equals(req.Type, "AlarisPump", StringComparison.OrdinalIgnoreCase) &&
pobs.PatientId == null)
{
logger.LogWarning("AlarisPump: Observation descartada por ausencia de PatientId. DeviceId={device}", pobs.DeviceId);
continue;
}
if (pobs.Time == DateTime.MinValue)
pobs.Time = DateTime.UtcNow;
try
{
// 1) Procesar por tipo HL7 o según ObservationType (Alaris)
switch (req.Type)
{
case "ORU_R01": // PCD-01
case "ORU_R42": // PCD-10
pobs.MessageType = PumpEnum.PumpMessageType.Observation;
await ProcessObservation(pobs);
break;
case "ORU_R40": // PCD-04
pobs.MessageType = PumpEnum.PumpMessageType.Alarm;
await ProcessAlarm(pobs);
break;
case "AlarisPump":
if (pobs.MessageType == PumpEnum.PumpMessageType.Alarm)
await ProcessAlarm(pobs);
else
await ProcessObservation(pobs);
break;
default:
logger.LogWarning("Tipo de request desconocido para PumpService: {type}", req.Type);
break;
}
// 2) Snapshot de bomba
var state = await UpdatePumpState(pobs);
// 3) Alarmas activas del dispositivo
var activeAlarms = await alarmStateRepo.FindAllActiveByDeviceAsync(pobs.DeviceId!);
// 4) Broadcast de snapshots (PumpState + todas las PumpAlarmState)
await SendSnapshotsBroadcast(state, activeAlarms, pobs.PatientId ?? foundPatientId, req);
// 5) Retención (sobre histórico de observaciones) - reglas
await DoRetentionActions(pobs);
}
catch (Exception ex)
{
logger.LogError(ex, "Error procesando observation/alarm DeviceId={deviceId}", pobs.DeviceId);
}
}
}
// ======================================================================
// OBSERVACIONES (PCD-01 / PCD-10)
// ======================================================================
/// <summary>
/// Processes a pump observation by assigning an identifier and expiration, mapping it to the persistence model, and persisting it together with an audit log entry when the mapping succeeds.
/// </summary>
/// <param name="obs">The pump observation to process. Its <c>Id</c> and <c>Expires</c> are populated before mapping.</param>
private async Task ProcessObservation(PumpObservation obs)
{
logger.LogDebug("Insertando OBSERVATION DeviceId={dev} Time={time}", obs.DeviceId, obs.Time);
obs.Id = ObjectId.GenerateNewId();
obs.Expires = _pumpExpiresSeconds;
var mapped = await MapPumpObservation(obs);
if (mapped != null)
{
await pumpObservationRepository.InsertAsync(mapped);
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, null, mapped);
}
}
// ======================================================================
// ALARMAS (PCD-04)
// ======================================================================
/// <summary>
/// Persists a pump alarm event derived from the supplied observation and updates the associated alarm state.
/// </summary>
/// <param name="obs">The pump observation providing the device, infusion, alarm, and patient context used to build the alarm event.</param>
private async Task ProcessAlarm(PumpObservation obs)
{
logger.LogDebug("Insertando ALARM DeviceId={dev}, Phase={phase}, Type={type}",
obs.DeviceId, obs.EventPhase, obs.AlarmType);
var alarmEvent = new PumpAlarmEvent
{
Id = ObjectId.GenerateNewId(),
DeviceId = obs.DeviceId,
RackId = obs.RackId,
DeviceTypeMdc = obs.DeviceTypeMdc,
DeviceIp = obs.DeviceIp,
PillarAssembly = obs.PillarAssembly,
PillarRackSlot = obs.PillarRackSlot,
Time = obs.Time,
InfusionId = obs.InfusionId,
AlarmType = obs.AlarmType,
AlarmTypeMdc = obs.AlarmTypeMdc,
AlarmDescription = obs.AlarmDescription,
AlarmPriority = obs.AlarmPriority,
AlarmState = obs.AlarmState,
AlarmInactivationState = obs.AlarmInactivationState,
EventPhase = obs.EventPhase,
AlertSourceMdc = obs.AlertSourceMdc,
PatientId = obs.PatientId
};
await alarmEventRepo.InsertAsync(alarmEvent);
await UpdateAlarmState(obs);
}
/// <summary>
/// Updates the alarm state for a pump observation, removing the active alarm when the event phase is "end" and upserting a new alarm state record for start or continue phases.
/// </summary>
/// <param name="obs">The pump observation providing the event phase, device identifier, alarm type, and related alarm metadata used to drive the alarm state change.</param>
private async Task UpdateAlarmState(PumpObservation obs)
{
if (obs.EventPhase == null) return;
var phaseLower = obs.EventPhase.Value.ToString().ToLowerInvariant();
// Cierre
if (phaseLower == "end")
{
await alarmStateRepo.RemoveAsync(obs.DeviceId!, obs.AlarmType, obs.AlarmTypeMdc);
return;
}
// Start/continue → upsert
var state = new PumpAlarmState
{
Id = ObjectId.GenerateNewId(),
DeviceId = obs.DeviceId,
AlarmType = obs.AlarmType,
AlarmCodeMdc = obs.AlarmTypeMdc,
AlarmDescription = obs.AlarmDescription,
AlarmPriority = obs.AlarmPriority,
AlarmState = obs.AlarmState,
LastPhase = obs.EventPhase,
FirstSeen = DateTime.UtcNow,
LastUpdated = DateTime.UtcNow,
AlertSourceMdc = obs.AlertSourceMdc,
InfusionId = obs.InfusionId,
PatientId = obs.PatientId
};
await alarmStateRepo.UpsertActiveAsync(state);
}
// ======================================================================
// SNAPSHOT (devuelve el PumpState actualizado)
// ======================================================================
/// <summary>
/// Updates the persisted pump state for the device associated with the supplied observation, creating a new state record if none exists, and persists the merged result.
/// </summary>
/// <param name="obs">The pump observation whose values are merged into the current state and used to locate the device's existing record.</param>
/// <returns>The updated <see cref="PumpState"/> instance reflecting the merged observation and the new <c>LastUpdated</c> timestamp.</returns>
private async Task<PumpState> UpdatePumpState(PumpObservation obs)
{
var current = await pumpStateRepo.FindByDeviceIdAsync(obs.DeviceId!)
?? new PumpState
{
Id = ObjectId.GenerateNewId(),
DeviceId = obs.DeviceId
};
MergePumpState(current, obs);
current.LastUpdated = DateTime.UtcNow;
await pumpStateRepo.UpsertAsync(current);
return current;
}
/// <summary>
/// Merges the data from a <see cref="PumpObservation"/> into a <see cref="PumpState"/>,
/// updating each field only when the observation provides a non-null, non-whitespace,
/// or otherwise valid value, thereby preserving existing state data when no new information is present.
/// </summary>
/// <param name="state">The target <see cref="PumpState"/> instance whose fields will be updated in place.</param>
/// <param name="obs">The <see cref="PumpObservation"/> instance supplying the new candidate values to merge.</param>
private static void MergePumpState(PumpState state, PumpObservation obs)
{
// Identidad / físico
if (!string.IsNullOrWhiteSpace(obs.RackId)) state.RackId = obs.RackId;
if (!string.IsNullOrWhiteSpace(obs.DeviceTypeMdc)) state.DeviceTypeMdc = obs.DeviceTypeMdc;
if (!string.IsNullOrWhiteSpace(obs.DeviceIp)) state.DeviceIp = obs.DeviceIp;
if (!string.IsNullOrWhiteSpace(obs.PillarAssembly)) state.PillarAssembly = obs.PillarAssembly;
if (!string.IsNullOrWhiteSpace(obs.PillarRackSlot)) state.PillarRackSlot = obs.PillarRackSlot;
// Infusión
if (!string.IsNullOrWhiteSpace(obs.InfusionId)) state.InfusionId = obs.InfusionId;
// Estado
if (obs.InfusingStatus != null)
{
state.InfusingStatus = obs.InfusingStatus;
state.IsInfusing = obs.IsInfusing ?? false;
}
if (obs.Status != null) state.Status = obs.Status;
if (obs.PumpMode != null) state.PumpMode = obs.PumpMode;
if (!string.IsNullOrWhiteSpace(obs.ActiveSourceInfo)) state.ActiveSourceInfo = obs.ActiveSourceInfo;
if (!string.IsNullOrWhiteSpace(obs.InfusionModeDetail)) state.InfusionModeDetail = obs.InfusionModeDetail;
if (!string.IsNullOrWhiteSpace(obs.NotDeliveringReason)) state.NotDeliveringReason = obs.NotDeliveringReason;
if (!string.IsNullOrWhiteSpace(obs.Source)) state.Source = obs.Source;
// Métricas
if (HasValue(obs.FlowFluid)) state.FlowFluid = obs.FlowFluid;
if (HasValue(obs.Rate)) state.Rate = obs.Rate;
if (HasValue(obs.VolumeInfused)) state.VolumeInfused = obs.VolumeInfused;
if (HasValue(obs.FluidDelivTotal)) state.FluidDelivTotal = obs.FluidDelivTotal;
if (HasValue(obs.FluidDelivTotalSet)) state.FluidDelivTotalSet = obs.FluidDelivTotalSet;
if (HasValue(obs.VolumeRemaining)) state.VolumeRemaining = obs.VolumeRemaining;
if (HasValue(obs.Vtbi)) state.Vtbi = obs.Vtbi;
if (HasValue(obs.TimeRemaining)) state.TimeRemaining = obs.TimeRemaining;
if (HasValue(obs.TimeProgrammed)) state.TimeProgrammed = obs.TimeProgrammed;
// Medicación
if (!string.IsNullOrWhiteSpace(obs.DrugName)) state.DrugName = obs.DrugName;
if (!string.IsNullOrWhiteSpace(obs.DrugId)) state.DrugId = obs.DrugId;
if (HasValue(obs.Concentration)) state.Concentration = obs.Concentration;
if (HasValue(obs.DoseRate)) state.DoseRate = obs.DoseRate;
if (HasValue(obs.DrugAmount)) state.DrugAmount = obs.DrugAmount;
if (HasValue(obs.DrugDoseDelivered)) state.DrugDoseDelivered = obs.DrugDoseDelivered;
if (HasValue(obs.PatientWeight)) state.PatientWeight = obs.PatientWeight;
if (obs.Syringe != null) state.Syringe = obs.Syringe;
// Eventos / Alarmas resumen
if (obs.Event != null) state.Event = obs.Event;
if (obs.EventPhase != null) state.EventPhase = obs.EventPhase;
if (obs.AlarmType != null) state.AlarmType = obs.AlarmType;
if (!string.IsNullOrWhiteSpace(obs.AlarmDescription)) state.AlarmDescription = obs.AlarmDescription;
if (!string.IsNullOrWhiteSpace(obs.AlarmState)) state.AlarmState = obs.AlarmState;
if (!string.IsNullOrWhiteSpace(obs.AlarmInactivationState)) state.AlarmInactivationState = obs.AlarmInactivationState;
if (!string.IsNullOrWhiteSpace(obs.AlarmPriority)) state.AlarmPriority = obs.AlarmPriority;
if (!string.IsNullOrWhiteSpace(obs.AlarmTypeMdc)) state.AlarmCodeMdc = obs.AlarmTypeMdc;
// Paciente
state.PatientId = obs.PatientId;
}
/// <summary>
/// Determines whether the specified nullable <see cref="CommonPumpTypes.PumpValue"/> contains a non-null <c>Value</c>.
/// </summary>
/// <param name="v">The nullable pump value to inspect.</param>
/// <returns><c>true</c> when <paramref name="v"/> is not null and its <c>Value</c> is not null; otherwise, <c>false</c>.</returns>
private static bool HasValue(CommonPumpTypes.PumpValue? v) => v is { Value: not null };
// MAP
/// <summary>
/// Maps a <see cref="PumpObservation"/> through the configuration pumps and configuration units services in sequence, and additionally evaluates it against the calculated observations service to determine whether the mapping should be ignored (logged as debug when the result is null). Returns the observation produced after the configuration units mapping stage.
/// </summary>
/// <param name="obs">The pump observation to be transformed through the mapping pipeline.</param>
/// <returns>A task that yields the mapped <see cref="PumpObservation"/>, or <c>null</c> when the calculated observations stage produces no result.</returns>
public async Task<PumpObservation?> MapPumpObservation(PumpObservation obs)
{
var obs2 = await configPumpsService.Map(obs);
var obs3 = await configUnitsService.Map(obs2);
var obs4 = await calculatedObservationsService.Value.Map(obs3);
if (obs4 == null)
logger.LogDebug("Mapping ignorado para obs");
return obs3;
}
//Métodos
/// <summary>
/// Asynchronously saves an API request by delegating to the underlying synchronous save operation.
/// </summary>
/// <param name="req">The API request to be saved.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public Task SaveRequestAsync(ApiRequest req) => SaveRequest(req);
// Últimas N observaciones por paciente
/// <summary>
/// Retrieves the most recent pump observations for a specified patient, returning up to the requested number of records ordered by time in descending order. Returns an empty list if the repository result is not a list of pump observations or if no observations exist for the patient.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose pump observations are being retrieved.</param>
/// <param name="num">The maximum number of recent pump observations to return. Defaults to 1.</param>
/// <returns>A task that resolves to a list of the most recent <see cref="PumpObservation"/> records for the patient, or an empty list when none are available.</returns>
public async Task<List<PumpObservation>> FindLastPumpObservations(ObjectId patientId, int num = 1)
{
var list = await pumpObservationRepository.FindByPatientId(patientId);
if (list is List<PumpObservation> pumpObservations)
return pumpObservations is { Count: 0 }
? []
: pumpObservations.OrderByDescending(x => x.Time).Take(num).ToList();
return [];
}
// Última fecha de observación por paciente (para todos los pacientes)
/// <summary>
/// Asynchronously retrieves the most recent pump observation timestamp for every patient by delegating to the pump observation repository.
/// </summary>
/// <returns>A task that resolves to a dictionary mapping each patient's <see cref="ObjectId"/> to the <see cref="DateTime"/> of their last pump observation.</returns>
public async Task<Dictionary<ObjectId, DateTime>> FindAllLastPatientObservationTime()
{
return await pumpObservationRepository.FindAllLastPatientObservationTimeAsync();
}
// Borrado completo por paciente (observaciones + alarmas + alarmState)
/// <summary>
/// Asynchronously deletes all pump observation, alarm event, and alarm state data associated with the specified patient identifier.
/// </summary>
/// <param name="id">The unique identifier of the patient whose related pump data should be removed.</param>
/// <returns>A task that represents the asynchronous deletion of the patient's data across the pump observation, alarm event, and alarm state repositories.</returns>
public async Task DeleteByPatientId(ObjectId id)
{
logger.LogDebug("Delete Pump data by Patient Id {id}", id);
await pumpObservationRepository.DeleteByPatientId(id);
await alarmEventRepo.DeleteByPatientId(id);
await alarmStateRepo.DeleteByPatientId(id);
}
// Archivo → por entidad paciente
/// <summary>
/// Archives the specified patient by delegating the operation to the archive routine identified by the patient's identifier.
/// </summary>
/// <param name="patient">The patient to be archived.</param>
public async Task Archive(Patient patient) => await ArchiveByPatientId(patient.Id);
// Archivo → por PatientId (mueve a archive_pumpobservations y elimina del activo)
/// <summary>
/// Archives all pump observations associated with the specified patient by moving them to the archive repository and then deleting the active data. If no pump observations are found for the patient, the archive insertion is skipped while the deletion of active data still proceeds.
/// </summary>
/// <param name="id">The unique identifier of the patient whose pump observations should be archived.</param>
public async Task ArchiveByPatientId(ObjectId id)
{
var list = await pumpObservationRepository.FindByPatientId(id);
var pumpObservations = list.ToList();
if (pumpObservations.Count != 0)
await pumpArchiveRepo.InsertManyAsync(pumpObservations);
await DeleteByPatientId(id);
logger.LogDebug("Archived Pump observations & deleted active data by Patient Id {id}", id);
}
// Actualización masiva
/// <summary>
/// Updates the ObjectId of pump observations and related alarms from an old identifier to a new one for the specified field, logging the operation and recording an audit entry.
/// </summary>
/// <param name="nameId">The name of the field whose ObjectId should be updated.</param>
/// <param name="id">The new ObjectId to assign.</param>
/// <param name="oldId">The existing ObjectId to be replaced.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="nameId"/> is null, empty, or whitespace.</exception>
public async Task UpdateManyObjectId(string nameId, ObjectId id, ObjectId oldId)
{
if (string.IsNullOrWhiteSpace(nameId))
throw new ArgumentException("nameId no puede ser nulo o vacío.", nameof(nameId));
var updatedObs = await pumpObservationRepository.UpdateManyObjectIdByFieldAsync(nameId, id, oldId);
// actualizar también alarmas activas e históricas
_ = await alarmEventRepo.UpdateManyObjectIdByFiledNameAsync(nameId, id, oldId);
_ = await alarmStateRepo.UpdateManyObjectIdByFieldNameAsync(nameId, id, oldId);
logger.LogInformation(
"UpdateManyObjectId completado. Campo={field}, oldId={oldId}, newId={newId}. Obs actualizadas={obsUpdated}",
nameId, oldId, id, updatedObs);
// Si quieres auditar el cambio:
await auditService.CreateAuditLogAsync(
httpContextAccessor.HttpContext?.User!,
new { Field = nameId, OldId = oldId, NewId = id, Scope = "PumpObservation" },
null);
}
// ConfigPumpsService
/// <summary>
/// Retrieves a list of configuration pump items associated with the specified identifier by delegating to the configuration pumps service.
/// </summary>
/// <param name="id">The identifier used to look up the configuration pump items.</param>
/// <returns>A task that represents the asynchronous operation. The task result is a list of <see cref="ConfigPumpItem"/> matching the given identifier, or <c>null</c> if no items are found.</returns>
public async Task<List<ConfigPumpItem>?> GetItemsById(string id)
=> await configPumpsService.GetConfigItems(id);
/// <summary>
/// Retrieves all available pump configurations from the configuration service.
/// Throws a <see cref="NotFoundException"/> if the service returns no data, indicating that the pump configuration resource is missing.
/// </summary>
/// <returns>A task that represents the asynchronous operation, containing a list of <see cref="ConfigPumps"/> configurations.</returns>
/// <exception cref="NotFoundException">Thrown when the underlying service returns a null result, meaning the pump configuration resource was not found.</exception>
public async Task<List<ConfigPumps>?> GetAllPumpConfig()
=> await configPumpsService.GetAllPumpConfigs()
?? throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
/// <summary>
/// Retrieves the pump configuration that matches the specified identifier.
/// Throws a not-found exception when no matching configuration exists in the underlying service.
/// </summary>
/// <param name="id">The unique identifier of the pump configuration to retrieve.</param>
/// <returns>The matching <see cref="ConfigPumps"/> instance, or <c>null</c> if the service returns one; otherwise a <see cref="NotFoundException"/> is thrown.</returns>
/// <exception cref="NotFoundException">Thrown when the configuration service returns <c>null</c>, indicating that the requested resource is missing.</exception>
public async Task<ConfigPumps?> GetPumpConfigsById(string id)
=> await configPumpsService.GetPumpConfigById(id)
?? throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
/// <summary>
/// Updates the pump configuration identified by <paramref name="config"/>, recording an audit log entry that captures the previous configuration and the new values before applying the change.
/// </summary>
/// <param name="config">The pump configuration containing the updated values to persist.</param>
/// <returns>The updated <see cref="ConfigPumps"/> configuration if the update succeeds.</returns>
/// <exception cref="NotFoundException">Thrown when the underlying update operation does not return a configuration, indicating the resource is missing.</exception>
public async Task<ConfigPumps?> UpdatePumpConfig(ConfigPumps config)
{
var oldConfig = await configPumpsService.GetPumpConfigById(config.Id);
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, oldConfig, config);
return await configPumpsService.UpdatePumpConfig(config)
?? throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
}
/// <summary>
/// Inserts a new pump configuration after recording an audit log entry. Throws a conflict exception if the underlying service fails to create the configuration.
/// </summary>
/// <param name="config">The pump configuration to insert.</param>
/// <returns>The newly inserted <see cref="ConfigPumps"/>, or <see langword="null"/> if the operation yields no result.</returns>
/// <exception cref="ConflictException">Thrown when the pump configuration could not be created by the underlying service.</exception>
public async Task<ConfigPumps?> InsertPumpConfig(ConfigPumps config)
{
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, null, config);
return await configPumpsService.InsertPumpConfig(config)
?? throw new ConflictException(HttpEnum.ErrorMessage.ConflictCreationFailed);
}
/// <summary>
/// Deletes the specified pump configuration and records an audit log entry when the operation succeeds.
/// </summary>
/// <param name="config">The pump configuration to delete.</param>
/// <returns>A task that represents the asynchronous operation, containing a value indicating whether the deletion was successful.</returns>
/// <exception cref="ConflictException">Thrown when the underlying deletion operation fails.</exception>
public async Task<bool> DeletePumpConfig(ConfigPumps config)
{
var result = await configPumpsService.DeletePumpConfig(config);
if (!result) throw new ConflictException(HttpEnum.ErrorMessage.ConflictDeleteFailed);
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, config, null);
return result;
}
// Paginación de observaciones
/// <summary>
/// Retrieves a paginated collection of <see cref="PumpObservation"/> records based on the supplied filter. When a <c>PatientId</c> is provided, the patient identifier is validated and results are filtered by an optional date range; when a <c>DeviceId</c> is provided instead, the repository query already applies the date range. If no filter criteria or only invalid input is supplied, an empty paged response is returned and the total count is reported as zero. Results are ordered by observation time in descending order before pagination is applied.
/// </summary>
/// <param name="filter">The pagination and filter criteria, including page number, page size, optional patient identifier, device identifier, and date range.</param>
/// <returns>A task that yields the paginated response of pump observations, or <c>null</c> if the operation cannot be performed.</returns>
public async Task<PaginationResponse<PumpObservation>?> GetPaginatedPump(PaginationFilter filter)
{
// Implementación compatible sin nuevos métodos en los repos:
// 1) Si llega PatientId, paginamos en memoria desde FindByPatientId.
// 2) Si llega DeviceId, usamos FindByDeviceIdAsync y paginamos en memoria.
// 3) Si no hay filtro, devolvemos vacío para evitar lecturas completas.
var page = filter.PageNumber <= 0 ? 1 : filter.PageNumber;
var size = filter.PageSize <= 0 ? 20 : filter.PageSize;
var fr = filter.FilteredRequest;
if (fr == null) return new PaginationResponse<PumpObservation>([], page, size, 0);
List<PumpObservation> all;
if (!string.IsNullOrWhiteSpace(fr.PatientId))
{
if (!ObjectId.TryParse(fr.PatientId, out var patientId))
return new PaginationResponse<PumpObservation>([], page, size, 0);
var list = await pumpObservationRepository.FindByPatientId(patientId);
all = list.ToList();
if (fr.StartDate.HasValue)
all = all.Where(o => o.Time >= fr.StartDate.Value).ToList();
if (fr.EndDate.HasValue)
all = all.Where(o => o.Time <= fr.EndDate.Value).ToList();
}
else if (!string.IsNullOrWhiteSpace(fr.DeviceId))
{
var found = await pumpObservationRepository.FindByDeviceIdAsync(fr.DeviceId, fr.StartDate, fr.EndDate);
all = found.ToList();
}
else
{
all = [];
}
var count = all.Count;
var pageData = all
.OrderByDescending(o => o.Time)
.Skip((page - 1) * size)
.Take(size)
.ToList();
return new PaginationResponse<PumpObservation>(pageData, page, size, count);
}
// Inserción manual
/// <summary>
/// Inserts a pump observation into the repository, applying default values for missing identifier and timestamp, and triggers post-insertion side effects such as audit logging, state updates, snapshot broadcasting, and retention actions. If the mapped observation is null, no further action is taken. Observations with a number of zero are skipped from post-insertion processing when zero-value emissions are disabled.
/// </summary>
/// <param name="obs">The pump observation to insert. Its <c>Id</c> is generated if not set, and its <c>Time</c> defaults to UTC now if set to <see cref="DateTime.MinValue"/>.</param>
public async Task InsertPumpObservation(PumpObservation obs)
{
logger.LogDebug("Insert {obs}", obs);
obs.Id = ObjectId.GenerateNewId();
if (obs.Time == DateTime.MinValue) obs.Time = DateTime.UtcNow;
var obsMapped = await MapPumpObservation(obs);
if (obsMapped != null)
{
await pumpObservationRepository.InsertAsync(obsMapped);
await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, null, obsMapped);
if (!_sendPumpsZero && obsMapped.Number == 0) return;
// Tras inserción manual, actualizar y emitir snapshots
var state = await UpdatePumpState(obsMapped);
var activeAlarms = await alarmStateRepo.FindAllActiveByDeviceAsync(obsMapped.DeviceId!);
await SendSnapshotsBroadcast(state, activeAlarms, obsMapped.PatientId, null);
await DoRetentionActions(obsMapped);
}
}
/// <summary>
/// Applies the configured retention policy to pump observations, executing deletion by age (days) or by count (keeping the last N), and logging a warning for unrecognized policies. Returns early when no retention policy value is available.
/// </summary>
/// <param name="obs">The pump observation used to resolve the applicable retention configuration.</param>
private async Task DoRetentionActions(PumpObservation obs)
{
var result = await configPumpsService.RetentionActions(obs);
if (result is not { RetentionPolicyValue: not null })
return;
switch (result.RetentionPolicy)
{
case RetentionPolicy.DeleteOlderDays:
{
var removed = await pumpObservationRepository.DeleteOlderThanDaysAsync(
result.RetentionPolicyValue.Value);
logger.LogInformation(
"Retention DeleteOlderDays: {removed} deleted (>{days} days)",
removed, result.RetentionPolicyValue);
break;
}
case RetentionPolicy.DeleteOlderNumber:
{
var removed = await pumpObservationRepository.DeleteKeepLastNAsync(
result.RetentionPolicyValue.Value);
logger.LogInformation(
"Retention DeleteOlderNumber: {removed} deleted (keeping {max})",
removed, result.RetentionPolicyValue);
break;
}
case RetentionPolicy.NoDelete:
case RetentionPolicy.DeleteOlderSeconds:
default:
logger.LogWarning("Unknown retention policy: {policy}", result.RetentionPolicy);
break;
}
}
// BROADCAST SNAPSHOTS: PumpState + PumpAlarmState (activos)
/// <summary>
/// Broadcasts the current pump state and active alarms to all WebSocket subscribers whose
/// configured location matches either the patient's location (when a patient identifier is
/// supplied and found) or the location carried by the request. The method performs no
/// broadcast and returns early when no matching subscribers exist.
/// </summary>
/// <param name="state">The current <see cref="PumpState"/> snapshot to send to every matched subscriber.</param>
/// <param name="activeAlarms">The collection of active <see cref="PumpAlarmState"/> entries to forward to every matched subscriber.</param>
/// <param name="patientId">Optional patient identifier used to resolve the patient and derive the target location; when null the request location is used instead.</param>
/// <param name="req">Optional API request whose <c>Location</c> is used as a fallback to resolve the target location when no patient identifier is provided.</param>
/// <returns>A <see cref="Task"/> that completes once the pump state and all active alarms have been dispatched, or immediately when there are no matching subscribers.</returns>
private async Task SendSnapshotsBroadcast(
PumpState state,
IEnumerable<PumpAlarmState> activeAlarms,
ObjectId? patientId,
ApiRequest? req)
{
var subscribers = new List<WsSubscriber>();
if (patientId != null)
{
var patient = await patientService.FindById(patientId.Value);
if (patient != null)
{
subscribers = subscribersService.GetSubscribers()
.Where(s => !s.Locations.IsNullOrEmpty()
&& s.Locations.Any(c =>
c.UnitName == patient.Location.UnitName &&
c.Bed == patient.Location.Bed &&
c.Room == patient.Location.Room))
.ToList();
}
}
else if (req?.Location != null)
{
var loc = req.Location;
subscribers = subscribersService.GetSubscribers()
.Where(s => !s.Locations.IsNullOrEmpty()
&& s.Locations.Any(c =>
c.UnitName == loc.UnitName &&
c.Bed == loc.Bed &&
c.Room == loc.Room))
.ToList();
}
if (subscribers.Count == 0) return;
// Enviar PumpState
foreach (var sub in subscribers)
await clientMessageService.SendAsync(sub.Id, OperationType.Pump, state);
// Enviar todas las PumpAlarmState activas
foreach (var alarm in activeAlarms)
{
foreach (var sub in subscribers)
await clientMessageService.SendAsync(sub.Id, OperationType.PumpAlarm, alarm);
}
}
/// <summary>
/// Updates the patient identifier on the pump observation by first preserving any existing value, then falling back to a previously found patient id, and finally attempting to parse the patient id provided in the API request.
/// </summary>
/// <param name="req">The API request that may contain a patient id to be parsed and assigned.</param>
/// <param name="obs">The pump observation whose patient id is being updated in place.</param>
/// <param name="foundPatientId">An optional patient id obtained from a prior lookup, used as a fallback when the observation has no patient id assigned.</param>
private static void UpdatePatientFromRequest(ApiRequest req, PumpObservation obs, ObjectId? foundPatientId)
{
if (obs.PatientId == null && foundPatientId != null)
obs.PatientId = foundPatientId;
if (obs.PatientId != null || string.IsNullOrWhiteSpace(req.PatientId)) return;
if (ObjectId.TryParse(req.PatientId, out var parsed))
obs.PatientId = parsed;
}
}
}