diff --git a/documents/logs/Screenshot_4.png b/documents/logs/Screenshot_4.png new file mode 100644 index 0000000..f136098 Binary files /dev/null and b/documents/logs/Screenshot_4.png differ diff --git a/documents/logs/Screenshot_5.png b/documents/logs/Screenshot_5.png new file mode 100644 index 0000000..a9b985a Binary files /dev/null and b/documents/logs/Screenshot_5.png differ diff --git a/init_odoo.py b/init_odoo.py index a10b3c9..db77305 100644 --- a/init_odoo.py +++ b/init_odoo.py @@ -189,6 +189,39 @@ EOF else: print(f"Advertencia: Fallo al actualizar logo de empresa (código {result.returncode})") + # --- Asignar admin al grupo de Administrador de Laboratorio --- + print("\nAsignando usuario admin al grupo de Administrador de Laboratorio...") + sys.stdout.flush() + + if os.path.exists("/app/scripts/assign_admin_to_lab_group.py"): + with open("/app/scripts/assign_admin_to_lab_group.py", "r") as f: + admin_group_script = f.read() + + assign_admin_command = f""" + odoo shell -c {ODOO_CONF} -d {DB_NAME} <<'EOF' +{admin_group_script} +EOF + """ + + result = subprocess.run( + assign_admin_command, + shell=True, + capture_output=True, + text=True, + check=False + ) + + print("--- Assign Admin to Lab Group stdout ---") + print(result.stdout) + print("--- Assign Admin to Lab Group stderr ---") + print(result.stderr) + sys.stdout.flush() + + if result.returncode == 0: + print("Usuario admin asignado exitosamente al grupo de Administrador de Laboratorio.") + else: + print(f"Advertencia: Fallo al asignar admin al grupo (código {result.returncode})") + # --- Validación final del logo --- print("\nValidando estado final del logo y nombre...") sys.stdout.flush() 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 9160d9a..9e096b9 100644 Binary files a/lims_management/__pycache__/__init__.cpython-312.pyc and b/lims_management/__pycache__/__init__.cpython-312.pyc differ diff --git a/lims_management/models/__init__.py b/lims_management/models/__init__.py index 7110907..73d5fd3 100644 --- a/lims_management/models/__init__.py +++ b/lims_management/models/__init__.py @@ -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 diff --git a/lims_management/models/__pycache__/__init__.cpython-312.pyc b/lims_management/models/__pycache__/__init__.cpython-312.pyc index fc0d06e..caac40f 100644 Binary files a/lims_management/models/__pycache__/__init__.cpython-312.pyc and b/lims_management/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/lims_management/models/__pycache__/sale_order.cpython-312.pyc b/lims_management/models/__pycache__/sale_order.cpython-312.pyc index 7492287..d4ff865 100644 Binary files a/lims_management/models/__pycache__/sale_order.cpython-312.pyc and b/lims_management/models/__pycache__/sale_order.cpython-312.pyc differ diff --git a/lims_management/models/__pycache__/stock_lot.cpython-312.pyc b/lims_management/models/__pycache__/stock_lot.cpython-312.pyc index 8e1cd01..120db73 100644 Binary files a/lims_management/models/__pycache__/stock_lot.cpython-312.pyc and b/lims_management/models/__pycache__/stock_lot.cpython-312.pyc differ 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/sale_order.py b/lims_management/models/sale_order.py index 61c6558..4e4bea2 100644 --- a/lims_management/models/sale_order.py +++ b/lims_management/models/sale_order.py @@ -33,6 +33,31 @@ class SaleOrder(models.Model): help="Muestras de laboratorio generadas automáticamente cuando se confirmó esta orden" ) + all_sample_ids = fields.Many2many( + 'stock.lot', + string='Todas las Muestras (inc. Re-muestras)', + compute='_compute_all_samples', + help="Todas las muestras relacionadas con esta orden, incluyendo re-muestras" + ) + + @api.depends('generated_sample_ids', 'generated_sample_ids.child_sample_ids') + def _compute_all_samples(self): + """Compute all samples including resamples""" + for order in self: + all_samples = order.generated_sample_ids + # Add all resamples recursively + resamples = self.env['stock.lot'] + for sample in order.generated_sample_ids: + resamples |= self._get_all_resamples(sample) + order.all_sample_ids = all_samples | resamples + + def _get_all_resamples(self, sample): + """Recursively get all resamples of a sample""" + resamples = sample.child_sample_ids + for resample in sample.child_sample_ids: + resamples |= self._get_all_resamples(resample) + return resamples + def action_confirm(self): """Override to generate laboratory samples and tests automatically""" res = super(SaleOrder, self).action_confirm() @@ -295,17 +320,22 @@ class SaleOrder(models.Model): return res def action_print_sample_labels(self): - """Imprimir etiquetas de todas las muestras generadas para esta orden""" + """Imprimir etiquetas de todas las muestras activas (incluyendo re-muestras)""" self.ensure_one() - if not self.generated_sample_ids: - raise UserError(_('No hay muestras generadas para esta orden. Por favor, confirme la orden primero.')) + # Obtener todas las muestras activas (no rechazadas ni canceladas) + active_samples = self.all_sample_ids.filtered( + lambda s: s.state not in ['rejected', 'cancelled', 'disposed'] + ) + + if not active_samples: + raise UserError(_('No hay muestras activas para imprimir. Todas las muestras están rechazadas, canceladas o desechadas.')) # Asegurar que todas las muestras tengan código de barras - self.generated_sample_ids._ensure_barcode() + active_samples._ensure_barcode() # Obtener el reporte report = self.env.ref('lims_management.action_report_sample_label') - # Retornar la acción de imprimir el reporte para todas las muestras - return report.report_action(self.generated_sample_ids) + # Retornar la acción de imprimir el reporte para las muestras activas + return report.report_action(active_samples) diff --git a/lims_management/models/stock_lot.py b/lims_management/models/stock_lot.py index ee44c78..dfe9ef6 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,43 @@ 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 + ) + root_sample_id = fields.Many2one( + 'stock.lot', + string='Muestra Original (Raíz)', + compute='_compute_root_sample', + store=True, + help='Muestra original de la cadena de re-muestreos' + ) + resample_chain_count = fields.Integer( + string='Re-muestreos en Cadena', + compute='_compute_resample_chain_count', + help='Número total de re-muestreos en toda la cadena' + ) def action_collect(self): """Mark sample as collected""" @@ -190,8 +228,12 @@ class StockLot(models.Model): } } - def action_reject(self): - """Reject the sample - to be called from wizard""" + def action_reject(self, create_resample=None): + """Reject the sample - to be called from wizard + + Args: + create_resample: Boolean to force resample creation. If None, uses system config + """ self.ensure_one() if self.state == 'completed': raise ValueError('No se puede rechazar una muestra ya completada') @@ -223,6 +265,35 @@ class StockLot(models.Model): subject='Muestra Rechazada', message_type='notification' ) + + # Determine if we should create a resample + should_create_resample = False + + if create_resample is not None: + # Explicit value from wizard + should_create_resample = create_resample + else: + # Check system configuration + IrConfig = self.env['ir.config_parameter'].sudo() + auto_resample = IrConfig.get_param('lims_management.auto_resample_on_rejection', 'True') == 'True' + should_create_resample = auto_resample + + if should_create_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 +419,177 @@ 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) + + @api.depends('parent_sample_id') + def _compute_root_sample(self): + """Compute the root sample of the resample chain""" + for record in self: + root = record + while root.parent_sample_id: + root = root.parent_sample_id + record.root_sample_id = root if root != record else False + + @api.depends('parent_sample_id', 'child_sample_ids') + def _compute_resample_chain_count(self): + """Compute total resamples in the entire chain""" + for record in self: + # Find root sample + root = record + while root.parent_sample_id: + root = root.parent_sample_id + # Count all resamples from root + record.resample_chain_count = self._count_all_resamples_in_chain(root) + + def action_create_resample(self): + """Create a new sample as a resample of the current one""" + self.ensure_one() + + # Determine the parent sample for the new resample + # If current sample is already a resample, use its parent + # Otherwise, use the current sample as parent + parent_for_resample = self.parent_sample_id if self.parent_sample_id else self + + # Check if there's already an active resample for the parent + active_resamples = parent_for_resample.child_sample_ids.filtered( + lambda s: s.state not in ['rejected', 'cancelled', 'disposed'] + ) + if active_resamples: + raise UserError(_('La muestra %s ya tiene una re-muestra activa (%s). No se puede crear otra hasta que se procese o rechace la existente.') % + (parent_for_resample.name, ', '.join(active_resamples.mapped('name')))) + + # 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')) + + # Find the original sample (root of the resample chain) + original_sample = parent_for_resample + while original_sample.parent_sample_id: + original_sample = original_sample.parent_sample_id + + # Count all resamples in the chain + total_resamples = self._count_all_resamples_in_chain(original_sample) + + # Check maximum resample attempts based on the entire chain + if max_attempts > 0 and total_resamples >= max_attempts: + raise UserError(_('Se ha alcanzado el número máximo de re-muestreos (%d) para esta cadena de muestras.') % max_attempts) + + # Calculate resample number for naming (based on parent's resample count) + resample_number = len(parent_for_resample.child_sample_ids) + 1 + + # Prepare values for new sample + vals = { + 'name': f"{prefix}{parent_for_resample.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': parent_for_resample.id, # Always use the determined parent + 'request_id': self.request_id.id if self.request_id else False, + } + + # Create the resample + resample = self.create(vals) + + # Post message in all relevant samples + self.message_post( + body=_('Re-muestra creada: %s') % resample.name, + subject='Re-muestreo', + message_type='notification' + ) + + if self != parent_for_resample: + # If we're creating from a resample, also notify the parent + parent_for_resample.message_post( + body=_('Nueva re-muestra creada: %s (debido al rechazo de %s)') % (resample.name, self.name), + subject='Re-muestreo', + message_type='notification' + ) + + resample.message_post( + body=_('Esta es una re-muestra de: %s
Creada debido al rechazo de: %s
Motivo: %s') % + (parent_for_resample.name, 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 _count_all_resamples_in_chain(self, root_sample): + """Count all resamples in the entire chain starting from root""" + count = 0 + samples_to_check = [root_sample] + + while samples_to_check: + sample = samples_to_check.pop(0) + # Add all child samples to the check list + for child in sample.child_sample_ids: + count += 1 + samples_to_check.append(child) + + return count + + 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 + + # Get the model id for stock.lot + model_id = self.env['ir.model'].search([('model', '=', 'stock.lot')], limit=1).id + + # Create activities for receptionists + for user in receptionist_users: + self.env['mail.activity'].create({ + 'res_model': 'stock.lot', + 'res_model_id': model_id, # Campo obligatorio + '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/sale_order_views.xml b/lims_management/views/sale_order_views.xml index b2b5020..58ee2cd 100644 --- a/lims_management/views/sale_order_views.xml +++ b/lims_management/views/sale_order_views.xml @@ -14,7 +14,7 @@ string="Imprimir Etiquetas" type="object" class="btn-primary" - invisible="not is_lab_request or state != 'sale' or not generated_sample_ids" + invisible="not is_lab_request or state != 'sale' or not all_sample_ids" icon="fa-print"/> @@ -29,26 +29,42 @@ - - - - + + + + - + - - - + + + + + +