diff --git a/cazar-tmp.ts b/cazar-tmp.ts deleted file mode 100644 index 21a6d6d..0000000 --- a/cazar-tmp.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Caza del error intermitente: corre el pipeline real contra UJ2606UR001 hasta que - * falle, e imprime TODO lo que sepamos de la peticion que lo provoco. - */ -import { cargarEconomia } from "@/lib/propuesta/economia"; -import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia"; -import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema, type Hechos, type Diagnostico } from "@/lib/propuesta/schemas"; -import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "@/lib/propuesta/prompts"; -import { prisma } from "@/lib/db"; -import type Anthropic from "@anthropic-ai/sdk"; -import type { z } from "zod"; -import fs from "node:fs"; - -const LOG = process.argv[2]; -// Escritura directa: el buffer de stdout se pierde si el proceso muere. -function log(...a: unknown[]) { - const linea = a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" "); - fs.appendFileSync(LOG, linea + "\n"); -} -const console = { log } as unknown as Console; - -const NUMERO = "UJ2606UR001"; -const TRANSCRIPCION = "Una propuesta de zero to hero para un negocio inicial"; - -/** Igual que llamarConHerramienta pero registra cada peticion y explota con detalle. */ -async function correrPaso(nombre: string, msg: string, schema: z.ZodType, maxTokens: number, maxIntentos: number) { - const cliente = crearCliente(); - const tools = [{ name: nombre, description: "Registra el resultado.", input_schema: aJsonSchema(schema) as never }]; - const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: msg }]; - - for (let intento = 1; intento <= maxIntentos; intento++) { - let res: Anthropic.Message; - try { - res = await cliente.messages.create({ - model: modelo(), max_tokens: maxTokens, - system: [{ type: "text", text: SYSTEM_BASE, cache_control: { type: "ephemeral" } }], - tools, messages: mensajes, - }); - } catch (e) { - const err = e as { status?: number; message?: string; error?: unknown }; - console.log(`\n*** ERROR EN ${nombre} intento ${intento} ***`); - console.log(" status:", err.status); - console.log(" message:", String(err.message).slice(0, 600)); - if (err.error) console.log(" error:", JSON.stringify(err.error).slice(0, 800)); - console.log("\n --- FORMA DE LOS MENSAJES ENVIADOS ---"); - mensajes.forEach((m, i) => { - const c = m.content; - const tipos = typeof c === "string" ? "string" : (c as { type: string }[]).map((b) => b.type).join(","); - console.log(` [${i}] role=${m.role} bloques=${tipos}`); - }); - throw new Error("REPRODUCIDO"); - } - - const bloques = res.content.map((b) => b.type).join(","); - console.log(` ${nombre} intento ${intento}: stop=${res.stop_reason} bloques=[${bloques}] out=${res.usage.output_tokens}`); - - const tus = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use"); - const ok = tus.find((b) => b.name === nombre); - if (ok) { - const parsed = schema.safeParse(ok.input); - if (parsed.success) return parsed.data; - const errTxt = (await import("zod")).z.prettifyError(parsed.error); - console.log(` zod rechazo: ${errTxt.split("\n")[0]}`); - mensajes.push( - { role: "assistant", content: res.content }, - { role: "user", content: [ - ...tus.map((b) => ({ type: "tool_result" as const, tool_use_id: b.id, is_error: true, content: errTxt })), - { type: "text" as const, text: "Corrige y reintenta." }, - ] }, - ); - continue; - } - mensajes.push( - { role: "assistant", content: res.content }, - { role: "user", content: `Debes llamar a ${nombre}.` }, - ); - } - return null; -} - -async function unaVuelta(entrada: ContextoEntrada, econ: Awaited>, n: number) { - console.log(`\n===== VUELTA ${n} =====`); - const h = (await correrPaso("registrar_hechos", mensajePaso1(entrada), hechosSchema, 8000, 5)) as Hechos | null; - if (!h) { console.log(" extraccion agoto intentos"); return; } - const d = (await correrPaso("registrar_diagnostico", mensajePaso2(entrada, h, econ), diagnosticoSchema, 8000, 4)) as Diagnostico | null; - if (!d) { console.log(" diagnostico agoto intentos"); return; } - const r = await correrPaso("registrar_propuesta", mensajePaso3(entrada, h, d, econ), redaccionSchema, 12000, 4); - console.log(r ? " vuelta completa OK" : " redaccion agoto intentos"); -} - -async function main() { - const cot = await prisma.cotizacion.findFirst({ where: { numero: NUMERO }, include: { cliente: true, servicios: true } }); - if (!cot) throw new Error("no encontrada"); - const econ = await cargarEconomia(cot.id); - const entrada: ContextoEntrada = { - transcripcion: TRANSCRIPCION, - notas: cot.servicios.map((s) => s.notas).filter(Boolean).join("\n"), - observaciones: cot.observaciones || "", - cliente: cot.cliente.nombre, empresa: cot.cliente.empresa || "", proyecto: cot.proyecto, - }; - console.log(`${econ.partidas.length} partidas`); - - for (let i = 1; i <= 3; i++) { - try { await unaVuelta(entrada, econ, i); } - catch (e) { - if (e instanceof Error && e.message === "REPRODUCIDO") { console.log("\n>>> error reproducido, deteniendo"); break; } - throw e; - } - } - await prisma.$disconnect(); -} - -main().catch((e) => { console.error("fallo:", e); process.exit(1); }); diff --git a/scripts/verificar-propuesta-pdf.ts b/scripts/verificar-propuesta-pdf.ts index 0fce823..dcc48f4 100644 --- a/scripts/verificar-propuesta-pdf.ts +++ b/scripts/verificar-propuesta-pdf.ts @@ -55,6 +55,7 @@ const econ: DatosEconomicos = { ], totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true }, moneda: "MXN", + planBucefalo: null, }; const propuesta: PropuestaConsultiva = { @@ -127,15 +128,29 @@ async function main() { 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")); + // 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 la nota cambia. + // Sin IVA no debe aparecer el renglon de IVA. const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } }); - check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA")); + 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({ diff --git a/scripts/verificar-propuesta-prompts.ts b/scripts/verificar-propuesta-prompts.ts index 9dbcef5..159cacd 100644 --- a/scripts/verificar-propuesta-prompts.ts +++ b/scripts/verificar-propuesta-prompts.ts @@ -49,6 +49,7 @@ const econ: DatosEconomicos = { partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }], totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true }, moneda: "MXN", + planBucefalo: null, }; const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" }; const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos; diff --git a/scripts/verificar-propuesta-validacion.ts b/scripts/verificar-propuesta-validacion.ts index 8876e1e..19d42af 100644 --- a/scripts/verificar-propuesta-validacion.ts +++ b/scripts/verificar-propuesta-validacion.ts @@ -15,6 +15,7 @@ const econ: DatosEconomicos = { partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }], totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true }, moneda: "MXN", + planBucefalo: null, }; const base = (): PropuestaConsultiva => ({ diff --git a/src/app/api/propuesta-ia/[id]/pdf/route.ts b/src/app/api/propuesta-ia/[id]/pdf/route.ts index f4d79ba..4d0e743 100644 --- a/src/app/api/propuesta-ia/[id]/pdf/route.ts +++ b/src/app/api/propuesta-ia/[id]/pdf/route.ts @@ -4,6 +4,7 @@ import { getConfigBranding } from "@/lib/config-helpers"; import { sanitizeFilename } from "@/lib/calculators"; import { cargarEconomia } from "@/lib/propuesta/economia"; import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf"; +import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion"; import type { PropuestaConsultiva } from "@/lib/propuesta/schemas"; /** GET /api/propuesta-ia/:id/pdf -> documento del cliente @@ -22,6 +23,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 }); if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 }); + // Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente. + // Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era + // decorativa y el PDF se descargaba igual con una fuga de notas internas o un + // monto en USD dentro. El anexo interno SI se permite: es justamente el que el + // asesor necesita para entender que hay que corregir. + const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : []; + if (!anexo && hayBloqueantes(avisos)) { + const cuales = avisos + .filter((a) => a.severidad === "bloqueante") + .map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`); + return NextResponse.json( + { + error: + "La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.", + bloqueantes: cuales, + }, + { status: 409 } + ); + } + const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva; const economia = await cargarEconomia(id); diff --git a/src/lib/propuesta/economia.ts b/src/lib/propuesta/economia.ts index 44afaab..deb94a9 100644 --- a/src/lib/propuesta/economia.ts +++ b/src/lib/propuesta/economia.ts @@ -31,6 +31,10 @@ export interface DatosEconomicos { partidas: PartidaCanonica[]; totales: TotalesCotizacion; moneda: string; + /** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF + * economico y en el Excel; tiene que aparecer tambien en el consultivo o el + * cliente recibe dos documentos con alcances distintos. */ + planBucefalo: { nivel: string; precio: number } | null; } export async function cargarEconomia(cotizacionId: string): Promise { @@ -43,6 +47,7 @@ export async function cargarEconomia(cotizacionId: string): Promise } } + // El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el + // cliente recibe un documento consultivo con menos alcance que su cotizacion. + if (d.economia.planBucefalo) { + const nivel = d.economia.planBucefalo.nivel; + const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`; + const hE = txtH(etiqueta, W * 0.68, 9.5); + y = need(hE + 22, y); + doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 }); + doc + .font("Helvetica-Bold") + .fontSize(9.5) + .fillColor(PRIMARY) + .text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" }); + y += hE + 2; + doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 }); + y += 20; + } + y = need(58, y); doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke(); y += 10; + // ── CAJA DE TOTALES ── + // Desglose completo (subtotal / IVA / total), como la plantilla autorizada. + // + // Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON + // IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban + // el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va + // en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras. + // Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA + // aparece como renglon propio. 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; + const fila = (etiqueta: string, valor: string, fuerte = false) => { + y = need(fuerte ? 18 : 15, y); + doc + .font(fuerte ? "Helvetica-Bold" : "Helvetica") + .fontSize(fuerte ? 10.5 : 9) + .fillColor(fuerte ? DARK : MUTED) + .text(etiqueta, L, y); + doc + .font("Helvetica-Bold") + .fontSize(fuerte ? 10.5 : 9) + .fillColor(fuerte ? PRIMARY : DARK) + .text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" }); + y += fuerte ? 18 : 15; }; - if (t.subtotalUnico > 0) filaTotal("Pago unico", formatCurrency(t.totalUnico)); - if (t.subtotalMensual > 0) filaTotal("Mensualidad", formatCurrency(t.totalMensual)); - y = need(14, y); + + if (t.subtotalUnico > 0) { + fila("Subtotal pago unico", formatCurrency(t.subtotalUnico)); + if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico)); + fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true); + } + if (t.subtotalMensual > 0) { + if (t.subtotalUnico > 0) y += 4; + fila("Subtotal mensual", formatCurrency(t.subtotalMensual)); + if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual)); + fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true); + } + + y = need(16, 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.", + ? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI." + : "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.", L, y, { width: W } ); - y += 22; + y += 24; // ── BENEFICIOS ── if (d.propuesta.redaccion.beneficios.length) { diff --git a/src/lib/propuesta/validacion.ts b/src/lib/propuesta/validacion.ts index 176446f..c02767d 100644 --- a/src/lib/propuesta/validacion.ts +++ b/src/lib/propuesta/validacion.ts @@ -35,13 +35,29 @@ const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(vent const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i; const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i; -/** Todo el texto que termina impreso en el documento del cliente. */ -function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] { +/** + * TODO el texto que termina impreso en el documento del cliente. + * + * Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la + * unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se + * imprime y no esta aqui llega al cliente sin que nadie lo mire. + * + * Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica + * y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido + * del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio. + */ +export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] { const out: { ruta: string; texto: string }[] = [ { ruta: "hero.titulo", texto: p.redaccion.hero.titulo }, { ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo }, { ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia }, ]; + // La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico). + const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId); + if (destacada) { + out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral }); + out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo }); + } p.diagnostico.hallazgos.forEach((h, i) => { out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo }); out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion }); @@ -49,15 +65,25 @@ function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] p.diagnostico.valorProblema.dimensiones.forEach((d, i) => out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion }) ); - p.diagnostico.resultados.forEach((r, i) => out.push({ ruta: `resultados[${i}]`, texto: r.enunciado })); + p.diagnostico.resultados.forEach((r, i) => { + out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado }); + out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica }); + out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion }); + }); p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado })); p.redaccion.beneficios.forEach((b, i) => out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` }) ); p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto })); - p.redaccion.backlogEvolucion.forEach((b, i) => out.push({ ruta: `backlog[${i}]`, texto: b.problema })); + p.redaccion.backlogEvolucion.forEach((b, i) => { + out.push({ ruta: `backlog[${i}].problema`, texto: b.problema }); + out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido }); + }); p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto })); - p.hechos.decisionesPendientes.forEach((d, i) => out.push({ ruta: `decisiones[${i}]`, texto: d.texto })); + p.hechos.decisionesPendientes.forEach((d, i) => { + out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto }); + out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide }); + }); return out; }