feat: MVP Fase 0 — Process Cost Platform v0.1.0

Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

255
src/domain/simulation.ts Normal file
View File

@@ -0,0 +1,255 @@
import { parseBpmnXml } from './bpmn-parser'
import type {
SimulationInput,
SimulationResult,
ActivitySimResult,
ResourceSimResult,
ProcessGraph,
BpmnElementId,
} from './types'
import { isGatewayNode } from './types'
// ─── Detección de back-edges (loops) via DFS con coloreado ──────────────────
function findBackEdges(graph: ProcessGraph): Set<string> {
const backEdges = new Set<string>()
const WHITE = 0, GRAY = 1, BLACK = 2
const color = new Map<BpmnElementId, number>()
function dfs(nodeId: BpmnElementId) {
color.set(nodeId, GRAY)
const node = graph.nodes.get(nodeId)
if (!node) { color.set(nodeId, BLACK); return }
for (const flowId of node.outgoing) {
const flow = graph.flows.get(flowId)
if (!flow) continue
const targetColor = color.get(flow.targetRef) ?? WHITE
if (targetColor === GRAY) {
backEdges.add(flowId)
} else if (targetColor === WHITE) {
dfs(flow.targetRef)
}
}
color.set(nodeId, BLACK)
}
for (const startId of graph.startEventIds) {
if ((color.get(startId) ?? 0) === 0) dfs(startId)
}
return backEdges
}
// ─── Detección de loops con mensajes accionables ──────────────────────────────
function detectLoops(graph: ProcessGraph, backEdges: Set<string>): string[] {
const warnings: string[] = []
const seen = new Set<string>()
for (const flowId of backEdges) {
const flow = graph.flows.get(flowId)
if (!flow || seen.has(flow.targetRef)) continue
seen.add(flow.targetRef)
const name = graph.nodes.get(flow.targetRef)?.name || flow.targetRef
warnings.push(
`Loop detectado en el proceso: el nodo "${name}" forma un ciclo. Los costos se calcularán asumiendo una sola ejecución del ciclo.`
)
}
return warnings
}
// ─── Sort topológico (Kahn's algorithm, ignora back-edges) ───────────────────
function topologicalSort(graph: ProcessGraph, backEdges: Set<string>): BpmnElementId[] {
const inDegree = new Map<BpmnElementId, number>()
for (const nodeId of graph.nodes.keys()) inDegree.set(nodeId, 0)
for (const [flowId, flow] of graph.flows) {
if (backEdges.has(flowId)) continue
inDegree.set(flow.targetRef, (inDegree.get(flow.targetRef) ?? 0) + 1)
}
const queue: BpmnElementId[] = []
for (const [nodeId, deg] of inDegree) {
if (deg === 0) queue.push(nodeId)
}
const order: BpmnElementId[] = []
while (queue.length > 0) {
const nodeId = queue.shift()!
order.push(nodeId)
const node = graph.nodes.get(nodeId)
if (!node) continue
for (const flowId of node.outgoing) {
if (backEdges.has(flowId)) continue
const flow = graph.flows.get(flowId)
if (!flow) continue
const newDeg = (inDegree.get(flow.targetRef) ?? 0) - 1
inDegree.set(flow.targetRef, newDeg)
if (newDeg === 0) queue.push(flow.targetRef)
}
}
return order
}
// ─── Propagación de probabilidades en orden topológico ───────────────────────
//
// Cada nodo se visita exactamente una vez, con su probabilidad ya acumulada.
// Esto evita el overcounting del BFS (donde nodos convergentes se visitaban
// múltiples veces con probabilidades parciales).
//
// Reglas de acumulación en el nodo TARGET:
// AND-join (parallelGateway convergente): max(existing, branchProb)
// — las ramas paralelas son instancias del mismo flujo, no alternativas.
// Todo lo demás (XOR-join, secuencia, eventos): sum clampeado a 1.0
// — representan caminos mutuamente excluyentes.
function computeExecutionProbabilities(
graph: ProcessGraph,
gatewayProbs: Map<BpmnElementId, Map<string, number>>,
backEdges: Set<string>
): Map<BpmnElementId, number> {
const andJoinIds = new Set<BpmnElementId>()
for (const node of graph.nodes.values()) {
if (node.type === 'parallelGateway' && node.incoming.length > 1 && node.outgoing.length <= 1) {
andJoinIds.add(node.id)
}
}
const probs = new Map<BpmnElementId, number>()
for (const startId of graph.startEventIds) probs.set(startId, 1.0)
const topoOrder = topologicalSort(graph, backEdges)
for (const nodeId of topoOrder) {
const node = graph.nodes.get(nodeId)
if (!node) continue
const nodeProb = probs.get(nodeId) ?? 0
for (const flowId of node.outgoing) {
if (backEdges.has(flowId)) continue
const flow = graph.flows.get(flowId)
if (!flow) continue
let branchProb = nodeProb
if (isGatewayNode(node.type)) {
const gwFlowProbs = gatewayProbs.get(nodeId)
if (gwFlowProbs) {
branchProb = nodeProb * (gwFlowProbs.get(flowId) ?? 0)
} else {
branchProb = node.type === 'parallelGateway'
? nodeProb
: nodeProb / Math.max(node.outgoing.length, 1)
}
}
const targetId = flow.targetRef
const existing = probs.get(targetId) ?? 0
const accumulated = andJoinIds.has(targetId)
? Math.max(existing, branchProb)
: Math.min(existing + branchProb, 1.0)
probs.set(targetId, accumulated)
}
}
return probs
}
// ─── Motor de simulación principal ───────────────────────────────────────────
export function runSimulation(input: SimulationInput): SimulationResult {
const { processXml, activities, gateways, resources, globalSettings } = input
const warnings: string[] = []
const graph = parseBpmnXml(processXml)
const backEdges = findBackEdges(graph)
warnings.push(...detectLoops(graph, backEdges))
const gatewayFlowProbs = new Map<BpmnElementId, Map<string, number>>()
for (const [elemId, gwConfig] of gateways.entries()) {
const flowMap = new Map<string, number>()
for (const branch of gwConfig.branches) flowMap.set(branch.flowId, branch.probability)
gatewayFlowProbs.set(elemId, flowMap)
}
const execProbs = computeExecutionProbabilities(graph, gatewayFlowProbs, backEdges)
const perActivity: ActivitySimResult[] = []
const resourceTotals = new Map<string, { cost: number; minutes: number; name: string }>()
for (const activity of activities.values()) {
const execProb = execProbs.get(activity.bpmnElementId) ?? 0
const node = graph.nodes.get(activity.bpmnElementId)
if (!node) {
warnings.push(
`La actividad "${activity.name}" (${activity.bpmnElementId}) no se encontró en el diagrama y se omitirá del cálculo.`
)
continue
}
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 cost = resource.costPerHour * hoursUsed * execProb
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
resourceCostDirect += cost
const prev = resourceTotals.get(resource.id) ?? { cost: 0, minutes: 0, name: resource.name }
resourceTotals.set(resource.id, {
cost: prev.cost + cost,
minutes: prev.minutes + activity.executionTimeMinutes * assignment.utilizationPercent * execProb,
name: resource.name,
})
}
const fixedCostExpected = activity.directCostFixed * execProb
const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect
perActivity.push({
activityId: activity.id,
bpmnElementId: activity.bpmnElementId,
activityName: activity.name || activity.bpmnElementId,
expectedDirectCost: totalExpectedDirectCost,
expectedIndirectCost: 0,
expectedTotalCost: 0,
percentOfTotal: 0,
expectedExecutions: execProb,
executionProbability: execProb,
resourceCostBreakdown: resourceBreakdown,
executionTimeMinutes: activity.executionTimeMinutes,
})
}
const totalDirectCost = perActivity.reduce((s, a) => s + a.expectedDirectCost, 0)
const totalIndirectCost = totalDirectCost * globalSettings.overheadPercentage
const totalCost = totalDirectCost + totalIndirectCost
for (const act of perActivity) {
const directRatio = totalDirectCost > 0 ? act.expectedDirectCost / totalDirectCost : 0
act.expectedIndirectCost = totalIndirectCost * directRatio
act.expectedTotalCost = act.expectedDirectCost + act.expectedIndirectCost
act.percentOfTotal = totalCost > 0 ? (act.expectedTotalCost / totalCost) * 100 : 0
}
perActivity.sort((a, b) => b.expectedTotalCost - a.expectedTotalCost)
const perResource: ResourceSimResult[] = Array.from(resourceTotals.entries()).map(([id, data]) => ({
resourceId: id, resourceName: data.name, totalCost: data.cost, totalMinutesUsed: data.minutes,
}))
const totalTimeMinutes = perActivity.reduce(
(s, a) => s + a.executionTimeMinutes * a.executionProbability,
0
)
return { totalCost, totalDirectCost, totalIndirectCost, totalTimeMinutes, perActivity, perResource, warnings }
}