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

2609 lines
70 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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 = () => (
<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**
```tsx
<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**
```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