import zlib from "node:zlib"; import { generarPropuestaPDF, generarAnexoInternoPDF, type DatosPropuestaPDF } from "@/lib/propuesta/pdf"; import type { PropuestaConsultiva } from "@/lib/propuesta/schemas"; import type { DatosEconomicos } from "@/lib/propuesta/economia"; /** Extrae el texto de un PDF de PDFKit. Con las fuentes base (Helvetica) no hay * subsetting ni ToUnicode: el texto va como hex de UN byte por caracter dentro de * streams comprimidos con Flate. */ function textoDePdf(pdf: Buffer): string { const crudo = pdf.toString("latin1"); let salida = ""; const re = /stream\r?\n/g; let m: RegExpExecArray | null; while ((m = re.exec(crudo)) !== null) { const ini = m.index + m[0].length; const fin = crudo.indexOf("endstream", ini); if (fin === -1) continue; let bloque = ""; try { bloque = zlib.inflateSync(Buffer.from(crudo.slice(ini, fin), "latin1")).toString("latin1"); } catch { continue; } // PDFKit alterna dos codificaciones segun el contenido de la cadena: // hex `<48656c6c6f>` y literal `(Hello) Tj`. Hay que leer las dos. // OJO: no se puede meter un separador entre trozos hex. PDFKit aplica kerning y // parte una misma palabra en varios elementos de un arreglo TJ // (`[<4d41> -20 <524341444f52>]`), asi que un espacio de mas convierte // "MARCADORVERDE" en "MARCA DORVERDE" y ninguna busqueda lo encuentra. for (const t of bloque.matchAll(/<([0-9a-fA-F]{2,})>/g)) { const hex = t[1]; for (let i = 0; i + 2 <= hex.length; i += 2) { const b = parseInt(hex.slice(i, i + 2), 16); if (b >= 32 && b < 256) salida += String.fromCharCode(b); } } salida += " "; for (const t of bloque.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { salida += t[1].replace(/\\([()\\])/g, "$1") + " "; } } return salida; } let fallas = 0; function check(n: string, ok: boolean, d = "") { console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`); if (!ok) fallas++; } const econ: DatosEconomicos = { partidas: [ { refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web institucional", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }, { refPartida: "P02", servicioCotizadoId: "c2", nombre: "Manejo de pauta", fase: 2, tipoPago: "mensual", precio: 8000, tiempoEntrega: "Mensual", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }, ], totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true }, moneda: "MXN", planBucefalo: null, }; const propuesta: PropuestaConsultiva = { hechos: { citas: [{ id: "C01", textoLiteral: "MARCADORCITA nadie contesta el fin de semana", quienLoDijo: "el socio" }], hechos: [{ id: "H01", enunciado: "Sin control de atencion", confianza: "confirmado", citas: ["C01"] }], materialesPendientes: [{ texto: "MARCADORMATERIAL accesos al dominio", bloqueaEntrega: true }], decisionesPendientes: [{ texto: "MARCADORDECISION definir responsable", quienDecide: "el socio" }], mencionesFueraDeAlcance: ["MARCADORFUERA app movil"], redFlags: [{ senal: "MARCADORFLAG pidio descuento antes de ver el alcance", severidad: "alta" }], }, diagnostico: { hallazgos: [ { id: "D01", urgencia: "rojo", titulo: "MARCADORHALLAZGO", descripcion: "Se pierden prospectos cada fin de semana.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }, { id: "D02", urgencia: "verde", titulo: "MARCADORVERDE", descripcion: "Ya tienen la base de clientes ordenada.", confianza: "confirmado", citas: ["C01"], hechos: ["H01"] }, ], valorProblema: { dimensiones: [{ tipo: "tiempo", descripcion: "MARCADORVALOR horas perdidas", factores: [{ nombre: "h", valor: 8, confianza: "confirmado" }, { nombre: "s", valor: 52, confianza: "confirmado" }, { nombre: "c", valor: 400, confianza: "estimado" }], montoAnualMXN: 166400, confianza: "estimado", hechos: ["H01"] }], notaMetodologia: "MARCADORMETODO ocho horas por semana a costo cargado.", }, resultados: [{ enunciado: "MARCADORRESULTADO cero mensajes sin respuesta", metrica: "tiempo", lineaBase: null, periodoMedicion: "mensual" }], }, redaccion: { hero: { titulo: "MARCADORTITULO", subtitulo: "MARCADORSUB ordenar la atencion para dejar de perder prospectos." }, citaDestacadaId: "C01", alcance: [ { refPartida: "P01", descripcionResultado: "MARCADORALCANCE un lugar a donde mandar prospectos." }, { refPartida: "P02", descripcionResultado: "MARCADORALCANCE2 cada peso a las busquedas que compran." }, ], beneficios: [{ etiqueta: "Respuesta", texto: "MARCADORBENEFICIO cada mensaje con responsable.", hallazgoId: "D01" }], exclusiones: [{ texto: "MARCADOREXCLUSION no incluye migrar el historico.", razon: "fase 2" }], backlogEvolucion: [{ problema: "MARCADORBACKLOG automatizar reportes", momentoSugerido: "tras la fase 1" }], notasInternas: ["MARCADORINTERNO el socio decide, no el que vino a la reunion"], }, }; const datos: DatosPropuestaPDF = { propuesta, economia: econ, cliente: "Cliente Prueba", empresa: "Empresa SA", asesor: "Asesor", numero: "UJ2607TEST", fecha: new Date("2026-07-29"), vigencia: new Date("2026-08-19"), proyecto: "MKT Digital", branding: { colorPrimario: "#2563eb" }, }; async function main() { const pdf = await generarPropuestaPDF(datos); const txt = textoDePdf(pdf); check("se genera el documento del cliente", pdf.length > 2000, `${pdf.length} bytes`); for (const marca of [ "MARCADORTITULO", "MARCADORSUB", "MARCADORCITA", "MARCADORHALLAZGO", "MARCADORVERDE", "MARCADORVALOR", "MARCADORMETODO", "MARCADORRESULTADO", "MARCADORALCANCE", "MARCADORALCANCE2", "MARCADORBENEFICIO", "MARCADOREXCLUSION", "MARCADORMATERIAL", "MARCADORDECISION", "MARCADORBACKLOG", ]) { check(`el documento del cliente incluye ${marca}`, txt.includes(marca)); } // Lo que NUNCA debe salir al cliente. check("NO lleva notas internas", !txt.includes("MARCADORINTERNO")); check("NO lleva red flags", !txt.includes("MARCADORFLAG")); check("NO lleva menciones fuera de alcance", !txt.includes("MARCADORFUERA")); // El dinero lo pone el codigo, no la IA. check("imprime el precio de P01", txt.includes("12,000")); check("imprime el precio de P02", txt.includes("8,000")); check("imprime el total unico con IVA", txt.includes("13,920")); check("imprime la mensualidad con IVA", txt.includes("9,280")); // El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio. check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000")); check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920")); check("imprime el total con IVA", txt.includes("Total pago unico")); check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido")); check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA")); check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente")); check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado")); check("el backlog se marca como no comprometido", txt.includes("No comprometido")); // Sin IVA no debe aparecer el renglon de IVA. const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } }); const txtSinIva = textoDePdf(sinIva); check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%")); check("sin IVA el total lo declara", txtSinIva.includes("sin IVA")); // El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores. const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } }); const txtPlan = textoDePdf(conPlan); check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo")); check("el plan aparece con su nivel", txtPlan.includes("Premium")); check("el plan aparece con su precio mensual", txtPlan.includes("4,500")); // Una partida que la IA invento no se dibuja (R2 ya la marco). const conFantasma = await generarPropuestaPDF({ ...datos, propuesta: { ...propuesta, redaccion: { ...propuesta.redaccion, alcance: [...propuesta.redaccion.alcance, { refPartida: "P99", descripcionResultado: "MARCADORFANTASMA inventada" }] } }, }); check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA")); // Completitud: la cotizacion manda, no lo que la IA alcanzo a describir. // Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40. const econGrande = { ...econ, partidas: [...econ.partidas, { refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] }, ] }; const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande }); const txtND = textoDePdf(conNoDescrita); check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA")); check("y aparece con su precio", txtND.includes("5,000")); check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A")); // ── Anexo interno ── const anexo = await generarAnexoInternoPDF(datos); const txtA = textoDePdf(anexo); check("se genera el anexo interno", anexo.length > 1000, `${anexo.length} bytes`); check("el anexo se marca NO ENVIAR", txtA.includes("NO ENVIAR")); check("el anexo lleva las notas internas", txtA.includes("MARCADORINTERNO")); check("el anexo lleva las red flags", txtA.includes("MARCADORFLAG")); check("el anexo lleva lo que quedo fuera", txtA.includes("MARCADORFUERA")); // 125280 / 166400 = 75.3% -> objecion probable check("el anexo calcula el ratio", txtA.includes("75.3"), "125280/166400"); check("el anexo interpreta el ratio", txtA.includes("objecion probable")); check("el anexo sugiere responder con alcance", txtA.includes("no con descuento")); check("el anexo desglosa de donde sale el valor anual", txtA.includes("De donde sale el valor anual")); check("el anexo muestra los factores con su confianza", txtA.includes("confirmado") && txtA.includes("estimado")); // R5b: una cifra sostenida solo por estimaciones debe llevar alerta visible. const soloEstimado = await generarAnexoInternoPDF({ ...datos, propuesta: { ...propuesta, diagnostico: { ...propuesta.diagnostico, valorProblema: { ...propuesta.diagnostico.valorProblema, dimensiones: [{ ...propuesta.diagnostico.valorProblema.dimensiones[0], factores: [{ nombre: "a", valor: 8, confianza: "estimado" as const }, { nombre: "b", valor: 20800, confianza: "estimado" as const }], montoAnualMXN: 166400 }] } } }, }); check("el anexo alerta si la cifra descansa solo en estimaciones", textoDePdf(soloEstimado).includes("puede cuadrar y aun asi estar inflada")); check("el anexo NO alerta si hay factores confirmados", !txtA.includes("puede cuadrar y aun asi estar inflada")); // Robustez del branding: la API de configuracion no valida los valores. for (const malo of ["