2609 lines
70 KiB
Markdown
2609 lines
70 KiB
Markdown
# 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
|