From 24850e23f08327d95ab4d6da5ce0705596a4873e Mon Sep 17 00:00:00 2001 From: Claude AI Date: Thu, 29 Jan 2026 22:13:18 +0000 Subject: [PATCH] docs: add Fase 5 Odoo Integration implementation plan --- .../2026-01-29-fase-5-integracion-odoo.md | 2608 +++++++++++++++++ 1 file changed, 2608 insertions(+) create mode 100644 docs/plans/2026-01-29-fase-5-integracion-odoo.md diff --git a/docs/plans/2026-01-29-fase-5-integracion-odoo.md b/docs/plans/2026-01-29-fase-5-integracion-odoo.md new file mode 100644 index 0000000..773be35 --- /dev/null +++ b/docs/plans/2026-01-29-fase-5-integracion-odoo.md @@ -0,0 +1,2608 @@ +# 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