Propuesta IA: pipeline de tres pasos y generadores PDF
El documento del cliente sigue la arquitectura argumental de la plantilla de referencia pero en tema claro con PDFKit: la plantilla original es oscura, y en impresion eso depende de una casilla del navegador desactivada por defecto. El anexo interno va en archivo separado, no como seccion oculta. 44 comprobaciones sobre PDFs reales: contenido presente, notas internas y red flags ausentes del documento del cliente, precios inyectados por el codigo, branding invalido tolerado y contenido largo multipagina. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
45f166c116
commit
91db8cc55c
@@ -0,0 +1,195 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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", calculo: { factores: [{ nombre: "h", valor: 8 }, { nombre: "s", valor: 52 }, { nombre: "c", valor: 400 }], 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"));
|
||||||
|
check("declara que los importes llevan IVA", txt.includes("IVA incluido"));
|
||||||
|
|
||||||
|
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 la nota cambia.
|
||||||
|
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
|
||||||
|
check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA"));
|
||||||
|
|
||||||
|
// 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"));
|
||||||
|
|
||||||
|
// ── 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"));
|
||||||
|
|
||||||
|
// Robustez del branding: la API de configuracion no valida los valores.
|
||||||
|
for (const malo of ["</style><script>", "rojo", "#GGG", ""]) {
|
||||||
|
const r = await generarPropuestaPDF({ ...datos, branding: { colorPrimario: malo } });
|
||||||
|
check(`un color invalido (${JSON.stringify(malo)}) no rompe el PDF`, r.length > 2000);
|
||||||
|
}
|
||||||
|
const logoMalo = await generarPropuestaPDF({ ...datos, branding: { logoBase64: "no-es-base64-valido" } });
|
||||||
|
check("un logo invalido no rompe el PDF", logoMalo.length > 2000);
|
||||||
|
|
||||||
|
// Contenido largo: no debe perderse ni romper el pie de pagina.
|
||||||
|
const largo = await generarPropuestaPDF({
|
||||||
|
...datos,
|
||||||
|
propuesta: {
|
||||||
|
...propuesta,
|
||||||
|
diagnostico: {
|
||||||
|
...propuesta.diagnostico,
|
||||||
|
hallazgos: Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
id: `D${String(i + 1).padStart(2, "0")}`, urgencia: "ambar" as const,
|
||||||
|
titulo: `Hallazgo ${i} MARCADORLARGO`, descripcion: "x".repeat(300),
|
||||||
|
confianza: "estimado" as const, citas: [], hechos: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const txtL = textoDePdf(largo);
|
||||||
|
check("el contenido largo se imprime", txtL.includes("MARCADORLARGO"));
|
||||||
|
check("el contenido largo conserva las secciones posteriores", txtL.includes("MARCADOREXCLUSION"));
|
||||||
|
check("el contenido largo genera varias paginas", (largo.toString("latin1").match(/\/Type\s*\/Page[^s]/g) || []).length > 1);
|
||||||
|
|
||||||
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Error inesperado:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import PDFDocument from "pdfkit";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/calculators";
|
||||||
|
import { calcularRatio, type DatosEconomicos } from "./economia";
|
||||||
|
import type { PropuestaConsultiva } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generador del documento consultivo.
|
||||||
|
*
|
||||||
|
* Sigue la arquitectura argumental de la plantilla de referencia (hero, diagnostico con
|
||||||
|
* semaforo, valor del problema, resultados, alcance, beneficios, exclusiones, pendientes,
|
||||||
|
* backlog) pero en tema claro y con PDFKit, como el resto del sistema: la plantilla
|
||||||
|
* original es de tema oscuro, y eso en impresion depende de una casilla del navegador
|
||||||
|
* que viene desactivada — el cliente recibiria texto crema sobre papel blanco.
|
||||||
|
*
|
||||||
|
* El anexo interno va en SU PROPIO archivo, nunca como seccion oculta del documento del
|
||||||
|
* cliente. Un display:none o una pagina extra se envian por error; un archivo con otro
|
||||||
|
* nombre, no.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DatosPropuestaPDF {
|
||||||
|
propuesta: PropuestaConsultiva;
|
||||||
|
economia: DatosEconomicos;
|
||||||
|
cliente: string;
|
||||||
|
empresa: string;
|
||||||
|
asesor: string;
|
||||||
|
numero: string;
|
||||||
|
fecha: Date;
|
||||||
|
vigencia: Date;
|
||||||
|
proyecto: string;
|
||||||
|
branding: {
|
||||||
|
colorPrimario?: string;
|
||||||
|
colorSecundario?: string;
|
||||||
|
logoBase64?: string;
|
||||||
|
logoMime?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SEMAFORO: Record<string, string> = {
|
||||||
|
rojo: "#dc2626",
|
||||||
|
ambar: "#d97706",
|
||||||
|
azul: "#2563eb",
|
||||||
|
verde: "#16a34a",
|
||||||
|
};
|
||||||
|
const ETIQUETA_SEMAFORO: Record<string, string> = {
|
||||||
|
rojo: "Problema critico",
|
||||||
|
ambar: "Area de mejora",
|
||||||
|
azul: "Oportunidad",
|
||||||
|
verde: "Ventaja existente",
|
||||||
|
};
|
||||||
|
const ETIQUETA_CONFIANZA: Record<string, string> = {
|
||||||
|
confirmado: "Confirmado",
|
||||||
|
estimado: "Estimado",
|
||||||
|
por_validar: "Por validar",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `PUT /api/configuracion` filtra las claves permitidas pero NO valida los valores,
|
||||||
|
* asi que aqui no se confia en ellos. */
|
||||||
|
function hexSeguro(v: string | undefined, porDefecto: string): string {
|
||||||
|
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Lienzo {
|
||||||
|
doc: PDFKit.PDFDocument;
|
||||||
|
W: number;
|
||||||
|
L: number;
|
||||||
|
ph: number;
|
||||||
|
maxY: number;
|
||||||
|
mTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crearLienzo(): { lienzo: Lienzo; listo: Promise<Buffer> } {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: "LETTER",
|
||||||
|
margins: { top: 50, bottom: 55, left: 50, right: 50 },
|
||||||
|
bufferPages: true,
|
||||||
|
});
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||||
|
const listo = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
|
||||||
|
const m = doc.page.margins;
|
||||||
|
return {
|
||||||
|
lienzo: {
|
||||||
|
doc,
|
||||||
|
W: doc.page.width - m.left - m.right,
|
||||||
|
L: m.left,
|
||||||
|
ph: doc.page.height,
|
||||||
|
maxY: doc.page.height - m.bottom - 5,
|
||||||
|
mTop: m.top,
|
||||||
|
},
|
||||||
|
listo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer> {
|
||||||
|
const PRIMARY = hexSeguro(d.branding.colorPrimario, "#2563eb");
|
||||||
|
const DARK = hexSeguro(d.branding.colorSecundario, "#1e293b");
|
||||||
|
const MUTED = "#64748b";
|
||||||
|
const BORDER = "#e2e8f0";
|
||||||
|
|
||||||
|
const { lienzo, listo } = crearLienzo();
|
||||||
|
const { doc, W, L, ph, maxY, mTop } = lienzo;
|
||||||
|
let y = mTop;
|
||||||
|
|
||||||
|
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
|
||||||
|
const txtH = (s: string, w: number, size: number) =>
|
||||||
|
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
|
||||||
|
|
||||||
|
function titulo(t: string) {
|
||||||
|
y = need(26, y);
|
||||||
|
doc.rect(L, y, 3, 10).fill(PRIMARY);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(t.toUpperCase(), L + 10, y);
|
||||||
|
y += 20;
|
||||||
|
}
|
||||||
|
function parrafo(t: string, size = 9, color = DARK) {
|
||||||
|
for (const p of t.split(/\r?\n/).map((x) => x.trim()).filter(Boolean)) {
|
||||||
|
const h = txtH(p, W, size);
|
||||||
|
y = need(h + 4, y);
|
||||||
|
doc.font("Helvetica").fontSize(size).fillColor(color).text(p, L, y, { width: W });
|
||||||
|
y += h + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HERO ──
|
||||||
|
if (d.branding.logoBase64) {
|
||||||
|
try {
|
||||||
|
doc.image(Buffer.from(d.branding.logoBase64, "base64"), L, y, { height: 26 });
|
||||||
|
y += 34;
|
||||||
|
} catch {
|
||||||
|
/* logo invalido: se omite, no se rompe el documento */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.font("Helvetica-Bold").fontSize(8.5).fillColor(PRIMARY).text("PROPUESTA CONSULTIVA", L, y);
|
||||||
|
y += 16;
|
||||||
|
const hTit = txtH(d.propuesta.redaccion.hero.titulo, W, 20);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(20).fillColor(DARK).text(d.propuesta.redaccion.hero.titulo, L, y, { width: W });
|
||||||
|
y += hTit + 8;
|
||||||
|
parrafo(d.propuesta.redaccion.hero.subtitulo, 10, MUTED);
|
||||||
|
y += 8;
|
||||||
|
const meta = `${d.empresa || d.cliente} · ${d.numero} · ${formatDate(d.fecha)} · Vigencia ${formatDate(d.vigencia)} · ${d.asesor}`;
|
||||||
|
const hMeta = txtH(meta, W, 8);
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(meta, L, y, { width: W });
|
||||||
|
y += hMeta + 12;
|
||||||
|
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
y += 18;
|
||||||
|
|
||||||
|
// ── DIAGNOSTICO ──
|
||||||
|
titulo("Diagnostico");
|
||||||
|
const cita = d.propuesta.hechos.citas.find((c) => c.id === d.propuesta.redaccion.citaDestacadaId);
|
||||||
|
if (cita) {
|
||||||
|
const texto = `"${cita.textoLiteral}"`;
|
||||||
|
const h = doc.font("Helvetica-Oblique").fontSize(11).heightOfString(texto, { width: W - 22 });
|
||||||
|
y = need(h + 22, y);
|
||||||
|
doc.rect(L, y - 3, 2.5, h + 10).fill(PRIMARY);
|
||||||
|
doc.font("Helvetica-Oblique").fontSize(11).fillColor(DARK).text(texto, L + 12, y, { width: W - 22 });
|
||||||
|
y += h + 4;
|
||||||
|
doc.font("Helvetica").fontSize(7.5).fillColor(MUTED).text(`— ${cita.quienLoDijo}`, L + 12, y);
|
||||||
|
y += 18;
|
||||||
|
}
|
||||||
|
for (const hal of d.propuesta.diagnostico.hallazgos) {
|
||||||
|
const cuerpo = `${hal.titulo} — ${hal.descripcion}`;
|
||||||
|
const h = txtH(cuerpo, W - 24, 9);
|
||||||
|
y = need(h + 14, y);
|
||||||
|
doc.circle(L + 4.5, y + 4.5, 3.2).fill(COLOR_SEMAFORO[hal.urgencia] || MUTED);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(cuerpo, L + 16, y, { width: W - 24 });
|
||||||
|
y += h + 1;
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`${ETIQUETA_SEMAFORO[hal.urgencia] ?? ""} · ${ETIQUETA_CONFIANZA[hal.confianza] ?? ""}`, L + 16, y);
|
||||||
|
y += 13;
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
// ── VALOR DEL PROBLEMA ──
|
||||||
|
const dims = d.propuesta.diagnostico.valorProblema.dimensiones;
|
||||||
|
if (dims.length) {
|
||||||
|
titulo("Lo que cuesta no resolverlo");
|
||||||
|
for (const dim of dims) {
|
||||||
|
const monto = dim.calculo.montoAnualMXN;
|
||||||
|
const linea = `${dim.descripcion} — ${monto !== null ? `${formatCurrency(monto)} al ano` : "sin cuantificar"}`;
|
||||||
|
const h = txtH(linea, W - 14, 9);
|
||||||
|
y = need(h + 14, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(linea, L + 6, y, { width: W - 14 });
|
||||||
|
y += h + 1;
|
||||||
|
doc.font("Helvetica").fontSize(7).fillColor(MUTED).text(ETIQUETA_CONFIANZA[dim.confianza] ?? "", L + 6, y);
|
||||||
|
y += 13;
|
||||||
|
}
|
||||||
|
if (d.propuesta.diagnostico.valorProblema.notaMetodologia.trim()) {
|
||||||
|
y += 2;
|
||||||
|
parrafo(d.propuesta.diagnostico.valorProblema.notaMetodologia, 8, MUTED);
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RESULTADOS ──
|
||||||
|
if (d.propuesta.diagnostico.resultados.length) {
|
||||||
|
titulo("Resultados a lograr");
|
||||||
|
for (const r of d.propuesta.diagnostico.resultados) {
|
||||||
|
const t = `• ${r.enunciado} (${r.metrica}, ${r.periodoMedicion})`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ALCANCE: la IA pone la prosa, el codigo pone el dinero ──
|
||||||
|
titulo("Alcance de la inversion");
|
||||||
|
const porRef = new Map(d.economia.partidas.map((p) => [p.refPartida, p]));
|
||||||
|
for (const a of d.propuesta.redaccion.alcance) {
|
||||||
|
const p = porRef.get(a.refPartida);
|
||||||
|
if (!p) continue; // R2 ya lo marco como bloqueante; aqui simplemente no se dibuja
|
||||||
|
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
|
||||||
|
const hDesc = txtH(a.descripcionResultado, W * 0.68, 8);
|
||||||
|
y = need(hNombre + hDesc + 14, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.fillColor(PRIMARY)
|
||||||
|
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
||||||
|
y += hNombre + 2;
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(a.descripcionResultado, L, y, { width: W * 0.68 });
|
||||||
|
y += hDesc + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
y = need(58, y);
|
||||||
|
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
y += 10;
|
||||||
|
const t = d.economia.totales;
|
||||||
|
const filaTotal = (etiqueta: string, valor: string) => {
|
||||||
|
y = need(16, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(etiqueta, L, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(10).fillColor(PRIMARY).text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
|
||||||
|
y += 16;
|
||||||
|
};
|
||||||
|
if (t.subtotalUnico > 0) filaTotal("Pago unico", formatCurrency(t.totalUnico));
|
||||||
|
if (t.subtotalMensual > 0) filaTotal("Mensualidad", formatCurrency(t.totalMensual));
|
||||||
|
y = need(14, y);
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7.5)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(
|
||||||
|
t.incluyeIva
|
||||||
|
? "Importes con IVA incluido. Moneda nacional (MXN). Facturacion CFDI."
|
||||||
|
: "Importes sin IVA. Moneda nacional (MXN). Facturacion CFDI.",
|
||||||
|
L,
|
||||||
|
y,
|
||||||
|
{ width: W }
|
||||||
|
);
|
||||||
|
y += 22;
|
||||||
|
|
||||||
|
// ── BENEFICIOS ──
|
||||||
|
if (d.propuesta.redaccion.beneficios.length) {
|
||||||
|
titulo("Por que tiene sentido");
|
||||||
|
for (const b of d.propuesta.redaccion.beneficios) {
|
||||||
|
const hEt = txtH(b.etiqueta, W * 0.24, 9);
|
||||||
|
const hTx = txtH(b.texto, W * 0.72, 8.5);
|
||||||
|
const h = Math.max(hEt, hTx);
|
||||||
|
y = need(h + 9, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(b.etiqueta, L, y, { width: W * 0.24 });
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(b.texto, L + W * 0.27, y, { width: W * 0.72 });
|
||||||
|
y += h + 9;
|
||||||
|
}
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EXCLUSIONES ──
|
||||||
|
titulo("Que no incluye esta propuesta");
|
||||||
|
for (const e of d.propuesta.redaccion.exclusiones) {
|
||||||
|
const t2 = `✕ ${e.texto}`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
|
||||||
|
// ── PENDIENTES: el canal de honestidad del documento ──
|
||||||
|
const mats = d.propuesta.hechos.materialesPendientes;
|
||||||
|
const decs = d.propuesta.hechos.decisionesPendientes;
|
||||||
|
if (mats.length || decs.length) {
|
||||||
|
titulo("Que necesitamos de ustedes");
|
||||||
|
for (const mm of mats) {
|
||||||
|
const t2 = `○ ${mm.texto}${mm.bloqueaEntrega ? " (el avance queda condicionado a esto)" : ""}`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
for (const dd of decs) {
|
||||||
|
const t2 = `· ${dd.texto} (decide: ${dd.quienDecide})`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BACKLOG ──
|
||||||
|
if (d.propuesta.redaccion.backlogEvolucion.length) {
|
||||||
|
titulo("Registrado para mas adelante");
|
||||||
|
y = need(14, y);
|
||||||
|
doc.font("Helvetica-Oblique").fontSize(7.5).fillColor(MUTED).text("No comprometido en esta propuesta.", L, y);
|
||||||
|
y += 14;
|
||||||
|
for (const b of d.propuesta.redaccion.backlogEvolucion) {
|
||||||
|
const t2 = `· ${b.problema} (${b.momentoSugerido})`;
|
||||||
|
const h = txtH(t2, W - 8, 8.5);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(8.5).fillColor(DARK).text(t2, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FOOTERS ──
|
||||||
|
// margins.bottom = 0 temporal: sin eso, escribir en el margen inferior dispara el
|
||||||
|
// auto-page-break de pdfkit y se generan paginas vacias.
|
||||||
|
const savedBottom = doc.page.margins.bottom;
|
||||||
|
const rango = doc.bufferedPageRange();
|
||||||
|
for (let i = rango.start; i < rango.start + rango.count; i++) {
|
||||||
|
doc.switchToPage(i);
|
||||||
|
doc.page.margins.bottom = 0;
|
||||||
|
doc.moveTo(L, ph - 42).lineTo(L + W, ph - 42).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`${d.numero} · Propuesta consultiva · ${d.empresa || d.cliente}`, L, ph - 35, { lineBreak: false });
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(7)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`${i - rango.start + 1} / ${rango.count}`, L, ph - 35, { width: W, align: "right", lineBreak: false });
|
||||||
|
doc.page.margins.bottom = savedBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return listo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anexo interno. Archivo SEPARADO y visualmente inconfundible: el riesgo de que una
|
||||||
|
* seccion oculta llegue al cliente es demasiado alto para un display:none. */
|
||||||
|
export async function generarAnexoInternoPDF(d: DatosPropuestaPDF): Promise<Buffer> {
|
||||||
|
const { lienzo, listo } = crearLienzo();
|
||||||
|
const { doc, W, L, maxY, mTop } = lienzo;
|
||||||
|
let y = mTop;
|
||||||
|
const DARK = "#1e293b";
|
||||||
|
const MUTED = "#64748b";
|
||||||
|
const need = (h: number, yy: number) => (yy + h > maxY ? (doc.addPage(), mTop) : yy);
|
||||||
|
const txtH = (s: string, w: number, size: number) =>
|
||||||
|
doc.font("Helvetica").fontSize(size).heightOfString(s, { width: w });
|
||||||
|
|
||||||
|
doc.rect(0, 0, doc.page.width, 36).fill("#b91c1c");
|
||||||
|
doc.font("Helvetica-Bold").fontSize(13).fillColor("#ffffff").text("USO INTERNO — NO ENVIAR AL CLIENTE", L, 11);
|
||||||
|
y = 56;
|
||||||
|
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(`${d.numero} · ${d.empresa || d.cliente} · ${d.asesor}`, L, y);
|
||||||
|
y += 24;
|
||||||
|
|
||||||
|
const valorAnual =
|
||||||
|
d.propuesta.diagnostico.valorProblema.dimensiones.reduce((a, x) => a + (x.calculo.montoAnualMXN ?? 0), 0) || null;
|
||||||
|
const r = calcularRatio(d.economia.totales.totalPrimerAnio, valorAnual);
|
||||||
|
|
||||||
|
const seccion = (t: string) => {
|
||||||
|
y = need(24, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(11).fillColor(DARK).text(t, L, y);
|
||||||
|
y += 18;
|
||||||
|
};
|
||||||
|
const linea = (k: string, v: string) => {
|
||||||
|
y = need(15, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(k, L, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(v, L + W * 0.5, y, { width: W * 0.5, align: "right" });
|
||||||
|
y += 15;
|
||||||
|
};
|
||||||
|
|
||||||
|
seccion("Ponderacion de la cotizacion");
|
||||||
|
linea("Inversion del primer ano", formatCurrency(d.economia.totales.totalPrimerAnio));
|
||||||
|
linea("Valor anual del problema", valorAnual ? formatCurrency(valorAnual) : "sin cuantificar");
|
||||||
|
linea("Ratio precio / valor", r ? `${(r.ratio * 100).toFixed(1)}% — ${r.lectura.replace(/_/g, " ")}` : "no calculable");
|
||||||
|
linea("Hallazgos en el diagnostico", String(d.propuesta.diagnostico.hallazgos.length));
|
||||||
|
linea("Cifras confirmadas", String(d.propuesta.diagnostico.hallazgos.filter((h) => h.confianza === "confirmado").length));
|
||||||
|
linea("Exclusiones definidas", String(d.propuesta.redaccion.exclusiones.length));
|
||||||
|
linea("Materiales pendientes", String(d.propuesta.hechos.materialesPendientes.length));
|
||||||
|
linea("Decisiones pendientes", String(d.propuesta.hechos.decisionesPendientes.length));
|
||||||
|
linea("Red flags detectadas", String(d.propuesta.hechos.redFlags.length));
|
||||||
|
y += 6;
|
||||||
|
|
||||||
|
if (r) {
|
||||||
|
const nota =
|
||||||
|
r.lectura === "subcotizado"
|
||||||
|
? "La inversion es menos del 10% de lo que el problema le cuesta al cliente cada ano. Probablemente subcotizaste."
|
||||||
|
: r.lectura === "objecion_probable"
|
||||||
|
? "La inversion supera el 40% del valor anual del problema. Prepara la conversacion de precio: se responde con alcance, no con descuento."
|
||||||
|
: r.lectura === "alto"
|
||||||
|
? "La inversion esta en la banda alta. Justificable, pero conviene anclar bien el valor antes de dar el numero."
|
||||||
|
: "La inversion cae en el rango de referencia (15-25% del valor anual).";
|
||||||
|
const h = txtH(nota, W, 8.5);
|
||||||
|
y = need(h + 12, y);
|
||||||
|
doc.font("Helvetica-Oblique").fontSize(8.5).fillColor(MUTED).text(nota, L, y, { width: W });
|
||||||
|
y += h + 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.propuesta.hechos.redFlags.length) {
|
||||||
|
seccion("Red flags");
|
||||||
|
for (const rf of d.propuesta.hechos.redFlags) {
|
||||||
|
const t = `· [${rf.severidad}] ${rf.senal}`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.propuesta.redaccion.notasInternas.length) {
|
||||||
|
seccion("Notas para el asesor");
|
||||||
|
for (const n of d.propuesta.redaccion.notasInternas) {
|
||||||
|
const t = `· ${n}`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.propuesta.hechos.mencionesFueraDeAlcance.length) {
|
||||||
|
seccion("Se menciono y quedo fuera");
|
||||||
|
for (const m of d.propuesta.hechos.mencionesFueraDeAlcance) {
|
||||||
|
const t = `· ${m}`;
|
||||||
|
const h = txtH(t, W - 8, 9);
|
||||||
|
y = need(h + 6, y);
|
||||||
|
doc.font("Helvetica").fontSize(9).fillColor(DARK).text(t, L + 4, y, { width: W - 8 });
|
||||||
|
y += h + 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return listo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { llamarConHerramienta, modelo, type UsoTokens, type BloqueSystem } from "./cliente-ia";
|
||||||
|
import {
|
||||||
|
hechosSchema,
|
||||||
|
diagnosticoSchema,
|
||||||
|
redaccionSchema,
|
||||||
|
type Hechos,
|
||||||
|
type Diagnostico,
|
||||||
|
type Redaccion,
|
||||||
|
type PropuestaConsultiva,
|
||||||
|
} from "./schemas";
|
||||||
|
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "./prompts";
|
||||||
|
import type { DatosEconomicos } from "./economia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline de tres pasos.
|
||||||
|
*
|
||||||
|
* Se separa en tres porque un solo prompt monolitico produce un documento mediocre en
|
||||||
|
* todas sus partes: la extraccion determina la calidad de todo lo demas y merece su
|
||||||
|
* propia pasada. Ademas permite corregir los hechos antes de que se propaguen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Traza {
|
||||||
|
modelo: string;
|
||||||
|
pasos: { paso: string; intentos: number; uso: UsoTokens }[];
|
||||||
|
totalUso: UsoTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un solo breakpoint de cache, al final de la capa estable. El pipeline hace tres
|
||||||
|
// llamadas seguidas con el mismo prefijo, asi que el TTL de 5 minutos de MiniMax
|
||||||
|
// alcanza de sobra y las llamadas 2 y 3 deberian leer del cache.
|
||||||
|
const SYSTEM: BloqueSystem[] = [{ texto: SYSTEM_BASE, cachear: true }];
|
||||||
|
|
||||||
|
export async function generarPropuesta(opts: {
|
||||||
|
entrada: ContextoEntrada;
|
||||||
|
economia: DatosEconomicos;
|
||||||
|
}): Promise<{ propuesta: PropuestaConsultiva; traza: Traza }> {
|
||||||
|
const pasos: Traza["pasos"] = [];
|
||||||
|
const total: UsoTokens = { entrada: 0, salida: 0, cacheLectura: 0, cacheEscritura: 0 };
|
||||||
|
const acumular = (u: UsoTokens) => {
|
||||||
|
total.entrada += u.entrada;
|
||||||
|
total.salida += u.salida;
|
||||||
|
total.cacheLectura += u.cacheLectura;
|
||||||
|
total.cacheEscritura += u.cacheEscritura;
|
||||||
|
};
|
||||||
|
|
||||||
|
const r1 = await llamarConHerramienta<Hechos>({
|
||||||
|
system: SYSTEM,
|
||||||
|
mensajeUsuario: mensajePaso1(opts.entrada),
|
||||||
|
herramienta: {
|
||||||
|
nombre: "registrar_hechos",
|
||||||
|
descripcion:
|
||||||
|
"Registra las citas literales, los hechos, los pendientes y las red flags extraidos del material de la reunion.",
|
||||||
|
schema: hechosSchema,
|
||||||
|
},
|
||||||
|
maxIntentos: 3,
|
||||||
|
maxTokens: 8000,
|
||||||
|
});
|
||||||
|
pasos.push({ paso: "extraccion", intentos: r1.intentos, uso: r1.uso });
|
||||||
|
acumular(r1.uso);
|
||||||
|
|
||||||
|
const r2 = await llamarConHerramienta<Diagnostico>({
|
||||||
|
system: SYSTEM,
|
||||||
|
mensajeUsuario: mensajePaso2(opts.entrada, r1.datos, opts.economia),
|
||||||
|
herramienta: {
|
||||||
|
nombre: "registrar_diagnostico",
|
||||||
|
descripcion:
|
||||||
|
"Registra los hallazgos con su semaforo de urgencia, el valor anual del problema y los resultados de negocio a lograr.",
|
||||||
|
schema: diagnosticoSchema,
|
||||||
|
},
|
||||||
|
maxIntentos: 3,
|
||||||
|
maxTokens: 8000,
|
||||||
|
});
|
||||||
|
pasos.push({ paso: "diagnostico", intentos: r2.intentos, uso: r2.uso });
|
||||||
|
acumular(r2.uso);
|
||||||
|
|
||||||
|
// El paso 3 recibe un intento extra: es el mas largo y el que atraviesa mas
|
||||||
|
// compuertas de validacion, asi que es donde mas probable es agotar el presupuesto.
|
||||||
|
const r3 = await llamarConHerramienta<Redaccion>({
|
||||||
|
system: SYSTEM,
|
||||||
|
mensajeUsuario: mensajePaso3(opts.entrada, r1.datos, r2.datos, opts.economia),
|
||||||
|
herramienta: {
|
||||||
|
nombre: "registrar_propuesta",
|
||||||
|
descripcion: "Registra la redaccion final de la propuesta consultiva para el cliente.",
|
||||||
|
schema: redaccionSchema,
|
||||||
|
},
|
||||||
|
maxIntentos: 4,
|
||||||
|
maxTokens: 12000,
|
||||||
|
});
|
||||||
|
pasos.push({ paso: "redaccion", intentos: r3.intentos, uso: r3.uso });
|
||||||
|
acumular(r3.uso);
|
||||||
|
|
||||||
|
return {
|
||||||
|
propuesta: { hechos: r1.datos, diagnostico: r2.datos, redaccion: r3.datos },
|
||||||
|
traza: { modelo: modelo(), pasos, totalUso: total },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user