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({
-
diff --git a/src/lib/calculators.ts b/src/lib/calculators.ts
index fe766fd..45650a2 100644
--- a/src/lib/calculators.ts
+++ b/src/lib/calculators.ts
@@ -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 = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
diff --git a/src/lib/excel-builder.ts b/src/lib/excel-builder.ts
index fdf2156..bd48040 100644
--- a/src/lib/excel-builder.ts
+++ b/src/lib/excel-builder.ts
@@ -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 {
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 {
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
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/src/lib/pdf-generator.ts b/src/lib/pdf-generator.ts
index b6fff12..779a30e 100644
--- a/src/lib/pdf-generator.ts
+++ b/src/lib/pdf-generator.ts
@@ -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;
colorPrimario?: string;
colorSecundario?: string;
@@ -324,9 +328,11 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise `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;
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 0f753bb..25c10bd 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -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(),
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 3bf3f4c..4131c2e 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -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: {},
};