feat(#8): Task 1 y 2 completadas - Crear modelos lims.test y lims.result

- Modelo lims.test con todos los campos especificados
- Modelo lims.result con soporte para múltiples tipos de valor
- Secuencia automática para códigos de prueba
- Flujo de estados: draft -> in_process -> result_entered -> validated
- Validación de un solo tipo de valor por resultado
- Permisos de seguridad configurados
This commit is contained in:
Luis Ernesto Portillo Zaldivar 2025-07-15 00:36:58 -06:00
parent 7e2dfb6465
commit a1b8f7b1de
11 changed files with 362 additions and 0 deletions

View File

@ -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 4. Only proceed to next task if no errors are found
5. If errors are found, fix them before continuing 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 ### Database Operations
#### Direct PostgreSQL Access #### Direct PostgreSQL Access

View File

@ -23,6 +23,7 @@
'data/ir_sequence.xml', 'data/ir_sequence.xml',
'data/product_category.xml', 'data/product_category.xml',
'data/sample_types.xml', 'data/sample_types.xml',
'data/lims_sequence.xml',
'views/partner_views.xml', 'views/partner_views.xml',
'views/analysis_views.xml', 'views/analysis_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Secuencia para lims.test -->
<record id="seq_lims_test" model="ir.sequence">
<field name="name">Secuencia de Pruebas de Laboratorio</field>
<field name="code">lims.test</field>
<field name="prefix">LAB-%(year)s-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@ -4,3 +4,5 @@ from . import product
from . import partner from . import partner
from . import sale_order from . import sale_order
from . import stock_lot from . import stock_lot
from . import lims_test
from . import lims_result

View File

@ -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.')
)

View File

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

View File

@ -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_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_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_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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_lims_analysis_range_user lims.analysis.range.user model_lims_analysis_range base.group_user 1 1 1 1
3 access_sale_order_receptionist sale.order.receptionist sale.model_sale_order group_lims_receptionist 1 1 1 0
4 access_stock_lot_user stock.lot.user stock.model_stock_lot base.group_user 1 1 1 1
5 access_lims_test_user lims.test.user model_lims_test base.group_user 1 1 1 1
6 access_lims_result_user lims.result.user model_lims_result base.group_user 1 1 1 1