# -*- 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.""" # Skip validation if we're in initialization context if self.env.context.get('skip_value_validation'): return 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 existe y no está en borrador if not has_value and record.parameter_id and record.test_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)