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>
256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
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 }
|
|
}
|