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
|
WS_PORT=3001
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Odoo (Opcional)
|
# Odoo Integration
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Configurar después de instalar
|
ODOO_URL=https://tu-empresa.odoo.com
|
||||||
ODOO_URL=https://odoo.tuempresa.com
|
ODOO_DB=nombre_base_datos
|
||||||
ODOO_DB=production
|
ODOO_USER=usuario@empresa.com
|
||||||
ODOO_USER=api-whatsapp@tuempresa.com
|
|
||||||
ODOO_API_KEY=
|
ODOO_API_KEY=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ services:
|
|||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
||||||
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
||||||
|
INTEGRATIONS_URL: http://integrations:8002
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -103,6 +104,24 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- wac_network
|
- 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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
|
ApiOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import Dashboard from '../pages/Dashboard';
|
import Dashboard from '../pages/Dashboard';
|
||||||
@@ -26,6 +27,7 @@ import FlowTemplates from '../pages/FlowTemplates';
|
|||||||
import GlobalVariables from '../pages/GlobalVariables';
|
import GlobalVariables from '../pages/GlobalVariables';
|
||||||
import Queues from '../pages/Queues';
|
import Queues from '../pages/Queues';
|
||||||
import SupervisorDashboard from '../pages/SupervisorDashboard';
|
import SupervisorDashboard from '../pages/SupervisorDashboard';
|
||||||
|
import OdooConfig from '../pages/OdooConfig';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -82,6 +84,11 @@ export default function MainLayout() {
|
|||||||
icon: <BarChartOutlined />,
|
icon: <BarChartOutlined />,
|
||||||
label: 'Supervisor',
|
label: 'Supervisor',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/odoo',
|
||||||
|
icon: <ApiOutlined />,
|
||||||
|
label: 'Odoo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/settings',
|
key: '/settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
@@ -194,6 +201,7 @@ export default function MainLayout() {
|
|||||||
<Route path="/variables" element={<GlobalVariables />} />
|
<Route path="/variables" element={<GlobalVariables />} />
|
||||||
<Route path="/queues" element={<Queues />} />
|
<Route path="/queues" element={<Queues />} />
|
||||||
<Route path="/supervisor" element={<SupervisorDashboard />} />
|
<Route path="/supervisor" element={<SupervisorDashboard />} />
|
||||||
|
<Route path="/odoo" element={<OdooConfig />} />
|
||||||
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
|
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -154,6 +154,54 @@ const AISentimentNode = () => (
|
|||||||
</div>
|
</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 = {
|
const nodeTypes: NodeTypes = {
|
||||||
trigger: TriggerNode,
|
trigger: TriggerNode,
|
||||||
message: MessageNode,
|
message: MessageNode,
|
||||||
@@ -176,6 +224,14 @@ const nodeTypes: NodeTypes = {
|
|||||||
http_request: HttpRequestNode,
|
http_request: HttpRequestNode,
|
||||||
ai_response: AIResponseNode,
|
ai_response: AIResponseNode,
|
||||||
ai_sentiment: AISentimentNode,
|
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 {
|
interface Flow {
|
||||||
@@ -306,6 +362,22 @@ export default function FlowBuilder() {
|
|||||||
>
|
>
|
||||||
<Button>+ Avanzados</Button>
|
<Button>+ Avanzados</Button>
|
||||||
</Dropdown>
|
</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
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SaveOutlined />}
|
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
|
||||||
FLOW_ENGINE_URL: str = "http://localhost:8001"
|
FLOW_ENGINE_URL: str = "http://localhost:8001"
|
||||||
|
|
||||||
|
# Integrations
|
||||||
|
INTEGRATIONS_URL: str = "http://localhost:8002"
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
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.config import get_settings
|
||||||
from app.core.database import engine, Base
|
from app.core.database import engine, Base
|
||||||
from app.routers import auth, whatsapp, flows, queues, supervisor, flow_templates, global_variables
|
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()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ app.include_router(queues.router)
|
|||||||
app.include_router(supervisor.router)
|
app.include_router(supervisor.router)
|
||||||
app.include_router(flow_templates.router)
|
app.include_router(flow_templates.router)
|
||||||
app.include_router(global_variables.router)
|
app.include_router(global_variables.router)
|
||||||
|
app.include_router(integrations_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@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.quick_reply import QuickReply
|
||||||
from app.models.global_variable import GlobalVariable
|
from app.models.global_variable import GlobalVariable
|
||||||
from app.models.flow_template import FlowTemplate
|
from app.models.flow_template import FlowTemplate
|
||||||
|
from app.models.odoo_config import OdooConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"QuickReply",
|
"QuickReply",
|
||||||
"GlobalVariable",
|
"GlobalVariable",
|
||||||
"FlowTemplate",
|
"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"
|
API_GATEWAY_URL: str = "http://localhost:8000"
|
||||||
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
||||||
|
INTEGRATIONS_URL: str = "http://localhost:8002"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ from app.nodes.validation import (
|
|||||||
)
|
)
|
||||||
from app.nodes.script import JavaScriptExecutor, HttpRequestExecutor
|
from app.nodes.script import JavaScriptExecutor, HttpRequestExecutor
|
||||||
from app.nodes.ai import AIResponseExecutor, AISentimentExecutor
|
from app.nodes.ai import AIResponseExecutor, AISentimentExecutor
|
||||||
|
from app.nodes.odoo import (
|
||||||
|
OdooSearchPartnerExecutor,
|
||||||
|
OdooCreatePartnerExecutor,
|
||||||
|
OdooGetBalanceExecutor,
|
||||||
|
OdooSearchOrdersExecutor,
|
||||||
|
OdooGetOrderExecutor,
|
||||||
|
OdooSearchProductsExecutor,
|
||||||
|
OdooCheckStockExecutor,
|
||||||
|
OdooCreateLeadExecutor,
|
||||||
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -60,6 +70,14 @@ def _register_executors():
|
|||||||
NodeRegistry.register("http_request", HttpRequestExecutor())
|
NodeRegistry.register("http_request", HttpRequestExecutor())
|
||||||
NodeRegistry.register("ai_response", AIResponseExecutor())
|
NodeRegistry.register("ai_response", AIResponseExecutor())
|
||||||
NodeRegistry.register("ai_sentiment", AISentimentExecutor())
|
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()
|
_register_executors()
|
||||||
|
|||||||
@@ -24,3 +24,13 @@ from app.nodes.validation import (
|
|||||||
ValidatePhoneExecutor,
|
ValidatePhoneExecutor,
|
||||||
ValidateRegexExecutor,
|
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