453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
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 })
|
||
}
|