Volver a Blog Base de Datos

Diseño de Base de Datos para un Sistema de Centros de Salud Municipal

8 min read vistas 1 leyendo ahora
Índice del artículo

Introducción: El desafío de digitalizar la salud pública

Cuando me enfrenté al desafío de diseñar un sistema de gestión para centros de salud municipales, me di cuenta de que no era un CRUD simple. Tenía que manejar pacientes, médicos, historias clínicas, internaciones, obras sociales y todo con integridad referencial, auditoría y consultas complejas.

Este proyecto me enseñó que el diseño de bases de datos no es solo crear tablas, sino modelar la realidad del negocio de forma eficiente y escalable. En este artículo comparto cómo abordé el problema, qué decisiones tomé y las mejores prácticas que apliqué.


El problema: requisitos complejos del sistema

Los requisitos del sistema eran claros pero desafiantes:

  • Múltiples centros de salud organizados por zonas y barrios
  • Médicos con múltiples especialidades trabajando en varios centros
  • Pacientes con historias clínicas detalladas
  • Internaciones con gestión de camas
  • Integración con obras sociales
  • Auditoría de todas las operaciones críticas
  • Consultas complejas para reportes y estadísticas

Un mal diseño aquí significaría: datos inconsistentes, consultas lentas y pesadillas de mantenimiento.


Fase 1: Modelado conceptual y normalización

Lo primero fue identificar las entidades principales y sus relaciones:

  • Zona → Barrio → Centro de Salud (relación jerárquica)
  • Médico ↔ Especialidad (relación N:N)
  • Paciente → Historia Clínica → Médico/Centro
  • Internación → Cama → Centro de Salud
  • Paciente → Obra Social (relación N:1)

Apliqué normalización hasta 3FN para evitar redundancia, pero sin obsesionarme (a veces un poco de desnormalización mejora el rendimiento).

-- Ejemplo: relación N:N entre Médico y Especialidad
CREATE TABLE Medico (
    id_medico SERIAL PRIMARY KEY,
    matricula VARCHAR(50) UNIQUE,
    nombres VARCHAR(255) NOT NULL,
    apellidos VARCHAR(255) NOT NULL,
    -- más campos...
);

CREATE TABLE EspecialidadesMedicas (
    id_especialidad SERIAL PRIMARY KEY,
    nombre_especialidad VARCHAR(255) NOT NULL UNIQUE
);

-- Tabla asociativa
CREATE TABLE MedicoEspecialidad (
    id_medico INT NOT NULL,
    id_especialidad INT NOT NULL,
    PRIMARY KEY (id_medico, id_especialidad),
    FOREIGN KEY (id_medico) REFERENCES Medico(id_medico) ON DELETE CASCADE,
    FOREIGN KEY (id_especialidad) REFERENCES EspecialidadesMedicas(id_especialidad) ON DELETE CASCADE
);

Las tablas asociativas fueron clave para manejar relaciones muchos-a-muchos.


Fase 2: Constraints y reglas de negocio

La integridad de datos no es opcional en salud. Implementé múltiples restricciones:

  • UNIQUE en DNI de pacientes y matrícula de médicos
  • NOT NULL en campos críticos
  • Foreign Keys con ON DELETE CASCADE/SET NULL según el caso
  • CHECK constraints (aunque finalmente usé triggers para validaciones complejas)
-- Paciente con DNI único y FK a obra social
CREATE TABLE Paciente (
    id_paciente SERIAL PRIMARY KEY,
    dni BIGINT UNIQUE NOT NULL,
    nombres VARCHAR(255) NOT NULL,
    apellidos VARCHAR(255) NOT NULL,
    id_obra_social INT,
    CONSTRAINT fk_paciente_obrasocial 
        FOREIGN KEY (id_obra_social) 
        REFERENCES ObraSocial(id_obra_social)
);

Aprendí que la base de datos debe ser el primer guardián de la integridad, no la aplicación.


Fase 3: Triggers para lógica compleja

Algunos requisitos no podían resolverse con constraints simples. Los triggers fueron la solución:

Trigger 1: Director único por centro

CREATE OR REPLACE FUNCTION fn_verificar_director_unico()
RETURNS TRIGGER AS $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM CentroSalud
        WHERE director = NEW.director
          AND id_centro_salud <> COALESCE(NEW.id_centro_salud, -1)
    ) THEN
        RAISE EXCEPTION 'Este director ya está asociado a otro centro de salud';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_director_unico
BEFORE INSERT OR UPDATE ON CentroSalud
FOR EACH ROW
EXECUTE FUNCTION fn_verificar_director_unico();

Trigger 2: Validar disponibilidad de cama

CREATE OR REPLACE FUNCTION fn_validar_disponibilidad_cama()
RETURNS TRIGGER AS $$
DECLARE
    ocupada INT;
BEGIN
    IF NEW.id_cama IS NULL THEN
        RETURN NEW;
    END IF;

    -- Solo cuenta camas con internaciones activas
    SELECT COUNT(*) INTO ocupada
    FROM Internacion
    WHERE id_cama = NEW.id_cama
      AND fecha_alta IS NULL;

    IF ocupada > 0 THEN
        RAISE EXCEPTION 'La cama (id: %) está ocupada.', NEW.id_cama;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Los triggers me permitieron validar reglas de negocio complejas directamente en la base de datos.


Fase 4: Auditoría automática

En sistemas de salud, saber quién cambió qué y cuándo es crítico. Implementé una tabla de auditoría con triggers:

CREATE TABLE Auditoria (
    id_auditoria SERIAL PRIMARY KEY,
    tabla_afectada VARCHAR(255) NOT NULL,
    tipo_operacion VARCHAR(50) NOT NULL,
    id_registro_afectado INT,
    usuario_operacion VARCHAR(255),
    detalle_operacion TEXT,
    fecha_hora TIMESTAMP DEFAULT now(),
    id_usuario INT
);

-- Trigger de auditoría en Paciente
CREATE OR REPLACE FUNCTION fn_audit_paciente()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO Auditoria(tabla_afectada, tipo_operacion, id_registro_afectado, 
                              usuario_operacion, detalle_operacion, fecha_hora)
        VALUES ('Paciente', TG_OP, NEW.id_paciente, CURRENT_USER, 
                'INSERT Paciente', NOW());
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO Auditoria(tabla_afectada, tipo_operacion, id_registro_afectado, 
                              usuario_operacion, detalle_operacion, fecha_hora)
        VALUES ('Paciente', TG_OP, NEW.id_paciente, CURRENT_USER, 
                'UPDATE Paciente', NOW());
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO Auditoria(tabla_afectada, tipo_operacion, id_registro_afectado, 
                              usuario_operacion, detalle_operacion, fecha_hora)
        VALUES ('Paciente', TG_OP, OLD.id_paciente, CURRENT_USER, 
                'DELETE Paciente', NOW());
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE plpgsql;

Ahora cada cambio en pacientes queda registrado automáticamente.


Fase 5: Procedimientos almacenados

Para operaciones comunes, creé stored procedures que encapsulan lógica compleja:

-- Insertar paciente con validación
CREATE OR REPLACE FUNCTION sp_insertar_paciente(
    p_nombres VARCHAR,
    p_apellidos VARCHAR,
    p_dni BIGINT,
    p_telefono VARCHAR,
    p_domicilio VARCHAR,
    p_email VARCHAR,
    p_fecha_nacimiento DATE,
    p_id_obra_social INT,
    p_numero_afiliado VARCHAR
) RETURNS INT AS $$
DECLARE
    new_id INT;
BEGIN
    INSERT INTO Paciente(
        nombres, apellidos, dni, telefono, domicilio, email,
        fecha_nacimiento, id_obra_social, numero_afiliado
    )
    VALUES (
        p_nombres, p_apellidos, p_dni, p_telefono, p_domicilio, p_email,
        p_fecha_nacimiento, p_id_obra_social, p_numero_afiliado
    )
    RETURNING id_paciente INTO new_id;
    RETURN new_id;
END;
$$ LANGUAGE plpgsql;

-- Uso:
-- SELECT sp_insertar_paciente('Juan', 'Pérez', 40123456, ...);

Los SPs centralizan lógica y mejoran la seguridad (permisos granulares).


Fase 6: Consultas complejas y optimización

Los requisitos incluían consultas complejas como:

  • Cantidad de consultas por mes y centro
  • Camas disponibles en tiempo real
  • Pacientes sin obra social
  • Centros que atienden traumatología

Ejemplo: Camas disponibles

CREATE OR REPLACE FUNCTION consulta_camas_disponibles()
RETURNS TABLE(
    nombre_centro VARCHAR,
    total_camas BIGINT,
    camas_disponibles BIGINT,
    camas_ocupadas BIGINT
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        cs.nombre AS nombre_centro,
        COUNT(c.id_cama) AS total_camas,
        SUM(CASE WHEN NOT EXISTS (
            SELECT 1 FROM Internacion i 
            WHERE i.id_cama = c.id_cama AND i.fecha_alta IS NULL
        ) THEN 1 ELSE 0 END) AS camas_disponibles,
        SUM(CASE WHEN EXISTS (
            SELECT 1 FROM Internacion i 
            WHERE i.id_cama = c.id_cama AND i.fecha_alta IS NULL
        ) THEN 1 ELSE 0 END) AS camas_ocupadas
    FROM CentroSalud cs
    LEFT JOIN Cama c ON cs.id_centro_salud = c.id_centro_salud
    GROUP BY cs.id_centro_salud, cs.nombre;
END;
$$ LANGUAGE plpgsql;

Para optimizar, agregué índices estratégicos:

CREATE INDEX idx_internacion_fecha_alta ON Internacion(fecha_alta);
CREATE INDEX idx_historia_fecha ON HistoriaClinica(fecha_hora);
CREATE INDEX idx_paciente_obra_social ON Paciente(id_obra_social);

Fase 7: Vistas para simplificar consultas

Creé vistas que abstraen la complejidad de los JOINs:

CREATE OR REPLACE VIEW VistaCentroSaludCompleta AS
SELECT 
    cs.id_centro_salud,
    cs.nombre AS nombre_centro,
    cs.direccion,
    cs.director,
    z.nombre_zona,
    b.nombre_barrio,
    (SELECT COUNT(*) FROM Cama c 
     WHERE c.id_centro_salud = cs.id_centro_salud) AS total_camas,
    (SELECT COUNT(*) FROM Cama c 
     WHERE c.id_centro_salud = cs.id_centro_salud 
     AND c.disponible = TRUE) AS camas_disponibles,
    (SELECT COUNT(*) FROM Internacion i 
     WHERE i.id_centro_salud = cs.id_centro_salud 
     AND i.fecha_alta IS NULL) AS internaciones_activas
FROM CentroSalud cs
LEFT JOIN Zona z ON cs.id_zona = z.id_zona
LEFT JOIN Barrio b ON cs.id_barrio = b.id_barrio;

-- Uso simple:
-- SELECT * FROM VistaCentroSaludCompleta WHERE nombre_zona = 'Zona Norte';

Fase 8: Seguridad y gestión de errores

Implementé múltiples capas de protección:

  • Validación en triggers (evitar datos inválidos)
  • Procedimiento seguro para eliminar centros (verifica dependencias)
  • Prevención de eliminación de médicos con historias clínicas
  • Manejo de errores con RAISE EXCEPTION
-- Eliminar centro solo si no tiene dependencias
CREATE OR REPLACE FUNCTION sp_eliminar_centro_salud_seguro(
    p_id_centro INT
) RETURNS TEXT AS $$
DECLARE
    v_count_internaciones INT;
    v_count_historias INT;
BEGIN
    SELECT COUNT(*) INTO v_count_internaciones 
    FROM Internacion WHERE id_centro_salud = p_id_centro;
    
    SELECT COUNT(*) INTO v_count_historias 
    FROM HistoriaClinica WHERE id_centro_salud = p_id_centro;
    
    IF v_count_internaciones > 0 OR v_count_historias > 0 THEN
        RETURN FORMAT('No se puede eliminar: %s internaciones, %s historias',
                      v_count_internaciones, v_count_historias);
    END IF;
    
    DELETE FROM CentroSalud WHERE id_centro_salud = p_id_centro;
    RETURN 'Centro de salud eliminado correctamente';
END;
$$ LANGUAGE plpgsql;

Fase 9: Testing y datos de prueba

Creé un script completo con datos de prueba realistas:

  • 5 zonas y barrios
  • 5 centros de salud
  • Obras sociales (PAMI, OSDE, Swiss Medical…)
  • Médicos con múltiples especialidades
  • Pacientes con historias clínicas
  • Internaciones activas y cerradas

Esto me permitió probar todas las consultas y validar triggers antes de producción.


Lecciones aprendidas

  • Normalizar correctamente (evité redundancia sin obsesionarme)
  • Usar triggers para reglas de negocio complejas
  • Implementar auditoría desde el inicio
  • Crear procedimientos almacenados para operaciones críticas
  • Agregar índices estratégicos (no todos los campos necesitan índice)
  • Usar vistas para simplificar consultas recurrentes

Errores que cometí:

  • Al principio, no consideré ON DELETE CASCADE/SET NULL (causó bugs)
  • Olvidé índices en campos de búsqueda frecuente (lentitud en consultas)
  • Inicialmente hardcodeé algunos valores que debían ser configurables
  • No documenté suficientemente los triggers al inicio

Escalabilidad y mantenibilidad

El diseño final es:

  • Escalable: agregar centros, médicos o servicios no requiere cambios en el esquema
  • Mantenible: la lógica está encapsulada en procedures y triggers
  • Seguro: validaciones en múltiples capas
  • Auditable: cada cambio crítico queda registrado
  • Performante: índices estratégicos + vistas optimizadas

Conclusión

Diseñar esta base de datos me enseñó que el modelado de datos es el 70% del éxito de un sistema.

Un buen diseño permite:

  • Integridad de datos garantizada por la BD, no solo por la app
  • Consultas complejas de forma eficiente
  • Mantenimiento sencillo a largo plazo
  • Escalabilidad sin reescribir todo

Si tuviera que dar un consejo a mi yo del pasado: invierte tiempo en el diseño inicial, documenta tus decisiones, y no tengas miedo de usar features avanzadas de PostgreSQL como triggers, procedures y constraints complejos.

La base de datos no es solo un lugar para guardar datos, es el corazón de la aplicación.

Últimos artículos

... tip: teclea algo secreto (una pista... CAMILO)