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
|
||||
}
|
||||
54
src/domain/schemas.ts
Normal file
54
src/domain/schemas.ts
Normal 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
255
src/domain/simulation.ts
Normal 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
175
src/domain/types.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user