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]>
This commit is contained in:
urieljareth
2026-07-28 14:50:03 -06:00
co-authored by Claude Opus 5
parent 0ff783fd65
commit 9023384de9
19 changed files with 322 additions and 54 deletions
+13 -2
View File
@@ -246,8 +246,18 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
async def _obtener_cotizacion(conn, args: dict) -> dict:
# Lista blanca de columnas: esta herramienta devuelve la fila completa al agente
# (result = dict(cot) mas abajo), asi que un SELECT c.* publicaria cualquier
# columna nueva sin que nadie lo decida. Espeja CotizacionResponse del REST
# (app/models/cotizacion.py) y excluye deliberadamente "observacionesInternas",
# que es texto que no debe salir de la app.
cot = await conn.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.moneda, c."tipoCambio",
c.proyecto, c."esquemaPago", c.estado, c."incluirBonos",
c."incluirFinanciamiento", c."incluirIva", c."esDoble",
c."opcionesMetadata", c.observaciones, c."clienteId", c."asesorId",
c."createdAt", c."updatedAt",
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
@@ -266,7 +276,8 @@ async def _obtener_cotizacion(conn, args: dict) -> dict:
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
"""SELECT id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt"
FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1""",
args["cotizacion_id"],
)