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
}