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

553 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 💊 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```sql
-- 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
```typescript
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
```typescript
const calculation = await doseValidationService.calculatePediatricDose(
'adrenalina',
PatientWeight.fromKg(20),
PatientAge.fromYears(5),
'weight'
);
console.log(`Dosis calculada: ${calculation.calculatedDose}${calculation.unit}`);
```
---
## 🧪 Tests
```typescript
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**