Merge pull request #61 from feature/58-sample-rejection-flow

feat(#58): Implementar flujo de rechazo de muestras
This commit is contained in:
Luis Ernesto Portillo Zaldivar 2025-07-15 23:39:28 -06:00
commit 0751f272ae
23 changed files with 651 additions and 6 deletions

View File

@ -24,7 +24,8 @@
"Bash(true)",
"Bash(bash:*)",
"Bash(grep:*)",
"Bash(gh pr merge:*)"
"Bash(gh pr merge:*)",
"Bash(git cherry-pick:*)"
],
"deny": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -36,7 +36,6 @@ odoo_command = [
"-d", DB_NAME,
"-i", MODULES_TO_INSTALL,
"--load-language", "es_ES",
"--without-demo=", # Forzar carga de datos demo
"--stop-after-init"
]

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizards

View File

@ -29,9 +29,12 @@
'data/product_category.xml',
'data/sample_types.xml',
'data/lims_sequence.xml',
'data/rejection_reason_data.xml',
'views/partner_views.xml',
'views/analysis_views.xml',
'views/sale_order_views.xml',
'views/rejection_reason_views.xml',
'wizards/sample_rejection_wizard_views.xml',
'views/stock_lot_views.xml',
'views/lims_test_views.xml',
'views/lims_result_views.xml',

View 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>

View File

@ -6,6 +6,7 @@ from . import product
from . import partner
from . import sale_order
from . import stock_lot
from . import rejection_reason
from . import lims_test
from . import lims_result
from . import res_config_settings

View 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!'),
]

View File

@ -82,8 +82,29 @@ class StockLot(models.Model):
('analyzed', 'Analizada'),
('stored', 'Almacenada'),
('disposed', 'Desechada'),
('cancelled', 'Cancelada')
('cancelled', 'Cancelada'),
('rejected', 'Rechazada')
], 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):
"""Mark sample as collected"""
@ -155,6 +176,54 @@ class StockLot(models.Model):
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')
def _onchange_sample_type_product_id(self):
"""Synchronize container_type when sample_type_product_id changes"""

View File

@ -6,6 +6,11 @@ access_product_template_parameter_manager,product.template.parameter.manager,mod
access_lims_parameter_range_user,lims.parameter.range.user,model_lims_parameter_range,base.group_user,1,0,0,0
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_sale_order_line_receptionist,sale.order.line.receptionist,sale.model_sale_order_line,group_lims_receptionist,1,1,1,0
access_sale_order_technician,sale.order.technician,sale.model_sale_order,group_lims_technician,1,0,0,0
access_sale_order_line_technician,sale.order.line.technician,sale.model_sale_order_line,group_lims_technician,1,0,0,0
access_sale_order_admin,sale.order.admin,sale.model_sale_order,group_lims_admin,1,1,1,1
access_sale_order_line_admin,sale.order.line.admin,sale.model_sale_order_line,group_lims_admin,1,1,1,1
access_stock_lot_user,stock.lot.user,stock.model_stock_lot,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
@ -13,3 +18,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_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_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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_lims_parameter_range_user lims.parameter.range.user model_lims_parameter_range base.group_user 1 0 0 0
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_sale_order_line_receptionist sale.order.line.receptionist sale.model_sale_order_line group_lims_receptionist 1 1 1 0
10 access_sale_order_technician sale.order.technician sale.model_sale_order group_lims_technician 1 0 0 0
11 access_sale_order_line_technician sale.order.line.technician sale.model_sale_order_line group_lims_technician 1 0 0 0
12 access_sale_order_admin sale.order.admin sale.model_sale_order group_lims_admin 1 1 1 1
13 access_sale_order_line_admin sale.order.line.admin sale.model_sale_order_line group_lims_admin 1 1 1 1
14 access_stock_lot_user stock.lot.user stock.model_stock_lot base.group_user 1 1 1 1
15 access_lims_test_receptionist lims.test.receptionist model_lims_test group_lims_receptionist 1 0 0 0
16 access_lims_test_technician lims.test.technician model_lims_test group_lims_technician 1 1 1 0
18 access_lims_result_receptionist lims.result.receptionist model_lims_result group_lims_receptionist 1 0 0 0
19 access_lims_result_technician lims.result.technician model_lims_result group_lims_technician 1 1 1 0
20 access_lims_result_admin lims.result.admin model_lims_result group_lims_admin 1 1 1 1
21 access_lims_rejection_reason_user lims.rejection.reason.user model_lims_rejection_reason base.group_user 1 0 0 0
22 access_lims_rejection_reason_technician lims.rejection.reason.technician model_lims_rejection_reason group_lims_technician 1 0 0 0
23 access_lims_rejection_reason_admin lims.rejection.reason.admin model_lims_rejection_reason group_lims_admin 1 1 1 1
24 access_lims_sample_rejection_wizard_user lims.sample.rejection.wizard.user model_lims_sample_rejection_wizard base.group_user 1 1 1 1
25 access_lims_sample_rejection_wizard_technician lims.sample.rejection.wizard.technician model_lims_sample_rejection_wizard group_lims_technician 1 1 1 1

View File

@ -64,7 +64,7 @@
<notebook>
<page string="Resultados">
<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}"
mode="list">
<list string="Resultados" editable="bottom"

View File

@ -102,6 +102,14 @@
action="action_lims_lab_sample"
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 -->
<menuitem
id="lims_menu_laboratory"
@ -265,6 +273,13 @@
action="action_lims_parameter_statistics"
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 -->
<menuitem id="menu_lims_config_settings"
name="Ajustes"

View 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>

View File

@ -15,7 +15,7 @@
<field name="collection_date" string="Fecha de Recolección"/>
<field name="collector_id" string="Recolectado por"/>
<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>
</field>
</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_store" string="Almacenar" type="object" invisible="state not in ['analyzed', 'in_process', 'received']"/>
<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>
<sheet>
<div class="oe_title">
@ -69,10 +75,65 @@
invisible="sample_type_product_id != False"/>
</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>
</form>
</field>
</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', '&gt;=', datetime.datetime.now().strftime('%Y-%m-%d 00:00:00')), ('collection_date', '&lt;=', datetime.datetime.now().strftime('%Y-%m-%d 23:59:59'))]"/>
<filter string="Esta Semana" name="this_week" domain="[('collection_date', '&gt;=', (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>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import sample_rejection_wizard

View 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

View 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
View 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

63
test/check_demo_users.py Normal file
View File

@ -0,0 +1,63 @@
import odoo
import json
def check_demo_users(cr):
"""Verificar si los usuarios demo fueron creados"""
cr.execute("""
SELECT
u.id,
u.login,
u.name,
u.active,
array_agg(g.name) as groups
FROM res_users u
LEFT JOIN res_groups_users_rel rel ON rel.uid = u.id
LEFT JOIN res_groups g ON g.id = rel.gid
WHERE u.login IN ('recepcionista', 'tecnico', 'administrador')
GROUP BY u.id, u.login, u.name, u.active
ORDER BY u.login
""")
users = cr.fetchall()
print("\n=== USUARIOS DEMO CREADOS ===")
print("-" * 60)
if not users:
print("❌ NO se encontraron usuarios demo")
return
for user in users:
user_id, login, name, active, groups = user
status = "✓ Activo" if active else "✗ Inactivo"
print(f"\nUsuario: {login}")
print(f" ID: {user_id}")
print(f" Nombre: {name}")
print(f" Estado: {status}")
print(f" Grupos: {', '.join(groups) if groups[0] else 'Sin grupos'}")
print("\n" + "-" * 60)
print(f"Total usuarios demo encontrados: {len(users)}")
# Verificar contraseñas (solo para confirmar que pueden loguearse)
expected_users = {
'recepcionista': 'Recepcionista Demo',
'tecnico': 'Técnico Demo',
'administrador': 'Administrador Lab Demo'
}
missing = []
for login, expected_name in expected_users.items():
if not any(u[1] == login for u in users):
missing.append(login)
if missing:
print(f"\n⚠️ Usuarios faltantes: {', '.join(missing)}")
else:
print("\n✅ Todos los usuarios demo esperados fueron creados")
if __name__ == '__main__':
db_name = 'lims_demo'
registry = odoo.registry(db_name)
with registry.cursor() as cr:
check_demo_users(cr)