diff --git a/api/app/mcp/server.py b/api/app/mcp/server.py index 2345c58..4ac3730 100644 --- a/api/app/mcp/server.py +++ b/api/app/mcp/server.py @@ -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"], ) diff --git a/api/main.py b/api/main.py index 24086eb..22652bc 100644 --- a/api/main.py +++ b/api/main.py @@ -9,10 +9,11 @@ from __future__ import annotations from contextlib import asynccontextmanager from datetime import datetime, timezone -from fastapi import FastAPI, Request +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 @@ -105,8 +106,14 @@ try: from mcp.server.streamable_http import StreamableHTTPServerTransport @app.post("/mcp") - async def mcp_endpoint(request: Request): - """MCP (Model Context Protocol) endpoint for AI agents.""" + 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) diff --git a/package.json b/package.json index ef218c0..e738256 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/prisma/migrations/20260728000000_add_observaciones_internas/migration.sql b/prisma/migrations/20260728000000_add_observaciones_internas/migration.sql new file mode 100644 index 0000000..b827e15 --- /dev/null +++ b/prisma/migrations/20260728000000_add_observaciones_internas/migration.sql @@ -0,0 +1,20 @@ +-- Fase 0-C, Despliegue A: separar el texto que ve el cliente del contexto interno. +-- +-- ESTA MIGRACION ES PURAMENTE ADITIVA. No modifica ni una sola fila existente. +-- El movimiento de datos (observaciones -> observacionesInternas) seria un +-- Despliegue B posterior y NO se incluye aqui, por dos razones: +-- +-- 1. Rollback seguro. No hay migraciones `down` en este repo ni servicio de +-- backup en docker-compose.coolify.yml. Si un UPDATE vaciara "observaciones" +-- y el despliegue se revirtiera, el codigo anterior leeria NULL en todas las +-- filas y el asesor veria sus notas desaparecidas: sin error y sin log. +-- +-- 2. El dato real no lo necesita. Al momento de escribir esto la unica fila de +-- produccion con "observaciones" no vacias (UJ2606AG777, aprobada) contiene +-- condiciones de pago dirigidas al cliente en segunda persona. Moverlas a +-- "internas" ocultaria terminos ya acordados en un documento emitido. +-- Esa fila ya esta clasificada correctamente donde esta. +-- +-- IF NOT EXISTS hace la sentencia idempotente si alguien la aplica a mano. + +ALTER TABLE "Cotizacion" ADD COLUMN IF NOT EXISTS "observacionesInternas" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2539dd7..17b4e60 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,7 +49,13 @@ model Cotizacion { incluirIva Boolean @default(true) esDoble Boolean @default(false) opcionesMetadata Json? + // Texto que SI ve el cliente: se imprime en el PDF y en el Excel. observaciones String? + // Contexto de discovery, notas del asesor, riesgos. NO sale de la app: ningun + // exportador declara este campo en su interfaz de datos (CotizacionPDFData / + // ExcelData) y la herramienta MCP obtener_cotizacion usa lista blanca de + // columnas. La garantia es estructural, no depende de recordar filtrarlo. + observacionesInternas String? clienteId String asesorId String createdAt DateTime @default(now()) diff --git a/src/app/(app)/cotizaciones/[id]/editar/page.tsx b/src/app/(app)/cotizaciones/[id]/editar/page.tsx index c3e7231..629dc6b 100644 --- a/src/app/(app)/cotizaciones/[id]/editar/page.tsx +++ b/src/app/(app)/cotizaciones/[id]/editar/page.tsx @@ -45,6 +45,7 @@ export default async function EditarCotizacionPage({ incluirBonos: cot.incluirBonos, incluirFinanciamiento: cot.incluirFinanciamiento, observaciones: cot.observaciones || "", + observacionesInternas: cot.observacionesInternas || "", planBucefaloNivel: cot.planBucefalo?.nivel || null, esDoble: cot.esDoble, opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {}, diff --git a/src/app/(app)/cotizaciones/[id]/page.tsx b/src/app/(app)/cotizaciones/[id]/page.tsx index 6c8e05f..4fa3a77 100644 --- a/src/app/(app)/cotizaciones/[id]/page.tsx +++ b/src/app/(app)/cotizaciones/[id]/page.tsx @@ -226,10 +226,29 @@ export default async function CotizacionDetailPage({ {cot.observaciones && (
-

Observaciones

+

+ Observaciones + + Las ve el cliente + +

{cot.observaciones}

)} + + {/* Unico lugar donde se muestran las notas internas. No van a ningun + exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */} + {cot.observacionesInternas && ( +
+

+ Notas internas + + No sale de la app + +

+

{cot.observacionesInternas}

+
+ )} ); } diff --git a/src/app/api/cotizaciones/[id]/route.ts b/src/app/api/cotizaciones/[id]/route.ts index 4ba8657..4f474d7 100644 --- a/src/app/api/cotizaciones/[id]/route.ts +++ b/src/app/api/cotizaciones/[id]/route.ts @@ -100,6 +100,7 @@ export async function PUT( esDoble, opciones, observaciones, + observacionesInternas, cliente, servicios, planBucefalo, @@ -158,6 +159,7 @@ export async function PUT( ...(esDoble !== undefined && { esDoble }), ...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }), ...(observaciones !== undefined && { observaciones }), + ...(observacionesInternas !== undefined && { observacionesInternas }), ...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }), }, }); diff --git a/src/app/api/cotizaciones/route.ts b/src/app/api/cotizaciones/route.ts index d1bfa33..0d44282 100644 --- a/src/app/api/cotizaciones/route.ts +++ b/src/app/api/cotizaciones/route.ts @@ -26,6 +26,7 @@ export async function POST(request: NextRequest) { esDoble, opciones, observaciones, + observacionesInternas, cliente, asesorId, servicios, @@ -81,6 +82,7 @@ export async function POST(request: NextRequest) { esDoble: esDoble ?? false, opcionesMetadata: esDoble ? opciones ?? {} : undefined, observaciones: observaciones || null, + observacionesInternas: observacionesInternas || null, clienteId: clienteIdFinal, asesorId, estado: "borrador", diff --git a/src/app/api/export/excel/[id]/route.ts b/src/app/api/export/excel/[id]/route.ts index 439358b..8882064 100644 --- a/src/app/api/export/excel/[id]/route.ts +++ b/src/app/api/export/excel/[id]/route.ts @@ -17,7 +17,12 @@ export async function GET( include: { cliente: true, asesor: true, - servicios: { include: { servicioCatalogo: true } }, + // Mismo orderBy que el PDF: los dos documentos van en el mismo correo y + // deben listar las partidas en el mismo orden. + servicios: { + include: { servicioCatalogo: true }, + orderBy: [{ fase: "asc" }, { createdAt: "asc" }], + }, planBucefalo: true, }, }), @@ -67,6 +72,9 @@ export async function GET( planBucefaloPrecio: cot.planBucefalo?.precio ?? null, colorPrimario: branding.colorPrimario || "#2563eb", colorSecundario: branding.colorSecundario || "#1e293b", + incluirIva: cot.incluirIva, + // El Excel es un documento del cliente: solo el texto del cliente. + observaciones: cot.observaciones, }; const buffer = await buildCotizacionExcel(data); diff --git a/src/app/api/export/excel/route.ts b/src/app/api/export/excel/route.ts index b7f0139..2f552fd 100644 --- a/src/app/api/export/excel/route.ts +++ b/src/app/api/export/excel/route.ts @@ -80,6 +80,7 @@ export async function POST(request: NextRequest) { planBucefaloNivel: draft.planBucefaloNivel, colorPrimario: branding.colorPrimario || "#2563eb", colorSecundario: branding.colorSecundario || "#1e293b", + observaciones: draft.observaciones, }; const buffer = await buildCotizacionExcel(data); diff --git a/src/app/api/export/pdf/[id]/route.ts b/src/app/api/export/pdf/[id]/route.ts index e0f67db..016bd41 100644 --- a/src/app/api/export/pdf/[id]/route.ts +++ b/src/app/api/export/pdf/[id]/route.ts @@ -10,18 +10,25 @@ export async function GET( ) { try { const { id } = await params; - const [cot, config, branding] = await Promise.all([ + const [cot, config, branding, bonos] = await Promise.all([ prisma.cotizacion.findUnique({ where: { id }, include: { cliente: true, asesor: true, - servicios: { include: { servicioCatalogo: true } }, + // orderBy explicito: drawSection abre un encabezado de fase nuevo cada vez + // que cambia serv.fase, o sea que asume el array agrupado. Sin esto el orden + // lo decide Postgres y el PDF puede salir distinto del Excel del mismo envio. + servicios: { + include: { servicioCatalogo: true }, + orderBy: [{ fase: "asc" }, { createdAt: "asc" }], + }, planBucefalo: true, }, }), getConfigBancaria(), getConfigBranding(), + prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }), ]); if (!cot) { @@ -61,6 +68,10 @@ export async function GET( planBucefaloNivel: cot.planBucefalo?.nivel || null, planBucefaloPrecio: cot.planBucefalo?.precio || 0, incluirBonos: cot.incluirBonos, + bonos, + incluirIva: cot.incluirIva, + // Solo el texto del cliente. observacionesInternas no se pasa nunca. + observaciones: cot.observaciones, configBancaria: config, ...branding, }); diff --git a/src/app/api/export/pdf/route.ts b/src/app/api/export/pdf/route.ts index 9bfb4f0..04585b1 100644 --- a/src/app/api/export/pdf/route.ts +++ b/src/app/api/export/pdf/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; import { generateCotizacionPDF } from "@/lib/pdf-generator"; import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators"; -import { getConfigBranding } from "@/lib/config-helpers"; +import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers"; export async function POST(request: NextRequest) { try { @@ -42,7 +43,14 @@ export async function POST(request: NextRequest) { const fechaCot = new Date(draft.fecha); const vigencia = calcularVigencia(fechaCot); - const branding = await getConfigBranding(); + // El borrador leia solo el branding, asi que imprimia los datos bancarios + // hardcodeados del generador en vez de los configurados. El Excel de borrador + // si leia ambos: esto empareja los dos. + const [branding, configBancaria, bonos] = await Promise.all([ + getConfigBranding(), + getConfigBancaria(), + prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }), + ]); const empresa = draft.clienteEmpresa || draft.clienteNombre; const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`; @@ -63,6 +71,10 @@ export async function POST(request: NextRequest) { planBucefaloNivel: draft.planBucefaloNivel, planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0, incluirBonos: draft.incluirBonos, + bonos, + // El cliente ya mandaba observaciones en el body; el generador nunca las recibia. + observaciones: draft.observaciones, + configBancaria, ...branding, }); diff --git a/src/components/CotizacionForm.tsx b/src/components/CotizacionForm.tsx index 1d61d9d..d36c787 100644 --- a/src/components/CotizacionForm.tsx +++ b/src/components/CotizacionForm.tsx @@ -17,6 +17,8 @@ import { Clock, Plus, Trash2, + Eye, + Lock, } from "lucide-react"; import clsx from "clsx"; import { @@ -82,6 +84,7 @@ export interface ExistingData { incluirBonos: boolean; incluirFinanciamiento: boolean; observaciones: string; + observacionesInternas?: string; planBucefaloNivel: string | null; estado: string; servicios: ServicioSeleccionado[]; @@ -165,6 +168,7 @@ export function CotizacionForm({ store.setField("incluirBonos", existingData.incluirBonos); store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento); store.setField("observaciones", existingData.observaciones); + store.setField("observacionesInternas", existingData.observacionesInternas ?? ""); store.setField("planBucefaloNivel", existingData.planBucefaloNivel); store.setField("esDoble", existingData.esDoble ?? false); store.setField("opciones", existingData.opciones ?? {}); @@ -333,6 +337,7 @@ export function CotizacionForm({ esDoble: store.draft.esDoble, opciones: store.draft.esDoble ? store.draft.opciones : undefined, observaciones: store.draft.observaciones, + observacionesInternas: store.draft.observacionesInternas, cliente: { nombre: store.draft.clienteNombre, empresa: store.draft.clienteEmpresa, @@ -1217,19 +1222,51 @@ export function CotizacionForm({ -
- -