import { formatCurrency, formatPayback, formatPercent } from '@/lib/format' import type { ActivitySimResult, Process, Activity } from '@/domain/types' import type { RoiResult } from '@/domain/roi' import { PDF, type DocLike } from './pdf-sections' const ROI_HIGH_THRESHOLD = 1000 // ─── Resumen ejecutivo ──────────────────────────────────────────────────────── export function drawRoiExecutiveSummary( doc: DocLike, process: Process, roi: RoiResult, currency: string, startY: number ): number { let y = startY doc.setFont('helvetica', 'bold') doc.setFontSize(12) doc.setTextColor(...PDF.slate900) doc.text('Análisis de retorno de inversión', PDF.margin, y) y += 6 doc.setDrawColor(...PDF.slate200) doc.setLineWidth(0.2) doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(9) doc.setTextColor(...PDF.slate700) const investmentStr = process.automationInvestment > 0 ? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}` : 'inversión aún no configurada' const summary = `Análisis de retorno de inversión para automatización del proceso "${process.name}"` + (process.clientName ? ` del cliente ${process.clientName}` : '') + `, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` + ` horizonte de ${process.analysisHorizonYears} años e ${investmentStr}.` const summaryLines = doc.splitTextToSize(summary, PDF.contentW) doc.text(summaryLines, PDF.margin, y) y += summaryLines.length * 4.5 + 3 // Sub-resumen de resultado const resultText = roi.savingsPerExecution > 0 ? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución (${formatPercent(roi.savingsPerExecutionPercent)} de reducción). ` + (Number.isFinite(roi.paybackMonths) ? roi.breaksEvenInHorizon ? `La inversión se recupera en ${roi.paybackMonths < 24 ? `${roi.paybackMonths.toFixed(1)} meses` : `${roi.paybackYears.toFixed(1)} años`}, dentro del horizonte de análisis.` : `El payback ocurre en ${roi.paybackYears.toFixed(1)} años, fuera del horizonte analizado.` : 'Sin ahorro neto — la automatización no se recupera con los datos actuales.') : 'El escenario automatizado no genera ahorro con los datos actuales. Revisá los costos configurados.' const resultLines = doc.splitTextToSize(resultText, PDF.contentW) doc.setFont('helvetica', 'italic') doc.setFontSize(8.5) doc.setTextColor(...PDF.slate500) doc.text(resultLines, PDF.margin, y) y += resultLines.length * 4 + 4 doc.setFont('helvetica', 'normal') doc.setTextColor(...PDF.slate700) return y } // ─── 5 KPI cards (grid 2×3 — 5 celdas, 6ta vacía o con meta-info) ──────────── export function drawRoiKpiCards( doc: DocLike, roi: RoiResult, currency: string, horizonYears: number, startY: number ): number { const cardW = 52, cardH = 28, gapX = 7, gapY = 5 const col1 = PDF.margin, col2 = PDF.margin + cardW + gapX, col3 = PDF.margin + (cardW + gapX) * 2 const paybackStr = formatPayback(roi.paybackMonths) const roiStr = !Number.isFinite(roi.roiAnnualPercent) ? '∞' : `${roi.roiAnnualPercent.toFixed(0)}%` const kpis = [ // Fila 1: 3 cards { label: 'AHORRO POR EJECUCIÓN', value: formatCurrency(roi.savingsPerExecution, currency), sub: `${formatPercent(roi.savingsPerExecutionPercent)} vs. actual`, positive: roi.savingsPerExecution >= 0 }, { label: 'AHORRO ANUAL', value: formatCurrency(roi.annualSavings, currency), sub: `× ${horizonYears} años`, positive: roi.annualSavings >= 0 }, { label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon }, // Fila 2: 2 cards + 1 vacía { label: `ACUMULADO (${horizonYears} AÑOS)`, value: formatCurrency(roi.cumulativeSavingsHorizon, currency), sub: 'neto de inversión', positive: roi.cumulativeSavingsHorizon >= 0 }, { label: 'ROI ANUAL', value: roiStr, sub: 'retorno sobre la inversión', positive: roi.roiAnnualPercent >= 0 }, ] const cols = [col1, col2, col3] let maxY = startY kpis.forEach((kpi, idx) => { const row = Math.floor(idx / 3) const col = idx % 3 const x = cols[col] const y = startY + row * (cardH + gapY) // Fondo doc.setFillColor(...PDF.slate50) doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F') // Borde doc.setDrawColor(...PDF.slate200) doc.setLineWidth(0.2) doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S') // Label doc.setFont('helvetica', 'normal') doc.setFontSize(5.5) doc.setTextColor(...PDF.slate500) doc.text(kpi.label, x + 3, y + 5.5) // Valor const valueColor = kpi.positive ? [16, 185, 129] as [number,number,number] : [239, 68, 68] as [number,number,number] doc.setFont('helvetica', 'bold') doc.setFontSize(kpi.value.length > 22 ? 7 : kpi.value.length > 14 ? 9 : 11) doc.setTextColor(...valueColor) doc.text(kpi.value, x + 3, y + 16) // Sub-texto doc.setFont('helvetica', 'normal') doc.setFontSize(6) doc.setTextColor(...PDF.slate500) const subLines = doc.splitTextToSize(kpi.sub, cardW - 6) doc.text(subLines, x + 3, y + 22) maxY = Math.max(maxY, y + cardH) }) return maxY + 6 } // ─── Config de autotable para la tabla comparativa ──────────────────────────── // Se llama desde pdf-export.ts donde autoTable ya está importado dinámicamente. export function buildComparisonTableConfig( perActivityActual: ActivitySimResult[], perActivityAutomated: ActivitySimResult[], activities: Activity[], currency: string, startY: number ): Record { const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a])) const isAutomatableMap = new Map(activities.map((a) => [a.bpmnElementId, a.automatable])) const RA = 'right' as const const head = [[ { content: 'Actividad', styles: { cellWidth: 42 } }, { content: `Act. (${currency})`, styles: { halign: RA, cellWidth: 22 } }, { content: `Auto. (${currency})`, styles: { halign: RA, cellWidth: 22 } }, { content: `Ahorro (${currency})`, styles: { halign: RA, cellWidth: 22 } }, { content: 'Ahorro %', styles: { halign: RA, cellWidth: 16 } }, { content: 'Prob.', styles: { halign: RA, cellWidth: 14 } }, ]] const body = [...perActivityActual] .sort((a, b) => { const aCost = autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost const bCost = autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost return (b.expectedTotalCost - bCost) - (a.expectedTotalCost - aCost) }) .map((act) => { const auto = autoByBpmnId.get(act.bpmnElementId) const autoCost = auto?.expectedTotalCost ?? act.expectedTotalCost const savings = act.expectedTotalCost - autoCost const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0 const isAutomatable = isAutomatableMap.get(act.bpmnElementId) ?? false const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139] const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252] return [ { content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const } }, { content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } }, { content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } }, { content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } }, { content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } }, { content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill } }, ] }) return { head, body, startY, margin: { left: PDF.margin, right: PDF.margin }, styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' }, headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 }, columnStyles: { 0: { cellWidth: 42 } }, } } // ─── Página 3: resúmenes textuales + transparencia ──────────────────────────── export function drawRoiAnalysisPage( doc: DocLike, roi: RoiResult, process: Process, perActivityActual: ActivitySimResult[], perActivityAutomated: ActivitySimResult[], activities: Activity[], currency: string, startY: number ): number { let y = startY doc.setFont('helvetica', 'bold') doc.setFontSize(12) doc.setTextColor(...PDF.slate900) doc.text('Análisis del ahorro', PDF.margin, y) y += 6 doc.setDrawColor(...PDF.slate200) doc.setLineWidth(0.2) doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y) y += 6 // ── Composición del ahorro ────────────────────────────────────────────────── const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a])) const savingsPerAct = perActivityActual .map((a) => { const auto = autoByBpmnId.get(a.bpmnElementId) return { name: a.activityName, savings: a.expectedTotalCost - (auto?.expectedTotalCost ?? a.expectedTotalCost) } }) .filter((s) => s.savings > 0) .sort((a, b) => b.savings - a.savings) doc.setFont('helvetica', 'bold') doc.setFontSize(9) doc.setTextColor(...PDF.slate700) doc.text('Composición del ahorro', PDF.margin, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(8.5) if (savingsPerAct.length > 0) { const top3 = savingsPerAct.slice(0, 3) const top3TotalSavings = top3.reduce((s, a) => s + a.savings, 0) const totalSavingsAll = savingsPerAct.reduce((s, a) => s + a.savings, 0) const top3Pct = totalSavingsAll > 0 ? (top3TotalSavings / totalSavingsAll * 100).toFixed(0) : '0' const compositionText = `El ${top3Pct}% del ahorro total proviene de las primeras ${Math.min(3, savingsPerAct.length)} actividades automatizadas:` const compositionLines = doc.splitTextToSize(compositionText, PDF.contentW) doc.text(compositionLines, PDF.margin, y) y += compositionLines.length * 4.5 + 2 for (const act of top3) { const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0' doc.text(` • ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, PDF.margin, y) y += 4.5 } } else { doc.setTextColor(...PDF.slate500) doc.text('Sin actividades con ahorro positivo en el escenario actual.', PDF.margin, y) doc.setTextColor(...PDF.slate700) y += 5 } y += 4 // ── Trayectoria del ahorro ────────────────────────────────────────────────── doc.setFont('helvetica', 'bold') doc.setFontSize(9) doc.text('Trayectoria del ahorro acumulado', PDF.margin, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(8.5) let trajectoryText: string if (process.automationInvestment > 0 && Number.isFinite(roi.paybackMonths)) { const recoveryStr = roi.paybackMonths < 24 ? `${roi.paybackMonths.toFixed(1)} meses` : `${roi.paybackYears.toFixed(1)} años` const multiple = process.automationInvestment > 0 ? (roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1) : '∞' trajectoryText = `La inversión de ${formatCurrency(process.automationInvestment, currency)} se recupera en ${recoveryStr}. ` + `A ${process.analysisHorizonYears} años, el ahorro acumulado proyectado es ` + `${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial.` } else if (process.automationInvestment === 0) { trajectoryText = `Sin inversión configurada — el ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` + `equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años.` } else { trajectoryText = 'Sin ahorro proyectado con los datos actuales. Revisá los costos automatizados configurados.' } const trajectoryLines = doc.splitTextToSize(trajectoryText, PDF.contentW) doc.text(trajectoryLines, PDF.margin, y) y += trajectoryLines.length * 4.5 + 6 // ── Transparencia metodológica (si aplica) ──────────────────────────────── y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y) return y } export function drawTransparencyBlock( doc: DocLike, activities: Activity[], investment: number, roi: RoiResult, startY: number ): number { const zeroCost = activities.filter( (a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0 ) const investmentZero = investment === 0 const roiHigh = Number.isFinite(roi.roiAnnualPercent) && roi.roiAnnualPercent > ROI_HIGH_THRESHOLD if (zeroCost.length === 0 && !investmentZero && !roiHigh) return startY const boxX = PDF.margin const boxW = PDF.contentW // ── Paso 1: recopilar todo el contenido para calcular altura ───────────── type TextBlock = { text: string[]; bold?: boolean; fontSize: number } const blocks: TextBlock[] = [] blocks.push({ text: ['Transparencia metodológica'], bold: true, fontSize: 9 }) if (zeroCost.length > 0) { const msg = zeroCost.length === 1 ? `1 actividad automatizable sin costo configurado: "${zeroCost[0].name || zeroCost[0].bpmnElementId}". El ahorro puede estar inflado artificialmente.` : `${zeroCost.length} actividades sin costo automatizado: ${zeroCost.map((a) => a.name || a.bpmnElementId).join(', ')}. El ahorro puede estar inflado.` blocks.push({ text: doc.splitTextToSize(msg, boxW - 8), fontSize: 8 }) } if (investmentZero) { blocks.push({ text: doc.splitTextToSize( 'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión en el workspace para un análisis realista.', boxW - 8 ), fontSize: 8, }) } if (roiHigh) { blocks.push({ text: doc.splitTextToSize( `ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`, boxW - 8 ), fontSize: 8, }) } // Calcular altura total del bloque let height = 6 // padding top for (const b of blocks) { height += b.text.length * (b.bold ? 4.5 : 3.8) + 3 } height += 3 // padding bottom // ── Paso 2: dibujar fondo y borde ──────────────────────────────────────── doc.setFillColor(255, 251, 235) // amber-50 doc.setDrawColor(253, 230, 138) // amber-200 doc.setLineWidth(0.3) doc.rect(boxX, startY, boxW, height, 'FD') // ── Paso 3: dibujar texto encima del rectángulo ─────────────────────────── let y = startY + 6 for (const b of blocks) { if (b.bold) { doc.setFont('helvetica', 'bold') doc.setFontSize(b.fontSize) doc.setTextColor(146, 64, 14) // amber-800 } else { doc.setFont('helvetica', 'normal') doc.setFontSize(b.fontSize) doc.setTextColor(180, 83, 9) // amber-700 } doc.text(b.text, boxX + 4, y) y += b.text.length * (b.bold ? 4.5 : 3.8) + 3 } return startY + height + 4 } // ─── Sección metodológica ampliada para el tab ROI ──────────────────────────── export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number { let y = startY doc.setFont('helvetica', 'bold') doc.setFontSize(10) doc.setTextColor(...PDF.slate900) doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(8) doc.setTextColor(...PDF.slate500) const sections = [ { title: 'Costo por ejecución actual:', body: `Σ(actividades) [costo_fijo × prob_ejecución] × (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagación hacia adelante en el grafo BPMN.`, }, { title: 'Costo por ejecución automatizado:', body: 'Mismo modelo, pero las actividades marcadas como automatizables usan su costo automatizado configurado. Las no marcadas mantienen el costo actual.', }, { title: 'Ahorro anual:', body: `(Costo actual − Costo automatizado) × ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones/año.`, }, { title: 'Payback (meses):', body: `(Inversión / Ahorro anual) × 12. Nominal simple — no incluye tasa de descuento ni inflación.`, }, { title: 'Ahorro acumulado:', body: `Ahorro anual × ${process.analysisHorizonYears} años − Inversión inicial. Nominal, sin VP.`, }, { title: 'ROI anualizado:', body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.', }, ] for (const s of sections) { doc.setFont('helvetica', 'bold') doc.text(s.title, PDF.margin, y) y += 4 doc.setFont('helvetica', 'normal') const lines = doc.splitTextToSize(s.body, PDF.contentW - 4) doc.text(lines, PDF.margin + 4, y) y += lines.length * 3.8 + 2 } return y } // ─── Helpers locales ────────────────────────────────────────────────────────── function fmtNum(n: number): string { return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }