# Fase 5: Integración Odoo Completa - Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implementar integración completa con Odoo vía XML-RPC, incluyendo sincronización de contactos, nodos de flujo para operaciones Odoo, y eventos bidireccionales. **Architecture:** Crear servicio de integraciones con cliente Odoo XML-RPC reutilizable. Los nodos Odoo en el Flow Engine delegarán al servicio de integraciones. Webhooks permitirán eventos Odoo → WhatsApp. **Tech Stack:** Python, xmlrpc.client, FastAPI, httpx, PostgreSQL --- ## Contexto ### Estructura Existente - `services/integrations/app/` - Directorio vacío (solo .gitkeep) - `services/api-gateway/` - API principal con modelos Contact (tiene `odoo_partner_id`) - `services/flow-engine/app/nodes/` - Sistema de nodos extensible con NodeRegistry ### Modelos Odoo a Integrar - `res.partner` - Contactos/Clientes - `crm.lead` - Oportunidades CRM - `sale.order` - Pedidos de venta - `product.product` - Productos - `stock.picking` - Envíos - `account.move` - Facturas - `helpdesk.ticket` - Tickets de soporte --- ## Task 1: Integrations Service Setup **Files:** - Create: `services/integrations/app/__init__.py` - Create: `services/integrations/app/main.py` - Create: `services/integrations/app/config.py` - Create: `services/integrations/requirements.txt` - Create: `services/integrations/Dockerfile` **Step 1: Create requirements.txt** ```txt fastapi==0.109.0 uvicorn[standard]==0.27.0 pydantic==2.5.3 pydantic-settings==2.1.0 httpx==0.26.0 python-multipart==0.0.6 ``` **Step 2: Create config.py** ```python from pydantic_settings import BaseSettings from functools import lru_cache class Settings(BaseSettings): # Odoo Connection ODOO_URL: str = "" ODOO_DB: str = "" ODOO_USER: str = "" ODOO_API_KEY: str = "" # Internal Services API_GATEWAY_URL: str = "http://localhost:8000" FLOW_ENGINE_URL: str = "http://localhost:8001" class Config: env_file = ".env" @lru_cache def get_settings() -> Settings: return Settings() ``` **Step 3: Create main.py** ```python from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI(title="WhatsApp Central - Integrations Service") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/health") def health_check(): return {"status": "healthy", "service": "integrations"} ``` **Step 4: Create Dockerfile** ```dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY ./app ./app CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"] ``` **Step 5: Create __init__.py** ```python # Integrations Service ``` **Step 6: Commit** ```bash git add services/integrations/ git commit -m "feat(integrations): setup integrations service structure" ``` --- ## Task 2: Odoo XML-RPC Client **Files:** - Create: `services/integrations/app/odoo/__init__.py` - Create: `services/integrations/app/odoo/client.py` - Create: `services/integrations/app/odoo/exceptions.py` **Step 1: Create exceptions.py** ```python class OdooError(Exception): """Base Odoo exception""" pass class OdooConnectionError(OdooError): """Failed to connect to Odoo""" pass class OdooAuthError(OdooError): """Authentication failed""" pass class OdooNotFoundError(OdooError): """Record not found""" pass class OdooValidationError(OdooError): """Validation error from Odoo""" pass ``` **Step 2: Create client.py** ```python import xmlrpc.client from typing import Any, Optional from functools import lru_cache from app.config import get_settings from app.odoo.exceptions import ( OdooConnectionError, OdooAuthError, OdooNotFoundError, OdooValidationError, ) settings = get_settings() class OdooClient: """XML-RPC client for Odoo""" def __init__( self, url: str = None, db: str = None, user: str = None, api_key: str = None, ): self.url = url or settings.ODOO_URL self.db = db or settings.ODOO_DB self.user = user or settings.ODOO_USER self.api_key = api_key or settings.ODOO_API_KEY self._uid: Optional[int] = None self._common = None self._models = None def _get_common(self): if not self._common: try: self._common = xmlrpc.client.ServerProxy( f"{self.url}/xmlrpc/2/common", allow_none=True, ) except Exception as e: raise OdooConnectionError(f"Failed to connect: {e}") return self._common def _get_models(self): if not self._models: try: self._models = xmlrpc.client.ServerProxy( f"{self.url}/xmlrpc/2/object", allow_none=True, ) except Exception as e: raise OdooConnectionError(f"Failed to connect: {e}") return self._models def authenticate(self) -> int: """Authenticate and return user ID""" if self._uid: return self._uid if not all([self.url, self.db, self.user, self.api_key]): raise OdooAuthError("Missing Odoo credentials") try: common = self._get_common() uid = common.authenticate(self.db, self.user, self.api_key, {}) if not uid: raise OdooAuthError("Invalid credentials") self._uid = uid return uid except OdooAuthError: raise except Exception as e: raise OdooConnectionError(f"Authentication failed: {e}") def execute( self, model: str, method: str, *args, **kwargs, ) -> Any: """Execute Odoo method""" uid = self.authenticate() models = self._get_models() try: return models.execute_kw( self.db, uid, self.api_key, model, method, args, kwargs, ) except xmlrpc.client.Fault as e: if "not found" in str(e).lower(): raise OdooNotFoundError(str(e)) if "validation" in str(e).lower(): raise OdooValidationError(str(e)) raise OdooError(str(e)) def search( self, model: str, domain: list, limit: int = None, offset: int = 0, order: str = None, ) -> list[int]: """Search records""" kwargs = {"offset": offset} if limit: kwargs["limit"] = limit if order: kwargs["order"] = order return self.execute(model, "search", domain, **kwargs) def read( self, model: str, ids: list[int], fields: list[str] = None, ) -> list[dict]: """Read records by IDs""" kwargs = {} if fields: kwargs["fields"] = fields return self.execute(model, "read", ids, **kwargs) def search_read( self, model: str, domain: list, fields: list[str] = None, limit: int = None, offset: int = 0, order: str = None, ) -> list[dict]: """Search and read in one call""" kwargs = {"offset": offset} if fields: kwargs["fields"] = fields if limit: kwargs["limit"] = limit if order: kwargs["order"] = order return self.execute(model, "search_read", domain, **kwargs) def create(self, model: str, values: dict) -> int: """Create a record""" return self.execute(model, "create", [values]) def write(self, model: str, ids: list[int], values: dict) -> bool: """Update records""" return self.execute(model, "write", ids, values) def unlink(self, model: str, ids: list[int]) -> bool: """Delete records""" return self.execute(model, "unlink", ids) @lru_cache def get_odoo_client() -> OdooClient: return OdooClient() ``` **Step 3: Create __init__.py** ```python from app.odoo.client import OdooClient, get_odoo_client from app.odoo.exceptions import ( OdooError, OdooConnectionError, OdooAuthError, OdooNotFoundError, OdooValidationError, ) __all__ = [ "OdooClient", "get_odoo_client", "OdooError", "OdooConnectionError", "OdooAuthError", "OdooNotFoundError", "OdooValidationError", ] ``` **Step 4: Commit** ```bash git add services/integrations/app/odoo/ git commit -m "feat(integrations): add Odoo XML-RPC client" ``` --- ## Task 3: Partner (Contact) Service **Files:** - Create: `services/integrations/app/services/__init__.py` - Create: `services/integrations/app/services/partner.py` - Create: `services/integrations/app/schemas/__init__.py` - Create: `services/integrations/app/schemas/partner.py` **Step 1: Create schemas/partner.py** ```python from pydantic import BaseModel from typing import Optional class PartnerBase(BaseModel): name: str phone: Optional[str] = None mobile: Optional[str] = None email: Optional[str] = None street: Optional[str] = None city: Optional[str] = None country_id: Optional[int] = None comment: Optional[str] = None class PartnerCreate(PartnerBase): pass class PartnerUpdate(BaseModel): name: Optional[str] = None phone: Optional[str] = None mobile: Optional[str] = None email: Optional[str] = None street: Optional[str] = None city: Optional[str] = None comment: Optional[str] = None class PartnerResponse(PartnerBase): id: int display_name: Optional[str] = None credit: Optional[float] = None debit: Optional[float] = None credit_limit: Optional[float] = None class PartnerSearchResult(BaseModel): id: int name: str phone: Optional[str] = None mobile: Optional[str] = None email: Optional[str] = None ``` **Step 2: Create services/partner.py** ```python from typing import Optional from app.odoo import get_odoo_client, OdooNotFoundError from app.schemas.partner import ( PartnerCreate, PartnerUpdate, PartnerResponse, PartnerSearchResult, ) class PartnerService: """Service for Odoo res.partner operations""" MODEL = "res.partner" FIELDS = [ "id", "name", "display_name", "phone", "mobile", "email", "street", "city", "country_id", "comment", "credit", "debit", "credit_limit", ] def __init__(self): self.client = get_odoo_client() def search_by_phone(self, phone: str) -> Optional[PartnerSearchResult]: """Search partner by phone number""" # Normalize phone (remove spaces, dashes) normalized = phone.replace(" ", "").replace("-", "") # Search in phone and mobile fields domain = [ "|", ("phone", "ilike", normalized), ("mobile", "ilike", normalized), ] results = self.client.search_read( self.MODEL, domain, fields=["id", "name", "phone", "mobile", "email"], limit=1, ) if results: return PartnerSearchResult(**results[0]) return None def search_by_email(self, email: str) -> Optional[PartnerSearchResult]: """Search partner by email""" results = self.client.search_read( self.MODEL, [("email", "=ilike", email)], fields=["id", "name", "phone", "mobile", "email"], limit=1, ) if results: return PartnerSearchResult(**results[0]) return None def get_by_id(self, partner_id: int) -> PartnerResponse: """Get partner by ID""" results = self.client.read(self.MODEL, [partner_id], self.FIELDS) if not results: raise OdooNotFoundError(f"Partner {partner_id} not found") data = results[0] # Handle country_id tuple (id, name) if data.get("country_id") and isinstance(data["country_id"], (list, tuple)): data["country_id"] = data["country_id"][0] return PartnerResponse(**data) def create(self, data: PartnerCreate) -> int: """Create a new partner""" values = data.model_dump(exclude_none=True) return self.client.create(self.MODEL, values) def update(self, partner_id: int, data: PartnerUpdate) -> bool: """Update a partner""" values = data.model_dump(exclude_none=True) if not values: return True return self.client.write(self.MODEL, [partner_id], values) def get_balance(self, partner_id: int) -> dict: """Get partner balance (credit/debit)""" results = self.client.read( self.MODEL, [partner_id], ["credit", "debit", "credit_limit"], ) if not results: raise OdooNotFoundError(f"Partner {partner_id} not found") data = results[0] return { "credit": data.get("credit", 0), "debit": data.get("debit", 0), "balance": data.get("debit", 0) - data.get("credit", 0), "credit_limit": data.get("credit_limit", 0), } ``` **Step 3: Create schemas/__init__.py** ```python from app.schemas.partner import ( PartnerBase, PartnerCreate, PartnerUpdate, PartnerResponse, PartnerSearchResult, ) __all__ = [ "PartnerBase", "PartnerCreate", "PartnerUpdate", "PartnerResponse", "PartnerSearchResult", ] ``` **Step 4: Create services/__init__.py** ```python from app.services.partner import PartnerService __all__ = ["PartnerService"] ``` **Step 5: Commit** ```bash git add services/integrations/app/schemas/ services/integrations/app/services/ git commit -m "feat(integrations): add Partner service for Odoo contacts" ``` --- ## Task 4: Sales Order Service **Files:** - Create: `services/integrations/app/schemas/sale.py` - Create: `services/integrations/app/services/sale.py` - Modify: `services/integrations/app/schemas/__init__.py` - Modify: `services/integrations/app/services/__init__.py` **Step 1: Create schemas/sale.py** ```python from pydantic import BaseModel from typing import Optional, List from datetime import datetime class SaleOrderLine(BaseModel): id: int product_id: int product_name: str quantity: float price_unit: float price_subtotal: float class SaleOrderResponse(BaseModel): id: int name: str state: str state_display: str partner_id: int partner_name: str date_order: Optional[str] = None amount_total: float amount_untaxed: float amount_tax: float currency: str order_lines: List[SaleOrderLine] = [] class SaleOrderSearchResult(BaseModel): id: int name: str state: str date_order: Optional[str] = None amount_total: float class QuotationCreate(BaseModel): partner_id: int lines: List[dict] # [{"product_id": 1, "quantity": 2}] note: Optional[str] = None ``` **Step 2: Create services/sale.py** ```python from typing import List, Optional from app.odoo import get_odoo_client, OdooNotFoundError from app.schemas.sale import ( SaleOrderResponse, SaleOrderSearchResult, SaleOrderLine, QuotationCreate, ) STATE_DISPLAY = { "draft": "Presupuesto", "sent": "Presupuesto Enviado", "sale": "Pedido de Venta", "done": "Bloqueado", "cancel": "Cancelado", } class SaleOrderService: """Service for Odoo sale.order operations""" MODEL = "sale.order" LINE_MODEL = "sale.order.line" def __init__(self): self.client = get_odoo_client() def search_by_partner( self, partner_id: int, state: str = None, limit: int = 10, ) -> List[SaleOrderSearchResult]: """Search orders by partner""" domain = [("partner_id", "=", partner_id)] if state: domain.append(("state", "=", state)) results = self.client.search_read( self.MODEL, domain, fields=["id", "name", "state", "date_order", "amount_total"], limit=limit, order="date_order desc", ) return [SaleOrderSearchResult(**r) for r in results] def get_by_id(self, order_id: int) -> SaleOrderResponse: """Get order details""" results = self.client.read( self.MODEL, [order_id], [ "id", "name", "state", "partner_id", "date_order", "amount_total", "amount_untaxed", "amount_tax", "currency_id", "order_line", ], ) if not results: raise OdooNotFoundError(f"Sale order {order_id} not found") order = results[0] # Get order lines lines = [] if order.get("order_line"): line_data = self.client.read( self.LINE_MODEL, order["order_line"], ["id", "product_id", "product_uom_qty", "price_unit", "price_subtotal"], ) for line in line_data: lines.append(SaleOrderLine( id=line["id"], product_id=line["product_id"][0] if line.get("product_id") else 0, product_name=line["product_id"][1] if line.get("product_id") else "", quantity=line.get("product_uom_qty", 0), price_unit=line.get("price_unit", 0), price_subtotal=line.get("price_subtotal", 0), )) return SaleOrderResponse( id=order["id"], name=order["name"], state=order["state"], state_display=STATE_DISPLAY.get(order["state"], order["state"]), partner_id=order["partner_id"][0] if order.get("partner_id") else 0, partner_name=order["partner_id"][1] if order.get("partner_id") else "", date_order=order.get("date_order"), amount_total=order.get("amount_total", 0), amount_untaxed=order.get("amount_untaxed", 0), amount_tax=order.get("amount_tax", 0), currency=order["currency_id"][1] if order.get("currency_id") else "USD", order_lines=lines, ) def get_by_name(self, name: str) -> Optional[SaleOrderResponse]: """Get order by name (SO001)""" ids = self.client.search(self.MODEL, [("name", "=", name)], limit=1) if not ids: return None return self.get_by_id(ids[0]) def create_quotation(self, data: QuotationCreate) -> int: """Create a quotation""" # Create order header order_id = self.client.create(self.MODEL, { "partner_id": data.partner_id, "note": data.note, }) # Add order lines for line in data.lines: self.client.create(self.LINE_MODEL, { "order_id": order_id, "product_id": line["product_id"], "product_uom_qty": line.get("quantity", 1), }) return order_id def confirm_order(self, order_id: int) -> bool: """Confirm quotation to sale order""" return self.client.execute(self.MODEL, "action_confirm", [order_id]) def get_pdf_url(self, order_id: int) -> str: """Get URL to download order PDF""" # This returns a URL pattern - actual implementation depends on Odoo setup return f"{self.client.url}/report/pdf/sale.report_saleorder/{order_id}" ``` **Step 3: Update schemas/__init__.py** ```python from app.schemas.partner import ( PartnerBase, PartnerCreate, PartnerUpdate, PartnerResponse, PartnerSearchResult, ) from app.schemas.sale import ( SaleOrderLine, SaleOrderResponse, SaleOrderSearchResult, QuotationCreate, ) __all__ = [ "PartnerBase", "PartnerCreate", "PartnerUpdate", "PartnerResponse", "PartnerSearchResult", "SaleOrderLine", "SaleOrderResponse", "SaleOrderSearchResult", "QuotationCreate", ] ``` **Step 4: Update services/__init__.py** ```python from app.services.partner import PartnerService from app.services.sale import SaleOrderService __all__ = ["PartnerService", "SaleOrderService"] ``` **Step 5: Commit** ```bash git add services/integrations/app/schemas/ services/integrations/app/services/ git commit -m "feat(integrations): add SaleOrder service for Odoo sales" ``` --- ## Task 5: Product and Stock Services **Files:** - Create: `services/integrations/app/schemas/product.py` - Create: `services/integrations/app/services/product.py` - Modify: `services/integrations/app/schemas/__init__.py` - Modify: `services/integrations/app/services/__init__.py` **Step 1: Create schemas/product.py** ```python from pydantic import BaseModel from typing import Optional, List class ProductResponse(BaseModel): id: int name: str default_code: Optional[str] = None # SKU list_price: float qty_available: float virtual_available: float # Available - Reserved description: Optional[str] = None categ_name: Optional[str] = None class ProductSearchResult(BaseModel): id: int name: str default_code: Optional[str] = None list_price: float qty_available: float class StockInfo(BaseModel): product_id: int product_name: str qty_available: float qty_reserved: float qty_incoming: float qty_outgoing: float virtual_available: float ``` **Step 2: Create services/product.py** ```python from typing import List, Optional from app.odoo import get_odoo_client, OdooNotFoundError from app.schemas.product import ProductResponse, ProductSearchResult, StockInfo class ProductService: """Service for Odoo product operations""" MODEL = "product.product" TEMPLATE_MODEL = "product.template" def __init__(self): self.client = get_odoo_client() def search( self, query: str = None, category_id: int = None, limit: int = 20, ) -> List[ProductSearchResult]: """Search products""" domain = [("sale_ok", "=", True)] if query: domain.append("|") domain.append(("name", "ilike", query)) domain.append(("default_code", "ilike", query)) if category_id: domain.append(("categ_id", "=", category_id)) results = self.client.search_read( self.MODEL, domain, fields=["id", "name", "default_code", "list_price", "qty_available"], limit=limit, ) return [ProductSearchResult(**r) for r in results] def get_by_id(self, product_id: int) -> ProductResponse: """Get product details""" results = self.client.read( self.MODEL, [product_id], [ "id", "name", "default_code", "list_price", "qty_available", "virtual_available", "description_sale", "categ_id", ], ) if not results: raise OdooNotFoundError(f"Product {product_id} not found") p = results[0] return ProductResponse( id=p["id"], name=p["name"], default_code=p.get("default_code"), list_price=p.get("list_price", 0), qty_available=p.get("qty_available", 0), virtual_available=p.get("virtual_available", 0), description=p.get("description_sale"), categ_name=p["categ_id"][1] if p.get("categ_id") else None, ) def get_by_sku(self, sku: str) -> Optional[ProductResponse]: """Get product by SKU (default_code)""" ids = self.client.search( self.MODEL, [("default_code", "=", sku)], limit=1, ) if not ids: return None return self.get_by_id(ids[0]) def check_stock(self, product_id: int) -> StockInfo: """Get stock info for a product""" results = self.client.read( self.MODEL, [product_id], [ "id", "name", "qty_available", "virtual_available", "incoming_qty", "outgoing_qty", ], ) if not results: raise OdooNotFoundError(f"Product {product_id} not found") p = results[0] qty_available = p.get("qty_available", 0) virtual = p.get("virtual_available", 0) return StockInfo( product_id=p["id"], product_name=p["name"], qty_available=qty_available, qty_reserved=qty_available - virtual if qty_available > virtual else 0, qty_incoming=p.get("incoming_qty", 0), qty_outgoing=p.get("outgoing_qty", 0), virtual_available=virtual, ) def check_availability(self, product_id: int, quantity: float) -> dict: """Check if quantity is available""" stock = self.check_stock(product_id) available = stock.virtual_available >= quantity return { "available": available, "requested": quantity, "in_stock": stock.qty_available, "virtual_available": stock.virtual_available, "shortage": max(0, quantity - stock.virtual_available), } ``` **Step 3: Update schemas/__init__.py** - Add product imports **Step 4: Update services/__init__.py** - Add ProductService **Step 5: Commit** ```bash git add services/integrations/app/schemas/ services/integrations/app/services/ git commit -m "feat(integrations): add Product and Stock services" ``` --- ## Task 6: CRM Lead Service **Files:** - Create: `services/integrations/app/schemas/crm.py` - Create: `services/integrations/app/services/crm.py` **Step 1: Create schemas/crm.py** ```python from pydantic import BaseModel from typing import Optional class LeadCreate(BaseModel): name: str partner_id: Optional[int] = None contact_name: Optional[str] = None phone: Optional[str] = None mobile: Optional[str] = None email_from: Optional[str] = None description: Optional[str] = None expected_revenue: Optional[float] = None source: Optional[str] = "WhatsApp" class LeadResponse(BaseModel): id: int name: str stage_id: int stage_name: str partner_id: Optional[int] = None partner_name: Optional[str] = None contact_name: Optional[str] = None phone: Optional[str] = None email_from: Optional[str] = None expected_revenue: float probability: float user_id: Optional[int] = None user_name: Optional[str] = None class LeadSearchResult(BaseModel): id: int name: str stage_name: str expected_revenue: float probability: float ``` **Step 2: Create services/crm.py** ```python from typing import List, Optional from app.odoo import get_odoo_client, OdooNotFoundError from app.schemas.crm import LeadCreate, LeadResponse, LeadSearchResult class CRMService: """Service for Odoo CRM operations""" MODEL = "crm.lead" def __init__(self): self.client = get_odoo_client() def create_lead(self, data: LeadCreate) -> int: """Create a new lead/opportunity""" values = data.model_dump(exclude_none=True) # Set source if provided if data.source: source_ids = self.client.search( "utm.source", [("name", "=", data.source)], limit=1, ) if source_ids: values["source_id"] = source_ids[0] del values["source"] return self.client.create(self.MODEL, values) def get_by_id(self, lead_id: int) -> LeadResponse: """Get lead details""" results = self.client.read( self.MODEL, [lead_id], [ "id", "name", "stage_id", "partner_id", "contact_name", "phone", "email_from", "expected_revenue", "probability", "user_id", ], ) if not results: raise OdooNotFoundError(f"Lead {lead_id} not found") lead = results[0] return LeadResponse( id=lead["id"], name=lead["name"], stage_id=lead["stage_id"][0] if lead.get("stage_id") else 0, stage_name=lead["stage_id"][1] if lead.get("stage_id") else "", partner_id=lead["partner_id"][0] if lead.get("partner_id") else None, partner_name=lead["partner_id"][1] if lead.get("partner_id") else None, contact_name=lead.get("contact_name"), phone=lead.get("phone"), email_from=lead.get("email_from"), expected_revenue=lead.get("expected_revenue", 0), probability=lead.get("probability", 0), user_id=lead["user_id"][0] if lead.get("user_id") else None, user_name=lead["user_id"][1] if lead.get("user_id") else None, ) def search_by_partner( self, partner_id: int, limit: int = 10, ) -> List[LeadSearchResult]: """Search leads by partner""" results = self.client.search_read( self.MODEL, [("partner_id", "=", partner_id)], fields=["id", "name", "stage_id", "expected_revenue", "probability"], limit=limit, order="create_date desc", ) return [ LeadSearchResult( id=r["id"], name=r["name"], stage_name=r["stage_id"][1] if r.get("stage_id") else "", expected_revenue=r.get("expected_revenue", 0), probability=r.get("probability", 0), ) for r in results ] def update_stage(self, lead_id: int, stage_id: int) -> bool: """Move lead to different stage""" return self.client.write(self.MODEL, [lead_id], {"stage_id": stage_id}) def add_note(self, lead_id: int, note: str) -> int: """Add internal note to lead""" return self.client.create("mail.message", { "model": self.MODEL, "res_id": lead_id, "body": note, "message_type": "comment", }) def get_stages(self) -> List[dict]: """Get all CRM stages""" return self.client.search_read( "crm.stage", [], fields=["id", "name", "sequence"], order="sequence", ) ``` **Step 3: Update __init__ files** **Step 4: Commit** ```bash git add services/integrations/app/schemas/ services/integrations/app/services/ git commit -m "feat(integrations): add CRM Lead service" ``` --- ## Task 7: Integrations API Routes **Files:** - Create: `services/integrations/app/routers/__init__.py` - Create: `services/integrations/app/routers/odoo.py` - Modify: `services/integrations/app/main.py` **Step 1: Create routers/odoo.py** ```python from fastapi import APIRouter, HTTPException from typing import Optional from app.services.partner import PartnerService from app.services.sale import SaleOrderService from app.services.product import ProductService from app.services.crm import CRMService from app.schemas.partner import PartnerCreate, PartnerUpdate from app.schemas.sale import QuotationCreate from app.schemas.crm import LeadCreate from app.odoo.exceptions import OdooError, OdooNotFoundError router = APIRouter(prefix="/api/odoo", tags=["odoo"]) # ============== Partners ============== @router.get("/partners/search") def search_partner(phone: str = None, email: str = None): """Search partner by phone or email""" service = PartnerService() if phone: result = service.search_by_phone(phone) elif email: result = service.search_by_email(email) else: raise HTTPException(400, "Provide phone or email") if not result: raise HTTPException(404, "Partner not found") return result @router.get("/partners/{partner_id}") def get_partner(partner_id: int): """Get partner by ID""" try: service = PartnerService() return service.get_by_id(partner_id) except OdooNotFoundError: raise HTTPException(404, "Partner not found") @router.post("/partners") def create_partner(data: PartnerCreate): """Create a new partner""" service = PartnerService() partner_id = service.create(data) return {"id": partner_id} @router.put("/partners/{partner_id}") def update_partner(partner_id: int, data: PartnerUpdate): """Update a partner""" service = PartnerService() service.update(partner_id, data) return {"success": True} @router.get("/partners/{partner_id}/balance") def get_partner_balance(partner_id: int): """Get partner balance""" try: service = PartnerService() return service.get_balance(partner_id) except OdooNotFoundError: raise HTTPException(404, "Partner not found") # ============== Sales ============== @router.get("/sales/partner/{partner_id}") def get_partner_orders(partner_id: int, state: str = None, limit: int = 10): """Get orders for a partner""" service = SaleOrderService() return service.search_by_partner(partner_id, state, limit) @router.get("/sales/{order_id}") def get_order(order_id: int): """Get order details""" try: service = SaleOrderService() return service.get_by_id(order_id) except OdooNotFoundError: raise HTTPException(404, "Order not found") @router.get("/sales/name/{name}") def get_order_by_name(name: str): """Get order by name (SO001)""" service = SaleOrderService() result = service.get_by_name(name) if not result: raise HTTPException(404, "Order not found") return result @router.post("/sales/quotation") def create_quotation(data: QuotationCreate): """Create a quotation""" service = SaleOrderService() order_id = service.create_quotation(data) return {"id": order_id} @router.post("/sales/{order_id}/confirm") def confirm_order(order_id: int): """Confirm quotation to sale order""" try: service = SaleOrderService() service.confirm_order(order_id) return {"success": True} except OdooError as e: raise HTTPException(400, str(e)) # ============== Products ============== @router.get("/products") def search_products(q: str = None, category_id: int = None, limit: int = 20): """Search products""" service = ProductService() return service.search(q, category_id, limit) @router.get("/products/{product_id}") def get_product(product_id: int): """Get product details""" try: service = ProductService() return service.get_by_id(product_id) except OdooNotFoundError: raise HTTPException(404, "Product not found") @router.get("/products/sku/{sku}") def get_product_by_sku(sku: str): """Get product by SKU""" service = ProductService() result = service.get_by_sku(sku) if not result: raise HTTPException(404, "Product not found") return result @router.get("/products/{product_id}/stock") def check_product_stock(product_id: int): """Check product stock""" try: service = ProductService() return service.check_stock(product_id) except OdooNotFoundError: raise HTTPException(404, "Product not found") @router.get("/products/{product_id}/availability") def check_availability(product_id: int, quantity: float): """Check if quantity is available""" try: service = ProductService() return service.check_availability(product_id, quantity) except OdooNotFoundError: raise HTTPException(404, "Product not found") # ============== CRM ============== @router.post("/crm/leads") def create_lead(data: LeadCreate): """Create a new lead""" service = CRMService() lead_id = service.create_lead(data) return {"id": lead_id} @router.get("/crm/leads/{lead_id}") def get_lead(lead_id: int): """Get lead details""" try: service = CRMService() return service.get_by_id(lead_id) except OdooNotFoundError: raise HTTPException(404, "Lead not found") @router.get("/crm/leads/partner/{partner_id}") def get_partner_leads(partner_id: int, limit: int = 10): """Get leads for a partner""" service = CRMService() return service.search_by_partner(partner_id, limit) @router.put("/crm/leads/{lead_id}/stage") def update_lead_stage(lead_id: int, stage_id: int): """Update lead stage""" service = CRMService() service.update_stage(lead_id, stage_id) return {"success": True} @router.post("/crm/leads/{lead_id}/note") def add_lead_note(lead_id: int, note: str): """Add note to lead""" service = CRMService() message_id = service.add_note(lead_id, note) return {"message_id": message_id} @router.get("/crm/stages") def get_crm_stages(): """Get all CRM stages""" service = CRMService() return service.get_stages() ``` **Step 2: Create routers/__init__.py** ```python from app.routers.odoo import router as odoo_router __all__ = ["odoo_router"] ``` **Step 3: Update main.py** ```python from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.routers import odoo_router app = FastAPI(title="WhatsApp Central - Integrations Service") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(odoo_router) @app.get("/health") def health_check(): return {"status": "healthy", "service": "integrations"} ``` **Step 4: Commit** ```bash git add services/integrations/app/routers/ services/integrations/app/main.py git commit -m "feat(integrations): add Odoo API routes" ``` --- ## Task 8: Odoo Flow Engine Nodes **Files:** - Create: `services/flow-engine/app/nodes/odoo.py` - Modify: `services/flow-engine/app/nodes/__init__.py` - Modify: `services/flow-engine/app/engine.py` - Modify: `services/flow-engine/app/config.py` **Step 1: Update config.py - Add INTEGRATIONS_URL** ```python # Add to Settings class: INTEGRATIONS_URL: str = "http://localhost:8002" ``` **Step 2: Create nodes/odoo.py** ```python from typing import Any, Optional import httpx from app.config import get_settings from app.context import FlowContext from app.nodes.base import NodeExecutor settings = get_settings() class OdooSearchPartnerExecutor(NodeExecutor): """Search Odoo partner by phone""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: phone = context.interpolate(config.get("phone", "{{contact.phone_number}}")) output_var = config.get("output_variable", "_odoo_partner") async with httpx.AsyncClient() as client: response = await client.get( f"{settings.INTEGRATIONS_URL}/api/odoo/partners/search", params={"phone": phone}, timeout=15, ) if response.status_code == 200: context.set(output_var, response.json()) return "found" elif response.status_code == 404: context.set(output_var, None) return "not_found" else: context.set("_odoo_error", response.text) return "error" class OdooCreatePartnerExecutor(NodeExecutor): """Create Odoo partner""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: data = { "name": context.interpolate(config.get("name", "{{contact.name}}")), "phone": context.interpolate(config.get("phone", "{{contact.phone_number}}")), } if config.get("email"): data["email"] = context.interpolate(config["email"]) async with httpx.AsyncClient() as client: response = await client.post( f"{settings.INTEGRATIONS_URL}/api/odoo/partners", json=data, timeout=15, ) if response.status_code == 200: result = response.json() context.set("_odoo_partner_id", result["id"]) return "success" else: context.set("_odoo_error", response.text) return "error" class OdooGetBalanceExecutor(NodeExecutor): """Get partner balance""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: partner_id = config.get("partner_id") or context.get("_odoo_partner.id") output_var = config.get("output_variable", "_odoo_balance") if not partner_id: return "error" async with httpx.AsyncClient() as client: response = await client.get( f"{settings.INTEGRATIONS_URL}/api/odoo/partners/{partner_id}/balance", timeout=15, ) if response.status_code == 200: context.set(output_var, response.json()) return "success" else: return "error" class OdooSearchOrdersExecutor(NodeExecutor): """Search orders for partner""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: partner_id = config.get("partner_id") or context.get("_odoo_partner.id") state = config.get("state") limit = config.get("limit", 5) output_var = config.get("output_variable", "_odoo_orders") if not partner_id: return "error" params = {"limit": limit} if state: params["state"] = state async with httpx.AsyncClient() as client: response = await client.get( f"{settings.INTEGRATIONS_URL}/api/odoo/sales/partner/{partner_id}", params=params, timeout=15, ) if response.status_code == 200: orders = response.json() context.set(output_var, orders) return "found" if orders else "not_found" else: return "error" class OdooGetOrderExecutor(NodeExecutor): """Get order details by ID or name""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: order_id = config.get("order_id") order_name = config.get("order_name") output_var = config.get("output_variable", "_odoo_order") async with httpx.AsyncClient() as client: if order_id: url = f"{settings.INTEGRATIONS_URL}/api/odoo/sales/{order_id}" elif order_name: name = context.interpolate(order_name) url = f"{settings.INTEGRATIONS_URL}/api/odoo/sales/name/{name}" else: return "error" response = await client.get(url, timeout=15) if response.status_code == 200: context.set(output_var, response.json()) return "found" elif response.status_code == 404: return "not_found" else: return "error" class OdooSearchProductsExecutor(NodeExecutor): """Search products""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: query = context.interpolate(config.get("query", "")) limit = config.get("limit", 10) output_var = config.get("output_variable", "_odoo_products") async with httpx.AsyncClient() as client: response = await client.get( f"{settings.INTEGRATIONS_URL}/api/odoo/products", params={"q": query, "limit": limit}, timeout=15, ) if response.status_code == 200: products = response.json() context.set(output_var, products) return "found" if products else "not_found" else: return "error" class OdooCheckStockExecutor(NodeExecutor): """Check product stock""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: product_id = config.get("product_id") quantity = config.get("quantity", 1) output_var = config.get("output_variable", "_odoo_stock") if not product_id: return "error" async with httpx.AsyncClient() as client: response = await client.get( f"{settings.INTEGRATIONS_URL}/api/odoo/products/{product_id}/availability", params={"quantity": quantity}, timeout=15, ) if response.status_code == 200: result = response.json() context.set(output_var, result) return "available" if result["available"] else "unavailable" else: return "error" class OdooCreateLeadExecutor(NodeExecutor): """Create CRM lead""" async def execute( self, config: dict, context: FlowContext, session: Any ) -> Optional[str]: data = { "name": context.interpolate(config.get("name", "Lead desde WhatsApp")), "contact_name": context.interpolate(config.get("contact_name", "{{contact.name}}")), "phone": context.interpolate(config.get("phone", "{{contact.phone_number}}")), } if config.get("email"): data["email_from"] = context.interpolate(config["email"]) if config.get("description"): data["description"] = context.interpolate(config["description"]) if config.get("expected_revenue"): data["expected_revenue"] = config["expected_revenue"] partner = context.get("_odoo_partner") if partner and partner.get("id"): data["partner_id"] = partner["id"] async with httpx.AsyncClient() as client: response = await client.post( f"{settings.INTEGRATIONS_URL}/api/odoo/crm/leads", json=data, timeout=15, ) if response.status_code == 200: result = response.json() context.set("_odoo_lead_id", result["id"]) return "success" else: context.set("_odoo_error", response.text) return "error" ``` **Step 3: Update nodes/__init__.py - Export Odoo executors** **Step 4: Update engine.py - Register Odoo executors** ```python # Add imports from app.nodes.odoo import ( OdooSearchPartnerExecutor, OdooCreatePartnerExecutor, OdooGetBalanceExecutor, OdooSearchOrdersExecutor, OdooGetOrderExecutor, OdooSearchProductsExecutor, OdooCheckStockExecutor, OdooCreateLeadExecutor, ) # Add to _register_executors(): NodeRegistry.register("odoo_search_partner", OdooSearchPartnerExecutor()) NodeRegistry.register("odoo_create_partner", OdooCreatePartnerExecutor()) NodeRegistry.register("odoo_get_balance", OdooGetBalanceExecutor()) NodeRegistry.register("odoo_search_orders", OdooSearchOrdersExecutor()) NodeRegistry.register("odoo_get_order", OdooGetOrderExecutor()) NodeRegistry.register("odoo_search_products", OdooSearchProductsExecutor()) NodeRegistry.register("odoo_check_stock", OdooCheckStockExecutor()) NodeRegistry.register("odoo_create_lead", OdooCreateLeadExecutor()) ``` **Step 5: Commit** ```bash git add services/flow-engine/ git commit -m "feat(flow-engine): add Odoo node executors" ``` --- ## Task 9: Update Docker Compose **Files:** - Modify: `docker-compose.yml` - Modify: `.env.example` **Step 1: Add integrations service to docker-compose.yml** ```yaml integrations: build: context: ./services/integrations dockerfile: Dockerfile container_name: wac_integrations restart: unless-stopped environment: ODOO_URL: ${ODOO_URL:-} ODOO_DB: ${ODOO_DB:-} ODOO_USER: ${ODOO_USER:-} ODOO_API_KEY: ${ODOO_API_KEY:-} API_GATEWAY_URL: http://api-gateway:8000 FLOW_ENGINE_URL: http://flow-engine:8001 ports: - "8002:8002" networks: - wac_network ``` **Step 2: Update flow-engine environment** ```yaml flow-engine: environment: # ... existing vars ... INTEGRATIONS_URL: http://integrations:8002 ``` **Step 3: Update .env.example with Odoo section** **Step 4: Commit** ```bash git add docker-compose.yml .env.example git commit -m "chore(docker): add integrations service" ``` --- ## Task 10: Frontend Odoo Config Page **Files:** - Create: `frontend/src/pages/OdooConfig.tsx` - Modify: `frontend/src/layouts/MainLayout.tsx` **Step 1: Create OdooConfig.tsx** ```tsx import { useState, useEffect } from 'react'; import { Card, Form, Input, Button, Space, Typography, Alert, Spin, Tag, message, } from 'antd'; import { LinkOutlined, CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, } from '@ant-design/icons'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../api/client'; const { Title, Text } = Typography; interface OdooConfig { url: string; database: string; username: string; is_connected: boolean; } interface OdooConfigUpdate { url: string; database: string; username: string; api_key?: string; } export default function OdooConfig() { const [form] = Form.useForm(); const queryClient = useQueryClient(); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); const { data: config, isLoading } = useQuery({ queryKey: ['odoo-config'], queryFn: () => apiClient.get('/api/integrations/odoo/config'), }); useEffect(() => { if (config) { form.setFieldsValue({ url: config.url, database: config.database, username: config.username, }); } }, [config, form]); const saveMutation = useMutation({ mutationFn: (data: OdooConfigUpdate) => apiClient.put('/api/integrations/odoo/config', data), onSuccess: () => { message.success('Configuración guardada'); queryClient.invalidateQueries({ queryKey: ['odoo-config'] }); }, onError: () => { message.error('Error al guardar'); }, }); const testMutation = useMutation({ mutationFn: () => apiClient.post('/api/integrations/odoo/test'), onSuccess: () => { setTestStatus('success'); message.success('Conexión exitosa'); queryClient.invalidateQueries({ queryKey: ['odoo-config'] }); }, onError: () => { setTestStatus('error'); message.error('Error de conexión'); }, }); const handleTest = () => { setTestStatus('testing'); testMutation.mutate(); }; const handleSave = async () => { const values = await form.validateFields(); saveMutation.mutate(values); }; if (isLoading) { return ; } return (
Configuración Odoo {config?.is_connected ? ( } color="success">Conectado ) : ( } color="error">Desconectado )}
} placeholder="https://tu-empresa.odoo.com" />
  • Inicia sesión en Odoo
  • Ve a Ajustes → Usuarios
  • Selecciona tu usuario
  • En la pestaña "Preferencias", genera una API Key
  • } type="info" style={{ marginBottom: 24 }} />
    ); } ``` **Step 2: Add route to MainLayout.tsx** Add import and route for OdooConfig page. **Step 3: Commit** ```bash git add frontend/src/pages/OdooConfig.tsx frontend/src/layouts/MainLayout.tsx git commit -m "feat(frontend): add Odoo configuration page" ``` --- ## Task 11: Frontend Odoo Node Components **Files:** - Modify: `frontend/src/pages/FlowBuilder.tsx` **Step 1: Add Odoo node components** ```tsx const OdooSearchPartnerNode = () => (
    🔍 Buscar Cliente Odoo
    ); const OdooCreatePartnerNode = () => (
    ➕ Crear Cliente Odoo
    ); const OdooGetBalanceNode = () => (
    💰 Saldo Cliente
    ); const OdooSearchOrdersNode = () => (
    📦 Buscar Pedidos
    ); const OdooGetOrderNode = () => (
    📋 Detalle Pedido
    ); const OdooSearchProductsNode = () => (
    🏷️ Buscar Productos
    ); const OdooCheckStockNode = () => (
    📊 Verificar Stock
    ); const OdooCreateLeadNode = () => (
    🎯 Crear Lead CRM
    ); ``` **Step 2: Register in nodeTypes** **Step 3: Add dropdown menu for Odoo nodes** ```tsx addNode('odoo_search_partner') }, { key: 'odoo_create_partner', label: '➕ Crear Cliente', onClick: () => addNode('odoo_create_partner') }, { key: 'odoo_get_balance', label: '💰 Saldo Cliente', onClick: () => addNode('odoo_get_balance') }, { key: 'odoo_search_orders', label: '📦 Buscar Pedidos', onClick: () => addNode('odoo_search_orders') }, { key: 'odoo_get_order', label: '📋 Detalle Pedido', onClick: () => addNode('odoo_get_order') }, { key: 'odoo_search_products', label: '🏷️ Buscar Productos', onClick: () => addNode('odoo_search_products') }, { key: 'odoo_check_stock', label: '📊 Verificar Stock', onClick: () => addNode('odoo_check_stock') }, { key: 'odoo_create_lead', label: '🎯 Crear Lead CRM', onClick: () => addNode('odoo_create_lead') }, ], }} > ``` **Step 4: Commit** ```bash git add frontend/src/pages/FlowBuilder.tsx git commit -m "feat(frontend): add Odoo node components to FlowBuilder" ``` --- ## Task 12: API Gateway Odoo Config Endpoints **Files:** - Create: `services/api-gateway/app/models/odoo_config.py` - Create: `services/api-gateway/app/routers/integrations.py` - Modify: `services/api-gateway/app/models/__init__.py` - Modify: `services/api-gateway/app/main.py` **Step 1: Create models/odoo_config.py** ```python import uuid from datetime import datetime from sqlalchemy import Column, String, Boolean, DateTime, Text from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base class OdooConfig(Base): __tablename__ = "odoo_config" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) url = Column(String(255), nullable=False) database = Column(String(100), nullable=False) username = Column(String(255), nullable=False) api_key_encrypted = Column(Text, nullable=True) is_active = Column(Boolean, default=True) last_sync_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) ``` **Step 2: Create routers/integrations.py** ```python from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from pydantic import BaseModel from typing import Optional import httpx from app.core.database import get_db from app.core.security import get_current_user from app.core.config import get_settings from app.models.user import User, UserRole from app.models.odoo_config import OdooConfig router = APIRouter(prefix="/api/integrations", tags=["integrations"]) settings = get_settings() def require_admin(current_user: User = Depends(get_current_user)): if current_user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Admin required") return current_user class OdooConfigResponse(BaseModel): url: str database: str username: str is_connected: bool class OdooConfigUpdate(BaseModel): url: str database: str username: str api_key: Optional[str] = None @router.get("/odoo/config", response_model=OdooConfigResponse) def get_odoo_config( db: Session = Depends(get_db), current_user: User = Depends(require_admin), ): config = db.query(OdooConfig).filter(OdooConfig.is_active == True).first() if not config: return OdooConfigResponse( url="", database="", username="", is_connected=False, ) return OdooConfigResponse( url=config.url, database=config.database, username=config.username, is_connected=config.api_key_encrypted is not None, ) @router.put("/odoo/config") def update_odoo_config( data: OdooConfigUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_admin), ): config = db.query(OdooConfig).filter(OdooConfig.is_active == True).first() if not config: config = OdooConfig() db.add(config) config.url = data.url config.database = data.database config.username = data.username if data.api_key: # In production, encrypt the API key config.api_key_encrypted = data.api_key db.commit() return {"success": True} @router.post("/odoo/test") async def test_odoo_connection( db: Session = Depends(get_db), current_user: User = Depends(require_admin), ): config = db.query(OdooConfig).filter(OdooConfig.is_active == True).first() if not config or not config.api_key_encrypted: raise HTTPException(400, "Odoo not configured") # Test connection via integrations service async with httpx.AsyncClient() as client: try: response = await client.get( f"{settings.INTEGRATIONS_URL}/api/odoo/partners/search", params={"phone": "0000000000"}, timeout=10, ) # 404 is OK (no partner found, but connection worked) if response.status_code in [200, 404]: return {"success": True, "message": "Conexión exitosa"} else: raise HTTPException(500, f"Odoo error: {response.text}") except httpx.RequestError as e: raise HTTPException(500, f"Connection error: {str(e)}") ``` **Step 3: Update imports and include router in main.py** **Step 4: Commit** ```bash git add services/api-gateway/ git commit -m "feat(api-gateway): add Odoo config endpoints" ``` --- ## Task 13: Contact Sync Service **Files:** - Create: `services/integrations/app/services/sync.py` - Create: `services/integrations/app/routers/sync.py` - Modify: `services/integrations/app/main.py` **Step 1: Create services/sync.py** ```python from typing import Optional import httpx from app.config import get_settings from app.services.partner import PartnerService from app.schemas.partner import PartnerCreate settings = get_settings() class ContactSyncService: """Sync contacts between WhatsApp Central and Odoo""" def __init__(self): self.partner_service = PartnerService() async def sync_contact_to_odoo( self, contact_id: str, phone: str, name: str, email: str = None, ) -> Optional[int]: """ Sync a WhatsApp contact to Odoo. Returns Odoo partner_id. """ # Check if partner exists partner = self.partner_service.search_by_phone(phone) if partner: # Update if needed return partner.id # Create new partner data = PartnerCreate( name=name or phone, mobile=phone, email=email, ) partner_id = self.partner_service.create(data) # Update contact in API Gateway with odoo_partner_id await self._update_contact_odoo_id(contact_id, partner_id) return partner_id async def _update_contact_odoo_id(self, contact_id: str, odoo_id: int): """Update contact's odoo_partner_id in API Gateway""" async with httpx.AsyncClient() as client: await client.patch( f"{settings.API_GATEWAY_URL}/api/internal/contacts/{contact_id}", json={"odoo_partner_id": odoo_id}, timeout=10, ) async def sync_partner_to_contact(self, partner_id: int) -> Optional[str]: """ Sync Odoo partner to WhatsApp contact. Returns contact_id if found/created. """ partner = self.partner_service.get_by_id(partner_id) if not partner.phone and not partner.mobile: return None phone = partner.mobile or partner.phone # Search contact in API Gateway async with httpx.AsyncClient() as client: response = await client.get( f"{settings.API_GATEWAY_URL}/api/internal/contacts/search", params={"phone": phone}, timeout=10, ) if response.status_code == 200: contact = response.json() # Update odoo_partner_id if not set if not contact.get("odoo_partner_id"): await self._update_contact_odoo_id(contact["id"], partner_id) return contact["id"] # Contact not found - could create one when they message return None ``` **Step 2: Create routers/sync.py** ```python from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import Optional from app.services.sync import ContactSyncService router = APIRouter(prefix="/api/sync", tags=["sync"]) class SyncContactRequest(BaseModel): contact_id: str phone: str name: Optional[str] = None email: Optional[str] = None @router.post("/contact-to-odoo") async def sync_contact_to_odoo(request: SyncContactRequest): """Sync WhatsApp contact to Odoo partner""" service = ContactSyncService() partner_id = await service.sync_contact_to_odoo( contact_id=request.contact_id, phone=request.phone, name=request.name, email=request.email, ) if partner_id: return {"success": True, "odoo_partner_id": partner_id} raise HTTPException(500, "Failed to sync contact") @router.post("/partner-to-contact/{partner_id}") async def sync_partner_to_contact(partner_id: int): """Sync Odoo partner to WhatsApp contact""" service = ContactSyncService() contact_id = await service.sync_partner_to_contact(partner_id) return { "success": True, "contact_id": contact_id, "message": "Contact found" if contact_id else "No matching contact", } ``` **Step 3: Update main.py to include sync router** **Step 4: Commit** ```bash git add services/integrations/ git commit -m "feat(integrations): add contact sync service" ``` --- ## Task 14: Webhooks for Odoo Events **Files:** - Create: `services/integrations/app/routers/webhooks.py` - Modify: `services/integrations/app/main.py` **Step 1: Create routers/webhooks.py** ```python from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel from typing import Optional, Any import httpx import hmac import hashlib from app.config import get_settings router = APIRouter(prefix="/api/webhooks", tags=["webhooks"]) settings = get_settings() class OdooWebhookPayload(BaseModel): model: str action: str # create, write, unlink record_id: int values: dict = {} old_values: dict = {} def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool: """Verify webhook signature""" expected = hmac.new( secret.encode(), payload, hashlib.sha256, ).hexdigest() return hmac.compare_digest(signature, expected) @router.post("/odoo") async def handle_odoo_webhook( payload: OdooWebhookPayload, x_odoo_signature: str = Header(None), ): """ Handle webhooks from Odoo. Odoo sends events when records are created/updated/deleted. """ # Verify signature in production # if not verify_webhook_signature(...): # raise HTTPException(403, "Invalid signature") handlers = { "sale.order": handle_sale_order_event, "stock.picking": handle_stock_picking_event, "account.move": handle_invoice_event, } handler = handlers.get(payload.model) if handler: await handler(payload) return {"status": "received"} async def handle_sale_order_event(payload: OdooWebhookPayload): """Handle sale order events""" if payload.action != "write": return old_state = payload.old_values.get("state") new_state = payload.values.get("state") # Order confirmed if old_state == "draft" and new_state == "sale": await send_order_confirmation(payload.record_id) # Order sent/delivered elif new_state == "done": await send_order_delivered(payload.record_id) async def handle_stock_picking_event(payload: OdooWebhookPayload): """Handle stock picking (delivery) events""" if payload.action != "write": return new_state = payload.values.get("state") # Shipment sent if new_state == "done": await send_shipment_notification(payload.record_id) async def handle_invoice_event(payload: OdooWebhookPayload): """Handle invoice events""" if payload.action != "write": return # Payment received if payload.values.get("payment_state") == "paid": await send_payment_confirmation(payload.record_id) async def send_order_confirmation(order_id: int): """Send WhatsApp message for order confirmation""" # Get order details from Odoo async with httpx.AsyncClient() as client: response = await client.get( f"http://localhost:8002/api/odoo/sales/{order_id}", timeout=10, ) if response.status_code != 200: return order = response.json() # Find contact by partner phone partner_response = await client.get( f"http://localhost:8002/api/odoo/partners/{order['partner_id']}", timeout=10, ) if partner_response.status_code != 200: return partner = partner_response.json() phone = partner.get("mobile") or partner.get("phone") if not phone: return # Send message via API Gateway message = f"""✅ *Pedido Confirmado* Hola {partner.get('name', '')}, Tu pedido *{order['name']}* ha sido confirmado. 📦 Total: {order['currency']} {order['amount_total']:.2f} Gracias por tu compra.""" await client.post( f"{settings.API_GATEWAY_URL}/api/internal/send-by-phone", json={"phone": phone, "message": message}, timeout=10, ) async def send_shipment_notification(picking_id: int): """Send WhatsApp message for shipment""" # Implementation similar to send_order_confirmation pass async def send_order_delivered(order_id: int): """Send WhatsApp message for delivered order""" pass async def send_payment_confirmation(invoice_id: int): """Send WhatsApp message for payment received""" pass ``` **Step 2: Update main.py to include webhooks router** **Step 3: Commit** ```bash git add services/integrations/app/routers/webhooks.py services/integrations/app/main.py git commit -m "feat(integrations): add Odoo webhooks handler" ``` --- ## Task 15: Final Integration Commit **Files:** - Review all changes - Run services to verify - Push to remote **Step 1: Verify all services build** ```bash docker-compose build integrations ``` **Step 2: Final commit if any pending changes** ```bash git status git add -A git commit -m "feat(fase5): complete Odoo integration - Integrations service with XML-RPC client - Partner, Sales, Product, CRM services - Odoo flow engine nodes - Contact sync service - Webhooks for Odoo events - Frontend Odoo config page - Frontend Odoo node components" ``` **Step 3: Push to remote** ```bash git push origin main ``` --- ## Summary Phase 5 implements complete Odoo integration: 1. **Integrations Service** - New microservice for external integrations 2. **XML-RPC Client** - Reusable Odoo client with auth caching 3. **Partner Service** - Search, create, update contacts 4. **Sales Service** - Orders, quotations, confirmations 5. **Product Service** - Search, stock checking 6. **CRM Service** - Leads, stages, notes 7. **Flow Engine Nodes** - 8 Odoo nodes for visual flows 8. **Contact Sync** - Bidirectional sync between platforms 9. **Webhooks** - Real-time events from Odoo 10. **Frontend** - Config page and node components