Calcula el presupuesto en el flujo: el PDF ya incluye el importe

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-10 14:12:05 +02:00
parent b0871b733c
commit dabdf32b8e
3 changed files with 74 additions and 1 deletions

View File

@@ -2,11 +2,13 @@ import { asc, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { leads, conversacionWhatsapp, leadPipelineEventos } from '@/db/schema'; import { leads, conversacionWhatsapp, leadPipelineEventos } from '@/db/schema';
import { chatJSON, openrouterConfigurado } from '@/lib/ai/openrouter'; import { chatJSON, openrouterConfigurado } from '@/lib/ai/openrouter';
import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto';
export interface AnalisisResultado { export interface AnalisisResultado {
ok: boolean; ok: boolean;
perfil?: Record<string, unknown>; perfil?: Record<string, unknown>;
turnos?: number; turnos?: number;
presupuesto?: number;
error?: string; error?: string;
} }
@@ -84,7 +86,11 @@ export async function analizarTranscripcion(
metadata: { origen: `analisis_${origen}`, campos: Object.keys(set) }, 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. // Entrada para WhatsApp: arma la transcripción desde conversacion_whatsapp y delega en el núcleo.

View File

@@ -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<CalculoResultado> {
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 };
}

View File

@@ -6,6 +6,7 @@ import { enviarPresupuestoEmail } from '@/lib/email/mailer';
import { notificarFlujoWhatsapp } from '@/lib/webhooks'; import { notificarFlujoWhatsapp } from '@/lib/webhooks';
import { resolveTheme } from '@/lib/funnel/themes'; import { resolveTheme } from '@/lib/funnel/themes';
import { normalizarTelefonoEs } from '@/lib/voice/retell'; import { normalizarTelefonoEs } from '@/lib/voice/retell';
import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto';
export type ResultadoFinalizar = { export type ResultadoFinalizar = {
ok: boolean; 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 // 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. // intermedio del funnel). Best-effort en email/WhatsApp: el lead avanza igualmente.
export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinalizar> { export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinalizar> {
// 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); const pdf = await construirPresupuestoPdf(leadId);
if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false }; if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false };