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 { const backEdges = new Set() const WHITE = 0, GRAY = 1, BLACK = 2 const color = new Map() 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[] { const warnings: string[] = [] const seen = new Set() 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): BpmnElementId[] { const inDegree = new Map() 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>, backEdges: Set ): Map { const andJoinIds = new Set() 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() 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>() for (const [elemId, gwConfig] of gateways.entries()) { const flowMap = new Map() 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() 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 } }