From a9ed1a23bdabbeab094873d7a42cceefd231dd4d Mon Sep 17 00:00:00 2001 From: Luis Ernesto Portillo Zaldivar Date: Mon, 14 Jul 2025 22:29:29 -0600 Subject: [PATCH] feat(#32): Add automatic sample generation - Task 1 completed - Added generated_sample_ids field to sale.order model - Override action_confirm() to intercept lab order confirmation - Implemented _generate_lab_samples() main logic method - Implemented _group_analyses_by_sample_type() for grouping - Implemented _create_sample_for_group() for sample creation - Added necessary fields to stock.lot model (doctor_id, origin, volume_ml, analysis_names) - Updated state field to include 'pending_collection' state - Added proper error handling and user notifications via message_post - Successful test with ephemeral instance restart --- .../__pycache__/product.cpython-312.pyc | Bin 2960 -> 2960 bytes .../__pycache__/sale_order.cpython-312.pyc | Bin 914 -> 7160 bytes .../__pycache__/stock_lot.cpython-312.pyc | Bin 4359 -> 5021 bytes lims_management/models/sale_order.py | 148 +++++++++++++++++- lims_management/models/stock_lot.py | 23 +++ pr_description_issue44.txt | 37 +++++ 6 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 pr_description_issue44.txt diff --git a/lims_management/models/__pycache__/product.cpython-312.pyc b/lims_management/models/__pycache__/product.cpython-312.pyc index 945c75b16eac0f33cb5386ca9878493a03ba04e2..7c7c8226d8bd29196aa4b427262850bdb6a78d9e 100644 GIT binary patch delta 19 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 delta 342 zcmexiK8c<0G%qg~0}#~jDb3Jhn#d=iVh7|+XGmd4Va#F3WsG9XWr|{AWME=&XGmdc zVMt+4Wz1rm{E|>mP!^))|VjtnoPIYa`RJCbBb@VrDdk(q!cHEgkgXU%J`hY zz%cPsI!h(DCeP;0j1r6@>_C}YY$^G6NicG#xV&5RF$H(sESY`QH6pcl2);byX*|k!g{tH zds8Jsat>4tJpk!e;#3JZprQnc$~P`lvQ(+!0;jI_ALxNggwlK8tR3VEv4?Npy!UR;-%`g7<~==TSoAomB9YJ^FeWXQ;FY9}l>lC;tvl4$s9 zu085TGD@<$+D@pQTR9Z)Cb}8zn2eFbUA)vg{V*ACbP)#v;_%ys6^Y~?Ao3o`llONg z-ZVN$3OA&cAu(eGh?s_r7wHwsDXY_sOlh)U*2=^->NYP-jW70R5`!0z}OrjB)N>1_oj6k84bvdwjv!?dyR0tMyZ zMQe2(R~`6qeIv1KTTYcSJXy7h8@Yo~-LC7FaOvxxjF_xaHf+nHM%}cTlLpTVA}zK{_%2eY zQo}G?P3Epg9-=W9%O=9^Px2gEa9_x)eN&*}V)yv8i()DF%jijT-n|v=RK#rzDi`95 zBAfwmU&-g%y8J;k<^CDVqb2uMtfLdRv*XC)lk!)Aws|l98$zetvwgQvsp<7SMI*CN z@S{uY8neG=#V9`|h@T0VbssARvfQLfpy4(3mxka51V7PPw9U&V@#F0qA;R#PRh|1X z99_j^eq`RV^|~Kl3y!K>wR}})y0s2hq0ICut?^*>$As?*0UrqXP{2n5A^?8OX2z<{ zDs+OM5y{U19!WeSV)0~n^Z>*=t~K1c_NmRC#lLuUATDL6z7}cv{v|PC!23_ez7~cb4y#wt=7VlsU)` z%9~#RU&(IG|D70pnHYU7#m3~len}g;*Y||%$C5GS)6KcpDfeRkHB@&0=pUZB4Ba8o hVHRMo2O;#2lz%DZ_rnaK^w!$#>o>3eD~UK*+kZ9^Ku7=p delta 584 zcmbQM-mb)VnwOW00SLbDFU>IKnaDSZX&2+hMb?ay8=1uFB^NQKNTo=p$gGiF%>q)z zzz`*nBG4} zB1ILbOR`c;Q~ef4a7li0woiUZ6=yMsrMFp(nU$GIlWlSy`!YtE$)X&YjB=CfIb0b{ zCm-d=;MN2hQX~%|v?g0{dNEo|e!wL%c{*nZL`DH3^Nllr(;CFo1rn1VaQH%Gl_0Xq zxhxp%fci4{z^18yML`O?IV30Nb1Sepf<$yTPvu_0$Y?g%j<=7|W%F6yU5wK9K#5!I zp?TSP`Q>?<>_uiEg|;BVe)1yzWX90RYyv5aQj@aAtDn(w|4!7$(G8cJdJ~6P%v3=lU;1^%Oy&>fb zKRd5YgX<@u$*+X6rNTkR!A<|gVUwGmQks)$R}?ilUszH!oRLwoqpZKCv*s%Uhz(W> E0DsAdPXGV_ diff --git a/lims_management/models/sale_order.py b/lims_management/models/sale_order.py index a9217b4..e3cc54b 100644 --- a/lims_management/models/sale_order.py +++ b/lims_management/models/sale_order.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- -from odoo import models, fields +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import logging + +_logger = logging.getLogger(__name__) class SaleOrder(models.Model): _inherit = 'sale.order' @@ -17,3 +21,145 @@ class SaleOrder(models.Model): domain="[('is_doctor', '=', True)]", help="The doctor who referred the patient for this laboratory request." ) + + generated_sample_ids = fields.Many2many( + 'stock.lot', + 'sale_order_stock_lot_rel', + 'order_id', + 'lot_id', + string='Muestras Generadas', + domain="[('is_lab_sample', '=', True)]", + readonly=True, + help="Laboratory samples automatically generated when this order was confirmed" + ) + + def action_confirm(self): + """Override to generate laboratory samples automatically""" + res = super(SaleOrder, self).action_confirm() + + # Generate samples only for laboratory requests + for order in self.filtered('is_lab_request'): + try: + order._generate_lab_samples() + 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 + # But notify the user + order.message_post( + body=_("Error al generar muestras automáticamente: %s. " + "Por favor, genere las muestras manualmente.") % str(e), + message_type='notification' + ) + + return res + + def _generate_lab_samples(self): + """Generate laboratory samples based on the analyses in the order""" + self.ensure_one() + _logger.info(f"Generating laboratory samples for order {self.name}") + + # Group analyses by sample type + sample_groups = self._group_analyses_by_sample_type() + + if not sample_groups: + _logger.warning(f"No analyses with sample types found in order {self.name}") + return + + # Create samples for each group + created_samples = self.env['stock.lot'] + + for sample_type_id, group_data in sample_groups.items(): + sample = self._create_sample_for_group(group_data) + if sample: + created_samples |= sample + + # Link created samples to the order + if created_samples: + self.generated_sample_ids = [(6, 0, created_samples.ids)] + _logger.info(f"Created {len(created_samples)} samples for order {self.name}") + + # Post message with created samples + sample_list = "
    " + for sample in created_samples: + sample_list += f"
  • {sample.name} - {sample.sample_type_product_id.name}
  • " + sample_list += "
" + + self.message_post( + body=_("Muestras generadas automáticamente: %s") % sample_list, + message_type='notification' + ) + + def _group_analyses_by_sample_type(self): + """Group order lines by required sample type""" + groups = {} + + for line in self.order_line: + product = line.product_id + + # Skip non-analysis products + if not product.is_analysis: + continue + + # Check if analysis has a required sample type + if not product.required_sample_type_id: + _logger.warning( + f"Analysis {product.name} has no required sample type defined" + ) + # Post warning message + self.message_post( + body=_("Advertencia: El análisis '%s' no tiene tipo de muestra definido") % product.name, + message_type='notification' + ) + continue + + sample_type = product.required_sample_type_id + + # Initialize group if not exists + if sample_type.id not in groups: + groups[sample_type.id] = { + 'sample_type': sample_type, + 'lines': [], + 'total_volume': 0.0, + 'analyses': [] + } + + # Add line to group + groups[sample_type.id]['lines'].append(line) + groups[sample_type.id]['analyses'].append(product.name) + groups[sample_type.id]['total_volume'] += (product.sample_volume_ml or 0.0) * line.product_uom_qty + + return groups + + def _create_sample_for_group(self, group_data): + """Create a single sample for a group of analyses""" + try: + sample_type = group_data['sample_type'] + + # Prepare sample values + vals = { + 'product_id': sample_type.product_variant_id.id, + 'patient_id': self.partner_id.id, + 'doctor_id': self.doctor_id.id if self.doctor_id else False, + 'origin': self.name, + 'sample_type_product_id': sample_type.id, + 'volume_ml': group_data['total_volume'], + 'is_lab_sample': True, + 'state': 'pending_collection', + 'analysis_names': ', '.join(group_data['analyses'][:3]) + + ('...' if len(group_data['analyses']) > 3 else '') + } + + # Create the sample + sample = self.env['stock.lot'].create(vals) + + _logger.info( + f"Created sample {sample.name} for {len(group_data['analyses'])} analyses" + ) + + return sample + + except Exception as e: + _logger.error(f"Error creating sample: {str(e)}") + raise UserError( + _("Error al crear muestra para %s: %s") % (sample_type.name, str(e)) + ) diff --git a/lims_management/models/stock_lot.py b/lims_management/models/stock_lot.py index e24cbf6..1cbb2a7 100644 --- a/lims_management/models/stock_lot.py +++ b/lims_management/models/stock_lot.py @@ -40,8 +40,31 @@ class StockLot(models.Model): string='Collected by', default=lambda self: self.env.user ) + + doctor_id = fields.Many2one( + 'res.partner', + string='Médico Referente', + domain="[('is_doctor', '=', True)]", + help="Médico que ordenó los análisis" + ) + + origin = fields.Char( + string='Origen', + help="Referencia a la orden de laboratorio que generó esta muestra" + ) + + volume_ml = fields.Float( + string='Volumen (ml)', + help="Volumen total de muestra requerido" + ) + + analysis_names = fields.Char( + string='Análisis', + help="Lista de análisis que se realizarán con esta muestra" + ) state = fields.Selection([ + ('pending_collection', 'Pendiente de Recolección'), ('collected', 'Recolectada'), ('received', 'Recibida en Laboratorio'), ('in_process', 'En Proceso'), diff --git a/pr_description_issue44.txt b/pr_description_issue44.txt new file mode 100644 index 0000000..a1d9983 --- /dev/null +++ b/pr_description_issue44.txt @@ -0,0 +1,37 @@ +## Resumen + +Este Pull Request implementa la relación entre análisis y tipos de muestra (Issue #44), estableciendo la base necesaria para la automatización de generación de muestras (Issue #32). + +## Cambios principales + +### 1. Modelos +- **ProductTemplate**: Añadidos campos `required_sample_type_id` y `sample_volume_ml` para definir requisitos de muestra en análisis +- **StockLot**: Añadido campo `sample_type_product_id` manteniendo compatibilidad con `container_type` + +### 2. Vistas +- Actualización de vistas de análisis para mostrar campos de tipo de muestra +- Actualización de vistas de stock.lot con nuevo campo de tipo de muestra +- Visualización de relaciones test-muestra en listas y formularios + +### 3. Datos +- Creación de 10 tipos de muestra comunes (Tubo Suero, EDTA, Orina, etc.) +- Actualización de análisis demo con tipos de muestra requeridos +- Actualización de muestras demo con referencias a productos tipo muestra + +### 4. Herramientas +- Script de verificación `verify_sample_relationships.py` para validar la implementación +- Documentación completa en `ISSUE44_IMPLEMENTATION.md` + +## Compatibilidad + +- Mantiene compatibilidad total con el campo legacy `container_type` +- Sincronización automática entre campos viejos y nuevos +- Sin ruptura de funcionalidad existente + +## Pruebas + +Todas las tareas fueron probadas individualmente con reinicio de instancia efímera y verificación de logs sin errores. + +## Próximos pasos + +Con esta base implementada, el Issue #32 puede proceder con la automatización de generación de muestras al confirmar órdenes de laboratorio. \ No newline at end of file