
- Agregar QWeb template para generar PDF profesional con: - Encabezado con datos del laboratorio y logo - Información completa del paciente y orden - Tabla de resultados con indicadores visuales para valores fuera de rango - Sección de observaciones y notas - Información del validador y fecha de validación - Agregar campo computado reference_text en parameter_range para mostrar rangos formateados - Agregar botón "Imprimir Informe de Resultados" en vista de órdenes (solo visible cuando hay pruebas validadas) - Agregar campo lab_notes en sale.order para observaciones generales - Reorganizar vista de lims.test con pestañas para mejor UX - Corregir manejo de employee_ids en el reporte para casos donde no existe el módulo HR - Incluir scripts de prueba para generar datos de demostración El informe resalta valores críticos y fuera de rango con colores distintivos, facilitando la interpretación rápida de los resultados por parte del médico. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
395 lines
16 KiB
Python
395 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import UserError
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
class SaleOrder(models.Model):
|
|
_inherit = 'sale.order'
|
|
|
|
is_lab_request = fields.Boolean(
|
|
string="Es Orden de Laboratorio",
|
|
default=False,
|
|
copy=False,
|
|
help="Campo técnico para identificar si la orden de venta es una solicitud de laboratorio."
|
|
)
|
|
|
|
doctor_id = fields.Many2one(
|
|
'res.partner',
|
|
string="Médico Referente",
|
|
domain="[('is_doctor', '=', True)]",
|
|
help="El médico que refirió al paciente para esta solicitud de laboratorio."
|
|
)
|
|
|
|
generated_sample_ids = fields.Many2many(
|
|
'stock.lot',
|
|
'sale_order_stock_lot_rel',
|
|
'order_id',
|
|
'lot_id',
|
|
string='Muestras Generadas',
|
|
domain="[('is_lab_sample', '=', True)]",
|
|
readonly=True,
|
|
help="Muestras de laboratorio generadas automáticamente cuando se confirmó esta orden"
|
|
)
|
|
|
|
all_sample_ids = fields.Many2many(
|
|
'stock.lot',
|
|
string='Todas las Muestras (inc. Re-muestras)',
|
|
compute='_compute_all_samples',
|
|
help="Todas las muestras relacionadas con esta orden, incluyendo re-muestras"
|
|
)
|
|
|
|
@api.depends('generated_sample_ids', 'generated_sample_ids.child_sample_ids')
|
|
def _compute_all_samples(self):
|
|
"""Compute all samples including resamples"""
|
|
for order in self:
|
|
all_samples = order.generated_sample_ids
|
|
# Add all resamples recursively
|
|
resamples = self.env['stock.lot']
|
|
for sample in order.generated_sample_ids:
|
|
resamples |= self._get_all_resamples(sample)
|
|
order.all_sample_ids = all_samples | resamples
|
|
|
|
def _get_all_resamples(self, sample):
|
|
"""Recursively get all resamples of a sample"""
|
|
resamples = sample.child_sample_ids
|
|
for resample in sample.child_sample_ids:
|
|
resamples |= self._get_all_resamples(resample)
|
|
return resamples
|
|
|
|
def action_confirm(self):
|
|
"""Override to generate laboratory samples and tests automatically"""
|
|
res = super(SaleOrder, self).action_confirm()
|
|
|
|
# Generate samples and tests only for laboratory requests
|
|
for order in self.filtered('is_lab_request'):
|
|
try:
|
|
order._generate_lab_samples()
|
|
order._generate_lab_tests()
|
|
except Exception as e:
|
|
_logger.error(f"Error generating samples/tests for order {order.name}: {str(e)}")
|
|
# Continue with order confirmation even if generation fails
|
|
# But notify the user
|
|
order.message_post(
|
|
body=_("Error al generar muestras/pruebas automáticamente: %s. "
|
|
"Por favor, genere las muestras y pruebas manualmente.") % str(e),
|
|
message_type='notification'
|
|
)
|
|
|
|
return res
|
|
|
|
def _generate_lab_samples(self):
|
|
"""Generate laboratory samples based on the analyses in the order"""
|
|
self.ensure_one()
|
|
_logger.info(f"Generating laboratory samples for order {self.name}")
|
|
|
|
# Group analyses by sample type
|
|
sample_groups = self._group_analyses_by_sample_type()
|
|
|
|
if not sample_groups:
|
|
_logger.warning(f"No analyses with sample types found in order {self.name}")
|
|
return
|
|
|
|
# Create samples for each group
|
|
created_samples = self.env['stock.lot']
|
|
|
|
for sample_type_id, group_data in sample_groups.items():
|
|
sample = self._create_sample_for_group(group_data)
|
|
if sample:
|
|
created_samples |= sample
|
|
|
|
# Link created samples to the order
|
|
if created_samples:
|
|
self.generated_sample_ids = [(6, 0, created_samples.ids)]
|
|
_logger.info(f"Created {len(created_samples)} samples for order {self.name}")
|
|
|
|
# Post message with created samples
|
|
sample_list = "<ul>"
|
|
for sample in created_samples:
|
|
sample_list += f"<li>{sample.name} - {sample.sample_type_product_id.name}</li>"
|
|
sample_list += "</ul>"
|
|
|
|
self.message_post(
|
|
body=_("Muestras generadas automáticamente: %s") % sample_list,
|
|
message_type='notification'
|
|
)
|
|
|
|
def _group_analyses_by_sample_type(self):
|
|
"""Group order lines by required sample type"""
|
|
groups = {}
|
|
|
|
for line in self.order_line:
|
|
product = line.product_id
|
|
|
|
# Skip non-analysis products
|
|
if not product.is_analysis:
|
|
continue
|
|
|
|
# Check if analysis has a required sample type
|
|
if not product.required_sample_type_id:
|
|
_logger.warning(
|
|
f"Analysis {product.name} has no required sample type defined"
|
|
)
|
|
# Post warning message
|
|
self.message_post(
|
|
body=_("Advertencia: El análisis '%s' no tiene tipo de muestra definido") % product.name,
|
|
message_type='notification'
|
|
)
|
|
continue
|
|
|
|
sample_type = product.required_sample_type_id
|
|
|
|
# Initialize group if not exists
|
|
if sample_type.id not in groups:
|
|
groups[sample_type.id] = {
|
|
'sample_type': sample_type,
|
|
'lines': [],
|
|
'total_volume': 0.0,
|
|
'analyses': []
|
|
}
|
|
|
|
# Add line to group
|
|
groups[sample_type.id]['lines'].append(line)
|
|
groups[sample_type.id]['analyses'].append(product.name)
|
|
groups[sample_type.id]['total_volume'] += (product.sample_volume_ml or 0.0) * line.product_uom_qty
|
|
|
|
return groups
|
|
|
|
def _create_sample_for_group(self, group_data):
|
|
"""Create a single sample for a group of analyses"""
|
|
try:
|
|
sample_type = group_data['sample_type']
|
|
|
|
# Generate a unique lot name using sequence
|
|
sequence = self.env['ir.sequence'].next_by_code('stock.lot.serial')
|
|
if not sequence:
|
|
# Fallback to timestamp-based name if no sequence exists
|
|
import time
|
|
sequence = 'LAB-' + str(int(time.time()))[-8:]
|
|
|
|
# Prepare sample values
|
|
vals = {
|
|
'name': sequence, # Add the lot name
|
|
'product_id': sample_type.product_variant_id.id,
|
|
'patient_id': self.partner_id.id,
|
|
'doctor_id': self.doctor_id.id if self.doctor_id else False,
|
|
'origin': self.name,
|
|
'sample_type_product_id': sample_type.id,
|
|
'volume_ml': group_data['total_volume'],
|
|
'is_lab_sample': True,
|
|
'state': 'pending_collection',
|
|
'analysis_names': ', '.join(group_data['analyses'][:3]) +
|
|
('...' if len(group_data['analyses']) > 3 else '')
|
|
}
|
|
|
|
# Create the sample
|
|
sample = self.env['stock.lot'].create(vals)
|
|
|
|
_logger.info(
|
|
f"Created sample {sample.name} for {len(group_data['analyses'])} analyses"
|
|
)
|
|
|
|
return sample
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error creating sample: {str(e)}")
|
|
raise UserError(
|
|
_("Error al crear muestra para %s: %s") % (sample_type.name, str(e))
|
|
)
|
|
|
|
def _generate_lab_tests(self):
|
|
"""Generate laboratory tests for analysis order lines"""
|
|
self.ensure_one()
|
|
_logger.info(f"Generating laboratory tests for order {self.name}")
|
|
|
|
# Get the test model
|
|
TestModel = self.env['lims.test']
|
|
created_tests = TestModel.browse()
|
|
|
|
# Create a test for each analysis line
|
|
for line in self.order_line:
|
|
if not line.product_id.is_analysis:
|
|
continue
|
|
|
|
# Find appropriate sample for this analysis
|
|
sample = self._find_sample_for_analysis(line.product_id)
|
|
|
|
if not sample:
|
|
_logger.warning(
|
|
f"No sample found for analysis {line.product_id.name} in order {self.name}"
|
|
)
|
|
self.message_post(
|
|
body=_("Advertencia: No se encontró muestra para el análisis '%s'") % line.product_id.name,
|
|
message_type='notification'
|
|
)
|
|
continue
|
|
|
|
# Create the test
|
|
try:
|
|
test = TestModel.create({
|
|
'sale_order_line_id': line.id,
|
|
'sample_id': sample.id,
|
|
})
|
|
created_tests |= test
|
|
_logger.info(f"Created test {test.name} for analysis {line.product_id.name}")
|
|
except Exception as e:
|
|
_logger.error(f"Error creating test for {line.product_id.name}: {str(e)}")
|
|
self.message_post(
|
|
body=_("Error al crear prueba para '%s': %s") % (line.product_id.name, str(e)),
|
|
message_type='notification'
|
|
)
|
|
|
|
# Post message with created tests
|
|
if created_tests:
|
|
test_list = "<ul>"
|
|
for test in created_tests:
|
|
test_list += f"<li>{test.name} - {test.product_id.name}</li>"
|
|
test_list += "</ul>"
|
|
|
|
self.message_post(
|
|
body=_("Pruebas generadas automáticamente: %s") % test_list,
|
|
message_type='notification'
|
|
)
|
|
_logger.info(f"Created {len(created_tests)} tests for order {self.name}")
|
|
|
|
def _find_sample_for_analysis(self, product):
|
|
"""Find the appropriate sample for an analysis product"""
|
|
# Check if the analysis has a required sample type
|
|
if not product.required_sample_type_id:
|
|
return False
|
|
|
|
# Find a generated sample with matching sample type
|
|
for sample in self.generated_sample_ids:
|
|
if sample.sample_type_product_id.id == product.required_sample_type_id.id:
|
|
return sample
|
|
|
|
return False
|
|
|
|
def action_cancel(self):
|
|
"""Override para cancelar automáticamente muestras y pruebas asociadas cuando se cancela una orden de laboratorio"""
|
|
# Primero llamar al método padre
|
|
res = super(SaleOrder, self).action_cancel()
|
|
|
|
# Si es una orden de laboratorio, cancelar muestras y pruebas asociadas
|
|
if self.is_lab_request:
|
|
# Cancelar muestras que estén en estados cancelables
|
|
cancelable_sample_states = ['pending_collection', 'collected', 'received', 'in_process']
|
|
samples_to_cancel = self.generated_sample_ids.filtered(
|
|
lambda s: s.state in cancelable_sample_states
|
|
)
|
|
|
|
if samples_to_cancel:
|
|
# Cancelar las muestras
|
|
samples_to_cancel.action_cancel()
|
|
|
|
# Registrar en el chatter de cada muestra
|
|
for sample in samples_to_cancel:
|
|
sample.message_post(
|
|
body=_("Muestra cancelada automáticamente debido a la cancelación de la orden %s") % self.name,
|
|
message_type='notification'
|
|
)
|
|
|
|
# Buscar y cancelar pruebas asociadas a estas muestras
|
|
tests_to_cancel = self.env['lims.test'].search([
|
|
('sample_id', 'in', samples_to_cancel.ids),
|
|
('state', 'not in', ['validated', 'cancelled'])
|
|
])
|
|
|
|
if tests_to_cancel:
|
|
for test in tests_to_cancel:
|
|
test.action_cancel()
|
|
test.message_post(
|
|
body=_("Prueba cancelada automáticamente debido a la cancelación de la orden %s") % self.name,
|
|
message_type='notification'
|
|
)
|
|
|
|
# Registrar en el chatter de la orden
|
|
message = _("Se cancelaron automáticamente:<br/>")
|
|
message += _("- %d muestras<br/>") % len(samples_to_cancel)
|
|
if tests_to_cancel:
|
|
message += _("- %d pruebas de laboratorio") % len(tests_to_cancel)
|
|
|
|
self.message_post(
|
|
body=message,
|
|
message_type='notification'
|
|
)
|
|
|
|
_logger.info(f"Cancelled {len(samples_to_cancel)} samples and {len(tests_to_cancel)} tests for order {self.name}")
|
|
|
|
return res
|
|
|
|
def action_print_sample_labels(self):
|
|
"""Imprimir etiquetas de todas las muestras activas (incluyendo re-muestras)"""
|
|
self.ensure_one()
|
|
|
|
# Obtener todas las muestras activas (no rechazadas ni canceladas)
|
|
active_samples = self.all_sample_ids.filtered(
|
|
lambda s: s.state not in ['rejected', 'cancelled', 'disposed']
|
|
)
|
|
|
|
if not active_samples:
|
|
raise UserError(_('No hay muestras activas para imprimir. Todas las muestras están rechazadas, canceladas o desechadas.'))
|
|
|
|
# Asegurar que todas las muestras tengan código de barras
|
|
active_samples._ensure_barcode()
|
|
|
|
# Obtener el reporte
|
|
report = self.env.ref('lims_management.action_report_sample_label')
|
|
|
|
# Retornar la acción de imprimir el reporte para las muestras activas
|
|
return report.report_action(active_samples)
|
|
|
|
# Fields for lab results report
|
|
can_print_results = fields.Boolean(
|
|
string="Puede Imprimir Resultados",
|
|
compute='_compute_can_print_results',
|
|
help="Indica si todas las pruebas están validadas y se puede imprimir el informe"
|
|
)
|
|
|
|
lab_test_ids = fields.One2many(
|
|
'lims.test',
|
|
'sale_order_id',
|
|
string="Pruebas de Laboratorio",
|
|
readonly=True,
|
|
help="Todas las pruebas de laboratorio asociadas a esta orden"
|
|
)
|
|
|
|
referring_doctor_id = fields.Many2one(
|
|
'res.partner',
|
|
string="Médico Solicitante",
|
|
related='doctor_id',
|
|
readonly=True,
|
|
help="Médico que solicitó los análisis"
|
|
)
|
|
|
|
lab_notes = fields.Text(
|
|
string="Observaciones del Laboratorio",
|
|
help="Observaciones generales sobre la orden o los resultados"
|
|
)
|
|
|
|
@api.depends('lab_test_ids.state')
|
|
def _compute_can_print_results(self):
|
|
"""Compute if results can be printed (all tests validated)"""
|
|
for order in self:
|
|
tests = order.lab_test_ids
|
|
order.can_print_results = (
|
|
tests and
|
|
all(test.state == 'validated' for test in tests)
|
|
)
|
|
|
|
def action_print_lab_results(self):
|
|
"""Generate and print lab results report"""
|
|
self.ensure_one()
|
|
|
|
# Verify all tests are validated
|
|
if not self.can_print_results:
|
|
raise UserError(_("No se puede imprimir el informe: hay pruebas sin validar"))
|
|
|
|
# Ensure this is a lab request
|
|
if not self.is_lab_request:
|
|
raise UserError(_("Esta no es una orden de laboratorio"))
|
|
|
|
# Generate the report
|
|
return self.env.ref('lims_management.action_report_lab_results').report_action(self)
|