diff --git a/scripts/verificar-propuesta-pdf.ts b/scripts/verificar-propuesta-pdf.ts index c527b7d..0fce823 100644 --- a/scripts/verificar-propuesta-pdf.ts +++ b/scripts/verificar-propuesta-pdf.ts @@ -144,6 +144,17 @@ async function main() { }); 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); diff --git a/src/lib/propuesta/pdf.ts b/src/lib/propuesta/pdf.ts index 2257e0e..996a0d7 100644 --- a/src/lib/propuesta/pdf.ts +++ b/src/lib/propuesta/pdf.ts @@ -59,6 +59,16 @@ function hexSeguro(v: string | undefined, porDefecto: string): string { return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto; } +/** Respaldo cuando la IA no describio una partida: se arma con los datos del + * catalogo, para que la partida nunca desaparezca del documento. */ +function detalleDePartida(p: { tiempoEntrega: string; entregables: string[]; tipoPago: string }): string { + const trozos: string[] = []; + if (p.entregables.length) trozos.push(p.entregables.slice(0, 4).join(" · ")); + if (p.tiempoEntrega) trozos.push(`Entrega: ${p.tiempoEntrega}`); + if (p.tipoPago === "mensual") trozos.push("Servicio mensual"); + return trozos.join(" | "); +} + interface Lienzo { doc: PDFKit.PDFDocument; W: number; @@ -206,14 +216,18 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise y += 8; } - // ── ALCANCE: la IA pone la prosa, el codigo pone el dinero ── + // ── ALCANCE ── + // Se recorren las partidas de la COTIZACION, no las que la IA alcanzo a describir. + // Motivo: el schema acota `alcance` y una cotizacion con mas partidas que ese tope + // dejaria servicios cotizados fuera del documento del cliente, en silencio. + // Verificado con UJ2606UR001: 58 partidas, la IA describio 40, faltaban 18. + // La completitud la manda la base de datos; la IA solo aporta la prosa. 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 descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado])); + for (const p of d.economia.partidas) { + const descripcion = descPorRef.get(p.refPartida); const hNombre = txtH(p.nombre, W * 0.68, 9.5); - const hDesc = txtH(a.descripcionResultado, W * 0.68, 8); + const hDesc = descripcion ? txtH(descripcion, W * 0.68, 8) : 0; 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 @@ -222,8 +236,20 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise .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; + if (descripcion) { + doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 }); + y += hDesc + 10; + } else { + // Sin prosa de la IA: la partida SI aparece, con su detalle del catalogo. + const respaldo = detalleDePartida(p); + if (respaldo) { + const hR = txtH(respaldo, W * 0.68, 8); + doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(respaldo, L, y, { width: W * 0.68 }); + y += hR + 10; + } else { + y += 8; + } + } } y = need(58, y);