Merge pull request 'feat(#8): Implementar gestión de pruebas y resultados de laboratorio' (#52) from feature/8-test-results-management into dev

This commit is contained in:
luis_portillo 2025-07-15 07:49:27 +00:00
commit beaed060eb
22 changed files with 1280 additions and 7 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
@ -94,6 +105,12 @@ python gitea_cli_helper.py comment-issue --issue-number 123 --body "Comment text
# Close issue # Close issue
python gitea_cli_helper.py close-issue --issue-number 123 python gitea_cli_helper.py close-issue --issue-number 123
# Get issue details and comments
python gitea_cli_helper.py get-issue --issue-number 8
# List all open issues
python gitea_cli_helper.py list-open-issues
``` ```
## Mandatory Reading ## Mandatory Reading

View File

@ -25,6 +25,7 @@ services:
- ./odoo.conf:/etc/odoo/odoo.conf - ./odoo.conf:/etc/odoo/odoo.conf
- ./init_odoo.py:/app/init_odoo.py - ./init_odoo.py:/app/init_odoo.py
- ./create_lab_requests.py:/app/create_lab_requests.py - ./create_lab_requests.py:/app/create_lab_requests.py
- ./test:/app/test
command: ["/usr/bin/python3", "/app/init_odoo.py"] command: ["/usr/bin/python3", "/app/init_odoo.py"]
environment: environment:
HOST: db HOST: db

View File

@ -0,0 +1,163 @@
# Plan de Implementación - Issue #8: Gestión de Pruebas y Resultados
## Objetivo
Implementar los modelos y la interfaz básica para la gestión de pruebas y resultados de laboratorio, específicamente los modelos `lims.test` y `lims.result` con entrada dinámica de resultados.
## Análisis de Requisitos
### Funcionalidad Esperada (según Issue #8)
1. **Modelo lims.test**: Representar la ejecución de un análisis con estados
2. **Modelo lims.result**: Almacenar cada valor de resultado con soporte para múltiples tipos
3. **Interfaz de entrada dinámica**: Vista formulario con lista editable de resultados
4. **Resaltado visual**: Mostrar en rojo los resultados fuera de rango
5. **Validación opcional**: Permitir configurar si se requiere validación por administrador
### Modelos de Datos Requeridos (según Issue #8)
1. **lims.test**: Representa la ejecución de un análisis
2. **lims.result**: Almacena cada valor de resultado
3. **lims.test.parameter**: Modelo referenciado (asumimos ya existe o se creará)
## Tareas de Implementación
### 1. Crear modelo lims.test
**Archivo:** `lims_management/models/lims_test.py`
- [ ] Definir modelo según especificación del issue:
```python
sale_order_line_id = fields.Many2one('sale.order.line', string='Línea de Orden')
patient_id = fields.Many2one('res.partner', string='Paciente',
related='sale_order_line_id.order_id.partner_id')
product_id = fields.Many2one('product.product', string='Análisis',
related='sale_order_line_id.product_id')
sample_id = fields.Many2one('stock.lot', string='Muestra')
state = fields.Selection([
('draft', 'Borrador'),
('in_process', 'En Proceso'),
('result_entered', 'Resultado Ingresado'),
('validated', 'Validado'),
('cancelled', 'Cancelado')
], string='Estado', default='draft')
validator_id = fields.Many2one('res.users', string='Validador')
validation_date = fields.Datetime(string='Fecha de Validación')
require_validation = fields.Boolean(string='Requiere Validación',
compute='_compute_require_validation')
```
- [ ] Implementar _compute_require_validation basado en configuración
- [ ] Agregar métodos de transición de estados
### 2. Crear modelo lims.result
**Archivo:** `lims_management/models/lims_result.py`
- [ ] Definir modelo según especificación:
```python
test_id = fields.Many2one('lims.test', string='Prueba', required=True, ondelete='cascade')
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([], string='Valor de Selección')
is_out_of_range = fields.Boolean(string='Fuera de Rango', compute='_compute_is_out_of_range')
notes = fields.Text(string='Notas del Técnico')
```
- [ ] Implementar _compute_is_out_of_range para detectar valores anormales
- [ ] Agregar validación para asegurar que solo un tipo de valor esté lleno
### 3. Desarrollar interfaz de ingreso de resultados
**Archivo:** `lims_management/views/lims_test_views.xml`
- [ ] Crear vista formulario para lims.test con:
- Información de cabecera (paciente, análisis, muestra)
- Lista editable (One2many) de lims.result
- Campos dinámicos según parámetros del análisis
- [ ] Implementar widget o CSS para resaltar en rojo valores fuera de rango
- [ ] Agregar botones de acción según estado
### 4. Implementar lógica visual para valores fuera de rango
**Archivo:** `lims_management/static/src/` (CSS/JS)
- [ ] Crear CSS para clase .out-of-range con color rojo
- [ ] Implementar widget o computed field que aplique la clase
- [ ] Asegurar que funcione en vista formulario y lista
### 5. Agregar configuración de validación opcional
**Archivo:** `lims_management/models/res_config_settings.py`
- [ ] Agregar campo booleano lims_require_validation
- [ ] Extender res.config.settings para incluir esta configuración
- [ ] Modificar lims.test para usar esta configuración en flujo de trabajo
### 6. Crear vistas básicas
**Archivo:** `lims_management/views/lims_test_views.xml`
- [ ] Vista lista de pruebas con campos básicos
- [ ] Vista kanban agrupada por estado
- [ ] Menú de acceso en Laboratorio > Pruebas
### 7. Crear datos de demostración básicos
**Archivo:** `lims_management/demo/lims_test_demo.xml`
- [ ] Crear algunos registros lims.test de ejemplo
- [ ] Agregar resultados de demostración
- [ ] Incluir casos con valores dentro y fuera de rango
## Consideraciones Técnicas
### Performance
- Usar compute fields con store=True para is_out_of_range
- Carga eficiente de parámetros relacionados
### Usabilidad
- Interfaz clara para entrada de resultados
- Feedback visual inmediato para valores fuera de rango
- Navegación intuitiva entre estados
### Validación de Datos
- Solo un tipo de valor debe estar lleno por resultado
- Validar que el parámetro corresponda al análisis
- Estados coherentes con el flujo de trabajo
## Flujo de Trabajo
```mermaid
graph TD
A[Línea de Orden] --> B[Crear lims.test]
B --> C[Estado: draft]
C --> D[Estado: in_process]
D --> E[Técnico Ingresa Resultados]
E --> F[Estado: result_entered]
F --> G{¿Requiere Validación?}
G -->|Sí| H[Esperar Validación]
G -->|No| I[Proceso Completo]
H --> J[Estado: validated]
```
## Criterios de Aceptación (según Issue #8)
1. [ ] Modelo lims.test creado con todos los campos especificados
2. [ ] Modelo lims.result creado con soporte para múltiples tipos de valor
3. [ ] Interfaz de formulario con lista editable de resultados
4. [ ] Valores fuera de rango se muestran en rojo
5. [ ] La validación por administrador es configurable
6. [ ] Los campos relacionados (patient_id, product_id) funcionan correctamente
## Estimación de Tiempo
- Tarea 1: 2 horas (modelo lims.test)
- Tarea 2: 1.5 horas (modelo lims.result)
- Tarea 3: 2 horas (interfaz de entrada)
- Tarea 4: 1 hora (lógica visual)
- Tarea 5: 1 hora (configuración)
- Tareas 6-7: 1.5 horas (vistas y demo)
**Total estimado: 9 horas**
## Dependencias
- Issue #31: Configuración inicial del módulo ✓
- Issue #32: Generación automática de muestras ✓
- Modelo lims.test.parameter (debe existir o crearse)
- Módulos de Odoo: sale, stock
## Riesgos y Mitigaciones
1. **Riesgo**: El modelo lims.test.parameter no está definido
- **Mitigación**: Crear modelo básico o usar product.product temporalmente
2. **Riesgo**: Complejidad en la detección de valores fuera de rango
- **Mitigación**: Implementar lógica simple inicialmente
3. **Riesgo**: Integración con flujo existente de órdenes
- **Mitigación**: Crear pruebas manualmente en primera versión

View File

@ -110,6 +110,60 @@ def close_issue(issue_number):
_make_gitea_request("PATCH", endpoint, payload) _make_gitea_request("PATCH", endpoint, payload)
print(f"Issue #{issue_number} cerrado exitosamente.") print(f"Issue #{issue_number} cerrado exitosamente.")
def get_issue_details(issue_number):
"""Gets details and comments for a specific issue."""
# Get issue details
endpoint = f"repos/{GITEA_USERNAME}/{GITEA_REPO_NAME}/issues/{issue_number}"
print(f"Obteniendo detalles del issue #{issue_number}...")
try:
issue = _make_gitea_request("GET", endpoint)
# Display issue information
print(f"\n{'=' * 80}")
print(f"Issue #{issue.get('number', 'N/A')}: {issue.get('title', 'Sin título')}")
print(f"{'=' * 80}")
print(f"Estado: {'Abierto' if issue.get('state') == 'open' else 'Cerrado'}")
print(f"Autor: {issue.get('user', {}).get('login', 'Desconocido')}")
print(f"Creado: {issue.get('created_at', '').replace('T', ' ').split('+')[0] if issue.get('created_at') else 'N/A'}")
if issue.get('closed_at'):
print(f"Cerrado: {issue.get('closed_at', '').replace('T', ' ').split('+')[0]}")
labels = [label.get('name', '') for label in issue.get('labels', [])]
if labels:
print(f"Etiquetas: {', '.join(labels)}")
print(f"URL: {issue.get('html_url', 'N/A')}")
print(f"\nDescripción:")
print("-" * 40)
print(issue.get('body', 'Sin descripción'))
# Get comments
comments_endpoint = f"{endpoint}/comments"
comments = _make_gitea_request("GET", comments_endpoint)
if comments:
print(f"\nComentarios ({len(comments)}):")
print("-" * 40)
for i, comment in enumerate(comments, 1):
author = comment.get('user', {}).get('login', 'Desconocido')
created = comment.get('created_at', '').replace('T', ' ').split('+')[0] if comment.get('created_at') else 'N/A'
body = comment.get('body', '')
print(f"\nComentario {i} - {author} ({created}):")
print(body)
if i < len(comments):
print("-" * 40)
else:
print(f"\nNo hay comentarios en este issue.")
print(f"\n{'=' * 80}\n")
except Exception as e:
print(f"Error al obtener el issue #{issue_number}: {e}")
def list_open_issues(): def list_open_issues():
"""Lists all open issues in the repository.""" """Lists all open issues in the repository."""
endpoint = f"repos/{GITEA_USERNAME}/{GITEA_REPO_NAME}/issues" endpoint = f"repos/{GITEA_USERNAME}/{GITEA_REPO_NAME}/issues"
@ -223,6 +277,10 @@ def main():
# Subparser para listar issues abiertos # Subparser para listar issues abiertos
list_issues_parser = subparsers.add_parser("list-open-issues", help="Lista todos los issues abiertos del repositorio.") list_issues_parser = subparsers.add_parser("list-open-issues", help="Lista todos los issues abiertos del repositorio.")
# Subparser para obtener detalles de un issue
get_issue_parser = subparsers.add_parser("get-issue", help="Obtiene detalles y comentarios de un issue específico.")
get_issue_parser.add_argument("--issue-number", type=int, required=True, help="Número del issue a consultar.")
args = parser.parse_args() args = parser.parse_args()
@ -238,6 +296,8 @@ def main():
merge_pull_request(args.pr_number, args.merge_method) merge_pull_request(args.pr_number, args.merge_method)
elif args.command == "list-open-issues": elif args.command == "list-open-issues":
list_open_issues() list_open_issues()
elif args.command == "get-issue":
get_issue_details(args.issue_number)
else: else:
parser.print_help() parser.print_help()

View File

@ -94,6 +94,40 @@ EOF
sys.exit(result.returncode) sys.exit(result.returncode)
print("Solicitudes de laboratorio de demostración creadas exitosamente.") print("Solicitudes de laboratorio de demostración creadas exitosamente.")
# --- Crear datos de demostración de pruebas ---
print("\nCreando datos de demostración de pruebas de laboratorio...")
sys.stdout.flush()
if os.path.exists("/app/test/create_test_demo_data.py"):
with open("/app/test/create_test_demo_data.py", "r") as f:
test_script_content = f.read()
create_tests_command = f"""
odoo shell -c {ODOO_CONF} -d {DB_NAME} <<'EOF'
{test_script_content}
EOF
"""
result = subprocess.run(
create_tests_command,
shell=True,
capture_output=True,
text=True,
check=False
)
print("--- Create Test Demo Data stdout ---")
print(result.stdout)
print("--- Create Test Demo Data stderr ---")
print(result.stderr)
sys.stdout.flush()
if result.returncode == 0:
print("Datos de demostración de pruebas creados exitosamente.")
else:
print(f"Advertencia: Fallo al crear datos de demostración de pruebas (código {result.returncode})")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:

View File

@ -16,17 +16,25 @@
'website': "https://gitea.grupoconsiti.com/luis_portillo/clinical_laboratory", 'website': "https://gitea.grupoconsiti.com/luis_portillo/clinical_laboratory",
'category': 'Industries', 'category': 'Industries',
'version': '18.0.1.0.0', 'version': '18.0.1.0.0',
'depends': ['base', 'product', 'sale'], 'depends': ['base', 'product', 'sale', 'base_setup'],
'assets': {
'web.assets_backend': [
'lims_management/static/src/css/lims_test.css',
],
},
'data': [ 'data': [
'security/lims_security.xml', 'security/lims_security.xml',
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'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',
'views/stock_lot_views.xml', 'views/stock_lot_views.xml',
'views/lims_test_views.xml',
'views/res_config_settings_views.xml',
'views/menus.xml', 'views/menus.xml',
], ],
'demo': [ 'demo': [

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,6 @@ 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
from . import res_config_settings

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,247 @@
# -*- 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), ('patient_id', '=', patient_id), ('state', 'in', ['collected', 'in_analysis'])]",
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."""
IrConfig = self.env['ir.config_parameter'].sudo()
require_validation = IrConfig.get_param('lims_management.require_validation', 'True')
for record in self:
record.require_validation = require_validation == 'True'
@api.onchange('sale_order_line_id')
def _onchange_sale_order_line(self):
"""Update sample domain when order line changes"""
if self.sale_order_line_id:
# Try to find a suitable sample from the order
order = self.sale_order_line_id.order_id
product = self.sale_order_line_id.product_id
if order.is_lab_request and product.required_sample_type_id:
# Find samples for this patient with the required sample type
suitable_samples = self.env['stock.lot'].search([
('is_lab_sample', '=', True),
('patient_id', '=', order.partner_id.id),
('sample_type_product_id', '=', product.required_sample_type_id.id),
('state', 'in', ['collected', 'in_analysis'])
])
if suitable_samples:
# If only one sample, select it automatically
if len(suitable_samples) == 1:
self.sample_id = suitable_samples[0]
# Update domain to show only suitable samples
return {
'domain': {
'sample_id': [
('id', 'in', suitable_samples.ids)
]
}
}
@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.'))
# Si no requiere validación, pasar directamente a validado
if not self.require_validation:
self.write({
'state': 'validated',
'validator_id': self.env.user.id,
'validation_date': fields.Datetime.now()
})
self.message_post(
body=_('Resultados ingresados y auto-validados por %s') % self.env.user.name,
subject=_('Resultados Validados')
)
else:
self.state = 'result_entered'
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

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
lims_require_validation = fields.Boolean(
string='Requerir Validación de Resultados',
help='Si está activado, los resultados de las pruebas deben ser validados por un administrador antes de considerarse finales.',
config_parameter='lims_management.require_validation',
default=True
)
lims_auto_generate_tests = fields.Boolean(
string='Generar Pruebas Automáticamente',
help='Si está activado, se generarán automáticamente registros de pruebas (lims.test) cuando se confirme una orden de laboratorio.',
config_parameter='lims_management.auto_generate_tests',
default=False
)

View File

@ -34,20 +34,21 @@ class SaleOrder(models.Model):
) )
def action_confirm(self): def action_confirm(self):
"""Override to generate laboratory samples automatically""" """Override to generate laboratory samples and tests automatically"""
res = super(SaleOrder, self).action_confirm() res = super(SaleOrder, self).action_confirm()
# Generate samples only for laboratory requests # Generate samples and tests only for laboratory requests
for order in self.filtered('is_lab_request'): for order in self.filtered('is_lab_request'):
try: try:
order._generate_lab_samples() order._generate_lab_samples()
order._generate_lab_tests()
except Exception as e: except Exception as e:
_logger.error(f"Error generating samples for order {order.name}: {str(e)}") _logger.error(f"Error generating samples/tests for order {order.name}: {str(e)}")
# Continue with order confirmation even if sample generation fails # Continue with order confirmation even if generation fails
# But notify the user # But notify the user
order.message_post( order.message_post(
body=_("Error al generar muestras automáticamente: %s. " body=_("Error al generar muestras/pruebas automáticamente: %s. "
"Por favor, genere las muestras manualmente.") % str(e), "Por favor, genere las muestras y pruebas manualmente.") % str(e),
message_type='notification' message_type='notification'
) )
@ -163,3 +164,71 @@ class SaleOrder(models.Model):
raise UserError( raise UserError(
_("Error al crear muestra para %s: %s") % (sample_type.name, str(e)) _("Error al crear muestra para %s: %s") % (sample_type.name, str(e))
) )
def _generate_lab_tests(self):
"""Generate laboratory tests for analysis order lines"""
self.ensure_one()
_logger.info(f"Generating laboratory tests for order {self.name}")
# Get the test model
TestModel = self.env['lims.test']
created_tests = TestModel.browse()
# Create a test for each analysis line
for line in self.order_line:
if not line.product_id.is_analysis:
continue
# Find appropriate sample for this analysis
sample = self._find_sample_for_analysis(line.product_id)
if not sample:
_logger.warning(
f"No sample found for analysis {line.product_id.name} in order {self.name}"
)
self.message_post(
body=_("Advertencia: No se encontró muestra para el análisis '%s'") % line.product_id.name,
message_type='notification'
)
continue
# Create the test
try:
test = TestModel.create({
'sale_order_line_id': line.id,
'sample_id': sample.id,
})
created_tests |= test
_logger.info(f"Created test {test.name} for analysis {line.product_id.name}")
except Exception as e:
_logger.error(f"Error creating test for {line.product_id.name}: {str(e)}")
self.message_post(
body=_("Error al crear prueba para '%s': %s") % (line.product_id.name, str(e)),
message_type='notification'
)
# Post message with created tests
if created_tests:
test_list = "<ul>"
for test in created_tests:
test_list += f"<li>{test.name} - {test.product_id.name}</li>"
test_list += "</ul>"
self.message_post(
body=_("Pruebas generadas automáticamente: %s") % test_list,
message_type='notification'
)
_logger.info(f"Created {len(created_tests)} tests for order {self.name}")
def _find_sample_for_analysis(self, product):
"""Find the appropriate sample for an analysis product"""
# Check if the analysis has a required sample type
if not product.required_sample_type_id:
return False
# Find a generated sample with matching sample type
for sample in self.generated_sample_ids:
if sample.sample_type_product_id.id == product.required_sample_type_id.id:
return sample
return False

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

View File

@ -0,0 +1,21 @@
/* Estilos para pruebas de laboratorio LIMS */
/* Resaltar valores fuera de rango con decoration-danger */
.o_list_view .o_data_row td[name="value_numeric"].text-danger,
.o_list_view .o_data_row td[name="value_numeric"] .text-danger {
color: #dc3545 !important;
font-weight: bold;
}
/* Asegurar que funcione con el decoration-danger de Odoo 18 */
.o_list_renderer tbody tr td.o_list_number.text-danger,
.o_list_renderer tbody tr td .o_field_number.text-danger {
color: #dc3545 !important;
font-weight: bold;
}
/* Para campos en vista formulario también */
.o_form_sheet .o_field_widget[name="value_numeric"].text-danger input {
color: #dc3545 !important;
font-weight: bold;
}

View File

@ -0,0 +1,213 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Vista formulario para lims.test -->
<record id="view_lims_test_form" model="ir.ui.view">
<field name="name">lims.test.form</field>
<field name="model">lims.test</field>
<field name="arch" type="xml">
<form string="Prueba de Laboratorio">
<header>
<button name="action_start_process" string="Iniciar Proceso"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_enter_results" string="Marcar Resultados Ingresados"
type="object" class="oe_highlight"
invisible="state != 'in_process'"/>
<button name="action_validate" string="Validar Resultados"
type="object" class="oe_highlight"
invisible="state != 'result_entered' or not require_validation"/>
<button name="action_cancel" string="Cancelar"
type="object"
invisible="state in ['validated', 'cancelled']"/>
<button name="action_draft" string="Volver a Borrador"
type="object"
invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,in_process,result_entered,validated"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="sale_order_line_id" invisible="1"/>
<field name="patient_id"/>
<field name="product_id"/>
<field name="sample_id"
options="{'no_create': True}"
domain="[('is_lab_sample', '=', True), ('patient_id', '=', patient_id)]"/>
</group>
<group>
<field name="technician_id" readonly="state != 'draft'"/>
<field name="require_validation" invisible="1"/>
<field name="validator_id" readonly="1" invisible="not validator_id"/>
<field name="validation_date" readonly="1" invisible="not validation_date"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Resultados">
<field name="result_ids"
readonly="state in ['validated', 'cancelled']"
context="{'default_test_id': id}">
<list string="Resultados" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="parameter_name"/>
<field name="value_numeric"
invisible="value_text or value_selection"
decoration-danger="is_out_of_range"/>
<field name="value_text"
invisible="value_numeric or value_selection"/>
<field name="value_selection"
invisible="value_numeric or value_text"/>
<field name="unit" optional="show"/>
<field name="normal_min" optional="hide"/>
<field name="normal_max" optional="hide"/>
<field name="is_out_of_range" invisible="1"/>
<field name="notes" optional="show"/>
</list>
</field>
</page>
<page string="Observaciones">
<field name="notes" placeholder="Agregar observaciones generales de la prueba..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Vista lista para lims.test -->
<record id="view_lims_test_list" model="ir.ui.view">
<field name="name">lims.test.list</field>
<field name="model">lims.test</field>
<field name="arch" type="xml">
<list string="Pruebas de Laboratorio">
<field name="name"/>
<field name="patient_id"/>
<field name="product_id"/>
<field name="sample_id"/>
<field name="technician_id" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'validated'"
decoration-warning="state == 'result_entered'"
decoration-info="state == 'in_process'"
decoration-muted="state == 'cancelled'"/>
<field name="create_date" optional="hide"/>
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
</list>
</field>
</record>
<!-- Vista kanban para lims.test -->
<record id="view_lims_test_kanban" model="ir.ui.view">
<field name="name">lims.test.kanban</field>
<field name="model">lims.test</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<field name="name"/>
<field name="patient_id"/>
<field name="product_id"/>
<field name="state"/>
<field name="technician_id"/>
<field name="create_date"/>
<templates>
<t t-name="kanban-card">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="oe_kanban_content">
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
</div>
</div>
<div class="o_kanban_record_body">
<div>
<i class="fa fa-user" title="Paciente"/>
<field name="patient_id"/>
</div>
<div>
<i class="fa fa-flask" title="Análisis"/>
<field name="product_id"/>
</div>
<div t-if="record.technician_id.raw_value">
<i class="fa fa-user-md" title="Técnico"/>
<field name="technician_id"/>
</div>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<field name="create_date" widget="date"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Vista búsqueda para lims.test -->
<record id="view_lims_test_search" model="ir.ui.view">
<field name="name">lims.test.search</field>
<field name="model">lims.test</field>
<field name="arch" type="xml">
<search string="Buscar Pruebas">
<field name="name"/>
<field name="patient_id"/>
<field name="product_id"/>
<field name="sample_id"/>
<field name="technician_id"/>
<separator/>
<filter string="Borrador" name="draft" domain="[('state','=','draft')]"/>
<filter string="En Proceso" name="in_process" domain="[('state','=','in_process')]"/>
<filter string="Resultado Ingresado" name="result_entered" domain="[('state','=','result_entered')]"/>
<filter string="Validado" name="validated" domain="[('state','=','validated')]"/>
<separator/>
<filter string="Mis Pruebas" name="my_tests" domain="[('technician_id','=',uid)]"/>
<separator/>
<filter string="Hoy" name="today" domain="[('create_date','&gt;=',(datetime.datetime.now().replace(hour=0, minute=0, second=0)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
<group expand="0" string="Agrupar Por">
<filter string="Estado" name="group_by_state" context="{'group_by':'state'}"/>
<filter string="Paciente" name="group_by_patient" context="{'group_by':'patient_id'}"/>
<filter string="Análisis" name="group_by_product" context="{'group_by':'product_id'}"/>
<filter string="Técnico" name="group_by_technician" context="{'group_by':'technician_id'}"/>
<filter string="Fecha" name="group_by_date" context="{'group_by':'create_date:day'}"/>
</group>
</search>
</field>
</record>
<!-- Acción para lims.test -->
<record id="action_lims_test" model="ir.actions.act_window">
<field name="name">Pruebas de Laboratorio</field>
<field name="res_model">lims.test</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_lims_test_search"/>
<field name="context">{'search_default_my_tests': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Crear primera prueba de laboratorio
</p>
<p>
Aquí podrá gestionar las pruebas de laboratorio,
ingresar resultados y validarlos.
</p>
</field>
</record>
</data>
</odoo>

View File

@ -101,6 +101,13 @@
parent="lims_menu_root" parent="lims_menu_root"
action="action_lims_lab_sample" action="action_lims_lab_sample"
sequence="16"/> sequence="16"/>
<!-- Menú para Pruebas -->
<menuitem id="menu_lims_tests"
name="Pruebas"
parent="lims_menu_root"
action="action_lims_test"
sequence="25"/>
<!-- Submenú de Configuración --> <!-- Submenú de Configuración -->
<menuitem <menuitem
@ -160,5 +167,12 @@
parent="lims_menu_config" parent="lims_menu_config"
action="action_lims_sample_type_catalog" action="action_lims_sample_type_catalog"
sequence="20"/> sequence="20"/>
<!-- Menú de configuración de ajustes -->
<menuitem id="menu_lims_config_settings"
name="Ajustes"
parent="lims_menu_config"
action="lims_management.action_lims_config_settings"
sequence="100"/>
</data> </data>
</odoo> </odoo>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Vista formulario heredada para res.config.settings -->
<record id="res_config_settings_view_form_lims" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.lims</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Laboratorio" string="Laboratorio" name="lims_management">
<block title="Configuración del Laboratorio" name="lims_settings">
<setting help="Si está activado, los resultados de las pruebas deben ser validados por un administrador">
<field name="lims_require_validation"/>
</setting>
<setting help="Si está activado, se generarán automáticamente registros de pruebas al confirmar órdenes">
<field name="lims_auto_generate_tests"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
<!-- Acción para abrir configuración de laboratorio -->
<record id="action_lims_config_settings" model="ir.actions.act_window">
<field name="name">Configuración</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module' : 'lims_management'}</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
import odoo
from datetime import datetime, timedelta
def create_test_demo_data(cr):
"""Crea datos de demostración para lims.test y lims.result"""
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
# Buscar algunos pacientes y análisis existentes
patients = env['res.partner'].search([('is_patient', '=', True)], limit=3)
if not patients:
print("No se encontraron pacientes para crear pruebas de demostración")
return
# Buscar análisis disponibles
hemograma = env.ref('lims_management.analysis_hemograma', raise_if_not_found=False)
glucosa = env.ref('lims_management.analysis_glucosa', raise_if_not_found=False)
if not hemograma or not glucosa:
print("No se encontraron análisis de demostración")
return
# Buscar o crear una orden de laboratorio simple
lab_order = env['sale.order'].search([
('is_lab_request', '=', True),
('state', '=', 'sale')
], limit=1)
if not lab_order:
# Crear una orden básica si no existe
lab_order = env['sale.order'].create({
'partner_id': patients[0].id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': hemograma.product_variant_id.id,
'product_uom_qty': 1
}), (0, 0, {
'product_id': glucosa.product_variant_id.id,
'product_uom_qty': 1
})]
})
lab_order.action_confirm()
# Obtener las líneas de orden
order_lines = lab_order.order_line
if not order_lines:
print("No se encontraron líneas de orden")
return
# Buscar muestras existentes
samples = env['stock.lot'].search([
('is_lab_sample', '=', True),
('patient_id', '=', lab_order.partner_id.id)
], limit=2)
if not samples:
print("No se encontraron muestras de laboratorio")
return
# Crear prueba 1: Hemograma en proceso
test1 = env['lims.test'].create({
'sale_order_line_id': order_lines[0].id,
'sample_id': samples[0].id,
'state': 'draft'
})
# Iniciar proceso
test1.action_start_process()
# Crear resultados para hemograma
results_data = [
{
'test_id': test1.id,
'parameter_name': 'Glóbulos Rojos',
'sequence': 10,
'value_numeric': 4.5,
'unit': '10^6/µL',
'normal_min': 4.2,
'normal_max': 5.4
},
{
'test_id': test1.id,
'parameter_name': 'Glóbulos Blancos',
'sequence': 20,
'value_numeric': 12.5, # Fuera de rango
'unit': '10^3/µL',
'normal_min': 4.5,
'normal_max': 11.0,
'notes': 'Valor elevado - posible infección'
},
{
'test_id': test1.id,
'parameter_name': 'Hemoglobina',
'sequence': 30,
'value_numeric': 14.2,
'unit': 'g/dL',
'normal_min': 12.0,
'normal_max': 16.0
},
{
'test_id': test1.id,
'parameter_name': 'Plaquetas',
'sequence': 40,
'value_numeric': 250,
'unit': '10^3/µL',
'normal_min': 150,
'normal_max': 400
}
]
for result_data in results_data:
env['lims.result'].create(result_data)
print(f"Creada prueba {test1.name} con 4 resultados")
# Crear prueba 2: Glucosa con resultado ingresado
if len(order_lines) > 1:
test2 = env['lims.test'].create({
'sale_order_line_id': order_lines[1].id,
'sample_id': samples[0].id,
'state': 'draft'
})
test2.action_start_process()
# Crear resultado de glucosa
env['lims.result'].create({
'test_id': test2.id,
'parameter_name': 'Glucosa en Ayunas',
'sequence': 10,
'value_numeric': 125, # Fuera de rango
'unit': 'mg/dL',
'normal_min': 70,
'normal_max': 110,
'notes': 'Valor elevado - prediabetes'
})
# Marcar resultados como ingresados
test2.action_enter_results()
print(f"Creada prueba {test2.name} con resultado ingresado")
# Crear prueba 3: Uroanálisis con valores mixtos (si hay más pacientes)
if len(patients) > 1 and len(samples) > 1:
# Crear una orden adicional
urine_analysis = env['product.template'].search([
('is_analysis', '=', True),
('name', 'ilike', 'orina')
], limit=1)
if urine_analysis:
lab_order2 = env['sale.order'].create({
'partner_id': patients[1].id,
'is_lab_request': True,
'order_line': [(0, 0, {
'product_id': urine_analysis.product_variant_id.id,
'product_uom_qty': 1
})]
})
lab_order2.action_confirm()
test3 = env['lims.test'].create({
'sale_order_line_id': lab_order2.order_line[0].id,
'sample_id': samples[1].id,
'state': 'draft'
})
test3.action_start_process()
# Crear resultados mixtos
urine_results = [
{
'test_id': test3.id,
'parameter_name': 'Color',
'sequence': 10,
'value_text': 'Amarillo claro'
},
{
'test_id': test3.id,
'parameter_name': 'pH',
'sequence': 20,
'value_numeric': 6.5,
'normal_min': 4.6,
'normal_max': 8.0
},
{
'test_id': test3.id,
'parameter_name': 'Densidad',
'sequence': 30,
'value_numeric': 1.020,
'normal_min': 1.005,
'normal_max': 1.030
},
{
'test_id': test3.id,
'parameter_name': 'Proteínas',
'sequence': 40,
'value_text': 'Negativo'
},
{
'test_id': test3.id,
'parameter_name': 'Glucosa',
'sequence': 50,
'value_text': 'Negativo'
}
]
for result_data in urine_results:
env['lims.result'].create(result_data)
# Ingresar y validar resultados
test3.action_enter_results()
if test3.state == 'result_entered':
test3.action_validate()
print(f"Creada prueba {test3.name} validada con resultados mixtos")
print("\nDatos de demostración de pruebas creados exitosamente")
if __name__ == '__main__':
db_name = 'lims_demo'
registry = odoo.registry(db_name)
with registry.cursor() as cr:
create_test_demo_data(cr)
cr.commit()