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:
|
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(
|
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
|
cl.email as cliente_email, cl.telefono as cliente_telefono
|
||||||
FROM "Cotizacion" c
|
FROM "Cotizacion" c
|
||||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
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(
|
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"],
|
args["cotizacion_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -9,10 +9,11 @@ from __future__ import annotations
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.auth import require_auth
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import close_db, init_db
|
from app.database import close_db, init_db
|
||||||
|
|
||||||
@@ -105,8 +106,14 @@ try:
|
|||||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||||
|
|
||||||
@app.post("/mcp")
|
@app.post("/mcp")
|
||||||
async def mcp_endpoint(request: Request):
|
async def mcp_endpoint(request: Request, _auth: dict = Depends(require_auth)):
|
||||||
"""MCP (Model Context Protocol) endpoint for AI agents."""
|
"""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)
|
transport = StreamableHTTPServerTransport(mcp_server)
|
||||||
return await transport.handle_request(request)
|
return await transport.handle_request(request)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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)
|
incluirIva Boolean @default(true)
|
||||||
esDoble Boolean @default(false)
|
esDoble Boolean @default(false)
|
||||||
opcionesMetadata Json?
|
opcionesMetadata Json?
|
||||||
|
// Texto que SI ve el cliente: se imprime en el PDF y en el Excel.
|
||||||
observaciones String?
|
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
|
clienteId String
|
||||||
asesorId String
|
asesorId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default async function EditarCotizacionPage({
|
|||||||
incluirBonos: cot.incluirBonos,
|
incluirBonos: cot.incluirBonos,
|
||||||
incluirFinanciamiento: cot.incluirFinanciamiento,
|
incluirFinanciamiento: cot.incluirFinanciamiento,
|
||||||
observaciones: cot.observaciones || "",
|
observaciones: cot.observaciones || "",
|
||||||
|
observacionesInternas: cot.observacionesInternas || "",
|
||||||
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||||
esDoble: cot.esDoble,
|
esDoble: cot.esDoble,
|
||||||
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
|
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
|
||||||
|
|||||||
@@ -226,10 +226,29 @@ export default async function CotizacionDetailPage({
|
|||||||
|
|
||||||
{cot.observaciones && (
|
{cot.observaciones && (
|
||||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
<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>
|
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export async function PUT(
|
|||||||
esDoble,
|
esDoble,
|
||||||
opciones,
|
opciones,
|
||||||
observaciones,
|
observaciones,
|
||||||
|
observacionesInternas,
|
||||||
cliente,
|
cliente,
|
||||||
servicios,
|
servicios,
|
||||||
planBucefalo,
|
planBucefalo,
|
||||||
@@ -158,6 +159,7 @@ export async function PUT(
|
|||||||
...(esDoble !== undefined && { esDoble }),
|
...(esDoble !== undefined && { esDoble }),
|
||||||
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
||||||
...(observaciones !== undefined && { observaciones }),
|
...(observaciones !== undefined && { observaciones }),
|
||||||
|
...(observacionesInternas !== undefined && { observacionesInternas }),
|
||||||
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
|
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
esDoble,
|
esDoble,
|
||||||
opciones,
|
opciones,
|
||||||
observaciones,
|
observaciones,
|
||||||
|
observacionesInternas,
|
||||||
cliente,
|
cliente,
|
||||||
asesorId,
|
asesorId,
|
||||||
servicios,
|
servicios,
|
||||||
@@ -81,6 +82,7 @@ export async function POST(request: NextRequest) {
|
|||||||
esDoble: esDoble ?? false,
|
esDoble: esDoble ?? false,
|
||||||
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
||||||
observaciones: observaciones || null,
|
observaciones: observaciones || null,
|
||||||
|
observacionesInternas: observacionesInternas || null,
|
||||||
clienteId: clienteIdFinal,
|
clienteId: clienteIdFinal,
|
||||||
asesorId,
|
asesorId,
|
||||||
estado: "borrador",
|
estado: "borrador",
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export async function GET(
|
|||||||
include: {
|
include: {
|
||||||
cliente: true,
|
cliente: true,
|
||||||
asesor: 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,
|
planBucefalo: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -67,6 +72,9 @@ export async function GET(
|
|||||||
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
|
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
|
||||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
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);
|
const buffer = await buildCotizacionExcel(data);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export async function POST(request: NextRequest) {
|
|||||||
planBucefaloNivel: draft.planBucefaloNivel,
|
planBucefaloNivel: draft.planBucefaloNivel,
|
||||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
colorSecundario: branding.colorSecundario || "#1e293b",
|
||||||
|
observaciones: draft.observaciones,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffer = await buildCotizacionExcel(data);
|
const buffer = await buildCotizacionExcel(data);
|
||||||
|
|||||||
@@ -10,18 +10,25 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [cot, config, branding] = await Promise.all([
|
const [cot, config, branding, bonos] = await Promise.all([
|
||||||
prisma.cotizacion.findUnique({
|
prisma.cotizacion.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
cliente: true,
|
cliente: true,
|
||||||
asesor: 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,
|
planBucefalo: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
getConfigBancaria(),
|
getConfigBancaria(),
|
||||||
getConfigBranding(),
|
getConfigBranding(),
|
||||||
|
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!cot) {
|
if (!cot) {
|
||||||
@@ -61,6 +68,10 @@ export async function GET(
|
|||||||
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||||
planBucefaloPrecio: cot.planBucefalo?.precio || 0,
|
planBucefaloPrecio: cot.planBucefalo?.precio || 0,
|
||||||
incluirBonos: cot.incluirBonos,
|
incluirBonos: cot.incluirBonos,
|
||||||
|
bonos,
|
||||||
|
incluirIva: cot.incluirIva,
|
||||||
|
// Solo el texto del cliente. observacionesInternas no se pasa nunca.
|
||||||
|
observaciones: cot.observaciones,
|
||||||
configBancaria: config,
|
configBancaria: config,
|
||||||
...branding,
|
...branding,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { generateCotizacionPDF } from "@/lib/pdf-generator";
|
import { generateCotizacionPDF } from "@/lib/pdf-generator";
|
||||||
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +43,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const fechaCot = new Date(draft.fecha);
|
const fechaCot = new Date(draft.fecha);
|
||||||
const vigencia = calcularVigencia(fechaCot);
|
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 empresa = draft.clienteEmpresa || draft.clienteNombre;
|
||||||
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
|
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
|
||||||
|
|
||||||
@@ -63,6 +71,10 @@ export async function POST(request: NextRequest) {
|
|||||||
planBucefaloNivel: draft.planBucefaloNivel,
|
planBucefaloNivel: draft.planBucefaloNivel,
|
||||||
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
|
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
|
||||||
incluirBonos: draft.incluirBonos,
|
incluirBonos: draft.incluirBonos,
|
||||||
|
bonos,
|
||||||
|
// El cliente ya mandaba observaciones en el body; el generador nunca las recibia.
|
||||||
|
observaciones: draft.observaciones,
|
||||||
|
configBancaria,
|
||||||
...branding,
|
...branding,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +84,7 @@ export interface ExistingData {
|
|||||||
incluirBonos: boolean;
|
incluirBonos: boolean;
|
||||||
incluirFinanciamiento: boolean;
|
incluirFinanciamiento: boolean;
|
||||||
observaciones: string;
|
observaciones: string;
|
||||||
|
observacionesInternas?: string;
|
||||||
planBucefaloNivel: string | null;
|
planBucefaloNivel: string | null;
|
||||||
estado: string;
|
estado: string;
|
||||||
servicios: ServicioSeleccionado[];
|
servicios: ServicioSeleccionado[];
|
||||||
@@ -165,6 +168,7 @@ export function CotizacionForm({
|
|||||||
store.setField("incluirBonos", existingData.incluirBonos);
|
store.setField("incluirBonos", existingData.incluirBonos);
|
||||||
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
|
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
|
||||||
store.setField("observaciones", existingData.observaciones);
|
store.setField("observaciones", existingData.observaciones);
|
||||||
|
store.setField("observacionesInternas", existingData.observacionesInternas ?? "");
|
||||||
store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
|
store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
|
||||||
store.setField("esDoble", existingData.esDoble ?? false);
|
store.setField("esDoble", existingData.esDoble ?? false);
|
||||||
store.setField("opciones", existingData.opciones ?? {});
|
store.setField("opciones", existingData.opciones ?? {});
|
||||||
@@ -333,6 +337,7 @@ export function CotizacionForm({
|
|||||||
esDoble: store.draft.esDoble,
|
esDoble: store.draft.esDoble,
|
||||||
opciones: store.draft.esDoble ? store.draft.opciones : undefined,
|
opciones: store.draft.esDoble ? store.draft.opciones : undefined,
|
||||||
observaciones: store.draft.observaciones,
|
observaciones: store.draft.observaciones,
|
||||||
|
observacionesInternas: store.draft.observacionesInternas,
|
||||||
cliente: {
|
cliente: {
|
||||||
nombre: store.draft.clienteNombre,
|
nombre: store.draft.clienteNombre,
|
||||||
empresa: store.draft.clienteEmpresa,
|
empresa: store.draft.clienteEmpresa,
|
||||||
@@ -1217,20 +1222,52 @@ export function CotizacionForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
{/* Dos campos deliberadamente distintos. El de arriba se imprime en el PDF y
|
||||||
<label className="block text-sm font-medium text-muted mb-1">
|
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
|
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>
|
</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
|
<textarea
|
||||||
value={store.draft.observaciones}
|
value={store.draft.observaciones}
|
||||||
onChange={(e) =>
|
onChange={(e) => store.setField("observaciones", e.target.value)}
|
||||||
store.setField("observaciones", e.target.value)
|
|
||||||
}
|
|
||||||
rows={3}
|
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}
|
className={INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="hidden lg:block w-80 shrink-0">
|
<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> = {
|
export const FASES: Record<number, string> = {
|
||||||
0: "FASE 0 - Auditoria / Acompanamiento",
|
0: "FASE 0 - Auditoria / Acompanamiento",
|
||||||
1: "FASE 1 - Setup e Infraestructura",
|
1: "FASE 1 - Setup e Infraestructura",
|
||||||
|
|||||||
+29
-11
@@ -1,5 +1,5 @@
|
|||||||
import ExcelJS from "exceljs";
|
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
|
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
|
||||||
@@ -44,6 +44,11 @@ export interface ExcelData {
|
|||||||
planBucefaloPrecio?: number | null;
|
planBucefaloPrecio?: number | null;
|
||||||
colorPrimario: string;
|
colorPrimario: string;
|
||||||
colorSecundario: 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):
|
// 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();
|
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 {
|
function detalleModelo(serv: ExcelServicio): string {
|
||||||
if (serv.modeloCobro === "retainer") {
|
return detalleModeloCanonico(serv);
|
||||||
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 "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
|
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
|
||||||
@@ -374,8 +374,10 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
|
|||||||
row++;
|
row++;
|
||||||
};
|
};
|
||||||
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
|
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);
|
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
|
||||||
compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont);
|
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);
|
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
|
||||||
row++;
|
row++;
|
||||||
} else {
|
} else {
|
||||||
@@ -402,6 +404,22 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
|
|||||||
ws.getCell(`B${row}`).font = smallFont;
|
ws.getCell(`B${row}`).font = smallFont;
|
||||||
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
|
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
|
// HOJAS DETALLADAS POR SERVICIO
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+64
-19
@@ -1,5 +1,5 @@
|
|||||||
import PDFDocument from "pdfkit";
|
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 {
|
interface ServicioPDF {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
@@ -18,14 +18,11 @@ interface ServicioPDF {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
|
// 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 {
|
function detalleModeloPDF(serv: ServicioPDF): string {
|
||||||
if (serv.modeloCobro === "retainer") {
|
return detalleModelo(serv);
|
||||||
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 "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CotizacionPDFData {
|
interface CotizacionPDFData {
|
||||||
@@ -45,6 +42,13 @@ interface CotizacionPDFData {
|
|||||||
planBucefaloNivel: string | null;
|
planBucefaloNivel: string | null;
|
||||||
planBucefaloPrecio: number;
|
planBucefaloPrecio: number;
|
||||||
incluirBonos: boolean;
|
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>;
|
configBancaria?: Record<string, string>;
|
||||||
colorPrimario?: string;
|
colorPrimario?: string;
|
||||||
colorSecundario?: 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 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 });
|
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
|
||||||
y += 18;
|
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][] = [
|
const filas: [string, string, string][] = [
|
||||||
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
|
[`Total unico${ivaLbl}`, fmt(conIva(t1.totalUnico, data.incluirIva)), fmt(conIva(t2.totalUnico, data.incluirIva))],
|
||||||
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
|
[`Total mensual${ivaLbl}`, fmt(conIva(t1.totalMensual, data.incluirIva)), fmt(conIva(t2.totalMensual, data.incluirIva))],
|
||||||
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
|
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
|
||||||
];
|
];
|
||||||
for (const [lab, v1, v2] of filas) {
|
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.rect(L, y, 3, 10).fill(PRIMARY);
|
||||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
|
||||||
y += 16;
|
y += 16;
|
||||||
const bonos = [
|
// Fuente de verdad: la tabla Bono. La lista de abajo es solo respaldo por si
|
||||||
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
|
// la consulta no trajo nada; antes estaba hardcodeada aqui y su texto ya no
|
||||||
"Bono 2: Workshop Estrategico de Buyer Persona",
|
// coincidia con el del seed (bono 5).
|
||||||
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
|
const RESPALDO = [
|
||||||
"Bono 4: 1 ano de Membresia Premium",
|
"30 min mensuales en servicios Centinela (Sitio Web)",
|
||||||
"Bono 5: Un mes gratis de Bucefalo CRM",
|
"Workshop Estrategico de Buyer Persona",
|
||||||
"Bono 6: Script de Ventas con mas de 100 complementos",
|
"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) {
|
for (const b of bonos) {
|
||||||
y = need(11, y);
|
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;
|
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 ──────────────────────────────────
|
// ── T&C PAGE ──────────────────────────────────
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
y = m.top;
|
y = m.top;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const cotizacionPostSchema = z.object({
|
|||||||
esDoble: z.boolean().optional(),
|
esDoble: z.boolean().optional(),
|
||||||
opciones: opcionesSchema,
|
opciones: opcionesSchema,
|
||||||
observaciones: z.string(),
|
observaciones: z.string(),
|
||||||
|
observacionesInternas: z.string().optional(),
|
||||||
asesorId: z.string().min(1),
|
asesorId: z.string().min(1),
|
||||||
cliente: z.object({
|
cliente: z.object({
|
||||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||||
@@ -73,6 +74,7 @@ export const cotizacionPutSchema = z.object({
|
|||||||
esDoble: z.boolean().optional(),
|
esDoble: z.boolean().optional(),
|
||||||
opciones: opcionesSchema,
|
opciones: opcionesSchema,
|
||||||
observaciones: z.string(),
|
observaciones: z.string(),
|
||||||
|
observacionesInternas: z.string().optional(),
|
||||||
cliente: z.object({
|
cliente: z.object({
|
||||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||||
empresa: z.string(),
|
empresa: z.string(),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface CotizacionDraft {
|
|||||||
planBucefaloNivel: string | null;
|
planBucefaloNivel: string | null;
|
||||||
servicios: ServicioSeleccionado[];
|
servicios: ServicioSeleccionado[];
|
||||||
observaciones: string;
|
observaciones: string;
|
||||||
|
observacionesInternas: string;
|
||||||
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
|
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
|
||||||
esDoble: boolean;
|
esDoble: boolean;
|
||||||
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
|
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
|
||||||
@@ -79,6 +80,7 @@ const initialDraft: CotizacionDraft = {
|
|||||||
planBucefaloNivel: null,
|
planBucefaloNivel: null,
|
||||||
servicios: [],
|
servicios: [],
|
||||||
observaciones: "",
|
observaciones: "",
|
||||||
|
observacionesInternas: "",
|
||||||
esDoble: false,
|
esDoble: false,
|
||||||
opciones: {},
|
opciones: {},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user