533 lines
23 KiB
Python
533 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import ValidationError
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LimsResult(models.Model):
|
|
_name = 'lims.result'
|
|
_description = 'Resultado de Prueba de Laboratorio'
|
|
_rec_name = 'display_name'
|
|
_order = 'test_id, sequence'
|
|
|
|
display_name = fields.Char(
|
|
string='Nombre',
|
|
compute='_compute_display_name',
|
|
store=True
|
|
)
|
|
|
|
test_id = fields.Many2one(
|
|
'lims.test',
|
|
string='Prueba',
|
|
required=True,
|
|
ondelete='cascade'
|
|
)
|
|
|
|
# Campo relacionado para acceder a la muestra sin duplicar datos
|
|
test_sample_id = fields.Many2one(
|
|
'stock.lot',
|
|
string='Muestra',
|
|
related='test_id.sample_id',
|
|
readonly=True,
|
|
store=True # Para poder buscar y filtrar
|
|
)
|
|
|
|
# Campo relacionado para mostrar el estado sin duplicar
|
|
test_sample_state = fields.Selection(
|
|
string='Estado de Muestra',
|
|
related='test_sample_id.state',
|
|
readonly=True
|
|
)
|
|
|
|
# Cambio de parameter_name a parameter_id
|
|
parameter_id = fields.Many2one(
|
|
'lims.analysis.parameter',
|
|
string='Parámetro',
|
|
required=True,
|
|
ondelete='restrict'
|
|
)
|
|
|
|
# Mantener parameter_name como campo related para compatibilidad
|
|
parameter_name = fields.Char(
|
|
string='Nombre del Parámetro',
|
|
related='parameter_id.name',
|
|
store=True,
|
|
readonly=True
|
|
)
|
|
|
|
parameter_code = fields.Char(
|
|
string='Código',
|
|
related='parameter_id.code',
|
|
store=True,
|
|
readonly=True
|
|
)
|
|
|
|
sequence = fields.Integer(
|
|
string='Secuencia',
|
|
default=10
|
|
)
|
|
|
|
# Campos relacionados del parámetro
|
|
parameter_value_type = fields.Selection(
|
|
related='parameter_id.value_type',
|
|
string='Tipo de Valor',
|
|
store=True,
|
|
readonly=True
|
|
)
|
|
|
|
parameter_unit = fields.Char(
|
|
related='parameter_id.unit',
|
|
string='Unidad',
|
|
readonly=True
|
|
)
|
|
|
|
# Valores del resultado
|
|
value_numeric = fields.Float(
|
|
string='Valor Numérico'
|
|
)
|
|
|
|
value_text = fields.Char(
|
|
string='Valor de Texto'
|
|
)
|
|
|
|
value_selection = fields.Char(
|
|
string='Valor de Selección',
|
|
help='Ingrese el valor o las primeras letras. Ej: P para Positivo, N para Negativo'
|
|
)
|
|
|
|
# Campo para mostrar las opciones disponibles
|
|
selection_options_display = fields.Char(
|
|
string='Opciones disponibles',
|
|
compute='_compute_selection_options_display',
|
|
help='Opciones válidas para este parámetro'
|
|
)
|
|
|
|
value_boolean = fields.Boolean(
|
|
string='Valor Sí/No'
|
|
)
|
|
|
|
# Campo unificado para mostrar el valor
|
|
value_display = fields.Char(
|
|
string='Valor',
|
|
compute='_compute_value_display',
|
|
store=True
|
|
)
|
|
|
|
# Campos computados para validación de rangos
|
|
applicable_range_id = fields.Many2one(
|
|
'lims.parameter.range',
|
|
compute='_compute_applicable_range',
|
|
string='Rango Aplicable',
|
|
store=False
|
|
)
|
|
|
|
is_out_of_range = fields.Boolean(
|
|
string='Fuera de Rango',
|
|
compute='_compute_is_out_of_range',
|
|
store=True
|
|
)
|
|
|
|
is_critical = fields.Boolean(
|
|
string='Valor Crítico',
|
|
compute='_compute_is_out_of_range',
|
|
store=True
|
|
)
|
|
|
|
notes = fields.Text(
|
|
string='Notas del Técnico'
|
|
)
|
|
|
|
# Información del paciente (para cálculo de rangos)
|
|
patient_id = fields.Many2one(
|
|
related='test_id.patient_id',
|
|
string='Paciente',
|
|
store=True
|
|
)
|
|
|
|
test_date = fields.Datetime(
|
|
related='test_id.create_date',
|
|
string='Fecha de la Prueba',
|
|
store=True
|
|
)
|
|
|
|
result_status = fields.Selection([
|
|
('normal', 'Normal'),
|
|
('abnormal', 'Anormal'),
|
|
('critical', 'Crítico')
|
|
], string='Estado', compute='_compute_result_status', store=True)
|
|
|
|
@api.depends('test_id', 'parameter_name')
|
|
def _compute_display_name(self):
|
|
"""Calcula el nombre a mostrar."""
|
|
for record in self:
|
|
if record.test_id and record.parameter_name:
|
|
record.display_name = f"{record.test_id.name} - {record.parameter_name}"
|
|
else:
|
|
record.display_name = record.parameter_name or _('Nuevo')
|
|
|
|
@api.depends('value_numeric', 'value_text', 'value_selection', 'value_boolean', 'parameter_value_type')
|
|
def _compute_value_display(self):
|
|
"""Calcula el valor a mostrar según el tipo de dato."""
|
|
for record in self:
|
|
if record.parameter_value_type == 'numeric':
|
|
if record.value_numeric is not False:
|
|
record.value_display = f"{record.value_numeric} {record.parameter_unit or ''}"
|
|
else:
|
|
record.value_display = ''
|
|
elif record.parameter_value_type == 'text':
|
|
record.value_display = record.value_text or ''
|
|
elif record.parameter_value_type == 'selection':
|
|
record.value_display = record.value_selection or ''
|
|
elif record.parameter_value_type == 'boolean':
|
|
record.value_display = 'Sí' if record.value_boolean else 'No'
|
|
else:
|
|
record.value_display = ''
|
|
|
|
@api.depends('parameter_id', 'patient_id', 'test_date')
|
|
def _compute_applicable_range(self):
|
|
"""Determina el rango de referencia aplicable según el paciente."""
|
|
for record in self:
|
|
if not record.parameter_id or not record.patient_id:
|
|
record.applicable_range_id = False
|
|
continue
|
|
|
|
# Calcular edad del paciente en la fecha del test
|
|
if record.test_date:
|
|
age = record.patient_id.get_age_at_date(record.test_date.date())
|
|
else:
|
|
age = record.patient_id.age
|
|
|
|
# Buscar rango más específico
|
|
domain = [
|
|
('parameter_id', '=', record.parameter_id.id),
|
|
('age_min', '<=', age),
|
|
('age_max', '>=', age),
|
|
'|',
|
|
('gender', '=', record.patient_id.gender),
|
|
('gender', '=', 'both')
|
|
]
|
|
|
|
# Considerar embarazo si aplica
|
|
if record.patient_id.gender == 'female' and record.patient_id.is_pregnant:
|
|
domain.append(('pregnant', '=', True))
|
|
|
|
# Ordenar para obtener el más específico primero
|
|
ranges = self.env['lims.parameter.range'].search(
|
|
domain,
|
|
order='gender desc, pregnant desc',
|
|
limit=1
|
|
)
|
|
|
|
record.applicable_range_id = ranges[0] if ranges else False
|
|
|
|
@api.depends('value_numeric', 'applicable_range_id', 'parameter_value_type')
|
|
def _compute_is_out_of_range(self):
|
|
"""Determina si el valor está fuera del rango normal y si es crítico."""
|
|
for record in self:
|
|
record.is_out_of_range = False
|
|
record.is_critical = False
|
|
|
|
# Solo aplica para valores numéricos
|
|
if record.parameter_value_type != 'numeric' or record.value_numeric is False:
|
|
continue
|
|
|
|
if not record.applicable_range_id:
|
|
continue
|
|
|
|
range_obj = record.applicable_range_id
|
|
status = range_obj.get_value_status(record.value_numeric)
|
|
|
|
record.is_out_of_range = (status != 'normal')
|
|
record.is_critical = (status == 'critical')
|
|
|
|
@api.depends('parameter_id', 'value_numeric', 'is_out_of_range', 'is_critical', 'parameter_value_type')
|
|
def _compute_result_status(self):
|
|
"""Calcula el estado visual del resultado."""
|
|
for record in self:
|
|
if record.parameter_value_type != 'numeric':
|
|
record.result_status = 'normal'
|
|
elif record.is_critical:
|
|
record.result_status = 'critical'
|
|
elif record.is_out_of_range:
|
|
record.result_status = 'abnormal'
|
|
else:
|
|
record.result_status = 'normal'
|
|
|
|
@api.constrains('value_numeric', 'value_text', 'value_selection', 'value_boolean', 'parameter_value_type')
|
|
def _check_value_type(self):
|
|
"""Asegura que el valor ingresado corresponda al tipo de parámetro."""
|
|
for record in self:
|
|
if not record.parameter_id:
|
|
continue
|
|
|
|
value_type = record.parameter_value_type
|
|
has_value = False
|
|
|
|
if value_type == 'numeric':
|
|
has_value = record.value_numeric not in [False, 0.0]
|
|
if record.value_text or record.value_selection:
|
|
raise ValidationError(
|
|
_('Para parámetros numéricos solo se debe ingresar el valor numérico.')
|
|
)
|
|
elif value_type == 'text':
|
|
has_value = bool(record.value_text)
|
|
if (record.value_numeric not in [False, 0.0]) or record.value_selection or record.value_boolean:
|
|
raise ValidationError(
|
|
_('Para parámetros de texto solo se debe ingresar el valor de texto.')
|
|
)
|
|
elif value_type == 'selection':
|
|
has_value = bool(record.value_selection)
|
|
if (record.value_numeric not in [False, 0.0]) or record.value_text or record.value_boolean:
|
|
raise ValidationError(
|
|
_('Para parámetros de selección solo se debe elegir una opción.')
|
|
)
|
|
# Validar que el valor seleccionado sea válido
|
|
if has_value and record.parameter_id:
|
|
valid_options = record.parameter_id.get_selection_list()
|
|
if valid_options and record.value_selection not in valid_options:
|
|
# Intentar autocompletar antes de rechazar
|
|
autocompleted = record._validate_and_autocomplete_selection(record.value_selection)
|
|
if autocompleted not in valid_options:
|
|
raise ValidationError(
|
|
_('El valor "%s" no es una opción válida. Opciones disponibles: %s') %
|
|
(record.value_selection, ', '.join(valid_options))
|
|
)
|
|
elif value_type == 'boolean':
|
|
has_value = True # Boolean siempre tiene valor (True o False)
|
|
if (record.value_numeric not in [False, 0.0]) or record.value_text or record.value_selection:
|
|
raise ValidationError(
|
|
_('Para parámetros Sí/No solo se debe marcar el checkbox.')
|
|
)
|
|
|
|
# Solo requerir valor si la prueba no está en borrador
|
|
if not has_value and record.parameter_id and record.test_id.state != 'draft':
|
|
raise ValidationError(
|
|
_('Debe ingresar un valor para el resultado del parámetro %s.') % record.parameter_name
|
|
)
|
|
|
|
@api.onchange('parameter_id')
|
|
def _onchange_parameter_id(self):
|
|
"""Limpia los valores cuando se cambia el parámetro."""
|
|
if self.parameter_id:
|
|
# Limpiar todos los valores
|
|
self.value_numeric = False
|
|
self.value_text = False
|
|
self.value_selection = False
|
|
self.value_boolean = False
|
|
|
|
# Si es selección, obtener las opciones
|
|
if self.parameter_value_type == 'selection' and self.parameter_id.selection_values:
|
|
# Esto se usará en las vistas para mostrar las opciones dinámicamente
|
|
pass
|
|
|
|
@api.depends('parameter_id', 'parameter_id.selection_values')
|
|
def _compute_selection_options_display(self):
|
|
"""Calcula las opciones disponibles para mostrar al usuario."""
|
|
for record in self:
|
|
if record.parameter_id and record.parameter_value_type == 'selection':
|
|
options = record.parameter_id.get_selection_list()
|
|
if options:
|
|
record.selection_options_display = ' | '.join(options)
|
|
else:
|
|
record.selection_options_display = 'Sin opciones definidas'
|
|
else:
|
|
record.selection_options_display = False
|
|
|
|
@api.onchange('value_selection')
|
|
def _onchange_value_selection(self):
|
|
"""Autocompleta el valor de selección basado en coincidencia parcial."""
|
|
if self.value_selection and self.parameter_id and self.parameter_value_type == 'selection':
|
|
# Obtener las opciones disponibles
|
|
options = self.parameter_id.get_selection_list()
|
|
if options:
|
|
# Convertir el valor ingresado a mayúsculas para comparación
|
|
input_upper = self.value_selection.upper().strip()
|
|
|
|
# Buscar coincidencias
|
|
matches = []
|
|
for option in options:
|
|
option_upper = option.upper()
|
|
if option_upper.startswith(input_upper):
|
|
matches.append(option)
|
|
|
|
# Si hay exactamente una coincidencia, autocompletar
|
|
if len(matches) == 1:
|
|
self.value_selection = matches[0]
|
|
elif len(matches) == 0:
|
|
# Si no hay coincidencias directas, buscar coincidencias parciales
|
|
for option in options:
|
|
if input_upper in option.upper():
|
|
matches.append(option)
|
|
|
|
# Si hay una sola coincidencia parcial, autocompletar
|
|
if len(matches) == 1:
|
|
self.value_selection = matches[0]
|
|
|
|
@api.onchange('value_numeric', 'is_critical')
|
|
def _onchange_critical_value(self):
|
|
"""Autocompleta las notas cuando el valor es crítico."""
|
|
if self.is_critical and self.parameter_value_type == 'numeric' and self.value_numeric:
|
|
# Diccionario de notas médicas para parámetros críticos
|
|
CRITICAL_NOTES = {
|
|
'glucosa': {
|
|
'high': 'Valor elevado de glucosa. Posible prediabetes o diabetes. Se recomienda repetir la prueba en ayunas y consultar con endocrinología.',
|
|
'low': 'Hipoglucemia detectada. Riesgo de síntomas neuroglucogénicos. Evaluar causas: medicamentos, insuficiencia hepática o endocrinopatías.'
|
|
},
|
|
'hemoglobina': {
|
|
'high': 'Policitemia. Evaluar posibles causas: deshidratación, tabaquismo, cardiopatía o policitemia vera.',
|
|
'low': 'Anemia severa. Investigar origen: deficiencia de hierro, pérdida sanguínea, hemólisis o enfermedad crónica.'
|
|
},
|
|
'hematocrito': {
|
|
'high': 'Hemoconcentración. Correlacionar con hemoglobina. Descartar deshidratación o policitemia.',
|
|
'low': 'Valor compatible con anemia. Evaluar junto con hemoglobina e índices eritrocitarios.'
|
|
},
|
|
'leucocitos': {
|
|
'high': 'Leucocitosis marcada. Descartar proceso infeccioso, inflamatorio o hematológico.',
|
|
'low': 'Leucopenia severa. Riesgo de infecciones. Evaluar causas: viral, medicamentosa o hematológica.'
|
|
},
|
|
'plaquetas': {
|
|
'high': 'Trombocitosis. Riesgo trombótico. Descartar causa primaria vs reactiva.',
|
|
'low': 'Trombocitopenia severa. Riesgo de sangrado. Evaluar PTI, hiperesplenismo o supresión medular.'
|
|
},
|
|
'neutrofilos': {
|
|
'high': 'Neutrofilia. Sugiere infección bacteriana o proceso inflamatorio agudo.',
|
|
'low': 'Neutropenia. Alto riesgo de infección bacteriana. Evaluar urgentemente.'
|
|
},
|
|
'linfocitos': {
|
|
'high': 'Linfocitosis. Considerar infección viral o proceso linfoproliferativo.',
|
|
'low': 'Linfopenia. Evaluar inmunodeficiencia o efecto de corticoides.'
|
|
},
|
|
'colesterol total': {
|
|
'high': 'Hipercolesterolemia. Riesgo cardiovascular elevado. Iniciar medidas dietéticas y evaluar tratamiento con estatinas.',
|
|
'low': 'Hipocolesterolemia. Evaluar malnutrición, hipertiroidismo o enfermedad hepática.'
|
|
},
|
|
'trigliceridos': {
|
|
'high': 'Hipertrigliceridemia severa. Riesgo de pancreatitis aguda. Considerar tratamiento farmacológico urgente.',
|
|
'low': 'Valor bajo, generalmente sin significado patológico.'
|
|
},
|
|
'hdl': {
|
|
'high': 'HDL elevado, factor protector cardiovascular.',
|
|
'low': 'HDL bajo. Factor de riesgo cardiovascular. Recomendar ejercicio y cambios en estilo de vida.'
|
|
},
|
|
'ldl': {
|
|
'high': 'LDL elevado. Alto riesgo aterogénico. Evaluar inicio de estatinas según riesgo global.',
|
|
'low': 'LDL bajo, generalmente favorable.'
|
|
},
|
|
'glucosa en sangre': {
|
|
'high': 'Hiperglucemia. Si en ayunas >126 mg/dL sugiere diabetes. Confirmar con segunda muestra.',
|
|
'low': 'Hipoglucemia. Evaluar síntomas y causas. Riesgo neurológico si <50 mg/dL.'
|
|
}
|
|
}
|
|
|
|
# Solo autocompletar si no hay notas previas o están vacías
|
|
if not self.notes or self.notes.strip() == '':
|
|
note = self._get_critical_note(CRITICAL_NOTES)
|
|
if note:
|
|
self.notes = note
|
|
|
|
def _get_critical_note(self, critical_notes_dict):
|
|
"""Obtiene la nota apropiada para un resultado crítico."""
|
|
if not self.parameter_id or not self.parameter_name:
|
|
return False
|
|
|
|
param_lower = self.parameter_name.lower()
|
|
|
|
# Buscar el parámetro en el diccionario
|
|
for key in critical_notes_dict:
|
|
if key in param_lower:
|
|
# Obtener rangos del rango aplicable si existe
|
|
normal_min = normal_max = None
|
|
if self.applicable_range_id:
|
|
normal_min = self.applicable_range_id.normal_min
|
|
normal_max = self.applicable_range_id.normal_max
|
|
|
|
if normal_max and self.value_numeric > normal_max:
|
|
return critical_notes_dict[key].get('high', f'Valor crítico alto para {self.parameter_name}. Requiere evaluación médica inmediata.')
|
|
elif normal_min and self.value_numeric < normal_min:
|
|
return critical_notes_dict[key].get('low', f'Valor crítico bajo para {self.parameter_name}. Requiere evaluación médica inmediata.')
|
|
|
|
# Nota genérica si no se encuentra el parámetro
|
|
if self.applicable_range_id:
|
|
normal_min = self.applicable_range_id.normal_min
|
|
normal_max = self.applicable_range_id.normal_max
|
|
|
|
if normal_max and self.value_numeric > normal_max:
|
|
return f'Valor significativamente elevado. Rango normal: {normal_min}-{normal_max}. Se recomienda evaluación médica.'
|
|
elif normal_min and self.value_numeric < normal_min:
|
|
return f'Valor significativamente bajo. Rango normal: {normal_min}-{normal_max}. Se recomienda evaluación médica.'
|
|
|
|
return 'Valor fuera de rango normal. Requiere interpretación clínica.'
|
|
|
|
def _validate_and_autocomplete_selection(self, value):
|
|
"""Valida y autocompleta el valor de selección.
|
|
|
|
Esta función es llamada antes de guardar para asegurar que el valor
|
|
sea válido y esté completo.
|
|
"""
|
|
if not value or not self.parameter_id or self.parameter_value_type != 'selection':
|
|
return value
|
|
|
|
options = self.parameter_id.get_selection_list()
|
|
if not options:
|
|
return value
|
|
|
|
# Convertir a mayúsculas para comparación
|
|
value_upper = value.upper().strip()
|
|
|
|
# Buscar coincidencias exactas primero
|
|
for option in options:
|
|
if option.upper() == value_upper:
|
|
return option
|
|
|
|
# Buscar coincidencias que empiecen con el valor
|
|
matches = []
|
|
for option in options:
|
|
if option.upper().startswith(value_upper):
|
|
matches.append(option)
|
|
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
elif len(matches) > 1:
|
|
# Si hay múltiples coincidencias, intentar ser más específico
|
|
# Preferir la coincidencia más corta
|
|
shortest = min(matches, key=len)
|
|
return shortest
|
|
|
|
# Si no hay coincidencias por inicio, buscar contenido
|
|
for option in options:
|
|
if value_upper in option.upper():
|
|
matches.append(option)
|
|
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
elif len(matches) > 1:
|
|
# Retornar la primera coincidencia
|
|
return matches[0]
|
|
|
|
# Si no hay ninguna coincidencia, retornar el valor original
|
|
# La validación en @api.constrains se encargará de rechazarlo
|
|
return value
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
"""Override create para autocompletar valores de selección."""
|
|
if 'value_selection' in vals and vals.get('value_selection'):
|
|
# Necesitamos el parameter_id para validar
|
|
if 'parameter_id' in vals:
|
|
parameter = self.env['lims.analysis.parameter'].browse(vals['parameter_id'])
|
|
if parameter.value_type == 'selection':
|
|
# Crear un registro temporal para usar el método
|
|
temp_record = self.new({'parameter_id': parameter.id, 'parameter_value_type': 'selection'})
|
|
vals['value_selection'] = temp_record._validate_and_autocomplete_selection(vals['value_selection'])
|
|
return super(LimsResult, self).create(vals)
|
|
|
|
def write(self, vals):
|
|
"""Override write para autocompletar valores de selección."""
|
|
if 'value_selection' in vals and vals.get('value_selection'):
|
|
for record in self:
|
|
if record.parameter_value_type == 'selection':
|
|
vals['value_selection'] = record._validate_and_autocomplete_selection(vals['value_selection'])
|
|
break # Solo necesitamos procesar una vez
|
|
return super(LimsResult, self).write(vals) |