""" Odoo Integration Service - Sync products, services, and leads with Odoo ERP. """ import xmlrpc.client from datetime import datetime from typing import List, Dict, Optional, Any import logging from app.core.config import settings from app.core.database import SessionLocal from app.models.product import Product from app.models.service import Service from app.models.lead import Lead from app.models.odoo_sync_log import OdooSyncLog logger = logging.getLogger(__name__) class OdooService: """Service for Odoo ERP integration.""" def __init__(self): self.url = settings.ODOO_URL self.db = settings.ODOO_DB self.username = settings.ODOO_USERNAME self.password = settings.ODOO_PASSWORD self.enabled = settings.ODOO_SYNC_ENABLED self._uid = None self._common = None self._models = None def _get_db(self): """Get database session.""" return SessionLocal() def _connect(self) -> bool: """Establish connection to Odoo.""" if not self.enabled or not all([self.url, self.db, self.username, self.password]): logger.warning("Odoo not configured or disabled") return False try: # Common endpoint for authentication self._common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common") # Authenticate self._uid = self._common.authenticate( self.db, self.username, self.password, {} ) if not self._uid: logger.error("Odoo authentication failed") return False # Models endpoint for data operations self._models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") return True except Exception as e: logger.error(f"Odoo connection error: {e}") return False def _execute(self, model: str, method: str, *args, **kwargs) -> Any: """Execute an Odoo model method.""" if not self._models or not self._uid: if not self._connect(): raise ConnectionError("Cannot connect to Odoo") return self._models.execute_kw( self.db, self._uid, self.password, model, method, list(args), kwargs ) async def test_connection(self) -> Dict: """Test connection to Odoo.""" if not self.enabled: return { "connected": False, "error": "Odoo integration not enabled", "configured": bool(self.url) } try: if self._connect(): version = self._common.version() return { "connected": True, "version": version.get("server_version"), "uid": self._uid } else: return { "connected": False, "error": "Authentication failed" } except Exception as e: return { "connected": False, "error": str(e) } async def sync_products(self, limit: int = 100) -> Dict: """ Sync products from Odoo to local database. Returns: Dict with sync statistics """ db = self._get_db() log = OdooSyncLog( sync_type="products", direction="import", status="started" ) db.add(log) db.commit() try: if not self._connect(): log.mark_failed("Cannot connect to Odoo") db.commit() return {"success": False, "error": "Connection failed"} # Fetch products from Odoo product_ids = self._execute( "product.template", "search", [["sale_ok", "=", True], ["active", "=", True]], limit=limit ) products_data = self._execute( "product.template", "read", product_ids, fields=["id", "name", "description_sale", "list_price", "categ_id", "image_1920", "qty_available", "default_code"] ) created = 0 updated = 0 failed = 0 for odoo_product in products_data: try: # Check if product exists locally local_product = db.query(Product).filter( Product.odoo_product_id == odoo_product["id"] ).first() product_data = { "name": odoo_product["name"], "description": odoo_product.get("description_sale") or "", "price": odoo_product.get("list_price", 0), "category": odoo_product.get("categ_id", [0, "general"])[1] if odoo_product.get("categ_id") else "general", "stock": int(odoo_product.get("qty_available", 0)), "is_available": odoo_product.get("qty_available", 0) > 0, "odoo_product_id": odoo_product["id"], "odoo_last_synced": datetime.utcnow() } if local_product: # Update existing for key, value in product_data.items(): setattr(local_product, key, value) updated += 1 else: # Create new local_product = Product(**product_data) db.add(local_product) created += 1 except Exception as e: logger.error(f"Error syncing product {odoo_product.get('id')}: {e}") failed += 1 db.commit() log.records_processed = len(products_data) log.records_created = created log.records_updated = updated log.records_failed = failed log.mark_completed() db.commit() return { "success": True, "processed": len(products_data), "created": created, "updated": updated, "failed": failed } except Exception as e: logger.error(f"Error in sync_products: {e}") log.mark_failed(str(e)) db.commit() return {"success": False, "error": str(e)} finally: db.close() async def sync_services(self, limit: int = 100) -> Dict: """ Sync services from Odoo to local database. Services in Odoo are typically products with type='service'. Returns: Dict with sync statistics """ db = self._get_db() log = OdooSyncLog( sync_type="services", direction="import", status="started" ) db.add(log) db.commit() try: if not self._connect(): log.mark_failed("Cannot connect to Odoo") db.commit() return {"success": False, "error": "Connection failed"} # Fetch service-type products from Odoo service_ids = self._execute( "product.template", "search", [["type", "=", "service"], ["sale_ok", "=", True], ["active", "=", True]], limit=limit ) services_data = self._execute( "product.template", "read", service_ids, fields=["id", "name", "description_sale", "list_price", "categ_id"] ) created = 0 updated = 0 failed = 0 for odoo_service in services_data: try: # Check if service exists locally local_service = db.query(Service).filter( Service.odoo_service_id == odoo_service["id"] ).first() service_data = { "name": odoo_service["name"], "description": odoo_service.get("description_sale") or "", "category": odoo_service.get("categ_id", [0, "general"])[1] if odoo_service.get("categ_id") else "general", "price_range": f"${odoo_service.get('list_price', 0):,.0f} MXN" if odoo_service.get("list_price") else None, "odoo_service_id": odoo_service["id"], "odoo_last_synced": datetime.utcnow() } if local_service: # Update existing for key, value in service_data.items(): setattr(local_service, key, value) updated += 1 else: # Create new local_service = Service(**service_data) db.add(local_service) created += 1 except Exception as e: logger.error(f"Error syncing service {odoo_service.get('id')}: {e}") failed += 1 db.commit() log.records_processed = len(services_data) log.records_created = created log.records_updated = updated log.records_failed = failed log.mark_completed() db.commit() return { "success": True, "processed": len(services_data), "created": created, "updated": updated, "failed": failed } except Exception as e: logger.error(f"Error in sync_services: {e}") log.mark_failed(str(e)) db.commit() return {"success": False, "error": str(e)} finally: db.close() async def create_lead(self, lead: Lead) -> Dict: """ Create a lead in Odoo CRM. Args: lead: Local lead object Returns: Dict with Odoo lead ID and status """ try: if not self._connect(): return {"success": False, "error": "Cannot connect to Odoo"} # Prepare lead data for Odoo odoo_lead_data = { "name": lead.interest or f"Lead from {lead.platform}", "contact_name": lead.name or lead.username, "email_from": lead.email, "phone": lead.phone, "partner_name": lead.company, "description": f""" Source: {lead.platform} Username: @{lead.username} Profile: {lead.profile_url} Original Content: {lead.source_content} Notes: {lead.notes or 'No notes'} """.strip(), "type": "lead", "priority": "2" if lead.priority == "high" else "1" if lead.priority == "medium" else "0", } # Create lead in Odoo odoo_lead_id = self._execute( "crm.lead", "create", [odoo_lead_data] ) return { "success": True, "odoo_lead_id": odoo_lead_id } except Exception as e: logger.error(f"Error creating Odoo lead: {e}") return {"success": False, "error": str(e)} async def export_leads_to_odoo(self) -> Dict: """ Export unsynced leads to Odoo CRM. Returns: Dict with export statistics """ db = self._get_db() log = OdooSyncLog( sync_type="leads", direction="export", status="started" ) db.add(log) db.commit() try: if not self._connect(): log.mark_failed("Cannot connect to Odoo") db.commit() return {"success": False, "error": "Connection failed"} # Get unsynced leads unsynced_leads = db.query(Lead).filter( Lead.synced_to_odoo == False ).all() created = 0 failed = 0 errors = [] for lead in unsynced_leads: try: result = await self.create_lead(lead) if result["success"]: lead.odoo_lead_id = result["odoo_lead_id"] lead.synced_to_odoo = True lead.odoo_synced_at = datetime.utcnow() created += 1 else: failed += 1 errors.append({ "lead_id": lead.id, "error": result.get("error") }) except Exception as e: logger.error(f"Error exporting lead {lead.id}: {e}") failed += 1 errors.append({ "lead_id": lead.id, "error": str(e) }) db.commit() log.records_processed = len(unsynced_leads) log.records_created = created log.records_failed = failed if errors: log.error_details = errors log.mark_completed() db.commit() return { "success": True, "processed": len(unsynced_leads), "created": created, "failed": failed, "errors": errors if errors else None } except Exception as e: logger.error(f"Error in export_leads_to_odoo: {e}") log.mark_failed(str(e)) db.commit() return {"success": False, "error": str(e)} finally: db.close() async def get_sales_summary(self, days: int = 30) -> Dict: """ Get sales summary from Odoo. Args: days: Number of days to look back Returns: Dict with sales statistics """ try: if not self._connect(): return {"success": False, "error": "Cannot connect to Odoo"} from datetime import timedelta start_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") # Get confirmed sales orders order_ids = self._execute( "sale.order", "search", [["state", "in", ["sale", "done"]], ["date_order", ">=", start_date]] ) orders_data = self._execute( "sale.order", "read", order_ids, fields=["id", "name", "amount_total", "date_order", "partner_id", "state"] ) total_revenue = sum(order.get("amount_total", 0) for order in orders_data) total_orders = len(orders_data) return { "success": True, "period_days": days, "total_orders": total_orders, "total_revenue": total_revenue, "avg_order_value": total_revenue / total_orders if total_orders > 0 else 0, "orders": orders_data[:10] # Return latest 10 } except Exception as e: logger.error(f"Error getting sales summary: {e}") return {"success": False, "error": str(e)} async def get_sync_logs(self, limit: int = 20) -> List[Dict]: """Get recent sync logs.""" db = self._get_db() try: logs = db.query(OdooSyncLog).order_by( OdooSyncLog.started_at.desc() ).limit(limit).all() return [log.to_dict() for log in logs] finally: db.close() # Global instance odoo_service = OdooService()