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,157 @@
/**
* Tests permanentes de validación de gateways.
* Protegen las reglas de negocio: XOR debe sumar 1, AND no tiene probs editables.
*/
import { describe, it, expect } from 'vitest'
import { isXorSumValid } from '@/features/workspace/GatewayPanel'
import { runSimulation } from '@/domain/simulation'
import type { GatewayConfig, SimulationInput, Activity } from '@/domain/types'
import { v4 as uuidv4 } from 'uuid'
import { readFileSync } from 'fs'
import { resolve } from 'path'
function makeBranches(probs: number[]): GatewayConfig['branches'] {
return probs.map((p, i) => ({ flowId: `f${i}`, targetElementId: `t${i}`, probability: p }))
}
function makeXorGateway(probs: number[]): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: makeBranches(probs),
}
}
function makeParallelGateway(n: number): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'parallel',
branches: makeBranches(Array(n).fill(1.0)),
}
}
// ─── isXorSumValid ────────────────────────────────────────────────────────────
describe('isXorSumValid', () => {
it('válido: suma exactamente 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.6, 0.4]))).toBe(true)
})
it('válido: suma = 1.0 con 3 ramas (0.5 + 0.3 + 0.2)', () => {
expect(isXorSumValid(makeXorGateway([0.5, 0.3, 0.2]))).toBe(true)
})
it('válido: tolerancia de 0.5% — 0.999 se acepta como 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.499, 0.501]))).toBe(true)
})
it('inválido: suma = 0.5 (faltan 50%)', () => {
expect(isXorSumValid(makeXorGateway([0.3, 0.2]))).toBe(false)
})
it('inválido: suma = 1.5 (excede 100%)', () => {
expect(isXorSumValid(makeXorGateway([0.8, 0.7]))).toBe(false)
})
it('inválido: suma = 0 (gateways sin configurar)', () => {
expect(isXorSumValid(makeXorGateway([0, 0]))).toBe(false)
})
it('gateway paralelo siempre válido (no aplica regla de suma)', () => {
expect(isXorSumValid(makeParallelGateway(2))).toBe(true)
expect(isXorSumValid(makeParallelGateway(3))).toBe(true)
})
it('gateway inclusivo tratado como exclusivo — aplica regla de suma', () => {
const gw: GatewayConfig = { ...makeXorGateway([0.6, 0.4]), gatewayType: 'inclusive' }
// inclusive usa isXorSumValid solo para el display; el motor lo trata igual
// Aquí verificamos que la función no lanza para gateways inclusive
expect(() => isXorSumValid(gw)).not.toThrow()
})
})
// ─── Motor: gateways con distribución 70/30 sobre medium BPMN ────────────────
describe('motor / propagación con GatewayConfig correcta', () => {
const xml = readFileSync(resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'), 'utf-8')
function buildSimInput(datosProbs: [number, number], decisionProbs: [number, number]): SimulationInput {
const processId = 'gv-test'
const actIds = ['task_recv', 'task_valid', 'task_pedir_docs', 'task_score',
'task_bureau', 'task_empleador', 'task_analisis',
'task_aprobar', 'task_rechazar', 'task_desembolso', 'task_archivo']
const activities = new Map<string, Activity>(
actIds.map((id) => [id, {
id: uuidv4(), processId, bpmnElementId: id, name: id,
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>([
['gw_datos', {
id: uuidv4(), processId, bpmnElementId: 'gw_datos', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_gw1_completo', targetElementId: 'task_score', probability: datosProbs[0] },
{ flowId: 'flow_gw1_incompleto', targetElementId: 'task_pedir_docs', probability: datosProbs[1] },
],
}],
['gw_parallel_split', {
id: uuidv4(), processId, bpmnElementId: 'gw_parallel_split', gatewayType: 'parallel',
branches: [
{ flowId: 'flow_para_bureau', targetElementId: 'task_bureau', probability: 1.0 },
{ flowId: 'flow_para_empleador', targetElementId: 'task_empleador', probability: 1.0 },
],
}],
['gw_decision', {
id: uuidv4(), processId, bpmnElementId: 'gw_decision', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_dec_aprobado', targetElementId: 'task_aprobar', probability: decisionProbs[0] },
{ flowId: 'flow_dec_rechazado', targetElementId: 'task_rechazar', probability: decisionProbs[1] },
],
}],
])
return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 } }
}
it('rama "completo" 70% → task_score tiene prob ≈ 0.7', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
expect(score?.executionProbability).toBeCloseTo(0.7, 2)
})
it('rama "incompleto" 30% → task_pedir_docs tiene prob ≈ 0.3', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const pedir = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(pedir?.executionProbability).toBeCloseTo(0.3, 2)
})
it('task_aprobar tiene prob = 0.8 (task_analisis converge ambos caminos a 1.0, luego 80% decision)', () => {
// task_analisis recibe:
// - camino completo (70%): AND join correcto → 0.7
// - camino incompleto (30%): task_pedir_docs → 0.3
// - suma XOR: 0.7 + 0.3 = 1.0
// gw_decision → task_aprobar: 1.0 × 0.8 = 0.8
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const analisis = result.perActivity.find((a) => a.bpmnElementId === 'task_analisis')
const aprobar = result.perActivity.find((a) => a.bpmnElementId === 'task_aprobar')
const rechazar = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazar')
expect(analisis?.executionProbability).toBeCloseTo(1.0, 2)
expect(aprobar?.executionProbability).toBeCloseTo(0.8, 2)
expect(rechazar?.executionProbability).toBeCloseTo(0.2, 2)
})
it('gateway AND: bureau y empleador tienen la misma prob que score', () => {
const result = runSimulation(buildSimInput([1.0, 0.0], [0.5, 0.5]))
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambos deben tener la misma probabilidad (AND paralelo)
expect(bureau?.executionProbability).toBeCloseTo(empleador?.executionProbability ?? -1, 2)
})
it('con probs que no suman 1 el motor no explota, solo produce resultados parciales', () => {
// Esto valida que el motor es robusto aunque la UI deba prevenirlo
expect(() => runSimulation(buildSimInput([0.3, 0.3], [0.5, 0.5]))).not.toThrow()
})
})