Files
WhatsAppCentralizado/docs/plans/2026-01-29-fase-5-integracion-odoo.md

70 KiB
Raw Permalink Blame History

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

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

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

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

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

# Integrations Service

Step 6: Commit

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

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

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

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

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

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

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

from app.schemas.partner import (
    PartnerBase,
    PartnerCreate,
    PartnerUpdate,
    PartnerResponse,
    PartnerSearchResult,
)

__all__ = [
    "PartnerBase",
    "PartnerCreate",
    "PartnerUpdate",
    "PartnerResponse",
    "PartnerSearchResult",
]

Step 4: Create services/init.py

from app.services.partner import PartnerService

__all__ = ["PartnerService"]

Step 5: Commit

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

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

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

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

from app.services.partner import PartnerService
from app.services.sale import SaleOrderService

__all__ = ["PartnerService", "SaleOrderService"]

Step 5: Commit

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

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

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

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

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

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

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

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

from app.routers.odoo import router as odoo_router

__all__ = ["odoo_router"]

Step 3: Update main.py

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

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

# Add to Settings class:
INTEGRATIONS_URL: str = "http://localhost:8002"

Step 2: Create nodes/odoo.py

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

# 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

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

  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

  flow-engine:
    environment:
      # ... existing vars ...
      INTEGRATIONS_URL: http://integrations:8002

Step 3: Update .env.example with Odoo section

Step 4: Commit

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

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<OdooConfig>('/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 <Spin />;
  }

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 24 }}>
        <Title level={4} style={{ margin: 0 }}>Configuración Odoo</Title>
        <Space>
          {config?.is_connected ? (
            <Tag icon={<CheckCircleOutlined />} color="success">Conectado</Tag>
          ) : (
            <Tag icon={<CloseCircleOutlined />} color="error">Desconectado</Tag>
          )}
        </Space>
      </div>

      <Card>
        <Form form={form} layout="vertical" style={{ maxWidth: 500 }}>
          <Form.Item
            name="url"
            label="URL de Odoo"
            rules={[{ required: true, message: 'Ingrese la URL' }]}
          >
            <Input
              prefix={<LinkOutlined />}
              placeholder="https://tu-empresa.odoo.com"
            />
          </Form.Item>

          <Form.Item
            name="database"
            label="Base de Datos"
            rules={[{ required: true, message: 'Ingrese el nombre de la base de datos' }]}
          >
            <Input placeholder="nombre_bd" />
          </Form.Item>

          <Form.Item
            name="username"
            label="Usuario (Email)"
            rules={[{ required: true, message: 'Ingrese el usuario' }]}
          >
            <Input placeholder="usuario@empresa.com" />
          </Form.Item>

          <Form.Item
            name="api_key"
            label="API Key"
            extra="Dejar vacío para mantener la actual"
          >
            <Input.Password placeholder="Nueva API Key (opcional)" />
          </Form.Item>

          <Alert
            message="Cómo obtener la API Key"
            description={
              <ol style={{ paddingLeft: 20, margin: 0 }}>
                <li>Inicia sesión en Odoo</li>
                <li>Ve a Ajustes  Usuarios</li>
                <li>Selecciona tu usuario</li>
                <li>En la pestaña "Preferencias", genera una API Key</li>
              </ol>
            }
            type="info"
            style={{ marginBottom: 24 }}
          />

          <Space>
            <Button
              type="primary"
              onClick={handleSave}
              loading={saveMutation.isPending}
            >
              Guardar
            </Button>
            <Button
              icon={<ReloadOutlined spin={testStatus === 'testing'} />}
              onClick={handleTest}
              loading={testMutation.isPending}
            >
              Probar Conexión
            </Button>
          </Space>
        </Form>
      </Card>
    </div>
  );
}

Step 2: Add route to MainLayout.tsx

Add import and route for OdooConfig page.

Step 3: Commit

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

const OdooSearchPartnerNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>🔍 Buscar Cliente Odoo</strong>
  </div>
);

const OdooCreatePartnerNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong> Crear Cliente Odoo</strong>
  </div>
);

const OdooGetBalanceNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>💰 Saldo Cliente</strong>
  </div>
);

const OdooSearchOrdersNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>📦 Buscar Pedidos</strong>
  </div>
);

const OdooGetOrderNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>📋 Detalle Pedido</strong>
  </div>
);

const OdooSearchProductsNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>🏷️ Buscar Productos</strong>
  </div>
);

const OdooCheckStockNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>📊 Verificar Stock</strong>
  </div>
);

const OdooCreateLeadNode = () => (
  <div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
    <strong>🎯 Crear Lead CRM</strong>
  </div>
);

Step 2: Register in nodeTypes

Step 3: Add dropdown menu for Odoo nodes

<Dropdown
  menu={{
    items: [
      { key: 'odoo_search_partner', label: '🔍 Buscar Cliente', onClick: () => 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') },
    ],
  }}
>
  <Button style={{ background: '#714B67', color: 'white' }}>+ Odoo</Button>
</Dropdown>

Step 4: Commit

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

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

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

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

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

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

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

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

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

docker-compose build integrations

Step 2: Final commit if any pending changes

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

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