Merge pull request 'feat(#54): Cancelar automáticamente muestras y pruebas al cancelar orden' (#55) from feature/54-auto-cancel-samples into dev

This commit is contained in:
luis_portillo 2025-07-16 00:59:08 +00:00
commit 4d3206f3a1
13 changed files with 599 additions and 2 deletions

24
issue_body.txt Normal file
View File

@ -0,0 +1,24 @@
## Descripción
Actualmente, cuando se cancela una orden de laboratorio, las muestras asociadas permanecen activas y no se descartan automáticamente. Esto puede causar confusión ya que quedan muestras "huérfanas" en el sistema que ya no tienen una orden válida.
## Comportamiento esperado
Cuando se cancela una orden de laboratorio:
1. Todas las muestras generadas asociadas a esa orden deben cambiar automáticamente su estado a "cancelled"
2. Si hay pruebas (lims.test) asociadas a esas muestras, también deben cancelarse
3. Se debe registrar en el chatter de la muestra que fue cancelada debido a la cancelación de la orden
## Criterios de aceptación
- [ ] Al cancelar una orden de laboratorio, todas sus muestras asociadas se marcan como canceladas
- [ ] Las pruebas asociadas a las muestras también se cancelan
- [ ] Se registra un mensaje en el chatter de cada muestra indicando la razón de cancelación
- [ ] Si una muestra ya estaba cancelada o completada, no se modifica
- [ ] La acción es reversible: si se vuelve a poner la orden en borrador, las muestras NO deben reactivarse automáticamente
## Notas técnicas
- El método a modificar es `action_cancel()` en el modelo `sale.order`
- Verificar el campo `generated_sample_ids` para obtener las muestras asociadas
- Solo cancelar muestras que estén en estados: 'pending_collection', 'collected', 'in_analysis'

View File

@ -240,3 +240,56 @@ class SaleOrder(models.Model):
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

View File

@ -80,7 +80,8 @@ class StockLot(models.Model):
('in_process', 'En Proceso'),
('analyzed', 'Analizada'),
('stored', 'Almacenada'),
('disposed', 'Desechada')
('disposed', 'Desechada'),
('cancelled', 'Cancelada')
], string='Estado', default='collected', tracking=True)
def action_collect(self):
@ -107,6 +108,10 @@ class StockLot(models.Model):
"""Dispose of the sample"""
self.write({'state': 'disposed'})
def action_cancel(self):
"""Cancel the sample"""
self.write({'state': 'cancelled'})
@api.onchange('sample_type_product_id')
def _onchange_sample_type_product_id(self):
"""Synchronize container_type when sample_type_product_id changes"""

View File

@ -2,4 +2,5 @@
from . import test_analysis_parameter
from . import test_parameter_range
from . import test_result_parameter_integration
from . import test_auto_result_generation
from . import test_auto_result_generation
from . import test_order_cancel_cascade

View File

@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""
Test para verificar la cancelación en cascada de muestras y pruebas
cuando se cancela una orden de laboratorio
"""
from odoo.tests import TransactionCase
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class TestOrderCancelCascade(TransactionCase):
"""Test de cancelación en cascada de órdenes de laboratorio"""
def setUp(self):
super().setUp()
# Obtener modelos
self.Partner = self.env['res.partner']
self.Product = self.env['product.product']
self.SaleOrder = self.env['sale.order']
self.StockLot = self.env['stock.lot']
self.LimsTest = self.env['lims.test']
# Crear datos de prueba
self.patient = self.Partner.create({
'name': 'Test Patient Cancel',
'is_patient': True,
'birthdate_date': '1990-01-01',
'gender': 'male'
})
self.doctor = self.Partner.create({
'name': 'Test Doctor Cancel',
'is_doctor': True
})
# Crear tipo de muestra
self.sample_type = self.env['product.template'].create({
'name': 'Tubo EDTA Test',
'is_sample_type': True,
'type': 'service',
'categ_id': self.env.ref('product.product_category_all').id
})
# Crear análisis
self.analysis = self.env['product.template'].create({
'name': 'Hemograma Test Cancel',
'is_analysis': True,
'type': 'service',
'required_sample_type_id': self.sample_type.id,
'categ_id': self.env.ref('product.product_category_all').id
})
# Crear parámetro para el análisis
self.parameter = self.env['lims.analysis.parameter'].create({
'name': 'Hemoglobina Test',
'code': 'HGB_TEST',
'value_type': 'numeric',
'unit': 'g/dL'
})
# Configurar parámetro en el análisis
self.env['product.template.parameter'].create({
'product_tmpl_id': self.analysis.id,
'parameter_id': self.parameter.id,
'sequence': 10
})
def test_01_cancel_order_cancels_samples(self):
"""Test que al cancelar una orden se cancelan las muestras asociadas"""
# Crear orden de laboratorio
order = self.SaleOrder.create({
'partner_id': self.patient.id,
'doctor_id': self.doctor.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': self.analysis.product_variant_id.id,
'product_uom_qty': 1.0
})]
})
# Confirmar la orden (debe generar muestras)
order.action_confirm()
# Verificar que se generaron muestras
self.assertTrue(order.generated_sample_ids, "No se generaron muestras")
samples = order.generated_sample_ids
# Verificar estado inicial de las muestras
for sample in samples:
self.assertIn(sample.state, ['pending_collection', 'collected'],
f"Estado inicial incorrecto: {sample.state}")
# Cancelar la orden
order.action_cancel()
# Verificar que las muestras fueron canceladas
for sample in samples:
self.assertEqual(sample.state, 'cancelled',
f"Muestra no fue cancelada: {sample.state}")
def test_02_cancel_order_cancels_tests(self):
"""Test que al cancelar una orden se cancelan las pruebas asociadas"""
# Crear orden de laboratorio
order = self.SaleOrder.create({
'partner_id': self.patient.id,
'doctor_id': self.doctor.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': self.analysis.product_variant_id.id,
'product_uom_qty': 1.0
})]
})
# Confirmar la orden
order.action_confirm()
# Obtener las pruebas generadas
tests = self.LimsTest.search([
('sale_order_line_id.order_id', '=', order.id)
])
self.assertTrue(tests, "No se generaron pruebas")
# Verificar estado inicial
for test in tests:
self.assertEqual(test.state, 'draft',
f"Estado inicial incorrecto: {test.state}")
# Iniciar proceso en una prueba
if tests:
tests[0].write({'sample_id': order.generated_sample_ids[0].id})
tests[0].action_start_process()
self.assertEqual(tests[0].state, 'in_process')
# Cancelar la orden
order.action_cancel()
# Verificar que las pruebas fueron canceladas
for test in tests:
self.assertEqual(test.state, 'cancelled',
f"Prueba no fue cancelada: {test.state}")
def test_03_dont_cancel_completed_samples(self):
"""Test que no se cancelan muestras en estados finales"""
# Crear orden
order = self.SaleOrder.create({
'partner_id': self.patient.id,
'doctor_id': self.doctor.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': self.analysis.product_variant_id.id,
'product_uom_qty': 1.0
})]
})
# Confirmar
order.action_confirm()
# Marcar una muestra como analizada
sample = order.generated_sample_ids[0]
sample.write({'state': 'analyzed'})
# Cancelar la orden
order.action_cancel()
# Verificar que la muestra analizada no fue cancelada
self.assertEqual(sample.state, 'analyzed',
"Muestra analizada fue cancelada incorrectamente")
def test_04_dont_cancel_validated_tests(self):
"""Test que no se cancelan pruebas validadas"""
# Crear orden
order = self.SaleOrder.create({
'partner_id': self.patient.id,
'doctor_id': self.doctor.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': self.analysis.product_variant_id.id,
'product_uom_qty': 1.0
})]
})
# Confirmar
order.action_confirm()
# Obtener prueba y marcarla como validada
test = self.LimsTest.search([
('sale_order_line_id.order_id', '=', order.id)
], limit=1)
if test:
test.write({
'state': 'validated',
'sample_id': order.generated_sample_ids[0].id
})
# Cancelar la orden
order.action_cancel()
# Verificar que la prueba validada no fue cancelada
self.assertEqual(test.state, 'validated',
"Prueba validada fue cancelada incorrectamente")
def test_05_chatter_messages_created(self):
"""Test que se crean mensajes en el chatter"""
# Crear orden
order = self.SaleOrder.create({
'partner_id': self.patient.id,
'doctor_id': self.doctor.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': self.analysis.product_variant_id.id,
'product_uom_qty': 1.0
})]
})
# Confirmar
order.action_confirm()
# Obtener conteo inicial de mensajes
initial_order_messages = len(order.message_ids)
sample = order.generated_sample_ids[0]
initial_sample_messages = len(sample.message_ids)
# Cancelar
order.action_cancel()
# Verificar que se agregaron mensajes
self.assertGreater(len(order.message_ids), initial_order_messages,
"No se agregó mensaje en la orden")
self.assertGreater(len(sample.message_ids), initial_sample_messages,
"No se agregó mensaje en la muestra")
# Verificar contenido del mensaje
last_order_msg = order.message_ids[0].body
self.assertIn("cancelaron automáticamente", last_order_msg,
"Mensaje de orden no contiene texto esperado")
def test_06_non_lab_order_not_affected(self):
"""Test que órdenes normales no son afectadas"""
# Crear orden normal (no de laboratorio)
order = self.SaleOrder.create({
'partner_id': self.patient.id,
'is_lab_request': False, # NO es orden de laboratorio
'order_line': [(0, 0, {
'product_id': self.analysis.product_variant_id.id,
'product_uom_qty': 1.0
})]
})
# Confirmar
order.action_confirm()
# No deberían generarse muestras
self.assertFalse(order.generated_sample_ids,
"Se generaron muestras en orden normal")
# Cancelar - no debería causar error
order.action_cancel()
self.assertEqual(order.state, 'cancel')

53
test/run_cancel_tests.py Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Ejecutar tests de cancelación en cascada
"""
import odoo
import sys
def run_tests(cr):
"""Ejecutar los tests"""
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
# Importar y ejecutar tests
from odoo.addons.lims_management.tests.test_order_cancel_cascade import TestOrderCancelCascade
suite = odoo.tests.loader.make_suite(TestOrderCancelCascade)
result = odoo.tests.runner.run(suite)
print(f"\n📊 Resultados de los tests:")
print(f" Tests ejecutados: {result.testsRun}")
print(f" Errores: {len(result.errors)}")
print(f" Fallos: {len(result.failures)}")
if result.errors:
print("\n❌ Errores:")
for test, error in result.errors:
print(f" - {test}: {error}")
if result.failures:
print("\n❌ Fallos:")
for test, failure in result.failures:
print(f" - {test}: {failure}")
if result.wasSuccessful():
print("\n✅ Todos los tests pasaron exitosamente!")
else:
print("\n❌ Algunos tests fallaron")
return result.wasSuccessful()
if __name__ == '__main__':
db_name = 'lims_demo'
try:
registry = odoo.modules.registry.Registry(db_name)
with registry.cursor() as cr:
success = run_tests(cr)
sys.exit(0 if success else 1)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

149
test/test_cancel_cascade.py Normal file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script para probar la funcionalidad de cancelación en cascada
"""
import odoo
import traceback
from datetime import datetime
def test_cancel_cascade(cr):
"""Probar la cancelación en cascada de órdenes"""
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
print("🧪 Probando cancelación en cascada de órdenes de laboratorio\n")
try:
# Buscar un paciente y doctor de prueba
patient = env['res.partner'].search([('is_patient', '=', True)], limit=1)
doctor = env['res.partner'].search([('is_doctor', '=', True)], limit=1)
if not patient or not doctor:
print("❌ No se encontraron pacientes o doctores de prueba")
return
# Buscar un análisis
analysis = env['product.product'].search([
('is_analysis', '=', True),
('required_sample_type_id', '!=', False)
], limit=1)
if not analysis:
print("❌ No se encontró un análisis con tipo de muestra requerido")
return
print(f"📋 Creando orden de laboratorio de prueba...")
print(f" Paciente: {patient.name}")
print(f" Doctor: {doctor.name}")
print(f" Análisis: {analysis.name}")
# Crear orden de laboratorio
order = env['sale.order'].create({
'partner_id': patient.id,
'doctor_id': doctor.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': analysis.id,
'product_uom_qty': 1.0
})]
})
print(f"\n✓ Orden creada: {order.name}")
print(f" Estado inicial: {order.state}")
# Confirmar la orden
print("\n🔄 Confirmando orden...")
order.action_confirm()
print(f"✓ Orden confirmada")
print(f" Estado: {order.state}")
print(f" Muestras generadas: {len(order.generated_sample_ids)}")
# Mostrar muestras generadas
if order.generated_sample_ids:
print("\n📦 Muestras generadas:")
for sample in order.generated_sample_ids:
print(f" - {sample.name}: {sample.sample_type_product_id.name} (Estado: {sample.state})")
# Buscar pruebas generadas
tests = env['lims.test'].search([
('sale_order_line_id.order_id', '=', order.id)
])
print(f"\n🔬 Pruebas generadas: {len(tests)}")
if tests:
for test in tests:
print(f" - {test.name}: {test.product_id.name} (Estado: {test.state})")
# Iniciar proceso en una prueba para hacerlo más realista
if tests and order.generated_sample_ids:
print("\n🔄 Iniciando proceso en primera prueba...")
test = tests[0]
test.sample_id = order.generated_sample_ids[0]
test.action_start_process()
print(f" ✓ Prueba {test.name} en proceso")
# CANCELAR LA ORDEN
print("\n❌ Cancelando la orden de laboratorio...")
order.action_cancel()
print(f"\n✓ Orden cancelada")
print(f" Estado de la orden: {order.state}")
# Verificar estado de las muestras
print("\n📦 Estado final de las muestras:")
for sample in order.generated_sample_ids:
print(f" - {sample.name}: {sample.state}")
# Verificar si hay mensaje en el chatter
last_msg = sample.message_ids[0] if sample.message_ids else None
if last_msg and "cancelada automáticamente" in last_msg.body:
print(f" ✓ Mensaje de cancelación registrado")
# Verificar estado de las pruebas
print("\n🔬 Estado final de las pruebas:")
for test in tests:
test_updated = env['lims.test'].browse(test.id)
print(f" - {test_updated.name}: {test_updated.state}")
# Verificar mensaje
last_msg = test_updated.message_ids[0] if test_updated.message_ids else None
if last_msg and "cancelada automáticamente" in last_msg.body:
print(f" ✓ Mensaje de cancelación registrado")
# Verificar mensaje en la orden
print("\n📝 Mensajes en la orden:")
for msg in order.message_ids[:3]: # Últimos 3 mensajes
if "cancelaron automáticamente" in msg.body:
print(f" ✓ Mensaje de resumen de cancelación encontrado")
# Extraer números del mensaje
import re
samples_match = re.search(r'(\d+) muestras', msg.body)
tests_match = re.search(r'(\d+) pruebas', msg.body)
if samples_match:
print(f" - Muestras canceladas: {samples_match.group(1)}")
if tests_match:
print(f" - Pruebas canceladas: {tests_match.group(1)}")
break
print("\n✅ Prueba completada exitosamente!")
# Hacer rollback para no dejar datos de prueba
cr.rollback()
print("\n⚠️ Cambios revertidos (rollback)")
except Exception as e:
print(f"\n❌ Error durante la prueba: {str(e)}")
traceback.print_exc()
cr.rollback()
if __name__ == '__main__':
db_name = 'lims_demo'
try:
registry = odoo.modules.registry.Registry(db_name)
with registry.cursor() as cr:
test_cancel_cascade(cr)
except Exception as e:
print(f"Error general: {e}")
traceback.print_exc()

View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Verificar estado de orden después de cancelar
"""
import odoo
def verify_order_state(cr):
"""Verificar que el estado de la orden cambia correctamente"""
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
# Buscar datos necesarios
patient = env['res.partner'].search([('is_patient', '=', True)], limit=1)
analysis = env['product.product'].search([('is_analysis', '=', True)], limit=1)
# Crear orden simple
order = env['sale.order'].create({
'partner_id': patient.id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': analysis.id,
'product_uom_qty': 1.0
})]
})
print(f"Orden creada: {order.name}")
print(f"Estado inicial: {order.state}")
# Confirmar
order.action_confirm()
print(f"Estado después de confirmar: {order.state}")
# Cancelar
order.action_cancel()
print(f"Estado después de cancelar: {order.state}")
# Verificar nuevamente
order_check = env['sale.order'].browse(order.id)
print(f"Estado verificado nuevamente: {order_check.state}")
if __name__ == '__main__':
db_name = 'lims_demo'
try:
registry = odoo.modules.registry.Registry(db_name)
with registry.cursor() as cr:
verify_order_state(cr)
except Exception as e:
print(f"Error: {e}")