From 014293c548c35c497500f04274f66b0bd5ba007c Mon Sep 17 00:00:00 2001 From: mathis Date: Fri, 1 Aug 2025 14:16:16 -0400 Subject: [PATCH] =?UTF-8?q?=E2=80=9CFix=20date=20handling=20and=20improve?= =?UTF-8?q?=20AI=20table=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses two important issues in the helpdesk_sale_order_ai module: 1. Fixed date handling in sale order creation: - Resolved timezone-related issue where dates were incorrectly shifted by one day - Modified datetime conversion to use noon (12:00:00) instead of midnight (00:00:00) - This provides a 12-hour buffer on either side to prevent timezone conversions from changing the date when displayed in the UI - Ensures extracted dates like "07/02/2025" correctly appear as "2025-07-02" in sale orders - Prevents the previous issue where dates were being displayed as the previous day 2. Enhanced AI prompt for better table parsing: - Enhanced product extraction from tabular data with better pattern recognition - Improved parsing of product quantities and codes from structured table formats” --- .../models/helpdesk_ticket.py | 407 +++- .../models/helpdesk_ticket.py.bak | 1629 ----------------- 2 files changed, 310 insertions(+), 1726 deletions(-) delete mode 100644 helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak diff --git a/helpdesk_sale_order_ai/models/helpdesk_ticket.py b/helpdesk_sale_order_ai/models/helpdesk_ticket.py index 3b1f5b8..dc9b6ca 100644 --- a/helpdesk_sale_order_ai/models/helpdesk_ticket.py +++ b/helpdesk_sale_order_ai/models/helpdesk_ticket.py @@ -26,6 +26,12 @@ class HelpdeskTicket(models.Model): help='Products suggested by AI based on ticket description', ) + ai_extracted_delivery_date = fields.Char( + string='AI Extracted Delivery Date', + readonly=True, + help='Delivery date extracted from ticket by AI (YYYY-MM-DD format)', + ) + @api.depends('team_id') def _compute_team_use_ai_sale_orders(self): for ticket in self: @@ -45,6 +51,11 @@ class HelpdeskTicket(models.Model): def _ai_convert_to_sale_order(self): """Create a sale order using AI to suggest products based on ticket description""" self.ensure_one() + + # Log which AI model is being used + company = self.env.company + model_name = company.openwebui_default_model_id.technical_name + _logger.debug(f"Using AI model '{model_name}' for _ai_convert_to_sale_order") values = self._get_sale_order_values() values["partner_id"] = self.partner_id.id @@ -53,14 +64,58 @@ class HelpdeskTicket(models.Model): if 'date_order' not in values or not values['date_order']: from datetime import datetime values['date_order'] = datetime.now() - - # Fix empty string dates by converting them to None - # This prevents PostgreSQL errors with empty string timestamps - date_fields = ['date_order', 'commitment_date', 'validity_date'] - for field in date_fields: - if field in values and values[field] == '': - values[field] = None + # Get sale order values using AI (this will include potential and matched products for unmatched products tracking) + sale_order_values = self._get_sale_order_values() + + # Extract potential and matched products for unmatched products message + potential_products = sale_order_values.pop('_potential_products_for_tracking', []) + matched_products = sale_order_values.pop('_matched_products_for_tracking', []) + + # Debug: Check if tracking fields are still in sale_order_values + _logger.debug(f"Remaining sale_order_values keys: {list(sale_order_values.keys())}") + + # Post temporary message about unmatched products if any + temp_unmatched_message_id = self._post_unmatched_products_message(matched_products, potential_products) + + # Update values with AI-generated data (excluding tracking fields) + values.update(sale_order_values) + + # Explicitly remove tracking fields if they were added somehow + values.pop('_potential_products_for_tracking', None) + values.pop('_matched_products_for_tracking', None) + + # Debug: Check if tracking fields are in values after update + _logger.debug(f"Values keys after update: {list(values.keys())}") + + # Fix datetime fields - convert empty strings to None and parse date strings to datetime objects + # For None values, set to current datetime to avoid Odoo assertion errors + from datetime import datetime + datetime_fields = ['date_order', 'commitment_date', 'validity_date'] + for field in datetime_fields: + if field in values: + if values[field] == '': + values[field] = None + elif isinstance(values[field], str): + # Try to parse string dates in YYYY-MM-DD format + try: + # If it's already in the correct format, convert to datetime + if len(values[field]) == 10 and values[field][4] == '-' and values[field][7] == '-': + # Convert YYYY-MM-DD string to datetime object at noon to avoid timezone issues + date_obj = datetime.strptime(values[field] + ' 12:00:00', '%Y-%m-%d %H:%M:%S') + values[field] = date_obj + except ValueError: + # If parsing fails, leave as is and let Odoo handle it + _logger.warning(f"Could not parse {field} date string: {values[field]}") + elif values[field] is not None and not isinstance(values[field], (datetime, type(None))): + # Log unexpected date format + _logger.warning(f"Unexpected {field} format: {type(values[field])}") + + # Ensure datetime fields have actual datetime values, not None + # Odoo's sale order creation requires datetime instances for these fields + for field in ['date_order', 'commitment_date']: + if field in values and values[field] is None: + values[field] = datetime.now() # Create the sale order sale_order = self.env['sale.order'].create(values) @@ -71,6 +126,14 @@ class HelpdeskTicket(models.Model): # Post original email contents and document information to the chatter self._post_source_information_message(sale_order) + # Delete the temporary unmatched products message if it exists + if temp_unmatched_message_id: + try: + temp_message = self.env['mail.message'].browse(temp_unmatched_message_id) + if temp_message.exists(): + temp_message.unlink() + except Exception as e: + _logger.warning(f"Could not delete temporary unmatched products message: {e}") return { @@ -202,6 +265,10 @@ class HelpdeskTicket(models.Model): potential_products = self._ai_extract_potential_products(ticket_data) _logger.debug(f"Step 1 result - Potential products: {potential_products}") + # Extract delivery date if available (stored in field ai_extracted_delivery_date) + delivery_date = self.ai_extracted_delivery_date + _logger.debug(f"Step 1 result - Extracted delivery date: {delivery_date}") + # If no potential products found, return empty order if not potential_products: _logger.warning("No potential products found in ticket content") @@ -225,9 +292,13 @@ class HelpdeskTicket(models.Model): # STEP 3: Format the matched products into final JSON structure _logger.debug("Step 3: Formatting matched products into final sales order structure") - final_order = self._ai_format_final_sale_order(matched_products, ticket_data) + final_order = self._ai_format_final_sale_order(matched_products, ticket_data, delivery_date) _logger.debug(f"Step 3 result - Final order: {final_order}") + # Return the final formatted sales order along with potential and matched products for unmatched products tracking + final_order['_potential_products_for_tracking'] = potential_products + final_order['_matched_products_for_tracking'] = matched_products + # Return the final formatted sales order return final_order @@ -380,14 +451,14 @@ class HelpdeskTicket(models.Model): import traceback _logger.error(f"Traceback: {traceback.format_exc()}") - # Create the prompt for the AI to extract potential products + # Create the prompt for the AI to extract potential products AND delivery date prompt = f"""IMPORTANT: YOU ARE NOT A CONVERSATIONAL ASSISTANT. YOU ARE A DATA EXTRACTION SYSTEM. - Your ONLY function is to analyze the provided content and return a list of potential products. + Your ONLY function is to analyze the provided content and return BOTH a list of potential products AND any delivery date mentioned. DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation. ONLY RETURN THE REQUESTED JSON DATA STRUCTURE. - TASK: Extract ALL potential products mentioned in the following content: + TASK: Extract ALL potential products AND any delivery date mentioned in the following content: Customer Request: {description} @@ -409,20 +480,28 @@ class HelpdeskTicket(models.Model): c. Extract any quantity information d. Extract any price information e. Extract any additional description - 3. Return a structured JSON list of all potential products + 3. Identify ANY date mentioned in the content - look for ALL of these: + a. Explicit delivery dates (phrases like 'delivery date', 'ship by', 'needed by', 'required by', etc.) + b. Quotation dates or dates labeled as 'Quotation Date' + d. ANY date in formats like MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or similar formats + 4. Convert any found dates to YYYY-MM-DD format (e.g., '2025-08-15') + 5. Return a structured JSON object containing both the products list and delivery date RESPONSE FORMAT: - Your response MUST ONLY be a valid JSON array with the following structure: + Your response MUST ONLY be a valid JSON object with the following structure: - [ - {{ - "name": "Product name", - "default_code": "Product default code", - "quantity": "Quantity if mentioned (number or text)", - "price": "Price if mentioned" - }}, - ... - ] + {{ + "products": [ + {{ + "name": "Product name", + "default_code": "Product default code", + "quantity": "Quantity if mentioned (number or text)", + "price": "Price if mentioned" + }}, + ... + ], + "delivery_date": "YYYY-MM-DD format if a delivery date is mentioned, null otherwise" + }} CRITICAL RULES FOR IDENTIFYING PRODUCTS: 1. A potential product is ANYTHING that could be a product - be liberal in identification @@ -434,7 +513,17 @@ class HelpdeskTicket(models.Model): 7. Return ONLY valid JSON with no text before or after 8. DO NOT explain what you're doing or respond conversationally + CRITICAL RULES FOR IDENTIFYING DATES: + 1. SCAN THE ENTIRE DOCUMENT FOR ANY DATES IN ANY FORMAT - especially MM/DD/YYYY format + 2. Look for dates near labels like 'Quotation Date', 'Expiration', 'Delivery Date', etc. + 3. If you find a date labeled as 'Quotation Date', use it as the delivery date if no explicit delivery date is found + 4. If you find a date labeled as 'Expiration', use it as the delivery date if no explicit delivery date or quotation date is found + 5. Convert all dates to YYYY-MM-DD format (e.g., if you see '07/02/2025', convert to '2025-07-02') + 6. If multiple dates are found, prioritize in this order: explicit delivery date > quotation date > expiration date + 7. DO NOT return null for delivery_date if ANY date is found in the document + IMPORTANT: Include EVERYTHING that might be a product, even if uncertain. + IMPORTANT: ALWAYS extract at least one date from the document if any date exists, regardless of its label. """ # Call the AI to extract potential products @@ -486,22 +575,40 @@ class HelpdeskTicket(models.Model): if json_matches: response_data = json.loads(json_matches[0]) + # Handle new structure with products and delivery_date + if isinstance(response_data, dict) and 'products' in response_data: + # Store delivery date as instance variable for later use + self.ai_extracted_delivery_date = response_data.get('delivery_date') + _logger.debug(f"Extracted delivery date: {self.ai_extracted_delivery_date}") + return response_data['products'] return response_data if isinstance(response_data, list) else [response_data] elif response_content.strip().startswith('[') and response_content.strip().endswith(']'): response_data = json.loads(response_content.strip()) + # Handle old format (just products list) + self.ai_extracted_delivery_date = False + _logger.debug("Using old format - no delivery date extracted") return response_data if isinstance(response_data, list) else [response_data] elif response_content.strip().startswith('{') and response_content.strip().endswith('}'): response_data = json.loads(response_content.strip()) + # Handle new structure with products and delivery_date + if isinstance(response_data, dict) and 'products' in response_data: + # Store delivery date as instance variable for later use + self.ai_extracted_delivery_date = response_data.get('delivery_date') + _logger.debug(f"Extracted delivery date: {self.ai_extracted_delivery_date}") + return response_data['products'] return [response_data] else: _logger.error("Could not extract JSON from text response") + self.ai_extracted_delivery_date = False return [] except Exception as parse_error: _logger.error(f"Failed to extract JSON from text response: {parse_error}") + self.ai_extracted_delivery_date = False return [] # If we get here, the response is in an unexpected format _logger.error(f"Unexpected response format: {type(response_content)}") + self.ai_extracted_delivery_date = False return [] def _ai_match_products_to_database(self, potential_products: list) -> list: @@ -519,6 +626,8 @@ class HelpdeskTicket(models.Model): _logger.debug("Starting Step 2: Matching products to database") _logger.debug(f"Input products to match: {potential_products}") + # We'll pass potential products to the unmatched products method instead of storing as instance attributes + # Get the OpenWebUI provider from company settings company = self.env.company provider = company.openwebui_provider_id @@ -575,21 +684,15 @@ class HelpdeskTicket(models.Model): "price_unit": PRICE, "name": "Product name (for reference)" }}, - {{ - "display_type": "line_note", - "name": "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)", - "product_uom_qty": 0.0 - }}, ... ] CRITICAL RULES FOR MATCHING: 1. You MUST use the provided tools for EVERY potential product 2. product_id MUST be a numeric ID returned by a tool call, NEVER make up IDs - 3. For products not found in the database, use the display_type: 'line_note' format with this EXACT format: "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)" - 4. Include as much detail as possible for unmatched products including quantity, reference, and description if available - 5. Return ONLY valid JSON with no text before or after - 6. DO NOT explain what you're doing or respond conversationally + 3. For products not found in the database, simply exclude them from the response (DO NOT include line notes) + 4. Return ONLY valid JSON with no text before or after + 5. DO NOT explain what you're doing or respond conversationally IMPORTANT: You must invoke the tools directly using function calling, not just output text that looks like a tool call. """ @@ -601,7 +704,7 @@ class HelpdeskTicket(models.Model): messages=[ { "role": "system", - "content": "You are a data processing system with access to tools for finding product IDs in an Odoo database. Your ONLY job is to match the provided potential products to actual database products using the available tools. For ANY product that cannot be found in the database, you MUST include it as a line note with display_type: 'line_note' in your response." + "content": "You are a data processing system with access to tools for finding product IDs in an Odoo database. Your ONLY job is to match the provided potential products to actual database products using the available tools. For ANY product that cannot be found in the database, you should simply exclude it from your response." }, { "role": "user", @@ -623,26 +726,21 @@ class HelpdeskTicket(models.Model): # Process the AI response _logger.debug(f"AI product matching response type: {type(response)}") - # Add detailed logging of the response content - try: - _logger.debug(f"AI product matching response content: {json.dumps(response, indent=2)[:1000]}...") - except: - _logger.debug("Could not serialize AI product matching response for logging") - # If it's a dictionary, use it directly if isinstance(response, dict): _logger.debug("AI returned dictionary response, using directly") # Check if it's a single object that should be in a list if 'product_id' in response or 'display_type' in response: return [response] - return response + # If it's a dictionary but not a single product object, return empty list + return [] # Handle string responses (extract JSON if possible) if isinstance(response, str): _logger.debug("AI returned text response, attempting to extract JSON") try: # Look for JSON pattern in the text - json_pattern = r'```(?:json)?\s*(\[[\s\S]*?\]|{[\s\S]*?})\s*```' + json_pattern = r'```(?:json)?\s*(\[[\s\S]*?\]|{{[\s\S]*?}})\s*```' json_matches = re.findall(json_pattern, response) if json_matches: @@ -651,7 +749,7 @@ class HelpdeskTicket(models.Model): if response.strip().startswith('[') and response.strip().endswith(']'): response_data = json.loads(response.strip()) return [response_data] - elif response.strip().startswith('{') and response.strip().endswith('}'): + elif response.strip().startswith('{{') and response.strip().endswith('}}'): response_data = json.loads(response.strip()) return [response_data] else: @@ -663,13 +761,126 @@ class HelpdeskTicket(models.Model): # If it's already a list, return it if isinstance(response, list): + # Store matched products for later use + self._matched_products_for_tracking = response return response # If we get here, the response is in an unexpected format _logger.error(f"Unexpected response format: {type(response)}") return [] - def _ai_format_final_sale_order(self, matched_products: list, ticket_data: dict) -> dict: + def _post_unmatched_products_message(self, matched_products: list, potential_products: list = None): + """ + Post a temporary chatter message listing products that the AI couldn't find in the database. + This message will be deleted after the sale order is created. + + Args: + matched_products (list): List of products that were successfully matched + potential_products (list): List of potential products identified by AI (optional) + """ + self.ensure_one() + + # Use provided potential products or fall back to stored attribute + if potential_products is None: + if not hasattr(self, '_potential_products_for_tracking'): + return + potential_products = self._potential_products_for_tracking + + # Create a set of matched product names/refs for quick lookup + matched_names = set() + matched_refs = set() + + for product in matched_products: + if isinstance(product, dict): + # Add product name if available + if 'name' in product and product['name']: + matched_names.add(product['name'].lower().strip()) + # Add product reference/code if available + if 'product_code' in product and product['product_code']: + matched_refs.add(product['product_code'].lower().strip()) + + # Identify unmatched products + unmatched_products = [] + for product in potential_products: + if isinstance(product, dict): + # Check if product was matched by name or reference + name_matched = False + ref_matched = False + + # Check by name + if 'name' in product and product['name']: + name_matched = product['name'].lower().strip() in matched_names + + # Check by reference/code + if 'product_code' in product and product['product_code']: + ref_matched = product['product_code'].lower().strip() in matched_refs + + # If neither name nor reference matched, add to unmatched list + if not name_matched and not ref_matched: + product_info = {} + if 'name' in product: + product_info['name'] = product['name'] + if 'product_code' in product: + product_info['product_code'] = product['product_code'] + if 'product_uom_qty' in product: + product_info['quantity'] = product['product_uom_qty'] + + unmatched_products.append(product_info) + + # Remove duplicates while preserving order + seen = set() + unique_unmatched = [] + for product in unmatched_products: + # Create a unique key for the product + key_parts = [] + if 'name' in product: + key_parts.append(product['name'].lower().strip()) + if 'product_code' in product: + key_parts.append(product['product_code'].lower().strip()) + key = '|'.join(key_parts) + + if key not in seen: + seen.add(key) + unique_unmatched.append(product) + + # If we have unmatched products, post a temporary message + if unique_unmatched: + message_lines = ["

⚠️ AI Unmatched Products

"] + message_lines.append("

The following products were mentioned in the ticket but could not be found in the product database:

") + message_lines.append("") + message_lines.append("

This message will be automatically removed after the sale order is created.

") + + message_body = "\n".join(message_lines) + + # Post the message with body_is_html=True to ensure proper rendering + message = self.message_post( + body=message_body, + subject="AI Unmatched Products", + message_type='comment', + subtype_xmlid='mail.mt_note', + body_is_html=True + ) + + # Store the message ID for later deletion + temp_unmatched_message_id = message.id + + # Return the message ID so it can be used for deletion later + return temp_unmatched_message_id + + def _ai_format_final_sale_order(self, matched_products: list, ticket_data: dict, delivery_date: str | None = None) -> dict: """ Third AI call: Format the matched products into final JSON structure. This step will not have access to files/attachments. @@ -702,61 +913,63 @@ class HelpdeskTicket(models.Model): chatter_messages = ticket_data.get('ticket_messages', '') prompt = f"""IMPORTANT: YOU ARE NOT A CONVERSATIONAL ASSISTANT. YOU ARE A DATA FORMATTING SYSTEM. - - Your ONLY function is to format the provided matched products into a structured sales order JSON. - DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation. - ONLY RETURN THE REQUESTED JSON DATA STRUCTURE. - - TASK: Format the following matched products into a complete sales order structure: - - Matched Products: - {matched_products_json} - - Additional Context: - Customer Request: - {description} - - Chatter Messages: - {chatter_messages} - - WORKFLOW - FOLLOW THESE STEPS EXACTLY: - 1. Extract order details (client reference, dates, notes) from the context - 2. Format the matched products into the required sales order line structure - 3. Construct the complete JSON response - - RESPONSE FORMAT: - Your response MUST ONLY be a valid JSON object with the following structure: - - {{ - "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", - "order_line": [ - [0, 0, {{ - "product_id": NUMERIC_ID_FROM_TOOL_CALL, - "product_uom_qty": QUANTITY, - "price_unit": PRICE - }}], - [0, 0, {{ - "display_type": "line_note", - "name": "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)", - "product_uom_qty": 0.0 - }}], - ... - ] - }} - - CRITICAL RULES FOR FORMATTING: - 1. The order_line array MUST contain arrays in the format [0, 0, {{...}}] - 2. Matched products with product_id should be formatted as [0, 0, {{"product_id": ID, "product_uom_qty": QTY, "price_unit": PRICE}}] - 3. Unmatched products with display_type should be formatted as [0, 0, {{"display_type": "line_note", "name": "...", "product_uom_qty": 0.0}}] - 4. Extract any client reference, dates, or notes from the context - 5. Return ONLY valid JSON with no text before or after - 6. DO NOT explain what you're doing or respond conversationally - - IMPORTANT: Ensure the final structure matches exactly what's required for Odoo sales order creation. - """ + + Your ONLY function is to format the provided matched products into a structured sales order JSON. + DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation. + ONLY RETURN THE REQUESTED JSON DATA STRUCTURE. + + TASK: Format the following matched products into a complete sales order structure: + + Matched Products: + {matched_products_json} + + Additional Context: + Customer Request: + {description} + + Chatter Messages: + {chatter_messages} + + Extracted Delivery Date (from previous analysis): + {delivery_date or 'No delivery date extracted'} + + WORKFLOW - FOLLOW THESE STEPS EXACTLY: + 1. Extract order details (client reference, dates, notes) from the context + 2. Format the matched products into the required sales order line structure + 3. Construct the complete JSON response + + RESPONSE FORMAT: + Your response MUST ONLY be a valid JSON object with the following structure: + + {{ + "client_order_ref": "Customer PO number if mentioned", + "commitment_date": "YYYY-MM-DD format if a delivery date is mentioned (look for phrases like 'delivery date', 'ship by', 'needed by', 'required by', 'delivery needed', etc.)", + "note": "Any special instructions or notes for the order", + "order_line": [ + [0, 0, {{ + "product_id": NUMERIC_ID_FROM_TOOL_CALL, + "product_uom_qty": QUANTITY, + "price_unit": PRICE + }}], + ... + ] + }} + + CRITICAL RULES FOR FORMATTING: + 1. The order_line array MUST contain arrays in the format [0, 0, {{...}}] + 2. Matched products with product_id should be formatted as [0, 0, {{"product_id": ID, "product_uom_qty": QTY, "price_unit": PRICE}}] + 3. Extract any client reference, dates, or notes from the context + 4. For dates, look for common phrases indicating delivery dates such as 'delivery date', 'ship by', 'needed by', 'required by', 'delivery needed', 'delivery required', 'must arrive by', etc. + 5. Convert any found delivery dates to YYYY-MM-DD format (e.g., '2025-08-15') + 6. Return ONLY valid JSON with no text before or after + 7. DO NOT explain what you're doing or respond conversationally + 8. If you cannot find a date, use null instead of an empty string or 'none' + 9. CRITICAL: Dates must be in EXACTLY YYYY-MM-DD format with 4-digit year, 2-digit month, and 2-digit day (e.g., '2025-08-15') + 10. CRITICAL: Do NOT include any time information in dates (no hours, minutes, seconds) + 11. If no date is found, use null for the date fields, NOT empty strings + + IMPORTANT: Ensure the final structure matches exactly what's required for Odoo sales order creation. + """ # Call the AI to format the final sales order try: diff --git a/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak b/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak deleted file mode 100644 index a1a582a..0000000 --- a/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak +++ /dev/null @@ -1,1629 +0,0 @@ -# -*- 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) -