diff --git a/CLAUDE.md b/CLAUDE.md index 182134f..48df957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,17 @@ After successful installation/update, the instance must remain active for user v 4. Only proceed to next task if no errors are found 5. If errors are found, fix them before continuing +### Development Workflow per Task +When implementing issues with multiple tasks, follow this workflow for EACH task: +1. **Stop instance**: `docker-compose down -v` +2. **Implement the task**: Make code changes +3. **Start instance**: `docker-compose up -d` (timeout: 300000ms) +4. **Validate logs**: Check for errors in initialization +5. **Commit & Push**: `git add -A && git commit -m "feat(#X): Task description" && git push` +6. **Comment on issue**: Update issue with task completion +7. **Mark task completed**: Update todo list +8. **Proceed to next task**: Only if no errors found + ### Database Operations #### Direct PostgreSQL Access diff --git a/lims_management/__manifest__.py b/lims_management/__manifest__.py index 27c5248..7ce03dc 100644 --- a/lims_management/__manifest__.py +++ b/lims_management/__manifest__.py @@ -23,6 +23,7 @@ 'data/ir_sequence.xml', 'data/product_category.xml', 'data/sample_types.xml', + 'data/lims_sequence.xml', 'views/partner_views.xml', 'views/analysis_views.xml', 'views/sale_order_views.xml', diff --git a/lims_management/data/lims_sequence.xml b/lims_management/data/lims_sequence.xml new file mode 100644 index 0000000..8179dbc --- /dev/null +++ b/lims_management/data/lims_sequence.xml @@ -0,0 +1,15 @@ + + + + + + + Secuencia de Pruebas de Laboratorio + lims.test + LAB-%(year)s- + 5 + + + + + \ No newline at end of file diff --git a/lims_management/models/__init__.py b/lims_management/models/__init__.py index 5b09e9e..a49eb6c 100644 --- a/lims_management/models/__init__.py +++ b/lims_management/models/__init__.py @@ -4,3 +4,5 @@ from . import product from . import partner from . import sale_order from . import stock_lot +from . import lims_test +from . import lims_result diff --git a/lims_management/models/__pycache__/__init__.cpython-312.pyc b/lims_management/models/__pycache__/__init__.cpython-312.pyc index c0fed3a..f3a9a8e 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__/product.cpython-312.pyc b/lims_management/models/__pycache__/product.cpython-312.pyc index 59f71a2..a2b65f2 100644 Binary files a/lims_management/models/__pycache__/product.cpython-312.pyc and b/lims_management/models/__pycache__/product.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 8e54302..50afd7a 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 051c734..5556171 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_result.py b/lims_management/models/lims_result.py new file mode 100644 index 0000000..a123c25 --- /dev/null +++ b/lims_management/models/lims_result.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +import logging + +_logger = logging.getLogger(__name__) + + +class LimsResult(models.Model): + _name = 'lims.result' + _description = 'Resultado de Prueba de Laboratorio' + _rec_name = 'display_name' + _order = 'test_id, sequence' + + display_name = fields.Char( + string='Nombre', + compute='_compute_display_name', + store=True + ) + + test_id = fields.Many2one( + 'lims.test', + string='Prueba', + required=True, + ondelete='cascade' + ) + + # Por ahora, estos campos básicos + parameter_name = fields.Char( + string='Parámetro', + required=True + ) + + sequence = fields.Integer( + string='Secuencia', + default=10 + ) + + # TODO: Implementar parameter_id cuando exista lims.test.parameter + # parameter_id = fields.Many2one( + # 'lims.test.parameter', + # string='Parámetro' + # ) + + value_numeric = fields.Float( + string='Valor Numérico' + ) + + value_text = fields.Char( + string='Valor de Texto' + ) + + value_selection = fields.Selection( + [], # Por ahora vacío + string='Valor de Selección' + ) + + is_out_of_range = fields.Boolean( + string='Fuera de Rango', + compute='_compute_is_out_of_range', + store=True + ) + + notes = fields.Text( + string='Notas del Técnico' + ) + + # Campos para rangos normales (temporal) + normal_min = fields.Float( + string='Valor Normal Mínimo' + ) + + normal_max = fields.Float( + string='Valor Normal Máximo' + ) + + unit = fields.Char( + string='Unidad' + ) + + @api.depends('test_id', 'parameter_name') + def _compute_display_name(self): + """Calcula el nombre a mostrar.""" + for record in self: + if record.test_id and record.parameter_name: + record.display_name = f"{record.test_id.name} - {record.parameter_name}" + else: + record.display_name = record.parameter_name or _('Nuevo') + + @api.depends('value_numeric', 'normal_min', 'normal_max') + def _compute_is_out_of_range(self): + """Determina si el valor está fuera del rango normal.""" + for record in self: + if record.value_numeric and (record.normal_min or record.normal_max): + if record.normal_min and record.value_numeric < record.normal_min: + record.is_out_of_range = True + elif record.normal_max and record.value_numeric > record.normal_max: + record.is_out_of_range = True + else: + record.is_out_of_range = False + else: + record.is_out_of_range = False + + @api.constrains('value_numeric', 'value_text', 'value_selection') + def _check_single_value_type(self): + """Asegura que solo un tipo de valor esté lleno.""" + for record in self: + filled_values = 0 + if record.value_numeric: + filled_values += 1 + if record.value_text: + filled_values += 1 + if record.value_selection: + filled_values += 1 + + if filled_values > 1: + raise ValidationError( + _('Solo se puede ingresar un tipo de valor (numérico, texto o selección) por resultado.') + ) + + if filled_values == 0: + raise ValidationError( + _('Debe ingresar al menos un valor para el resultado.') + ) \ No newline at end of file diff --git a/lims_management/models/lims_test.py b/lims_management/models/lims_test.py new file mode 100644 index 0000000..cdd8464 --- /dev/null +++ b/lims_management/models/lims_test.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import logging + +_logger = logging.getLogger(__name__) + + +class LimsTest(models.Model): + _name = 'lims.test' + _description = 'Prueba de Laboratorio' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _rec_name = 'name' + _order = 'create_date desc' + + name = fields.Char( + string='Código de Prueba', + required=True, + readonly=True, + copy=False, + default='Nuevo' + ) + + sale_order_line_id = fields.Many2one( + 'sale.order.line', + string='Línea de Orden', + required=True, + ondelete='restrict' + ) + + patient_id = fields.Many2one( + 'res.partner', + string='Paciente', + related='sale_order_line_id.order_id.partner_id', + store=True, + readonly=True + ) + + product_id = fields.Many2one( + 'product.product', + string='Análisis', + related='sale_order_line_id.product_id', + store=True, + readonly=True + ) + + sample_id = fields.Many2one( + 'stock.lot', + string='Muestra', + domain="[('is_lab_sample', '=', True)]", + tracking=True + ) + + state = fields.Selection([ + ('draft', 'Borrador'), + ('in_process', 'En Proceso'), + ('result_entered', 'Resultado Ingresado'), + ('validated', 'Validado'), + ('cancelled', 'Cancelado') + ], string='Estado', default='draft', tracking=True) + + validator_id = fields.Many2one( + 'res.users', + string='Validador', + readonly=True, + tracking=True + ) + + validation_date = fields.Datetime( + string='Fecha de Validación', + readonly=True, + tracking=True + ) + + technician_id = fields.Many2one( + 'res.users', + string='Técnico', + default=lambda self: self.env.user, + tracking=True + ) + + require_validation = fields.Boolean( + string='Requiere Validación', + compute='_compute_require_validation', + store=True + ) + + result_ids = fields.One2many( + 'lims.result', + 'test_id', + string='Resultados' + ) + + notes = fields.Text( + string='Observaciones' + ) + + company_id = fields.Many2one( + 'res.company', + string='Compañía', + required=True, + default=lambda self: self.env.company + ) + + @api.depends('company_id') + def _compute_require_validation(self): + """Calcula si la prueba requiere validación basado en configuración.""" + for record in self: + # Por ahora, siempre requiere validación + # TODO: Implementar cuando se agregue res.config.settings + record.require_validation = True + + @api.model_create_multi + def create(self, vals_list): + """Genera código único al crear.""" + for vals in vals_list: + if vals.get('name', 'Nuevo') == 'Nuevo': + vals['name'] = self.env['ir.sequence'].next_by_code('lims.test') or 'Nuevo' + return super().create(vals_list) + + def action_start_process(self): + """Inicia el proceso de análisis.""" + self.ensure_one() + if self.state != 'draft': + raise UserError(_('Solo se pueden procesar pruebas en estado borrador.')) + if not self.sample_id: + raise UserError(_('Debe asignar una muestra antes de iniciar el proceso.')) + + self.write({ + 'state': 'in_process', + 'technician_id': self.env.user.id + }) + + # Log en el chatter + self.message_post( + body=_('Prueba iniciada por %s') % self.env.user.name, + subject=_('Proceso Iniciado') + ) + + return True + + def action_enter_results(self): + """Marca como resultados ingresados.""" + self.ensure_one() + if self.state != 'in_process': + raise UserError(_('Solo se pueden ingresar resultados en pruebas en proceso.')) + + if not self.result_ids: + raise UserError(_('Debe ingresar al menos un resultado.')) + + self.state = 'result_entered' + + # Log en el chatter + self.message_post( + body=_('Resultados ingresados por %s') % self.env.user.name, + subject=_('Resultados Ingresados') + ) + + return True + + def action_validate(self): + """Valida los resultados (solo administradores).""" + self.ensure_one() + if self.state != 'result_entered': + raise UserError(_('Solo se pueden validar pruebas con resultados ingresados.')) + + # TODO: Verificar permisos cuando se implemente seguridad + + self.write({ + 'state': 'validated', + 'validator_id': self.env.user.id, + 'validation_date': fields.Datetime.now() + }) + + # Log en el chatter + self.message_post( + body=_('Resultados validados por %s') % self.env.user.name, + subject=_('Resultados Validados') + ) + + return True + + def action_cancel(self): + """Cancela la prueba.""" + self.ensure_one() + if self.state == 'validated': + raise UserError(_('No se pueden cancelar pruebas validadas.')) + + self.state = 'cancelled' + + # Log en el chatter + self.message_post( + body=_('Prueba cancelada por %s') % self.env.user.name, + subject=_('Prueba Cancelada') + ) + + return True + + def action_draft(self): + """Regresa a borrador.""" + self.ensure_one() + if self.state not in ['cancelled']: + raise UserError(_('Solo se pueden regresar a borrador pruebas canceladas.')) + + self.state = 'draft' + + return True \ No newline at end of file diff --git a/lims_management/security/ir.model.access.csv b/lims_management/security/ir.model.access.csv index 9d946ac..b2d9cb9 100644 --- a/lims_management/security/ir.model.access.csv +++ b/lims_management/security/ir.model.access.csv @@ -2,3 +2,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_lims_analysis_range_user,lims.analysis.range.user,model_lims_analysis_range,base.group_user,1,1,1,1 access_sale_order_receptionist,sale.order.receptionist,sale.model_sale_order,group_lims_receptionist,1,1,1,0 access_stock_lot_user,stock.lot.user,stock.model_stock_lot,base.group_user,1,1,1,1 +access_lims_test_user,lims.test.user,model_lims_test,base.group_user,1,1,1,1 +access_lims_result_user,lims.result.user,model_lims_result,base.group_user,1,1,1,1