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:
255
src/domain/simulation.ts
Normal file
255
src/domain/simulation.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user