Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import Papa from 'papaparse'
|
||||
import { formatSimulationTimestamp } from '@/lib/format'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import { buildFileName } from './slug'
|
||||
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
|
||||
import type { Process, Simulation, Resource, Activity, ActivitySimResult } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
|
||||
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
|
||||
@@ -18,19 +21,11 @@ function formatDecimalForCsv(value: number, currency: string): string {
|
||||
return formatted
|
||||
}
|
||||
|
||||
function buildResourcesCell(
|
||||
act: ActivitySimResult,
|
||||
resources: Resource[]
|
||||
): string {
|
||||
function buildResourcesCell(act: ActivitySimResult, resources: Resource[]): string {
|
||||
if (act.resourceCostBreakdown.length === 0) return ''
|
||||
const resourceById = new Map(resources.map((r) => [r.id, r]))
|
||||
return act.resourceCostBreakdown
|
||||
.map((rb) => {
|
||||
const res = resourceById.get(rb.resourceId)
|
||||
if (!res) return rb.resourceName
|
||||
// Calcular % de utilización desde el costo relativo
|
||||
return res.name
|
||||
})
|
||||
.map((rb) => resourceById.get(rb.resourceId)?.name ?? rb.resourceName)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
@@ -38,42 +33,43 @@ export interface CsvExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
resources: Resource[]
|
||||
tab?: ReportTab // default: 'actual'
|
||||
activities?: Activity[] // requerido para tab='roi'
|
||||
}
|
||||
|
||||
export function generateCsvContent(params: CsvExportParams): string {
|
||||
const { process, simulation, resources } = params
|
||||
const { process, simulation, resources, tab = 'actual', activities = [] } = params
|
||||
const { currency } = process
|
||||
const result = simulation.result
|
||||
|
||||
if (tab === 'roi') {
|
||||
return generateRoiCsvContent(process, simulation, resources, activities, currency)
|
||||
}
|
||||
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
|
||||
const delimiter = getDelimiter(currency)
|
||||
const { date } = formatSimulationTimestamp(simulation.executedAt)
|
||||
|
||||
// ── Filas de metadata como comentarios ──────────────────────────────────
|
||||
const meta = [
|
||||
`# Proceso: ${process.name}`,
|
||||
`# Cliente: ${process.clientName || 'N/A'}`,
|
||||
`# Fecha simulación: ${date}`,
|
||||
`# Moneda: ${currency}`,
|
||||
`# Escenario: ${tab === 'automatizado' ? 'Automatizado' : 'Actual'}`,
|
||||
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
|
||||
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
|
||||
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
// ── Headers ──────────────────────────────────────────────────────────────
|
||||
const headers = [
|
||||
'ID BPMN',
|
||||
'Actividad',
|
||||
'Tipo',
|
||||
`Costo directo (${currency})`,
|
||||
`Costo indirecto (${currency})`,
|
||||
`Costo total (${currency})`,
|
||||
'% del total',
|
||||
'Tiempo (min)',
|
||||
'Ejecuciones esperadas',
|
||||
'Recursos asignados',
|
||||
'ID BPMN', 'Actividad', 'Tipo',
|
||||
`Costo directo (${currency})`, `Costo indirecto (${currency})`, `Costo total (${currency})`,
|
||||
'% del total', 'Tiempo (min)', 'Ejecuciones esperadas', 'Recursos asignados',
|
||||
]
|
||||
|
||||
// ── Filas ─────────────────────────────────────────────────────────────────
|
||||
const rows = result.perActivity.map((act) => [
|
||||
act.bpmnElementId,
|
||||
act.activityName,
|
||||
@@ -87,22 +83,135 @@ export function generateCsvContent(params: CsvExportParams): string {
|
||||
buildResourcesCell(act, resources),
|
||||
])
|
||||
|
||||
const csvBody = Papa.unparse(
|
||||
{ fields: headers, data: rows },
|
||||
{ delimiter, newline: '\r\n', quotes: true }
|
||||
)
|
||||
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
|
||||
return '' + meta + csvBody
|
||||
}
|
||||
|
||||
// BOM UTF-8 + metadata + datos
|
||||
// ─── CSV de ROI: 16 columnas + metadata extendida ────────────────────────────
|
||||
|
||||
function generateRoiCsvContent(
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
): string {
|
||||
const resultActual = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? simulation.result
|
||||
const delimiter = getDelimiter(currency)
|
||||
const { date } = formatSimulationTimestamp(simulation.executedAt)
|
||||
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
|
||||
const paybackStr = !Number.isFinite(roi.paybackMonths)
|
||||
? 'N/A'
|
||||
: roi.paybackMonths === 0
|
||||
? '0'
|
||||
: formatDecimalForCsv(roi.paybackMonths, currency)
|
||||
|
||||
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
|
||||
? 'Infinito'
|
||||
: formatDecimalForCsv(roi.roiAnnualPercent, currency)
|
||||
|
||||
const meta = [
|
||||
`# Proceso: ${process.name}`,
|
||||
`# Cliente: ${process.clientName || 'N/A'}`,
|
||||
`# Fecha simulación: ${date}`,
|
||||
`# Moneda: ${currency}`,
|
||||
`# Costo total actual (${currency}): ${formatDecimalForCsv(resultActual.totalCost, currency)}`,
|
||||
`# Costo total automatizado (${currency}): ${formatDecimalForCsv(resultAutomated.totalCost, currency)}`,
|
||||
`# Ahorro por ejecución (${currency}): ${formatDecimalForCsv(roi.savingsPerExecution, currency)}`,
|
||||
`# Ahorro anual (${currency}): ${formatDecimalForCsv(roi.annualSavings, currency)}`,
|
||||
`# Payback (meses): ${paybackStr}`,
|
||||
`# Ahorro acumulado a ${process.analysisHorizonYears} años (${currency}): ${formatDecimalForCsv(roi.cumulativeSavingsHorizon, currency)}`,
|
||||
`# ROI anualizado (%): ${roiStr}`,
|
||||
`# Frecuencia anual: ${process.annualFrequency}`,
|
||||
`# Horizonte de análisis: ${process.analysisHorizonYears} años`,
|
||||
`# Inversión en automatización (${currency}): ${formatDecimalForCsv(process.automationInvestment, currency)}`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
// Mapas para lookup eficiente
|
||||
const autoByBpmnId = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a]))
|
||||
const actByBpmnId = new Map(activities.map((a) => [a.bpmnElementId, a]))
|
||||
|
||||
const headers = [
|
||||
'ID BPMN',
|
||||
'Actividad',
|
||||
'Tipo',
|
||||
'Automatizable',
|
||||
'Probabilidad (%)',
|
||||
`Costo actual directo (${currency})`,
|
||||
`Costo actual indirecto (${currency})`,
|
||||
`Costo actual total (${currency})`,
|
||||
'Tiempo actual (min)',
|
||||
`Costo automatizado directo (${currency})`,
|
||||
`Costo automatizado indirecto (${currency})`,
|
||||
`Costo automatizado total (${currency})`,
|
||||
'Tiempo automatizado (min)',
|
||||
`Ahorro (${currency})`,
|
||||
'Ahorro (%)',
|
||||
'Recursos asignados',
|
||||
]
|
||||
|
||||
const rows = [...resultActual.perActivity]
|
||||
.sort((a, b) => {
|
||||
const aSavings = a.expectedTotalCost - (autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost)
|
||||
const bSavings = b.expectedTotalCost - (autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost)
|
||||
return bSavings - aSavings
|
||||
})
|
||||
.map((act) => {
|
||||
const autoAct = autoByBpmnId.get(act.bpmnElementId)
|
||||
const domainAct = actByBpmnId.get(act.bpmnElementId)
|
||||
const isAutomatable = domainAct?.automatable ?? false
|
||||
const automatedTime = domainAct?.automatedTimeMinutes ?? act.executionTimeMinutes
|
||||
|
||||
const autoCostDirect = autoAct?.expectedDirectCost ?? act.expectedDirectCost
|
||||
const autoCostIndirect = autoAct?.expectedIndirectCost ?? act.expectedIndirectCost
|
||||
const autoCostTotal = autoAct?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - autoCostTotal
|
||||
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
|
||||
return [
|
||||
act.bpmnElementId,
|
||||
act.activityName,
|
||||
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
|
||||
isAutomatable ? 'true' : 'false',
|
||||
formatDecimalForCsv(act.executionProbability * 100, currency),
|
||||
formatDecimalForCsv(act.expectedDirectCost, currency),
|
||||
formatDecimalForCsv(act.expectedIndirectCost, currency),
|
||||
formatDecimalForCsv(act.expectedTotalCost, currency),
|
||||
formatDecimalForCsv(act.executionTimeMinutes, currency),
|
||||
formatDecimalForCsv(autoCostDirect, currency),
|
||||
formatDecimalForCsv(autoCostIndirect, currency),
|
||||
formatDecimalForCsv(autoCostTotal, currency),
|
||||
formatDecimalForCsv(automatedTime, currency),
|
||||
formatDecimalForCsv(savings, currency),
|
||||
formatDecimalForCsv(savingsPct, currency),
|
||||
buildResourcesCell(act, resources),
|
||||
]
|
||||
})
|
||||
|
||||
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
|
||||
return '' + meta + csvBody
|
||||
}
|
||||
|
||||
export function exportToCsv(params: CsvExportParams): void {
|
||||
const content = generateCsvContent(params)
|
||||
const tab = params.tab ?? 'actual'
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
const fileName = buildFileName(
|
||||
params.process.name,
|
||||
params.process.clientName,
|
||||
new Date(params.simulation.executedAt),
|
||||
'csv'
|
||||
'csv',
|
||||
suffix
|
||||
)
|
||||
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { buildFileName } from './slug'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
|
||||
} from './pdf-sections'
|
||||
import type { Process, Simulation, Resource } from '@/domain/types'
|
||||
import {
|
||||
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection,
|
||||
} from './pdf-sections-roi'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
export interface PdfExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
resources: Resource[]
|
||||
heatmapImageData: string | null // data URL de la imagen capturada del canvas
|
||||
heatmapImageData: string | null
|
||||
tab?: ReportTab // default: 'actual'
|
||||
activities?: Activity[] // requerido para tab='roi' (transparencia + tabla)
|
||||
}
|
||||
|
||||
export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
const { process, simulation, resources, heatmapImageData } = params
|
||||
const result = simulation.result
|
||||
const { process, simulation, resources, heatmapImageData, tab = 'actual', activities = [] } = params
|
||||
const currency = process.currency
|
||||
|
||||
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
|
||||
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable'),
|
||||
@@ -25,8 +32,36 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
|
||||
|
||||
// Metadata del PDF
|
||||
doc.setProperties({
|
||||
if (tab === 'roi') {
|
||||
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency)
|
||||
} else {
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency)
|
||||
}
|
||||
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
const fileName = buildFileName(process.name, process.clientName, new Date(simulation.executedAt), 'pdf', suffix)
|
||||
doc.save(fileName)
|
||||
}
|
||||
|
||||
// ─── Escenario único (actual o automatizado) ──────────────────────────────────
|
||||
|
||||
async function exportScenarioPdf(
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
result: Simulation['result'],
|
||||
resources: Resource[],
|
||||
heatmapImageData: string | null,
|
||||
currency: string
|
||||
): Promise<void> {
|
||||
const docAny = doc as any
|
||||
|
||||
docAny.setProperties({
|
||||
title: `Análisis de costos - ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Simulación de proceso BPMN',
|
||||
@@ -34,44 +69,37 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
keywords: 'bpmn, costos, simulación',
|
||||
})
|
||||
|
||||
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
|
||||
let y = drawHeader(doc as any, process, simulation.executedAt)
|
||||
y = drawKpiCards(doc as any, result, currency, y)
|
||||
let y = drawHeader(docAny, process, simulation.executedAt)
|
||||
y = drawKpiCards(docAny, result, currency, y)
|
||||
y += 4
|
||||
|
||||
// Título del heatmap
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Mapa de calor de costos', PDF.margin, y)
|
||||
docAny.setFont('helvetica', 'bold')
|
||||
docAny.setFontSize(10)
|
||||
docAny.setTextColor(...PDF.slate900)
|
||||
docAny.text('Mapa de calor de costos', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
y = drawHeatmapImage(doc as any, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(doc as any, y)
|
||||
y = drawHeatmapImage(docAny, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(docAny, y)
|
||||
y += 4
|
||||
|
||||
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
docAny.addPage()
|
||||
y = PDF.margin
|
||||
y = drawAnalysisSection(doc as any, result, currency, y)
|
||||
y = drawAnalysisSection(docAny, result, currency, y)
|
||||
y += 6
|
||||
|
||||
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
|
||||
void resources // disponible para uso futuro (recursos en tabla de actividades)
|
||||
void resources
|
||||
|
||||
const RA = 'right' as const
|
||||
|
||||
const tableHead = [
|
||||
[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
|
||||
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
],
|
||||
]
|
||||
const tableHead = [[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
|
||||
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
]]
|
||||
|
||||
const tableBody = result.perActivity.map((act) => [
|
||||
{ content: act.activityName },
|
||||
@@ -83,40 +111,92 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
|
||||
])
|
||||
|
||||
autoTable(doc, {
|
||||
autoTable(docAny, {
|
||||
head: tableHead,
|
||||
body: tableBody,
|
||||
startY: y,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
|
||||
headStyles: {
|
||||
fillColor: PDF.blue,
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
fontSize: 8,
|
||||
},
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
|
||||
alternateRowStyles: { fillColor: PDF.slate50 },
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
didDrawPage: () => {
|
||||
// Placeholder — los footers se añaden al final con addPageNumbers
|
||||
},
|
||||
})
|
||||
|
||||
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
|
||||
docAny.addPage()
|
||||
drawMethodologySection(docAny, process, PDF.margin)
|
||||
addPageNumbers(docAny, 'Process Cost Platform', simulation.executedAt)
|
||||
}
|
||||
|
||||
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
|
||||
|
||||
async function exportRoiPdf(
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
): Promise<void> {
|
||||
const result = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? result
|
||||
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: result.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
|
||||
doc.setProperties({
|
||||
title: `Análisis de ROI - ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Análisis de retorno de inversión para automatización BPMN',
|
||||
creator: 'Process Cost Platform',
|
||||
keywords: 'bpmn, roi, automatización, payback',
|
||||
})
|
||||
|
||||
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
|
||||
let y = drawHeader(doc, process, simulation.executedAt)
|
||||
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
|
||||
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
|
||||
|
||||
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawMethodologySection(doc as any, process, PDF.margin)
|
||||
y = PDF.margin
|
||||
|
||||
// ── FOOTER en todas las páginas ───────────────────────────────────────────
|
||||
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Comparación por actividad', PDF.margin, y)
|
||||
y += 4
|
||||
|
||||
// ── Descargar ─────────────────────────────────────────────────────────────
|
||||
const fileName = buildFileName(
|
||||
process.name,
|
||||
process.clientName,
|
||||
new Date(simulation.executedAt),
|
||||
'pdf'
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text('★ Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
|
||||
y += 8
|
||||
|
||||
const tableConfig = buildComparisonTableConfig(
|
||||
result.perActivity,
|
||||
resultAutomated.perActivity,
|
||||
activities,
|
||||
currency,
|
||||
y
|
||||
)
|
||||
doc.save(fileName)
|
||||
autoTable(doc, tableConfig)
|
||||
|
||||
// ── Página 3: Análisis textual + transparencia ─────────────────────────────
|
||||
doc.addPage()
|
||||
y = PDF.margin
|
||||
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
|
||||
|
||||
// ── Página 4: Metodología ──────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawRoiMethodologySection(doc, process, PDF.margin)
|
||||
|
||||
addPageNumbers(doc, 'Process Cost Platform · ROI', simulation.executedAt)
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
|
||||
458
src/lib/export/pdf-sections-roi.ts
Normal file
458
src/lib/export/pdf-sections-roi.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { formatCurrency, 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 = !Number.isFinite(roi.paybackMonths)
|
||||
? 'No se recupera'
|
||||
: roi.paybackMonths === 0
|
||||
? 'Inmediato'
|
||||
: roi.paybackMonths < 24
|
||||
? `${roi.paybackMonths.toFixed(1)} meses`
|
||||
: `${roi.paybackYears.toFixed(1)} años`
|
||||
|
||||
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 > 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)} 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 })
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const PDF = {
|
||||
}
|
||||
|
||||
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
|
||||
type DocLike = {
|
||||
export type DocLike = {
|
||||
setFont: (name: string, style: string) => void
|
||||
setFontSize: (size: number) => void
|
||||
setTextColor: (...args: number[]) => void
|
||||
|
||||
@@ -12,7 +12,8 @@ export function buildFileName(
|
||||
processName: string,
|
||||
clientName: string | undefined,
|
||||
date: Date,
|
||||
ext: 'pdf' | 'csv'
|
||||
ext: 'pdf' | 'csv',
|
||||
suffix = '' // '_actual' | '_automatizado' | '_roi' | ''
|
||||
): string {
|
||||
const dateStr = [
|
||||
date.getFullYear(),
|
||||
@@ -24,5 +25,5 @@ export function buildFileName(
|
||||
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
|
||||
parts.push(dateStr)
|
||||
|
||||
return `${parts.join('_')}.${ext}`
|
||||
return `${parts.join('_')}${suffix}.${ext}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user