Files
inq-roi-simulador-web/src/lib/export/pdf-sections-roi.ts

453 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> {
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 })
}