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]>
99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { calcularVigencia, sanitizeFilename } from "@/lib/calculators";
|
|
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
|
|
import { buildCotizacionExcel, type ExcelData } from "@/lib/excel-builder";
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json();
|
|
const { draft } = body as {
|
|
draft: {
|
|
clienteNombre: string;
|
|
clienteEmpresa: string;
|
|
asesorNombre: string;
|
|
fecha: string;
|
|
moneda: string;
|
|
tipoCambio: string;
|
|
proyecto: string;
|
|
esquemaPago: string;
|
|
incluirBonos: boolean;
|
|
servicios: {
|
|
nombre: string;
|
|
fase: number;
|
|
tipoPago: string;
|
|
precio: number;
|
|
tiempoEntrega: string;
|
|
entregables: string[];
|
|
esPersonalizado?: boolean;
|
|
horas?: number;
|
|
tarifaHora?: number;
|
|
modeloCobro?: string;
|
|
montoMinimo?: number;
|
|
horasIncluidas?: number;
|
|
opcion?: string;
|
|
}[];
|
|
planBucefaloNivel: string | null;
|
|
observaciones: string;
|
|
esDoble?: boolean;
|
|
opciones?: { "1"?: { titulo?: string; descripcion?: string; noIncluye?: string }; "2"?: { titulo?: string; descripcion?: string; noIncluye?: string } };
|
|
};
|
|
};
|
|
|
|
const fechaCot = new Date(draft.fecha);
|
|
const empresa = draft.clienteEmpresa || draft.clienteNombre;
|
|
|
|
const [branding, bancaria] = await Promise.all([
|
|
getConfigBranding(),
|
|
getConfigBancaria(),
|
|
]);
|
|
|
|
const data: ExcelData = {
|
|
razonSocial: bancaria.razon_social || "Cotizador E3",
|
|
domicilioFiscal: bancaria.domicilio_fiscal || undefined,
|
|
numero: "BORRADOR",
|
|
clienteNombre: draft.clienteNombre,
|
|
clienteEmpresa: draft.clienteEmpresa,
|
|
asesorNombre: draft.asesorNombre,
|
|
fecha: fechaCot,
|
|
vigencia: calcularVigencia(fechaCot),
|
|
moneda: draft.moneda,
|
|
tipoCambio: draft.tipoCambio,
|
|
proyecto: draft.proyecto,
|
|
esquemaPago: draft.esquemaPago,
|
|
servicios: draft.servicios.map((s) => ({
|
|
nombre: s.nombre,
|
|
fase: s.fase,
|
|
tipoPago: s.tipoPago,
|
|
precio: s.precio,
|
|
tiempoEntrega: s.tiempoEntrega,
|
|
entregables: s.entregables ?? [],
|
|
modeloCobro: s.modeloCobro,
|
|
esPersonalizado: s.esPersonalizado,
|
|
horas: s.horas,
|
|
tarifaHora: s.tarifaHora,
|
|
montoMinimo: s.montoMinimo,
|
|
horasIncluidas: s.horasIncluidas,
|
|
opcion: s.opcion,
|
|
})),
|
|
esDoble: draft.esDoble,
|
|
opcionesMetadata: draft.esDoble ? draft.opciones ?? null : null,
|
|
planBucefaloNivel: draft.planBucefaloNivel,
|
|
colorPrimario: branding.colorPrimario || "#2563eb",
|
|
colorSecundario: branding.colorSecundario || "#1e293b",
|
|
observaciones: draft.observaciones,
|
|
};
|
|
|
|
const buffer = await buildCotizacionExcel(data);
|
|
return new NextResponse(new Uint8Array(buffer), {
|
|
headers: {
|
|
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR.xlsx"`,
|
|
},
|
|
});
|
|
} catch (error: unknown) {
|
|
console.error("Error exporting Excel:", error);
|
|
const message = error instanceof Error ? error.message : "Error interno";
|
|
return NextResponse.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|