Files
cotizador/api/main.py
T
urieljarethandClaude Opus 5 9023384de9 Fase 0: cerrar el MCP, unificar totales y separar el contexto interno del cliente
Primera fase del plugin de propuesta consultiva (docs/superpowers/specs/
2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md). Recupera el contexto
humano que hoy se captura y se descarta, y cierra los bloqueadores que el
analisis previo destapo.

Seguridad (lo mas urgente):
- POST /mcp no tenia NINGUNA autenticacion: cero Depends() en main.py, mientras
  el servicio recibe dominio publico en produccion (SERVICE_FQDN_API_8000).
  Cualquiera en internet podia leer y escribir cotizaciones. Ahora exige
  require_auth, que ya existia en app/auth.py y no se estaba usando ahi.
- _obtener_cotizacion hacia SELECT c.* y devolvia dict(cot) al agente, asi que
  cualquier columna nueva se publicaba sola. Ahora usa lista blanca espejo de
  CotizacionResponse, excluyendo observacionesInternas.

Totales:
- Nueva calcularTotalesCotizacion() en calculators.ts como fuente de verdad
  unica. El calculo estaba duplicado a mano en siete consumidores.
- conIva() sustituye a los `* 1.16` hardcodeados de pdf-generator y
  excel-builder, que ignoraban IVA_RATE y el flag incluirIva.

Contexto humano:
- Campo nuevo Cotizacion.observacionesInternas. La migracion es PURAMENTE
  ADITIVA: no mueve ni una fila. El movimiento de datos no hace falta porque la
  unica fila de produccion con observaciones ya contiene texto dirigido al
  cliente, y separarlo en dos despliegues mantiene el rollback limpio.
- Dos textareas visualmente inconfundibles en el formulario.
- observaciones se imprime por primera vez en el PDF y el Excel; el dato ya
  viajaba hasta las rutas de borrador y se tiraba.
- observacionesInternas solo se ve en la app. La garantia es estructural: el
  campo no existe en CotizacionPDFData ni en ExcelData, asi que el generador no
  puede filtrarlo aunque alguien lo intente.

Bugs vecinos:
- orderBy explicito en las cuatro rutas de export: el PDF asume las partidas
  agrupadas por fase y sin orderBy podia diferir del Excel del mismo envio.
- El PDF de borrador leia solo el branding, no los datos bancarios, e imprimia
  los hardcodeados del generador.
- detalleModelo local en PDF y Excel omitia la rama "demanda": esa partida
  salia en $0 y sin explicacion. Ahora delegan en la version canonica.
- Los bonos salen de la tabla Bono; la lista hardcodeada queda de respaldo y su
  texto ya no coincidia con el seed.
- La palomita de los bonos mide 0pt en las fuentes base de PDFKit (verificado),
  o sea que salia como dos espacios. Sustituida por una vineta.

Ademas: zod pasa a ser dependencia declarada. Se importaba en schemas.ts y
resolvia transitivamente, asi que un npm ci --omit=dev reventaba.

Verificado con build, lint y 22 comprobaciones funcionales sobre PDF y Excel
reales (texto del cliente presente, texto interno ausente incluso inyectandolo
a la fuerza, texto largo multipagina, y totales con y sin IVA).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:50:03 -06:00

150 lines
4.6 KiB
Python

"""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 Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.auth import require_auth
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, _auth: dict = Depends(require_auth)):
"""MCP (Model Context Protocol) endpoint for AI agents.
Requiere autenticacion (X-API-Key o Bearer JWT), igual que el resto del API.
Sin esto el endpoint quedaba abierto a internet: el servicio recibe dominio
publico en produccion (docker-compose.coolify.yml, SERVICE_FQDN_API_8000) y
las herramientas MCP leen y escriben cotizaciones directamente.
"""
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)