diff --git a/issue_body.txt b/issue_body.txt new file mode 100644 index 0000000..7cf6864 --- /dev/null +++ b/issue_body.txt @@ -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' \ No newline at end of file diff --git a/lims_management/models/__pycache__/__init__.cpython-312.pyc b/lims_management/models/__pycache__/__init__.cpython-312.pyc index 4830c5f..9029664 100644 Binary files a/lims_management/models/__pycache__/__init__.cpython-312.pyc and b/lims_management/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/lims_management/models/__pycache__/partner.cpython-312.pyc b/lims_management/models/__pycache__/partner.cpython-312.pyc index 0ba6f10..0b833d5 100644 Binary files a/lims_management/models/__pycache__/partner.cpython-312.pyc and b/lims_management/models/__pycache__/partner.cpython-312.pyc differ diff --git a/lims_management/models/__pycache__/product.cpython-312.pyc b/lims_management/models/__pycache__/product.cpython-312.pyc index a8b31aa..2031240 100644 Binary files a/lims_management/models/__pycache__/product.cpython-312.pyc and b/lims_management/models/__pycache__/product.cpython-312.pyc differ diff --git a/lims_management/models/__pycache__/sale_order.cpython-312.pyc b/lims_management/models/__pycache__/sale_order.cpython-312.pyc index 31b1b70..9f92bfa 100644 Binary files a/lims_management/models/__pycache__/sale_order.cpython-312.pyc and b/lims_management/models/__pycache__/sale_order.cpython-312.pyc differ diff --git a/lims_management/models/__pycache__/stock_lot.cpython-312.pyc b/lims_management/models/__pycache__/stock_lot.cpython-312.pyc index 5556171..7c49021 100644 Binary files a/lims_management/models/__pycache__/stock_lot.cpython-312.pyc and b/lims_management/models/__pycache__/stock_lot.cpython-312.pyc differ diff --git a/lims_management/models/sale_order.py b/lims_management/models/sale_order.py index c4a10f3..697e824 100644 --- a/lims_management/models/sale_order.py +++ b/lims_management/models/sale_order.py @@ -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:
") + message += _("- %d muestras
") % 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 diff --git a/lims_management/models/stock_lot.py b/lims_management/models/stock_lot.py index 9ec953e..02d42e1 100644 --- a/lims_management/models/stock_lot.py +++ b/lims_management/models/stock_lot.py @@ -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""" diff --git a/lims_management/tests/__init__.py b/lims_management/tests/__init__.py index 0a66197..103baea 100644 --- a/lims_management/tests/__init__.py +++ b/lims_management/tests/__init__.py @@ -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 \ No newline at end of file +from . import test_auto_result_generation +from . import test_order_cancel_cascade \ No newline at end of file diff --git a/lims_management/tests/test_order_cancel_cascade.py b/lims_management/tests/test_order_cancel_cascade.py new file mode 100644 index 0000000..adaa118 --- /dev/null +++ b/lims_management/tests/test_order_cancel_cascade.py @@ -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') \ No newline at end of file diff --git a/test/run_cancel_tests.py b/test/run_cancel_tests.py new file mode 100644 index 0000000..0cc22d3 --- /dev/null +++ b/test/run_cancel_tests.py @@ -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) \ No newline at end of file diff --git a/test/test_cancel_cascade.py b/test/test_cancel_cascade.py new file mode 100644 index 0000000..b7ffdb6 --- /dev/null +++ b/test/test_cancel_cascade.py @@ -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() \ No newline at end of file diff --git a/test/verify_order_state.py b/test/verify_order_state.py new file mode 100644 index 0000000..99540b0 --- /dev/null +++ b/test/verify_order_state.py @@ -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}") \ No newline at end of file