Fase 0: cerrar el MCP, unificar totales y separar el contexto interno del cliente

Primera fase del plugin de propuesta consultiva (docs/superpowers/specs/
2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md). Recupera el contexto
humano que hoy se captura y se descarta, y cierra los bloqueadores que el
analisis previo destapo.

Seguridad (lo mas urgente):
- POST /mcp no tenia NINGUNA autenticacion: cero Depends() en main.py, mientras
  el servicio recibe dominio publico en produccion (SERVICE_FQDN_API_8000).
  Cualquiera en internet podia leer y escribir cotizaciones. Ahora exige
  require_auth, que ya existia en app/auth.py y no se estaba usando ahi.
- _obtener_cotizacion hacia SELECT c.* y devolvia dict(cot) al agente, asi que
  cualquier columna nueva se publicaba sola. Ahora usa lista blanca espejo de
  CotizacionResponse, excluyendo observacionesInternas.

Totales:
- Nueva calcularTotalesCotizacion() en calculators.ts como fuente de verdad
  unica. El calculo estaba duplicado a mano en siete consumidores.
- conIva() sustituye a los `* 1.16` hardcodeados de pdf-generator y
  excel-builder, que ignoraban IVA_RATE y el flag incluirIva.

Contexto humano:
- Campo nuevo Cotizacion.observacionesInternas. La migracion es PURAMENTE
  ADITIVA: no mueve ni una fila. El movimiento de datos no hace falta porque la
  unica fila de produccion con observaciones ya contiene texto dirigido al
  cliente, y separarlo en dos despliegues mantiene el rollback limpio.
- Dos textareas visualmente inconfundibles en el formulario.
- observaciones se imprime por primera vez en el PDF y el Excel; el dato ya
  viajaba hasta las rutas de borrador y se tiraba.
- observacionesInternas solo se ve en la app. La garantia es estructural: el
  campo no existe en CotizacionPDFData ni en ExcelData, asi que el generador no
  puede filtrarlo aunque alguien lo intente.

Bugs vecinos:
- orderBy explicito en las cuatro rutas de export: el PDF asume las partidas
  agrupadas por fase y sin orderBy podia diferir del Excel del mismo envio.
- El PDF de borrador leia solo el branding, no los datos bancarios, e imprimia
  los hardcodeados del generador.
- detalleModelo local en PDF y Excel omitia la rama "demanda": esa partida
  salia en $0 y sin explicacion. Ahora delegan en la version canonica.
- Los bonos salen de la tabla Bono; la lista hardcodeada queda de respaldo y su
  texto ya no coincidia con el seed.
- La palomita de los bonos mide 0pt en las fuentes base de PDFKit (verificado),
  o sea que salia como dos espacios. Sustituida por una vineta.

Ademas: zod pasa a ser dependencia declarada. Se importaba en schemas.ts y
resolvia transitivamente, asi que un npm ci --omit=dev reventaba.

Verificado con build, lint y 22 comprobaciones funcionales sobre PDF y Excel
reales (texto del cliente presente, texto interno ausente incluso inyectandolo
a la fuerza, texto largo multipagina, y totales con y sin IVA).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 14:50:03 -06:00
co-authored by Claude Opus 5
parent 0ff783fd65
commit 9023384de9
19 changed files with 322 additions and 54 deletions
+63
View File
@@ -112,6 +112,69 @@ export function calcularTotalesOpcion(
};
}
// ----- Totales de una cotizacion: FUENTE DE VERDAD UNICA -----
// Antes este calculo estaba duplicado a mano con .filter().reduce() en siete
// consumidores (PDF, Excel, detalle, PreciosEditables, formulario, lista y dashboard),
// con criterios que no coincidian, y el IVA estaba hardcodeado como * 1.16 en los dos
// exportadores ignorando IVA_RATE y el flag incluirIva. Toda comparacion de totales
// (y cualquier documento que deba coincidir con el PDF) debe pasar por aqui.
//
// Nota: los subtotales son SIN IVA, que es como los exportadores presentan hoy los
// totales de una cotizacion simple ("los precios no incluyen IVA" en la nota al pie).
export interface TotalesCotizacion {
subtotalUnico: number;
subtotalMensual: number;
ivaUnico: number;
ivaMensual: number;
totalUnico: number;
totalMensual: number;
/** Desembolso real a 12 meses: unico + mensual x 12, con IVA si aplica.
* Es la base del ratio precio/valor de la propuesta consultiva. */
totalPrimerAnio: number;
incluyeIva: boolean;
}
export function calcularTotalesCotizacion(
servicios: Array<{ tipoPago: string; precio: number; seleccionado?: boolean }>,
opciones?: { incluirIva?: boolean }
): TotalesCotizacion {
const incluyeIva = opciones?.incluirIva !== false;
const activos = servicios.filter((s) => s.seleccionado !== false);
const suma = (tipo: string) =>
r2(activos.filter((s) => s.tipoPago === tipo).reduce((a, s) => a + (s.precio || 0), 0));
const subtotalUnico = suma("unico");
const subtotalMensual = suma("mensual");
const ivaUnico = incluyeIva ? r2(subtotalUnico * IVA_RATE) : 0;
const ivaMensual = incluyeIva ? r2(subtotalMensual * IVA_RATE) : 0;
const totalUnico = r2(subtotalUnico + ivaUnico);
const totalMensual = r2(subtotalMensual + ivaMensual);
return {
subtotalUnico,
subtotalMensual,
ivaUnico,
ivaMensual,
totalUnico,
totalMensual,
totalPrimerAnio: r2(totalUnico + totalMensual * 12),
incluyeIva,
};
}
function r2(n: number): number {
return Math.round(n * 100) / 100;
}
/** Aplica IVA a un monto respetando el flag de la cotizacion.
* Sustituye a los `* 1.16` hardcodeados que habia en pdf-generator y excel-builder. */
export function conIva(monto: number, incluirIva: boolean = true): number {
return r2((monto || 0) * (incluirIva ? 1 + IVA_RATE : 1));
}
export const FASES: Record<number, string> = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
+29 -11
View File
@@ -1,5 +1,5 @@
import ExcelJS from "exceljs";
import { bucefaloPrecio, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
import { bucefaloPrecio, conIva, detalleModelo as detalleModeloCanonico, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
// ─────────────────────────────────────────────────────────────────────────────
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
@@ -44,6 +44,11 @@ export interface ExcelData {
planBucefaloPrecio?: number | null;
colorPrimario: string;
colorSecundario: string;
incluirIva?: boolean;
/** Texto que SI ve el cliente. El Excel es un documento del cliente (lleva razon
* social, "En atencion a:", domicilio fiscal y nota legal), no una herramienta
* interna: por eso `observacionesInternas` NO se declara aqui. */
observaciones?: string | null;
}
// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex):
@@ -54,15 +59,10 @@ function argb(hex: string, alpha = "FF"): string {
return alpha + h.toUpperCase();
}
// Texto de desglose por modelo de cobro (horas / retainer) para mostrar junto al servicio.
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
// omitia la rama "demanda", igual que la del PDF.
function detalleModelo(serv: ExcelServicio): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
return detalleModeloCanonico(serv);
}
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
@@ -374,8 +374,10 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
row++;
};
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
compRow("Total unico (c/IVA)", formatCurrency(t1.totalUnico * 1.16), formatCurrency(t2.totalUnico * 1.16), valueFont);
compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont);
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
compRow(`Total unico${ivaLbl}`, formatCurrency(conIva(t1.totalUnico, data.incluirIva)), formatCurrency(conIva(t2.totalUnico, data.incluirIva)), valueFont);
compRow(`Total mensual${ivaLbl}`, formatCurrency(conIva(t1.totalMensual, data.incluirIva)), formatCurrency(conIva(t2.totalMensual, data.incluirIva)), valueFont);
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
row++;
} else {
@@ -402,6 +404,22 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
ws.getCell(`B${row}`).font = smallFont;
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
// Observaciones del cliente. Solo este campo: el Excel lleva razon social,
// "En atencion a:" y domicilio fiscal, o sea que es un documento que el cliente
// recibe. observacionesInternas no existe en ExcelData a proposito.
if (data.observaciones && data.observaciones.trim()) {
row += 2;
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = "OBSERVACIONES";
ws.getCell(`B${row}`).font = { ...boldFont, color: { argb: PRIMARY } };
row++;
ws.mergeCells(`B${row}:K${row}`);
const texto = data.observaciones.trim();
ws.getCell(`B${row}`).value = texto;
ws.getCell(`B${row}`).font = smallFont;
setWrapped(ws, row, "B", texto, mergedWidth(ws, "B", "K"), { fontSize: 9 });
}
// ═══════════════════════════════════════════════════════════════════════════
// HOJAS DETALLADAS POR SERVICIO
// ═══════════════════════════════════════════════════════════════════════════
+64 -19
View File
@@ -1,5 +1,5 @@
import PDFDocument from "pdfkit";
import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
import { FASES_SHORT as FASES, conIva, detalleModelo, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
interface ServicioPDF {
nombre: string;
@@ -18,14 +18,11 @@ interface ServicioPDF {
}
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
// omitia la rama "demanda", asi que esa partida se imprimia sin desglose y con
// precio $0, sin explicar que se factura segun consumo.
function detalleModeloPDF(serv: ServicioPDF): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
return detalleModelo(serv);
}
interface CotizacionPDFData {
@@ -45,6 +42,13 @@ interface CotizacionPDFData {
planBucefaloNivel: string | null;
planBucefaloPrecio: number;
incluirBonos: boolean;
/** Bonos desde la tabla Bono. Si no se pasan, se usa la lista de respaldo. */
bonos?: { numero: number; descripcion: string }[];
incluirIva?: boolean;
/** Texto que SI ve el cliente. `observacionesInternas` NO se declara aqui a
* proposito: si el generador no puede verlo, no puede filtrarlo. La garantia
* es estructural, no depende de la disciplina de quien dibuje. */
observaciones?: string | null;
configBancaria?: Record<string, string>;
colorPrimario?: string;
colorSecundario?: string;
@@ -324,9 +328,11 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
y += 18;
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
const filas: [string, string, string][] = [
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
[`Total unico${ivaLbl}`, fmt(conIva(t1.totalUnico, data.incluirIva)), fmt(conIva(t2.totalUnico, data.incluirIva))],
[`Total mensual${ivaLbl}`, fmt(conIva(t1.totalMensual, data.incluirIva)), fmt(conIva(t2.totalMensual, data.incluirIva))],
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
];
for (const [lab, v1, v2] of filas) {
@@ -374,21 +380,60 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
y += 16;
const bonos = [
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
"Bono 2: Workshop Estrategico de Buyer Persona",
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
"Bono 4: 1 ano de Membresia Premium",
"Bono 5: Un mes gratis de Bucefalo CRM",
"Bono 6: Script de Ventas con mas de 100 complementos",
];
// Fuente de verdad: la tabla Bono. La lista de abajo es solo respaldo por si
// la consulta no trajo nada; antes estaba hardcodeada aqui y su texto ya no
// coincidia con el del seed (bono 5).
const RESPALDO = [
"30 min mensuales en servicios Centinela (Sitio Web)",
"Workshop Estrategico de Buyer Persona",
"Workshop de Propuestas de Valor y Oferta Irresistible",
"1 ano de Membresia Premium",
"Un mes gratis de Bucefalo CRM, Marketing y Ventas",
"Script de Ventas con mas de 100 complementos",
].map((d, i) => `Bono ${i + 1}: ${d}`);
const bonos = data.bonos?.length
? data.bonos.map((b) => `Bono ${b.numero}: ${b.descripcion}`)
: RESPALDO;
for (const b of bonos) {
y = need(11, y);
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2713 ${b}`, L + 8, y, { width: W - 16 });
// Vinneta "\u2022" y no la palomita "\u2713": en las fuentes estandar de PDFKit
// la palomita mide 0pt de ancho, o sea que hoy salia como dos espacios.
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2022 ${b}`, L + 8, y, { width: W - 16 });
y += 11;
}
}
// ── OBSERVACIONES (solo el texto del cliente) ──
// Va al final de la Hoja Resumen, junto a los totales y antes de T&C, que es
// donde el cliente espera leer un mensaje del asesor. Nunca imprime
// observacionesInternas: ese campo ni siquiera existe en CotizacionPDFData.
if (data.observaciones && data.observaciones.trim()) {
y = sectionTitle("Observaciones", y);
const usable = maxY - m.top;
const parrafos = data.observaciones
.split(/\r?\n/)
.map((p) => p.trim())
.filter(Boolean);
for (const p of parrafos) {
const h = txtHeight(p, W - 4, 7.5);
if (h > usable) {
// Parrafo mas alto que una pagina entera: need() no sabe partir, asi que
// se deja fluir a pdfkit y se resincroniza el contador con doc.y.
y = need(20, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
y = doc.y + 4;
} else {
y = need(h + 4, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
y += h + 4;
}
}
y += 4;
}
// ── T&C PAGE ──────────────────────────────────
doc.addPage();
y = m.top;
+2
View File
@@ -43,6 +43,7 @@ export const cotizacionPostSchema = z.object({
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
observacionesInternas: z.string().optional(),
asesorId: z.string().min(1),
cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"),
@@ -73,6 +74,7 @@ export const cotizacionPutSchema = z.object({
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
observacionesInternas: z.string().optional(),
cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"),
empresa: z.string(),
+2
View File
@@ -42,6 +42,7 @@ export interface CotizacionDraft {
planBucefaloNivel: string | null;
servicios: ServicioSeleccionado[];
observaciones: string;
observacionesInternas: string;
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
esDoble: boolean;
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
@@ -79,6 +80,7 @@ const initialDraft: CotizacionDraft = {
planBucefaloNivel: null,
servicios: [],
observaciones: "",
observacionesInternas: "",
esDoble: false,
opciones: {},
};