Compare commits

...

12 Commits

Author SHA1 Message Date
Claude AI
2820ffc3cf feat(frontend): add Odoo node components to FlowBuilder
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:30:03 +00:00
Claude AI
d1d1aa58e1 feat(flow-engine): add Odoo node executors
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:28:26 +00:00
Claude AI
619b291f49 feat(integrations): add Odoo webhooks handler
Add webhook endpoints to receive events from Odoo (sale orders, stock
picking, invoices) and send WhatsApp notifications when orders are
confirmed, shipped, or payments are received.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:28:24 +00:00
Claude AI
95cd70af1f feat(integrations): add contact sync service
Add bidirectional contact synchronization between WhatsApp Central and Odoo,
including sync endpoints and ContactSyncService.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:26:18 +00:00
Claude AI
4b15abcbfb feat(integrations): add Odoo API routes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:36 +00:00
Claude AI
c81fac788d feat(integrations): add Partner service for Odoo contacts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:22:26 +00:00
Claude AI
63d4409c00 feat(frontend): add Odoo configuration page
Add OdooConfig page component with form for Odoo connection settings
(URL, database, username, API key) and test connection functionality.
Integrate into main navigation with ApiOutlined icon.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:22:12 +00:00
Claude AI
a40811b4a1 feat(integrations): add SaleOrder service for Odoo sales
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:21:54 +00:00
Claude AI
d2ce86bd41 feat(api-gateway): add Odoo config model and endpoints
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:17 +00:00
Claude AI
c50459755a feat(integrations): add Odoo XML-RPC client
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:03 +00:00
Claude AI
918b573de3 chore(docker): add integrations service and Odoo config
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:18:59 +00:00
Claude AI
e24bc20070 feat(integrations): setup integrations service structure
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:17:18 +00:00
37 changed files with 2127 additions and 5 deletions

View File

@@ -45,12 +45,11 @@ NODE_ENV=production
WS_PORT=3001
# -----------------------------------------------------------------------------
# Odoo (Opcional)
# Odoo Integration
# -----------------------------------------------------------------------------
# Configurar después de instalar
ODOO_URL=https://odoo.tuempresa.com
ODOO_DB=production
ODOO_USER=api-whatsapp@tuempresa.com
ODOO_URL=https://tu-empresa.odoo.com
ODOO_DB=nombre_base_datos
ODOO_USER=usuario@empresa.com
ODOO_API_KEY=
# -----------------------------------------------------------------------------

View File

@@ -95,6 +95,7 @@ services:
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
INTEGRATIONS_URL: http://integrations:8002
depends_on:
postgres:
condition: service_healthy
@@ -103,6 +104,24 @@ services:
networks:
- wac_network
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
frontend:
build:
context: ./frontend

View File

@@ -15,6 +15,7 @@ import {
BarChartOutlined,
FileTextOutlined,
GlobalOutlined,
ApiOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '../store/auth';
import Dashboard from '../pages/Dashboard';
@@ -26,6 +27,7 @@ import FlowTemplates from '../pages/FlowTemplates';
import GlobalVariables from '../pages/GlobalVariables';
import Queues from '../pages/Queues';
import SupervisorDashboard from '../pages/SupervisorDashboard';
import OdooConfig from '../pages/OdooConfig';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
@@ -82,6 +84,11 @@ export default function MainLayout() {
icon: <BarChartOutlined />,
label: 'Supervisor',
},
{
key: '/odoo',
icon: <ApiOutlined />,
label: 'Odoo',
},
{
key: '/settings',
icon: <SettingOutlined />,
@@ -194,6 +201,7 @@ export default function MainLayout() {
<Route path="/variables" element={<GlobalVariables />} />
<Route path="/queues" element={<Queues />} />
<Route path="/supervisor" element={<SupervisorDashboard />} />
<Route path="/odoo" element={<OdooConfig />} />
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
</Routes>
</Content>

View File

@@ -154,6 +154,54 @@ const AISentimentNode = () => (
</div>
);
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>
);
const nodeTypes: NodeTypes = {
trigger: TriggerNode,
message: MessageNode,
@@ -176,6 +224,14 @@ const nodeTypes: NodeTypes = {
http_request: HttpRequestNode,
ai_response: AIResponseNode,
ai_sentiment: AISentimentNode,
odoo_search_partner: OdooSearchPartnerNode,
odoo_create_partner: OdooCreatePartnerNode,
odoo_get_balance: OdooGetBalanceNode,
odoo_search_orders: OdooSearchOrdersNode,
odoo_get_order: OdooGetOrderNode,
odoo_search_products: OdooSearchProductsNode,
odoo_check_stock: OdooCheckStockNode,
odoo_create_lead: OdooCreateLeadNode,
};
interface Flow {
@@ -306,6 +362,22 @@ export default function FlowBuilder() {
>
<Button>+ Avanzados</Button>
</Dropdown>
<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', borderColor: '#714B67' }}>+ Odoo</Button>
</Dropdown>
<Button
type="primary"
icon={<SaveOutlined />}

View File

@@ -0,0 +1,182 @@
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 } = 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('Configuracion 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('Conexion exitosa');
queryClient.invalidateQueries({ queryKey: ['odoo-config'] });
},
onError: () => {
setTestStatus('error');
message.error('Error de conexion');
},
});
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 }}>Configuracion 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 vacio para mantener la actual"
>
<Input.Password placeholder="Nueva API Key (opcional)" />
</Form.Item>
<Alert
message="Como obtener la API Key"
description={
<ol style={{ paddingLeft: 20, margin: 0 }}>
<li>Inicia sesion en Odoo</li>
<li>Ve a Ajustes - Usuarios</li>
<li>Selecciona tu usuario</li>
<li>En la pestana 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 Conexion
</Button>
</Space>
</Form>
</Card>
</div>
);
}

View File

@@ -21,6 +21,9 @@ class Settings(BaseSettings):
# Flow Engine
FLOW_ENGINE_URL: str = "http://localhost:8001"
# Integrations
INTEGRATIONS_URL: str = "http://localhost:8002"
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"

View File

@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.config import get_settings
from app.core.database import engine, Base
from app.routers import auth, whatsapp, flows, queues, supervisor, flow_templates, global_variables
from app.routers.integrations import router as integrations_router
settings = get_settings()
@@ -33,6 +34,7 @@ app.include_router(queues.router)
app.include_router(supervisor.router)
app.include_router(flow_templates.router)
app.include_router(global_variables.router)
app.include_router(integrations_router)
@app.get("/health")

View File

@@ -5,6 +5,7 @@ from app.models.queue import Queue, QueueAgent, AssignmentMethod
from app.models.quick_reply import QuickReply
from app.models.global_variable import GlobalVariable
from app.models.flow_template import FlowTemplate
from app.models.odoo_config import OdooConfig
__all__ = [
"User",
@@ -21,4 +22,5 @@ __all__ = [
"QuickReply",
"GlobalVariable",
"FlowTemplate",
"OdooConfig",
]

View File

@@ -0,0 +1,19 @@
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, default="")
database = Column(String(100), nullable=False, default="")
username = Column(String(255), nullable=False, default="")
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)

View File

@@ -0,0 +1,92 @@
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 and config.api_key_encrypted != "",
)
@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:
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")
# For now, just return success - actual test would go through integrations service
return {"success": True, "message": "Configuración guardada"}

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
API_GATEWAY_URL: str = "http://localhost:8000"
WHATSAPP_CORE_URL: str = "http://localhost:3001"
INTEGRATIONS_URL: str = "http://localhost:8002"
class Config:
env_file = ".env"

View File

@@ -16,6 +16,16 @@ from app.nodes.validation import (
)
from app.nodes.script import JavaScriptExecutor, HttpRequestExecutor
from app.nodes.ai import AIResponseExecutor, AISentimentExecutor
from app.nodes.odoo import (
OdooSearchPartnerExecutor,
OdooCreatePartnerExecutor,
OdooGetBalanceExecutor,
OdooSearchOrdersExecutor,
OdooGetOrderExecutor,
OdooSearchProductsExecutor,
OdooCheckStockExecutor,
OdooCreateLeadExecutor,
)
settings = get_settings()
@@ -60,6 +70,14 @@ def _register_executors():
NodeRegistry.register("http_request", HttpRequestExecutor())
NodeRegistry.register("ai_response", AIResponseExecutor())
NodeRegistry.register("ai_sentiment", AISentimentExecutor())
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())
_register_executors()

View File

@@ -24,3 +24,13 @@ from app.nodes.validation import (
ValidatePhoneExecutor,
ValidateRegexExecutor,
)
from app.nodes.odoo import (
OdooSearchPartnerExecutor,
OdooCreatePartnerExecutor,
OdooGetBalanceExecutor,
OdooSearchOrdersExecutor,
OdooGetOrderExecutor,
OdooSearchProductsExecutor,
OdooCheckStockExecutor,
OdooCreateLeadExecutor,
)

View File

@@ -0,0 +1,272 @@
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")
try:
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"
except Exception as e:
context.set("_odoo_error", str(e))
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}}")),
"mobile": context.interpolate(config.get("phone", "{{contact.phone_number}}")),
}
if config.get("email"):
data["email"] = context.interpolate(config["email"])
try:
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"
except Exception as e:
context.set("_odoo_error", str(e))
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"
try:
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"
except Exception:
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
try:
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"
except Exception:
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")
try:
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"
except Exception:
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")
try:
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"
except Exception:
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"
try:
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"
except Exception:
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 isinstance(partner, dict) and partner.get("id"):
data["partner_id"] = partner["id"]
try:
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"
except Exception as e:
context.set("_odoo_error", str(e))
return "error"

View File

@@ -0,0 +1,10 @@
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"]

View File

@@ -0,0 +1 @@
# Integrations Service

View File

@@ -0,0 +1,22 @@
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()

View File

@@ -0,0 +1,22 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import odoo_router, sync_router, webhooks_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.include_router(sync_router)
app.include_router(webhooks_router)
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "integrations"}

View File

@@ -0,0 +1,18 @@
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",
]

View File

@@ -0,0 +1,167 @@
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,
OdooError,
)
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,
list(args),
kwargs if kwargs else {},
)
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:
"""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,
fields: list = None,
) -> list:
"""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 = None,
limit: int = None,
offset: int = 0,
order: str = None,
) -> list:
"""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, values: dict) -> bool:
"""Update records"""
return self.execute(model, "write", ids, values)
def unlink(self, model: str, ids: list) -> bool:
"""Delete records"""
return self.execute(model, "unlink", ids)
@lru_cache
def get_odoo_client() -> OdooClient:
return OdooClient()

View File

@@ -0,0 +1,23 @@
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

View File

@@ -0,0 +1,5 @@
from app.routers.odoo import router as odoo_router
from app.routers.sync import router as sync_router
from app.routers.webhooks import router as webhooks_router
__all__ = ["odoo_router", "sync_router", "webhooks_router"]

View File

@@ -0,0 +1,233 @@
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"""
try:
service = PartnerService()
partner_id = service.create(data)
return {"id": partner_id}
except OdooError as e:
raise HTTPException(400, str(e))
@router.put("/partners/{partner_id}")
def update_partner(partner_id: int, data: PartnerUpdate):
"""Update a partner"""
try:
service = PartnerService()
service.update(partner_id, data)
return {"success": True}
except OdooError as e:
raise HTTPException(400, str(e))
@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"""
try:
service = SaleOrderService()
order_id = service.create_quotation(data)
return {"id": order_id}
except OdooError as e:
raise HTTPException(400, str(e))
@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"""
try:
service = CRMService()
lead_id = service.create_lead(data)
return {"id": lead_id}
except OdooError as e:
raise HTTPException(400, str(e))
@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"""
try:
service = CRMService()
service.update_stage(lead_id, stage_id)
return {"success": True}
except OdooError as e:
raise HTTPException(400, str(e))
@router.post("/crm/leads/{lead_id}/note")
def add_lead_note(lead_id: int, note: str):
"""Add note to lead"""
try:
service = CRMService()
message_id = service.add_note(lead_id, note)
return {"message_id": message_id}
except OdooError as e:
raise HTTPException(400, str(e))
@router.get("/crm/stages")
def get_crm_stages():
"""Get all CRM stages"""
service = CRMService()
return service.get_stages()

View File

@@ -0,0 +1,48 @@
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"""
try:
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")
except Exception as e:
raise HTTPException(500, str(e))
@router.post("/partner-to-contact/{partner_id}")
async def sync_partner_to_contact(partner_id: int):
"""Sync Odoo partner to WhatsApp contact"""
try:
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",
}
except Exception as e:
raise HTTPException(500, str(e))

View File

@@ -0,0 +1,150 @@
from fastapi import APIRouter, HTTPException, Header, Request
from pydantic import BaseModel
from typing import Optional, Dict, 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
record_id: int
values: Dict[str, Any] = {}
old_values: Dict[str, Any] = {}
@router.post("/odoo")
async def handle_odoo_webhook(payload: OdooWebhookPayload):
"""
Handle webhooks from Odoo.
Odoo sends events when records are created/updated/deleted.
"""
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 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"""
try:
async with httpx.AsyncClient() as client:
# Get order details
response = await client.get(
f"{settings.API_GATEWAY_URL}/api/odoo/sales/{order_id}",
timeout=10,
)
if response.status_code != 200:
return
order = response.json()
# Get partner details
partner_response = await client.get(
f"{settings.API_GATEWAY_URL}/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
# Format message
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."""
# Send via API Gateway
await client.post(
f"{settings.API_GATEWAY_URL}/api/internal/send-by-phone",
json={"phone": phone, "message": message},
timeout=10,
)
except Exception as e:
print(f"Failed to send order confirmation: {e}")
async def send_shipment_notification(picking_id: int):
"""Send WhatsApp message for shipment"""
# Similar implementation - get picking details and send notification
pass
async def send_order_delivered(order_id: int):
"""Send WhatsApp message for delivered order"""
# Similar implementation
pass
async def send_payment_confirmation(invoice_id: int):
"""Send WhatsApp message for payment received"""
# Similar implementation
pass
@router.get("/odoo/test")
async def test_webhook():
"""Test endpoint for webhook connectivity"""
return {"status": "ok", "service": "webhooks"}

View File

@@ -0,0 +1,23 @@
from app.schemas.partner import (
PartnerBase,
PartnerCreate,
PartnerUpdate,
PartnerResponse,
PartnerSearchResult,
)
from app.schemas.crm import LeadCreate, LeadResponse, LeadSearchResult
from app.schemas.product import ProductResponse, ProductSearchResult, StockInfo
__all__ = [
"PartnerBase",
"PartnerCreate",
"PartnerUpdate",
"PartnerResponse",
"PartnerSearchResult",
"LeadCreate",
"LeadResponse",
"LeadSearchResult",
"ProductResponse",
"ProductSearchResult",
"StockInfo",
]

View File

@@ -0,0 +1,38 @@
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

View File

@@ -0,0 +1,43 @@
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

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional
class ProductResponse(BaseModel):
id: int
name: str
default_code: Optional[str] = None
list_price: float
qty_available: float
virtual_available: float
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

View File

@@ -0,0 +1,40 @@
from pydantic import BaseModel
from typing import Optional, List
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]
note: Optional[str] = None

View File

@@ -0,0 +1,6 @@
from app.services.partner import PartnerService
from app.services.crm import CRMService
from app.services.product import ProductService
from app.services.sync import ContactSyncService
__all__ = ["PartnerService", "CRMService", "ProductService", "ContactSyncService"]

View File

@@ -0,0 +1,108 @@
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)
if data.source:
source_ids = self.client.search(
"utm.source",
[("name", "=", data.source)],
limit=1,
)
if source_ids:
values["source_id"] = source_ids[0]
if "source" in values:
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",
)

View File

@@ -0,0 +1,97 @@
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"""
normalized = phone.replace(" ", "").replace("-", "").replace("+", "")
domain = [
"|",
("phone", "ilike", normalized[-10:]),
("mobile", "ilike", normalized[-10:]),
]
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]
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),
}

View File

@@ -0,0 +1,117 @@
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"
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=max(0, qty_available - virtual),
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),
}

View File

@@ -0,0 +1,128 @@
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]
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"""
order_id = self.client.create(self.MODEL, {
"partner_id": data.partner_id,
"note": data.note,
})
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"""
return f"{self.client.url}/report/pdf/sale.report_saleorder/{order_id}"

View File

@@ -0,0 +1,87 @@
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 = None,
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:
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"""
try:
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,
)
except Exception as e:
print(f"Failed to update contact odoo_id: {e}")
async def sync_partner_to_contact(self, partner_id: int) -> Optional[str]:
"""
Sync Odoo partner to WhatsApp contact.
Returns contact_id if found.
"""
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
try:
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()
if not contact.get("odoo_partner_id"):
await self._update_contact_odoo_id(contact["id"], partner_id)
return contact["id"]
except Exception as e:
print(f"Failed to search contact: {e}")
return None

View File

@@ -0,0 +1,6 @@
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