clinical_laboratory/lims_management/models/sale_order.py
Luis Ernesto Portillo Zaldivar 39318f9073 feat(#54): Cancelar automáticamente muestras y pruebas al cancelar orden
- Agregar estado 'cancelled' a stock.lot para muestras
- Implementar método action_cancel() en stock.lot
- Override action_cancel() en sale.order para:
  * Cancelar muestras en estados: pending_collection, collected, received, in_process
  * Cancelar pruebas asociadas que no estén validadas
  * Registrar mensajes en el chatter de cada elemento cancelado
  * Mostrar resumen de elementos cancelados en la orden
- Agregar tests unitarios completos para verificar:
  * Cancelación correcta de muestras y pruebas
  * No cancelación de elementos en estados finales
  * Generación de mensajes en chatter
  * Órdenes normales no afectadas

La funcionalidad asegura que no queden muestras o pruebas "huérfanas"
cuando se cancela una orden de laboratorio.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 18:53:19 -06:00

296 lines
12 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"
)
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