codigo0/docs/SISTEMA_VALIDACION_DOSIS.md
planetazuzu 5d7a6500fe refactor: Fase 1 - Clean Architecture, refactorización modular y eliminación de duplicidades
-  Ticket 1.1: Estructura Clean Architecture en backend
-  Ticket 1.2: Schemas Zod compartidos
-  Ticket 1.3: Refactorización drugs.ts (1362 → 8 archivos modulares)
-  Ticket 1.4: Refactorización procedures.ts (3583 → 6 archivos modulares)
-  Ticket 1.5: Eliminación de duplicidades (~50 líneas)

Cambios principales:
- Creada estructura Clean Architecture en backend/src/
- Schemas Zod compartidos en backend/src/shared/schemas/
- Refactorización modular de drugs y procedures
- Utilidades genéricas en src/utils/ (filter, validation)
- Eliminados scripts obsoletos y documentación antigua
- Corregidos errores: QueryClient, import test-error-handling
- Build verificado y funcionando correctamente
2026-01-25 21:09:47 +01:00

16 KiB
Raw Blame History

💊 Sistema de Validación de Dosis

🎯 Objetivo

Crear un sistema robusto para validar dosis médicas con rangos máximos/mínimos por edad/peso y validación de cálculos pediátricos.


📋 Reglas de Negocio

1. Rangos de Dosis por Edad/Peso

Grupos de Edad

  • Neonatal: 0-28 días
  • Lactante: 29 días - 12 meses
  • Pediátrico: 1-12 años
  • Adolescente: 13-17 años
  • Adulto: ≥18 años

Cálculo Pediátrico

  • Por peso: dosis = peso_kg × dosis_por_kg
  • Por superficie corporal: dosis = SCA × dosis_por_m2 (SCA = Superficie Corporal)
  • Por edad: Tablas de dosis según edad

2. Validaciones Críticas

Validaciones Obligatorias

  1. Peso mínimo: No permitir dosis si peso < límite mínimo
  2. Peso máximo: Alertar si peso excede límite máximo (posible error)
  3. Dosis mínima: No permitir dosis por debajo del mínimo terapéutico
  4. Dosis máxima: Bloquear dosis que excedan el máximo seguro
  5. Concentración: Validar que la concentración del fármaco es correcta
  6. Vía de administración: Validar que la vía es apropiada para la edad

⚠️ Alertas (No bloquean, pero advierten)

  1. Dosis en límite superior del rango
  2. Dosis en límite inferior del rango
  3. Peso fuera de percentiles normales
  4. Dosis acumulada diaria alta

🏗️ Arquitectura

Domain Layer

// domain/entities/DoseCalculation.ts
export interface DoseCalculation {
  readonly drugId: string;
  readonly patientWeight: number; // kg
  readonly patientAge: number; // años
  readonly patientAgeMonths?: number; // meses (para <2 años)
  readonly calculatedDose: number; // mg o ml
  readonly unit: 'mg' | 'ml' | 'mcg' | 'UI';
  readonly route: AdministrationRoute;
  readonly method: 'weight' | 'bsa' | 'age'; // Método de cálculo
}

// domain/value-objects/DoseRange.ts
export class DoseRange {
  private constructor(
    readonly min: number,
    readonly max: number,
    readonly unit: string,
    readonly ageGroup: AgeGroup
  ) {}
  
  static create(
    min: number,
    max: number,
    unit: string,
    ageGroup: AgeGroup
  ): DoseRange {
    if (min < 0) throw new Error('Dosis mínima no puede ser negativa');
    if (max < min) throw new Error('Dosis máxima debe ser mayor que mínima');
    return new DoseRange(min, max, unit, ageGroup);
  }
  
  isValid(dose: number): boolean {
    return dose >= this.min && dose <= this.max;
  }
  
  getWarningLevel(dose: number): 'safe' | 'low' | 'high' | 'critical' {
    const range = this.max - this.min;
    const lowerThreshold = this.min + (range * 0.1); // 10% del rango
    const upperThreshold = this.max - (range * 0.1);
    
    if (dose < this.min || dose > this.max) return 'critical';
    if (dose < lowerThreshold) return 'low';
    if (dose > upperThreshold) return 'high';
    return 'safe';
  }
}

// domain/value-objects/PatientAge.ts
export class PatientAge {
  private constructor(
    readonly years: number,
    readonly months?: number,
    readonly days?: number
  ) {}
  
  static fromYears(years: number): PatientAge {
    if (years < 0) throw new Error('Edad no puede ser negativa');
    return new PatientAge(years);
  }
  
  static fromMonths(months: number): PatientAge {
    if (months < 0) throw new Error('Edad en meses no puede ser negativa');
    const years = Math.floor(months / 12);
    const remainingMonths = months % 12;
    return new PatientAge(years, remainingMonths);
  }
  
  static fromDays(days: number): PatientAge {
    if (days < 0) throw new Error('Edad en días no puede ser negativa');
    const months = Math.floor(days / 30);
    const years = Math.floor(months / 12);
    const remainingMonths = months % 12;
    const remainingDays = days % 30;
    return new PatientAge(years, remainingMonths, remainingDays);
  }
  
  getAgeGroup(): AgeGroup {
    if (this.days !== undefined && this.days <= 28) return 'neonatal';
    if (this.months !== undefined && this.months < 12) return 'lactante';
    if (this.years < 1) return 'lactante';
    if (this.years < 13) return 'pediatrico';
    if (this.years < 18) return 'adolescente';
    return 'adulto';
  }
  
  getWeightRange(): { min: number; max: number } {
    const ageGroup = this.getAgeGroup();
    // Rangos de peso normales por grupo de edad (percentiles 5-95)
    const ranges: Record<AgeGroup, { min: number; max: number }> = {
      neonatal: { min: 2.0, max: 4.5 },
      lactante: { min: 3.0, max: 12.0 },
      pediatrico: { min: 10.0, max: 50.0 },
      adolescente: { min: 40.0, max: 80.0 },
      adulto: { min: 45.0, max: 120.0 },
      todos: { min: 2.0, max: 120.0 }
    };
    return ranges[ageGroup];
  }
}

// domain/value-objects/PatientWeight.ts
export class PatientWeight {
  private constructor(readonly value: number, readonly unit: 'kg' | 'g') {}
  
  static fromKg(kg: number): PatientWeight {
    if (kg <= 0) throw new Error('Peso debe ser mayor que cero');
    if (kg > 200) throw new Error('Peso excesivo, verificar entrada');
    return new PatientWeight(kg, 'kg');
  }
  
  static fromGrams(grams: number): PatientWeight {
    if (grams <= 0) throw new Error('Peso debe ser mayor que cero');
    return new PatientWeight(grams / 1000, 'kg');
  }
  
  toKg(): number {
    return this.unit === 'kg' ? this.value : this.value / 1000;
  }
  
  isValidForAge(age: PatientAge): { valid: boolean; warning?: string } {
    const range = age.getWeightRange();
    const weightKg = this.toKg();
    
    if (weightKg < range.min) {
      return {
        valid: false,
        warning: `Peso (${weightKg}kg) está por debajo del rango normal para esta edad (${range.min}-${range.max}kg). Verificar entrada.`
      };
    }
    
    if (weightKg > range.max) {
      return {
        valid: true,
        warning: `Peso (${weightKg}kg) está por encima del rango normal para esta edad (${range.min}-${range.max}kg). Verificar entrada.`
      };
    }
    
    return { valid: true };
  }
}

Application Layer

// application/services/DoseValidationService.ts
export class DoseValidationService {
  constructor(
    private readonly drugRepository: IDrugRepository,
    private readonly doseRangeRepository: IDoseRangeRepository
  ) {}
  
  async validateDose(
    drugId: string,
    dose: number,
    patientWeight: PatientWeight,
    patientAge: PatientAge,
    route: AdministrationRoute
  ): Promise<DoseValidationResult> {
    // 1. Obtener fármaco
    const drug = await this.drugRepository.findById(drugId);
    if (!drug) {
      throw new Error(`Fármaco ${drugId} no encontrado`);
    }
    
    // 2. Validar peso para edad
    const weightValidation = patientWeight.isValidForAge(patientAge);
    if (!weightValidation.valid) {
      return {
        valid: false,
        errors: [weightValidation.warning!],
        warnings: [],
        critical: true
      };
    }
    
    // 3. Obtener rango de dosis para grupo de edad
    const ageGroup = patientAge.getAgeGroup();
    const doseRange = await this.doseRangeRepository.findByDrugAndAgeGroup(
      drugId,
      ageGroup
    );
    
    if (!doseRange) {
      return {
        valid: false,
        errors: [`No hay rango de dosis definido para ${drug.genericName} en grupo de edad ${ageGroup}`],
        warnings: [],
        critical: true
      };
    }
    
    // 4. Validar dosis contra rango
    const isValid = doseRange.isValid(dose);
    const warningLevel = doseRange.getWarningLevel(dose);
    
    const errors: string[] = [];
    const warnings: string[] = [];
    
    if (!isValid) {
      errors.push(
        `Dosis ${dose}${doseRange.unit} está fuera del rango permitido ` +
        `(${doseRange.min}-${doseRange.max}${doseRange.unit}) para ${ageGroup}`
      );
    }
    
    if (warningLevel === 'low') {
      warnings.push(`Dosis está en el límite inferior del rango. Verificar necesidad.`);
    }
    
    if (warningLevel === 'high') {
      warnings.push(`Dosis está en el límite superior del rango. Monitorizar efectos.`);
    }
    
    // 5. Validar vía de administración
    if (!drug.routes.includes(route)) {
      errors.push(
        `Vía ${route} no está indicada para ${drug.genericName}. ` +
        `Vías permitidas: ${drug.routes.join(', ')}`
      );
    }
    
    // 6. Validar contraindicaciones por edad
    const ageContraindications = this.checkAgeContraindications(
      drug,
      patientAge
    );
    if (ageContraindications.length > 0) {
      errors.push(...ageContraindications);
    }
    
    return {
      valid: errors.length === 0,
      errors,
      warnings,
      critical: errors.length > 0,
      calculatedDose: dose,
      recommendedRange: {
        min: doseRange.min,
        max: doseRange.max,
        unit: doseRange.unit
      }
    };
  }
  
  async calculatePediatricDose(
    drugId: string,
    patientWeight: PatientWeight,
    patientAge: PatientAge,
    method: 'weight' | 'bsa' = 'weight'
  ): Promise<DoseCalculation> {
    const drug = await this.drugRepository.findById(drugId);
    if (!drug) {
      throw new Error(`Fármaco ${drugId} no encontrado`);
    }
    
    if (!drug.pediatricDose) {
      throw new Error(`No hay dosis pediátrica definida para ${drug.genericName}`);
    }
    
    // Parsear dosis pediátrica (ej: "0.01 mg/kg" o "10 mg/m²")
    const dosePerKg = this.parseDosePerKg(drug.pediatricDose);
    
    if (!dosePerKg) {
      throw new Error(`Formato de dosis pediátrica inválido: ${drug.pediatricDose}`);
    }
    
    const weightKg = patientWeight.toKg();
    let calculatedDose: number;
    
    if (method === 'weight') {
      calculatedDose = weightKg * dosePerKg.value;
    } else {
      // BSA (Body Surface Area) usando fórmula de Mosteller
      const heightCm = this.estimateHeight(patientAge);
      const bsa = Math.sqrt((weightKg * heightCm) / 3600);
      calculatedDose = bsa * dosePerKg.value;
    }
    
    return {
      drugId,
      patientWeight: weightKg,
      patientAge: patientAge.years,
      patientAgeMonths: patientAge.months,
      calculatedDose: Math.round(calculatedDose * 100) / 100, // Redondear a 2 decimales
      unit: dosePerKg.unit,
      route: drug.routes[0], // Primera vía permitida
      method
    };
  }
  
  private parseDosePerKg(doseString: string): { value: number; unit: string } | null {
    // Parsear formatos como "0.01 mg/kg" o "10 mg/m²"
    const match = doseString.match(/(\d+\.?\d*)\s*(mg|mcg|ml|UI|g)\/(kg|m²)/);
    if (!match) return null;
    
    return {
      value: parseFloat(match[1]),
      unit: match[2]
    };
  }
  
  private estimateHeight(age: PatientAge): number {
    // Estimación de altura según edad (fórmulas aproximadas)
    if (age.years < 1) {
      return 50 + (age.months || 0) * 2.5; // cm
    }
    if (age.years < 2) {
      return 75 + (age.years - 1) * 10;
    }
    // Fórmula de altura para niños: altura = edad × 6 + 77
    return age.years * 6 + 77;
  }
  
  private checkAgeContraindications(
    drug: Drug,
    age: PatientAge
  ): string[] {
    const errors: string[] = [];
    const ageGroup = age.getAgeGroup();
    
    // Verificar contraindicaciones específicas por edad
    if (drug.contraindications.some(c => c.includes('neonatal')) && ageGroup === 'neonatal') {
      errors.push(`Fármaco contraindicado en neonatos`);
    }
    
    if (drug.contraindications.some(c => c.includes('lactante')) && ageGroup === 'lactante') {
      errors.push(`Fármaco contraindicado en lactantes`);
    }
    
    return errors;
  }
}

export interface DoseValidationResult {
  valid: boolean;
  errors: string[];
  warnings: string[];
  critical: boolean;
  calculatedDose?: number;
  recommendedRange?: {
    min: number;
    max: number;
    unit: string;
  };
}

Infrastructure Layer

// infrastructure/repositories/DoseRangeRepository.ts
export class DoseRangeRepository implements IDoseRangeRepository {
  constructor(private readonly db: Database) {}
  
  async findByDrugAndAgeGroup(
    drugId: string,
    ageGroup: AgeGroup
  ): Promise<DoseRange | null> {
    const result = await this.db.query(
      `SELECT min_dose, max_dose, unit, age_group
       FROM tes_content.dose_ranges
       WHERE drug_id = $1 AND age_group = $2`,
      [drugId, ageGroup]
    );
    
    if (result.rows.length === 0) return null;
    
    const row = result.rows[0];
    return DoseRange.create(
      row.min_dose,
      row.max_dose,
      row.unit,
      row.age_group
    );
  }
}

🗄️ Esquema de Base de Datos

-- Tabla de rangos de dosis
CREATE TABLE tes_content.dose_ranges (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  drug_id UUID NOT NULL REFERENCES tes_content.drugs(id),
  age_group tes_content.age_group NOT NULL,
  min_dose DECIMAL(10, 3) NOT NULL,
  max_dose DECIMAL(10, 3) NOT NULL,
  unit VARCHAR(20) NOT NULL,
  method VARCHAR(20) NOT NULL DEFAULT 'weight', -- 'weight', 'bsa', 'age'
  source VARCHAR(200),
  notes TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  
  CONSTRAINT chk_min_max CHECK (max_dose > min_dose),
  CONSTRAINT chk_min_positive CHECK (min_dose > 0),
  CONSTRAINT unique_drug_age UNIQUE (drug_id, age_group)
);

CREATE INDEX idx_dose_ranges_drug_id ON tes_content.dose_ranges(drug_id);
CREATE INDEX idx_dose_ranges_age_group ON tes_content.dose_ranges(age_group);

Casos de Uso

Caso 1: Validar dosis antes de administrar

const validation = await doseValidationService.validateDose(
  'adrenalina',
  0.5, // mg
  PatientWeight.fromKg(70),
  PatientAge.fromYears(35),
  'IM'
);

if (!validation.valid) {
  // Bloquear administración
  throw new Error(`Dosis inválida: ${validation.errors.join(', ')}`);
}

if (validation.warnings.length > 0) {
  // Mostrar advertencias pero permitir
  console.warn(validation.warnings);
}

Caso 2: Calcular dosis pediátrica

const calculation = await doseValidationService.calculatePediatricDose(
  'adrenalina',
  PatientWeight.fromKg(20),
  PatientAge.fromYears(5),
  'weight'
);

console.log(`Dosis calculada: ${calculation.calculatedDose}${calculation.unit}`);

🧪 Tests

describe('DoseValidationService', () => {
  describe('validateDose', () => {
    it('should reject dose below minimum', async () => {
      const result = await service.validateDose(
        'adrenalina',
        0.1, // mg - muy bajo
        PatientWeight.fromKg(70),
        PatientAge.fromYears(35),
        'IM'
      );
      
      expect(result.valid).toBe(false);
      expect(result.errors).toContain(expect.stringContaining('mínima'));
    });
    
    it('should reject dose above maximum', async () => {
      const result = await service.validateDose(
        'adrenalina',
        10, // mg - muy alto
        PatientWeight.fromKg(70),
        PatientAge.fromYears(35),
        'IM'
      );
      
      expect(result.valid).toBe(false);
      expect(result.errors).toContain(expect.stringContaining('máxima'));
    });
    
    it('should warn for weight outside normal range', async () => {
      const result = await service.validateDose(
        'adrenalina',
        0.5,
        PatientWeight.fromKg(150), // Peso muy alto
        PatientAge.fromYears(10), // Niño de 10 años
        'IM'
      );
      
      expect(result.warnings).toContain(expect.stringContaining('peso'));
    });
  });
});

📝 Notas Importantes

  1. Errores críticos bloquean la acción: Dosis fuera de rango, peso inválido, vía incorrecta
  2. Advertencias no bloquean: Dosis en límites, peso fuera de percentiles normales
  3. Validación siempre en backend: El frontend puede pre-validar, pero el backend es la fuente de verdad
  4. Logs de todas las validaciones: Registrar todas las validaciones para auditoría

Fin del documento