This commit is contained in:
2026-06-10 02:04:37 -06:00
parent 1ba3d8dea4
commit 31cdce03a0
131 changed files with 15922 additions and 106 deletions
+470
View File
@@ -0,0 +1,470 @@
# Cotizador E3 — API Documentation for AI Skills
## Overview
REST API for Consultoría E3's digital marketing quotation system. Allows AI agents to manage clients, browse service catalogs, create quotations, calculate financing, generate PDF/Excel documents, and manage the full sales pipeline.
**Base URL:** `http://localhost:8000`
**Auth:** API Key via `X-API-Key` header or `Authorization: Bearer <key>`
**Content-Type:** `application/json`
**OpenAPI Spec:** `GET /openapi.json` (no auth required)
---
## Domain Context
Consultoría E3 is a digital marketing agency in Querétaro, México. Services are organized in 4 project phases:
- **Fase 0 — Auditoría / Acompañamiento:** Initial assessments and ongoing consulting
- **Fase 1 — Setup e Infraestructura:** One-time setup (websites, branding, CRM config)
- **Fase 2 — Publicidad y Manejo:** Ongoing advertising and social media management
- **Fase 3 — Contenido y SEO:** Content creation and search engine optimization
Two payment types:
- **unico:** One-time payment
- **mensual:** Monthly recurring payment
Currency: MXN (Mexican Pesos) or USD. IVA (tax) rate: 16%.
---
## Authentication
### API Key (for agents)
```
X-API-Key: your-api-key-here
```
or
```
Authorization: Bearer your-api-key-here
```
### JWT (for human login)
```
POST /auth/login
Body: { "email": "[email protected]", "password": "secret" }
Response: { "user": { "id", "name", "email", "role" } }
Sets cookie: cotizador-session (httpOnly, 7 days)
```
---
## Endpoints Reference
### Health & Info
#### GET /health
Health check endpoint. No auth required.
- **Response:** `{ "status": "ok", "timestamp": "2026-05-03T..." }`
#### GET /api-info
API capabilities and version info.
- **Response:** `{ "name": "Cotizador E3 API", "version": "1.0.0", "capabilities": [...] }`
---
### Clients (Clientes)
#### GET /clientes
List clients with optional search and pagination.
- **Query params:** `?q=texto&page=1&limit=50`
- **Response:** `{ "data": [Cliente], "meta": { "total", "page", "limit", "pages" } }`
#### POST /clientes
Create a new client.
- **Body:**
```json
{
"nombre": "Juan Pérez",
"empresa": "ACME Corp",
"email": "[email protected]",
"telefono": "4421234567"
}
```
- **Response (201):** Full Cliente object with `id`
#### GET /clientes/{id}
Get client details including recent quotations.
- **Response:** Cliente object + `cotizaciones` array
#### PUT /clientes/{id}
Update client information.
- **Body:** Same as POST (all fields optional for update)
#### DELETE /clientes/{id}
Delete client. Returns 409 if client has quotations.
- **Response (200):** `{ "ok": true }`
- **Error (409):** `{ "error": "Cliente tiene N cotizaciones asociadas" }`
---
### Service Catalog (Catálogo)
#### GET /catalogo
List all active services with optional filters.
- **Query params:** `?fase=0&tipoPago=mensual&categoriaId=xxx&q=seo`
- **Response:** Array of ServicioCatalogo objects
Each service:
```json
{
"id": "cuid",
"nombre": "Posicionamiento SEO",
"descripcion": "...",
"fase": 3,
"tipoPago": "mensual",
"precioBase": 2900,
"tiempoEntrega": "7 - 14 dias",
"entregablesDefault": ["Keyword research", "On-page optimization"],
"categoriaId": "cuid",
"variante": null,
"activo": true,
"orden": 0
}
```
#### POST /catalogo
Create a new service.
- **Body:**
```json
{
"nombre": "Nuevo Servicio",
"descripcion": "Descripción del servicio",
"fase": 1,
"tipoPago": "unico",
"precioBase": 5000,
"tiempoEntrega": "7 - 14 dias",
"entregablesDefault": ["Entregable 1", "Entregable 2"],
"categoriaId": "cuid",
"variante": null,
"orden": 0
}
```
#### GET /catalogo/{id}
Get service details with category info.
#### PUT /catalogo/{id}
Update service. Same body as POST.
#### DELETE /catalogo/{id}
Soft-delete if used in quotations, hard-delete otherwise.
- **Response:** `{ "ok": true, "archived": true|false }`
---
### Categories (Categorías)
Available: SEO (#10b981), Marketing (#6366f1), Paid Media (#f59e0b), Desarrollo Web (#3b82f6), Automatizaciones (#8b5cf6), CRM (#ec4899), Desarrollo Personalizado (#14b8a6)
#### GET /categorias
List all categories ordered by `orden`.
#### POST /categorias
- **Body:** `{ "nombre": "Nueva Cat", "descripcion": "...", "color": "#6b7280", "orden": 0 }`
#### GET /categorias/{id}
Get category with its services.
#### PUT /categorias/{id}
- **Body:** `{ "nombre", "descripcion", "color", "orden", "activo" }`
#### DELETE /categorias/{id}
Returns 409 if category has associated services.
---
### Quotations (Cotizaciones)
#### GET /cotizaciones
List quotations with filters.
- **Query params:** `?estado=borrador&asesorId=xxx&clienteId=xxx&q=texto&desde=2026-01-01&hasta=2026-12-31&page=1&limit=50`
- **Response:** Array with full relations (cliente, asesor, servicios+catalogo, planBucefalo)
#### POST /cotizaciones
Create a complete quotation. Auto-creates client if not found.
- **Body:**
```json
{
"numero": "UJ2605AG001",
"fecha": "2026-05-03",
"vigencia": "2026-05-24",
"moneda": "MXN",
"tipoCambio": "NA",
"proyecto": "MKT Digital",
"esquemaPago": "Pago Unico/Mensual",
"incluirBonos": false,
"incluirFinanciamiento": false,
"observaciones": "",
"asesorId": "cuid",
"cliente": {
"nombre": "Juan Pérez",
"empresa": "ACME Corp",
"email": "[email protected]",
"telefono": "4421234567"
},
"servicios": [
{
"catalogoId": "cuid",
"nombre": "SEO On-Page",
"fase": 3,
"tipoPago": "mensual",
"precio": 2900,
"tiempoEntrega": "7 - 14 dias",
"entregables": ["Keyword research", "On-page SEO"]
}
],
"planBucefalo": {
"nivel": "basico",
"precio": 1000
}
}
```
#### GET /cotizaciones/{id}
Get quotation with all relations.
#### PUT /cotizaciones/{id}
Update quotation. Replaces all services in a transaction.
- **Body:** Same as POST without `numero` and `asesorId`. Optional `estado`.
#### DELETE /cotizaciones/{id}
Hard-delete with cascade.
#### PATCH /cotizaciones/{id}/precio
Update price of a single service.
- **Body:** `{ "servicioId": "cuid", "precio": 7500 }`
#### PATCH /cotizaciones/{id}/estado
Change quotation status.
- **Body:** `{ "estado": "enviada" }``borrador`, `enviada`, `aprobada`, `rechazada`
#### POST /cotizaciones/{id}/duplicate
Duplicate quotation as new copy in `borrador` state.
---
### Packages (Paquetes)
#### GET /paquetes
List active packages with nested phases and services.
#### POST /paquetes
- **Body:**
```json
{
"nombre": "Paquete Básico",
"descripcion": "...",
"fases": [
{ "nombre": "Auditoría", "orden": 0 },
{ "nombre": "Setup", "orden": 1 }
]
}
```
#### GET /paquetes/{id}
Get package with full nested structure.
#### PUT /paquetes/{id}
- **Body:** `{ "nombre", "descripcion", "activo" }`
#### DELETE /paquetes/{id}
Hard-delete with cascade.
#### POST /paquetes/{id}/manage
Multi-action endpoint:
- `{ "action": "addFase", "nombre": "Fase 3", "orden": 2 }`
- `{ "action": "updateFase", "faseId": "cuid", "nombre": "New Name", "orden": 1 }`
- `{ "action": "deleteFase", "faseId": "cuid" }`
- `{ "action": "addServicio", "servicioCatalogoId": "cuid", "fasePaqueteId": "cuid" }`
- `{ "action": "removeServicio", "servicioCatalogoId": "cuid", "fasePaqueteId": "cuid" }`
---
### Configuration (Configuración)
Allowed keys: `color_primario`, `color_secundario`, `logo_base64`, `razon_social`, `rfc`, `domicilio_fiscal`, `cuenta_nacional`, `clabe_interbancaria`, `cuenta_internacional`, `cuenta_internacional_swift`, `hora_centinela`, `anualidad_hosting`, `iva`, `terminos_condiciones`, `no_incluye`, `notas_adicionales`
#### GET /configuracion
- **Response:** `{ "color_primario": "#2563eb", "razon_social": "...", ... }`
#### PUT /configuracion
Bulk upsert. Only whitelisted keys processed.
- **Body:** `{ "color_primario": "#ff0000", "razon_social": "Nuevo Nombre" }`
---
### Bonuses (Bonos)
Available: Centinela Web, Workshop Buyer Persona, Workshop Propuesta de Valor, Membresía Premium, Mes Gratis CRM, Script de Ventas
#### GET /bonos
---
### Financing (Financiamiento)
| Months | Rate | Commission | Min Amount |
|--------|------|------------|------------|
| 3 | 7.7% | 2.5% | $300 |
| 6 | 10.7% | 2.5% | $600 |
| 9 | 13.7% | 2.5% | $900 |
| 12 | 16.7% | 2.5% | $1,200 |
#### GET /financiamiento/planes
#### POST /financiamiento/calcular
- **Body:** `{ "monto": 50000, "meses": 6 }`
- **Response:** `{ "pagoMensual", "ivaMensual", "totalMensual", "comisionTotal", "granTotal" }`
#### GET /financiamiento/simulacion/{cotizacion_id}
Auto-calculate financing for existing quotation.
---
### Export
#### POST /export/pdf — PDF from draft data
#### GET /export/pdf/{id} — PDF from saved quotation
#### POST /export/excel — Excel from draft data
#### GET /export/excel/{id} — Excel from saved quotation
#### GET /export/catalogo — CSV of active services
#### GET /export/catalogo/plantilla — CSV import template
---
### Import
#### POST /import/catalogo
Bulk import services from CSV. `multipart/form-data` with `file` field.
- **Response:** `{ "creados": 5, "omitidos": 2, "errores": [...] }`
---
## Business Rules
### Quotation Number
Format: `UJ{YY}{MM}{AsesorInitials}{sequence}` — e.g., `UJ2605AG001`
### Quotation Validity
15 business days from issue (excludes Sat/Sun).
### CRM Bucefalo Plans
basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes
### Client Deduplication
Matched by `(nombre, empresa)` pair.
### Service Soft-Delete
If referenced by quotations → `activo=false`. Otherwise hard-delete.
### Financing Formula
```
comisionTotal = monto × comision%
montoConComision = monto + comisionTotal
pagoMensual = montoConComision × (1 + tasa) / meses
ivaMensual = pagoMensual × 0.16
totalMensual = pagoMensual + ivaMensual
granTotal = totalMensual × meses
```
---
## Common Responses
### Success (paginated)
```json
{
"data": [...],
"meta": { "total": 45, "page": 1, "limit": 50, "pages": 1 }
}
```
### Error
```json
{ "error": "Message", "detail": "Details", "code": "ERROR_CODE" }
```
### HTTP Codes
200=OK, 201=Created, 204=No Content, 400=Bad Request, 401=Unauthorized, 404=Not Found, 409=Conflict, 500=Server Error
---
## Agent Workflows
### Create quotation
```
1. GET /catalogo?fase=0 → audit services
2. GET /catalogo?fase=1 → setup services
3. GET /catalogo?fase=2&tipoPago=mensual → ongoing services
4. POST /financiamiento/calcular → simulate payments
5. POST /cotizaciones → create quotation
6. GET /export/pdf/{id} → generate PDF
```
### Review pipeline
```
1. GET /cotizaciones?estado=borrador → pending
2. GET /cotizaciones?estado=enviada → awaiting
3. GET /cotizaciones?estado=aprobada → won
```
### Negotiate price
```
1. GET /cotizaciones/{id} → current state
2. PATCH /cotizaciones/{id}/precio → adjust price
3. GET /export/pdf/{id} → regenerate PDF
```
---
## MCP Integration
MCP server at `/mcp` for OpenClaw, Claude, ChatGPT.
### Tools
| Tool | Description |
|------|-------------|
| `buscar_servicios` | Search catalog by phase, payment type, category, text |
| `crear_cotizacion` | Create complete quotation in one call |
| `obtener_cotizacion` | Get quotation details |
| `listar_cotizaciones` | List with filters |
| `cambiar_estado_cotizacion` | Change status |
| `actualizar_precio_servicio` | Adjust service price |
| `duplicar_cotizacion` | Clone as draft |
| `calcular_financiamiento` | Calculate payments |
| `generar_pdf_cotizacion` | Generate PDF |
| `obtener_configuracion` | Company config |
| `listar_bonos` | Available bonuses |
| `listar_planes_bucefalo` | CRM plans |
### Resources
| Resource | Description |
|----------|-------------|
| `cotizador://servicios` | Full catalog |
| `cotizador://categorias` | Categories |
| `cotizador://configuracion` | Company config |
---
## Environment
```
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=cotizador_e3
API_KEY=your-secret-api-key
JWT_SECRET=your-jwt-secret
```
## Run
```bash
pip install -r requirements.txt
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Swagger: http://localhost:8000/docs
# ReDoc: http://localhost:8000/redoc
# OpenAPI: http://localhost:8000/openapi.json
```
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
Binary file not shown.
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import Header, HTTPException, status
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 7
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=ALGORITHM)
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[ALGORITHM])
except JWTError:
return None
async def get_api_key(x_api_key: str | None = Header(None)) -> str | None:
if x_api_key and x_api_key == settings.API_KEY:
return x_api_key
return None
async def require_auth(
authorization: str | None = Header(None),
x_api_key: str | None = Header(None),
) -> dict:
if x_api_key and x_api_key == settings.API_KEY:
return {"auth_type": "api_key"}
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token:
payload = decode_token(token)
if payload:
return {"auth_type": "jwt", **payload}
if x_api_key == settings.API_KEY:
return {"auth_type": "api_key"}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
+18
View File
@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DB_HOST: str = "localhost"
DB_PORT: int = 5432
DB_USER: str = "postgres"
DB_PASSWORD: str = "postgres"
DB_NAME: str = "cotizador_e3"
API_KEY: str = "change-me-to-a-secure-random-key"
JWT_SECRET: str = "change-me-to-a-secure-jwt-secret"
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings()
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import asyncpg
from app.config import settings
pool: asyncpg.Pool | None = None
async def init_db() -> None:
global pool
pool = await asyncpg.create_pool(
host=settings.DB_HOST,
port=settings.DB_PORT,
user=settings.DB_USER,
password=settings.DB_PASSWORD,
database=settings.DB_NAME,
min_size=2,
max_size=10,
)
async def close_db() -> None:
global pool
if pool:
await pool.close()
pool = None
async def get_pool() -> asyncpg.Pool:
if pool is None:
raise RuntimeError("Database pool not initialized. Call init_db() first.")
return pool
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Query
from app.database import get_pool
async def get_db():
pool = await get_pool()
async with pool.acquire() as conn:
yield conn
class PaginationParams:
def __init__(
self,
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
):
self.page = page
self.limit = limit
self.offset = (page - 1) * limit
def parse_date(date_str: str | None):
if not date_str:
return None
from datetime import datetime
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except ValueError:
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
return None
View File
Binary file not shown.
Binary file not shown.
+446
View File
@@ -0,0 +1,446 @@
"""MCP Server for the Cotizador E3 API.
Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.)
to interact with the quotation system.
"""
from __future__ import annotations
import json
from datetime import datetime
from mcp.server import Server
from mcp.types import Resource, TextContent, Tool
from app.database import get_pool
from app.mcp.tools import RESOURCES, TOOLS
from app.services.calculators import (
BONOS,
PLANES_BUCEFALO,
calcular_financiamiento,
bucefalo_precio,
)
server = Server("cotizador-e3")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name=t["name"],
description=t["description"],
inputSchema=t["inputSchema"],
)
for t in TOOLS
]
@server.list_resources()
async def list_resources() -> list[Resource]:
return [
Resource(
uri=r["uri"],
name=r["name"],
description=r["description"],
mimeType=r["mimeType"],
)
for r in RESOURCES
]
@server.read_resource()
async def read_resource(uri: str) -> str:
pool = await get_pool()
async with pool.acquire() as conn:
if uri == "cotizador://servicios":
rows = await conn.fetch(
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, s.activo, s.orden,
c.nombre as categoria_nombre
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
WHERE s.activo = true
ORDER BY s.fase ASC, s.orden ASC"""
)
return json.dumps([dict(r) for r in rows], default=str)
elif uri == "cotizador://categorias":
rows = await conn.fetch(
'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC'
)
return json.dumps([dict(r) for r in rows], default=str)
elif uri == "cotizador://configuracion":
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
return json.dumps({r["clave"]: r["valor"] for r in rows})
return json.dumps({"error": f"Unknown resource: {uri}"})
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
pool = await get_pool()
async with pool.acquire() as conn:
result = await _handle_tool(conn, name, arguments)
return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
async def _handle_tool(conn, name: str, arguments: dict) -> dict:
if name == "buscar_servicios":
return await _buscar_servicios(conn, arguments)
elif name == "crear_cotizacion":
return await _crear_cotizacion(conn, arguments)
elif name == "obtener_cotizacion":
return await _obtener_cotizacion(conn, arguments)
elif name == "listar_cotizaciones":
return await _listar_cotizaciones(conn, arguments)
elif name == "cambiar_estado_cotizacion":
return await _cambiar_estado(conn, arguments)
elif name == "actualizar_precio_servicio":
return await _actualizar_precio(conn, arguments)
elif name == "duplicar_cotizacion":
return await _duplicar_cotizacion(conn, arguments)
elif name == "calcular_financiamiento":
return await _calcular_financiamiento(arguments)
elif name == "generar_pdf_cotizacion":
return await _generar_pdf(conn, arguments)
elif name == "obtener_configuracion":
return await _obtener_configuracion(conn)
elif name == "listar_bonos":
return {"bonos": BONOS}
elif name == "listar_planes_bucefalo":
return {"planes": PLANES_BUCEFALO}
else:
return {"error": f"Unknown tool: {name}"}
async def _buscar_servicios(conn, args: dict) -> dict:
conditions = ['s.activo = true']
params = []
idx = 1
if args.get("fase") is not None:
conditions.append(f's.fase = ${idx}')
params.append(args["fase"])
idx += 1
if args.get("tipo_pago"):
conditions.append(f's."tipoPago" = ${idx}')
params.append(args["tipo_pago"])
idx += 1
if args.get("categoria"):
conditions.append(f'LOWER(c.nombre) = LOWER(${idx})')
params.append(args["categoria"])
idx += 1
if args.get("busqueda"):
conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))')
params.append(f"%{args['busqueda']}%")
idx += 1
where = " AND ".join(conditions)
query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, c.nombre as categoria
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
WHERE {where}
ORDER BY s.fase ASC, s.orden ASC"""
rows = await conn.fetch(query, *params)
servicios = []
for r in rows:
d = dict(r)
if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str):
try:
d["entregablesDefault"] = json.loads(d["entregablesDefault"])
except (json.JSONDecodeError, TypeError):
pass
servicios.append(d)
return {"servicios": servicios, "total": len(servicios)}
async def _crear_cotizacion(conn, args: dict) -> dict:
cliente_data = args["cliente"]
servicios_data = args["servicios"]
plan_bucefalo = args.get("plan_bucefalo")
moneda = args.get("moneda", "MXN")
proyecto = args.get("proyecto", "MKT Digital")
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
cliente = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
cliente_data["nombre"],
cliente_data.get("empresa", ""),
)
if not cliente:
cliente = await conn.fetchrow(
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id',
cliente_data["nombre"],
cliente_data.get("empresa", ""),
cliente_data.get("email", ""),
cliente_data.get("telefono", ""),
)
cliente_id = cliente["id"]
now = datetime.now()
numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001"
from app.services.calculators import calcular_vigencia
vigencia = calcular_vigencia(now)
async with conn.transaction():
cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, '', $7, $8, NOW(), NOW())
RETURNING id, numero""",
numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent",
)
cot_id = cot["id"]
for srv in servicios_data:
catalogo_id = srv["servicio_id"]
cat_row = await conn.fetchrow(
'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1',
catalogo_id,
)
if not cat_row:
continue
precio = srv.get("precio_personalizado") or cat_row["precioBase"]
entregables = cat_row["entregablesDefault"]
if isinstance(entregables, str):
try:
entregables = json.loads(entregables)
except (json.JSONDecodeError, TypeError):
entregables = []
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())""",
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []),
)
if plan_bucefalo:
nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico")
precio_bp = bucefalo_precio(nivel)
await conn.fetchrow(
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""",
cot_id, nivel, precio_bp,
)
return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)}
async def _obtener_cotizacion(conn, args: dict) -> dict:
cot = await conn.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
cl.email as cliente_email, cl.telefono as cliente_telefono
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
WHERE c.id = $1""",
args["cotizacion_id"],
)
if not cot:
return {"error": "Cotización no encontrada"}
servicios = await conn.fetch(
"""SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago"
FROM "ServicioCotizado" sc
LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id
WHERE sc."cotizacionId" = $1""",
args["cotizacion_id"],
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
args["cotizacion_id"],
)
result = dict(cot)
result["cliente"] = {
"nombre": cot["cliente_nombre"],
"empresa": cot["cliente_empresa"],
"email": cot["cliente_email"],
"telefono": cot["cliente_telefono"],
}
result["servicios"] = [dict(s) for s in servicios]
result["planBucefalo"] = dict(plan) if plan else None
for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]:
result.pop(k, None)
return result
async def _listar_cotizaciones(conn, args: dict) -> dict:
conditions = []
params = []
idx = 1
if args.get("estado"):
conditions.append(f'c.estado = ${idx}')
params.append(args["estado"])
idx += 1
if args.get("cliente_nombre"):
conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})')
params.append(f"%{args['cliente_nombre']}%")
idx += 1
if args.get("busqueda"):
conditions.append(
f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))'
)
params.append(f"%{args['busqueda']}%")
idx += 1
where = "WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto,
c.moneda, c."esquemaPago",
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
{where}
ORDER BY c."createdAt" DESC
LIMIT 50"""
rows = await conn.fetch(query, *params)
cotizaciones = []
for r in rows:
d = dict(r)
d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")}
cotizaciones.append(d)
return {"cotizaciones": cotizaciones, "total": len(cotizaciones)}
async def _cambiar_estado(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
estado = args["estado"]
cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot:
return {"error": "Cotización no encontrada"}
await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id)
return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado}
async def _actualizar_precio(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
servicio_id = args["servicio_id"]
nuevo_precio = args["nuevo_precio"]
srv = await conn.fetchrow(
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
servicio_id, cot_id,
)
if not srv:
return {"error": "Servicio no encontrado en esta cotización"}
await conn.execute(
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
nuevo_precio, servicio_id,
)
return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio}
async def _duplicar_cotizacion(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not original:
return {"error": "Cotización no encontrada"}
now = datetime.now()
new_numero = f"{original['numero']}-COPY"
async with conn.transaction():
new_cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, NOW(), NOW())
RETURNING id, numero""",
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
original["incluirFinanciamiento"], original["observaciones"],
original["clienteId"], original["asesorId"],
)
new_id = new_cot["id"]
servicios = await conn.fetch(
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id
)
for s in servicios:
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())""",
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"],
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id
)
if plan:
await conn.fetchrow(
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""",
new_id, plan["nivel"], plan["precio"], plan["seleccionado"],
)
return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"}
async def _calcular_financiamiento(args: dict) -> dict:
monto = args["monto"]
meses = args["meses"]
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
if not plan:
from app.services.calculators import FINANCIAMIENTO_PLANES
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
if not plan:
return {"error": f"Plan de {meses} meses no disponible"}
result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"])
result["meses"] = meses
result["tasa"] = plan["tasa"]
result["comision"] = plan["comision"]
return result
async def _generar_pdf(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
cot = await conn.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
WHERE c.id = $1""",
cot_id,
)
if not cot:
return {"error": "Cotización no encontrada"}
return {
"status": "pdf_generated",
"cotizacion_id": cot_id,
"numero": cot["numero"],
"filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf",
"message": "PDF generation will be implemented with reportlab",
}
async def _obtener_configuracion(conn) -> dict:
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
return {"config": {r["clave"]: r["valor"] for r in rows}}
from app.services.calculators import FINANCIAMIENTO_PLANES
+289
View File
@@ -0,0 +1,289 @@
"""MCP (Model Context Protocol) tool definitions for the Cotizador API.
Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT.
"""
TOOLS = [
{
"name": "buscar_servicios",
"description": (
"Busca servicios del catálogo de marketing digital de Consultoría E3. "
"Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. "
"Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), "
"2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). "
"Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)."
),
"inputSchema": {
"type": "object",
"properties": {
"fase": {
"type": "integer",
"enum": [0, 1, 2, 3],
"description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO",
},
"tipo_pago": {
"type": "string",
"enum": ["unico", "mensual"],
"description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente",
},
"categoria": {
"type": "string",
"description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado",
},
"busqueda": {
"type": "string",
"description": "Texto libre para buscar en nombre y descripción del servicio",
},
},
},
},
{
"name": "crear_cotizacion",
"description": (
"Crea una cotización completa de servicios de marketing digital para un cliente. "
"El cliente se crea automáticamente si no existe (busca por nombre+empresa). "
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
"y configuración de moneda y esquema de pago. "
"La cotización se crea en estado 'borrador'. "
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes."
),
"inputSchema": {
"type": "object",
"properties": {
"cliente": {
"type": "object",
"properties": {
"nombre": {"type": "string", "description": "Nombre completo del contacto"},
"empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"},
"email": {"type": "string", "description": "Email de contacto"},
"telefono": {"type": "string", "description": "Teléfono de contacto"},
},
"required": ["nombre"],
},
"servicios": {
"type": "array",
"items": {
"type": "object",
"properties": {
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
},
"required": ["servicio_id"],
},
"description": "Lista de servicios a incluir en la cotización",
},
"plan_bucefalo": {
"type": "string",
"enum": ["basico", "estandar", "premium", "empresarial"],
"description": "Nivel del plan CRM Bucefalo (opcional)",
},
"proyecto": {
"type": "string",
"description": "Nombre o descripción del proyecto (default: 'MKT Digital')",
},
"moneda": {
"type": "string",
"enum": ["MXN", "USD"],
"description": "Moneda de la cotización (default: MXN)",
},
"esquema_pago": {
"type": "string",
"enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"],
"description": "Esquema de pago (default: Pago Unico/Mensual)",
},
},
"required": ["cliente", "servicios"],
},
},
{
"name": "obtener_cotizacion",
"description": (
"Obtiene los detalles completos de una cotización existente incluyendo: "
"datos del cliente, servicios seleccionados con precios, estado actual, "
"plan CRM Bucefalo si aplica, observaciones, fechas y vigencia."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "listar_cotizaciones",
"description": (
"Lista cotizaciones con filtros opcionales. "
"Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. "
"Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)."
),
"inputSchema": {
"type": "object",
"properties": {
"estado": {
"type": "string",
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
"description": "Filtrar por estado",
},
"cliente_nombre": {
"type": "string",
"description": "Buscar por nombre de cliente",
},
"busqueda": {
"type": "string",
"description": "Texto libre para buscar en número, proyecto o cliente",
},
},
},
},
{
"name": "cambiar_estado_cotizacion",
"description": (
"Cambia el estado de una cotización. "
"Flujo normal: borrador → enviada → aprobada o rechazada. "
"Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. "
"Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
"estado": {
"type": "string",
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
"description": "Nuevo estado de la cotización",
},
},
"required": ["cotizacion_id", "estado"],
},
},
{
"name": "actualizar_precio_servicio",
"description": (
"Actualiza el precio de un servicio específico dentro de una cotización. "
"No modifica el precio base del catálogo, solo el precio en esta cotización. "
"Útil para negociar precios individuales sin recrear toda la cotización."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
"servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"},
"nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"},
},
"required": ["cotizacion_id", "servicio_id", "nuevo_precio"],
},
},
{
"name": "duplicar_cotizacion",
"description": (
"Duplica una cotización existente como nueva copia en estado 'borrador'. "
"Crea una copia exacta con nuevo ID y número. "
"Útil para crear variaciones de una propuesta o reenviar una cotización actualizada."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "calcular_financiamiento",
"description": (
"Calcula las mensualidades para financiar una cotización o monto específico. "
"Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). "
"Todos incluyen 2.5% de comisión + 16% IVA. "
"Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total."
),
"inputSchema": {
"type": "object",
"properties": {
"monto": {"type": "number", "description": "Monto total a financiar en MXN"},
"meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"},
},
"required": ["monto", "meses"],
},
},
{
"name": "generar_pdf_cotizacion",
"description": (
"Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, "
"tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, "
"y datos bancarios para transferencia. Devuelve el archivo PDF."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "obtener_configuracion",
"description": (
"Obtiene la configuración de la empresa Consultoría E3: "
"razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), "
"colores de marca, logo, y términos y condiciones. "
"Útil para generar documentos o verificar información fiscal."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "listar_bonos",
"description": (
"Lista los bonos/disponibles que se pueden incluir en una cotización: "
"1) Servicio Centinela Web (monitoreo 30 min/mes), "
"2) Workshop Buyer Persona, "
"3) Workshop Propuesta de Valor, "
"4) Membresía Premium (1 año), "
"5) Mes Gratis CRM Bucefalo, "
"6) Script de Ventas (100+ complementos)."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "listar_planes_bucefalo",
"description": (
"Lista los niveles del CRM Bucefalo con precios mensuales: "
"Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). "
"Bucefalo es un CRM para gestión de ventas y clientes."
),
"inputSchema": {"type": "object", "properties": {}},
},
]
RESOURCES = [
{
"uri": "cotizador://servicios",
"name": "Catálogo de Servicios",
"description": (
"Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: "
"Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), "
"Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, "
"tiempo de entrega, tipo de pago (único/mensual), y entregables."
),
"mimeType": "application/json",
},
{
"uri": "cotizador://categorias",
"name": "Categorías de Servicios",
"description": (
"Categorías disponibles para clasificar servicios: "
"SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado."
),
"mimeType": "application/json",
},
{
"uri": "cotizador://configuracion",
"name": "Configuración de la Empresa",
"description": (
"Datos fiscales, bancarios y de marca de Consultoría E3. "
"Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, "
"colores de marca, logo, y términos y condiciones."
),
"mimeType": "application/json",
},
]
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CategoriaBase(BaseModel):
nombre: str = Field(..., min_length=1, description="Nombre de la categoría")
descripcion: str | None = None
color: str = Field("#6b7280", description="Color hex para UI")
orden: int = Field(0, ge=0)
class CategoriaCreate(CategoriaBase):
pass
class CategoriaUpdate(BaseModel):
nombre: str | None = Field(None, min_length=1)
descripcion: str | None = None
color: str | None = None
orden: int | None = Field(None, ge=0)
activo: bool | None = None
class CategoriaResponse(CategoriaBase):
id: str
activo: bool
createdAt: datetime
updatedAt: datetime
model_config = {"from_attributes": True}
class ServicioCatalogoBase(BaseModel):
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
descripcion: str | None = None
fase: int = Field(..., ge=0, le=3, description="Fase: 0=Auditoría, 1=Setup, 2=Publicidad, 3=Contenido/SEO")
tipoPago: str = Field(..., pattern="^(unico|mensual)$", description="Tipo de pago")
precioBase: float = Field(..., ge=0, description="Precio base en la moneda local")
tiempoEntrega: str = Field("7 - 14 dias", description="Tiempo de entrega estimado")
entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables")
categoriaId: str = Field(..., min_length=1, description="ID de la categoría")
variante: str | None = None
orden: int = Field(0, ge=0)
class ServicioCatalogoCreate(ServicioCatalogoBase):
pass
class ServicioCatalogoUpdate(BaseModel):
nombre: str | None = Field(None, min_length=1)
descripcion: str | None = None
fase: int | None = Field(None, ge=0, le=3)
tipoPago: str | None = Field(None, pattern="^(unico|mensual)$")
precioBase: float | None = Field(None, ge=0)
tiempoEntrega: str | None = None
entregablesDefault: list[str] | None = None
categoriaId: str | None = None
variante: str | None = None
activo: bool | None = None
orden: int | None = Field(None, ge=0)
class ServicioCatalogoResponse(ServicioCatalogoBase):
id: str
activo: bool
createdAt: datetime
updatedAt: datetime
categoriaRel: CategoriaResponse | None = None
model_config = {"from_attributes": True}
from app.models.cliente import ClienteResponse
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class ClienteBase(BaseModel):
nombre: str = Field(..., min_length=1, description="Nombre completo del contacto")
empresa: str | None = Field(None, description="Nombre de la empresa")
email: str | None = Field(None, description="Email de contacto")
telefono: str | None = Field(None, description="Teléfono de contacto")
class ClienteCreate(ClienteBase):
pass
class ClienteUpdate(BaseModel):
nombre: str | None = Field(None, min_length=1)
empresa: str | None = None
email: str | None = None
telefono: str | None = None
class ClienteResponse(ClienteBase):
id: str
createdAt: datetime
updatedAt: datetime
model_config = {"from_attributes": True}
class ClienteWithCotizaciones(ClienteResponse):
cotizaciones: list[dict] | None = None
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class Meta(BaseModel):
total: int = 0
page: int = 1
limit: int = 50
pages: int = 0
class PaginatedResponse(BaseModel, Generic[T]):
data: list[T]
meta: Meta
class ErrorResponse(BaseModel):
error: str
detail: str | None = None
code: str | None = None
class OkResponse(BaseModel):
ok: bool = True
class ArchivedResponse(BaseModel):
ok: bool = True
archived: bool
class HealthResponse(BaseModel):
status: str = "ok"
timestamp: str
database: str = "connected"
class APIInfoResponse(BaseModel):
name: str = "Cotizador E3 API"
version: str = "1.0.0"
description: str = "API para sistema de cotizaciones de marketing digital"
capabilities: list[str] = Field(default_factory=list)
class LoginRequest(BaseModel):
email: str
password: str
class LoginResponse(BaseModel):
user: dict[str, Any]
token: str | None = None
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from pydantic import BaseModel, Field
ALLOWED_CONFIG_KEYS: set[str] = {
"color_primario",
"color_secundario",
"logo_base64",
"razon_social",
"rfc",
"domicilio_fiscal",
"cuenta_nacional",
"clabe_interbancaria",
"cuenta_internacional",
"cuenta_internacional_swift",
"hora_centinela",
"anualidad_hosting",
"iva",
"terminos_condiciones",
"no_incluye",
"notas_adicionales",
}
class ConfigUpdate(BaseModel):
config: dict[str, str] = Field(
...,
description="Key-value config pairs. Only whitelisted keys are processed.",
json_schema_extra={
"examples": [
{"color_primario": "#ff0000", "razon_social": "Nueva Empresa SA de CV"}
]
},
)
def get_allowed(self) -> dict[str, str]:
return {k: v for k, v in self.config.items() if k in ALLOWED_CONFIG_KEYS}
class ConfigResponse(BaseModel):
config: dict[str, str]
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class ServicioCotizadoInput(BaseModel):
catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo")
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
fase: int = Field(..., ge=0, le=3)
tipoPago: str = Field(..., pattern="^(unico|mensual)$")
precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)")
tiempoEntrega: str
entregables: list[str] = Field(default_factory=list)
class PlanBucefaloInput(BaseModel):
nivel: str = Field(..., description="Nivel: basico, estandar, premium, empresarial")
precio: float = Field(..., ge=0)
class ClienteInput(BaseModel):
nombre: str = Field(..., min_length=1)
empresa: str = ""
email: str = ""
telefono: str = ""
class CotizacionCreate(BaseModel):
numero: str = Field(..., min_length=1, description="Número de cotización (formato UJ{YY}{MM}{init}{seq})")
fecha: datetime
vigencia: datetime
moneda: str = Field("MXN", pattern="^(MXN|USD)$")
tipoCambio: str = "NA"
proyecto: str = "MKT Digital"
esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
incluirBonos: bool = False
incluirFinanciamiento: bool = False
observaciones: str = ""
asesorId: str = Field(..., min_length=1)
cliente: ClienteInput
servicios: list[ServicioCotizadoInput] = Field(..., min_length=1)
planBucefalo: PlanBucefaloInput | None = None
model_config = {
"json_schema_extra": {
"examples": [
{
"numero": "UJ2605AG001",
"fecha": "2026-05-03",
"vigencia": "2026-05-24",
"moneda": "MXN",
"tipoCambio": "NA",
"proyecto": "MKT Digital",
"esquemaPago": "Pago Unico/Mensual",
"incluirBonos": False,
"incluirFinanciamiento": False,
"observaciones": "",
"asesorId": "cuid-asesor",
"cliente": {
"nombre": "Juan Pérez",
"empresa": "ACME Corp",
"email": "[email protected]",
"telefono": "4421234567",
},
"servicios": [
{
"catalogoId": "cuid-servicio",
"nombre": "SEO On-Page",
"fase": 3,
"tipoPago": "mensual",
"precio": 2900,
"tiempoEntrega": "7 - 14 dias",
"entregables": ["Keyword research"],
}
],
"planBucefalo": {"nivel": "basico", "precio": 1000},
}
]
}
}
class CotizacionUpdate(BaseModel):
fecha: datetime | None = None
vigencia: datetime | None = None
moneda: str | None = Field(None, pattern="^(MXN|USD)$")
tipoCambio: str | None = None
proyecto: str | None = None
esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
incluirBonos: bool | None = None
incluirFinanciamiento: bool | None = None
observaciones: str | None = None
estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$")
cliente: ClienteInput | None = None
servicios: list[ServicioCotizadoInput] | None = None
planBucefalo: PlanBucefaloInput | None = None
class CambiarEstadoRequest(BaseModel):
estado: str = Field(..., pattern="^(borrador|enviada|aprobada|rechazada)$")
class ActualizarPrecioRequest(BaseModel):
servicioId: str = Field(..., min_length=1)
precio: float = Field(..., ge=0)
class CotizacionResponse(BaseModel):
id: str
numero: str
fecha: datetime
vigencia: datetime
moneda: str
tipoCambio: str
proyecto: str
esquemaPago: str
estado: str
incluirBonos: bool
incluirFinanciamiento: bool
observaciones: str | None
clienteId: str
asesorId: str
createdAt: datetime
updatedAt: datetime
cliente: dict[str, Any] | None = None
asesor: dict[str, Any] | None = None
servicios: list[dict[str, Any]] | None = None
planBucefalo: dict[str, Any] | None = None
model_config = {"from_attributes": True}
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class ServicioDraft(BaseModel):
nombre: str
fase: int
tipoPago: str
precio: float
tiempoEntrega: str
entregables: list[str] = Field(default_factory=list)
class ExportDraft(BaseModel):
clienteNombre: str = ""
clienteEmpresa: str = ""
asesorNombre: str = ""
fecha: str = ""
moneda: str = "MXN"
tipoCambio: str = "NA"
proyecto: str = "MKT Digital"
esquemaPago: str = "Pago Unico/Mensual"
incluirBonos: bool = False
servicios: list[ServicioDraft] = Field(default_factory=list)
planBucefaloNivel: str | None = None
observaciones: str = ""
class FinanciamientoRequest(BaseModel):
monto: float = Field(..., ge=0, description="Monto total a financiar en MXN")
meses: int = Field(..., ge=3, le=12, description="Plazo en meses: 3, 6, 9, o 12")
class FinanciamientoResponse(BaseModel):
pagoMensual: float
ivaMensual: float
totalMensual: float
comisionTotal: float
granTotal: float
meses: int
tasa: float
comision: float
class BonoResponse(BaseModel):
id: str
numero: int
titulo: str
descripcion: str
activo: bool
class PlanBucefaloResponse(BaseModel):
nivel: str
label: str
precio: float
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class FasePaqueteInput(BaseModel):
nombre: str = Field(..., min_length=1)
orden: int = Field(0, ge=0)
class PaqueteCreate(BaseModel):
nombre: str = Field(..., min_length=1, description="Nombre del paquete")
descripcion: str | None = None
fases: list[FasePaqueteInput] = Field(default_factory=list)
class PaqueteUpdate(BaseModel):
nombre: str | None = Field(None, min_length=1)
descripcion: str | None = None
activo: bool | None = None
class ManageAction(BaseModel):
action: str = Field(..., description="Acción: addFase, updateFase, deleteFase, addServicio, removeServicio")
faseId: str | None = None
nombre: str | None = None
orden: int | None = None
servicioCatalogoId: str | None = None
fasePaqueteId: str | None = None
class FasePaqueteResponse(BaseModel):
id: str
paqueteId: str
nombre: str
orden: int
createdAt: datetime
updatedAt: datetime
servicios: list[dict] | None = None
model_config = {"from_attributes": True}
class PaqueteResponse(BaseModel):
id: str
nombre: str
descripcion: str | None
activo: bool
createdAt: datetime
updatedAt: datetime
fases: list[FasePaqueteResponse] | None = None
model_config = {"from_attributes": True}
View File
+71
View File
@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.database import get_pool
from app.auth import verify_password, create_access_token, require_auth, get_password_hash
from app.models.common import LoginRequest, LoginResponse, OkResponse, ErrorResponse
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest):
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
'SELECT id, email, password, name, role FROM "User" WHERE email = $1',
body.email,
)
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas",
)
if not verify_password(body.password, row["password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas",
)
token = create_access_token({"sub": str(row["id"]), "email": row["email"], "role": row["role"]})
return LoginResponse(
access_token=token,
user={
"id": row["id"],
"email": row["email"],
"name": row["name"],
"role": row["role"],
},
)
@router.post("/logout", response_model=OkResponse)
async def logout():
return OkResponse(ok=True)
@router.get("/me")
async def me(current_user: dict = Depends(require_auth)):
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
'SELECT id, email, name, role, "createdAt", "updatedAt" FROM "User" WHERE id = $1',
current_user["sub"],
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Usuario no encontrado",
)
return {
"id": row["id"],
"email": row["email"],
"name": row["name"],
"role": row["role"],
"createdAt": row["createdAt"].isoformat() if row["createdAt"] else None,
"updatedAt": row["updatedAt"].isoformat() if row["updatedAt"] else None,
}
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.auth import require_auth
from app.models.export_ import BonoResponse
from app.services.calculators import BONOS
router = APIRouter(prefix="/bonos", tags=["Bonos"])
@router.get("", response_model=list[BonoResponse])
async def get_bonos(_auth: dict = Depends(require_auth)):
return [BonoResponse(**b) for b in BONOS]
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asyncpg import Connection
from app.auth import require_auth
from app.dependencies import get_db
from app.models.catalogo import (
ServicioCatalogoCreate,
ServicioCatalogoUpdate,
ServicioCatalogoResponse,
CategoriaResponse,
)
from app.models.common import ArchivedResponse, ErrorResponse, OkResponse
router = APIRouter(prefix="/catalogo", tags=["Catalogo"])
def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse:
entregables = row["entregablesDefault"]
if isinstance(entregables, str):
entregables = json.loads(entregables)
categoria = None
if categoria_row:
categoria = CategoriaResponse(
id=categoria_row["id"],
nombre=categoria_row["nombre"],
descripcion=categoria_row["descripcion"],
color=categoria_row["color"],
orden=categoria_row["orden"],
activo=categoria_row["activo"],
createdAt=categoria_row["createdAt"],
updatedAt=categoria_row["updatedAt"],
)
return ServicioCatalogoResponse(
id=row["id"],
nombre=row["nombre"],
descripcion=row["descripcion"],
fase=row["fase"],
tipoPago=row["tipoPago"],
precioBase=float(row["precioBase"]),
tiempoEntrega=row["tiempoEntrega"],
entregablesDefault=entregables,
categoriaId=row["categoriaId"],
variante=row["variante"],
activo=row["activo"],
orden=row["orden"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
categoriaRel=categoria,
)
@router.get(
"",
response_model=list[ServicioCatalogoResponse],
summary="List active catalog services with filters",
)
async def list_catalogo(
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
fase: int | None = Query(None, ge=0, le=3),
tipoPago: str | None = Query(None, pattern="^(unico|mensual)$"),
categoriaId: str | None = Query(None),
q: str | None = Query(None, description="Search by nombre or descripcion"),
):
where_clauses: list[str] = ['activo = true']
params: list = []
idx = 1
if fase is not None:
where_clauses.append(f"fase = ${idx}")
params.append(fase)
idx += 1
if tipoPago:
where_clauses.append(f'"tipoPago" = ${idx}')
params.append(tipoPago)
idx += 1
if categoriaId:
where_clauses.append(f'"categoriaId" = ${idx}')
params.append(categoriaId)
idx += 1
if q:
where_clauses.append(f"(nombre ILIKE ${idx} OR descripcion ILIKE ${idx})")
params.append(f"%{q}%")
idx += 1
where_sql = f"WHERE {' AND '.join(where_clauses)}"
rows = await db.fetch(
f'SELECT * FROM "ServicioCatalogo" {where_sql} ORDER BY fase ASC, orden ASC',
*params,
)
result: list[ServicioCatalogoResponse] = []
for r in rows:
cat_row = None
if r["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', r["categoriaId"]
)
result.append(_row_to_response(r, cat_row))
return result
@router.post(
"",
response_model=ServicioCatalogoResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new catalog service",
)
async def create_catalogo(
body: ServicioCatalogoCreate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
if body.categoriaId:
cat = await db.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId
)
if not cat:
raise HTTPException(status_code=400, detail="Categoria no encontrada")
entregables_json = json.dumps(body.entregablesDefault)
row = await db.fetchrow(
'INSERT INTO "ServicioCatalogo" '
'(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", '
'"entregablesDefault", "categoriaId", variante, activo, orden) '
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10) RETURNING *",
body.nombre,
body.descripcion,
body.fase,
body.tipoPago,
body.precioBase,
body.tiempoEntrega,
entregables_json,
body.categoriaId,
body.variante,
body.orden,
)
cat_row = None
if row["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
)
return _row_to_response(row, cat_row)
@router.get(
"/{servicio_id}",
response_model=ServicioCatalogoResponse,
responses={404: {"model": ErrorResponse}},
summary="Get catalog service by ID with categoria join",
)
async def get_catalogo(
servicio_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
row = await db.fetchrow(
'SELECT * FROM "ServicioCatalogo" WHERE id = $1', servicio_id
)
if not row:
raise HTTPException(status_code=404, detail="Servicio no encontrado")
cat_row = None
if row["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
)
return _row_to_response(row, cat_row)
@router.put(
"/{servicio_id}",
response_model=ServicioCatalogoResponse,
responses={404: {"model": ErrorResponse}},
summary="Update a catalog service",
)
async def update_catalogo(
servicio_id: str,
body: ServicioCatalogoUpdate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow(
'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id
)
if not existing:
raise HTTPException(status_code=404, detail="Servicio no encontrado")
if body.categoriaId:
cat = await db.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId
)
if not cat:
raise HTTPException(status_code=400, detail="Categoria no encontrada")
field_map = {
"nombre": "nombre",
"descripcion": "descripcion",
"fase": "fase",
"tipoPago": '"tipoPago"',
"precioBase": '"precioBase"',
"tiempoEntrega": '"tiempoEntrega"',
"entregablesDefault": '"entregablesDefault"',
"categoriaId": '"categoriaId"',
"variante": "variante",
"activo": "activo",
"orden": "orden",
}
updates: list[str] = []
params: list = []
idx = 1
for field, col in field_map.items():
value = getattr(body, field, None)
if value is not None:
if field == "entregablesDefault":
value = json.dumps(value)
updates.append(f"{col} = ${idx}")
params.append(value)
idx += 1
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(servicio_id)
row = await db.fetchrow(
f'UPDATE "ServicioCatalogo" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *',
*params,
)
cat_row = None
if row["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
)
return _row_to_response(row, cat_row)
@router.delete(
"/{servicio_id}",
response_model=ArchivedResponse,
responses={404: {"model": ErrorResponse}},
summary="Delete or soft-delete a catalog service",
)
async def delete_catalogo(
servicio_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow(
'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id
)
if not existing:
raise HTTPException(status_code=404, detail="Servicio no encontrado")
ref_count = await db.fetchval(
'SELECT COUNT(*) FROM "ServicioCotizado" WHERE "servicioCatalogoId" = $1',
servicio_id,
)
if ref_count > 0:
await db.execute(
'UPDATE "ServicioCatalogo" SET activo = false WHERE id = $1', servicio_id
)
return ArchivedResponse(ok=True, archived=True)
await db.execute('DELETE FROM "ServicioCatalogo" WHERE id = $1', servicio_id)
return ArchivedResponse(ok=True, archived=False)
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from app.database import get_pool
from app.dependencies import get_db
from app.auth import require_auth
from app.models.catalogo import CategoriaCreate, CategoriaUpdate, CategoriaResponse
from app.models.common import ErrorResponse, OkResponse
router = APIRouter(prefix="/categorias", tags=["Categorias"])
@router.get("", response_model=list[CategoriaResponse])
async def list_categorias(
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
rows = await conn.fetch(
'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"'
' FROM "Categoria" ORDER BY orden ASC'
)
return [dict(r) for r in rows]
@router.post(
"",
response_model=CategoriaResponse,
status_code=status.HTTP_201_CREATED,
responses={409: {"model": ErrorResponse}},
)
async def create_categoria(
body: CategoriaCreate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
now = datetime.now(timezone.utc)
try:
row = await conn.fetchrow(
'INSERT INTO "Categoria" (id, nombre, descripcion, color, orden, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $5)"
' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"',
body.nombre,
body.descripcion,
body.color,
body.orden,
now,
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe una categoría con el nombre '{body.nombre}'",
)
raise
return dict(row)
@router.get(
"/{categoria_id}",
responses={404: {"model": ErrorResponse}},
)
async def get_categoria(
categoria_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
row = await conn.fetchrow(
'SELECT c.id, c.nombre, c.descripcion, c.color, c.activo, c.orden,'
' c."createdAt", c."updatedAt",'
' COUNT(s.id)::int AS "serviciosCount"'
' FROM "Categoria" c'
' LEFT JOIN "ServicioCatalogo" s ON s."categoriaId" = c.id'
" WHERE c.id = $1"
" GROUP BY c.id",
categoria_id,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Categoría no encontrada",
)
return dict(row)
@router.put(
"/{categoria_id}",
response_model=CategoriaResponse,
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def update_categoria(
categoria_id: str,
body: CategoriaUpdate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
existing = await conn.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', categoria_id
)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Categoría no encontrada",
)
updates: list[str] = []
params: list = []
idx = 1
if body.nombre is not None:
updates.append(f"nombre = ${idx}")
params.append(body.nombre)
idx += 1
if body.descripcion is not None:
updates.append(f"descripcion = ${idx}")
params.append(body.descripcion)
idx += 1
if body.color is not None:
updates.append(f"color = ${idx}")
params.append(body.color)
idx += 1
if body.orden is not None:
updates.append(f"orden = ${idx}")
params.append(body.orden)
idx += 1
if body.activo is not None:
updates.append(f"activo = ${idx}")
params.append(body.activo)
idx += 1
if not updates:
row = await conn.fetchrow(
'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"'
' FROM "Categoria" WHERE id = $1',
categoria_id,
)
return dict(row)
updates.append(f'"updatedAt" = ${idx}')
params.append(datetime.now(timezone.utc))
idx += 1
params.append(categoria_id)
try:
row = await conn.fetchrow(
f'UPDATE "Categoria" SET {", ".join(updates)} WHERE id = ${idx}'
' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"',
*params,
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe una categoría con el nombre '{body.nombre}'",
)
raise
return dict(row)
@router.delete(
"/{categoria_id}",
response_model=OkResponse,
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def delete_categoria(
categoria_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
existing = await conn.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', categoria_id
)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Categoría no encontrada",
)
count = await conn.fetchval(
'SELECT COUNT(*)::int FROM "ServicioCatalogo" WHERE "categoriaId" = $1',
categoria_id,
)
if count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tiene {count} servicios asociados",
)
await conn.execute('DELETE FROM "Categoria" WHERE id = $1', categoria_id)
return OkResponse(ok=True)
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
import math
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asyncpg import Connection
from app.auth import require_auth
from app.dependencies import PaginationParams, get_db
from app.models.cliente import ClienteCreate, ClienteUpdate, ClienteResponse
from app.models.common import PaginatedResponse, Meta, ErrorResponse, OkResponse
router = APIRouter(prefix="/clientes", tags=["Clientes"])
@router.get(
"",
response_model=PaginatedResponse[ClienteResponse],
summary="List clients with pagination and search",
)
async def list_clientes(
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
pagination: PaginationParams = Depends(PaginationParams),
q: str | None = Query(None, description="Search by nombre, empresa, or email"),
):
where_clauses: list[str] = []
params: list = []
idx = 1
if q:
where_clauses.append(
f"(nombre ILIKE ${idx} OR empresa ILIKE ${idx} OR email ILIKE ${idx})"
)
params.append(f"%{q}%")
idx += 1
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
count_row = await db.fetchrow(
f"SELECT COUNT(*) AS total FROM \"Cliente\" {where_sql}", *params
)
total = count_row["total"]
pages = math.ceil(total / pagination.limit) if total > 0 else 0
rows = await db.fetch(
f'SELECT * FROM "Cliente" {where_sql} ORDER BY "createdAt" DESC '
f"LIMIT ${idx} OFFSET ${idx + 1}",
*params,
pagination.limit,
pagination.offset,
)
data = [
ClienteResponse(
id=r["id"],
nombre=r["nombre"],
empresa=r["empresa"],
email=r["email"],
telefono=r["telefono"],
createdAt=r["createdAt"],
updatedAt=r["updatedAt"],
)
for r in rows
]
return PaginatedResponse(
data=data,
meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages),
)
@router.post(
"",
response_model=ClienteResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new client",
)
async def create_cliente(
body: ClienteCreate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
row = await db.fetchrow(
'INSERT INTO "Cliente" (nombre, empresa, email, telefono) '
"VALUES ($1, $2, $3, $4) RETURNING *",
body.nombre,
body.empresa,
body.email,
body.telefono,
)
return ClienteResponse(
id=row["id"],
nombre=row["nombre"],
empresa=row["empresa"],
email=row["email"],
telefono=row["telefono"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
)
@router.get(
"/{cliente_id}",
response_model=ClienteResponse,
responses={404: {"model": ErrorResponse}},
summary="Get client by ID with recent cotizaciones",
)
async def get_cliente(
cliente_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
row = await db.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cliente_id)
if not row:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
cotizaciones = await db.fetch(
'SELECT * FROM "Cotizacion" WHERE "clienteId" = $1 '
'ORDER BY "createdAt" DESC LIMIT 10',
cliente_id,
)
cliente = ClienteResponse(
id=row["id"],
nombre=row["nombre"],
empresa=row["empresa"],
email=row["email"],
telefono=row["telefono"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
)
return cliente.model_copy(
update={"cotizaciones": [dict(c) for c in cotizaciones]}
)
@router.put(
"/{cliente_id}",
response_model=ClienteResponse,
responses={404: {"model": ErrorResponse}},
summary="Update a client",
)
async def update_cliente(
cliente_id: str,
body: ClienteUpdate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id)
if not existing:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
updates: list[str] = []
params: list = []
idx = 1
for field in ("nombre", "empresa", "email", "telefono"):
value = getattr(body, field, None)
if value is not None:
updates.append(f"{field} = ${idx}")
params.append(value)
idx += 1
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(cliente_id)
row = await db.fetchrow(
f'UPDATE "Cliente" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *',
*params,
)
return ClienteResponse(
id=row["id"],
nombre=row["nombre"],
empresa=row["empresa"],
email=row["email"],
telefono=row["telefono"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
)
@router.delete(
"/{cliente_id}",
response_model=OkResponse,
responses={409: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
summary="Delete a client (fails if cotizaciones exist)",
)
async def delete_cliente(
cliente_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id)
if not existing:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
cot_count = await db.fetchval(
'SELECT COUNT(*) FROM "Cotizacion" WHERE "clienteId" = $1', cliente_id
)
if cot_count > 0:
raise HTTPException(
status_code=409,
detail="No se puede eliminar: el cliente tiene cotizaciones asociadas",
)
await db.execute('DELETE FROM "Cliente" WHERE id = $1', cliente_id)
return OkResponse()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from asyncpg import Connection
from app.dependencies import get_db
from app.models.configuracion import ConfigUpdate, ConfigResponse, ALLOWED_CONFIG_KEYS
from app.models.common import ErrorResponse, OkResponse
from app.auth import require_auth
router = APIRouter(prefix="/configuracion", tags=["Configuración"])
@router.get("", response_model=ConfigResponse)
async def get_configuracion(
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
rows = await db.fetch("SELECT clave, valor FROM Configuracion")
config = {row["clave"]: row["valor"] for row in rows}
return ConfigResponse(config=config)
@router.put("", response_model=OkResponse, responses={400: {"model": ErrorResponse}})
async def update_configuracion(
body: ConfigUpdate,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
allowed = body.get_allowed()
if not allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No valid config keys provided",
)
async with db.transaction():
for clave, valor in allowed.items():
await db.execute(
"""
INSERT INTO "Configuracion" (id, clave, valor, "updatedAt")
VALUES (gen_random_uuid(), $1, $2, now())
ON CONFLICT (clave) DO UPDATE SET valor = $2, "updatedAt" = now()
""",
clave,
valor,
)
return OkResponse()
+565
View File
@@ -0,0 +1,565 @@
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.auth import require_auth
from app.database import get_pool
from app.dependencies import PaginationParams, parse_date
from app.models.common import Meta, OkResponse, PaginatedResponse
from app.models.cotizacion import (
ActualizarPrecioRequest,
CambiarEstadoRequest,
CotizacionCreate,
CotizacionResponse,
CotizacionUpdate,
)
from app.services.calculators import ESTADOS_COTIZACION
router = APIRouter(prefix="/cotizaciones", tags=["Cotizaciones"])
def _cuid() -> str:
return uuid.uuid4().hex[:25]
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
d = dict(cot)
d["cliente"] = dict(cliente) if cliente else None
d["asesor"] = dict(asesor) if asesor else None
d["servicios"] = [dict(s) for s in servicios] if servicios else []
d["planBucefalo"] = dict(plan) if plan else None
return d
async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot:
return None
cliente = await conn.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cot["clienteId"])
asesor = await conn.fetchrow('SELECT id, email, name, role FROM "User" WHERE id = $1', cot["asesorId"])
servicios = await conn.fetch(
"""
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
s.nombre, s.descripcion, s."categoriaId"
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1
ORDER BY sc.fase, sc.id
""",
cot_id,
)
plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
async def _resolve_bucefalo_servicio(conn) -> str | None:
row = await conn.fetchrow(
"""
SELECT s.id FROM "ServicioCatalogo" s
JOIN "Categoria" c ON c.id = s."categoriaId"
WHERE c.nombre = 'CRM' AND s."tipoPago" = 'mensual' AND s.activo = true
ORDER BY s.orden LIMIT 1
"""
)
return row["id"] if row else None
async def _find_or_create_cliente(conn, nombre: str, empresa: str, email: str, telefono: str) -> str:
row = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND COALESCE(empresa, \'\') = $2',
nombre,
empresa or "",
)
if row:
if email or telefono:
await conn.execute(
'UPDATE "Cliente" SET email = COALESCE($1, email), telefono = COALESCE($2, telefono), "updatedAt" = NOW() WHERE id = $3',
email or None,
telefono or None,
row["id"],
)
return row["id"]
new_id = _cuid()
await conn.execute(
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, $5, NOW(), NOW())',
new_id,
nombre,
empresa or None,
email or None,
telefono or None,
)
return new_id
# ---------------------------------------------------------------------------
# 1. GET /cotizaciones — List with filters + pagination
# ---------------------------------------------------------------------------
@router.get("", response_model=PaginatedResponse[CotizacionResponse])
async def list_cotizaciones(
pagination: PaginationParams = Depends(),
estado: str | None = Query(None),
asesorId: str | None = Query(None),
clienteId: str | None = Query(None),
q: str | None = Query(None),
desde: str | None = Query(None),
hasta: str | None = Query(None),
_auth: dict = Depends(require_auth),
):
pool = await get_pool()
conditions: list[str] = []
params: list = []
idx = 1
if estado:
conditions.append(f'c.estado = ${idx}')
params.append(estado)
idx += 1
if asesorId:
conditions.append(f'c."asesorId" = ${idx}')
params.append(asesorId)
idx += 1
if clienteId:
conditions.append(f'c."clienteId" = ${idx}')
params.append(clienteId)
idx += 1
if q:
conditions.append(
f'(c.numero ILIKE ${idx} OR cl.nombre ILIKE ${idx} OR c.proyecto ILIKE ${idx})'
)
params.append(f"%{q}%")
idx += 1
desde_dt = parse_date(desde)
if desde_dt:
conditions.append(f'c.fecha >= ${idx}')
params.append(desde_dt)
idx += 1
hasta_dt = parse_date(hasta)
if hasta_dt:
conditions.append(f'c.fecha <= ${idx}')
params.append(hasta_dt)
idx += 1
where = "WHERE " + " AND ".join(conditions) if conditions else ""
count_row = await pool.fetchrow(
f'SELECT COUNT(*) FROM "Cotizacion" c LEFT JOIN "Cliente" cl ON cl.id = c."clienteId" {where}',
*params,
)
total = count_row["count"]
rows = await pool.fetch(
f"""
SELECT c.* FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON cl.id = c."clienteId"
{where}
ORDER BY c."createdAt" DESC
LIMIT ${idx} OFFSET ${idx + 1}
""",
*params,
pagination.limit,
pagination.offset,
)
cotizaciones = []
for r in rows:
full = await _fetch_cotizacion_full(pool, r["id"])
if full:
cotizaciones.append(full)
pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0
return PaginatedResponse(
data=cotizaciones,
meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages),
)
# ---------------------------------------------------------------------------
# 2. POST /cotizaciones — Create
# ---------------------------------------------------------------------------
@router.post("", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED)
async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(require_auth)):
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
cliente_id = await _find_or_create_cliente(
conn,
body.cliente.nombre,
body.cliente.empresa,
body.cliente.email,
body.cliente.telefono,
)
cot_id = _cuid()
now = datetime.now(timezone.utc)
await conn.execute(
"""
INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
"clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
""",
cot_id,
body.numero,
body.fecha,
body.vigencia,
body.moneda,
body.tipoCambio,
body.proyecto,
body.esquemaPago,
body.incluirBonos,
body.incluirFinanciamiento,
body.observaciones or None,
cliente_id,
body.asesorId,
now,
)
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
for svc in body.servicios:
catalogo_id = svc.catalogoId
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
""",
_cuid(),
cot_id,
catalogo_id,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
if body.planBucefalo:
await conn.execute(
"""
INSERT INTO "PlanBucefaloCotizacion"
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,true,$5,$5)
""",
_cuid(),
cot_id,
body.planBucefalo.nivel,
body.planBucefalo.precio,
now,
)
row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
return dict(row)
# ---------------------------------------------------------------------------
# 3. GET /cotizaciones/{id} — Get with relations
# ---------------------------------------------------------------------------
@router.get("/{cotizacion_id}", response_model=CotizacionResponse)
async def get_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
pool = await get_pool()
full = await _fetch_cotizacion_full(pool, cotizacion_id)
if not full:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
return full
# ---------------------------------------------------------------------------
# 4. PUT /cotizaciones/{id} — Update
# ---------------------------------------------------------------------------
@router.put("/{cotizacion_id}", response_model=CotizacionResponse)
async def update_cotizacion(
cotizacion_id: str,
body: CotizacionUpdate,
_auth: dict = Depends(require_auth),
):
pool = await get_pool()
async with pool.acquire() as conn:
existing = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not existing:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
async with conn.transaction():
cliente_id = existing["clienteId"]
if body.cliente:
cliente_id = await _find_or_create_cliente(
conn,
body.cliente.nombre,
body.cliente.empresa,
body.cliente.email,
body.cliente.telefono,
)
updates: list[str] = []
params: list = []
idx = 1
def _add(field: str, col: str, val):
nonlocal idx
if val is not None:
updates.append(f'{col} = ${idx}')
params.append(val)
idx += 1
_add("fecha", "fecha", body.fecha)
_add("vigencia", "vigencia", body.vigencia)
_add("moneda", "moneda", body.moneda)
_add("tipoCambio", '"tipoCambio"', body.tipoCambio)
_add("proyecto", "proyecto", body.proyecto)
_add("esquemaPago", '"esquemaPago"', body.esquemaPago)
_add("incluirBonos", '"incluirBonos"', body.incluirBonos)
_add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento)
_add("observaciones", "observaciones", body.observaciones)
_add("estado", "estado", body.estado)
if cliente_id != existing["clienteId"]:
updates.append(f'"clienteId" = ${idx}')
params.append(cliente_id)
idx += 1
if updates:
updates.append(f'"updatedAt" = ${idx}')
params.append(datetime.now(timezone.utc))
idx += 1
params.append(cotizacion_id)
await conn.execute(
f'UPDATE "Cotizacion" SET {", ".join(updates)} WHERE id = ${idx}',
*params,
)
if body.servicios is not None:
await conn.execute('DELETE FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cotizacion_id)
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
now = datetime.now(timezone.utc)
for svc in body.servicios:
catalogo_id = svc.catalogoId
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
""",
_cuid(),
cotizacion_id,
catalogo_id,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
if body.planBucefalo is not None:
existing_plan = await conn.fetchrow(
'SELECT id FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
now = datetime.now(timezone.utc)
if existing_plan:
await conn.execute(
'UPDATE "PlanBucefaloCotizacion" SET nivel = $1, precio = $2, "updatedAt" = $3 WHERE id = $4',
body.planBucefalo.nivel,
body.planBucefalo.precio,
now,
existing_plan["id"],
)
else:
await conn.execute(
"""
INSERT INTO "PlanBucefaloCotizacion"
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,true,$5,$5)
""",
_cuid(),
cotizacion_id,
body.planBucefalo.nivel,
body.planBucefalo.precio,
now,
)
elif body.planBucefalo is None and "planBucefalo" in body.model_fields_set:
await conn.execute(
'DELETE FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
full = await _fetch_cotizacion_full(pool, cotizacion_id)
return full
# ---------------------------------------------------------------------------
# 5. DELETE /cotizaciones/{id}
# ---------------------------------------------------------------------------
@router.delete("/{cotizacion_id}", response_model=OkResponse)
async def delete_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
pool = await get_pool()
existing = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not existing:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
await pool.execute('DELETE FROM "Cotizacion" WHERE id = $1', cotizacion_id)
return OkResponse(ok=True)
# ---------------------------------------------------------------------------
# 6. PATCH /cotizaciones/{id}/precio
# ---------------------------------------------------------------------------
@router.patch("/{cotizacion_id}/precio", response_model=OkResponse)
async def update_precio_servicio(
cotizacion_id: str,
body: ActualizarPrecioRequest,
_auth: dict = Depends(require_auth),
):
pool = await get_pool()
cot = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not cot:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
svc = await pool.fetchrow(
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
body.servicioId,
cotizacion_id,
)
if not svc:
raise HTTPException(status_code=404, detail="Servicio no pertenece a esta cotización")
await pool.execute(
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
body.precio,
body.servicioId,
)
return OkResponse(ok=True)
# ---------------------------------------------------------------------------
# 7. PATCH /cotizaciones/{id}/estado
# ---------------------------------------------------------------------------
@router.patch("/{cotizacion_id}/estado", response_model=OkResponse)
async def cambiar_estado(
cotizacion_id: str,
body: CambiarEstadoRequest,
_auth: dict = Depends(require_auth),
):
if body.estado not in ESTADOS_COTIZACION:
raise HTTPException(
status_code=422,
detail=f"Estado inválido. Permitidos: {', '.join(ESTADOS_COTIZACION)}",
)
pool = await get_pool()
result = await pool.execute(
'UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2',
body.estado,
cotizacion_id,
)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Cotización no encontrada")
return OkResponse(ok=True)
# ---------------------------------------------------------------------------
# 8. POST /cotizaciones/{id}/duplicate
# ---------------------------------------------------------------------------
@router.post("/{cotizacion_id}/duplicate", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
pool = await get_pool()
async with pool.acquire() as conn:
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not original:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
servicios = await conn.fetch(
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1',
cotizacion_id,
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
async with conn.transaction():
now = datetime.now(timezone.utc)
new_id = _cuid()
new_numero = original["numero"] + "-COPY"
await conn.execute(
"""
INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
"clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
""",
new_id,
new_numero,
original["fecha"],
original["vigencia"],
original["moneda"],
original["tipoCambio"],
original["proyecto"],
original["esquemaPago"],
original["incluirBonos"],
original["incluirFinanciamiento"],
original["observaciones"],
original["clienteId"],
original["asesorId"],
now,
)
for svc in servicios:
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)
""",
_cuid(),
new_id,
svc["servicioCatalogoId"],
svc["fase"],
svc["tipoPago"],
svc["precio"],
svc["tiempoEntrega"],
svc["entregables"],
svc["notas"],
svc["seleccionado"],
now,
)
if plan:
await conn.execute(
"""
INSERT INTO "PlanBucefaloCotizacion"
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$6)
""",
_cuid(),
new_id,
plan["nivel"],
plan["precio"],
plan["seleccionado"],
now,
)
full = await _fetch_cotizacion_full(pool, new_id)
return full
+331
View File
@@ -0,0 +1,331 @@
"""Export endpoints — PDF, Excel, CSV generation."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import PlainTextResponse, Response
from asyncpg import Connection
from app.dependencies import get_db
from app.models.export_ import ExportDraft
from app.models.common import ErrorResponse
from app.auth import require_auth
from app.services.calculators import bucefalo_precio, calcular_vigencia, sanitize_filename
from app.services.pdf_generator import generate_cotizacion_pdf
from app.services.excel_generator import generate_cotizacion_excel
router = APIRouter(prefix="/export", tags=["Export"])
async def _load_branding(db: Connection) -> dict[str, str]:
rows = await db.fetch('SELECT clave, valor FROM "Configuracion"')
return {r["clave"]: r["valor"] for r in rows}
def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) -> dict[str, Any]:
fecha = datetime.now()
if draft.fecha:
try:
fecha = datetime.fromisoformat(draft.fecha.replace("Z", "+00:00"))
except ValueError:
try:
fecha = datetime.strptime(draft.fecha, "%Y-%m-%d")
except ValueError:
pass
vigencia = calcular_vigencia(fecha)
logo_raw = branding.get("logo_base64", "")
logo_mime = None
logo_b64 = None
if logo_raw and ":" in logo_raw:
parts = logo_raw.split(":", 1)
logo_mime = parts[0]
logo_b64 = parts[1]
elif logo_raw:
logo_b64 = logo_raw
return {
"numero": "BORRADOR",
"clienteNombre": draft.clienteNombre,
"clienteEmpresa": draft.clienteEmpresa,
"asesorNombre": draft.asesorNombre,
"fecha": fecha,
"vigencia": vigencia,
"moneda": draft.moneda,
"tipoCambio": draft.tipoCambio,
"proyecto": draft.proyecto,
"esquemaPago": draft.esquemaPago,
"servicios": [
{
"nombre": s.nombre,
"fase": s.fase,
"tipoPago": s.tipoPago,
"precio": s.precio,
"tiempoEntrega": s.tiempoEntrega,
"entregables": s.entregables,
}
for s in draft.servicios
],
"planBucefaloNivel": draft.planBucefaloNivel,
"planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0,
"incluirBonos": draft.incluirBonos,
"colorPrimario": branding.get("color_primario", "#2563eb"),
"colorSecundario": branding.get("color_secundario", "#1e293b"),
"logoBase64": logo_b64,
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
}
async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[str, Any] | None:
cot = await db.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
u.name as asesor_nombre
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON cl.id = c."clienteId"
LEFT JOIN "User" u ON u.id = c."asesorId"
WHERE c.id = $1""",
cotizacion_id,
)
if not cot:
return None
servicios = await db.fetch(
"""SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables,
s.nombre
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true
ORDER BY sc.fase, sc.id""",
cotizacion_id,
)
plan = await db.fetchrow(
'SELECT nivel, precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
branding = await _load_branding(db)
logo_raw = branding.get("logo_base64", "")
logo_mime = None
logo_b64 = None
if logo_raw and ":" in logo_raw:
parts = logo_raw.split(":", 1)
logo_mime = parts[0]
logo_b64 = parts[1]
elif logo_raw:
logo_b64 = logo_raw
servicios_list = []
for s in servicios:
ent = s["entregables"]
if isinstance(ent, str):
try:
ent = json.loads(ent)
except (json.JSONDecodeError, TypeError):
ent = []
servicios_list.append({
"nombre": s["nombre"],
"fase": s["fase"],
"tipoPago": s["tipoPago"],
"precio": float(s["precio"]),
"tiempoEntrega": s["tiempoEntrega"],
"entregables": ent or [],
})
plan_nivel = plan["nivel"] if plan else None
return {
"numero": cot["numero"],
"clienteNombre": cot["cliente_nombre"] or "",
"clienteEmpresa": cot["cliente_empresa"] or "",
"asesorNombre": cot["asesor_nombre"] or "",
"fecha": cot["fecha"],
"vigencia": cot["vigencia"],
"moneda": cot["moneda"],
"tipoCambio": cot["tipoCambio"],
"proyecto": cot["proyecto"],
"esquemaPago": cot["esquemaPago"],
"servicios": servicios_list,
"planBucefaloNivel": plan_nivel,
"planBucefaloPrecio": float(plan["precio"]) if plan else 0,
"incluirBonos": cot["incluirBonos"],
"colorPrimario": branding.get("color_primario", "#2563eb"),
"colorSecundario": branding.get("color_secundario", "#1e293b"),
"logoBase64": logo_b64,
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
}
# ── PDF ENDPOINTS ──────────────────────────────────
@router.post("/pdf")
async def export_pdf_draft(
body: ExportDraft,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
branding = await _load_branding(db)
pdf_data = _build_pdf_data_from_draft(body, branding)
pdf_bytes = generate_cotizacion_pdf(pdf_data)
empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion"
filename = f"{sanitize_filename(empresa)} - BORRADOR.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/pdf/{cotizacion_id}")
async def export_pdf_saved(
cotizacion_id: str,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
pdf_data = await _build_pdf_data_from_db(db, cotizacion_id)
if not pdf_data:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
pdf_bytes = generate_cotizacion_pdf(pdf_data)
empresa = pdf_data["clienteEmpresa"] or pdf_data["clienteNombre"]
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(pdf_data['numero'])}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ── EXCEL ENDPOINTS ────────────────────────────────
@router.post("/excel")
async def export_excel_draft(
body: ExportDraft,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
branding = await _load_branding(db)
excel_data = _build_pdf_data_from_draft(body, branding)
excel_bytes = generate_cotizacion_excel(excel_data, saved=False)
empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion"
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(body.clienteNombre)} - BORRADOR.xlsx"
return Response(
content=excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/excel/{cotizacion_id}")
async def export_excel_saved(
cotizacion_id: str,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
excel_data = await _build_pdf_data_from_db(db, cotizacion_id)
if not excel_data:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
excel_bytes = generate_cotizacion_excel(excel_data, saved=True)
empresa = excel_data["clienteEmpresa"] or excel_data["clienteNombre"]
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(excel_data['numero'])}.xlsx"
return Response(
content=excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ── CSV ENDPOINTS ──────────────────────────────────
@router.get("/catalogo", response_class=PlainTextResponse)
async def export_catalogo_csv(
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
rows = await db.fetch(
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, s.orden, c.nombre AS categoria
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON c.id = s."categoriaId"
WHERE s.activo = true
ORDER BY s.fase, s.orden"""
)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"Nombre", "Categoria", "Fase", "Tipo de Pago", "Precio Base",
"Tiempo de Entrega", "Entregables", "Variante",
])
for r in rows:
entregables = r["entregablesDefault"] or []
if isinstance(entregables, list):
entregables = " | ".join(entregables)
elif isinstance(entregables, str):
try:
ent_list = json.loads(entregables)
entregables = " | ".join(ent_list) if isinstance(ent_list, list) else entregables
except (json.JSONDecodeError, TypeError):
pass
writer.writerow([
r["nombre"], r["categoria"], r["fase"], r["tipoPago"],
r["precioBase"], r["tiempoEntrega"], entregables, r["variante"],
])
return PlainTextResponse(
content=buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=catalogo-servicios.csv"},
)
@router.get("/catalogo/plantilla", response_class=PlainTextResponse)
async def export_catalogo_template(db: Connection = Depends(get_db)):
cats = await db.fetch(
'SELECT nombre FROM "Categoria" WHERE activo = true ORDER BY orden'
)
cat_names = [r["nombre"] for r in cats]
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"nombre", "descripcion", "fase", "tipoPago",
"precioBase", "tiempoEntrega", "entregablesDefault", "variante", "categoria",
])
writer.writerow([
"SEO On-Page", "Optimización on-page para buscadores", "Contenido y SEO", "mensual",
"2900", "7 - 14 dias", "Keyword research | On-page optimization", "", "SEO",
])
writer.writerow([
"", "", "", "", "", "", "", "", "",
])
writer.writerow([f"CATEGORIAS: {', '.join(cat_names)}"])
writer.writerow(["FASES: Auditoria, Setup e Infraestructura, Publicidad y Manejo, Contenido y SEO"])
writer.writerow(["TIPOS DE PAGO: unico, mensual"])
return PlainTextResponse(
content=buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=plantilla-catalogo.csv"},
)
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from asyncpg import Connection
from app.dependencies import get_db
from app.models.export_ import (
FinanciamientoRequest,
FinanciamientoResponse,
BonoResponse,
PlanBucefaloResponse,
)
from app.models.common import ErrorResponse
from app.auth import require_auth
from app.services.calculators import (
calcular_financiamiento,
FINANCIAMIENTO_PLANES,
PLANES_BUCEFALO,
)
router = APIRouter(prefix="/financiamiento", tags=["Financiamiento"])
class PlanResponse(FinanciamientoResponse):
meses: int
tasa: float
comision: float
@router.get("/planes", response_model=list[PlanResponse])
async def get_planes(_auth: dict = Depends(require_auth)):
return [
PlanResponse(
meses=p["meses"],
tasa=p["tasa"],
comision=p["comision"],
**calcular_financiamiento(1000, p["meses"], p["tasa"], p["comision"]),
)
for p in sorted(FINANCIAMIENTO_PLANES, key=lambda x: x["meses"])
]
@router.post(
"/calcular",
response_model=FinanciamientoResponse,
responses={400: {"model": ErrorResponse}},
)
async def calcular(
body: FinanciamientoRequest,
_auth: dict = Depends(require_auth),
):
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == body.meses), None)
if not plan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"No financing plan available for {body.meses} months",
)
if body.monto < plan["montoMinimo"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Minimum amount for {body.meses} months is ${plan['montoMinimo']}",
)
result = calcular_financiamiento(body.monto, body.meses, plan["tasa"], plan["comision"])
return FinanciamientoResponse(
**result,
meses=body.meses,
tasa=plan["tasa"],
comision=plan["comision"],
)
@router.get(
"/simulacion/{cotizacion_id}",
response_model=list[FinanciamientoResponse],
responses={404: {"model": ErrorResponse}},
)
async def simulacion(
cotizacion_id: str,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
cot = await db.fetchrow(
'SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id
)
if not cot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cotización not found",
)
rows = await db.fetch(
'SELECT precio FROM "ServicioCotizado" WHERE "cotizacionId" = $1 AND seleccionado = true',
cotizacion_id,
)
total = sum(float(r["precio"]) for r in rows)
plan_buc = await db.fetchrow(
'SELECT precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
if plan_buc:
total += float(plan_buc["precio"])
if total <= 0:
return []
results = []
for plan in FINANCIAMIENTO_PLANES:
if total < plan["montoMinimo"]:
continue
result = calcular_financiamiento(total, plan["meses"], plan["tasa"], plan["comision"])
results.append(
FinanciamientoResponse(
**result,
meses=plan["meses"],
tasa=plan["tasa"],
comision=plan["comision"],
)
)
return results
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from fastapi import APIRouter
from app.database import get_pool
from app.models.common import ErrorResponse, HealthResponse, APIInfoResponse
router = APIRouter(tags=["Health"])
@router.get("/health", response_model=HealthResponse)
async def health_check():
pool = await get_pool()
try:
async with pool.acquire() as conn:
await conn.execute("SELECT 1")
db_status = "connected"
except Exception:
db_status = "disconnected"
return HealthResponse(
status="ok",
timestamp=datetime.now(timezone.utc).isoformat(),
database=db_status,
)
@router.get("/api-info", response_model=APIInfoResponse)
async def api_info():
return APIInfoResponse(
name="Cotizador E3 API",
version="1.0.0",
description="API para el sistema de cotizaciones de Consultoría E3",
capabilities=[
"Gestión de cotizaciones",
"Catálogo de servicios",
"Planes Bucéfalo CRM",
"Exportación PDF/Excel",
"Autenticación JWT",
"Financiamiento Openpay",
],
)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import csv
import io
from fastapi import APIRouter, Depends, File, UploadFile
from asyncpg import Connection
from app.dependencies import get_db
from app.auth import require_auth
router = APIRouter(prefix="/import", tags=["Import"])
REQUIRED_COLUMNS = {"nombre", "fase", "tipoPago", "precioBase", "categoria"}
@router.post("/catalogo")
async def import_catalogo_csv(
file: UploadFile = File(...),
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
content = await file.read()
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames or not REQUIRED_COLUMNS.issubset(set(reader.fieldnames)):
missing = REQUIRED_COLUMNS - set(reader.fieldnames or [])
return {
"creados": 0,
"omitidos": 0,
"errores": [f"Missing required columns: {', '.join(missing)}"],
}
categorias = await db.fetch('SELECT id, nombre FROM "Categoria" WHERE activo = true')
cat_map = {r["nombre"]: r["id"] for r in categorias}
existing = await db.fetch('SELECT nombre FROM "ServicioCatalogo" WHERE activo = true')
existing_names = {r["nombre"] for r in existing}
creados = 0
omitidos = 0
errores: list[str] = []
async with db.transaction():
for i, row in enumerate(reader, start=2):
nombre = (row.get("nombre") or "").strip()
if not nombre:
omitidos += 1
continue
if nombre in existing_names:
omitidos += 1
continue
cat_nombre = (row.get("categoria") or "").strip()
cat_id = cat_map.get(cat_nombre)
if not cat_id:
errores.append(f"Row {i}: category '{cat_nombre}' not found")
continue
try:
fase = int(row.get("fase", "0"))
if fase not in (0, 1, 2, 3):
raise ValueError
except (ValueError, TypeError):
errores.append(f"Row {i}: invalid fase '{row.get('fase')}'")
continue
tipo_pago = (row.get("tipoPago") or "").strip()
if tipo_pago not in ("unico", "mensual"):
errores.append(f"Row {i}: invalid tipoPago '{tipo_pago}'")
continue
try:
precio = float(row.get("precioBase", "0"))
except (ValueError, TypeError):
errores.append(f"Row {i}: invalid precioBase '{row.get('precioBase')}'")
continue
tiempo = (row.get("tiempoEntrega") or "7 - 14 dias").strip()
variante = (row.get("variante") or "").strip() or None
try:
orden = int(row.get("orden", "0"))
except (ValueError, TypeError):
orden = 0
entregables_raw = (row.get("entregablesDefault") or "").strip()
entregables = [e.strip() for e in entregables_raw.split(";") if e.strip()] if entregables_raw else []
await db.execute(
"""
INSERT INTO "ServicioCatalogo"
(id, nombre, descripcion, fase, "tipoPago", "precioBase",
"tiempoEntrega", "entregablesDefault", "categoriaId",
variante, orden, activo, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, now(), now())
""",
nombre,
(row.get("descripcion") or "").strip() or None,
fase,
tipo_pago,
precio,
tiempo,
entregables if entregables else None,
cat_id,
variante,
orden,
)
existing_names.add(nombre)
creados += 1
return {"creados": creados, "omitidos": omitidos, "errores": errores}
+425
View File
@@ -0,0 +1,425 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from app.database import get_pool
from app.dependencies import get_db
from app.auth import require_auth
from app.models.paquete import (
PaqueteCreate,
PaqueteUpdate,
PaqueteResponse,
FasePaqueteResponse,
ManageAction,
)
from app.models.common import ErrorResponse, OkResponse
router = APIRouter(prefix="/paquetes", tags=["Paquetes"])
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"""Build a PaqueteResponse dict with nested fases and servicios."""
paquete_id = paquete_row["id"]
fases_rows = await conn.fetch(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC',
paquete_id,
)
fases: list[dict] = []
for fase in fases_rows:
servicios_rows = await conn.fetch(
'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",'
' sp."createdAt", sp."updatedAt",'
' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,'
' sc.fase AS sc_fase, sc."tipoPago" AS sc_tipoPago,'
' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,'
' sc."entregablesDefault" AS sc_entregablesDefault,'
' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,'
' sc.activo AS sc_activo, sc.orden AS sc_orden,'
' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,'
' cat.id AS cat_id, cat.nombre AS cat_nombre, cat.descripcion AS cat_descripcion,'
' cat.color AS cat_color, cat.activo AS cat_activo, cat.orden AS cat_orden,'
' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt'
' FROM "ServicioPaquete" sp'
' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"'
' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"'
' WHERE sp."fasePaqueteId" = $1',
fase["id"],
)
servicios: list[dict] = []
for s in servicios_rows:
cat = None
if s["cat_id"]:
cat = {
"id": s["cat_id"],
"nombre": s["cat_nombre"],
"descripcion": s["cat_descripcion"],
"color": s["cat_color"],
"activo": s["cat_activo"],
"orden": s["cat_orden"],
"createdAt": s["cat_createdAt"],
"updatedAt": s["cat_updatedAt"],
}
servicios.append({
"id": s["id"],
"servicioCatalogoId": s["servicioCatalogoId"],
"fasePaqueteId": s["fasePaqueteId"],
"createdAt": s["createdAt"],
"updatedAt": s["updatedAt"],
"servicio": {
"id": s["sc_id"],
"nombre": s["sc_nombre"],
"descripcion": s["sc_descripcion"],
"fase": s["sc_fase"],
"tipoPago": s["sc_tipoPago"],
"precioBase": s["sc_precioBase"],
"tiempoEntrega": s["sc_tiempoEntrega"],
"entregablesDefault": s["sc_entregablesDefault"],
"categoriaId": s["sc_categoriaId"],
"variante": s["sc_variante"],
"activo": s["sc_activo"],
"orden": s["sc_orden"],
"createdAt": s["sc_createdAt"],
"updatedAt": s["sc_updatedAt"],
"categoriaRel": cat,
},
})
fases.append({
"id": fase["id"],
"paqueteId": fase["paqueteId"],
"nombre": fase["nombre"],
"orden": fase["orden"],
"createdAt": fase["createdAt"],
"updatedAt": fase["updatedAt"],
"servicios": servicios,
})
return {
"id": paquete_row["id"],
"nombre": paquete_row["nombre"],
"descripcion": paquete_row["descripcion"],
"activo": paquete_row["activo"],
"createdAt": paquete_row["createdAt"],
"updatedAt": paquete_row["updatedAt"],
"fases": fases,
}
@router.get("", response_model=list[PaqueteResponse])
async def list_paquetes(
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
rows = await conn.fetch(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC'
)
results: list[dict] = []
for row in rows:
results.append(await _build_paquete_response(conn, dict(row)))
return results
@router.post(
"",
response_model=PaqueteResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_paquete(
body: PaqueteCreate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
now = datetime.now(timezone.utc)
paquete_row = await conn.fetchrow(
'INSERT INTO "Paquete" (id, nombre, descripcion, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $3)"
' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"',
body.nombre,
body.descripcion,
now,
)
paquete_id = paquete_row["id"]
for fase in body.fases:
await conn.execute(
'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)",
paquete_id,
fase.nombre,
fase.orden,
now,
)
return await _build_paquete_response(conn, dict(paquete_row))
@router.get(
"/{paquete_id}",
response_model=PaqueteResponse,
responses={404: {"model": ErrorResponse}},
)
async def get_paquete(
paquete_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
row = await conn.fetchrow(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE id = $1',
paquete_id,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
return await _build_paquete_response(conn, dict(row))
@router.put(
"/{paquete_id}",
response_model=PaqueteResponse,
responses={404: {"model": ErrorResponse}},
)
async def update_paquete(
paquete_id: str,
body: PaqueteUpdate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
existing = await conn.fetchrow(
'SELECT id FROM "Paquete" WHERE id = $1', paquete_id
)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
updates: list[str] = []
params: list = []
idx = 1
if body.nombre is not None:
updates.append(f"nombre = ${idx}")
params.append(body.nombre)
idx += 1
if body.descripcion is not None:
updates.append(f"descripcion = ${idx}")
params.append(body.descripcion)
idx += 1
if body.activo is not None:
updates.append(f"activo = ${idx}")
params.append(body.activo)
idx += 1
if not updates:
row = await conn.fetchrow(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE id = $1',
paquete_id,
)
return await _build_paquete_response(conn, dict(row))
updates.append(f'"updatedAt" = ${idx}')
params.append(datetime.now(timezone.utc))
idx += 1
params.append(paquete_id)
row = await conn.fetchrow(
f'UPDATE "Paquete" SET {", ".join(updates)} WHERE id = ${idx}'
' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"',
*params,
)
return await _build_paquete_response(conn, dict(row))
@router.delete(
"/{paquete_id}",
response_model=OkResponse,
responses={404: {"model": ErrorResponse}},
)
async def delete_paquete(
paquete_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
result = await conn.execute(
'DELETE FROM "Paquete" WHERE id = $1', paquete_id
)
if result == "DELETE 0":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
return OkResponse(ok=True)
@router.post(
"/{paquete_id}/manage",
responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def manage_paquete(
paquete_id: str,
body: ManageAction,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
paquete = await conn.fetchrow(
'SELECT id FROM "Paquete" WHERE id = $1', paquete_id
)
if not paquete:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
now = datetime.now(timezone.utc)
action = body.action
if action == "addFase":
if not body.nombre:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El campo 'nombre' es requerido para addFase",
)
row = await conn.fetchrow(
'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)"
' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"',
paquete_id,
body.nombre,
body.orden or 0,
now,
)
return dict(row), status.HTTP_201_CREATED
if action == "updateFase":
if not body.faseId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El campo 'faseId' es requerido para updateFase",
)
updates: list[str] = []
params: list = []
idx = 1
if body.nombre is not None:
updates.append(f"nombre = ${idx}")
params.append(body.nombre)
idx += 1
if body.orden is not None:
updates.append(f"orden = ${idx}")
params.append(body.orden)
idx += 1
if not updates:
row = await conn.fetchrow(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
body.faseId,
paquete_id,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada",
)
return dict(row)
updates.append(f'"updatedAt" = ${idx}')
params.append(now)
idx += 1
params.extend([body.faseId, paquete_id])
row = await conn.fetchrow(
f'UPDATE "FasePaquete" SET {", ".join(updates)}'
f" WHERE id = ${idx} AND \"paqueteId\" = ${idx + 1}"
' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"',
*params,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada",
)
return dict(row)
if action == "deleteFase":
if not body.faseId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El campo 'faseId' es requerido para deleteFase",
)
result = await conn.execute(
'DELETE FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
body.faseId,
paquete_id,
)
if result == "DELETE 0":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada",
)
return {"ok": True}
if action == "addServicio":
if not body.servicioCatalogoId or not body.fasePaqueteId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para addServicio",
)
fase = await conn.fetchrow(
'SELECT id FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
body.fasePaqueteId,
paquete_id,
)
if not fase:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada en este paquete",
)
try:
row = await conn.fetchrow(
'INSERT INTO "ServicioPaquete" (id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $3)"
' RETURNING id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt"',
body.servicioCatalogoId,
body.fasePaqueteId,
now,
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Este servicio ya está asignado a esta fase",
)
raise
return dict(row), status.HTTP_201_CREATED
if action == "removeServicio":
if not body.servicioCatalogoId or not body.fasePaqueteId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para removeServicio",
)
result = await conn.execute(
'DELETE FROM "ServicioPaquete"'
' WHERE "servicioCatalogoId" = $1 AND "fasePaqueteId" = $2',
body.servicioCatalogoId,
body.fasePaqueteId,
)
if result == "DELETE 0":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Servicio no encontrado en esta fase",
)
return {"ok": True}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Acción desconocida: '{action}'. Acciones válidas: addFase, updateFase, deleteFase, addServicio, removeServicio",
)
View File
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from datetime import date, datetime
IVA_RATE = 0.16
FASES: dict[int, str] = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
2: "FASE 2 - Publicidad y Manejo",
3: "FASE 3 - Contenido y SEO",
}
FASES_SHORT: dict[int, str] = {
0: "FASE 0 - Auditoria",
1: "FASE 1 - Setup e Infraestructura",
2: "FASE 2 - Publicidad y Manejo",
3: "FASE 3 - Contenido y SEO",
}
PLANES_BUCEFALO = [
{"nivel": "basico", "label": "Basico", "precio": 1000},
{"nivel": "estandar", "label": "Estandar", "precio": 3500},
{"nivel": "premium", "label": "Premium", "precio": 4500},
{"nivel": "empresarial", "label": "Empresarial", "precio": 7500},
]
ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"]
FINANCIAMIENTO_PLANES = [
{"meses": 3, "tasa": 0.077, "comision": 2.5, "montoMinimo": 300},
{"meses": 6, "tasa": 0.107, "comision": 2.5, "montoMinimo": 600},
{"meses": 9, "tasa": 0.137, "comision": 2.5, "montoMinimo": 900},
{"meses": 12, "tasa": 0.167, "comision": 2.5, "montoMinimo": 1200},
]
BONOS = [
{"id": "bono-1", "numero": 1, "titulo": "Servicio Centinela Web", "descripcion": "Monitoreo web 30 min/mes", "activo": True},
{"id": "bono-2", "numero": 2, "titulo": "Workshop Buyer Persona", "descripcion": "Taller de definición de buyer persona", "activo": True},
{"id": "bono-3", "numero": 3, "titulo": "Workshop Propuesta de Valor", "descripcion": "Taller de propuesta de valor", "activo": True},
{"id": "bono-4", "numero": 4, "titulo": "Membresia Premium", "descripcion": "Membresía premium por 1 año", "activo": True},
{"id": "bono-5", "numero": 5, "titulo": "Mes Gratis Bucefalo CRM", "descripcion": "Un mes gratis del CRM Bucefalo", "activo": True},
{"id": "bono-6", "numero": 6, "titulo": "Script de Ventas", "descripcion": "Script de ventas con 100+ complementos", "activo": True},
]
def bucefalo_precio(nivel: str) -> float:
for p in PLANES_BUCEFALO:
if p["nivel"] == nivel:
return float(p["precio"])
return 0.0
def calcular_vigencia(fecha: datetime | date) -> datetime:
if isinstance(fecha, datetime):
vigencia = fecha.replace()
else:
vigencia = datetime.combine(fecha, datetime.min.time())
dias_habiles = 0
while dias_habiles < 15:
vigencia = vigencia.replace(day=vigencia.day + 1) if vigencia.day < 28 else vigencia + __import__("datetime").timedelta(days=1)
vigencia = vigencia + __import__("datetime").timedelta(days=1)
dia = vigencia.weekday()
if dia < 5:
dias_habiles += 1
return vigencia
def calcular_financiamiento(monto: float, meses: int, tasa: float, comision: float, iva: float = IVA_RATE) -> dict:
comision_total = (monto * comision) / 100
monto_con_comision = monto + comision_total
pago_mensual = monto_con_comision * (1 + tasa) / meses
iva_mensual = pago_mensual * iva
total_mensual = pago_mensual + iva_mensual
return {
"pagoMensual": round(pago_mensual, 2),
"ivaMensual": round(iva_mensual, 2),
"totalMensual": round(total_mensual, 2),
"comisionTotal": round(comision_total, 2),
"granTotal": round(total_mensual * meses, 2),
}
def generar_numero_cotizacion(iniciales: str, secuencia: int) -> str:
now = datetime.now()
yy = str(now.year)[-2:]
mm = f"{now.month:02d}"
seq = f"{secuencia:03d}"
return f"UJ{yy}{mm}{iniciales}{seq}"
def sanitize_filename(name: str) -> str:
import re
name = re.sub(r"[^\w\s.\-]", "", name)
name = re.sub(r"\s+", " ", name)
return name.strip()
def format_currency(amount: float) -> str:
return f"${amount:,.2f}"
def format_date(d: datetime | date) -> str:
return f"{d.day:02d}/{d.month:02d}/{d.year}"
+339
View File
@@ -0,0 +1,339 @@
"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl."""
from __future__ import annotations
import io
import re
from datetime import datetime
from typing import Any
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from app.services.calculators import FASES_SHORT, bucefalo_precio
FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"}
def _hex_to_fill(hex_color: str) -> PatternFill:
h = hex_color.lstrip("#")
return PatternFill(start_color=f"FF{h.upper()}", end_color=f"FF{h.upper()}", fill_type="solid")
def _argb(hex_color: str, alpha: str = "FF") -> str:
h = hex_color.lstrip("#")
if len(h) > 6:
h = h[:6]
return f"{alpha}{h.upper()}"
def _thin_border(color: str = "FFD1D5DB") -> Border:
side = Side(style="thin", color=color)
return Border(top=side, left=side, bottom=side, right=side)
def _sanitize_sheet_name(name: str) -> str:
name = name[:31]
return re.sub(r"[/\\*?\[\]:]", "", name)
def _set_col_widths(ws, widths: list[tuple[str, float]]):
for col_letter, width in widths:
ws.column_dimensions[col_letter].width = width
def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes:
"""Generate a multi-sheet Excel workbook for a quotation.
Args:
data: dict with quotation data (same as PDF generator)
saved: if True, uses real quotation number instead of "BORRADOR"
"""
config_color = data.get("colorPrimario", "#2563eb")
PRIMARY = _argb(config_color)
PRIMARY_LIGHT = _argb(config_color, alpha="18")
SECONDARY = _argb(data.get("colorSecundario", "#1e293b"))
WHITE = "FFFFFFFF"
LIGHT_BG = "FFF8FAFC"
MUTED = "FF64748B"
BORDER_COLOR = "FFE2E8F0"
header_font = Font(bold=True, size=10, name="Calibri", color=WHITE)
label_font = Font(bold=True, size=10, name="Calibri", color=MUTED)
value_font = Font(size=10, name="Calibri")
bold_font = Font(bold=True, size=10, name="Calibri")
total_font = Font(bold=True, size=11, name="Calibri")
small_font = Font(italic=True, size=9, name="Calibri", color=MUTED)
fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY)
primary_fill = PatternFill(start_color=PRIMARY, end_color=PRIMARY, fill_type="solid")
primary_light_fill = PatternFill(start_color=PRIMARY_LIGHT, end_color=PRIMARY_LIGHT, fill_type="solid")
secondary_fill = PatternFill(start_color=SECONDARY, end_color=SECONDARY, fill_type="solid")
light_fill = PatternFill(start_color=LIGHT_BG, end_color=LIGHT_BG, fill_type="solid")
white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid")
razon_social = data.get("razonSocial", "Cotizador E3")
cliente_nombre = data.get("clienteNombre", "")
cliente_empresa = data.get("clienteEmpresa", "")
asesor_nombre = data.get("asesorNombre", "")
fecha = data.get("fecha", datetime.now())
vigencia = data.get("vigencia", datetime.now())
moneda = data.get("moneda", "MXN")
tipo_cambio = data.get("tipoCambio", "NA")
proyecto = data.get("proyecto", "MKT Digital")
esquema = data.get("esquemaPago", "Pago Unico/Mensual")
servicios = data.get("servicios", [])
plan_nivel = data.get("planBucefaloNivel")
numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR"
empresa = cliente_empresa or cliente_nombre
wb = Workbook()
wb.properties.creator = "Cotizador E3"
# ── HOJA RESUMEN ──────────────────────────────
ws = wb.active
ws.title = "HOJA RESUMEN"
ws.sheet_view.showGridLines = False
_set_col_widths(ws, [
("A", 3), ("B", 22), ("C", 22), ("D", 16),
("E", 38), ("F", 5), ("G", 20), ("H", 5),
("I", 20), ("J", 5), ("K", 18),
])
# Header banner
ws.merge_cells("B2:K2")
cell = ws["B2"]
cell.value = razon_social
cell.font = Font(bold=True, size=16, name="Calibri", color=WHITE)
cell.alignment = Alignment(vertical="center")
for col in range(1, 12):
ws.cell(row=2, column=col).fill = primary_fill
ws.merge_cells("B3:K3")
cell = ws["B3"]
cell.value = numero_label
cell.font = Font(bold=True, size=11, name="Calibri", color=WHITE)
cell.alignment = Alignment(vertical="center")
for col in range(1, 12):
ws.cell(row=3, column=col).fill = secondary_fill
# Info section
info_start = 5
info_rows = [
[("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")],
[("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)],
[("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)],
[("B", "Fecha:"), ("C", fecha), ("E", "Esquema de Pago:"), ("F", esquema)],
]
for i, row_data in enumerate(info_rows):
r = info_start + i
for col_letter, label, is_value in [
(row_data[0][0], row_data[0][1], False),
(row_data[1][0], row_data[1][1], True),
(row_data[2][0], row_data[2][1], False),
(row_data[3][0], row_data[3][1], True),
]:
cell = ws[f"{col_letter}{r}"]
cell.value = label
cell.font = value_font if is_value else label_font
# Vigencia
vigencia_row = info_start + 4
ws[f"B{vigencia_row}"].value = "Vigencia:"
ws[f"B{vigencia_row}"].font = label_font
ws[f"C{vigencia_row}"].value = vigencia
ws[f"C{vigencia_row}"].number_format = "DD/MM/YYYY"
ws[f"C{vigencia_row}"].font = value_font
# Services table
table_start = info_start + 6
cols = ["B", "C", "D", "K"]
headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"]
ws.merge_cells(f"D{table_start}:J{table_start}")
for i, col_letter in enumerate(cols):
cell = ws[f"{col_letter}{table_start}"]
cell.value = headers[i]
cell.font = header_font
cell.fill = primary_fill
cell.alignment = Alignment(
horizontal="right" if i == len(cols) - 1 else "left",
vertical="center",
)
cell.border = _thin_border(PRIMARY)
ws.row_dimensions[table_start].height = 24
servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
row = table_start + 1
current_fase = -1
for serv in servicios_unicos + servicios_mensuales:
fase = serv.get("fase", 0)
if fase != current_fase:
current_fase = fase
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}")
ws[f"B{row}"].font = fase_font
for col in range(2, 12):
ws.cell(row=row, column=col).fill = primary_light_fill
row += 1
row_bg = light_fill if row % 2 == 0 else white_fill
ws[f"B{row}"].value = ""
ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
ws[f"C{row}"].font = value_font
ws.merge_cells(f"D{row}:J{row}")
ws[f"D{row}"].value = serv.get("nombre", "")
ws[f"D{row}"].font = value_font
ws[f"K{row}"].value = serv.get("precio", 0)
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = value_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].fill = row_bg
ws[f"{col_letter}{row}"].border = _thin_border(BORDER_COLOR)
row += 1
row += 1
total_unico = sum(s.get("precio", 0) for s in servicios_unicos)
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "Total Pago Unico"
ws[f"B{row}"].font = total_font
ws[f"K{row}"].value = total_unico
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = total_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
ws[f"{col_letter}{row}"].fill = primary_light_fill
row += 1
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "(+ IVA)"
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
row += 1
total_mensual = sum(s.get("precio", 0) for s in servicios_mensuales)
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "Total Pago Mensual"
ws[f"B{row}"].font = total_font
ws[f"K{row}"].value = total_mensual
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = total_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
ws[f"{col_letter}{row}"].fill = primary_light_fill
row += 1
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "(+ IVA)"
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
row += 2
if plan_nivel:
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = f"CRM Bucefalo - {plan_nivel.capitalize()}"
ws[f"B{row}"].font = bold_font
ws[f"K{row}"].value = bucefalo_precio(plan_nivel)
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = bold_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
ws[f"{col_letter}{row}"].fill = primary_light_fill
row += 2
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
ws[f"B{row}"].font = small_font
# ── DETAIL SHEETS ──────────────────────────────
for serv in servicios:
safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
dw = wb.create_sheet(title=safe_name)
dw.sheet_view.showGridLines = False
_set_col_widths(dw, [
("A", 3), ("B", 35), ("C", 5), ("D", 15), ("E", 5),
("F", 35), ("G", 5), ("H", 15), ("I", 5), ("J", 18),
])
fase = serv.get("fase", 0)
dw.merge_cells("B1:J1")
dw["B1"].value = f"{FASES_MAP.get(fase, f'FASE {fase}')} \u2014 {serv.get('nombre', '')}"
dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE)
dw["B1"].alignment = Alignment(vertical="center")
for col in range(1, 11):
dw.cell(row=1, column=col).fill = primary_fill
dw.row_dimensions[1].height = 30
r = 3
dw[f"B{r}"].value = "Cliente:"
dw[f"B{r}"].font = label_font
dw[f"C{r}"].value = cliente_empresa or cliente_nombre
dw[f"C{r}"].font = bold_font
dw[f"F{r}"].value = "No. Cotizacion:"
dw[f"F{r}"].font = label_font
dw[f"G{r}"].value = numero_label
dw[f"G{r}"].font = bold_font
r += 2
dw[f"B{r}"].value = "Servicio:"
dw[f"B{r}"].font = label_font
dw[f"C{r}"].value = serv.get("nombre", "")
dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY)
dw[f"F{r}"].value = "Tiempo de Entrega:"
dw[f"F{r}"].font = label_font
dw[f"G{r}"].value = serv.get("tiempoEntrega", "")
dw[f"G{r}"].font = value_font
r += 2
# Entregables header
for col_letter in ["B", "I"]:
dw[f"{col_letter}{r}"].fill = primary_fill
dw[f"{col_letter}{r}"].font = header_font
dw[f"B{r}"].value = "Entregable"
r += 1
entregables = serv.get("entregables", [])
for i, ent in enumerate(entregables):
bg = light_fill if i % 2 == 1 else white_fill
dw[f"B{i + r}"].value = f"{i + 1}. {ent}"
dw[f"B{i + r}"].font = value_font
dw[f"B{i + r}"].fill = bg
dw[f"B{i + r}"].border = _thin_border(BORDER_COLOR)
r += len(entregables) + 1
# Total
for col_letter in ["B", "I"]:
dw[f"{col_letter}{r}"].fill = primary_light_fill
dw[f"{col_letter}{r}"].border = _thin_border(PRIMARY)
tipo_label = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
dw[f"B{r}"].value = f"Total Pago {tipo_label}"
dw[f"B{r}"].font = total_font
dw[f"I{r}"].value = serv.get("precio", 0)
dw[f"I{r}"].number_format = "$#,##0.00"
dw[f"I{r}"].font = total_font
dw[f"I{r}"].alignment = Alignment(horizontal="right")
r += 1
dw[f"B{r}"].value = "(+ IVA)"
dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
r += 2
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."
dw[f"B{r}"].font = small_font
r += 2
dw[f"B{r}"].value = razon_social
dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY)
output = io.BytesIO()
wb.save(output)
return output.getvalue()
+437
View File
@@ -0,0 +1,437 @@
"""PDF Generator for Cotizador E3 — port of pdf-generator.ts using ReportLab."""
from __future__ import annotations
import base64
import io
from datetime import datetime
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio
PRIMARY_DEFAULT = "#2563eb"
DARK_DEFAULT = "#1e293b"
MUTED = "#64748b"
BORDER = "#cbd5e1"
LIGHT_BG = "#f1f5f9"
WHITE = "#ffffff"
PAGE_W, PAGE_H = LETTER
MARGIN_TOP = 45
MARGIN_BOTTOM = 55
MARGIN_LEFT = 50
MARGIN_RIGHT = 50
CONTENT_W = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT
def _hex_to_rgb(hex_color: str) -> colors.Color:
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return colors.Color(r / 255, g / 255, b / 255)
def _fmt_currency(n: float) -> str:
return f"${n:,.2f}"
def _fmt_date(d: datetime) -> str:
months = [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
]
return f"{d.day} de {months[d.month - 1]} de {d.year}"
def _load_logo(logo_base64: str | None, logo_mime: str | None) -> ImageReader | None:
if not logo_base64:
return None
try:
raw = base64.b64decode(logo_base64)
return io.BytesIO(raw)
except Exception:
return None
def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
"""Generate a professional quotation PDF.
Args:
data: dict with keys matching CotizacionPDFData interface:
numero, clienteNombre, clienteEmpresa, asesorNombre, fecha, vigencia,
moneda, tipoCambio, proyecto, esquemaPago, servicios[],
planBucefaloNivel, planBucefaloPrecio, incluirBonos,
configBancaria{}, colorPrimario, colorSecundario, logoBase64, logoMime
"""
PRIMARY = data.get("colorPrimario") or PRIMARY_DEFAULT
DARK = data.get("colorSecundario") or DARK_DEFAULT
primary_rgb = _hex_to_rgb(PRIMARY)
dark_rgb = _hex_to_rgb(DARK)
muted_rgb = _hex_to_rgb(MUTED)
border_rgb = _hex_to_rgb(BORDER)
light_rgb = _hex_to_rgb(LIGHT_BG)
white_rgb = _hex_to_rgb(WHITE)
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=LETTER)
c.setTitle(f"Cotizacion {data.get('numero', '')}")
logo = _load_logo(data.get("logoBase64"), data.get("logoMime"))
servicios = data.get("servicios", [])
plan_nivel = data.get("planBucefaloNivel")
plan_precio = data.get("planBucefaloPrecio", 0)
incluir_bonos = data.get("incluirBonos", False)
cfg_bancaria = data.get("configBancaria") or {}
# ── PAGE 1: COVER ──────────────────────────────
c.setFillColor(white_rgb)
c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
y = PAGE_H - 80
if logo:
try:
c.drawImage(logo, MARGIN_LEFT, y - 20, width=65, height=65, preserveAspectRatio=True, mask="auto")
y -= 85
except Exception:
logo = None
if not logo:
c.setFont("Helvetica-Bold", 28)
c.setFillColor(primary_rgb)
c.drawString(MARGIN_LEFT, y, "UJ")
c.setFont("Helvetica", 9)
c.setFillColor(muted_rgb)
c.drawString(MARGIN_LEFT + 48, y - 15, "Uriel Jareth Consulting")
y -= 40
bar_y = y
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, bar_y, CONTENT_W, 4, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 36)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT, bar_y - 40, "Cotizacion")
c.setFont("Helvetica", 18)
c.setFillColor(primary_rgb)
c.drawString(MARGIN_LEFT, bar_y - 65, data.get("numero", ""))
iy = bar_y - 100
cL = MARGIN_LEFT
cR = MARGIN_LEFT + CONTENT_W * 0.52
info_l = [
("Cliente", data.get("clienteNombre", "")),
("Empresa", data.get("clienteEmpresa", "")),
("Proyecto", data.get("proyecto", "")),
("Asesor", data.get("asesorNombre", "")),
]
info_r = [
("Fecha", _fmt_date(data.get("fecha", datetime.now()))),
("Vigencia", _fmt_date(data.get("vigencia", datetime.now()))),
("Moneda", data.get("moneda", "MXN")),
("Esquema", data.get("esquemaPago", "")),
]
for i in range(max(len(info_l), len(info_r))):
if i < len(info_l) and info_l[i][1]:
c.setFont("Helvetica", 9)
c.setFillColor(muted_rgb)
c.drawString(cL, iy, info_l[i][0])
c.setFont("Helvetica", 11)
c.setFillColor(dark_rgb)
c.drawString(cL, iy - 14, info_l[i][1])
if i < len(info_r) and info_r[i][1]:
c.setFont("Helvetica", 9)
c.setFillColor(muted_rgb)
c.drawString(cR, iy, info_r[i][0])
c.setFont("Helvetica", 11)
c.setFillColor(dark_rgb)
c.drawString(cR, iy - 14, str(info_r[i][1]))
iy -= 32
# ── PAGE 2+: RESUMEN ──────────────────────────
c.showPage()
y = PAGE_H - MARGIN_TOP
if logo:
try:
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
except Exception:
pass
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 15)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 12, y - 15, "Hoja Resumen")
y -= 28
cliente_ref = data.get("clienteEmpresa") or data.get("clienteNombre", "")
c.setFont("Helvetica", 8)
c.setFillColor(muted_rgb)
c.drawString(MARGIN_LEFT, y, f"En atencion a: {cliente_ref}")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"No. Cotizacion: {data.get('numero', '')}")
y -= 11
c.drawString(MARGIN_LEFT, y, f"Asesor: {data.get('asesorNombre', '')}")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"Fecha: {_fmt_date(data.get('fecha', datetime.now()))} | Moneda: {data.get('moneda', 'MXN')}")
y -= 18
col_nombre = MARGIN_LEFT
col_tipo = MARGIN_LEFT + CONTENT_W - 200
col_tiempo = MARGIN_LEFT + CONTENT_W - 120
col_precio = MARGIN_LEFT + CONTENT_W - 60
# Header row
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(col_nombre + 6, y + 3, "Servicio")
c.drawString(col_tipo + 4, y + 3, "Tipo")
c.drawString(col_tiempo + 4, y + 3, "Entrega")
c.drawRightString(col_precio + 60, y + 3, "Precio")
y -= 4
c.setStrokeColor(border_rgb)
c.setLineWidth(0.3)
c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y)
y -= 14
unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
def _draw_section(srv_list, tipo_label, titulo_total, y_pos):
if not srv_list:
return y_pos
current_fase = -1
max_y = PAGE_H - MARGIN_BOTTOM - 5
for serv in srv_list:
fase = serv.get("fase", 0)
if fase != current_fase:
current_fase = fase
if y_pos - 20 < max_y - PAGE_H:
c.showPage()
y_pos = PAGE_H - MARGIN_TOP
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y_pos - 14, CONTENT_W, 14, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(primary_rgb)
c.drawString(MARGIN_LEFT + 6, y_pos - 11, FASES_SHORT.get(fase, f"FASE {fase}"))
y_pos -= 18
c.setFont("Helvetica", 8)
c.setFillColor(dark_rgb)
nombre = serv.get("nombre", "")
c.drawString(col_nombre + 6, y_pos - 10, nombre[:60])
c.setFont("Helvetica", 7)
c.setFillColor(muted_rgb)
c.drawString(col_tipo + 4, y_pos - 10, tipo_label)
c.drawString(col_tiempo + 4, y_pos - 10, serv.get("tiempoEntrega", "")[:15])
c.setFont("Helvetica-Bold", 8)
c.setFillColor(dark_rgb)
c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0)))
y_pos -= 14
entregables = serv.get("entregables", [])
if entregables:
half = (len(entregables) + 1) // 2
for idx in range(half):
e1 = entregables[idx]
e2 = entregables[idx + half] if idx + half < len(entregables) else None
c.setFont("Helvetica", 6)
c.setFillColor(muted_rgb)
c.drawString(col_nombre + 10, y_pos - 8, f"\u2022 {e1[:50]}")
if e2:
c.drawString(col_nombre + CONTENT_W * 0.48, y_pos - 8, f"\u2022 {e2[:50]}")
y_pos -= 9
c.setStrokeColor(_hex_to_rgb("#e5e7eb"))
c.setLineWidth(0.2)
c.line(col_nombre + 6, y_pos, MARGIN_LEFT + CONTENT_W, y_pos)
y_pos -= 6
total = sum(s.get("precio", 0) for s in srv_list)
y_pos -= 4
c.setFillColor(_hex_to_rgb("#f8fafc"))
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 18, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 8.5)
c.setFillColor(primary_rgb)
c.drawString(col_nombre + 6, y_pos + 3, titulo_total)
c.drawRightString(col_precio + 60, y_pos + 3, _fmt_currency(total))
y_pos -= 18
c.setFont("Helvetica", 6)
c.setFillColor(muted_rgb)
c.drawString(col_nombre + 6, y_pos, "(Precios en Moneda Nacional, no incluyen IVA)")
y_pos -= 14
return y_pos
if unicos:
y = _draw_section(unicos, "Unico", "Total Pago Unico", y)
if mensuales:
y = _draw_section(mensuales, "Mensual", "Total Pago Mensual", y)
if plan_nivel:
label = plan_nivel.capitalize()
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 14, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7.5)
c.setFillColor(dark_rgb)
c.drawString(col_nombre + 6, y + 3, f"Plan Bucefalo CRM - {label}")
c.drawRightString(col_precio + 60, y + 3, _fmt_currency(plan_precio))
y -= 20
if incluir_bonos:
y -= 6
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 10, 3, 10, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 9)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, y - 8, "Bonos (Pago en una exhibicion)")
y -= 20
bonos = [
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
"Bono 2: Workshop Estrategico de Buyer Persona",
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
"Bono 4: 1 ano de Membresia Premium",
"Bono 5: Un mes gratis de Bucefalo CRM",
"Bono 6: Script de Ventas con mas de 100 complementos",
]
for b in bonos:
c.setFont("Helvetica", 7)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 8, y - 8, f"\u2713 {b}")
y -= 12
# ── T&C PAGE ──────────────────────────────────
c.showPage()
y = PAGE_H - MARGIN_TOP
if logo:
try:
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
except Exception:
pass
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 15)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 12, y - 15, "Terminos y Condiciones")
y -= 30
def _section_title(title, yy):
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, yy - 9, 3, 9, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 9)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, yy - 8, title)
return yy - 20
def _draw_bullets(title, items, yy):
yy = _section_title(title, yy)
for t in items:
c.setFont("Helvetica", 7.5)
c.setFillColor(dark_rgb)
# Simple text wrapping
words = t.split()
line = ""
for word in words:
test = f"{line} {word}".strip()
if c.stringWidth(test, "Helvetica", 7.5) > CONTENT_W - 10:
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
yy -= 11
line = word
else:
line = test
if line:
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
yy -= 11
return yy - 8
y = _draw_bullets("Condiciones", [
"Esta cotizacion tiene una vigencia de 15 dias habiles.",
"Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.",
"El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.",
"Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.",
"Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.",
"Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.",
], y)
y = _draw_bullets("Que no incluye el proyecto?", [
"Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado.",
"Redaccion de entradas de Blog.",
"Integracion de Servicios de terceros ajenos a los cotizados.",
"Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads.",
"Creacion de Redes Sociales (En caso de requerir el servicio incluira un costo adicional).",
], y)
y = _draw_bullets("Notas", [
"El presente proyecto debera tener un responsable oficial.",
"La Hora Centinela tiene un precio de $700.00 MXN.",
"Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia. Si requiere los archivos editables, estos pueden ser adquiridos abonando una tarifa de liberacion (buy-out fee).",
"Si el proyecto se pausa por razones ajenas a Uriel Jareth Consulting, esto generara costo extra del 15% al 30% para retomar el proyecto.",
], y)
y -= 8
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 9, 3, 9, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 9)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, y - 8, "Datos Bancarios")
y -= 20
razon_social = cfg_bancaria.get("razon_social", "URIEL JARETH ALVARADO ORTIZ")
rfc = cfg_bancaria.get("rfc", "AAOU970201SU7")
clabe_nac = cfg_bancaria.get("clabe_interbancaria", cfg_bancaria.get("cuenta_nacional", ""))
cuenta_nac = cfg_bancaria.get("cuenta_nacional", "")
banco_nac = "BBVA" if cuenta_nac else ""
box_h = 48
c.setFillColor(light_rgb)
c.setStrokeColor(border_rgb)
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Nacional")
c.setFont("Helvetica", 6.5)
if cuenta_nac:
c.drawString(MARGIN_LEFT + 8, y - 24, f"Cuenta: {cuenta_nac}")
if clabe_nac:
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 24, f"CLABE: {clabe_nac}")
c.drawString(MARGIN_LEFT + 8, y - 35, f"Razon Social: {razon_social}")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, f"RFC: {rfc}")
if banco_nac:
c.drawString(MARGIN_LEFT + 8, y - 46, f"Banco: {banco_nac}")
y -= box_h + 8
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Internacional")
c.setFont("Helvetica", 6.5)
c.drawString(MARGIN_LEFT + 8, y - 24, f"Beneficiario: {razon_social}")
c.drawString(MARGIN_LEFT + 8, y - 35, "Banco: BBVA Mexico")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, "SWIFT: BCMRMXMMPYM")
# ── FOOTER ON ALL PAGES ──────────────────────
page_count = c.getPageNumber()
for i in range(1, page_count + 1):
c.setPageSize(LETTER)
c.setFont("Helvetica", 6)
c.setFillColor(muted_rgb)
c.setStrokeColor(border_rgb)
c.setLineWidth(0.5)
c.line(MARGIN_LEFT, MARGIN_BOTTOM - 10, MARGIN_LEFT + CONTENT_W, MARGIN_BOTTOM - 10)
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 22, "Uriel Jareth Consulting")
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 32, "urieljareth.com | [email protected] | (445) 182 9943")
c.save()
return buf.getvalue()
+142
View File
@@ -0,0 +1,142 @@
"""Cotizador E3 — FastAPI Application
REST API + MCP Server for digital marketing quotation management.
Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT).
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.config import settings
from app.database import close_db, init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
await close_db()
app = FastAPI(
title="Cotizador E3 API",
description=(
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. "
"Optimizada para integración con n8n y agentes de IA (OpenClaw, Claude, ChatGPT) "
"via MCP (Model Context Protocol)."
),
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
openapi_tags=[
{"name": "Health", "description": "Health check y información de la API"},
{"name": "Auth", "description": "Autenticación (API Key + JWT)"},
{"name": "Clientes", "description": "Gestión de clientes"},
{"name": "Catálogo", "description": "Catálogo de servicios de marketing digital"},
{"name": "Categorías", "description": "Categorías de servicios"},
{"name": "Cotizaciones", "description": "Cotizaciones — CRUD completo con servicios y plan CRM"},
{"name": "Paquetes", "description": "Paquetes de servicios con fases"},
{"name": "Configuración", "description": "Configuración de la empresa (key-value)"},
{"name": "Bonos", "description": "Bonos disponibles para cotizaciones"},
{"name": "Financiamiento", "description": "Planes y cálculo de financiamiento"},
{"name": "Export", "description": "Exportación de PDF, Excel y CSV"},
{"name": "Import", "description": "Importación masiva de servicios"},
],
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"error": "Error interno del servidor", "detail": str(exc)},
)
# Register routers
from app.routers import (
auth,
bonos,
catalogo,
categorias,
clientes,
configuracion,
cotizaciones,
export_,
financiamiento,
health,
import_,
paquetes,
)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(clientes.router)
app.include_router(catalogo.router)
app.include_router(categorias.router)
app.include_router(cotizaciones.router)
app.include_router(paquetes.router)
app.include_router(configuracion.router)
app.include_router(bonos.router)
app.include_router(financiamiento.router)
app.include_router(export_.router)
app.include_router(import_.router)
# Mount MCP server
try:
from app.mcp.server import server as mcp_server
from mcp.server.streamable_http import StreamableHTTPServerTransport
@app.post("/mcp")
async def mcp_endpoint(request: Request):
"""MCP (Model Context Protocol) endpoint for AI agents."""
transport = StreamableHTTPServerTransport(mcp_server)
return await transport.handle_request(request)
@app.get("/mcp")
async def mcp_info():
"""MCP server info. Use POST for actual MCP communication."""
return {
"name": "cotizador-e3",
"version": "1.0.0",
"protocol": "mcp",
"description": "MCP server for Cotizador E3 quotation system",
}
except ImportError:
# MCP SDK not installed, skip MCP mount
pass
@app.get("/", include_in_schema=False)
async def root():
return {
"name": "Cotizador E3 API",
"version": "1.0.0",
"docs": "/docs",
"openapi": "/openapi.json",
"health": "/health",
"mcp": "/mcp",
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host=settings.API_HOST, port=settings.API_PORT, reload=True)
+12
View File
@@ -0,0 +1,12 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
asyncpg>=0.30.0
pydantic>=2.0
pydantic-settings>=2.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.18
reportlab>=4.0
openpyxl>=3.1.0
mcp>=1.0.0
httpx>=0.27.0