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

View File

@@ -0,0 +1,278 @@
/**
* Tests permanentes del motor de simulación corriendo sobre los 3 sample BPMNs.
* Protegen contra regresiones en runSimulation, la propagación de probabilidades
* y la detección de loops.
*
* Costos dummy predecibles para que las aserciones sean deterministas.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types'
function loadSample(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
// Construye un SimulationInput con costos fijos iguales para todas las actividades
function buildInput(
xml: string,
opts: {
directCostFixed?: number
executionTimeMinutes?: number
overheadPercentage?: number
gatewayProbsOverride?: Record<string, number[]>
} = {}
): SimulationInput {
const {
directCostFixed = 100,
executionTimeMinutes = 30,
overheadPercentage = 0.2,
gatewayProbsOverride = {},
} = opts
const processId = 'sim-test'
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const activities = new Map<string, Activity>(
actEls.map((el) => [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed,
executionTimeMinutes,
assignedResources: [],
},
])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
const probsOverride = gatewayProbsOverride[el.bpmnElementId]
return [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId, i) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability:
probsOverride?.[i] ??
(gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(6))),
})),
},
]
})
)
return {
processXml: xml,
activities,
gateways,
resources: new Map<string, Resource>(),
globalSettings: { currency: 'USD', overheadPercentage },
}
}
// ─── simple-linear.bpmn ──────────────────────────────────────────────────────
describe('simulación / simple-linear.bpmn', () => {
const xml = loadSample('simple-linear.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (proceso sin loops ni gateways)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('costo total = 5 × 100 × 1.2 = 600 (5 tareas, prob=1, overhead=20%)', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100, overheadPercentage: 0.2 }))
expect(result.totalDirectCost).toBe(500)
expect(result.totalIndirectCost).toBeCloseTo(100)
expect(result.totalCost).toBeCloseTo(600)
})
it('perActivity tiene 5 entradas, todas con executionProbability = 1', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(5)
result.perActivity.forEach((a) => {
expect(a.executionProbability).toBeCloseTo(1.0)
})
})
it('perActivity ordenado descendente por expectedTotalCost', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
it('percentOfTotal suma exactamente 100%', () => {
const result = runSimulation(buildInput(xml))
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 1)
})
it('tiempo total = 5 × 30 min = 150 min', () => {
const result = runSimulation(buildInput(xml, { executionTimeMinutes: 30 }))
expect(result.totalTimeMinutes).toBeCloseTo(150)
})
})
// ─── medium-with-gateways.bpmn ───────────────────────────────────────────────
describe('simulación / medium-with-gateways.bpmn', () => {
const xml = loadSample('medium-with-gateways.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (sin loops)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('perActivity tiene exactamente 11 entradas', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(11)
})
it('actividades antes de cualquier gateway tienen prob = 1.0', () => {
const result = runSimulation(buildInput(xml))
const recv = result.perActivity.find((a) => a.bpmnElementId === 'task_recv')
const valid = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
expect(recv?.executionProbability).toBeCloseTo(1.0)
expect(valid?.executionProbability).toBeCloseTo(1.0)
})
it('gateway XOR con probs 0.7/0.3 propaga correctamente', () => {
// gw_datos: completo(idx 0)=0.7, incompleto(idx 1)=0.3
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: { gw_datos: [0.7, 0.3] },
})
)
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
const pedirDocs = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(score?.executionProbability).toBeCloseTo(0.7, 1)
expect(pedirDocs?.executionProbability).toBeCloseTo(0.3, 1)
})
it('gateway paralelo AND: ambas ramas tienen prob igual a la entrada', () => {
// gw_datos completo=1.0 para simplificar, gw_parallel_split AND ambas =1
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: {
gw_datos: [1.0, 0.0], // siempre el camino completo
gw_decision: [0.5, 0.5],
},
})
)
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambas ramas del AND deben tener prob similar (≈ prob del score)
expect(bureau?.executionProbability).toBeGreaterThan(0)
expect(empleador?.executionProbability).toBeGreaterThan(0)
expect(Math.abs((bureau?.executionProbability ?? 0) - (empleador?.executionProbability ?? 0))).toBeLessThan(0.01)
})
it('perActivity ordenado descendente por costo', () => {
const result = runSimulation(buildInput(xml))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
})
// ─── complex-with-loop.bpmn ───────────────────────────────────────────────────
describe('simulación / complex-with-loop.bpmn', () => {
const xml = loadSample('complex-with-loop.bpmn')
it('corre sin lanzar error (a pesar del loop)', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('🚨 genera al menos un warning de loop detectado', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings.length).toBeGreaterThan(0)
const hasLoopWarning = result.warnings.some(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(hasLoopWarning).toBe(true)
})
it('el warning menciona el nodo "Inspección visual y funcional"', () => {
const result = runSimulation(buildInput(xml))
const loopWarning = result.warnings.find(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(loopWarning).toContain('Inspección visual y funcional')
})
it('resultado es válido a pesar del loop: costo > 0 y 9 actividades', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
expect(result.totalCost).toBeGreaterThan(0)
expect(result.perActivity).toHaveLength(9)
})
it('con probabilidades reales (60% aprobado, 30% correcciones, 10% rechazo)', () => {
const result = runSimulation(
buildInput(xml, {
directCostFixed: 200,
overheadPercentage: 0.15,
gatewayProbsOverride: {
// gw_resultado: 3 salidas — aprobado 0.6, obs_menores 0.3, rechazo 0.1
gw_resultado: [0.6, 0.3, 0.1],
},
})
)
expect(result.totalCost).toBeGreaterThan(0)
expect(result.warnings.length).toBeGreaterThan(0)
// task_inspeccion tiene prob alta (es el nodo más costoso del proceso con loop)
const inspeccion = result.perActivity.find((a) => a.bpmnElementId === 'task_inspeccion')
expect(inspeccion).toBeDefined()
expect(inspeccion!.executionProbability).toBeGreaterThan(0)
// task_correcciones tiene prob = prob del camino de correcciones ≈ 0.3
const correcciones = result.perActivity.find((a) => a.bpmnElementId === 'task_correcciones')
expect(correcciones).toBeDefined()
expect(correcciones!.executionProbability).toBeCloseTo(0.3, 1)
// task_rechazo tiene prob ≈ 0.1
const rechazo = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazo')
expect(rechazo).toBeDefined()
expect(rechazo!.executionProbability).toBeCloseTo(0.1, 1)
})
it('percentOfTotal suma ≈ 100%', () => {
const result = runSimulation(buildInput(xml))
if (result.perActivity.length > 0) {
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 0)
}
})
})