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:
2026-05-15 22:14:53 -03:00
parent b6f31f4371
commit 8b71c6ee13
54 changed files with 6497 additions and 261 deletions

79
src/domain/roi.ts Normal file
View File

@@ -0,0 +1,79 @@
// Cálculos de ROI de automatización — funciones puras, sin dependencias de UI ni persistencia.
// Fórmulas nominales simples (ADR-014): sin VPN, TIR ni tasa de descuento.
// Los edge cases (inversión cero, ahorro negativo, división por cero) están explicitados.
export interface RoiInput {
costPerExecutionActual: number // costo total de una ejecución en escenario actual
costPerExecutionAutomated: number // costo total de una ejecución en escenario automatizado
annualFrequency: number // ejecuciones por año
analysisHorizonYears: number // horizonte de análisis en años
automationInvestment: number // inversión total única del proyecto de automatización
}
export interface RoiResult {
savingsPerExecution: number // ahorro por ejecución individual (actual - automatizado)
savingsPerExecutionPercent: number // ahorro % sobre el costo actual (0 si actual = 0)
annualSavings: number // ahorro anual = ahorro/ejecución × frecuencia
paybackMonths: number // meses para recuperar inversión (Infinity si ahorro <= 0)
paybackYears: number // años para recuperar inversión
cumulativeSavingsHorizon: number // ahorro acumulado neto al final del horizonte
breaksEvenInHorizon: boolean // ¿el payback ocurre dentro del horizonte?
roiAnnualPercent: number // ROI anualizado % = (ahorroAnual / inversión) × 100
// Infinity si inversión = 0 y ahorro > 0
}
export function calculateRoi(input: RoiInput): RoiResult {
const {
costPerExecutionActual,
costPerExecutionAutomated,
annualFrequency,
analysisHorizonYears,
automationInvestment,
} = input
const savingsPerExecution = costPerExecutionActual - costPerExecutionAutomated
// Evitar división por cero cuando el costo actual es 0
const savingsPerExecutionPercent =
costPerExecutionActual > 0
? (savingsPerExecution / costPerExecutionActual) * 100
: 0
const annualSavings = savingsPerExecution * annualFrequency
// Payback: si no hay ahorro (o es negativo), nunca se recupera la inversión
let paybackMonths: number
if (annualSavings <= 0) {
paybackMonths = Infinity
} else if (automationInvestment === 0) {
// Inversión cero con ahorro positivo: recuperación instantánea
paybackMonths = 0
} else {
paybackMonths = (automationInvestment / annualSavings) * 12
}
const paybackYears = paybackMonths / 12 // Infinity / 12 = Infinity, 0 / 12 = 0
const cumulativeSavingsHorizon = annualSavings * analysisHorizonYears - automationInvestment
const breaksEvenInHorizon = isFinite(paybackYears) && paybackYears <= analysisHorizonYears
// ROI anualizado: Infinity si inversión = 0 con ahorro > 0; 0 si inversión = 0 y ahorro = 0
let roiAnnualPercent: number
if (automationInvestment === 0) {
roiAnnualPercent = annualSavings > 0 ? Infinity : 0
} else {
roiAnnualPercent = (annualSavings / automationInvestment) * 100
}
return {
savingsPerExecution,
savingsPerExecutionPercent,
annualSavings,
paybackMonths,
paybackYears,
cumulativeSavingsHorizon,
breaksEvenInHorizon,
roiAnnualPercent,
}
}

View File

@@ -22,6 +22,9 @@ export const ActivitySchema = z.object({
directCostFixed: z.number().min(0),
executionTimeMinutes: z.number().min(0),
assignedResources: z.array(ActivityResourceAssignmentSchema),
automatable: z.boolean().default(false),
automatedCostFixed: z.number().min(0).default(0),
automatedTimeMinutes: z.number().min(0).default(0),
})
export const GatewayBranchSchema = z.object({
@@ -45,6 +48,9 @@ export const ProcessSchema = z.object({
bpmnXml: z.string().min(1),
currency: z.string().length(3, 'Código ISO de 3 letras'),
overheadPercentage: z.number().min(0).max(1),
annualFrequency: z.number().min(1).default(1000),
analysisHorizonYears: z.number().min(1).max(20).default(3),
automationInvestment: z.number().min(0).default(0),
createdAt: z.number(),
updatedAt: z.number(),
})

View File

@@ -181,6 +181,9 @@ export function runSimulation(input: SimulationInput): SimulationResult {
const perActivity: ActivitySimResult[] = []
const resourceTotals = new Map<string, { cost: number; minutes: number; name: string }>()
// Seleccionar campos del escenario: para actividades automatable, usar costos automatizados
const scenario = input.scenario ?? 'actual'
for (const activity of activities.values()) {
const execProb = execProbs.get(activity.bpmnElementId) ?? 0
const node = graph.nodes.get(activity.bpmnElementId)
@@ -192,13 +195,19 @@ export function runSimulation(input: SimulationInput): SimulationResult {
continue
}
// En escenario automatizado, usar campos automatizados solo para actividades marcadas;
// el resto mantiene sus costos originales (regla de negocio central del Sprint 1).
const useAutomated = scenario === 'automated' && activity.automatable
const directCostFixed = useAutomated ? activity.automatedCostFixed : activity.directCostFixed
const executionTimeMinutes = useAutomated ? activity.automatedTimeMinutes : activity.executionTimeMinutes
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
let resourceCostDirect = 0
for (const assignment of activity.assignedResources) {
const resource = resources.get(assignment.resourceId)
if (!resource) continue
const hoursUsed = (activity.executionTimeMinutes / 60) * assignment.utilizationPercent
const hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent
const cost = resource.costPerHour * hoursUsed * execProb
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
resourceCostDirect += cost
@@ -211,7 +220,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
})
}
const fixedCostExpected = activity.directCostFixed * execProb
const fixedCostExpected = directCostFixed * execProb
const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect
perActivity.push({
@@ -225,7 +234,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
expectedExecutions: execProb,
executionProbability: execProb,
resourceCostBreakdown: resourceBreakdown,
executionTimeMinutes: activity.executionTimeMinutes,
executionTimeMinutes,
})
}

View File

@@ -11,6 +11,9 @@ export interface Process {
bpmnXml: string
currency: string
overheadPercentage: number
annualFrequency: number // 🆕 Sprint 1 — execuciones por año, default 1000
analysisHorizonYears: number // 🆕 Sprint 1 — horizonte de análisis en años, default 3
automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0
createdAt: number
updatedAt: number
}
@@ -26,6 +29,9 @@ export interface Activity {
directCostFixed: number
executionTimeMinutes: number
assignedResources: ActivityResourceAssignment[]
automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización
automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada
automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado
}
export interface ActivityResourceAssignment {
@@ -63,11 +69,16 @@ export interface Simulation {
id: UUID
processId: UUID
executedAt: number
result: SimulationResult
result: SimulationResult // escenario actual
resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad)
}
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes)
// o automatizado (automatedCostFixed/automatedTimeMinutes para actividades marcadas).
export type Scenario = 'actual' | 'automated'
export interface SimulationInput {
processXml: string
activities: Map<BpmnElementId, Activity>
@@ -77,6 +88,7 @@ export interface SimulationInput {
currency: string
overheadPercentage: number
}
scenario?: Scenario // default 'actual' — opcional para retrocompatibilidad con tests existentes
}
export interface ActivitySimResult {