Phase 1 - Analytics y Reportes: - PostMetrics and AnalyticsReport models for tracking engagement - Analytics service with dashboard stats, top posts, optimal times - 8 API endpoints at /api/analytics/* - Interactive dashboard with Chart.js charts - Celery tasks for metrics fetch (15min) and weekly reports Phase 2 - Integración Odoo: - Lead and OdooSyncLog models for CRM integration - Odoo fields added to Product and Service models - XML-RPC service for bidirectional sync - Lead management API at /api/leads/* - Leads dashboard template - Celery tasks for product/service sync and lead export Phase 3 - A/B Testing y Recycling: - ABTest, ABTestVariant, RecycledPost models - Statistical winner analysis using chi-square test - Content recycling with engagement-based scoring - APIs at /api/ab-tests/* and /api/recycling/* - Automated test evaluation and content recycling tasks Phase 4 - Thread Series y Templates: - ThreadSeries and ThreadPost models for multi-post threads - AI-powered thread generation - Enhanced ImageTemplate with HTML template support - APIs at /api/threads/* and /api/templates/* - Thread scheduling with reply chain support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
494 lines
16 KiB
Python
494 lines
16 KiB
Python
"""
|
|
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()
|