Files
cotizador/src/app/api/export/excel/route.ts
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

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 });
}
}