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
@@ -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