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:
79
src/domain/roi.ts
Normal file
79
src/domain/roi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user