# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import UserError import logging import json import re _logger = logging.getLogger(__name__) # Import the client model to ensure it's loaded from . import ai_openwebui_client class HelpdeskTicket(models.Model): _inherit = 'helpdesk.ticket' # Computed field to determine if team uses AI sale orders team_use_ai_sale_orders = fields.Boolean( string='Team Uses AI Sale Orders', compute='_compute_team_use_ai_sale_orders', readonly=True, ) ai_generated_products = fields.Text( string='AI Generated Products', readonly=True, help='Products suggested by AI based on ticket description', ) @api.depends('team_id') def _compute_team_use_ai_sale_orders(self): for ticket in self: if ticket.team_id: ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders() else: ticket.team_use_ai_sale_orders = False def action_convert_to_sale_order(self): """Override to use AI if enabled""" self.ensure_one() # Check if AI sale orders are enabled for this team if self.team_use_ai_sale_orders: return self._ai_convert_to_sale_order() # Otherwise, use the standard method return super(HelpdeskTicket, self).action_convert_to_sale_order() def _ai_convert_to_sale_order(self): """Create a sale order using AI to suggest products based on ticket description""" self.ensure_one() _logger.info("Starting AI conversion to sale order for ticket %s", self.id) # Always generate fresh AI suggestions _logger.info("Generating fresh AI suggestions for ticket %s", self.id) result = self._generate_ai_product_suggestions() _logger.info("AI suggestion generation result for ticket %s: %s", self.id, result) # Get base values for sale order (partner, pricelist, etc.) partner_id = self.partner_id.id partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice'] partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery'] # Parse AI suggestions to get order lines and sale order fields ai_data = {'order_lines': [], 'sale_order_fields': {}} if self.ai_generated_products: _logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200]) ai_data = self._parse_ai_product_suggestions() _logger.info("Parsed AI data: %s", ai_data) # Prepare sale order values so_values = { 'partner_id': partner_id, 'partner_invoice_id': partner_invoice_id, 'partner_shipping_id': partner_shipping_id, 'ticket_id': self.id, 'origin': self.name, 'note': self.description, } # Add AI-extracted fields to sale order values if available if ai_data.get('sale_order_fields'): so_fields = ai_data['sale_order_fields'] # Client order reference (PO number) if so_fields.get('client_order_ref'): so_values['client_order_ref'] = so_fields['client_order_ref'] _logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}") # Order date if so_fields.get('date_order'): try: # Validate date format from datetime import datetime date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d') so_values['date_order'] = date_order _logger.info(f"Setting date_order to: {so_fields['date_order']}") except (ValueError, TypeError) as e: _logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}") # Commitment date (delivery date) if so_fields.get('commitment_date'): try: # Validate date format from datetime import datetime commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d') so_values['commitment_date'] = commitment_date _logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}") except (ValueError, TypeError) as e: _logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}") # Note (special instructions) if so_fields.get('note'): # Append to existing note if any existing_note = so_values.get('note', '') if existing_note: so_values['note'] = f"{existing_note}\n\n{so_fields['note']}" else: so_values['note'] = so_fields['note'] _logger.info(f"Setting note to: {so_values['note'][:100]}...") # Payment terms if so_fields.get('payment_term_id'): # Try to find matching payment term payment_term_name = so_fields['payment_term_id'] payment_term = self.env['account.payment.term'].search( ['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1) if payment_term: so_values['payment_term_id'] = payment_term.id _logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})") else: _logger.warning(f"Payment term not found: {payment_term_name}") # Create the sale order sale_order = self.env['sale.order'].create(so_values) _logger.info(f"Created sale order with ID {sale_order.id}") # Check if there are missing products and post a message missing_products = ai_data.get('missing_products', []) if missing_products: missing_products_html = "
" missing_products_html += "

Products not found in database:

" missing_products_html += "" missing_products_html += "

Please add these products manually or create them in the system.

" missing_products_html += "
" missing_products_html += "" # Post the message on the sale order sale_order.message_post(body=missing_products_html, body_is_html=True, subtype_id=self.env.ref('mail.mt_note').id) # Update the sale order to indicate it has missing products sale_order.write({ 'missing_product_count': len(missing_products), 'has_missing_products': True }) _logger.info(f"Posted message about {len(missing_products)} missing products on sale order {sale_order.id}") # Add order lines to the sale order from the AI data if available order_lines = ai_data.get('order_lines', []) _logger.info(f"Adding {len(order_lines)} order lines to sale order {sale_order.id}") for line_values in order_lines: if not line_values: continue _logger.info(f"Processing order line: {line_values}") initial_values = { 'order_id': sale_order.id, 'product_id': line_values.get('product_id'), 'product_uom_qty': line_values.get('product_uom_qty'), 'name': line_values.get('name'), } # Get the price that was calculated in _create_product_order_line calculated_price = line_values.get('price_unit') if calculated_price is not None: _logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}") initial_values['price_unit'] = calculated_price try: order_line = self.env['sale.order.line'].create(initial_values) _logger.info(f"Created order line with ID {order_line.id}") # No need to create the order line twice - the above create is sufficient except Exception as e: _logger.error(f"Error creating order line: {e}") continue # Link the sale order to the ticket self.write({ 'sale_order_id': sale_order.id, }) # Return the action to view the created sale order return { 'type': 'ir.actions.act_window', 'name': _('Sale Order'), 'res_model': 'sale.order', 'res_id': sale_order.id, 'view_mode': 'form,list', 'context': self.env.context, } def _prepare_ai_prompt_data(self): """Prepare data for the AI prompt template""" self.ensure_one() # Get the ticket description description = self.description or "" # If description is empty, try to use the name if not description.strip(): description = self.name or "" # Get chatter messages chatter_messages = "" if self.message_ids: # Process messages in reverse chronological order (newest first) for message in self.message_ids.sorted(key=lambda m: m.id, reverse=True): if message.body and not message.is_internal: # Extract text from HTML more carefully from html import unescape # First unescape any HTML entities unescaped_body = unescape(message.body) # Then remove HTML tags but preserve line breaks body_text = re.sub(r'', '\n', unescaped_body, flags=re.IGNORECASE) body_text = re.sub(r'<[^>]+>', ' ', body_text) # Clean up excessive whitespace body_text = re.sub(r'\s+', ' ', body_text).strip() # Add message to chatter with author and date for context date_str = message.date.strftime('%Y-%m-%d') if message.date else 'Unknown date' chatter_messages += f"Message from {message.author_id.name or 'Unknown'} on {date_str}: {body_text}\n\n" # Get attachments attachments_info = "" attachment_contents = "" if self.message_ids: for message in self.message_ids: if message.attachment_ids: for attachment in message.attachment_ids: attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n" # Extract text from PDF attachments if attachment.mimetype == 'application/pdf' and attachment.datas: try: import base64 import io # Try to use PyPDF2 if available try: from PyPDF2 import PdfReader pdf_data = base64.b64decode(attachment.datas) pdf_file = io.BytesIO(pdf_data) pdf_reader = PdfReader(pdf_file) pdf_text = "" for page_num in range(len(pdf_reader.pages)): # Process all pages page = pdf_reader.pages[page_num] pdf_text += page.extract_text() + "\n" attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text except ImportError: _logger.warning("PyPDF2 not available, skipping PDF text extraction") except Exception as e: _logger.error(f"Error extracting text from PDF: {str(e)}") # Return the prepared data as a dictionary for template formatting return { 'ticket_description': description, 'ticket_messages': chatter_messages, 'attachments_info': attachments_info, 'attachment_contents': attachment_contents } def _generate_ai_product_suggestions(self): """Use AI to generate product suggestions based on ticket description, chatter messages and attachments""" self.ensure_one() _logger.info("Generating AI product suggestions for ticket %s", self.id) # Get the ticket data ticket_data = self._prepare_ai_prompt_data() description = ticket_data['ticket_description'] chatter_messages = ticket_data['ticket_messages'] attachments_info = ticket_data['attachments_info'] attachment_contents = ticket_data['attachment_contents'] # If everything is empty, show error if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip(): _logger.error("No content available for AI analysis") return False # Create the prompt for the AI prompt = f"""You are an expert sales assistant for a pneumatic automation company. Your task is to analyze the customer request and suggest appropriate products or services. Customer Request: {description} Chatter Messages (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION): {chatter_messages} Attachments Information: {attachments_info} Attachment Contents: {attachment_contents} Based on this information, please perform two tasks: 1. Map the following Odoo sale order fields from the information provided: - client_order_ref: Customer's reference/PO number (e.g., SO26321, Contract Number, etc.) - date_order: Order date (in YYYY-MM-DD format) - commitment_date: Delivery date (in YYYY-MM-DD format) - note: Any special instructions or notes - payment_term_id: Payment terms (e.g., "Net 30", "Net 90 Days") 2. Extract products or services from ALL sources (ticket description, chatter messages, and attachments). Pay special attention to: - Line items in tables or structured formats - Product codes/SKUs (e.g., PS-0600-4L, CPGPD-20N000BEE) - Part numbers in brackets like [1231541w] or similar formats - Exact product names as they appear in any message - Quantities and units (including formats like "3x" or "qty: 5") - Descriptions that include specifications - Informal product requests in chatter messages (e.g., "I would like to also buy 3 of [1231541w]These-nuts") IMPORTANT INSTRUCTIONS FOR COMPLEX DOCUMENTS: - If the document is a quote, invoice, or similar structured document, extract EACH LINE ITEM exactly as it appears - Include the EXACT product code/SKU if present (e.g., [FEE-TECH-SPEC], [PS-0600-4L], [CPGPD-20N000BEE]) - For each product, include the EXACT name, quantity, and full description - Do not summarize or combine line items - Do not skip any products listed in the document - If a product has a part number in brackets like [ABC-123], include it in the name field IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure. ```json {{ "sale_order_fields": {{ "client_order_ref": "Customer PO number", "date_order": "YYYY-MM-DD", "commitment_date": "YYYY-MM-DD", "note": "Special instructions", "payment_term_id": "Payment terms" }}, "products": [ {{ "name": "[ABC-123] Product Name", "quantity": 2, "description": "Full product description with all specifications" }}, {{ "name": "[DEF-456] Another Product", "quantity": 1, "description": "Another full description" }} ] }} ``` """ # Get the AI client from the openwebui_base module try: _logger.info("Using OpenWebUI client for ticket %s", self.id) # Use the openwebui.client model that we've defined as a bridge # This model handles the connection to the OpenWebUI API # The model is defined in ai_openwebui_client.py and imported at the top of this file ai_client = self.env['openwebui.client'].sudo() # Get the prompt template from the team settings or default template_content = None if self.team_id: template_content = self.team_id._get_ai_prompt_template() # If a template is found, use it instead of the hardcoded prompt if template_content: # Replace placeholders in the template prompt = template_content.format( description=description, chatter_messages=chatter_messages, attachments_info=attachments_info, attachment_contents=attachment_contents ) _logger.info("Using custom prompt template for ticket %s", self.id) # Call the OpenWebUI API using the client model # The chat_completion method is defined in the AIOpenWebUIClient class _logger.info("Sending prompt to OpenWebUI API") response = ai_client.chat_completion( messages=[{"role": "user", "content": prompt}], model="anthropic.claude-3-7-sonnet-latest" # Use the default model from memory ) # Extract the response content if response and response.get('choices') and response['choices'][0].get('message'): ai_response = response['choices'][0]['message'].get('content', '') _logger.info("Received AI response for ticket %s (length: %s)", self.id, len(ai_response)) # Save the AI response to the ticket self.ai_generated_products = ai_response return True else: _logger.error("Invalid response format from OpenWebUI API: %s", response) return False except Exception as e: _logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e)) import traceback _logger.error("Traceback: %s", traceback.format_exc()) return False class HelpdeskTicket(models.Model): _inherit = 'helpdesk.ticket' # Computed field to determine if team uses AI sale orders team_use_ai_sale_orders = fields.Boolean( string='Team Uses AI Sale Orders', compute='_compute_team_use_ai_sale_orders', readonly=True, ) ai_generated_products = fields.Text( string='AI Generated Products', readonly=True, help='Products suggested by AI based on ticket description', ) @api.depends('team_id') def _compute_team_use_ai_sale_orders(self): for ticket in self: if ticket.team_id: ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders() else: ticket.team_use_ai_sale_orders = False def action_convert_to_sale_order(self): """Override to use AI if enabled""" self.ensure_one() # Check if AI sale orders are enabled for this team if self.team_use_ai_sale_orders: return self._ai_convert_to_sale_order() # Otherwise, use the standard method return super(HelpdeskTicket, self).action_convert_to_sale_order() def _ai_convert_to_sale_order(self): """Create a sale order using AI to suggest products based on ticket description""" self.ensure_one() _logger.info("Starting AI conversion to sale order for ticket %s", self.id) # Always generate fresh AI suggestions _logger.info("Generating fresh AI suggestions for ticket %s", self.id) result = self._generate_ai_product_suggestions() _logger.info("AI suggestion generation result for ticket %s: %s", self.id, result) # Get base values for sale order (partner, pricelist, etc.) partner_id = self.partner_id.id partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice'] partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery'] # Parse AI suggestions to get order lines and sale order fields ai_data = {'order_lines': [], 'sale_order_fields': {}} if self.ai_generated_products: _logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200]) ai_data = self._parse_ai_product_suggestions() _logger.info("Parsed AI data: %s", ai_data) # Prepare sale order values so_values = { 'partner_id': partner_id, 'partner_invoice_id': partner_invoice_id, 'partner_shipping_id': partner_shipping_id, 'ticket_id': self.id, 'origin': self.name, 'note': self.description, } # Add AI-extracted fields to sale order values if available if ai_data.get('sale_order_fields'): so_fields = ai_data['sale_order_fields'] # Client order reference (PO number) if so_fields.get('client_order_ref'): so_values['client_order_ref'] = so_fields['client_order_ref'] _logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}") # Order date if so_fields.get('date_order'): try: # Validate date format from datetime import datetime date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d') so_values['date_order'] = date_order _logger.info(f"Setting date_order to: {so_fields['date_order']}") except (ValueError, TypeError) as e: _logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}") # Create the sale order with the prepared values SaleOrder = self.env['sale.order'] sale_order = SaleOrder.create(so_values) _logger.info(f"Created sale order {sale_order.name} (ID: {sale_order.id}) for ticket {self.id}") # Add order lines from AI data if ai_data.get('order_lines'): _logger.info(f"Adding {len(ai_data['order_lines'])} order lines to sale order {sale_order.name}") sale_order.write({ 'order_line': ai_data['order_lines'] }) # Link the sale order to the ticket self.write({ 'sale_order_id': sale_order.id, }) # Return the action to open the created sale order return { 'type': 'ir.actions.act_window', 'name': _('Sale Order'), 'res_model': 'sale.order', 'res_id': sale_order.id, 'view_mode': 'form,tree', 'context': self.env.context, } def _prepare_ai_prompt_data(self): """Prepare data for the AI prompt template""" self.ensure_one() # Get ticket data ticket_data = { 'name': self.name or '', 'description': self.description or '', 'partner_name': self.partner_id.name if self.partner_id else '', 'category': self.category_id.name if self.category_id else '', 'team': self.team_id.name if self.team_id else '', 'priority': dict(self._fields['priority'].selection).get(self.priority, ''), 'create_date': self.create_date.strftime('%Y-%m-%d %H:%M:%S') if self.create_date else '', } # Get chatter messages (excluding internal notes) messages = [] for message in self.message_ids: # Skip internal notes if message.message_type == 'comment' and not message.subtype_id.internal: author = message.author_id.name if message.author_id else 'System' date = message.date.strftime('%Y-%m-%d %H:%M:%S') if message.date else '' body = message.body or '' # Remove HTML tags from body body = re.sub('<.*?>', ' ', body) body = re.sub('\s+', ' ', body).strip() if body: # Only include non-empty messages messages.append({ 'author': author, 'date': date, 'body': body }) # Get attachment information attachments = [] for attachment in self.attachment_ids: if attachment.mimetype and ('text/' in attachment.mimetype or 'application/pdf' in attachment.mimetype): try: # For text files, try to get the content if 'text/' in attachment.mimetype and attachment.datas: import base64 content = base64.b64decode(attachment.datas).decode('utf-8', errors='ignore') # Truncate long content if len(content) > 1000: content = content[:1000] + '... [truncated]' else: content = '[Binary content not extracted]' attachments.append({ 'name': attachment.name or '', 'mimetype': attachment.mimetype or '', 'content': content }) except Exception as e: _logger.error(f"Error processing attachment {attachment.name}: {e}") # Get available products (limit to reasonable number to avoid token limits) products = [] product_records = self.env['product.product'].search([('sale_ok', '=', True)], limit=100) for product in product_records: products.append({ 'id': product.id, 'name': product.name, 'default_code': product.default_code or '', 'list_price': product.list_price, 'description': product.description_sale or '', 'category': product.categ_id.name if product.categ_id else '', 'uom': product.uom_id.name if product.uom_id else '', }) # Combine all data prompt_data = { 'ticket': ticket_data, 'messages': messages, 'attachments': attachments, 'products': products, } return prompt_data def _generate_ai_product_suggestions(self): """Use AI to generate product suggestions based on ticket description, chatter messages and attachments""" self.ensure_one() if not self.description and not self.message_ids: _logger.warning("Cannot generate AI suggestions: ticket %s has no description or messages", self.id) return {'success': False, 'error': 'Ticket has no description or messages'} try: # Prepare data for the AI prompt prompt_data = self._prepare_ai_prompt_data() # Get the OpenWebUI client client = self.env['helpdesk.openwebui.client'] # Prepare the system message system_message = """ You are a helpful AI assistant for a helpdesk system. Your task is to analyze the ticket description, messages, and attachments to suggest products that should be included in a sales order for this ticket. For each product suggestion, provide: 1. The product name that best matches a product in our database 2. The quantity needed 3. A brief description or note if needed Format your response as a JSON object with the following structure: ```json { "products": [ { "name": "Product Name", "quantity": 1, "description": "Optional description" }, ... ], "sale_order_fields": { "client_order_ref": "Customer PO number if mentioned", "date_order": "YYYY-MM-DD format if a specific date is mentioned", "note": "Any additional notes for the sale order" } } ``` If you can't find any product suggestions, return an empty products array. Use the product database information provided to match products accurately. If you see a part number or product code mentioned, prioritize matching based on that. """ # Prepare the user message with the ticket data user_message = f""" Please suggest products for the following helpdesk ticket: ## Ticket Information - Name: {prompt_data['ticket']['name']} - Description: {prompt_data['ticket']['description']} - Customer: {prompt_data['ticket']['partner_name']} - Category: {prompt_data['ticket']['category']} - Team: {prompt_data['ticket']['team']} - Priority: {prompt_data['ticket']['priority']} - Created on: {prompt_data['ticket']['create_date']} ## Messages {"\n".join([f"- {m['date']} - {m['author']}: {m['body']}" for m in prompt_data['messages']][:10]) if prompt_data['messages'] else "No messages"} ## Attachments {"\n".join([f"- {a['name']} ({a['mimetype']}): {a['content'][:200] + '...' if len(a['content']) > 200 else a['content']}" for a in prompt_data['attachments']][:3]) if prompt_data['attachments'] else "No attachments"} ## Available Products in Database {json.dumps(prompt_data['products'], indent=2)} """ # Prepare the messages for the AI messages = [ {"role": "system", "content": system_message}, {"role": "user", "content": user_message} ] # Call the AI service _logger.info("Calling OpenWebUI client for ticket %s", self.id) response = client.chat_completion(messages) if not response: _logger.error("Failed to get response from OpenWebUI client for ticket %s", self.id) return {'success': False, 'error': 'Failed to get response from AI service'} # Extract the content from the response ai_content = response.get('choices', [{}])[0].get('message', {}).get('content', '') if not ai_content: _logger.error("Empty content in AI response for ticket %s", self.id) return {'success': False, 'error': 'Empty content in AI response'} # Save the AI response to the ticket self.write({ 'ai_generated_products': ai_content }) _logger.info("Successfully generated AI product suggestions for ticket %s", self.id) return {'success': True} except Exception as e: _logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, e) return {'success': False, 'error': str(e)} # End of _generate_ai_product_suggestions method def _parse_ai_product_suggestions(self): """Parse AI-generated product suggestions into sale order lines This method parses the AI-generated product suggestions from the ticket's ai_generated_products field and converts them into sale order lines. It handles various formats including JSON, table format, and line-by-line parsing. Returns: dict: A dictionary containing sale order fields and order lines """ result = { 'order_lines': [], 'sale_order_fields': {}, 'missing_products': [] } if not self.ai_generated_products: _logger.warning("No AI generated products found for ticket %s", self.id) return result 'id': self.partner_id.id, 'name': self.partner_id.name, 'is_company': self.partner_id.is_company, 'parent_id': self.partner_id.parent_id.id if self.partner_id.parent_id else False, 'parent_name': self.partner_id.parent_id.name if self.partner_id.parent_id else '', }) if self.partner_id.parent_id: partners.append({ 'id': self.partner_id.parent_id.id, 'name': self.partner_id.parent_id.name, 'is_company': self.partner_id.parent_id.is_company, 'parent_id': False, 'parent_name': '', }) # Prepare ticket data ticket_data = { 'id': self.id, 'name': self.name, 'description': self.description or '', 'partner_id': self.partner_id.id if self.partner_id else False, 'partner_name': self.partner_id.name if self.partner_id else '', 'team_id': self.team_id.id if self.team_id else False, 'team_name': self.team_id.name if self.team_id else '', } # Get message history messages_data = [] if self.message_ids: for message in self.message_ids: if message.body and not message.is_internal: messages_data.append({ 'id': message.id, 'date': message.date, 'author': message.author_id.name if message.author_id else 'System', 'body': message.body, }) # Prepare the prompt for the AI messages = [ { 'role': 'system', 'content': f''' You are an expert sales assistant for a pneumatics company. Your task is to analyze a helpdesk ticket and create a complete sales order structure with matched products from the database. Return your response as a JSON object with the following structure: {{ "sale_order_fields": {{ "client_order_ref": "Customer PO number if mentioned", "date_order": "YYYY-MM-DD format if a specific order date is mentioned", "commitment_date": "YYYY-MM-DD format if a delivery date is mentioned", "note": "Any special instructions or notes for the order" }}, "products": [ {{ "product_id": 123, // The ID of the matched product from the database "name": "Product name", // For reference only "quantity": 2.0, // Quantity needed "description": "Any special notes about this line item" }}, // Additional products... ] }} If you can't find an exact product match in the database, include as much detail as possible about what the customer needs so a new product can be created. ''' }, { 'role': 'user', 'content': f''' Helpdesk Ticket: {ticket_data} Message History: {messages_data} Available Products: {product_data} Partner Information: {partners} Please analyze this ticket and create a complete sales order structure with matched products. ''' } ] # Call the OpenWebUI client try: client = self.env['openwebui.client'] response = client.chat_completion(messages) if not response: _logger.error("Failed to get response from OpenWebUI client") return result # Extract the content from the response ai_content = response.get('choices', [{}])[0].get('message', {}).get('content', '{}') _logger.info(f"AI response: {ai_content[:500]}") # Try to extract JSON from the response json_pattern = r'```(?:json)?\s*({[\s\S]*?})\s*```' json_matches = re.findall(json_pattern, ai_content) if json_matches: try: json_str = json_matches[0] # Remove any trailing commas before closing brackets (common JSON error) json_str = re.sub(r',\s*([\]\}])', r'\1', json_str) parsed_data = json.loads(json_str) _logger.info(f"Parsed AI result: {parsed_data}") # Extract sale order fields if 'sale_order_fields' in parsed_data: result['sale_order_fields'] = parsed_data['sale_order_fields'] # Extract products and create order lines products_key = None for key in ['products', 'product_suggestions', 'order_lines', 'items']: if key in parsed_data and isinstance(parsed_data[key], list): products_key = key break if products_key: for product_item in parsed_data[products_key]: if not isinstance(product_item, dict): continue product_id = product_item.get('product_id') quantity = product_item.get('quantity', 1.0) description = product_item.get('description', '') try: quantity = float(quantity) except (ValueError, TypeError): quantity = 1.0 if product_id: # Direct product ID match product = self.env['product.product'].browse(product_id) if product.exists(): order_line = self._prepare_order_line_values(product, quantity, description) result['order_lines'].append(order_line) continue # If no direct product ID or it doesn't exist, try to find by name product_name = product_item.get('name') if product_name: order_line = self._create_product_order_line(product_name, quantity, description) if order_line: result['order_lines'].append(order_line) else: # Track missing products missing_product_info = { 'name': product_name, 'quantity': quantity, 'description': description } result['missing_products'].append(missing_product_info) return result except (json.JSONDecodeError, Exception) as e: _logger.error(f"Error parsing AI response JSON: {e}") except Exception as e: _logger.error(f"Error using AI for parsing suggestions: {e}") # If AI parsing failed, return empty result return result def _parse_ai_product_suggestions(self): """Parse AI-generated product suggestions into sale order lines This method parses the AI-generated product suggestions from the ticket's ai_generated_products field and converts them into sale order lines. It handles various formats including JSON, table format, and line-by-line parsing. Returns: dict: A dictionary containing sale order fields and order lines """ result = { 'order_lines': [], 'sale_order_fields': {}, 'missing_products': [] } if not self.ai_generated_products: _logger.warning("No AI generated products found for ticket %s", self.id) return result # Log the AI response for debugging _logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300]) # First, try to extract JSON from the response using multiple patterns # Pattern 1: Standard code block with json tag json_pattern1 = r'```(?:json)?\s*({[\s\S]*?})\s*```' # Pattern 2: Just find any JSON-like structure with sale_order_fields or products json_pattern2 = r'({[\s\S]*?"(?:sale_order_fields|products)"[\s\S]*?})' # Pattern 3: Find any JSON-like structure (most permissive) json_pattern3 = r'({\s*"[^"]+"\s*:.*})' # Any JSON object with at least one key json_matches = re.findall(json_pattern1, self.ai_generated_products) if not json_matches: _logger.info("No JSON found with pattern 1, trying pattern 2") json_matches = re.findall(json_pattern2, self.ai_generated_products) if not json_matches: _logger.info("No JSON found with pattern 2, trying pattern 3") json_matches = re.findall(json_pattern3, self.ai_generated_products) if json_matches: # Try to parse the JSON try: # Clean up the JSON string before parsing json_str = json_matches[0] # Remove any trailing commas before closing brackets (common JSON error) json_str = re.sub(r',\s*([\]\}])', r'\1', json_str) json_data = json.loads(json_str) _logger.info(f"Successfully parsed JSON data: {json_data}") # Extract sale order fields if 'sale_order_fields' in json_data: result['sale_order_fields'] = json_data['sale_order_fields'] _logger.info(f"Extracted sale order fields: {result['sale_order_fields']}") # Direct fields at root level (fallback) elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']): so_fields = {} for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']: if field in json_data: so_fields[field] = json_data[field] result['sale_order_fields'] = so_fields _logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}") # Extract products - check multiple possible keys product_key = None for key in ['products', 'product_suggestions', 'order_lines', 'items']: if key in json_data and isinstance(json_data[key], list): product_key = key break if product_key: for product in json_data[product_key]: if not isinstance(product, dict): continue product_name = product.get('name') if not product_name: continue quantity = product.get('quantity', 1.0) try: quantity = float(quantity) except (ValueError, TypeError): quantity = 1.0 description = product.get('description', '') _logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}") order_line = self._create_product_order_line(product_name, quantity, description) if order_line: result['order_lines'].append(order_line) else: # Track missing products missing_product_info = { 'name': product_name, 'quantity': quantity, 'description': description } result['missing_products'].append(missing_product_info) _logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON") return result except json.JSONDecodeError as e: _logger.error(f"Failed to parse JSON: {e}") # Try to extract just the sale order fields using regex as a last resort try: # Look for client_order_ref pattern po_pattern = r'(?:client_order_ref|PO number|purchase order)[\s"]*[:=]\s*["]*([^"\n,}]+)' po_match = re.search(po_pattern, self.ai_generated_products, re.IGNORECASE) if po_match: result['sale_order_fields']['client_order_ref'] = po_match.group(1).strip() # Look for dates date_pattern = r'(?:date_order|order date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})' date_match = re.search(date_pattern, self.ai_generated_products, re.IGNORECASE) if date_match: result['sale_order_fields']['date_order'] = date_match.group(1) # Look for commitment date commit_pattern = r'(?:commitment_date|delivery date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})' commit_match = re.search(commit_pattern, self.ai_generated_products, re.IGNORECASE) if commit_match: result['sale_order_fields']['commitment_date'] = commit_match.group(1) # Look for payment terms payment_pattern = r'(?:payment_term_id|payment terms)[\s"]*[:=]\s*["]*([^"\n,}]+)' payment_match = re.search(payment_pattern, self.ai_generated_products, re.IGNORECASE) if payment_match: result['sale_order_fields']['payment_term_id'] = payment_match.group(1).strip() if result['sale_order_fields']: _logger.info(f"Extracted sale order fields using regex: {result['sale_order_fields']}") except Exception as regex_error: _logger.error(f"Error in regex extraction fallback: {regex_error}") # If JSON parsing failed, fall back to the old parsing methods _logger.info("Falling back to legacy parsing methods") # Try to extract reference number using regex before falling back to line-by-line parsing ref_patterns = [ r'(?:reference|ticket|po|purchase order)[\s\-]*(?:number|#)?[\s\-:]*([\d\-]+)', r'(?:client_order_ref|order ref)[\s"]*[:=]\s*["]*([^"\n,}]+)' ] for pattern in ref_patterns: ref_match = re.search(pattern, self.ai_generated_products, re.IGNORECASE) if ref_match: ref_number = ref_match.group(1).strip() _logger.info(f"Found reference number using regex: {ref_number}") result['sale_order_fields']['client_order_ref'] = ref_number break order_lines = [] # Try to parse the AI response in different formats # First, look for a table format with | separators table_pattern = r"([^|\n]+)\s*\|\s*(\d*\.?\d*)\s*\|\s*([^|\n]*)" table_matches = re.findall(table_pattern, self.ai_generated_products) if table_matches: # Process table format _logger.info(f"Found table format with {len(table_matches)} matches") for match in table_matches: product_name = match[0].strip() if not product_name or product_name.lower() in ['product/service name', 'product', 'service', 'item']: continue # Parse quantity quantity = 1.0 if match[1].strip(): try: quantity = float(match[1].strip()) except ValueError: quantity = 1.0 # Get description description = match[2].strip() if match[2].strip() else product_name # Add the order line order_line = self._create_product_order_line(product_name, quantity, description) if order_line: order_lines.append(order_line) else: # Try to parse line by line for products and quantities # Look for patterns like "2x Product Name" or "Product Name (qty: 3)" or "Product Name - 4 units" lines = self.ai_generated_products.strip().split('\n') _logger.info(f"Parsing line by line, found {len(lines)} lines") # Skip header lines and empty lines processed_lines = [] for line in lines: line = line.strip() # Skip empty lines, headers, and other non-product lines if (not line or line.startswith('#') or line.lower().startswith('product') or line.lower() == 'format your response as' or line.lower() == 'for example:'): continue # Remove bullet points and other common prefixes line = re.sub(r'^[-*\u2022]\s*', '', line) processed_lines.append(line) for line in processed_lines: _logger.info(f"Processing line: {line}") # Try to extract quantity, product name, part number, and description # Format examples: # - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency # - 1x Preventive Maintenance Service: Annual service package # - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI # Pattern for the format specified in the prompt template detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)" match = re.search(detailed_pattern, line, re.IGNORECASE) if match: quantity = float(match.group(1)) product_name = match.group(2).strip() part_number = match.group(3) if match.group(3) else '' specs = match.group(4).strip() if match.group(4) else '' # Combine part number with product name if available if part_number: full_product_name = f"{product_name} {part_number}" else: full_product_name = product_name # Use specifications as description if available description = specs if specs else product_name _logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}") order_line = self._create_product_order_line(full_product_name, quantity, description) if order_line: order_lines.append(order_line) continue # Try other common patterns if the detailed pattern didn't match qty_patterns = [ r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name" r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)" r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units" r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2" r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name" ] product_name = None quantity = 1.0 description = "" for pattern in qty_patterns: match = re.search(pattern, line, re.IGNORECASE) if match: if pattern == qty_patterns[0]: # "2x Product Name" try: quantity = float(match.group(1)) product_name = match.group(2).strip() except (ValueError, IndexError): continue else: # Other patterns try: product_name = match.group(1).strip() quantity = float(match.group(2)) except (ValueError, IndexError): continue # Try to extract description after the product name desc_match = re.search(r"[^:]+:(.+)$", line) if desc_match: description = desc_match.group(1).strip() _logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}") break # If no pattern matched, use the whole line as product name if not product_name: # Check if there's a colon that might separate product name from description if ':' in line: parts = line.split(':', 1) product_name = parts[0].strip() description = parts[1].strip() if len(parts) > 1 else '' else: product_name = line description = '' _logger.info(f"No pattern match, using line as product: {product_name}, desc={description}") # Add the order line order_line = self._create_product_order_line(product_name, quantity, description) if order_line: order_lines.append(order_line) result['order_lines'] = order_lines _logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions") return result def _ai_find_product_match(self, product_info): """ Use OpenWebUI client to find a matching product in the database Args: product_info: Dictionary containing product information (name, quantity, description, etc.) Returns: Tuple of (product, confidence_score) if found, or (False, 0) if not found """ _logger.info(f"Using AI to find product match for: {product_info}") # Get all available products that can be sold available_products = self.env['product.product'].search([('sale_ok', '=', True)]) # Prepare product data for the AI - include more relevant fields for better matching product_data = [] for product in available_products: product_data.append({ 'id': product.id, 'name': product.name, 'default_code': product.default_code or '', 'description': product.description or '', 'description_sale': product.description_sale or '', 'categ_id': product.categ_id.name if product.categ_id else '', 'list_price': product.list_price, 'uom_name': product.uom_id.name if product.uom_id else '', }) # Extract key information from product_info for better matching product_name = product_info.get('name', '') description = product_info.get('description', '') # Try to extract part numbers from the product name part_numbers = [] bracket_match = re.search(r'\[(.*?)\]', product_name) if bracket_match: part_numbers.append(bracket_match.group(1).strip()) # Extract additional part numbers using patterns part_number_patterns = [ r'[A-Z][A-Z0-9\-]{3,}', # Model numbers like ABC-123 r'[A-Z]+-[A-Z0-9]+', # Part numbers with specific formats r'[A-Z]+-[0-9]+-[A-Z0-9]+' # Product codes with specific prefixes ] for pattern in part_number_patterns: matches = re.findall(pattern, product_name, re.IGNORECASE) if matches: part_numbers.extend(matches) # Filter out duplicates and short part numbers part_numbers = list(set([p for p in part_numbers if p and len(p) >= 4])) # Prepare the prompt for the AI with enhanced context messages = [ { 'role': 'system', 'content': f''' You are a product matching expert for an industrial equipment company. Your task is to find the best matching product from the database based on the provided product information. You have access to the following product attributes: id, name, default_code (SKU/part number), description, description_sale, category, list_price, and unit of measure. When matching products, consider the following priority order: 1. Exact match on default_code (part number) 2. Exact match on product name 3. Partial match on default_code 4. Partial match on product name with high similarity 5. Match based on description and category Return your response as a JSON object with the following structure: {{ "matched_product_id": 123, // The ID of the matched product, or null if no match found "confidence_score": 0.95, // A score between 0 and 1 indicating your confidence in the match "reasoning": "Explanation of why this product was matched" }} Only return a match if you are reasonably confident (score > 0.7). Otherwise, return null for matched_product_id. ''' }, { 'role': 'user', 'content': f''' Product to match: {{ "name": "{product_name}", "description": "{description}", "quantity": {product_info.get('quantity', 1.0)}, "extracted_part_numbers": {part_numbers} }} Available products in database (showing first 100 of {len(product_data)} products): {json.dumps(product_data[:100], indent=2)} ''' } ] # Call the OpenWebUI client try: client = self.env['helpdesk.openwebui.client'] response = client.chat_completion(messages) if not response: _logger.error("Failed to get response from OpenWebUI client") return False, 0 # Extract the content from the response ai_content = response.get('choices', [{}])[0].get('message', {}).get('content', '{}') _logger.info(f"AI response: {ai_content[:500]}") # Try multiple JSON extraction patterns json_patterns = [ r'```(?:json)?\s*({[\s\S]*?})\s*```', # Code block with JSON r'({\s*"matched_product_id"[\s\S]*?})(?:\s*$|\n)', # JSON starting with matched_product_id r'({[\s\S]*?"matched_product_id"[\s\S]*?})(?:\s*$|\n)', # Any JSON containing matched_product_id r'({[\s\S]*?"confidence_score"[\s\S]*?})(?:\s*$|\n)' # Any JSON containing confidence_score ] parsed_result = None # Try each pattern in order for pattern in json_patterns: json_matches = re.findall(pattern, ai_content) if json_matches: for json_str in json_matches: try: # Clean up the JSON string # Remove any trailing commas before closing brackets (common JSON error) json_str = re.sub(r',\s*([\]\}])', r'\1', json_str) # Fix missing quotes around keys (another common error) json_str = re.sub(r'([{,])\s*(\w+)\s*:', r'\1"\2":', json_str) result = json.loads(json_str) _logger.info(f"Parsed AI result: {result}") # Check if this JSON has the fields we need if 'matched_product_id' in result: parsed_result = result break except json.JSONDecodeError: continue if parsed_result: break # If we found a valid JSON result if parsed_result: # Get the matched product ID and confidence score product_id = parsed_result.get('matched_product_id') confidence_score = parsed_result.get('confidence_score', 0) reasoning = parsed_result.get('reasoning', 'No reasoning provided') _logger.info(f"AI reasoning: {reasoning}") # Handle null/None product_id if product_id is None or product_id == 'null' or product_id == 'None': _logger.info("AI couldn't find a confident match") return False, 0 # Convert string ID to int if needed if isinstance(product_id, str) and product_id.isdigit(): product_id = int(product_id) if product_id and confidence_score > 0.7: # Only accept matches with high confidence try: product = self.env['product.product'].browse(product_id) if product.exists(): _logger.info(f"AI found product match: {product.name} (ID: {product.id}) with confidence {confidence_score}") return product, confidence_score except Exception as e: _logger.error(f"Error retrieving product: {e}") # If JSON parsing failed, try regex extraction as a last resort id_pattern = r'"matched_product_id"\s*:\s*(\d+)' id_match = re.search(id_pattern, ai_content) if id_match: try: product_id = int(id_match.group(1)) product = self.env['product.product'].browse(product_id) if product.exists(): _logger.info(f"AI found product match using regex: {product.name} (ID: {product.id})") return product, 0.8 # Assume reasonable confidence except (ValueError, Exception) as e: _logger.error(f"Error extracting product ID: {e}") # Look for product mentions in the text as a final fallback product_mention_pattern = r'product\s+(?:id|ID|Id)\s*[:#]?\s*(\d+)' mention_match = re.search(product_mention_pattern, ai_content) if mention_match: try: product_id = int(mention_match.group(1)) product = self.env['product.product'].browse(product_id) if product.exists(): _logger.info(f"AI found product match from text mention: {product.name} (ID: {product.id})") return product, 0.75 # Lower confidence for this method except (ValueError, Exception) as e: _logger.error(f"Error extracting product ID from mention: {e}") except Exception as e: _logger.error(f"Error using AI for product matching: {e}") return False, 0 def _create_product_order_line(self, product_name, quantity, description=""): """Create a sale order line for a product This method attempts to find a matching product in the database using both AI-powered matching and traditional matching techniques. It follows this process: 1. Try AI-powered matching with the OpenWebUI client 2. If AI matching fails or has low confidence, fall back to traditional matching: - Look for exact matches on product name or part number - Try partial matches with sufficient similarity - Extract and match potential part numbers 3. If no product is found, return False Args: product_name (str): The name of the product to find quantity (float): The quantity for the order line description (str, optional): Additional description for the order line Returns: dict: Order line values if a product is found, False otherwise """ if not product_name: return False _logger.info(f"Attempting to find product match for: {product_name}") # First, try using AI to find a product match product_info = { 'name': product_name, 'quantity': quantity, 'description': description } # Try AI matching with increasing confidence thresholds # This allows us to prefer high-confidence matches but fall back to lower confidence # if no high-confidence match is found confidence_thresholds = [0.9, 0.8, 0.7] for threshold in confidence_thresholds: product, confidence = self._ai_find_product_match(product_info) if product and confidence >= threshold: _logger.info(f"Using AI-matched product: {product.name} (ID: {product.id}) with confidence {confidence}") return self._prepare_order_line_values(product, quantity, description) # If AI matching failed or had low confidence, fall back to traditional matching _logger.info("AI matching failed or had sufficient confidence, falling back to traditional matching") # First, check if there's a part number in brackets like [ABC-123] bracketed_part_number = None bracket_match = re.search(r'\[(.*?)\]', product_name) if bracket_match: bracketed_part_number = bracket_match.group(1).strip() _logger.info(f"Found bracketed part number: {bracketed_part_number}") # Try exact match on the bracketed part number first product = self.env['product.product'].search([ ('default_code', '=', bracketed_part_number), ('sale_ok', '=', True) ], limit=1) if product: _logger.info(f"Found exact match for bracketed part number: {bracketed_part_number} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # If no exact match, try case-insensitive match product = self.env['product.product'].search([ ('default_code', '=ilike', bracketed_part_number), ('sale_ok', '=', True) ], limit=1) if product: _logger.info(f"Found case-insensitive match for bracketed part number: {bracketed_part_number} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # Search for matching product - try exact match on full name product = self.env['product.product'].search([ ('name', '=', product_name), ('sale_ok', '=', True) ], limit=1) if product: _logger.info(f"Found exact name match: {product_name} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # If bracketed part number exists but didn't match, try to extract the product name without brackets clean_product_name = product_name if bracket_match: clean_product_name = product_name.replace(f"[{bracketed_part_number}]", "").strip() _logger.info(f"Trying with clean product name (without brackets): {clean_product_name}") # Try exact match with clean name product = self.env['product.product'].search([ ('name', '=', clean_product_name), ('sale_ok', '=', True) ], limit=1) if product: _logger.info(f"Found exact match with clean name: {clean_product_name} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # Try partial name match but only if name is substantial (to avoid false positives) if len(clean_product_name) > 8: # Only try partial match if product name is substantial _logger.info(f"Trying partial name match for: {clean_product_name}") product = self.env['product.product'].search([ ('name', 'ilike', clean_product_name), ('sale_ok', '=', True) ], limit=1) if product: # Verify this isn't just a trivial match if len(product.name) > 5 and (clean_product_name.lower() in product.name.lower() or product.name.lower() in clean_product_name.lower()): _logger.info(f"Found partial name match: {clean_product_name} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # If still no product found, try extracting potential part numbers if any(c.isdigit() for c in product_name): # Check if product name contains numbers _logger.info(f"Trying to extract part numbers from: {product_name}") # Start with bracketed part number if available part_numbers = [bracketed_part_number] if bracketed_part_number else [] # Extract additional part numbers using patterns part_number_patterns = [ # Model numbers like ABC-123, ABC123, etc. r'[A-Z][A-Z0-9\-]{3,}', # Part numbers with specific formats (e.g., CPGPD-20N000BEE) r'[A-Z]+-[A-Z0-9]+', # Product codes with specific prefixes (e.g., PS-0600-4L) r'[A-Z]+-[0-9]+-[A-Z0-9]+' ] for pattern in part_number_patterns: matches = re.findall(pattern, product_name, re.IGNORECASE) if matches: part_numbers.extend([m for m in matches if m != bracketed_part_number]) # Filter out duplicates and short part numbers part_numbers = [p for p in part_numbers if p and len(p) >= 4] if part_numbers: _logger.info(f"Extracted potential part numbers: {part_numbers}") for part in part_numbers: # Try exact match on part number product = self.env['product.product'].search([ ('default_code', '=', part), ('sale_ok', '=', True) ], limit=1) if product: _logger.info(f"Found exact match for part number: {part} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # Try case-insensitive match product = self.env['product.product'].search([ ('default_code', '=ilike', part), ('sale_ok', '=', True) ], limit=1) if product: _logger.info(f"Found case-insensitive match for part number: {part} -> {product.name} (ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # Only try partial match for longer part numbers (to avoid false positives) if len(part) >= 6: product = self.env['product.product'].search([ ('default_code', 'ilike', part), ('sale_ok', '=', True) ], limit=1) if product and product.default_code: # Verify this isn't just a trivial match by checking substantial overlap if len(product.default_code) >= 4 and \ (part.lower() in product.default_code.lower() or \ product.default_code.lower() in part.lower()): _logger.info(f"Found partial match for part number: {part} -> {product.default_code} ({product.name}, ID: {product.id})") return self._prepare_order_line_values(product, quantity, description) # If no product found, log it and return False if not product: _logger.info(f"No matching product found for: {product_name}") return False return self._prepare_order_line_values(product, quantity, description) def _prepare_order_line_values(self, product, quantity, description=""): """Prepare values for creating a sale order line""" # Create order line with price information line_values = { 'product_id': product.id, 'product_uom_qty': quantity, 'name': description or product.name, } # We don't need to set the price here - Odoo will handle this automatically # when the sale order line is created with the product # Just log the product's list price for debugging _logger.info(f"Product {product.name} (ID: {product.id}) has list_price: {product.list_price}") # We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it return (0, 0, line_values)