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:
co-authored by
Claude Opus 5
parent
0ff783fd65
commit
9023384de9
+13
-2
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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) ?? {},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,20 +1222,52 @@ 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">
|
||||
{/* 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)
|
||||
}
|
||||
onChange={(e) => store.setField("observaciones", e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Notas adicionales para la cotizacion..."
|
||||
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>
|
||||
|
||||
<div className="hidden lg:block w-80 shrink-0">
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user