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"],
)
+10 -3
View File
@@ -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)
+1
View File
@@ -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": {
@@ -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;
+6
View File
@@ -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())
@@ -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) ?? {},
+20 -1
View File
@@ -226,10 +226,29 @@ export default async function CotizacionDetailPage({
{cot.observaciones && (
<div className="bg-card-bg rounded-xl border border-border p-5">
<h3 className="font-semibold mb-2">Observaciones</h3>
<h3 className="font-semibold mb-2 flex items-center gap-2">
Observaciones
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
Las ve el cliente
</span>
</h3>
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
</div>
)}
{/* Unico lugar donde se muestran las notas internas. No van a ningun
exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */}
{cot.observacionesInternas && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-5">
<h3 className="font-semibold mb-2 flex items-center gap-2 text-amber-900">
Notas internas
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
No sale de la app
</span>
</h3>
<p className="text-sm text-amber-900/80 whitespace-pre-wrap">{cot.observacionesInternas}</p>
</div>
)}
</div>
);
}
+2
View File
@@ -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 }),
},
});
+2
View File
@@ -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",
+9 -1
View File
@@ -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);
+1
View File
@@ -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);
+13 -2
View File
@@ -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,
});
+14 -2
View File
@@ -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,
});
+50 -13
View File
@@ -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({
</div>
</div>
<div className="bg-card-bg rounded-xl border border-border p-5">
<label className="block text-sm font-medium text-muted mb-1">
Observaciones
</label>
<textarea
value={store.draft.observaciones}
onChange={(e) =>
store.setField("observaciones", e.target.value)
}
rows={3}
placeholder="Notas adicionales para la cotizacion..."
className={INPUT_CLS}
/>
{/* Dos campos deliberadamente distintos. El de arriba se imprime en el PDF y
el Excel que recibe el cliente; el de abajo no sale de la aplicacion.
La diferencia visual es la barrera contra escribir en el equivocado. */}
<div className="bg-card-bg rounded-xl border border-border p-5 space-y-5">
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-1">
<Eye className="w-4 h-4 text-primary" />
Observaciones
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
Las ve el cliente
</span>
</label>
<p className="text-xs text-muted mb-2">
Se imprime en el PDF y en el Excel que le envias. Condiciones, supuestos y
aclaraciones del acuerdo.
</p>
<textarea
value={store.draft.observaciones}
onChange={(e) => store.setField("observaciones", e.target.value)}
rows={3}
placeholder="Ej: El anticipo es del 50%. El avance queda condicionado a la entrega de accesos."
className={INPUT_CLS}
/>
</div>
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
<label className="flex items-center gap-2 text-sm font-medium mb-1 text-amber-900">
<Lock className="w-4 h-4" />
Notas internas
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
No sale de la app
</span>
</label>
<p className="text-xs text-amber-800 mb-2">
Contexto de la reunion, con quien hablar, riesgos, recordatorios. No se imprime
en ningun documento ni se expone por el API.
</p>
<textarea
value={store.draft.observacionesInternas}
onChange={(e) => store.setField("observacionesInternas", e.target.value)}
rows={3}
placeholder="Ej: El que decide es el socio, no el que vino a la reunion. Pidio descuento antes de ver el alcance."
className={INPUT_CLS}
/>
</div>
</div>
</div>
+63
View File
@@ -112,6 +112,69 @@ export function calcularTotalesOpcion(
};
}
// ----- Totales de una cotizacion: FUENTE DE VERDAD UNICA -----
// Antes este calculo estaba duplicado a mano con .filter().reduce() en siete
// consumidores (PDF, Excel, detalle, PreciosEditables, formulario, lista y dashboard),
// con criterios que no coincidian, y el IVA estaba hardcodeado como * 1.16 en los dos
// exportadores ignorando IVA_RATE y el flag incluirIva. Toda comparacion de totales
// (y cualquier documento que deba coincidir con el PDF) debe pasar por aqui.
//
// Nota: los subtotales son SIN IVA, que es como los exportadores presentan hoy los
// totales de una cotizacion simple ("los precios no incluyen IVA" en la nota al pie).
export interface TotalesCotizacion {
subtotalUnico: number;
subtotalMensual: number;
ivaUnico: number;
ivaMensual: number;
totalUnico: number;
totalMensual: number;
/** Desembolso real a 12 meses: unico + mensual x 12, con IVA si aplica.
* Es la base del ratio precio/valor de la propuesta consultiva. */
totalPrimerAnio: number;
incluyeIva: boolean;
}
export function calcularTotalesCotizacion(
servicios: Array<{ tipoPago: string; precio: number; seleccionado?: boolean }>,
opciones?: { incluirIva?: boolean }
): TotalesCotizacion {
const incluyeIva = opciones?.incluirIva !== false;
const activos = servicios.filter((s) => s.seleccionado !== false);
const suma = (tipo: string) =>
r2(activos.filter((s) => s.tipoPago === tipo).reduce((a, s) => a + (s.precio || 0), 0));
const subtotalUnico = suma("unico");
const subtotalMensual = suma("mensual");
const ivaUnico = incluyeIva ? r2(subtotalUnico * IVA_RATE) : 0;
const ivaMensual = incluyeIva ? r2(subtotalMensual * IVA_RATE) : 0;
const totalUnico = r2(subtotalUnico + ivaUnico);
const totalMensual = r2(subtotalMensual + ivaMensual);
return {
subtotalUnico,
subtotalMensual,
ivaUnico,
ivaMensual,
totalUnico,
totalMensual,
totalPrimerAnio: r2(totalUnico + totalMensual * 12),
incluyeIva,
};
}
function r2(n: number): number {
return Math.round(n * 100) / 100;
}
/** Aplica IVA a un monto respetando el flag de la cotizacion.
* Sustituye a los `* 1.16` hardcodeados que habia en pdf-generator y excel-builder. */
export function conIva(monto: number, incluirIva: boolean = true): number {
return r2((monto || 0) * (incluirIva ? 1 + IVA_RATE : 1));
}
export const FASES: Record<number, string> = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
+29 -11
View File
@@ -1,5 +1,5 @@
import ExcelJS from "exceljs";
import { bucefaloPrecio, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
import { bucefaloPrecio, conIva, detalleModelo as detalleModeloCanonico, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
// ─────────────────────────────────────────────────────────────────────────────
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
@@ -44,6 +44,11 @@ export interface ExcelData {
planBucefaloPrecio?: number | null;
colorPrimario: string;
colorSecundario: string;
incluirIva?: boolean;
/** Texto que SI ve el cliente. El Excel es un documento del cliente (lleva razon
* social, "En atencion a:", domicilio fiscal y nota legal), no una herramienta
* interna: por eso `observacionesInternas` NO se declara aqui. */
observaciones?: string | null;
}
// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex):
@@ -54,15 +59,10 @@ function argb(hex: string, alpha = "FF"): string {
return alpha + h.toUpperCase();
}
// Texto de desglose por modelo de cobro (horas / retainer) para mostrar junto al servicio.
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
// omitia la rama "demanda", igual que la del PDF.
function detalleModelo(serv: ExcelServicio): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
return detalleModeloCanonico(serv);
}
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
@@ -374,8 +374,10 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
row++;
};
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
compRow("Total unico (c/IVA)", formatCurrency(t1.totalUnico * 1.16), formatCurrency(t2.totalUnico * 1.16), valueFont);
compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont);
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
compRow(`Total unico${ivaLbl}`, formatCurrency(conIva(t1.totalUnico, data.incluirIva)), formatCurrency(conIva(t2.totalUnico, data.incluirIva)), valueFont);
compRow(`Total mensual${ivaLbl}`, formatCurrency(conIva(t1.totalMensual, data.incluirIva)), formatCurrency(conIva(t2.totalMensual, data.incluirIva)), valueFont);
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
row++;
} else {
@@ -402,6 +404,22 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
ws.getCell(`B${row}`).font = smallFont;
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
// Observaciones del cliente. Solo este campo: el Excel lleva razon social,
// "En atencion a:" y domicilio fiscal, o sea que es un documento que el cliente
// recibe. observacionesInternas no existe en ExcelData a proposito.
if (data.observaciones && data.observaciones.trim()) {
row += 2;
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = "OBSERVACIONES";
ws.getCell(`B${row}`).font = { ...boldFont, color: { argb: PRIMARY } };
row++;
ws.mergeCells(`B${row}:K${row}`);
const texto = data.observaciones.trim();
ws.getCell(`B${row}`).value = texto;
ws.getCell(`B${row}`).font = smallFont;
setWrapped(ws, row, "B", texto, mergedWidth(ws, "B", "K"), { fontSize: 9 });
}
// ═══════════════════════════════════════════════════════════════════════════
// HOJAS DETALLADAS POR SERVICIO
// ═══════════════════════════════════════════════════════════════════════════
+64 -19
View File
@@ -1,5 +1,5 @@
import PDFDocument from "pdfkit";
import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
import { FASES_SHORT as FASES, conIva, detalleModelo, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
interface ServicioPDF {
nombre: string;
@@ -18,14 +18,11 @@ interface ServicioPDF {
}
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
// omitia la rama "demanda", asi que esa partida se imprimia sin desglose y con
// precio $0, sin explicar que se factura segun consumo.
function detalleModeloPDF(serv: ServicioPDF): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
return detalleModelo(serv);
}
interface CotizacionPDFData {
@@ -45,6 +42,13 @@ interface CotizacionPDFData {
planBucefaloNivel: string | null;
planBucefaloPrecio: number;
incluirBonos: boolean;
/** Bonos desde la tabla Bono. Si no se pasan, se usa la lista de respaldo. */
bonos?: { numero: number; descripcion: string }[];
incluirIva?: boolean;
/** Texto que SI ve el cliente. `observacionesInternas` NO se declara aqui a
* proposito: si el generador no puede verlo, no puede filtrarlo. La garantia
* es estructural, no depende de la disciplina de quien dibuje. */
observaciones?: string | null;
configBancaria?: Record<string, string>;
colorPrimario?: string;
colorSecundario?: string;
@@ -324,9 +328,11 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
y += 18;
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
const filas: [string, string, string][] = [
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
[`Total unico${ivaLbl}`, fmt(conIva(t1.totalUnico, data.incluirIva)), fmt(conIva(t2.totalUnico, data.incluirIva))],
[`Total mensual${ivaLbl}`, fmt(conIva(t1.totalMensual, data.incluirIva)), fmt(conIva(t2.totalMensual, data.incluirIva))],
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
];
for (const [lab, v1, v2] of filas) {
@@ -374,21 +380,60 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
y += 16;
const bonos = [
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
"Bono 2: Workshop Estrategico de Buyer Persona",
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
"Bono 4: 1 ano de Membresia Premium",
"Bono 5: Un mes gratis de Bucefalo CRM",
"Bono 6: Script de Ventas con mas de 100 complementos",
];
// Fuente de verdad: la tabla Bono. La lista de abajo es solo respaldo por si
// la consulta no trajo nada; antes estaba hardcodeada aqui y su texto ya no
// coincidia con el del seed (bono 5).
const RESPALDO = [
"30 min mensuales en servicios Centinela (Sitio Web)",
"Workshop Estrategico de Buyer Persona",
"Workshop de Propuestas de Valor y Oferta Irresistible",
"1 ano de Membresia Premium",
"Un mes gratis de Bucefalo CRM, Marketing y Ventas",
"Script de Ventas con mas de 100 complementos",
].map((d, i) => `Bono ${i + 1}: ${d}`);
const bonos = data.bonos?.length
? data.bonos.map((b) => `Bono ${b.numero}: ${b.descripcion}`)
: RESPALDO;
for (const b of bonos) {
y = need(11, y);
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2713 ${b}`, L + 8, y, { width: W - 16 });
// Vinneta "\u2022" y no la palomita "\u2713": en las fuentes estandar de PDFKit
// la palomita mide 0pt de ancho, o sea que hoy salia como dos espacios.
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2022 ${b}`, L + 8, y, { width: W - 16 });
y += 11;
}
}
// ── OBSERVACIONES (solo el texto del cliente) ──
// Va al final de la Hoja Resumen, junto a los totales y antes de T&C, que es
// donde el cliente espera leer un mensaje del asesor. Nunca imprime
// observacionesInternas: ese campo ni siquiera existe en CotizacionPDFData.
if (data.observaciones && data.observaciones.trim()) {
y = sectionTitle("Observaciones", y);
const usable = maxY - m.top;
const parrafos = data.observaciones
.split(/\r?\n/)
.map((p) => p.trim())
.filter(Boolean);
for (const p of parrafos) {
const h = txtHeight(p, W - 4, 7.5);
if (h > usable) {
// Parrafo mas alto que una pagina entera: need() no sabe partir, asi que
// se deja fluir a pdfkit y se resincroniza el contador con doc.y.
y = need(20, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
y = doc.y + 4;
} else {
y = need(h + 4, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
y += h + 4;
}
}
y += 4;
}
// ── T&C PAGE ──────────────────────────────────
doc.addPage();
y = m.top;
+2
View File
@@ -43,6 +43,7 @@ export const cotizacionPostSchema = z.object({
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
observacionesInternas: z.string().optional(),
asesorId: z.string().min(1),
cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"),
@@ -73,6 +74,7 @@ export const cotizacionPutSchema = z.object({
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
observacionesInternas: z.string().optional(),
cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"),
empresa: z.string(),
+2
View File
@@ -42,6 +42,7 @@ export interface CotizacionDraft {
planBucefaloNivel: string | null;
servicios: ServicioSeleccionado[];
observaciones: string;
observacionesInternas: string;
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
esDoble: boolean;
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
@@ -79,6 +80,7 @@ const initialDraft: CotizacionDraft = {
planBucefaloNivel: null,
servicios: [],
observaciones: "",
observacionesInternas: "",
esDoble: false,
opciones: {},
};