From 0cf2e42f7aefdc23f0ece9dd8ac009aee43b47b1 Mon Sep 17 00:00:00 2001 From: Luis Ernesto Portillo Zaldivar Date: Wed, 16 Jul 2025 07:39:43 -0600 Subject: [PATCH 1/7] =?UTF-8?q?feat(#60):=20Implementar=20automatizaci?= =?UTF-8?q?=C3=B3n=20configurable=20de=20re-muestreo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lims_management/__manifest__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 209 -> 209 bytes lims_management/models/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 564 -> 599 bytes .../__pycache__/stock_lot.cpython-312.pyc | Bin 14341 -> 20990 bytes lims_management/models/lims_config.py | 44 +++++ lims_management/models/stock_lot.py | 156 +++++++++++++++++- lims_management/security/ir.model.access.csv | 1 + lims_management/views/lims_config_views.xml | 55 ++++++ lims_management/views/stock_lot_views.xml | 30 ++++ 10 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 lims_management/models/lims_config.py create mode 100644 lims_management/views/lims_config_views.xml diff --git a/lims_management/__manifest__.py b/lims_management/__manifest__.py index ca286f3..86435d5 100644 --- a/lims_management/__manifest__.py +++ b/lims_management/__manifest__.py @@ -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': [ diff --git a/lims_management/__pycache__/__init__.cpython-312.pyc b/lims_management/__pycache__/__init__.cpython-312.pyc index 9160d9a6f0c2b097c38378fa202761479edf44a4..9e096b93bde7dba23ac55b25b26a18be82d5238e 100644 GIT binary patch delta 18 Ycmcb}c#)CoG%qg~0}u#WPvkld04yy8OaK4? delta 18 Ycmcb}c#)CoG%qg~0}$-doXB+?053cQWukhgKng<&PYy#aPZSR$LnW^!-^49Pgftm% zapz>_7RM*&=cQ$)`)Tq{u4c4kD&m{Gk#QpuyqJMq8~W|%6pI9aTmXhk5Pbju diff --git a/lims_management/models/__pycache__/stock_lot.cpython-312.pyc b/lims_management/models/__pycache__/stock_lot.cpython-312.pyc index 8e1cd0131210198ae74f2b98601755983cd729c6..5e39c5ffea3f7209d0190bf8e13e2a2f9d1f7c22 100644 GIT binary patch delta 7816 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 delta 2177 zcmai#YfKbZ6vyYz?hMNUyTHP-z%DGX0=q7#JmewhA_@eRVDQz|1}DrQBfB%pok7JH zlF(LbYKymz#3r>(KOs#?vq?>prb(OHnA$Y9Nt-xL`=Q!2_LEWhLYmrpX0X~cshjZ2 zx#ynqzh~|}_wv{K=!KN)Mox~Cp}*qj(J{~3IhU_}4;{V5h>VC5OcKRWl8v)TTik}g z$0qDad)#i?wgeaFSf-Z|?X!%?N$6sIL1e)OY77(4n6icWyI_WG2A@<#DWMtss4OK! z&9Dh6dG50+_I2Awx9_kWV$&=WFB6+7%f`zoMjd)lZl7LO*AfI#_fT)zL?mxz*^Jd=BFZ~VmFPf6Sq*- zi(4tzO!b6&4abnKj*M?t_2oyu{j~?rwV3F1-YKpZe=zSNA{3qx`>cnNV>eAHa z&!cAYj=RoN0-~i9hFuYo(maa;m_z-*-$nCG$S?*Pvxz(49Z2E#524K^zn#AW1BUKAZA(fnPe?j1|BhKzeQ z2cBr+t^ZB@+S86^Xkvg=7PPDsRbA+}1ZqiNVRz;K3Q$GEM{XAS$hE>Mbe<}$AwKV_ z6(wg;zNHi)l|`GLQOfC}TJ(ll%9lltOS$eVvIL^!H=p?g$PoSXyXqG0z7uqE8FlW$)3BgDA&*PTXcgX1ept6{^*Ch0g~lpC0l-(t@4kqbyBrK> zDZ&V!BikBE`ppxBX22UM42P!UQC&_-7*5ULHA%oDW7bMGS<#>Rs1yWnvMwd*vrN;- zVneGBPRmlt5(QlvPGMYv=QZ{WeY4DV$=|GGVxcX+=m(X|iVatY$r#BW6jvq+>c3L46o- z5YP?i0rUa-0owrsfI+|kz!(5d(Qv5B$e5rUlCTE8rIe(I8qQ*vItHJCiA#X1 z6!VOE53t!=4)6lt=(Qm>9UHib*!xa$VX(~Y;GFjhvMKyQ&UJ4K`FL>W^n+ZEE3gD0 zE$lGts;H_M-Vp0@o4sXr(%aN$a68~Xx48zs0YO0kEWYgMphrBJkT&9P=@4Eet%~BF l4I%V96Z(z`{mcY@VsakZ_i?D;?2%Vbp36SD#K6E*`5R3;4cq_# diff --git a/lims_management/models/lims_config.py b/lims_management/models/lims_config.py new file mode 100644 index 0000000..e9295dc --- /dev/null +++ b/lims_management/models/lims_config.py @@ -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 + ) \ No newline at end of file diff --git a/lims_management/models/stock_lot.py b/lims_management/models/stock_lot.py index ee44c78..5cd2659 100644 --- a/lims_management/models/stock_lot.py +++ b/lims_management/models/stock_lot.py @@ -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
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(), + }) diff --git a/lims_management/security/ir.model.access.csv b/lims_management/security/ir.model.access.csv index fbe8000..4a122e9 100644 --- a/lims_management/security/ir.model.access.csv +++ b/lims_management/security/ir.model.access.csv @@ -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 diff --git a/lims_management/views/lims_config_views.xml b/lims_management/views/lims_config_views.xml new file mode 100644 index 0000000..723bd71 --- /dev/null +++ b/lims_management/views/lims_config_views.xml @@ -0,0 +1,55 @@ + + + + + + lims.config.settings.form + lims.config.settings + +
+
+
+ +
Configuración de Re-muestreo
+ + + + + + + + + + + + +
+

El re-muestreo automático permite generar una nueva muestra cuando se rechaza una existente.

+

Las notificaciones se enviarán a todos los usuarios con rol de Recepcionista.

+
+
+
+
+
+
+ + + + Configuración del Laboratorio + lims.config.settings + form + inline + {'dialog_size': 'medium'} + + + + +
+
\ No newline at end of file diff --git a/lims_management/views/stock_lot_views.xml b/lims_management/views/stock_lot_views.xml index 35dce0c..ec33607 100644 --- a/lims_management/views/stock_lot_views.xml +++ b/lims_management/views/stock_lot_views.xml @@ -16,6 +16,8 @@ + + @@ -39,6 +41,12 @@ class="btn-danger" invisible="state in ['completed', 'rejected', 'disposed', 'cancelled']"/>