bemade-addons/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak
mathis c5904a51d7 [REF] Replace openwebui_connector with centralized openwebui_base module
This commit represents a significant architectural improvement:

- Completely replaced openwebui_connector with a more robust openwebui_base module
- Centralized OpenWebUI API integration for better maintainability
- Redesigned helpdesk_sale_order_ai to use the new openwebui_base module
- Added support for multiple OpenWebUI providers and models
- Improved error handling and response parsing
- Added proper template management with openwebui_prompt_template
- Fixed KeyError issues with safe template substitution
- Streamlined API client initialization and usage
2025-07-28 13:27:29 -04:00

1629 lines
77 KiB
Python

# -*- 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 = "<div style='font-size: 13px;'>"
missing_products_html += "<p><strong>Products not found in database:</strong></p>"
missing_products_html += "<ul style='margin-top: 4px; margin-bottom: 4px;'>"
for product in missing_products:
product_name = product.get('name', '')
quantity = product.get('quantity', 1.0)
description = product.get('description', '')
missing_products_html += "<li>"
missing_products_html += f"<strong>{product_name}</strong>"
# Always show quantity
missing_products_html += f" (QTY: {quantity})"
# Add description if available
if description:
missing_products_html += f" - {description}"
missing_products_html += "</li>"
missing_products_html += "</ul>"
missing_products_html += "<p><em>Please add these products manually or create them in the system.</em></p>"
missing_products_html += "</div>"
missing_products_html += "<!-- MISSING_PRODUCTS_MESSAGE -->"
# 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'<br\s*/?>', '\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)