Compare commits
12 Commits
24850e23f0
...
2820ffc3cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2820ffc3cf | ||
|
|
d1d1aa58e1 | ||
|
|
619b291f49 | ||
|
|
95cd70af1f | ||
|
|
4b15abcbfb | ||
|
|
c81fac788d | ||
|
|
63d4409c00 | ||
|
|
a40811b4a1 | ||
|
|
d2ce86bd41 | ||
|
|
c50459755a | ||
|
|
918b573de3 | ||
|
|
e24bc20070 |
@@ -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=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
182
frontend/src/pages/OdooConfig.tsx
Normal file
182
frontend/src/pages/OdooConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
19
services/api-gateway/app/models/odoo_config.py
Normal file
19
services/api-gateway/app/models/odoo_config.py
Normal 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)
|
||||
92
services/api-gateway/app/routers/integrations.py
Normal file
92
services/api-gateway/app/routers/integrations.py
Normal 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"}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,3 +24,13 @@ from app.nodes.validation import (
|
||||
ValidatePhoneExecutor,
|
||||
ValidateRegexExecutor,
|
||||
)
|
||||
from app.nodes.odoo import (
|
||||
OdooSearchPartnerExecutor,
|
||||
OdooCreatePartnerExecutor,
|
||||
OdooGetBalanceExecutor,
|
||||
OdooSearchOrdersExecutor,
|
||||
OdooGetOrderExecutor,
|
||||
OdooSearchProductsExecutor,
|
||||
OdooCheckStockExecutor,
|
||||
OdooCreateLeadExecutor,
|
||||
)
|
||||
|
||||
272
services/flow-engine/app/nodes/odoo.py
Normal file
272
services/flow-engine/app/nodes/odoo.py
Normal 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"
|
||||
10
services/integrations/Dockerfile
Normal file
10
services/integrations/Dockerfile
Normal 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"]
|
||||
1
services/integrations/app/__init__.py
Normal file
1
services/integrations/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Integrations Service
|
||||
22
services/integrations/app/config.py
Normal file
22
services/integrations/app/config.py
Normal 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()
|
||||
22
services/integrations/app/main.py
Normal file
22
services/integrations/app/main.py
Normal 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"}
|
||||
18
services/integrations/app/odoo/__init__.py
Normal file
18
services/integrations/app/odoo/__init__.py
Normal 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",
|
||||
]
|
||||
167
services/integrations/app/odoo/client.py
Normal file
167
services/integrations/app/odoo/client.py
Normal 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()
|
||||
23
services/integrations/app/odoo/exceptions.py
Normal file
23
services/integrations/app/odoo/exceptions.py
Normal 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
|
||||
5
services/integrations/app/routers/__init__.py
Normal file
5
services/integrations/app/routers/__init__.py
Normal 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"]
|
||||
233
services/integrations/app/routers/odoo.py
Normal file
233
services/integrations/app/routers/odoo.py
Normal 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()
|
||||
48
services/integrations/app/routers/sync.py
Normal file
48
services/integrations/app/routers/sync.py
Normal 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))
|
||||
150
services/integrations/app/routers/webhooks.py
Normal file
150
services/integrations/app/routers/webhooks.py
Normal 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"}
|
||||
23
services/integrations/app/schemas/__init__.py
Normal file
23
services/integrations/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
38
services/integrations/app/schemas/crm.py
Normal file
38
services/integrations/app/schemas/crm.py
Normal 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
|
||||
43
services/integrations/app/schemas/partner.py
Normal file
43
services/integrations/app/schemas/partner.py
Normal 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
|
||||
31
services/integrations/app/schemas/product.py
Normal file
31
services/integrations/app/schemas/product.py
Normal 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
|
||||
40
services/integrations/app/schemas/sale.py
Normal file
40
services/integrations/app/schemas/sale.py
Normal 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
|
||||
6
services/integrations/app/services/__init__.py
Normal file
6
services/integrations/app/services/__init__.py
Normal 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"]
|
||||
108
services/integrations/app/services/crm.py
Normal file
108
services/integrations/app/services/crm.py
Normal 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",
|
||||
)
|
||||
97
services/integrations/app/services/partner.py
Normal file
97
services/integrations/app/services/partner.py
Normal 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),
|
||||
}
|
||||
117
services/integrations/app/services/product.py
Normal file
117
services/integrations/app/services/product.py
Normal 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),
|
||||
}
|
||||
128
services/integrations/app/services/sale.py
Normal file
128
services/integrations/app/services/sale.py
Normal 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}"
|
||||
87
services/integrations/app/services/sync.py
Normal file
87
services/integrations/app/services/sync.py
Normal 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
|
||||
6
services/integrations/requirements.txt
Normal file
6
services/integrations/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user