feat(#58): Implementar flujo de rechazo de muestras
- Agregar estado 'rejected' al ciclo de vida de la muestra - Crear modelo lims.rejection.reason para gestionar motivos de rechazo - Agregar campos de rechazo en stock.lot (reason, notes, rejected_by, date) - Crear wizard para proceso de rechazo con validaciones - Implementar acción de rechazo con notificaciones - Crear vistas para muestras rechazadas con filtros y búsquedas - Agregar 10 motivos de rechazo predefinidos (hemolizada, coagulada, etc.) - Incluir permisos de seguridad para los nuevos modelos - Agregar menús para gestión de rechazos y muestras rechazadas - Corregir compatibilidad con Odoo 18 en vistas existentes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f2dff1de65
commit
87640b48e0
|
@ -24,7 +24,8 @@
|
||||||
"Bash(true)",
|
"Bash(true)",
|
||||||
"Bash(bash:*)",
|
"Bash(bash:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(gh pr merge:*)"
|
"Bash(gh pr merge:*)",
|
||||||
|
"Bash(git cherry-pick:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import wizards
|
||||||
|
|
|
@ -29,9 +29,12 @@
|
||||||
'data/product_category.xml',
|
'data/product_category.xml',
|
||||||
'data/sample_types.xml',
|
'data/sample_types.xml',
|
||||||
'data/lims_sequence.xml',
|
'data/lims_sequence.xml',
|
||||||
|
'data/rejection_reason_data.xml',
|
||||||
'views/partner_views.xml',
|
'views/partner_views.xml',
|
||||||
'views/analysis_views.xml',
|
'views/analysis_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
|
'views/rejection_reason_views.xml',
|
||||||
|
'wizards/sample_rejection_wizard_views.xml',
|
||||||
'views/stock_lot_views.xml',
|
'views/stock_lot_views.xml',
|
||||||
'views/lims_test_views.xml',
|
'views/lims_test_views.xml',
|
||||||
'views/lims_result_views.xml',
|
'views/lims_result_views.xml',
|
||||||
|
|
Binary file not shown.
95
lims_management/data/rejection_reason_data.xml
Normal file
95
lims_management/data/rejection_reason_data.xml
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Rejection Reasons -->
|
||||||
|
<record id="rejection_reason_insufficient" model="lims.rejection.reason">
|
||||||
|
<field name="name">Muestra Insuficiente</field>
|
||||||
|
<field name="code">INSUF</field>
|
||||||
|
<field name="description">El volumen de muestra recibido es insuficiente para realizar los análisis solicitados</field>
|
||||||
|
<field name="severity">high</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_hemolyzed" model="lims.rejection.reason">
|
||||||
|
<field name="name">Muestra Hemolizada</field>
|
||||||
|
<field name="code">HEMO</field>
|
||||||
|
<field name="description">La muestra presenta hemólisis que interfiere con los análisis</field>
|
||||||
|
<field name="severity">high</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_coagulated" model="lims.rejection.reason">
|
||||||
|
<field name="name">Muestra Coagulada</field>
|
||||||
|
<field name="code">COAG</field>
|
||||||
|
<field name="description">La muestra presenta coágulos que impiden su procesamiento</field>
|
||||||
|
<field name="severity">high</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_lipemic" model="lims.rejection.reason">
|
||||||
|
<field name="name">Muestra Lipémica</field>
|
||||||
|
<field name="code">LIP</field>
|
||||||
|
<field name="description">La muestra presenta lipemia excesiva que interfiere con los análisis</field>
|
||||||
|
<field name="severity">medium</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_wrong_container" model="lims.rejection.reason">
|
||||||
|
<field name="name">Recipiente Inadecuado</field>
|
||||||
|
<field name="code">RECIP</field>
|
||||||
|
<field name="description">El tipo de recipiente utilizado no es apropiado para el análisis solicitado</field>
|
||||||
|
<field name="severity">high</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_wrong_id" model="lims.rejection.reason">
|
||||||
|
<field name="name">Identificación Incorrecta</field>
|
||||||
|
<field name="code">ID</field>
|
||||||
|
<field name="description">La identificación de la muestra no coincide con la solicitud o es ilegible</field>
|
||||||
|
<field name="severity">critical</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_no_label" model="lims.rejection.reason">
|
||||||
|
<field name="name">Muestra sin Rotular</field>
|
||||||
|
<field name="code">NOLAB</field>
|
||||||
|
<field name="description">La muestra no tiene etiqueta de identificación</field>
|
||||||
|
<field name="severity">critical</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_transport" model="lims.rejection.reason">
|
||||||
|
<field name="name">Condiciones de Transporte Inadecuadas</field>
|
||||||
|
<field name="code">TRANS</field>
|
||||||
|
<field name="description">La muestra no fue transportada en las condiciones requeridas (temperatura, tiempo, etc.)</field>
|
||||||
|
<field name="severity">high</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">80</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_contaminated" model="lims.rejection.reason">
|
||||||
|
<field name="name">Muestra Contaminada</field>
|
||||||
|
<field name="code">CONT</field>
|
||||||
|
<field name="description">La muestra presenta signos evidentes de contaminación</field>
|
||||||
|
<field name="severity">critical</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">90</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rejection_reason_expired" model="lims.rejection.reason">
|
||||||
|
<field name="name">Tiempo de Entrega Excedido</field>
|
||||||
|
<field name="code">TIME</field>
|
||||||
|
<field name="description">La muestra fue recibida fuera del tiempo límite establecido para su procesamiento</field>
|
||||||
|
<field name="severity">high</field>
|
||||||
|
<field name="requires_new_sample" eval="True"/>
|
||||||
|
<field name="sequence">100</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
|
@ -6,6 +6,7 @@ from . import product
|
||||||
from . import partner
|
from . import partner
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
from . import stock_lot
|
from . import stock_lot
|
||||||
|
from . import rejection_reason
|
||||||
from . import lims_test
|
from . import lims_test
|
||||||
from . import lims_result
|
from . import lims_result
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
61
lims_management/models/rejection_reason.py
Normal file
61
lims_management/models/rejection_reason.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
class LimsRejectionReason(models.Model):
|
||||||
|
_name = 'lims.rejection.reason'
|
||||||
|
_description = 'Motivo de Rechazo de Muestra'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Motivo',
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
code = fields.Char(
|
||||||
|
string='Código',
|
||||||
|
required=True,
|
||||||
|
help="Código único para identificar el motivo"
|
||||||
|
)
|
||||||
|
description = fields.Text(
|
||||||
|
string='Descripción',
|
||||||
|
help="Descripción detallada del motivo de rechazo"
|
||||||
|
)
|
||||||
|
active = fields.Boolean(
|
||||||
|
string='Activo',
|
||||||
|
default=True
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(
|
||||||
|
string='Secuencia',
|
||||||
|
default=10,
|
||||||
|
help="Orden de aparición en las listas"
|
||||||
|
)
|
||||||
|
requires_new_sample = fields.Boolean(
|
||||||
|
string='Requiere Nueva Muestra',
|
||||||
|
default=True,
|
||||||
|
help="Indica si este tipo de rechazo requiere solicitar una nueva muestra"
|
||||||
|
)
|
||||||
|
severity = fields.Selection([
|
||||||
|
('low', 'Baja'),
|
||||||
|
('medium', 'Media'),
|
||||||
|
('high', 'Alta'),
|
||||||
|
('critical', 'Crítica')
|
||||||
|
], string='Severidad', default='medium',
|
||||||
|
help="Severidad del problema que causa el rechazo")
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
rejection_count = fields.Integer(
|
||||||
|
string='Cantidad de Rechazos',
|
||||||
|
compute='_compute_rejection_count',
|
||||||
|
help="Número de muestras rechazadas con este motivo"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('name')
|
||||||
|
def _compute_rejection_count(self):
|
||||||
|
for record in self:
|
||||||
|
record.rejection_count = self.env['stock.lot'].search_count([
|
||||||
|
('rejection_reason_id', '=', record.id),
|
||||||
|
('state', '=', 'rejected')
|
||||||
|
])
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('code_uniq', 'unique (code)', 'El código del motivo de rechazo debe ser único!'),
|
||||||
|
]
|
|
@ -82,8 +82,29 @@ class StockLot(models.Model):
|
||||||
('analyzed', 'Analizada'),
|
('analyzed', 'Analizada'),
|
||||||
('stored', 'Almacenada'),
|
('stored', 'Almacenada'),
|
||||||
('disposed', 'Desechada'),
|
('disposed', 'Desechada'),
|
||||||
('cancelled', 'Cancelada')
|
('cancelled', 'Cancelada'),
|
||||||
|
('rejected', 'Rechazada')
|
||||||
], string='Estado', default='collected', tracking=True)
|
], string='Estado', default='collected', tracking=True)
|
||||||
|
|
||||||
|
# Rejection fields
|
||||||
|
rejection_reason_id = fields.Many2one(
|
||||||
|
'lims.rejection.reason',
|
||||||
|
string='Motivo de Rechazo',
|
||||||
|
tracking=True
|
||||||
|
)
|
||||||
|
rejection_notes = fields.Text(
|
||||||
|
string='Notas de Rechazo',
|
||||||
|
help="Información adicional sobre el rechazo"
|
||||||
|
)
|
||||||
|
rejected_by = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='Rechazado por',
|
||||||
|
readonly=True
|
||||||
|
)
|
||||||
|
rejection_date = fields.Datetime(
|
||||||
|
string='Fecha de Rechazo',
|
||||||
|
readonly=True
|
||||||
|
)
|
||||||
|
|
||||||
def action_collect(self):
|
def action_collect(self):
|
||||||
"""Mark sample as collected"""
|
"""Mark sample as collected"""
|
||||||
|
@ -155,6 +176,54 @@ class StockLot(models.Model):
|
||||||
message_type='notification'
|
message_type='notification'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def action_open_rejection_wizard(self):
|
||||||
|
"""Open the rejection wizard"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Rechazar Muestra',
|
||||||
|
'res_model': 'lims.sample.rejection.wizard',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {
|
||||||
|
'default_sample_id': self.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_reject(self):
|
||||||
|
"""Reject the sample - to be called from wizard"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'completed':
|
||||||
|
raise ValueError('No se puede rechazar una muestra ya completada')
|
||||||
|
|
||||||
|
# This method is called from the wizard, so rejection fields should already be set
|
||||||
|
self.write({
|
||||||
|
'state': 'rejected',
|
||||||
|
'rejected_by': self.env.user.id,
|
||||||
|
'rejection_date': fields.Datetime.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
reason_name = self.rejection_reason_id.name if self.rejection_reason_id else 'Sin especificar'
|
||||||
|
notes = self.rejection_notes or ''
|
||||||
|
|
||||||
|
body = f'Muestra rechazada por {self.env.user.name}<br/>Motivo: {reason_name}'
|
||||||
|
if notes:
|
||||||
|
body += f'<br/>Notas: {notes}'
|
||||||
|
|
||||||
|
self.message_post(
|
||||||
|
body=body,
|
||||||
|
subject='Estado actualizado: Rechazada',
|
||||||
|
message_type='notification'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify related sale order if exists
|
||||||
|
if self.request_id:
|
||||||
|
self.request_id.message_post(
|
||||||
|
body=f'La muestra {self.name} ha sido rechazada. Motivo: {reason_name}',
|
||||||
|
subject='Muestra Rechazada',
|
||||||
|
message_type='notification'
|
||||||
|
)
|
||||||
|
|
||||||
@api.onchange('sample_type_product_id')
|
@api.onchange('sample_type_product_id')
|
||||||
def _onchange_sample_type_product_id(self):
|
def _onchange_sample_type_product_id(self):
|
||||||
"""Synchronize container_type when sample_type_product_id changes"""
|
"""Synchronize container_type when sample_type_product_id changes"""
|
||||||
|
|
|
@ -13,3 +13,8 @@ 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_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_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
|
access_lims_result_admin,lims.result.admin,model_lims_result,group_lims_admin,1,1,1,1
|
||||||
|
access_lims_rejection_reason_user,lims.rejection.reason.user,model_lims_rejection_reason,base.group_user,1,0,0,0
|
||||||
|
access_lims_rejection_reason_technician,lims.rejection.reason.technician,model_lims_rejection_reason,group_lims_technician,1,0,0,0
|
||||||
|
access_lims_rejection_reason_admin,lims.rejection.reason.admin,model_lims_rejection_reason,group_lims_admin,1,1,1,1
|
||||||
|
access_lims_sample_rejection_wizard_user,lims.sample.rejection.wizard.user,model_lims_sample_rejection_wizard,base.group_user,1,1,1,1
|
||||||
|
access_lims_sample_rejection_wizard_technician,lims.sample.rejection.wizard.technician,model_lims_sample_rejection_wizard,group_lims_technician,1,1,1,1
|
||||||
|
|
|
|
@ -64,7 +64,7 @@
|
||||||
<notebook>
|
<notebook>
|
||||||
<page string="Resultados">
|
<page string="Resultados">
|
||||||
<field name="result_ids"
|
<field name="result_ids"
|
||||||
readonly="state in ['validated', 'cancelled'] or not env.user.has_group('lims_management.group_lims_technician')"
|
readonly="state in ['validated', 'cancelled']"
|
||||||
context="{'default_test_id': id, 'default_patient_id': patient_id, 'default_test_date': create_date}"
|
context="{'default_test_id': id, 'default_patient_id': patient_id, 'default_test_date': create_date}"
|
||||||
mode="list">
|
mode="list">
|
||||||
<list string="Resultados" editable="bottom"
|
<list string="Resultados" editable="bottom"
|
||||||
|
|
|
@ -102,6 +102,14 @@
|
||||||
action="action_lims_lab_sample"
|
action="action_lims_lab_sample"
|
||||||
sequence="16"/>
|
sequence="16"/>
|
||||||
|
|
||||||
|
<!-- Menú para Muestras Rechazadas -->
|
||||||
|
<menuitem
|
||||||
|
id="lims_menu_lab_samples_rejected"
|
||||||
|
name="Muestras Rechazadas"
|
||||||
|
parent="lims_menu_root"
|
||||||
|
action="action_lab_sample_rejected"
|
||||||
|
sequence="17"/>
|
||||||
|
|
||||||
<!-- Submenú de Laboratorio -->
|
<!-- Submenú de Laboratorio -->
|
||||||
<menuitem
|
<menuitem
|
||||||
id="lims_menu_laboratory"
|
id="lims_menu_laboratory"
|
||||||
|
@ -265,6 +273,13 @@
|
||||||
action="action_lims_parameter_statistics"
|
action="action_lims_parameter_statistics"
|
||||||
sequence="40"/>
|
sequence="40"/>
|
||||||
|
|
||||||
|
<!-- Menú de Motivos de Rechazo -->
|
||||||
|
<menuitem id="menu_lims_rejection_reason"
|
||||||
|
name="Motivos de Rechazo"
|
||||||
|
parent="lims_menu_config"
|
||||||
|
action="action_lims_rejection_reason"
|
||||||
|
sequence="50"/>
|
||||||
|
|
||||||
<!-- Menú de configuración de ajustes -->
|
<!-- Menú de configuración de ajustes -->
|
||||||
<menuitem id="menu_lims_config_settings"
|
<menuitem id="menu_lims_config_settings"
|
||||||
name="Ajustes"
|
name="Ajustes"
|
||||||
|
|
93
lims_management/views/rejection_reason_views.xml
Normal file
93
lims_management/views/rejection_reason_views.xml
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- List View for Rejection Reasons -->
|
||||||
|
<record id="view_lims_rejection_reason_list" model="ir.ui.view">
|
||||||
|
<field name="name">lims.rejection.reason.list</field>
|
||||||
|
<field name="model">lims.rejection.reason</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Motivos de Rechazo" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="severity" widget="badge"/>
|
||||||
|
<field name="requires_new_sample"/>
|
||||||
|
<field name="rejection_count"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Form View for Rejection Reasons -->
|
||||||
|
<record id="view_lims_rejection_reason_form" model="ir.ui.view">
|
||||||
|
<field name="name">lims.rejection.reason.form</field>
|
||||||
|
<field name="model">lims.rejection.reason</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Motivo de Rechazo">
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archivado" invisible="active"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Motivo de rechazo..."/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="severity"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="requires_new_sample"/>
|
||||||
|
<field name="active"/>
|
||||||
|
<field name="rejection_count"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Descripción">
|
||||||
|
<field name="description" nolabel="1" placeholder="Descripción detallada del motivo..."/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Search View for Rejection Reasons -->
|
||||||
|
<record id="view_lims_rejection_reason_search" model="ir.ui.view">
|
||||||
|
<field name="name">lims.rejection.reason.search</field>
|
||||||
|
<field name="model">lims.rejection.reason</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Buscar Motivos de Rechazo">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<filter string="Activos" name="active" domain="[('active', '=', True)]"/>
|
||||||
|
<filter string="Archivados" name="inactive" domain="[('active', '=', False)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Requiere Nueva Muestra" name="requires_new" domain="[('requires_new_sample', '=', True)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Severidad Alta/Crítica" name="high_severity" domain="[('severity', 'in', ['high', 'critical'])]"/>
|
||||||
|
<group expand="0" string="Agrupar por">
|
||||||
|
<filter string="Severidad" name="group_severity" context="{'group_by': 'severity'}"/>
|
||||||
|
<filter string="Requiere Nueva Muestra" name="group_requires_new" context="{'group_by': 'requires_new_sample'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action for Rejection Reasons -->
|
||||||
|
<record id="action_lims_rejection_reason" model="ir.actions.act_window">
|
||||||
|
<field name="name">Motivos de Rechazo</field>
|
||||||
|
<field name="res_model">lims.rejection.reason</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_lims_rejection_reason_search"/>
|
||||||
|
<field name="context">{'search_default_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Configure los motivos de rechazo de muestras
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Los motivos de rechazo permiten categorizar y documentar
|
||||||
|
las razones por las cuales una muestra no puede ser procesada.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
|
@ -15,7 +15,7 @@
|
||||||
<field name="collection_date" string="Fecha de Recolección"/>
|
<field name="collection_date" string="Fecha de Recolección"/>
|
||||||
<field name="collector_id" string="Recolectado por"/>
|
<field name="collector_id" string="Recolectado por"/>
|
||||||
<field name="container_type" optional="hide" string="Tipo Contenedor (Obsoleto)"/>
|
<field name="container_type" optional="hide" string="Tipo Contenedor (Obsoleto)"/>
|
||||||
<field name="state" string="Estado" decoration-success="state == 'analyzed'" decoration-info="state == 'in_process'" decoration-muted="state == 'stored' or state == 'disposed'" widget="badge"/>
|
<field name="state" string="Estado" decoration-success="state == 'analyzed'" decoration-info="state == 'in_process'" decoration-danger="state == 'rejected'" decoration-muted="state == 'stored' or state == 'disposed' or state == 'cancelled'" widget="badge"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
@ -33,7 +33,13 @@
|
||||||
<button name="action_complete_analysis" string="Completar Análisis" type="object" class="oe_highlight" invisible="state != 'in_process'"/>
|
<button name="action_complete_analysis" string="Completar Análisis" type="object" class="oe_highlight" invisible="state != 'in_process'"/>
|
||||||
<button name="action_store" string="Almacenar" type="object" invisible="state not in ['analyzed', 'in_process', 'received']"/>
|
<button name="action_store" string="Almacenar" type="object" invisible="state not in ['analyzed', 'in_process', 'received']"/>
|
||||||
<button name="action_dispose" string="Desechar" type="object" invisible="state == 'disposed'"/>
|
<button name="action_dispose" string="Desechar" type="object" invisible="state == 'disposed'"/>
|
||||||
<field name="state" widget="statusbar" statusbar_visible="pending_collection,collected,received,in_process,analyzed,stored"/>
|
<button name="action_open_rejection_wizard"
|
||||||
|
string="Rechazar Muestra"
|
||||||
|
type="object"
|
||||||
|
class="btn-danger"
|
||||||
|
invisible="state in ['completed', 'rejected', 'disposed', 'cancelled']"/>
|
||||||
|
<button name="action_cancel" string="Cancelar" type="object" invisible="state in ['cancelled', 'rejected', 'disposed']"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="pending_collection,collected,received,in_process,analyzed,stored,rejected"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
|
@ -69,10 +75,65 @@
|
||||||
invisible="sample_type_product_id != False"/>
|
invisible="sample_type_product_id != False"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<group string="Información de Rechazo" invisible="state != 'rejected'" col="4">
|
||||||
|
<field name="rejection_reason_id" readonly="1"/>
|
||||||
|
<field name="rejected_by" readonly="1"/>
|
||||||
|
<field name="rejection_date" readonly="1"/>
|
||||||
|
<field name="rejection_notes" readonly="1" colspan="4"/>
|
||||||
|
</group>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Search View for Lab Samples -->
|
||||||
|
<record id="view_lab_sample_search" model="ir.ui.view">
|
||||||
|
<field name="name">lab.sample.search</field>
|
||||||
|
<field name="model">stock.lot</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Buscar Muestras">
|
||||||
|
<field name="name" string="Código"/>
|
||||||
|
<field name="patient_id"/>
|
||||||
|
<field name="barcode"/>
|
||||||
|
<field name="analysis_names"/>
|
||||||
|
<filter string="Pendientes" name="pending" domain="[('state', 'in', ['pending_collection', 'collected', 'received'])]"/>
|
||||||
|
<filter string="En Proceso" name="in_process" domain="[('state', '=', 'in_process')]"/>
|
||||||
|
<filter string="Analizadas" name="analyzed" domain="[('state', '=', 'analyzed')]"/>
|
||||||
|
<filter string="Rechazadas" name="rejected" domain="[('state', '=', 'rejected')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Hoy" name="today" domain="[('collection_date', '>=', datetime.datetime.now().strftime('%Y-%m-%d 00:00:00')), ('collection_date', '<=', datetime.datetime.now().strftime('%Y-%m-%d 23:59:59'))]"/>
|
||||||
|
<filter string="Esta Semana" name="this_week" domain="[('collection_date', '>=', (datetime.datetime.now() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Rechazadas - Alta Severidad" name="rejected_high"
|
||||||
|
domain="[('state', '=', 'rejected'), ('rejection_reason_id.severity', 'in', ['high', 'critical'])]"/>
|
||||||
|
<group expand="0" string="Agrupar por">
|
||||||
|
<filter string="Estado" name="group_state" context="{'group_by': 'state'}"/>
|
||||||
|
<filter string="Paciente" name="group_patient" context="{'group_by': 'patient_id'}"/>
|
||||||
|
<filter string="Fecha de Recolección" name="group_collection" context="{'group_by': 'collection_date:day'}"/>
|
||||||
|
<filter string="Motivo de Rechazo" name="group_rejection" context="{'group_by': 'rejection_reason_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action for Rejected Samples -->
|
||||||
|
<record id="action_lab_sample_rejected" model="ir.actions.act_window">
|
||||||
|
<field name="name">Muestras Rechazadas</field>
|
||||||
|
<field name="res_model">stock.lot</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="domain">[('is_lab_sample', '=', True), ('state', '=', 'rejected')]</field>
|
||||||
|
<field name="context">{'search_default_rejected': 1, 'default_is_lab_sample': True}</field>
|
||||||
|
<field name="search_view_id" ref="view_lab_sample_search"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No hay muestras rechazadas
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Las muestras rechazadas aparecerán aquí con información
|
||||||
|
sobre el motivo del rechazo y las acciones tomadas.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
</data>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
2
lims_management/wizards/__init__.py
Normal file
2
lims_management/wizards/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import sample_rejection_wizard
|
94
lims_management/wizards/sample_rejection_wizard.py
Normal file
94
lims_management/wizards/sample_rejection_wizard.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
class SampleRejectionWizard(models.TransientModel):
|
||||||
|
_name = 'lims.sample.rejection.wizard'
|
||||||
|
_description = 'Wizard para Rechazo de Muestras'
|
||||||
|
|
||||||
|
sample_id = fields.Many2one(
|
||||||
|
'stock.lot',
|
||||||
|
string='Muestra',
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
domain=[('is_lab_sample', '=', True)]
|
||||||
|
)
|
||||||
|
|
||||||
|
rejection_reason_id = fields.Many2one(
|
||||||
|
'lims.rejection.reason',
|
||||||
|
string='Motivo de Rechazo',
|
||||||
|
required=True,
|
||||||
|
domain=[('active', '=', True)]
|
||||||
|
)
|
||||||
|
|
||||||
|
rejection_notes = fields.Text(
|
||||||
|
string='Notas Adicionales',
|
||||||
|
help="Información adicional sobre el rechazo"
|
||||||
|
)
|
||||||
|
|
||||||
|
requires_new_sample = fields.Boolean(
|
||||||
|
string='Requiere Nueva Muestra',
|
||||||
|
related='rejection_reason_id.requires_new_sample',
|
||||||
|
readonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
create_new_sample = fields.Boolean(
|
||||||
|
string='Crear Nueva Solicitud',
|
||||||
|
help="Crear automáticamente una nueva solicitud de muestra"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields):
|
||||||
|
res = super(SampleRejectionWizard, self).default_get(fields)
|
||||||
|
active_id = self.env.context.get('active_id')
|
||||||
|
if active_id:
|
||||||
|
sample = self.env['stock.lot'].browse(active_id)
|
||||||
|
res['sample_id'] = sample.id
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.onchange('rejection_reason_id')
|
||||||
|
def _onchange_rejection_reason_id(self):
|
||||||
|
if self.rejection_reason_id and self.rejection_reason_id.requires_new_sample:
|
||||||
|
self.create_new_sample = True
|
||||||
|
|
||||||
|
def action_reject_sample(self):
|
||||||
|
"""Reject the sample with the provided reason"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.sample_id:
|
||||||
|
raise ValidationError('No se ha seleccionado ninguna muestra')
|
||||||
|
|
||||||
|
if self.sample_id.state == 'completed':
|
||||||
|
raise ValidationError('No se puede rechazar una muestra ya completada')
|
||||||
|
|
||||||
|
# Update sample with rejection information
|
||||||
|
self.sample_id.write({
|
||||||
|
'rejection_reason_id': self.rejection_reason_id.id,
|
||||||
|
'rejection_notes': self.rejection_notes
|
||||||
|
})
|
||||||
|
|
||||||
|
# Call the rejection method on the sample
|
||||||
|
self.sample_id.action_reject()
|
||||||
|
|
||||||
|
# Create new sample request if needed
|
||||||
|
if self.create_new_sample and self.sample_id.request_id:
|
||||||
|
self._create_new_sample_request()
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _create_new_sample_request(self):
|
||||||
|
"""Create a new sample request based on the rejected one"""
|
||||||
|
original_order = self.sample_id.request_id
|
||||||
|
|
||||||
|
# Create a note in the original order
|
||||||
|
original_order.message_post(
|
||||||
|
body=f'Se solicitará una nueva muestra debido al rechazo. Motivo: {self.rejection_reason_id.name}',
|
||||||
|
subject='Nueva Muestra Solicitada',
|
||||||
|
message_type='notification'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Here you could implement logic to create a new sale.order
|
||||||
|
# or a specific request for a new sample
|
||||||
|
# For now, we'll just add a note
|
||||||
|
|
||||||
|
return True
|
45
lims_management/wizards/sample_rejection_wizard_views.xml
Normal file
45
lims_management/wizards/sample_rejection_wizard_views.xml
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Form View for Sample Rejection Wizard -->
|
||||||
|
<record id="view_lims_sample_rejection_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">lims.sample.rejection.wizard.form</field>
|
||||||
|
<field name="model">lims.sample.rejection.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Rechazar Muestra">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="sample_id" options="{'no_create': True, 'no_open': True}"/>
|
||||||
|
<field name="rejection_reason_id" options="{'no_create': True}"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="requires_new_sample" invisible="1"/>
|
||||||
|
<field name="create_new_sample"
|
||||||
|
invisible="not requires_new_sample"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Información Adicional">
|
||||||
|
<field name="rejection_notes" placeholder="Agregue cualquier información relevante sobre el rechazo..."/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_reject_sample"
|
||||||
|
string="Rechazar Muestra"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
confirm="¿Está seguro de rechazar esta muestra? Esta acción no se puede deshacer."/>
|
||||||
|
<button string="Cancelar" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action for Sample Rejection Wizard -->
|
||||||
|
<record id="action_lims_sample_rejection_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Rechazar Muestra</field>
|
||||||
|
<field name="res_model">lims.sample.rejection.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
<field name="context">{'default_sample_id': active_id}</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
32
pr_body_10.txt
Normal file
32
pr_body_10.txt
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
## Descripción
|
||||||
|
Implementación del sistema de etiquetas con código de barras para las muestras de laboratorio.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
### Funcionalidad principal
|
||||||
|
- Creado reporte QWeb para imprimir etiquetas de muestras (100x50mm)
|
||||||
|
- Implementado botón 'Imprimir Etiquetas' en órdenes de laboratorio
|
||||||
|
- Las etiquetas incluyen:
|
||||||
|
- Información del paciente
|
||||||
|
- Código de muestra y orden
|
||||||
|
- Tipo de contenedor
|
||||||
|
- Fecha de recolección
|
||||||
|
- Código de barras Code128
|
||||||
|
- Lista de análisis a realizar
|
||||||
|
|
||||||
|
### Correcciones técnicas
|
||||||
|
- **Código de barras**: Corregido problema de visualización usando widget nativo de Odoo 18
|
||||||
|
- **Caracteres especiales**: Solucionado problema de codificación UTF-8 con referencias numéricas
|
||||||
|
- **Layout**: Ajustado diseño para mostrar múltiples etiquetas por página sin solapamiento
|
||||||
|
- **Espaciado**: Optimizado el tamaño y posición del código de barras
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Probado con órdenes que tienen múltiples muestras
|
||||||
|
- Verificado que los códigos de barras se generen y visualicen correctamente
|
||||||
|
- Confirmado que los caracteres en español (tildes, ñ) se muestren bien
|
||||||
|
- Validado que no hay solapamiento entre etiquetas
|
||||||
|
|
||||||
|
## Capturas
|
||||||
|
- Los códigos de barras ahora se visualizan correctamente
|
||||||
|
- Las etiquetas respetan el formato 100x50mm
|
||||||
|
- Múltiples etiquetas por página sin problemas de diseño
|
Loading…
Reference in New Issue
Block a user