feat(#60): Implementar automatización configurable de re-muestreo

- Agregar modelo de configuración del laboratorio (lims.config.settings)
- Implementar generación automática de re-muestras al rechazar
- Añadir campos de trazabilidad: parent_sample_id, child_sample_ids
- Crear vista de configuración accesible desde menú admin
- Mejorar vistas de stock.lot con información de re-muestreo
- Incluir notificaciones automáticas a recepcionistas
- Configurar límite máximo de re-muestreos por muestra

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Luis Ernesto Portillo Zaldivar 2025-07-16 07:39:43 -06:00
parent 0751f272ae
commit 0cf2e42f7a
10 changed files with 287 additions and 1 deletions

View File

@ -46,6 +46,7 @@
'views/product_template_parameter_config_views.xml',
'views/parameter_dashboard_views.xml',
'views/menus.xml',
'views/lims_config_views.xml',
'report/sample_label_report.xml',
],
'demo': [

View File

@ -10,3 +10,4 @@ from . import rejection_reason
from . import lims_test
from . import lims_result
from . import res_config_settings
from . import lims_config

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class LimsConfig(models.TransientModel):
_name = 'lims.config.settings'
_inherit = 'res.config.settings'
_description = 'Configuración del Laboratorio'
auto_resample_on_rejection = fields.Boolean(
string='Re-muestreo Automático al Rechazar',
help='Si está activo, se generará automáticamente una nueva muestra cuando se rechace una existente',
config_parameter='lims_management.auto_resample_on_rejection',
default=True
)
resample_state = fields.Selection([
('pending_collection', 'Pendiente de Recolección'),
('collected', 'Recolectada'),
], string='Estado Inicial para Re-muestras',
help='Estado en el que se crearán las nuevas muestras generadas por re-muestreo',
config_parameter='lims_management.resample_state',
default='pending_collection'
)
auto_notify_resample = fields.Boolean(
string='Notificar Re-muestreo Automático',
help='Enviar notificación al recepcionista cuando se genera una nueva muestra por re-muestreo',
config_parameter='lims_management.auto_notify_resample',
default=True
)
resample_prefix = fields.Char(
string='Prefijo para Re-muestras',
help='Prefijo que se añadirá al código de las muestras generadas por re-muestreo (ej: RE-)',
config_parameter='lims_management.resample_prefix',
default='RE-'
)
max_resample_attempts = fields.Integer(
string='Máximo de Re-muestreos',
help='Número máximo de veces que se puede re-muestrear una muestra (0 = sin límite)',
config_parameter='lims_management.max_resample_attempts',
default=3
)

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import datetime
import random
@ -105,6 +106,31 @@ class StockLot(models.Model):
string='Fecha de Rechazo',
readonly=True
)
# Re-sampling fields
parent_sample_id = fields.Many2one(
'stock.lot',
string='Muestra Original',
help='Muestra original de la cual esta es un re-muestreo',
domain="[('is_lab_sample', '=', True)]"
)
child_sample_ids = fields.One2many(
'stock.lot',
'parent_sample_id',
string='Re-muestras',
help='Muestras generadas como re-muestreo de esta'
)
resample_count = fields.Integer(
string='Número de Re-muestreo',
help='Indica cuántas veces se ha re-muestreado esta muestra',
compute='_compute_resample_count',
store=True
)
is_resample = fields.Boolean(
string='Es Re-muestra',
compute='_compute_is_resample',
store=True
)
def action_collect(self):
"""Mark sample as collected"""
@ -223,6 +249,27 @@ class StockLot(models.Model):
subject='Muestra Rechazada',
message_type='notification'
)
# Check if automatic resample is enabled
IrConfig = self.env['ir.config_parameter'].sudo()
auto_resample = IrConfig.get_param('lims_management.auto_resample_on_rejection', 'True') == 'True'
if auto_resample:
try:
# Create resample automatically
resample_action = self.action_create_resample()
self.message_post(
body=_('Re-muestra generada automáticamente debido al rechazo'),
subject='Re-muestreo Automático',
message_type='notification'
)
except UserError as e:
# If resample creation fails (e.g., max attempts reached), log it
self.message_post(
body=_('No se pudo generar re-muestra automática: %s') % str(e),
subject='Error en Re-muestreo',
message_type='notification'
)
@api.onchange('sample_type_product_id')
def _onchange_sample_type_product_id(self):
@ -348,3 +395,110 @@ class StockLot(models.Model):
if record.is_lab_sample and not record.barcode:
record.barcode = record._generate_unique_barcode()
return True
@api.depends('parent_sample_id')
def _compute_is_resample(self):
"""Compute if this sample is a resample"""
for record in self:
record.is_resample = bool(record.parent_sample_id)
@api.depends('child_sample_ids')
def _compute_resample_count(self):
"""Compute the number of times this sample has been resampled"""
for record in self:
record.resample_count = len(record.child_sample_ids)
def action_create_resample(self):
"""Create a new sample as a resample of the current one"""
self.ensure_one()
# Get configuration
IrConfig = self.env['ir.config_parameter'].sudo()
auto_resample = IrConfig.get_param('lims_management.auto_resample_on_rejection', 'True') == 'True'
initial_state = IrConfig.get_param('lims_management.resample_state', 'pending_collection')
prefix = IrConfig.get_param('lims_management.resample_prefix', 'RE-')
max_attempts = int(IrConfig.get_param('lims_management.max_resample_attempts', '3'))
# Check maximum resample attempts
if max_attempts > 0 and self.resample_count >= max_attempts:
raise UserError(_('Se ha alcanzado el número máximo de re-muestreos (%d) para esta muestra.') % max_attempts)
# Calculate resample number for naming
resample_number = self.resample_count + 1
# Prepare values for new sample
vals = {
'name': f"{prefix}{self.name}-{resample_number}",
'product_id': self.product_id.id,
'patient_id': self.patient_id.id,
'doctor_id': self.doctor_id.id,
'origin': self.origin,
'sample_type_product_id': self.sample_type_product_id.id,
'volume_ml': self.volume_ml,
'is_lab_sample': True,
'state': initial_state,
'analysis_names': self.analysis_names,
'parent_sample_id': self.id,
'request_id': self.request_id.id if self.request_id else False,
}
# Create the resample
resample = self.create(vals)
# Post message in both samples
self.message_post(
body=_('Re-muestra creada: %s') % resample.name,
subject='Re-muestreo',
message_type='notification'
)
resample.message_post(
body=_('Esta es una re-muestra de: %s<br/>Motivo: %s') %
(self.name, self.rejection_reason_id.name if self.rejection_reason_id else 'No especificado'),
subject='Re-muestra creada',
message_type='notification'
)
# Notify receptionist if configured
auto_notify = IrConfig.get_param('lims_management.auto_notify_resample', 'True') == 'True'
if auto_notify:
self._notify_resample_created(resample)
# If there's a related order, update it
if self.request_id:
self.request_id.message_post(
body=_('Se ha creado una re-muestra (%s) para la muestra rechazada %s') % (resample.name, self.name),
subject='Re-muestra creada',
message_type='notification'
)
# Add the new sample to the order's generated samples
self.request_id.generated_sample_ids = [(4, resample.id)]
return {
'type': 'ir.actions.act_window',
'name': 'Re-muestra Creada',
'res_model': 'stock.lot',
'res_id': resample.id,
'view_mode': 'form',
'target': 'current',
}
def _notify_resample_created(self, resample):
"""Notify receptionist users about the created resample"""
# Find receptionist users
receptionist_group = self.env.ref('lims_management.group_lims_receptionist', raise_if_not_found=False)
if receptionist_group:
receptionist_users = receptionist_group.users
# Create activities for receptionists
for user in receptionist_users:
self.env['mail.activity'].create({
'res_model': 'stock.lot',
'res_id': resample.id,
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': _('Nueva re-muestra pendiente de recolección'),
'note': _('Se ha generado una re-muestra (%s) que requiere recolección. Muestra original: %s') %
(resample.name, self.name),
'user_id': user.id,
'date_deadline': fields.Date.today(),
})

View File

@ -23,3 +23,4 @@ access_lims_rejection_reason_technician,lims.rejection.reason.technician,model_l
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
access_lims_config_settings_admin,lims.config.settings.admin,model_lims_config_settings,group_lims_admin,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
26 access_lims_config_settings_admin lims.config.settings.admin model_lims_config_settings group_lims_admin 1 1 1 1

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Laboratory Configuration Form View -->
<record id="view_lims_config_settings_form" model="ir.ui.view">
<field name="name">lims.config.settings.form</field>
<field name="model">lims.config.settings</field>
<field name="arch" type="xml">
<form string="Configuración del Laboratorio">
<header>
<button string="Guardar" type="object" name="execute" class="oe_highlight"/>
<button string="Cancelar" special="cancel"/>
</header>
<sheet>
<div class="o_form_label">Configuración de Re-muestreo</div>
<group>
<group name="resample_settings" string="Re-muestreo Automático">
<field name="auto_resample_on_rejection"/>
<field name="resample_state" invisible="not auto_resample_on_rejection"/>
<field name="resample_prefix" invisible="not auto_resample_on_rejection"/>
<field name="max_resample_attempts" invisible="not auto_resample_on_rejection"/>
</group>
<group name="notification_settings" string="Notificaciones">
<field name="auto_notify_resample" invisible="not auto_resample_on_rejection"/>
</group>
</group>
<group string="Información">
<div class="text-muted">
<p>El re-muestreo automático permite generar una nueva muestra cuando se rechaza una existente.</p>
<p>Las notificaciones se enviarán a todos los usuarios con rol de Recepcionista.</p>
</div>
</group>
</sheet>
</form>
</field>
</record>
<!-- Action to open laboratory configuration -->
<record id="action_lims_config_settings" model="ir.actions.act_window">
<field name="name">Configuración del Laboratorio</field>
<field name="res_model">lims.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'dialog_size': 'medium'}</field>
</record>
<!-- Menu for Laboratory Configuration -->
<menuitem id="menu_lims_lab_config"
name="Configuración del Laboratorio"
parent="lims_management.lims_menu_config"
action="action_lims_config_settings"
sequence="60"
groups="lims_management.group_lims_admin"/>
</data>
</odoo>

View File

@ -16,6 +16,8 @@
<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-danger="state == 'rejected'" decoration-muted="state == 'stored' or state == 'disposed' or state == 'cancelled'" widget="badge"/>
<field name="is_resample" string="Re-muestra" widget="boolean_toggle" optional="show"/>
<field name="resample_count" string="Re-muestreos" optional="show"/>
</list>
</field>
</record>
@ -39,6 +41,12 @@
class="btn-danger"
invisible="state in ['completed', 'rejected', 'disposed', 'cancelled']"/>
<button name="action_cancel" string="Cancelar" type="object" invisible="state in ['cancelled', 'rejected', 'disposed']"/>
<button name="action_create_resample"
string="Crear Re-muestra"
type="object"
class="btn-primary"
invisible="state != 'rejected' or resample_count >= 3"
confirm="¿Está seguro de que desea crear una re-muestra para esta muestra rechazada?"/>
<field name="state" widget="statusbar" statusbar_visible="pending_collection,collected,received,in_process,analyzed,stored,rejected"/>
</header>
<sheet>
@ -81,6 +89,25 @@
<field name="rejection_date" readonly="1"/>
<field name="rejection_notes" readonly="1" colspan="4"/>
</group>
<notebook>
<page string="Re-muestreo" invisible="not is_resample and resample_count == 0">
<group>
<field name="is_resample" invisible="1"/>
<field name="resample_count" invisible="1"/>
<field name="parent_sample_id" readonly="1" invisible="not is_resample"/>
</group>
<group string="Re-muestras Generadas" invisible="resample_count == 0">
<field name="child_sample_ids" nolabel="1">
<list>
<field name="name"/>
<field name="state" widget="badge"/>
<field name="collection_date"/>
<field name="rejection_reason_id"/>
</list>
</field>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
@ -100,6 +127,8 @@
<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')]"/>
<filter string="Re-muestras" name="resamples" domain="[('is_resample', '=', True)]"/>
<filter string="Con Re-muestras" name="has_resamples" domain="[('resample_count', '>', 0)]"/>
<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'))]"/>
@ -111,6 +140,7 @@
<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'}"/>
<filter string="Es Re-muestra" name="group_resample" context="{'group_by': 'is_resample'}"/>
</group>
</search>
</field>