diff --git a/CLAUDE.md b/CLAUDE.md
index 064b638..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
@@ -94,6 +105,12 @@ python gitea_cli_helper.py comment-issue --issue-number 123 --body "Comment text
# Close issue
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
diff --git a/docker-compose.yml b/docker-compose.yml
index 51252a8..87c8b34 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,6 +25,7 @@ services:
- ./odoo.conf:/etc/odoo/odoo.conf
- ./init_odoo.py:/app/init_odoo.py
- ./create_lab_requests.py:/app/create_lab_requests.py
+ - ./test:/app/test
command: ["/usr/bin/python3", "/app/init_odoo.py"]
environment:
HOST: db
diff --git a/documents/plans/ISSUE8_PLAN.md b/documents/plans/ISSUE8_PLAN.md
new file mode 100644
index 0000000..40edc6c
--- /dev/null
+++ b/documents/plans/ISSUE8_PLAN.md
@@ -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
\ No newline at end of file
diff --git a/gitea_cli_helper.py b/gitea_cli_helper.py
index 4049758..97a2412 100644
--- a/gitea_cli_helper.py
+++ b/gitea_cli_helper.py
@@ -110,6 +110,60 @@ def close_issue(issue_number):
_make_gitea_request("PATCH", endpoint, payload)
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():
"""Lists all open issues in the repository."""
endpoint = f"repos/{GITEA_USERNAME}/{GITEA_REPO_NAME}/issues"
@@ -223,6 +277,10 @@ def main():
# Subparser para listar issues abiertos
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()
@@ -238,6 +296,8 @@ def main():
merge_pull_request(args.pr_number, args.merge_method)
elif args.command == "list-open-issues":
list_open_issues()
+ elif args.command == "get-issue":
+ get_issue_details(args.issue_number)
else:
parser.print_help()
diff --git a/init_odoo.py b/init_odoo.py
index 7ab37ef..923571a 100644
--- a/init_odoo.py
+++ b/init_odoo.py
@@ -94,6 +94,40 @@ EOF
sys.exit(result.returncode)
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)
except Exception as e:
diff --git a/lims_management/__manifest__.py b/lims_management/__manifest__.py
index 27c5248..3aecd8d 100644
--- a/lims_management/__manifest__.py
+++ b/lims_management/__manifest__.py
@@ -16,17 +16,25 @@
'website': "https://gitea.grupoconsiti.com/luis_portillo/clinical_laboratory",
'category': 'Industries',
'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': [
'security/lims_security.xml',
'security/ir.model.access.csv',
'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',
'views/stock_lot_views.xml',
+ 'views/lims_test_views.xml',
+ 'views/res_config_settings_views.xml',
'views/menus.xml',
],
'demo': [
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..78eb9f2 100644
--- a/lims_management/models/__init__.py
+++ b/lims_management/models/__init__.py
@@ -4,3 +4,6 @@ from . import product
from . import partner
from . import sale_order
from . import stock_lot
+from . import lims_test
+from . import lims_result
+from . import res_config_settings
diff --git a/lims_management/models/__pycache__/__init__.cpython-312.pyc b/lims_management/models/__pycache__/__init__.cpython-312.pyc
index c0fed3a..8a440fc 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..b4b6816 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..a098d79
--- /dev/null
+++ b/lims_management/models/lims_test.py
@@ -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
\ No newline at end of file
diff --git a/lims_management/models/res_config_settings.py b/lims_management/models/res_config_settings.py
new file mode 100644
index 0000000..ed0ddcb
--- /dev/null
+++ b/lims_management/models/res_config_settings.py
@@ -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
+ )
\ No newline at end of file
diff --git a/lims_management/models/sale_order.py b/lims_management/models/sale_order.py
index 4f2227f..5024cb5 100644
--- a/lims_management/models/sale_order.py
+++ b/lims_management/models/sale_order.py
@@ -34,20 +34,21 @@ class SaleOrder(models.Model):
)
def action_confirm(self):
- """Override to generate laboratory samples automatically"""
+ """Override to generate laboratory samples and tests automatically"""
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'):
try:
order._generate_lab_samples()
+ order._generate_lab_tests()
except Exception as e:
- _logger.error(f"Error generating samples for order {order.name}: {str(e)}")
- # Continue with order confirmation even if sample generation fails
+ _logger.error(f"Error generating samples/tests for order {order.name}: {str(e)}")
+ # Continue with order confirmation even if generation fails
# But notify the user
order.message_post(
- body=_("Error al generar muestras automáticamente: %s. "
- "Por favor, genere las muestras manualmente.") % str(e),
+ body=_("Error al generar muestras/pruebas automáticamente: %s. "
+ "Por favor, genere las muestras y pruebas manualmente.") % str(e),
message_type='notification'
)
@@ -163,3 +164,71 @@ class SaleOrder(models.Model):
raise UserError(
_("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 = "
"
+ for test in created_tests:
+ test_list += f"- {test.name} - {test.product_id.name}
"
+ test_list += "
"
+
+ 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
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
diff --git a/lims_management/static/src/css/lims_test.css b/lims_management/static/src/css/lims_test.css
new file mode 100644
index 0000000..f0ef335
--- /dev/null
+++ b/lims_management/static/src/css/lims_test.css
@@ -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;
+}
\ No newline at end of file
diff --git a/lims_management/views/lims_test_views.xml b/lims_management/views/lims_test_views.xml
new file mode 100644
index 0000000..4588e15
--- /dev/null
+++ b/lims_management/views/lims_test_views.xml
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+ lims.test.form
+ lims.test
+
+
+
+
+
+
+
+ lims.test.list
+ lims.test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ lims.test.kanban
+ lims.test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ lims.test.search
+ lims.test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pruebas de Laboratorio
+ lims.test
+ list,kanban,form
+
+ {'search_default_my_tests': 1}
+
+
+ Crear primera prueba de laboratorio
+
+
+ Aquí podrá gestionar las pruebas de laboratorio,
+ ingresar resultados y validarlos.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lims_management/views/menus.xml b/lims_management/views/menus.xml
index c7f58cd..e8e789b 100644
--- a/lims_management/views/menus.xml
+++ b/lims_management/views/menus.xml
@@ -101,6 +101,13 @@
parent="lims_menu_root"
action="action_lims_lab_sample"
sequence="16"/>
+
+
+
+
+
+
diff --git a/lims_management/views/res_config_settings_views.xml b/lims_management/views/res_config_settings_views.xml
new file mode 100644
index 0000000..1e7e15a
--- /dev/null
+++ b/lims_management/views/res_config_settings_views.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ res.config.settings.view.form.inherit.lims
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configuración
+ ir.actions.act_window
+ res.config.settings
+ form
+ inline
+ {'module' : 'lims_management'}
+
+
+
+
\ No newline at end of file
diff --git a/test/create_test_demo_data.py b/test/create_test_demo_data.py
new file mode 100644
index 0000000..4952c24
--- /dev/null
+++ b/test/create_test_demo_data.py
@@ -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()
\ No newline at end of file