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

153
src/domain/bpmn-parser.ts Normal file
View File

@@ -0,0 +1,153 @@
import type {
ProcessGraph,
ProcessGraphNode,
ProcessGraphFlow,
BpmnNodeType,
BpmnElementId,
} from './types'
// Mapeo de tags XML BPMN → nuestro tipo normalizado
const TAG_TO_TYPE: Record<string, BpmnNodeType> = {
startevent: 'startEvent',
endevent: 'endEvent',
task: 'task',
usertask: 'userTask',
servicetask: 'serviceTask',
scripttask: 'scriptTask',
manualtask: 'manualTask',
businessruletask: 'businessRuleTask',
subprocess: 'subprocess',
exclusivegateway: 'exclusiveGateway',
parallelgateway: 'parallelGateway',
inclusivegateway: 'inclusiveGateway',
eventbasedgateway: 'eventBasedGateway',
intermediatethrowevent: 'intermediateThrowEvent',
intermediatecatchevent: 'intermediateCatchEvent',
}
function normalizeLocalName(tagName: string): string {
// Eliminar namespace prefix (bpmn:Task → task)
const local = tagName.includes(':') ? tagName.split(':')[1] : tagName
return local.toLowerCase()
}
function getAttr(el: Element, name: string): string {
return el.getAttribute(name) ?? ''
}
function parseFlowRefs(el: Element, tagName: string): string[] {
return Array.from(el.getElementsByTagNameNS('*', tagName)).map((ref) => ref.textContent?.trim() ?? '')
}
export class BpmnParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'BpmnParseError'
}
}
export function parseBpmnXml(xml: string): ProcessGraph {
let doc: Document
try {
const parser = new DOMParser()
doc = parser.parseFromString(xml, 'application/xml')
} catch {
throw new BpmnParseError('El archivo no es un XML válido')
}
const parserError = doc.querySelector('parsererror')
if (parserError) {
throw new BpmnParseError('XML malformado: ' + parserError.textContent?.slice(0, 200))
}
const nodes = new Map<BpmnElementId, ProcessGraphNode>()
const flows = new Map<string, ProcessGraphFlow>()
const startEventIds: BpmnElementId[] = []
// Buscar el proceso principal (puede estar anidado bajo definitions)
const processEl = doc.querySelector('[*|localName="process"], process')
if (!processEl) {
throw new BpmnParseError('No se encontró un elemento <process> en el BPMN')
}
// Iterar todos los hijos directos del proceso
for (const child of Array.from(processEl.children)) {
const local = normalizeLocalName(child.tagName)
const type: BpmnNodeType = TAG_TO_TYPE[local] ?? 'unknown'
if (local === 'sequenceflow') {
const flow: ProcessGraphFlow = {
id: getAttr(child, 'id'),
sourceRef: getAttr(child, 'sourceRef'),
targetRef: getAttr(child, 'targetRef'),
name: getAttr(child, 'name') || undefined,
}
if (flow.id) flows.set(flow.id, flow)
continue
}
if (type === 'unknown') continue
const id = getAttr(child, 'id')
if (!id) continue
const node: ProcessGraphNode = {
id,
name: getAttr(child, 'name'),
type,
outgoing: parseFlowRefs(child, 'outgoing'),
incoming: parseFlowRefs(child, 'incoming'),
}
nodes.set(id, node)
if (type === 'startEvent') startEventIds.push(id)
}
if (nodes.size === 0) {
throw new BpmnParseError('El proceso no contiene elementos reconocibles')
}
if (startEventIds.length === 0) {
throw new BpmnParseError('El proceso no tiene evento de inicio (StartEvent)')
}
return { nodes, flows, startEventIds }
}
// Extrae solo los IDs y nombres de los task/subprocess — util para inicializar actividades
export function extractActivityElements(
graph: ProcessGraph
): Array<{ bpmnElementId: BpmnElementId; name: string; type: 'task' | 'subprocess' }> {
const result = []
for (const node of graph.nodes.values()) {
if (
['task', 'userTask', 'serviceTask', 'scriptTask', 'manualTask', 'businessRuleTask'].includes(node.type)
) {
result.push({ bpmnElementId: node.id, name: node.name || node.id, type: 'task' as const })
} else if (node.type === 'subprocess') {
result.push({ bpmnElementId: node.id, name: node.name || node.id, type: 'subprocess' as const })
}
}
return result
}
// Extrae gateways divergentes (los que tienen >1 outgoing)
export function extractGatewayElements(
graph: ProcessGraph
): Array<{ bpmnElementId: BpmnElementId; name: string; gatewayType: string; outgoing: string[] }> {
const result = []
for (const node of graph.nodes.values()) {
if (
['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway'].includes(node.type) &&
node.outgoing.length > 1
) {
result.push({
bpmnElementId: node.id,
name: node.name || node.id,
gatewayType: node.type,
outgoing: node.outgoing,
})
}
}
return result
}

54
src/domain/schemas.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
export const ResourceSchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
name: z.string().min(1, 'El nombre es requerido'),
type: z.enum(['role', 'person', 'system', 'equipment', 'supply']),
costPerHour: z.number().min(0, 'El costo no puede ser negativo'),
})
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationPercent: z.number().min(0).max(1),
})
export const ActivitySchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
bpmnElementId: z.string().min(1),
name: z.string(),
type: z.enum(['task', 'subprocess']),
directCostFixed: z.number().min(0),
executionTimeMinutes: z.number().min(0),
assignedResources: z.array(ActivityResourceAssignmentSchema),
})
export const GatewayBranchSchema = z.object({
flowId: z.string(),
targetElementId: z.string(),
probability: z.number().min(0).max(1),
})
export const GatewayConfigSchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
bpmnElementId: z.string().min(1),
gatewayType: z.enum(['exclusive', 'parallel', 'inclusive', 'event-based']),
branches: z.array(GatewayBranchSchema),
})
export const ProcessSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, 'El nombre del proceso es requerido'),
clientName: z.string(),
bpmnXml: z.string().min(1),
currency: z.string().length(3, 'Código ISO de 3 letras'),
overheadPercentage: z.number().min(0).max(1),
createdAt: z.number(),
updatedAt: z.number(),
})
export type ResourceInput = z.infer<typeof ResourceSchema>
export type ActivityInput = z.infer<typeof ActivitySchema>
export type ProcessInput = z.infer<typeof ProcessSchema>

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 }
}

175
src/domain/types.ts Normal file
View File

@@ -0,0 +1,175 @@
// Tipos de identificadores
export type UUID = string
export type BpmnElementId = string
// ─── Entidades del modelo de dominio ─────────────────────────────────────────
export interface Process {
id: UUID
name: string
clientName: string
bpmnXml: string
currency: string
overheadPercentage: number
createdAt: number
updatedAt: number
}
export type ActivityType = 'task' | 'subprocess'
export interface Activity {
id: UUID
processId: UUID
bpmnElementId: BpmnElementId
name: string
type: ActivityType
directCostFixed: number
executionTimeMinutes: number
assignedResources: ActivityResourceAssignment[]
}
export interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number
}
export type GatewayType = 'exclusive' | 'parallel' | 'inclusive' | 'event-based'
export interface GatewayBranch {
flowId: string
targetElementId: BpmnElementId
probability: number
}
export interface GatewayConfig {
id: UUID
processId: UUID
bpmnElementId: BpmnElementId
gatewayType: GatewayType
branches: GatewayBranch[]
}
export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
export interface Resource {
id: UUID
processId: UUID
name: string
type: ResourceType
costPerHour: number
}
export interface Simulation {
id: UUID
processId: UUID
executedAt: number
result: SimulationResult
}
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
export interface SimulationInput {
processXml: string
activities: Map<BpmnElementId, Activity>
gateways: Map<BpmnElementId, GatewayConfig>
resources: Map<UUID, Resource>
globalSettings: {
currency: string
overheadPercentage: number
}
}
export interface ActivitySimResult {
activityId: UUID
bpmnElementId: BpmnElementId
activityName: string
expectedDirectCost: number
expectedIndirectCost: number
expectedTotalCost: number
percentOfTotal: number
expectedExecutions: number
executionProbability: number
resourceCostBreakdown: Array<{ resourceId: UUID; resourceName: string; cost: number }>
executionTimeMinutes: number
}
export interface ResourceSimResult {
resourceId: UUID
resourceName: string
totalCost: number
totalMinutesUsed: number
}
export interface SimulationResult {
totalCost: number
totalDirectCost: number
totalIndirectCost: number
totalTimeMinutes: number
perActivity: ActivitySimResult[]
perResource: ResourceSimResult[]
warnings: string[]
}
// ─── Grafo en memoria (solo para el parser/motor) ────────────────────────────
export type BpmnNodeType =
| 'startEvent'
| 'endEvent'
| 'task'
| 'userTask'
| 'serviceTask'
| 'scriptTask'
| 'manualTask'
| 'businessRuleTask'
| 'subprocess'
| 'exclusiveGateway'
| 'parallelGateway'
| 'inclusiveGateway'
| 'eventBasedGateway'
| 'intermediateThrowEvent'
| 'intermediateCatchEvent'
| 'unknown'
export interface ProcessGraphNode {
id: BpmnElementId
name: string
type: BpmnNodeType
outgoing: string[]
incoming: string[]
}
export interface ProcessGraphFlow {
id: string
sourceRef: BpmnElementId
targetRef: BpmnElementId
name?: string
}
export interface ProcessGraph {
nodes: Map<BpmnElementId, ProcessGraphNode>
flows: Map<string, ProcessGraphFlow>
startEventIds: BpmnElementId[]
}
// ─── Helpers de clasificación ─────────────────────────────────────────────────
export function isTaskNode(type: BpmnNodeType): boolean {
return ['task', 'userTask', 'serviceTask', 'scriptTask', 'manualTask', 'businessRuleTask', 'subprocess'].includes(type)
}
export function isGatewayNode(type: BpmnNodeType): boolean {
return ['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway'].includes(type)
}
export function bpmnTypeToActivityType(type: BpmnNodeType): ActivityType {
return type === 'subprocess' ? 'subprocess' : 'task'
}
export function bpmnTypeToGatewayType(type: BpmnNodeType): GatewayType {
switch (type) {
case 'parallelGateway': return 'parallel'
case 'inclusiveGateway': return 'inclusive'
case 'eventBasedGateway': return 'event-based'
default: return 'exclusive'
}
}