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