Merge pull request 'feat(#9): Implementar flujo de validación y seguridad' (#56) from feature/9-validation-security-flow into dev

Reviewed-on: luis_portillo/clinical_laboratory#56
This commit is contained in:
luis_portillo 2025-07-16 02:05:51 +00:00
commit 0c210bef9e
8 changed files with 461 additions and 25 deletions

View File

@ -45,6 +45,7 @@
'views/menus.xml',
],
'demo': [
'demo/demo_users.xml',
'demo/z_lims_demo.xml',
'demo/z_analysis_demo.xml',
'demo/z_sample_demo.xml',

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Usuario Recepcionista -->
<record id="demo_user_receptionist" model="res.users">
<field name="name">Recepcionista Demo</field>
<field name="login">recepcionista</field>
<field name="password">demo</field>
<field name="email">recepcionista@example.com</field>
<field name="groups_id" eval="[(6, 0, [ref('lims_management.group_lims_receptionist'), ref('base.group_user')])]"/>
<field name="company_ids" eval="[(4, ref('base.main_company'))]"/>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Usuario Técnico -->
<record id="demo_user_technician" model="res.users">
<field name="name">Técnico Demo</field>
<field name="login">tecnico</field>
<field name="password">demo</field>
<field name="email">tecnico@example.com</field>
<field name="groups_id" eval="[(6, 0, [ref('lims_management.group_lims_technician'), ref('base.group_user')])]"/>
<field name="company_ids" eval="[(4, ref('base.main_company'))]"/>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Usuario Administrador de Laboratorio -->
<record id="demo_user_lab_admin" model="res.users">
<field name="name">Administrador Lab Demo</field>
<field name="login">administrador</field>
<field name="password">demo</field>
<field name="email">administrador@example.com</field>
<field name="groups_id" eval="[(6, 0, [ref('lims_management.group_lims_admin'), ref('base.group_user')])]"/>
<field name="company_ids" eval="[(4, ref('base.main_company'))]"/>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Partner (empleado) para cada usuario -->
<record id="demo_user_receptionist_partner" model="res.partner">
<field name="name">Recepcionista Demo</field>
<field name="email">recepcionista@example.com</field>
<field name="user_id" ref="demo_user_receptionist"/>
<field name="is_company" eval="False"/>
</record>
<record id="demo_user_technician_partner" model="res.partner">
<field name="name">Técnico Demo</field>
<field name="email">tecnico@example.com</field>
<field name="user_id" ref="demo_user_technician"/>
<field name="is_company" eval="False"/>
</record>
<record id="demo_user_lab_admin_partner" model="res.partner">
<field name="name">Administrador Lab Demo</field>
<field name="email">administrador@example.com</field>
<field name="user_id" ref="demo_user_lab_admin"/>
<field name="is_company" eval="False"/>
</record>
</data>
</odoo>

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError
import logging
_logger = logging.getLogger(__name__)
@ -190,6 +190,12 @@ class LimsTest(models.Model):
def action_start_process(self):
"""Inicia el proceso de análisis."""
self.ensure_one()
# Verificar permisos: solo técnicos y administradores
if not (self.env.user.has_group('lims_management.group_lims_technician') or
self.env.user.has_group('lims_management.group_lims_admin')):
raise UserError(_('No tiene permisos para iniciar el proceso de análisis. Solo técnicos y administradores pueden realizar esta acción.'))
if self.state != 'draft':
raise UserError(_('Solo se pueden procesar pruebas en estado borrador.'))
if not self.sample_id:
@ -203,20 +209,44 @@ class LimsTest(models.Model):
# Log en el chatter
self.message_post(
body=_('Prueba iniciada por %s') % self.env.user.name,
subject=_('Proceso Iniciado')
subject=_('Proceso Iniciado'),
message_type='notification'
)
# Actualizar estado de la muestra si es necesario
if self.sample_id and self.sample_id.state == 'collected':
self.sample_id.write({'state': 'in_analysis'})
self.sample_id.message_post(
body=_('Muestra en análisis para la prueba %s') % self.name,
subject=_('Estado actualizado'),
message_type='notification'
)
return True
def action_enter_results(self):
"""Marca como resultados ingresados."""
self.ensure_one()
# Verificar permisos: solo técnicos y administradores
if not (self.env.user.has_group('lims_management.group_lims_technician') or
self.env.user.has_group('lims_management.group_lims_admin')):
raise UserError(_('No tiene permisos para ingresar resultados. Solo técnicos y administradores pueden realizar esta acción.'))
if self.state != 'in_process':
raise UserError(_('Solo se pueden ingresar resultados en pruebas en proceso.'))
if not self.result_ids:
raise UserError(_('Debe ingresar al menos un resultado.'))
# Verificar que todos los resultados tengan valores ingresados
empty_results = self.result_ids.filtered(
lambda r: not r.value_text and not r.value_numeric and not r.value_boolean and r.parameter_id.value_type != 'boolean'
)
if empty_results:
params = ', '.join(empty_results.mapped('parameter_id.name'))
raise UserError(_('Los siguientes parámetros no tienen resultados ingresados: %s') % params)
# Si no requiere validación, pasar directamente a validado
if not self.require_validation:
self.write({
@ -226,13 +256,15 @@ class LimsTest(models.Model):
})
self.message_post(
body=_('Resultados ingresados y auto-validados por %s') % self.env.user.name,
subject=_('Resultados Validados')
subject=_('Resultados Validados'),
message_type='notification'
)
else:
self.state = 'result_entered'
self.message_post(
body=_('Resultados ingresados por %s') % self.env.user.name,
subject=_('Resultados Ingresados')
subject=_('Resultados Ingresados'),
message_type='notification'
)
return True
@ -240,10 +272,23 @@ class LimsTest(models.Model):
def action_validate(self):
"""Valida los resultados (solo administradores)."""
self.ensure_one()
# Verificar permisos: solo administradores
if not self.env.user.has_group('lims_management.group_lims_admin'):
raise UserError(_('No tiene permisos para validar resultados. Solo administradores pueden realizar esta acción.'))
if self.state != 'result_entered':
raise UserError(_('Solo se pueden validar pruebas con resultados ingresados.'))
# TODO: Verificar permisos cuando se implemente seguridad
# Verificar que todos los resultados críticos tengan observaciones si están fuera de rango
critical_results = []
for result in self.result_ids:
if result.is_out_of_range and result.parameter_id.is_critical:
if not result.notes:
critical_results.append(result.parameter_id.name)
if critical_results:
raise UserError(_('Los siguientes parámetros críticos están fuera de rango y requieren observaciones: %s') % ', '.join(critical_results))
self.write({
'state': 'validated',
@ -251,26 +296,56 @@ class LimsTest(models.Model):
'validation_date': fields.Datetime.now()
})
# Log en el chatter
# Log en el chatter con más detalles
out_of_range_count = len(self.result_ids.filtered('is_out_of_range'))
body = _('Resultados validados por %s') % self.env.user.name
if out_of_range_count:
body += _('<br/>%d parámetros fuera de rango') % out_of_range_count
self.message_post(
body=_('Resultados validados por %s') % self.env.user.name,
subject=_('Resultados Validados')
body=body,
subject=_('Resultados Validados'),
message_type='notification'
)
# Actualizar estado de la muestra si todas las pruebas están validadas
if self.sample_id:
all_tests = self.env['lims.test'].search([
('sample_id', '=', self.sample_id.id),
('state', '!=', 'cancelled')
])
if all(test.state == 'validated' for test in all_tests):
self.sample_id.write({'state': 'analyzed'})
self.sample_id.message_post(
body=_('Todas las pruebas de la muestra han sido validadas'),
subject=_('Análisis completado'),
message_type='notification'
)
return True
def action_cancel(self):
"""Cancela la prueba."""
self.ensure_one()
if self.state == 'validated':
raise UserError(_('No se pueden cancelar pruebas validadas.'))
# Verificar permisos: técnicos y administradores pueden cancelar
if not (self.env.user.has_group('lims_management.group_lims_technician') or
self.env.user.has_group('lims_management.group_lims_admin')):
raise UserError(_('No tiene permisos para cancelar pruebas. Solo técnicos y administradores pueden realizar esta acción.'))
if self.state == 'validated':
# Solo administradores pueden cancelar pruebas validadas
if not self.env.user.has_group('lims_management.group_lims_admin'):
raise UserError(_('No se pueden cancelar pruebas validadas. Solo administradores pueden realizar esta acción.'))
old_state = self.state
self.state = 'cancelled'
# Log en el chatter
# Log en el chatter con el estado anterior
self.message_post(
body=_('Prueba cancelada por %s') % self.env.user.name,
subject=_('Prueba Cancelada')
body=_('Prueba cancelada por %s (estado anterior: %s)') % (self.env.user.name, dict(self._fields['state'].selection).get(old_state)),
subject=_('Prueba Cancelada'),
message_type='notification'
)
return True
@ -278,6 +353,12 @@ class LimsTest(models.Model):
def action_regenerate_results(self):
"""Regenera los resultados basados en la configuración actual del análisis."""
self.ensure_one()
# Verificar permisos: solo técnicos y administradores
if not (self.env.user.has_group('lims_management.group_lims_technician') or
self.env.user.has_group('lims_management.group_lims_admin')):
raise UserError(_('No tiene permisos para regenerar resultados. Solo técnicos y administradores pueden realizar esta acción.'))
if self.state not in ['draft', 'in_process']:
raise UserError(_('Solo se pueden regenerar resultados en pruebas en borrador o en proceso.'))
@ -292,7 +373,8 @@ class LimsTest(models.Model):
self.message_post(
body=_('Resultados regenerados por %s') % self.env.user.name,
subject=_('Resultados Regenerados')
subject=_('Resultados Regenerados'),
message_type='notification'
)
return True
@ -300,9 +382,98 @@ class LimsTest(models.Model):
def action_draft(self):
"""Regresa a borrador."""
self.ensure_one()
# Verificar permisos: solo administradores pueden regresar a borrador
if not self.env.user.has_group('lims_management.group_lims_admin'):
raise UserError(_('No tiene permisos para regresar pruebas a borrador. Solo administradores pueden realizar esta acción.'))
if self.state not in ['cancelled']:
raise UserError(_('Solo se pueden regresar a borrador pruebas canceladas.'))
self.state = 'draft'
return True
self.message_post(
body=_('Prueba regresada a borrador por %s') % self.env.user.name,
subject=_('Estado Restaurado'),
message_type='notification'
)
return True
@api.constrains('state')
def _check_state_transition(self):
"""Valida que las transiciones de estado sean válidas"""
for record in self:
# Definir transiciones válidas
valid_transitions = {
'draft': ['in_process', 'cancelled'],
'in_process': ['result_entered', 'cancelled'],
'result_entered': ['validated', 'cancelled'],
'validated': ['cancelled'], # Solo admin puede cancelar validados
'cancelled': ['draft'] # Solo admin puede regresar a draft
}
# Si es un registro nuevo, no hay transición que validar
if not record._origin.id:
continue
old_state = record._origin.state
new_state = record.state
# Si el estado no cambió, no hay nada que validar
if old_state == new_state:
continue
# Verificar si la transición es válida
if old_state in valid_transitions:
if new_state not in valid_transitions[old_state]:
raise ValidationError(
_('Transición de estado no válida: No se puede cambiar de "%s" a "%s"') %
(dict(self._fields['state'].selection).get(old_state),
dict(self._fields['state'].selection).get(new_state))
)
@api.constrains('sample_id', 'state')
def _check_sample_state(self):
"""Valida que la muestra esté en un estado apropiado para la prueba"""
for record in self:
if record.sample_id and record.state in ['in_process', 'result_entered']:
# La muestra debe estar al menos recolectada
if record.sample_id.state in ['pending_collection', 'cancelled']:
raise ValidationError(
_('No se puede procesar una prueba con una muestra en estado "%s"') %
dict(record.sample_id._fields['state'].selection).get(record.sample_id.state)
)
@api.model
def create(self, vals):
"""Override create para validaciones adicionales"""
# Si se está creando con un estado diferente a draft, verificar permisos
if vals.get('state') and vals['state'] != 'draft':
if not self.env.user.has_group('lims_management.group_lims_admin'):
raise UserError(_('Solo administradores pueden crear pruebas en estado diferente a borrador'))
return super().create(vals)
def write(self, vals):
"""Override write para auditoría adicional"""
# Si se está cambiando el estado, registrar más detalles
if 'state' in vals:
for record in self:
old_state = record.state
# El write real se hace en el super()
result = super().write(vals)
# Registrar cambios importantes después del write
if 'sample_id' in vals:
for record in self:
if vals.get('sample_id'):
sample = self.env['stock.lot'].browse(vals['sample_id'])
record.message_post(
body=_('Muestra asignada: %s') % sample.name,
subject=_('Muestra Asignada'),
message_type='notification'
)
return result

View File

@ -4,7 +4,8 @@ from datetime import datetime
import random
class StockLot(models.Model):
_inherit = 'stock.lot'
_name = 'stock.lot'
_inherit = ['stock.lot', 'mail.thread', 'mail.activity.mixin']
is_lab_sample = fields.Boolean(string='Es Muestra de Laboratorio')
@ -86,31 +87,73 @@ class StockLot(models.Model):
def action_collect(self):
"""Mark sample as collected"""
old_state = self.state
self.write({'state': 'collected', 'collection_date': fields.Datetime.now()})
self.message_post(
body='Muestra recolectada por %s' % self.env.user.name,
subject='Estado actualizado: Recolectada',
message_type='notification'
)
def action_receive(self):
"""Mark sample as received in laboratory"""
old_state = self.state
self.write({'state': 'received'})
self.message_post(
body='Muestra recibida en laboratorio por %s' % self.env.user.name,
subject='Estado actualizado: Recibida',
message_type='notification'
)
def action_start_analysis(self):
"""Start analysis process"""
old_state = self.state
self.write({'state': 'in_process'})
self.message_post(
body='Análisis iniciado por %s' % self.env.user.name,
subject='Estado actualizado: En Proceso',
message_type='notification'
)
def action_complete_analysis(self):
"""Mark analysis as completed"""
old_state = self.state
self.write({'state': 'analyzed'})
self.message_post(
body='Análisis completado por %s' % self.env.user.name,
subject='Estado actualizado: Analizada',
message_type='notification'
)
def action_store(self):
"""Store the sample"""
old_state = self.state
self.write({'state': 'stored'})
self.message_post(
body='Muestra almacenada por %s' % self.env.user.name,
subject='Estado actualizado: Almacenada',
message_type='notification'
)
def action_dispose(self):
"""Dispose of the sample"""
old_state = self.state
self.write({'state': 'disposed'})
self.message_post(
body='Muestra desechada por %s. Motivo de disposición registrado.' % self.env.user.name,
subject='Estado actualizado: Desechada',
message_type='notification'
)
def action_cancel(self):
"""Cancel the sample"""
old_state = self.state
self.write({'state': 'cancelled'})
self.message_post(
body='Muestra cancelada por %s' % self.env.user.name,
subject='Estado actualizado: Cancelada',
message_type='notification'
)
@api.onchange('sample_type_product_id')
def _onchange_sample_type_product_id(self):

View File

@ -7,5 +7,9 @@ access_lims_parameter_range_user,lims.parameter.range.user,model_lims_parameter_
access_lims_parameter_range_manager,lims.parameter.range.manager,model_lims_parameter_range,group_lims_admin,1,1,1,1
access_sale_order_receptionist,sale.order.receptionist,sale.model_sale_order,group_lims_receptionist,1,1,1,0
access_stock_lot_user,stock.lot.user,stock.model_stock_lot,base.group_user,1,1,1,1
access_lims_test_user,lims.test.user,model_lims_test,base.group_user,1,1,1,1
access_lims_result_user,lims.result.user,model_lims_result,base.group_user,1,1,1,1
access_lims_test_receptionist,lims.test.receptionist,model_lims_test,group_lims_receptionist,1,0,0,0
access_lims_test_technician,lims.test.technician,model_lims_test,group_lims_technician,1,1,1,0
access_lims_test_admin,lims.test.admin,model_lims_test,group_lims_admin,1,1,1,1
access_lims_result_receptionist,lims.result.receptionist,model_lims_result,group_lims_receptionist,1,0,0,0
access_lims_result_technician,lims.result.technician,model_lims_result,group_lims_technician,1,1,1,0
access_lims_result_admin,lims.result.admin,model_lims_result,group_lims_admin,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_lims_parameter_range_manager lims.parameter.range.manager model_lims_parameter_range group_lims_admin 1 1 1 1
8 access_sale_order_receptionist sale.order.receptionist sale.model_sale_order group_lims_receptionist 1 1 1 0
9 access_stock_lot_user stock.lot.user stock.model_stock_lot base.group_user 1 1 1 1
10 access_lims_test_user access_lims_test_receptionist lims.test.user lims.test.receptionist model_lims_test base.group_user group_lims_receptionist 1 1 0 1 0 1 0
11 access_lims_result_user access_lims_test_technician lims.result.user lims.test.technician model_lims_result model_lims_test base.group_user group_lims_technician 1 1 1 1 0
12 access_lims_test_admin lims.test.admin model_lims_test group_lims_admin 1 1 1 1
13 access_lims_result_receptionist lims.result.receptionist model_lims_result group_lims_receptionist 1 0 0 0
14 access_lims_result_technician lims.result.technician model_lims_result group_lims_technician 1 1 1 0
15 access_lims_result_admin lims.result.admin model_lims_result group_lims_admin 1 1 1 1

View File

@ -33,5 +33,81 @@
El usuario tiene acceso completo al módulo LIMS, incluyendo la validación de resultados, configuración y reportes.
</field>
</record>
<!-- Reglas de registro para lims.test -->
<!-- Recepcionistas: Solo pueden ver pruebas, no editarlas -->
<record id="lims_test_receptionist_read_rule" model="ir.rule">
<field name="name">Recepcionista: Solo lectura en pruebas</field>
<field name="model_id" ref="model_lims_test"/>
<field name="groups" eval="[(4, ref('group_lims_receptionist'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Técnicos: Pueden editar solo pruebas no validadas -->
<record id="lims_test_technician_write_rule" model="ir.rule">
<field name="name">Técnico: Editar solo pruebas no validadas</field>
<field name="model_id" ref="model_lims_test"/>
<field name="groups" eval="[(4, ref('group_lims_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[('state', '!=', 'validated')]</field>
</record>
<!-- Administradores: Acceso completo (sin restricciones) -->
<record id="lims_test_admin_all_rule" model="ir.rule">
<field name="name">Administrador: Acceso completo a pruebas</field>
<field name="model_id" ref="model_lims_test"/>
<field name="groups" eval="[(4, ref('group_lims_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Reglas de registro para lims.result -->
<!-- Recepcionistas: Solo pueden ver resultados -->
<record id="lims_result_receptionist_read_rule" model="ir.rule">
<field name="name">Recepcionista: Solo lectura en resultados</field>
<field name="model_id" ref="model_lims_result"/>
<field name="groups" eval="[(4, ref('group_lims_receptionist'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Técnicos: Pueden editar resultados de pruebas no validadas -->
<record id="lims_result_technician_write_rule" model="ir.rule">
<field name="name">Técnico: Editar resultados de pruebas no validadas</field>
<field name="model_id" ref="model_lims_result"/>
<field name="groups" eval="[(4, ref('group_lims_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[('test_id.state', '!=', 'validated')]</field>
</record>
<!-- Administradores: Acceso completo a resultados -->
<record id="lims_result_admin_all_rule" model="ir.rule">
<field name="name">Administrador: Acceso completo a resultados</field>
<field name="model_id" ref="model_lims_result"/>
<field name="groups" eval="[(4, ref('group_lims_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
</data>
</odoo>

View File

@ -11,23 +11,29 @@
<header>
<button name="action_start_process" string="Iniciar Proceso"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
invisible="state != 'draft'"
groups="lims_management.group_lims_technician"/>
<button name="action_enter_results" string="Marcar Resultados Ingresados"
type="object" class="oe_highlight"
invisible="state != 'in_process'"/>
invisible="state != 'in_process'"
groups="lims_management.group_lims_technician"/>
<button name="action_validate" string="Validar Resultados"
type="object" class="oe_highlight"
invisible="state != 'result_entered' or not require_validation"/>
invisible="state != 'result_entered' or not require_validation"
groups="lims_management.group_lims_admin"/>
<button name="action_cancel" string="Cancelar"
type="object"
invisible="state in ['validated', 'cancelled']"/>
invisible="state in ['validated', 'cancelled']"
groups="lims_management.group_lims_technician"/>
<button name="action_draft" string="Volver a Borrador"
type="object"
invisible="state != 'cancelled'"/>
invisible="state != 'cancelled'"
groups="lims_management.group_lims_admin"/>
<button name="action_regenerate_results" string="Regenerar Resultados"
type="object"
invisible="state not in ['draft', 'in_process']"
confirm="¿Está seguro de regenerar los resultados? Esto eliminará los resultados actuales."/>
confirm="¿Está seguro de regenerar los resultados? Esto eliminará los resultados actuales."
groups="lims_management.group_lims_technician"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,in_process,result_entered,validated"/>
</header>
@ -58,7 +64,7 @@
<notebook>
<page string="Resultados">
<field name="result_ids"
readonly="state in ['validated', 'cancelled']"
readonly="state in ['validated', 'cancelled'] or not env.user.has_group('lims_management.group_lims_technician')"
context="{'default_test_id': id, 'default_patient_id': patient_id, 'default_test_date': create_date}"
mode="list">
<list string="Resultados" editable="bottom"

74
pr_body_54.txt Normal file
View File

@ -0,0 +1,74 @@
## Descripción
Implementación de la funcionalidad para cancelar automáticamente muestras y pruebas cuando se cancela una orden de laboratorio, evitando que queden elementos "huérfanos" en el sistema.
## 🎯 Objetivo
Resolver el issue #54: Las muestras y pruebas asociadas a una orden de laboratorio deben cancelarse automáticamente cuando se cancela la orden.
## 🔧 Cambios implementados
### 1. Modelo `stock.lot` (Muestras)
- Agregado nuevo estado `'cancelled'` a la selección de estados
- Implementado método `action_cancel()` para cambiar el estado a cancelado
### 2. Modelo `sale.order` (Órdenes)
- Override del método `action_cancel()` que:
- Llama primero al método padre para mantener el comportamiento estándar
- Si es una orden de laboratorio (`is_lab_request = True`):
- Cancela muestras en estados: `pending_collection`, `collected`, `received`, `in_process`
- Cancela pruebas asociadas que no estén en estado `validated` o `cancelled`
- Registra mensajes en el chatter de cada elemento cancelado
- Muestra un resumen en la orden con la cantidad de elementos cancelados
### 3. Tests unitarios
- Creado `test_order_cancel_cascade.py` con 6 tests que verifican:
- ✅ Cancelación correcta de muestras
- ✅ Cancelación correcta de pruebas
- ✅ No cancelación de muestras en estados finales (analyzed, stored, disposed)
- ✅ No cancelación de pruebas validadas
- ✅ Generación de mensajes en chatter
- ✅ Órdenes normales (no laboratorio) no afectadas
## 🧪 Pruebas realizadas
### Test manual exitoso:
```
📦 Muestras generadas:
- 0000012: Contenedor de Heces (Estado: pending_collection)
🔬 Pruebas generadas:
- LAB-2025-00014: Coprocultivo (Estado: draft)
❌ Cancelando la orden de laboratorio...
📦 Estado final de las muestras:
- 0000012: cancelled ✓
🔬 Estado final de las pruebas:
- LAB-2025-00014: cancelled ✓
✅ Mensajes de cancelación registrados en todos los elementos
```
## 📋 Checklist
- [x] Código implementado y probado
- [x] Tests unitarios creados
- [x] Pruebas manuales exitosas
- [x] Mensajes en chatter funcionando
- [x] Sin errores o warnings
- [x] Documentación en código
## 🔍 Cómo probar
1. Crear una orden de laboratorio con análisis
2. Confirmar la orden (se generan muestras y pruebas)
3. Opcionalmente iniciar proceso en alguna prueba
4. Cancelar la orden
5. Verificar que:
- Las muestras cambiaron a estado "Cancelada"
- Las pruebas cambiaron a estado "Cancelado"
- Hay mensajes en el chatter explicando la cancelación
Resuelve #54