- ✅ 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
553 lines
16 KiB
Markdown
553 lines
16 KiB
Markdown
# 💊 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**
|