From dabdf32b8e41c2e3951dec73043483e99afcbe93 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 10 Jun 2026 14:12:05 +0200 Subject: [PATCH] Calcula el presupuesto en el flujo: el PDF ya incluye el importe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El PDF llegaba con render+fotos pero sin importe porque nadie ejecutaba el motor. Nuevo helper calcularPresupuestoLead (reutiliza computeBudget + catálogo del orquestador). Se llama tras el post-análisis (WhatsApp/llamada) y, como red de seguridad, al inicio de finalizarYEntregar antes de construir el PDF. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/funnel/analizar-conversacion.ts | 8 ++- .../src/lib/funnel/calcular-presupuesto.ts | 62 +++++++++++++++++++ mvp/b2c/src/lib/funnel/finalizar.ts | 5 ++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 mvp/b2c/src/lib/funnel/calcular-presupuesto.ts diff --git a/mvp/b2c/src/lib/funnel/analizar-conversacion.ts b/mvp/b2c/src/lib/funnel/analizar-conversacion.ts index 60ce63f..f1ae7e3 100644 --- a/mvp/b2c/src/lib/funnel/analizar-conversacion.ts +++ b/mvp/b2c/src/lib/funnel/analizar-conversacion.ts @@ -2,11 +2,13 @@ import { asc, eq } from 'drizzle-orm'; import { db } from '@/db'; import { leads, conversacionWhatsapp, leadPipelineEventos } from '@/db/schema'; import { chatJSON, openrouterConfigurado } from '@/lib/ai/openrouter'; +import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto'; export interface AnalisisResultado { ok: boolean; perfil?: Record; turnos?: number; + presupuesto?: number; error?: string; } @@ -84,7 +86,11 @@ export async function analizarTranscripcion( metadata: { origen: `analisis_${origen}`, campos: Object.keys(set) }, }); - return { ok: true, perfil: set }; + // Con los datos capturados, calcula ya el presupuesto orientativo (para que el PDF y el panel lo + // muestren). Best-effort: si faltan tipoReforma/m2 no se calcula, pero el resto sí queda guardado. + const presupuesto = await calcularPresupuestoLead(leadId); + + return { ok: true, perfil: set, presupuesto: presupuesto.ok ? presupuesto.total : undefined }; } // Entrada para WhatsApp: arma la transcripción desde conversacion_whatsapp y delega en el núcleo. diff --git a/mvp/b2c/src/lib/funnel/calcular-presupuesto.ts b/mvp/b2c/src/lib/funnel/calcular-presupuesto.ts new file mode 100644 index 0000000..a029132 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/calcular-presupuesto.ts @@ -0,0 +1,62 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { leads } from '@/db/schema'; +import { getPricingConfigFor, getCatalogFor } from '@/db/pricing-queries'; +import { computeBudget } from '@/budget'; +import { deterministicExtractor } from '@/lib/voice/extractor'; +import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply'; +import type { RawCallData } from '@/lib/voice/preferences'; + +export interface CalculoResultado { + ok: boolean; + total?: number; + error?: string; +} + +// Calcula el presupuesto orientativo del lead con su catálogo (mismo motor que el orquestador) y lo +// persiste en presupuestoEstimado + desgloseSnapshot. Reutilizable desde el post-análisis (WhatsApp/ +// llamada) y desde finalizar (antes de construir el PDF). Requiere al menos tipoReforma + m2Suelo. +export async function calcularPresupuestoLead(leadId: string): Promise { + const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); + if (!lead) return { ok: false, error: 'Lead no encontrado.' }; + if (!lead.tipoReforma || !lead.m2Suelo) { + return { ok: false, error: 'Faltan tipoReforma o m2Suelo para calcular el presupuesto.' }; + } + + const [config, catalog] = await Promise.all([ + getPricingConfigFor(lead.tenantId), + getCatalogFor(lead.tenantId), + ]); + + const raw: RawCallData = { + tipoReforma: lead.tipoReforma, + m2Suelo: lead.m2Suelo ?? null, + calidad: lead.calidadGlobal ?? null, + estructural: lead.estructural, + urgencia: lead.urgencia ?? null, + presupuestoTarget: lead.presupuestoTarget ?? null, + tasteText: lead.tasteText ?? '', + }; + const prefs = deterministicExtractor.extract(raw, catalog); + const inputs = mergeIntoBudgetInputs(prefs, { + tipoReforma: lead.tipoReforma, + m2Suelo: lead.m2Suelo ?? null, + alturaTecho: lead.alturaTecho ?? null, + provincia: lead.provincia ?? null, + anteriorA2000: lead.anteriorA2000, + cambioDistribucion: lead.cambioDistribucion, + }); + const result = applyPreferences(computeBudget(inputs, config, catalog), prefs); + + await db + .update(leads) + .set({ + presupuestoEstimado: result.total, + desgloseSnapshot: { stage: 'presupuesto_generado', result }, + preferencesSnapshot: prefs, + updatedAt: new Date(), + }) + .where(eq(leads.id, leadId)); + + return { ok: true, total: result.total }; +} diff --git a/mvp/b2c/src/lib/funnel/finalizar.ts b/mvp/b2c/src/lib/funnel/finalizar.ts index ccb7fd4..d1cb867 100644 --- a/mvp/b2c/src/lib/funnel/finalizar.ts +++ b/mvp/b2c/src/lib/funnel/finalizar.ts @@ -6,6 +6,7 @@ import { enviarPresupuestoEmail } from '@/lib/email/mailer'; import { notificarFlujoWhatsapp } from '@/lib/webhooks'; import { resolveTheme } from '@/lib/funnel/themes'; import { normalizarTelefonoEs } from '@/lib/voice/retell'; +import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto'; export type ResultadoFinalizar = { ok: boolean; @@ -18,6 +19,10 @@ export type ResultadoFinalizar = { // flujo externo. Entrega real (la rama simulada de orchestrator.ts:Paso 7 es solo el estado // intermedio del funnel). Best-effort en email/WhatsApp: el lead avanza igualmente. export async function finalizarYEntregar(leadId: string): Promise { + // Asegura el presupuesto orientativo ANTES de construir el PDF (si los datos lo permiten), para + // que el documento incluya la cifra. Best-effort: sin tipoReforma/m2 el PDF sale sin importe. + await calcularPresupuestoLead(leadId); + const pdf = await construirPresupuestoPdf(leadId); if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false };