Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.
This commit is contained in:
262
CHANGELOG.md
Normal file
262
CHANGELOG.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Changelog
|
||||
|
||||
Historial de cambios del proyecto. Formato basado en [Keep a Changelog](https://keepachangelog.com/es/).
|
||||
Los cambios se describen desde la perspectiva del código: qué archivos cambiaron,
|
||||
por qué, y qué decisiones de diseño están implícitas — para que futuras
|
||||
modificaciones profundas tengan contexto suficiente.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] — Sprint 1: ROI y Escenarios de Automatización
|
||||
|
||||
### Estado
|
||||
En construcción. Etapas 1–4 completas; Etapas 5–6 pendientes.
|
||||
|
||||
### Etapa 1 — Cimientos del dominio (completada)
|
||||
|
||||
**Nuevos campos en el modelo de dominio** (`src/domain/types.ts`):
|
||||
|
||||
- `Activity` gana: `automatable: boolean`, `automatedCostFixed: number`, `automatedTimeMinutes: number`
|
||||
- `Process` gana: `annualFrequency: number`, `analysisHorizonYears: number`, `automationInvestment: number`
|
||||
- Nuevo tipo `Scenario = 'actual' | 'automated'`
|
||||
- `SimulationInput` acepta `scenario?: Scenario` (opcional, default `'actual'` para retrocompatibilidad)
|
||||
- `Simulation` gana `resultAutomated?: SimulationResult` (opcional, para retrocompatibilidad con snapshots viejos)
|
||||
|
||||
**Migración Dexie v1 → v2** (`src/persistence/db.ts`):
|
||||
|
||||
- `version(2).upgrade()` popula defaults en registros existentes:
|
||||
- Activities: `automatable=false`, `automatedCostFixed=0`, `automatedTimeMinutes=0`
|
||||
- Processes: `annualFrequency=1000`, `analysisHorizonYears=3`, `automationInvestment=0`
|
||||
- Sin cambio de índices — solo nuevos campos con defaults seguros.
|
||||
|
||||
**Nuevo módulo de ROI** (`src/domain/roi.ts`):
|
||||
|
||||
- Función pura `calculateRoi(input: RoiInput): RoiResult`
|
||||
- Fórmulas nominales simples (sin VPN ni TIR — decisión de diseño ADR-014)
|
||||
- Edge cases explicitados: inversión cero, ahorro negativo, costo actual cero (evitar div/0)
|
||||
- `paybackMonths = Infinity` cuando no hay ahorro, `roiAnnualPercent = Infinity` cuando inversión=0 y ahorro>0
|
||||
|
||||
**Motor de simulación dual** (`src/domain/simulation.ts`):
|
||||
|
||||
- Acepta `scenario` en `SimulationInput`
|
||||
- En `'automated'`: actividades con `automatable=true` usan `automatedCostFixed`/`automatedTimeMinutes`
|
||||
- Las no-automatable mantienen sus costos originales en ambos escenarios
|
||||
- La probabilidad de ejecución NO cambia entre escenarios — solo costos y tiempos
|
||||
|
||||
**Simulation store refactorizado** (`src/store/simulation-store.ts`):
|
||||
|
||||
- `result` (single) → `resultActual + resultAutomated + lastSimulatedAt`
|
||||
- Nueva acción `persistResults(processId, actual, automated)`: guarda en Dexie + actualiza memoria en un solo paso atómico
|
||||
- Nueva acción `invalidateResults()`: pone ambos a null
|
||||
- Suscripción reactiva a `process-store`: cualquier cambio en `activities` o `currentProcess` invalida ambos resultados automáticamente
|
||||
- **Dependencia intencional documentada**: `simulation-store` importa `process-store` en una dirección (sin inversa → sin ciclo)
|
||||
|
||||
**useSimulate refactorizado** (`src/features/simulation/useSimulate.ts`):
|
||||
|
||||
- Corre las dos simulaciones en el mismo ciclo y llama `persistResults` del store (eliminando el import directo de `simulationRepo` desde un hook de feature)
|
||||
- Retorna `{ resultActual, resultAutomated }` en lugar de un único result
|
||||
|
||||
**Infraestructura ESLint** (`eslint.config.js` — archivo nuevo):
|
||||
|
||||
- Primer config de ESLint del proyecto (antes no existía)
|
||||
- Regla `no-restricted-imports`: prohíbe importar `persistence/repositories` desde fuera de `store/`
|
||||
- Overrides para `store/**`, `features/import/**`, `features/report/useReportData.ts`, `tests/**`
|
||||
- Regla `react-hooks/set-state-in-effect: 'off'`: patrón de sync local state con useEffect es válido en este codebase
|
||||
- **Por qué importa**: si alguien llama `activityRepo.save()` directamente desde una feature sin pasar por el store, los resultados de simulación quedan stale (la suscripción reactiva no dispara)
|
||||
|
||||
**Tests nuevos** (32 tests):
|
||||
|
||||
- `tests/domain/roi.test.ts`: 16 casos cubriendo happy path, inversión cero, ahorro negativo, NaN
|
||||
- `tests/integration/dexie-migration.test.ts`: 6 casos de persistencia de los nuevos campos con valores default y no-default
|
||||
- `tests/store/simulation-store-invalidation.test.ts`: 7 casos de la invariante de invalidación dual
|
||||
- `tests/integration/sprint1-flow.test.ts`: 11 casos del flujo integrado BPMN → sim actual → sim automated → calculateRoi → KPIs
|
||||
- `tests/domain/simulation.test.ts`: +4 casos de `scenario=automated` con costos en cero, sin NaN, probabilidad invariante
|
||||
- `tests/integration/bpmn-collaboration.test.ts`: 3 casos para BPMNs de colaboración con múltiples pools
|
||||
|
||||
**Archivos de test actualizados** (fixtures con nuevos campos):
|
||||
|
||||
- `tests/integration/simulation-engine.test.ts`
|
||||
- `tests/integration/gateway-validation.test.ts`
|
||||
- `tests/integration/indexeddb-persistence.test.ts`
|
||||
- `tests/features/report/components.test.tsx`
|
||||
- `tests/features/report/derivations.test.ts`
|
||||
- `tests/lib/export/measure-sizes.test.ts`
|
||||
- `tests/lib/export/csv-export.test.ts`
|
||||
- `tests/lib/export/pdf-export.test.ts`
|
||||
- `tests/setup.ts`: mocks globales `ResizeObserver` y `PointerEvent` para jsdom (necesario desde Sprint 1 para tests con Slider de Radix)
|
||||
|
||||
### Etapa 2 — UI de Configuración (completada)
|
||||
|
||||
**Switch component** (`src/components/ui/switch.tsx` — nuevo):
|
||||
|
||||
- Implementación Tailwind pura sin `@radix-ui/react-switch`
|
||||
- `role="switch"` + `aria-checked` + `aria-label` para accesibilidad
|
||||
- Transiciones CSS: `transition-colors duration-200` en el fondo, `transition-transform duration-200` en el knob
|
||||
- **Decisión**: no instalar `@radix-ui/react-switch` para no agregar dependencia — el comportamiento visual es idéntico
|
||||
|
||||
**ActivityPanel ampliado** (`src/features/workspace/ActivityPanel.tsx`):
|
||||
|
||||
- Nueva sección "Automatización" al pie del panel, separada con `<Separator>`
|
||||
- Toggle Switch (`automatable`) oculta/muestra los campos con `transition-all duration-200 overflow-hidden max-h-{0|80}`
|
||||
- **Limitación conocida**: el max-height trick no anima a la altura real del contenido. Ver TODO.md para mejora con `useRef + scrollHeight`
|
||||
- Inputs numéricos `automatedCostFixed` y `automatedTimeMinutes` con mismo patrón que los existentes (placeholder cuando valor=0)
|
||||
- Helper ámbar de transparencia metodológica:
|
||||
- Aparece cuando `automatable=true && cost===0 && time===0`
|
||||
- **Siempre está en el DOM** (no conditional render) — la visibilidad se controla con `opacity-0/100 + max-h-0/20 + transition-all duration-150`
|
||||
- `aria-hidden` refleja el estado de visibilidad
|
||||
- **Por qué importa**: con conditional render (`&&`) el helper desaparece instantáneamente al tipear — sin transición. Al mantenerlo en el DOM, el fade de 150ms se aplica correctamente
|
||||
|
||||
**GlobalSettingsPanel ampliado** (`src/features/workspace/GlobalSettingsPanel.tsx`):
|
||||
|
||||
- Nueva sección "Análisis de Impacto" al pie del panel
|
||||
- `annualFrequency`: input numérico, min=1, sugerencias de referencia en texto helper
|
||||
- `analysisHorizonYears`: Slider de 1 a 10 años con valor en texto ("3 años")
|
||||
- `automationInvestment`: input numérico, default placeholder vacío cuando valor=0
|
||||
- La validación de rango de `analysisHorizonYears` (clamp 1-10) ocurre en `handleSave`, no en `onChange`
|
||||
|
||||
**BpmnCanvas ampliado** (`src/features/workspace/BpmnCanvas.tsx`):
|
||||
|
||||
- Nueva prop `automatableElementIds?: string[]`
|
||||
- `syncAutomationOverlays()`: callback memoizado con `useCallback([automatableElementIds])`
|
||||
- Limpia overlays anteriores con `overlays.remove({ type: 'automation-indicator' })`
|
||||
- Re-agrega un badge por cada elementId en la lista
|
||||
- Se llama después de `importXML` (en el `.then()`) y en un `useEffect` separado cuando cambia la lista
|
||||
- **Posicionamiento del badge**: `{ top: -8, left: element.width - 12 }`
|
||||
- Obtenido via `elementRegistry.get(elementId)` con fallback `width ?? 100`
|
||||
- **No usar** `position.right` de diagram-js: con `right: -8`, la fórmula interna `left = right * -1 + width` da `left = 8 + width`, colocando el badge enteramente fuera del nodo sin overlap de esquina
|
||||
- Con `left = width - 12`: badge left está 12px antes del borde derecho, su borde derecho queda 8px fuera → overlap visible en la esquina
|
||||
- **Badge HTML**: círculo 20×20px, fondo `#fffbeb` (amber-50), borde `#e2e8f0`, sombra `0 1px 3px rgba(0,0,0,0.12)`, ícono Bot Lucide en `#f59e0b` como SVG inline
|
||||
- `pointer-events: none` para no interferir con clicks en el nodo
|
||||
- `title` nativo del browser como tooltip
|
||||
- **Por qué `#fffbeb` y no `#ffffff`**: el fondo de los nodos BPMN también es blanco, el fondo ámbar le da identidad cromática propia y hace visible el círculo por contraste
|
||||
|
||||
**WorkspacePage** (`src/features/workspace/WorkspacePage.tsx`):
|
||||
|
||||
- Destructura `activities` del process-store
|
||||
- Calcula `automatableElementIds` con `useMemo`
|
||||
- Pasa `useDeferredValue(automatableElementIds)` al BpmnCanvas (evita cascada de re-renders al editar el panel)
|
||||
|
||||
**AutomationScenarioNote** (`src/features/report/AutomationScenarioNote.tsx` — nuevo):
|
||||
|
||||
- Componente de leyenda para el tab "Automatizado" del reporte
|
||||
- Preparado y listo para ser colocado por Etapa 3 — no tiene lógica propia
|
||||
|
||||
### Etapa 3 — Motor dual y reporte con tabs (completada)
|
||||
|
||||
**ReportPage ampliada** (`src/features/report/ReportPage.tsx`):
|
||||
|
||||
- La página ahora tiene 3 tabs: **Actual** | **Automatizado** | **Comparación + ROI**
|
||||
- El contenido existente (KpiBar, heatmap, 3 gráficos, tabla de actividades) pasa intacto al tab "Actual"
|
||||
- Los tabs "Automatizado" y "ROI" se deshabilitan si la simulación no tiene `resultAutomated` (snapshot viejo) — en ese caso se muestra `NeedsResimulate` que guía al usuario a re-simular
|
||||
- `useMemo` para `automatableIds` y `roi` se declaran antes de los early returns (cumple reglas de hooks)
|
||||
- Los refs de heatmap son independientes por tab (`heatmapRef` / `heatmapRefAuto`) — el tab "Actual" sigue siendo el fuente de imagen para el export PDF
|
||||
- Export PDF/CSV sin cambios — Etapa 4 los ampliará
|
||||
|
||||
**Tab "Automatizado"**:
|
||||
|
||||
- Mismo layout que "Actual": KpiBar → heatmap con cross-linking → 3 gráficos → tabla
|
||||
- Usa `simulation.resultAutomated` como fuente de datos
|
||||
- `AutomationScenarioNote` aparece al pie del heatmap (componente preparado en Etapa 2)
|
||||
- Heatmap y tabla tienen su propio estado `selectedIdAuto` — cross-linking funciona independientemente del tab "Actual"
|
||||
|
||||
**Tab "Comparación + ROI"**:
|
||||
|
||||
- `RoiKpiBar` (`src/features/report/RoiKpiBar.tsx` — nuevo): 5 KPI cards en grid 2×5 (lg)
|
||||
- Ahorro por ejecución (con % sobre costo actual)
|
||||
- Ahorro anual
|
||||
- Payback (meses o años según magnitud; "Inmediato" si inversión=0; "No se recupera" si Infinity)
|
||||
- Ahorro acumulado neto a N años
|
||||
- ROI anual % ("∞%" si inversión=0 y ahorro>0)
|
||||
- Los valores positivos se muestran en `text-emerald-700`, negativos en `text-red-600`
|
||||
- `ComparisonTable` (`src/features/report/ComparisonTable.tsx` — nuevo):
|
||||
- Tabla actividad por actividad: costo actual | costo automatizado | ahorro | % ahorro
|
||||
- Ícono Bot ámbar en la primera columna para actividades marcadas `automatable`
|
||||
- Ordenable por ahorro (↕) — default descendente
|
||||
- Usa `Set<bpmnElementId>` para el lookup de `automatable` en O(1)
|
||||
- `CumulativeSavingsChart` (`src/features/report/CumulativeSavingsChart.tsx` — nuevo):
|
||||
- Gráfico de línea + área (ECharts): X=años 0..N, Y=ahorro neto acumulado
|
||||
- Línea de break-even en y=0 siempre visible
|
||||
- Marker vertical dashed verde en `x=paybackYears` (solo si finite y dentro del horizonte)
|
||||
- Tooltip muestra valor del año exacto con color según signo
|
||||
- `TopSavingsChart` (`src/features/report/TopSavingsChart.tsx` — nuevo):
|
||||
- Top 5 actividades por ahorro positivo — barras horizontales verdes
|
||||
- Excluye actividades con ahorro ≤ 0
|
||||
- Altura dinámica: `max(180, n * 48)` px — no hay espacio vacío si hay menos de 5 actividades
|
||||
- `TransparencySection` (`src/features/report/TransparencySection.tsx` — nuevo):
|
||||
- Condicional — solo se renderiza si hay issues metodológicos detectados
|
||||
- **Issue 1**: actividades con `automatable=true` pero `automatedCostFixed=0` y `automatedTimeMinutes=0` → el ahorro puede estar inflado. Lista los nombres de cada actividad
|
||||
- **Issue 2**: `automationInvestment=0` → payback "inmediato" e ROI "infinito" no reflejan la realidad
|
||||
- Fondo amber-50, borde amber-200 — consistente con el helper del ActivityPanel
|
||||
|
||||
**Por qué ROI inline y no en `useReportData`**:
|
||||
El `calculateRoi` toma `simulation.result.totalCost` y `simulation.resultAutomated.totalCost` — valores ya disponibles en el componente. Moverlo al hook agregaría un `useEffect` extra sin beneficio. Se mantiene en el componente como `useMemo`.
|
||||
|
||||
**Tests nuevos** (17 tests):
|
||||
|
||||
- `tests/features/workspace/ActivityPanel.test.tsx`: 6 casos — toggle, helper ámbar con `aria-hidden`, dispatch de `updateActivity`
|
||||
- `tests/features/workspace/GlobalSettingsPanel.test.tsx`: 7 casos — defaults, edición de campos, dispatch de `updateProcess`
|
||||
- `tests/features/workspace/BpmnCanvas.test.tsx`: 4 casos — overlay add/remove, posición `left=width-12`, HTML con `#fffbeb` y `#f59e0b`
|
||||
|
||||
**Patrón de mocks para tests de componentes** (documentado para referencia futura):
|
||||
|
||||
- Los mocks del store con `vi.hoisted()` devuelven referencias estables
|
||||
- Sin `vi.hoisted`: cada llamada a `useProcessStore()` devuelve un nuevo objeto → el `useEffect([activities])` de ActivityPanel dispara en loop infinito → "Maximum update depth exceeded"
|
||||
- `simulation-store.ts` importa `process-store.ts` al nivel de módulo: los mocks deben ser compatibles o el store no carga
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0-mvp] — MVP Fase 0 — 2026-05-13
|
||||
|
||||
### Contexto
|
||||
|
||||
Primera entrega funcional. Construida en una sesión de trabajo.
|
||||
Objetivo: demostrar en ≤15 minutos que se puede transformar un BPMN en un reporte de costos.
|
||||
|
||||
### Funcionalidades
|
||||
|
||||
- Importar BPMN 2.0 por drag & drop o desde procesos de ejemplo
|
||||
- Renderizar diagrama en canvas read-only (bpmn-js Viewer)
|
||||
- Configurar actividades: costo fijo, tiempo, recursos asignados con utilización
|
||||
- CRUD de recursos (rol/persona/sistema/equipo/insumo + costo/hora)
|
||||
- Configuración global: moneda, overhead %, nombre proceso, cliente
|
||||
- Motor de simulación determinístico con propagación de probabilidades por gateways XOR, AND, inclusivo
|
||||
- Detección de loops (back-edges via DFS): se advierte pero no bloquea
|
||||
- Reporte visual: mapa de calor en dos modos (relativo/absoluto), KPIs, top actividades, composición, costo por recurso
|
||||
- Export PDF: 3 páginas (header+KPIs+heatmap, análisis, tabla detalle), con jsPDF-autotable
|
||||
- Export CSV: con BOM UTF-8 y metadatos del proceso
|
||||
- Persistencia en IndexedDB (Dexie v1)
|
||||
- Deploy estático en Cloudflare Pages
|
||||
|
||||
### Decisiones de diseño relevantes para el futuro
|
||||
|
||||
**Parser BPMN multi-pool** (`bpmn-parser.ts`):
|
||||
|
||||
- Itera todos los `<bpmn:process>` hijos directos de `<definitions>` (no solo el primero)
|
||||
- Fix crítico: el parser original usaba `querySelector` que retorna solo el primero → todos los pools de una colaboración excepto el primero eran ignorados
|
||||
- Pools de soporte (SAP, bandeja manual) pueden no tener StartEvent propio — la validación exige al menos uno entre todos los pools
|
||||
|
||||
**Heatmap sobre bpmn-js** (`HeatmapCanvas.tsx`):
|
||||
|
||||
- bpmn-js rechaza la promesa de `importXML` pero el diagrama se renderiza parcialmente → usar `eventBus.on('import.done', ...)` en lugar de `.then()`
|
||||
- `element.style.fill = color` (NO `setAttribute('fill', color)`): bpmn-js aplica CSS que tiene mayor especificidad que atributos SVG
|
||||
- `captureImage()` espera 500ms después de importar para que la transición CSS `fill 0.35s ease` complete antes de la captura con html2canvas
|
||||
|
||||
**Validación XOR en WorkspacePage** (`WorkspacePage.tsx`):
|
||||
|
||||
- `hasLoadedOnce` ref: evita que el redirect "proceso no encontrado" dispare en el render inicial donde `isLoading=false` y `currentProcess=null` (antes del primer efecto de carga)
|
||||
|
||||
### Tests
|
||||
|
||||
268 tests unitarios + integración al cierre de MVP.
|
||||
5 tests E2E con Playwright (import → simulate → export PDF/CSV × 3 BPMNs + acentos + gradiente de color).
|
||||
El test de gradiente verifica con `sharp` que el heatmap del PDF contiene píxeles verdes, ámbar y rojos — evita regresiones en la lógica de coloreo.
|
||||
|
||||
---
|
||||
|
||||
## Convenciones de este changelog
|
||||
|
||||
- **Decisiones de diseño**: se documenta el *por qué*, no solo el *qué*
|
||||
- **Gotchas**: comportamientos contrarios a la intuición se marcan explícitamente
|
||||
- **Edge cases**: si un edge case motivó un test específico, se menciona
|
||||
- **Retrocompatibilidad**: cuando un campo es opcional por retrocompat, se indica
|
||||
- Las entradas son para humanos que modificarán el código, no para usuarios finales
|
||||
@@ -112,6 +112,21 @@ Antes de presentar el reporte de ROI al cliente, validar:
|
||||
| Payback | 6 - 24 meses | < 3 meses: revisar inversión; > 36 meses: revisar el caso de negocio |
|
||||
| Tasa de excepciones | 2% - 15% | < 1%: optimista; > 20%: el proceso quizás no es buen candidato |
|
||||
| Ahorro % por ejecución | 70% - 95% | < 50%: validar si vale la pena; > 95%: revisar excepciones |
|
||||
| **ROI anual** | **50% - 1.000%** | **> 1.000%: señal de inputs incorrectos — ver abajo** |
|
||||
|
||||
### ⚠️ ROI superior al 1.000% anual
|
||||
|
||||
La plataforma muestra una advertencia automática en la sección "Transparencia metodológica" cuando el ROI anualizado supera el 1.000% (y es un valor finito — no Infinity por inversión en cero).
|
||||
|
||||
Un ROI de este nivel es matemáticamente posible pero comercialmente inverosímil. Las causas más frecuentes son:
|
||||
|
||||
1. **Frecuencia anual sobreestimada**: confundir instancias del proceso con ejecuciones individuales de actividades. Verificar con datos reales del cliente.
|
||||
|
||||
2. **Costo automatizado por ejecución subestimado**: olvidar el componente de excepciones, el mantenimiento o la infra. Usar la fórmula de los 4 componentes de este documento.
|
||||
|
||||
3. **Inversión en automatización subestimada**: solo se incluyó el desarrollo y se olvidaron licencias, capacitación e implementación.
|
||||
|
||||
**Acción recomendada**: corregir los inputs, no ignorar la advertencia. El cliente —o alguien de su equipo técnico— va a hacer las mismas cuentas y un ROI de 10.000% destruye credibilidad.
|
||||
|
||||
---
|
||||
|
||||
|
||||
247
README.md
247
README.md
@@ -1,46 +1,223 @@
|
||||
# Process Cost Platform — MVP Fase 0
|
||||
# Process Cost Platform
|
||||
|
||||
Plataforma web que transforma un archivo BPMN 2.0 en un reporte visual
|
||||
de costos operativos, listo para presentar a clientes en minutos.
|
||||
100% en el navegador — sin backend, sin registro, datos guardados localmente.
|
||||
> Transformá un archivo BPMN 2.0 en un reporte visual de costos operativos —
|
||||
> y calculá el ROI de automatizar ese proceso — en minutos, sin backend, sin registro.
|
||||
|
||||
## Cómo usarlo
|
||||
**Hecho desde Paraguay 🇵🇾 · Herramienta interna de InQuality · Sprint 1 en desarrollo activo**
|
||||
|
||||
1. **Importá** tu archivo `.bpmn` arrastrándolo a la pantalla principal,
|
||||
o cargá uno de los tres procesos de ejemplo incluidos.
|
||||
2. **Configurá** los costos fijos y tiempos de cada actividad en el workspace.
|
||||
Ajustá las probabilidades de los gateways XOR si las hay.
|
||||
3. **Simulá** para generar el análisis agregado de costos.
|
||||
4. **Exportá** el reporte en PDF (presentación para el cliente) o CSV (datos).
|
||||
---
|
||||
|
||||
## Stack
|
||||
## ¿Qué hace esto?
|
||||
|
||||
| Capa | Tecnología |
|
||||
|---|---|
|
||||
| Framework | React 19 + Vite 8 |
|
||||
| Routing | TanStack Router |
|
||||
| Estado | Zustand 5 |
|
||||
| Canvas BPMN | bpmn-js 18 (Viewer) |
|
||||
| UI | shadcn/ui + Tailwind CSS |
|
||||
| Gráficos | Apache ECharts |
|
||||
| Persistencia | IndexedDB vía Dexie 4 |
|
||||
| Export PDF | jsPDF + jspdf-autotable |
|
||||
| Export CSV | papaparse |
|
||||
| Testing | Vitest + Playwright |
|
||||
| Deploy | Cloudflare Pages |
|
||||
Un consultor de procesos recibe un diagrama BPMN del cliente. Con esta plataforma puede:
|
||||
|
||||
## Scripts
|
||||
1. **Importar** el diagrama (.bpmn) y ver el proceso renderizado al instante.
|
||||
2. **Configurar costos** actividad por actividad: costo fijo, tiempo, recursos humanos/sistemas.
|
||||
3. **Simular** para obtener el costo esperado de una ejecución completa, con propagación de probabilidades a través de gateways XOR, AND e inclusivos.
|
||||
4. **Ver el reporte** con mapa de calor sobre el diagrama, KPIs, gráficos, tabla de actividades.
|
||||
5. **Exportar** a PDF (presentación para el cliente) o CSV (datos para Excel).
|
||||
|
||||
En Sprint 1 (en curso):
|
||||
|
||||
6. **Marcar actividades como automatizables** y configurar sus costos en el escenario automatizado.
|
||||
7. **Calcular el ROI** del proyecto de automatización: payback, ahorro anual, acumulado a N años.
|
||||
8. **Mostrar un reporte comparativo** Actual vs. Automatizado con transparencia metodológica.
|
||||
|
||||
**Audiencia primaria**: consultores de InQuality que usan esto en pre-venta de proyectos de automatización.
|
||||
No es un producto de consumo masivo. El diseño prioriza densidad de información y velocidad de uso.
|
||||
|
||||
---
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
```bash
|
||||
npm run dev # Servidor de desarrollo (localhost:5173)
|
||||
npm run build # Build de producción en dist/
|
||||
npm run preview # Preview del build en localhost:4173
|
||||
npm run test # Tests unitarios con Vitest
|
||||
npx playwright test # Tests E2E con Playwright
|
||||
git clone <repo>
|
||||
cd simulador-web
|
||||
npm install --legacy-peer-deps
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Estado
|
||||
Abre `http://localhost:5173`. Arrastrá un `.bpmn` o hacé clic en uno de los tres procesos de ejemplo.
|
||||
|
||||
**MVP Fase 0** — funcionalidad completa para uso consultivo básico.
|
||||
Las features adicionales (multi-tenant, IA, versionado, etc.)
|
||||
están documentadas en `TODO.md` para la Fase 1.
|
||||
> **Sin instalación de backend.** Todo corre en el navegador. Los datos se guardan en IndexedDB
|
||||
> del browser — si borrás el caché, perdés los procesos guardados. Exportá el PDF/CSV antes de limpiar.
|
||||
|
||||
---
|
||||
|
||||
## Flujo de uso detallado
|
||||
|
||||
### 1. Importar un proceso
|
||||
|
||||
Arrastrá tu archivo `.bpmn` a la pantalla de inicio, o cargá uno de los procesos de ejemplo:
|
||||
- **Proceso lineal simple** — para entender el flujo básico
|
||||
- **Aprobación de crédito** — con gateways XOR y AND paralelo
|
||||
- **Control de calidad con revisión** — con loop
|
||||
|
||||
La plataforma soporta BPMN 2.0 con **múltiples pools** (colaboraciones): todos los pools se importan y sus actividades aparecen configurables en el workspace.
|
||||
|
||||
### 2. Configurar el workspace
|
||||
|
||||
El panel derecho tiene tres tabs:
|
||||
|
||||
**Tab "Actividad"** (al hacer click en un nodo del diagrama):
|
||||
- Costo directo fijo por ejecución
|
||||
- Tiempo de ejecución en minutos
|
||||
- Recursos asignados (rol/sistema/equipo con % de utilización)
|
||||
- Sección "Automatización" *(Sprint 1)*: toggle + costo y tiempo del escenario automatizado
|
||||
|
||||
**Tab "Recursos"**: CRUD de recursos reutilizables con costo/hora.
|
||||
|
||||
**Tab "Global"**:
|
||||
- Nombre del proceso y cliente
|
||||
- Moneda (USD, PYG, BRL, ARS, EUR, COP, MXN, CLP)
|
||||
- Overhead (% de costos indirectos sobre directos)
|
||||
- Frecuencia anual de ejecución *(Sprint 1)*
|
||||
- Horizonte de análisis en años *(Sprint 1)*
|
||||
- Inversión total en automatización *(Sprint 1)*
|
||||
|
||||
Las actividades marcadas como automatizables muestran un badge Bot ámbar (⚙️) en la esquina superior derecha del nodo en el canvas.
|
||||
|
||||
### 3. Simular
|
||||
|
||||
El botón "Simular" corre el motor determinístico. Si hay gateways XOR con probabilidades que no suman 100%, el botón queda deshabilitado y se muestran advertencias en la topbar.
|
||||
|
||||
Desde Sprint 1, el motor corre **dos escenarios simultáneamente** (actual + automatizado) y los guarda como un único snapshot. Cualquier cambio en los datos invalida ambos resultados — hay que re-simular.
|
||||
|
||||
### 4. Reportar y exportar
|
||||
|
||||
El reporte muestra mapa de calor, KPIs, gráficos y tabla de actividades. En Sprint 1 se agregan tabs de comparación y ROI.
|
||||
|
||||
Exportación:
|
||||
- **PDF**: 3+ páginas presentables ante el cliente, con heatmap embebido como imagen JPEG.
|
||||
- **CSV**: datos con BOM UTF-8 para compatibilidad con Excel regional.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura técnica
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ ← Lógica de negocio pura, sin React, 100% testeable
|
||||
│ ├── types.ts ← Todas las interfaces del modelo de dominio
|
||||
│ ├── simulation.ts ← Motor: propaga probabilidades por el grafo BPMN
|
||||
│ ├── roi.ts ← Cálculos de ROI: payback, ahorro, acumulado (Sprint 1)
|
||||
│ ├── bpmn-parser.ts← Parser XML BPMN 2.0 → grafo en memoria
|
||||
│ └── schemas.ts ← Validación Zod de todas las entidades
|
||||
│
|
||||
├── store/ ← Estado global con Zustand
|
||||
│ ├── process-store.ts ← Proceso actual, actividades, gateways, recursos
|
||||
│ └── simulation-store.ts ← Resultados actual + automatizado, invalidación reactiva
|
||||
│
|
||||
├── persistence/ ← IndexedDB vía Dexie (schema versión 2)
|
||||
│ ├── db.ts ← Schema y migraciones
|
||||
│ └── repositories.ts ← CRUD de entidades (@internal: solo llamar desde stores)
|
||||
│
|
||||
├── features/
|
||||
│ ├── import/ ← Landing, drag & drop, carga de ejemplos
|
||||
│ ├── workspace/ ← Canvas BPMN + panel lateral de configuración
|
||||
│ ├── simulation/ ← Hook useSimulate: dispara ambos escenarios
|
||||
│ └── report/ ← Reporte visual, heatmap, gráficos, export
|
||||
│
|
||||
└── lib/
|
||||
├── export/ ← pdf-export.ts, csv-export.ts (lazy-loaded al hacer clic)
|
||||
├── colors.ts ← Gradiente heatmap: verde → ámbar → rojo
|
||||
└── format.ts ← Formateo de moneda, tiempo, porcentajes
|
||||
```
|
||||
|
||||
### Reglas de dependencia
|
||||
|
||||
```
|
||||
domain/ ← no importa nada del proyecto (puro TypeScript)
|
||||
store/ ← importa solo de domain/ y persistence/
|
||||
features/ ← importa de domain/, store/, persistence/, lib/, components/
|
||||
```
|
||||
|
||||
La regla ESLint `no-restricted-imports` hace que cualquier import de
|
||||
`persistence/repositories` desde fuera de `store/` falle en `npm run lint`.
|
||||
La única excepción intencional es `simulation-store.ts`, que importa
|
||||
`process-store.ts` para suscribirse a cambios y mantener la invariante
|
||||
de invalidación dual (documentado como excepción en el archivo).
|
||||
|
||||
### Motor de simulación — decisiones clave
|
||||
|
||||
El motor acepta `scenario: 'actual' | 'automated'`. En `'automated'`, las
|
||||
actividades con `automatable=true` usan sus campos alternativos de costo y tiempo.
|
||||
La probabilidad de ejecución **no cambia entre escenarios** — solo los costos.
|
||||
|
||||
Overlays en el canvas: se usa `overlays.add()` de diagram-js con posición
|
||||
`{ top: -8, left: element.width - 12 }` obtenida via `elementRegistry.get()`.
|
||||
**No usar** `position.right` directamente: la fórmula interna de diagram-js es
|
||||
`left = right * -1 + width`, lo que con `right: -8` produce `left = width + 8`
|
||||
(badge enteramente fuera del nodo, sin overlap de esquina).
|
||||
|
||||
---
|
||||
|
||||
## Stack tecnológico
|
||||
|
||||
| Capa | Tecnología | Nota |
|
||||
|---|---|---|
|
||||
| Framework | React 19 + Vite 8 (Rolldown) | |
|
||||
| Routing | TanStack Router 1.x | Type-safe |
|
||||
| Estado | Zustand 5 | Stores: process + simulation |
|
||||
| Canvas BPMN | bpmn-js 18 / diagram-js | Viewer (read-only) |
|
||||
| UI | shadcn/ui + Tailwind CSS | Components copy-pasted |
|
||||
| Iconos | Lucide React | |
|
||||
| Gráficos | Apache ECharts 5 | Lazy-loaded |
|
||||
| Persistencia | Dexie 4 (IndexedDB) | Schema v2, migraciones automáticas |
|
||||
| Validación | Zod 3 | |
|
||||
| Export PDF | jsPDF + jspdf-autotable | Lazy-loaded |
|
||||
| Export CSV | papaparse 5 | |
|
||||
| Tests unitarios | Vitest 4 | 336+ tests |
|
||||
| Tests E2E | Playwright | Con análisis de PDF y píxeles |
|
||||
| Linting | ESLint 10 (flat config) | `no-restricted-imports` custom |
|
||||
| Deploy | Cloudflare Pages | Estático, gratis |
|
||||
|
||||
---
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
npm run dev # Servidor local en localhost:5173 con HMR
|
||||
|
||||
# Build y preview
|
||||
npm run build # TypeScript check + Vite build → dist/
|
||||
npm run preview # Preview del build en localhost:4173
|
||||
|
||||
# Testing
|
||||
npm run test # Tests unitarios e integración (Vitest)
|
||||
npx playwright test # Tests E2E contra localhost:4173 (requiere build previo)
|
||||
npx playwright test --config=playwright.prod.config.ts # Smoke test vs producción
|
||||
|
||||
# Calidad
|
||||
npm run lint # ESLint — 0 errores esperados
|
||||
|
||||
# Deploy
|
||||
CLOUDFLARE_ACCOUNT_ID=<id> npx wrangler pages deploy dist \
|
||||
--project-name=process-cost-platform
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Producción
|
||||
|
||||
**URL**: https://process-cost-platform.pages.dev
|
||||
**Plataforma**: Cloudflare Pages (estático, sin servidor)
|
||||
|
||||
Smoke test post-deploy: `npx playwright test --config=playwright.prod.config.ts`
|
||||
Genera `tests/e2e/__output__/prod-smoke.pdf` como evidencia del flujo completo.
|
||||
|
||||
---
|
||||
|
||||
## Documentación adicional
|
||||
|
||||
| Archivo | Contenido |
|
||||
|---|---|
|
||||
| `ARCHITECTURE.md` | Diseño del sistema, ADRs 1-15, roadmap completo |
|
||||
| `CHANGELOG.md` | Historial detallado de cambios por versión |
|
||||
| `TODO.md` | Backlog de features y deuda técnica |
|
||||
| `METHODOLOGY_AUTOMATED_COST.md` | Guía para calcular el costo automatizado por ejecución |
|
||||
|
||||
---
|
||||
|
||||
*Los procesos de negocio son activos estratégicos de las empresas y un diferencial competitivo.*
|
||||
|
||||
12
TODO.md
12
TODO.md
@@ -21,6 +21,18 @@
|
||||
- [ ] **Detalle de recursos por actividad** en el PDF (tabla de costos por recurso asignado)
|
||||
- [ ] **Importación por URL**: cargar BPMN desde enlace directo (GitHub, Confluence, etc.)
|
||||
|
||||
## Refinamientos UX
|
||||
|
||||
- [ ] **Mejorar animación de expansión de la sección "Automatización" en ActivityPanel.** Actualmente usa max-h trick (funciona pero la aceleración no es lineal en los últimos píxeles porque el contenedor crece hasta un max fijo de 320px). Solución sofisticada: `useRef + scrollHeight` para animar a la altura real del contenido. Costo: medio. Beneficio: micro-pulido visual.
|
||||
|
||||
## Deuda técnica / Decisiones arquitectónicas pendientes
|
||||
|
||||
- [ ] **Dexie live queries (Modelo B de sincronización store↔db)**: Hoy la invalidación del resultado de simulación se garantiza via ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`. Si en Sprint 2 la grilla de edición masiva requiere escrituras de alta frecuencia directas a IndexedDB (por performance), evaluar migración a live queries de Dexie que invaliden el store automáticamente. Documentado en la auditoría de Mayo 2026.
|
||||
|
||||
- [ ] **Indicador visual en el reporte de actividades automatable** (borde punteado o ícono diferenciador): en Sprint 1 se usa solo leyenda de texto. Evaluar si el cliente necesita más claridad visual al comparar Actual vs Automatizado.
|
||||
|
||||
- [ ] **Columna "Eficiencia de automatización" en ComparisonTable**: métrica = `(ahorro$ × probabilidad) / inversión-prorrateada`. Permite rankear actividades por "qué tan valiosa es automatizar cada una" en lugar de solo "cuánto cuestan". Útil en conversaciones de pre-venta donde el cliente quiere priorizar qué automatizar primero.
|
||||
|
||||
## Baja prioridad / Fase 2
|
||||
|
||||
- [ ] **Login y multi-tenant**: persistencia en la nube, colaboración
|
||||
|
||||
92
eslint.config.js
Normal file
92
eslint.config.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import js from '@eslint/js'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules'] },
|
||||
|
||||
// ─── Base: JS + TS strict ────────────────────────────────────────────────────
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
|
||||
// ── Regla estructural crítica ─────────────────────────────────────────
|
||||
// Repositorios solo pueden llamarse desde stores.
|
||||
// Cualquier import de persistence/repositories en código que no sea src/store/**
|
||||
// falla el lint con un mensaje explicativo.
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['*/persistence/repositories*', '../persistence/repositories*', '@/persistence/repositories*'],
|
||||
message:
|
||||
'Los repositorios solo deben llamarse desde stores (src/store/**). ' +
|
||||
'Usá el store correspondiente (useProcessStore, useSimulationStore, etc.).',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// ── Reglas ajustadas al proyecto ─────────────────────────────────────
|
||||
// any en bordes con librerías sin tipos (bpmn-js, jsPDF, autotable) está
|
||||
// permitido por CLAUDE.md — se reporta como warning, no error.
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// Funciones auxiliares definidas dentro de componentes (ej: Th en ActivitiesTable,
|
||||
// handlers en BpmnCanvas) son un patrón aceptado en este codebase. El riesgo de
|
||||
// reset de estado no aplica porque son funciones sin estado propio.
|
||||
'react-hooks/static-components': 'off',
|
||||
|
||||
// setState síncrono en useEffect con condicional deliberado (sincronización de
|
||||
// estado local con props/store). Los efectos están correctamente dependenciados.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
|
||||
// Cálculos de posición en pdf-export.ts usan y como variable acumulativa;
|
||||
// el patrón y += delta → doc.addPage() → y = PDF.margin es intencional
|
||||
// (separa visualmente las secciones del cálculo aunque no use el valor final).
|
||||
'no-useless-assignment': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Override: src/store/** puede importar repositories ──────────────────────
|
||||
{
|
||||
files: ['src/store/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Override: features/import puede importar repositories (operación de
|
||||
// importación inicial — no hay estado de simulación previo que invalidar) ──
|
||||
{
|
||||
files: ['src/features/import/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Override: features/report usa repos en modo read-only (useReportData) ───
|
||||
{
|
||||
files: ['src/features/report/useReportData.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Override: tests e2e y integración pueden importar repos directamente ────
|
||||
{
|
||||
files: ['tests/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
36
src/components/ui/switch.tsx
Normal file
36
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export function Switch({ checked, onCheckedChange, id, disabled, 'aria-label': ariaLabel }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-slate-200'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
79
src/domain/roi.ts
Normal file
79
src/domain/roi.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Cálculos de ROI de automatización — funciones puras, sin dependencias de UI ni persistencia.
|
||||
// Fórmulas nominales simples (ADR-014): sin VPN, TIR ni tasa de descuento.
|
||||
// Los edge cases (inversión cero, ahorro negativo, división por cero) están explicitados.
|
||||
|
||||
export interface RoiInput {
|
||||
costPerExecutionActual: number // costo total de una ejecución en escenario actual
|
||||
costPerExecutionAutomated: number // costo total de una ejecución en escenario automatizado
|
||||
annualFrequency: number // ejecuciones por año
|
||||
analysisHorizonYears: number // horizonte de análisis en años
|
||||
automationInvestment: number // inversión total única del proyecto de automatización
|
||||
}
|
||||
|
||||
export interface RoiResult {
|
||||
savingsPerExecution: number // ahorro por ejecución individual (actual - automatizado)
|
||||
savingsPerExecutionPercent: number // ahorro % sobre el costo actual (0 si actual = 0)
|
||||
annualSavings: number // ahorro anual = ahorro/ejecución × frecuencia
|
||||
paybackMonths: number // meses para recuperar inversión (Infinity si ahorro <= 0)
|
||||
paybackYears: number // años para recuperar inversión
|
||||
cumulativeSavingsHorizon: number // ahorro acumulado neto al final del horizonte
|
||||
breaksEvenInHorizon: boolean // ¿el payback ocurre dentro del horizonte?
|
||||
roiAnnualPercent: number // ROI anualizado % = (ahorroAnual / inversión) × 100
|
||||
// Infinity si inversión = 0 y ahorro > 0
|
||||
}
|
||||
|
||||
export function calculateRoi(input: RoiInput): RoiResult {
|
||||
const {
|
||||
costPerExecutionActual,
|
||||
costPerExecutionAutomated,
|
||||
annualFrequency,
|
||||
analysisHorizonYears,
|
||||
automationInvestment,
|
||||
} = input
|
||||
|
||||
const savingsPerExecution = costPerExecutionActual - costPerExecutionAutomated
|
||||
|
||||
// Evitar división por cero cuando el costo actual es 0
|
||||
const savingsPerExecutionPercent =
|
||||
costPerExecutionActual > 0
|
||||
? (savingsPerExecution / costPerExecutionActual) * 100
|
||||
: 0
|
||||
|
||||
const annualSavings = savingsPerExecution * annualFrequency
|
||||
|
||||
// Payback: si no hay ahorro (o es negativo), nunca se recupera la inversión
|
||||
let paybackMonths: number
|
||||
if (annualSavings <= 0) {
|
||||
paybackMonths = Infinity
|
||||
} else if (automationInvestment === 0) {
|
||||
// Inversión cero con ahorro positivo: recuperación instantánea
|
||||
paybackMonths = 0
|
||||
} else {
|
||||
paybackMonths = (automationInvestment / annualSavings) * 12
|
||||
}
|
||||
|
||||
const paybackYears = paybackMonths / 12 // Infinity / 12 = Infinity, 0 / 12 = 0
|
||||
|
||||
const cumulativeSavingsHorizon = annualSavings * analysisHorizonYears - automationInvestment
|
||||
|
||||
const breaksEvenInHorizon = isFinite(paybackYears) && paybackYears <= analysisHorizonYears
|
||||
|
||||
// ROI anualizado: Infinity si inversión = 0 con ahorro > 0; 0 si inversión = 0 y ahorro = 0
|
||||
let roiAnnualPercent: number
|
||||
if (automationInvestment === 0) {
|
||||
roiAnnualPercent = annualSavings > 0 ? Infinity : 0
|
||||
} else {
|
||||
roiAnnualPercent = (annualSavings / automationInvestment) * 100
|
||||
}
|
||||
|
||||
return {
|
||||
savingsPerExecution,
|
||||
savingsPerExecutionPercent,
|
||||
annualSavings,
|
||||
paybackMonths,
|
||||
paybackYears,
|
||||
cumulativeSavingsHorizon,
|
||||
breaksEvenInHorizon,
|
||||
roiAnnualPercent,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const ActivitySchema = z.object({
|
||||
directCostFixed: z.number().min(0),
|
||||
executionTimeMinutes: z.number().min(0),
|
||||
assignedResources: z.array(ActivityResourceAssignmentSchema),
|
||||
automatable: z.boolean().default(false),
|
||||
automatedCostFixed: z.number().min(0).default(0),
|
||||
automatedTimeMinutes: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
export const GatewayBranchSchema = z.object({
|
||||
@@ -45,6 +48,9 @@ export const ProcessSchema = z.object({
|
||||
bpmnXml: z.string().min(1),
|
||||
currency: z.string().length(3, 'Código ISO de 3 letras'),
|
||||
overheadPercentage: z.number().min(0).max(1),
|
||||
annualFrequency: z.number().min(1).default(1000),
|
||||
analysisHorizonYears: z.number().min(1).max(20).default(3),
|
||||
automationInvestment: z.number().min(0).default(0),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
})
|
||||
|
||||
@@ -181,6 +181,9 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
const perActivity: ActivitySimResult[] = []
|
||||
const resourceTotals = new Map<string, { cost: number; minutes: number; name: string }>()
|
||||
|
||||
// Seleccionar campos del escenario: para actividades automatable, usar costos automatizados
|
||||
const scenario = input.scenario ?? 'actual'
|
||||
|
||||
for (const activity of activities.values()) {
|
||||
const execProb = execProbs.get(activity.bpmnElementId) ?? 0
|
||||
const node = graph.nodes.get(activity.bpmnElementId)
|
||||
@@ -192,13 +195,19 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
continue
|
||||
}
|
||||
|
||||
// En escenario automatizado, usar campos automatizados solo para actividades marcadas;
|
||||
// el resto mantiene sus costos originales (regla de negocio central del Sprint 1).
|
||||
const useAutomated = scenario === 'automated' && activity.automatable
|
||||
const directCostFixed = useAutomated ? activity.automatedCostFixed : activity.directCostFixed
|
||||
const executionTimeMinutes = useAutomated ? activity.automatedTimeMinutes : activity.executionTimeMinutes
|
||||
|
||||
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 hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent
|
||||
const cost = resource.costPerHour * hoursUsed * execProb
|
||||
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
|
||||
resourceCostDirect += cost
|
||||
@@ -211,7 +220,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
})
|
||||
}
|
||||
|
||||
const fixedCostExpected = activity.directCostFixed * execProb
|
||||
const fixedCostExpected = directCostFixed * execProb
|
||||
const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect
|
||||
|
||||
perActivity.push({
|
||||
@@ -225,7 +234,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
expectedExecutions: execProb,
|
||||
executionProbability: execProb,
|
||||
resourceCostBreakdown: resourceBreakdown,
|
||||
executionTimeMinutes: activity.executionTimeMinutes,
|
||||
executionTimeMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface Process {
|
||||
bpmnXml: string
|
||||
currency: string
|
||||
overheadPercentage: number
|
||||
annualFrequency: number // 🆕 Sprint 1 — execuciones por año, default 1000
|
||||
analysisHorizonYears: number // 🆕 Sprint 1 — horizonte de análisis en años, default 3
|
||||
automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -26,6 +29,9 @@ export interface Activity {
|
||||
directCostFixed: number
|
||||
executionTimeMinutes: number
|
||||
assignedResources: ActivityResourceAssignment[]
|
||||
automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización
|
||||
automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada
|
||||
automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado
|
||||
}
|
||||
|
||||
export interface ActivityResourceAssignment {
|
||||
@@ -63,11 +69,16 @@ export interface Simulation {
|
||||
id: UUID
|
||||
processId: UUID
|
||||
executedAt: number
|
||||
result: SimulationResult
|
||||
result: SimulationResult // escenario actual
|
||||
resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad)
|
||||
}
|
||||
|
||||
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
|
||||
|
||||
// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes)
|
||||
// o automatizado (automatedCostFixed/automatedTimeMinutes para actividades marcadas).
|
||||
export type Scenario = 'actual' | 'automated'
|
||||
|
||||
export interface SimulationInput {
|
||||
processXml: string
|
||||
activities: Map<BpmnElementId, Activity>
|
||||
@@ -77,6 +88,7 @@ export interface SimulationInput {
|
||||
currency: string
|
||||
overheadPercentage: number
|
||||
}
|
||||
scenario?: Scenario // default 'actual' — opcional para retrocompatibilidad con tests existentes
|
||||
}
|
||||
|
||||
export interface ActivitySimResult {
|
||||
|
||||
@@ -64,6 +64,9 @@ export function ImportPage() {
|
||||
bpmnXml: xml,
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -78,6 +81,9 @@ export function ImportPage() {
|
||||
directCostFixed: 0,
|
||||
executionTimeMinutes: 0,
|
||||
assignedResources: [],
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
}))
|
||||
|
||||
const gatewayElements = extractGatewayElements(graph)
|
||||
|
||||
12
src/features/report/AutomationScenarioNote.tsx
Normal file
12
src/features/report/AutomationScenarioNote.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
// Leyenda para el tab "Automatizado" del reporte.
|
||||
// Explica por qué ciertas actividades mantienen su costo original.
|
||||
// Se coloca al pie del heatmap en el tab "Automatizado" — wired en Etapa 3.
|
||||
|
||||
export function AutomationScenarioNote() {
|
||||
return (
|
||||
<p className="text-xs text-slate-500 text-center mt-2 px-4">
|
||||
💡 Las actividades no marcadas como automatizables mantienen su costo original en este
|
||||
escenario. Identificalas por el ícono ámbar de automatización en el workspace.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
130
src/features/report/ComparisonTable.tsx
Normal file
130
src/features/report/ComparisonTable.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Bot, ArrowUpDown } from 'lucide-react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import type { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
interface ComparisonRow {
|
||||
activityId: string
|
||||
activityName: string
|
||||
actualCost: number
|
||||
automatedCost: number
|
||||
savings: number
|
||||
savingsPercent: number
|
||||
automatable: boolean
|
||||
}
|
||||
|
||||
interface ComparisonTableProps {
|
||||
perActivityActual: ActivitySimResult[]
|
||||
perActivityAutomated: ActivitySimResult[]
|
||||
automatableIds: Set<string> // bpmnElementIds marcados como automatizables
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function ComparisonTable({
|
||||
perActivityActual,
|
||||
perActivityAutomated,
|
||||
automatableIds,
|
||||
currency,
|
||||
}: ComparisonTableProps) {
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const automatedByActivityId = useMemo(() => {
|
||||
const m = new Map<string, ActivitySimResult>()
|
||||
for (const a of perActivityAutomated) m.set(a.activityId, a)
|
||||
return m
|
||||
}, [perActivityAutomated])
|
||||
|
||||
const rows: ComparisonRow[] = useMemo(() => {
|
||||
return perActivityActual
|
||||
.map((act) => {
|
||||
const automated = automatedByActivityId.get(act.activityId)
|
||||
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - automatedCost
|
||||
const savingsPercent =
|
||||
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
return {
|
||||
activityId: act.activityId,
|
||||
activityName: act.activityName,
|
||||
actualCost: act.expectedTotalCost,
|
||||
automatedCost,
|
||||
savings,
|
||||
savingsPercent,
|
||||
automatable: automatableIds.has(act.bpmnElementId),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1))
|
||||
}, [perActivityActual, automatedByActivityId, automatableIds, sortDir])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 text-xs text-slate-400 rounded-xl border border-slate-200">
|
||||
Sin actividades para comparar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50">
|
||||
<tr>
|
||||
<TableHead className="w-8 text-xs" />
|
||||
<TableHead className="text-xs">Actividad</TableHead>
|
||||
<TableHead className="text-xs text-right">Costo actual</TableHead>
|
||||
<TableHead className="text-xs text-right">Costo automatizado</TableHead>
|
||||
<TableHead
|
||||
className="text-xs text-right cursor-pointer select-none hover:text-foreground"
|
||||
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
|
||||
>
|
||||
<span className="inline-flex items-center justify-end gap-1">
|
||||
Ahorro
|
||||
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-right">% ahorro</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const savingsColor =
|
||||
row.savings > 0
|
||||
? 'text-emerald-700'
|
||||
: row.savings < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-400'
|
||||
return (
|
||||
<TableRow key={row.activityId}>
|
||||
<TableCell className="py-2">
|
||||
{row.automatable && (
|
||||
<Bot
|
||||
className="h-3.5 w-3.5 text-amber-500"
|
||||
aria-label="Marcada como automatizable"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
|
||||
{row.activityName}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-slate-600 text-right">
|
||||
{formatCurrency(row.actualCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-slate-600 text-right">
|
||||
{formatCurrency(row.automatedCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-xs font-mono font-semibold text-right ${savingsColor}`}>
|
||||
{row.savings > 0 ? '+' : ''}
|
||||
{formatCurrency(row.savings, currency)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-xs font-mono text-right ${savingsColor}`}>
|
||||
{row.savings > 0 ? '+' : ''}
|
||||
{formatPercent(row.savingsPercent)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/features/report/CumulativeSavingsChart.tsx
Normal file
115
src/features/report/CumulativeSavingsChart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
interface CumulativeSavingsChartProps {
|
||||
roi: RoiResult
|
||||
horizonYears: number
|
||||
currency: string
|
||||
investment: number
|
||||
}
|
||||
|
||||
export function CumulativeSavingsChart({
|
||||
roi,
|
||||
horizonYears,
|
||||
currency,
|
||||
investment,
|
||||
}: CumulativeSavingsChartProps) {
|
||||
const option = useMemo(() => {
|
||||
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
|
||||
const values = years.map((y) => roi.annualSavings * y - investment)
|
||||
|
||||
const markLineData: unknown[] = []
|
||||
if (isFinite(roi.paybackYears) && roi.paybackYears > 0 && roi.paybackYears <= horizonYears) {
|
||||
markLineData.push({
|
||||
xAxis: roi.paybackYears,
|
||||
name: 'Payback',
|
||||
label: {
|
||||
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#10b981',
|
||||
},
|
||||
lineStyle: { color: '#10b981', type: 'dashed', width: 2 },
|
||||
})
|
||||
}
|
||||
|
||||
// Línea de referencia en y=0 para identificar el break-even visualmente
|
||||
markLineData.push({
|
||||
yAxis: 0,
|
||||
name: 'Break-even',
|
||||
label: { formatter: '', position: 'insideStartTop' },
|
||||
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
|
||||
})
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { dataIndex: number; value: [number, number] }
|
||||
const year = p.value[0]
|
||||
const val = p.value[1]
|
||||
return `<div style="font-size:12px;line-height:1.6">
|
||||
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
|
||||
<div>Acumulado neto: <strong style="color:${val >= 0 ? '#10b981' : '#ef4444'}">${formatCurrency(val, currency)}</strong></div>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: 'Año',
|
||||
nameLocation: 'end',
|
||||
nameTextStyle: { fontSize: 10, color: '#94a3b8' },
|
||||
min: 0,
|
||||
max: horizonYears,
|
||||
interval: 1,
|
||||
axisLabel: { fontSize: 11, formatter: (v: number) => `Año ${v}` },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => {
|
||||
if (v === 0) return '0'
|
||||
const abs = Math.abs(v)
|
||||
const sign = v < 0 ? '-' : ''
|
||||
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: years.map((y, i) => [y, values[i]]),
|
||||
smooth: false,
|
||||
lineStyle: { color: '#3b82f6', width: 2.5 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59,130,246,0.18)' },
|
||||
{ offset: 1, color: 'rgba(59,130,246,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
symbolSize: 6,
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: ['none', 'none'],
|
||||
data: markLineData,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [roi, horizonYears, currency, investment])
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import { AlertTriangle, Home } from 'lucide-react'
|
||||
import { AlertTriangle, Home, Info } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AppFooter } from '@/components/AppFooter'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import { useReportData } from './useReportData'
|
||||
import { ReportHeader } from './ReportHeader'
|
||||
import { KpiBar } from './KpiBar'
|
||||
import { RoiKpiBar } from './RoiKpiBar'
|
||||
import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas'
|
||||
import { HeatmapLegend } from './HeatmapLegend'
|
||||
import { HeatmapModeToggle } from './HeatmapModeToggle'
|
||||
@@ -17,48 +20,102 @@ import { TopActivitiesChart } from './TopActivitiesChart'
|
||||
import { CostCompositionChart } from './CostCompositionChart'
|
||||
import { CostByResourceChart } from './CostByResourceChart'
|
||||
import { ActivitiesTable } from './ActivitiesTable'
|
||||
import { ComparisonTable } from './ComparisonTable'
|
||||
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
|
||||
import { TopSavingsChart } from './TopSavingsChart'
|
||||
import { TransparencySection } from './TransparencySection'
|
||||
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
||||
import { MethodologyFooter } from './MethodologyFooter'
|
||||
import type { HeatmapMode } from '@/lib/colors'
|
||||
|
||||
export function ReportPage() {
|
||||
const { processId } = useParams({ from: '/report/$processId' })
|
||||
const navigate = useNavigate()
|
||||
const { process, simulation, resources, isLoading, error } = useReportData(processId)
|
||||
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
|
||||
|
||||
const { toast } = useToast()
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
||||
const [isExportingCsv, setIsExportingCsv] = useState(false)
|
||||
|
||||
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
|
||||
const automatableIds = useMemo(
|
||||
() => new Set(activities.filter((a) => a.automatable).map((a) => a.bpmnElementId)),
|
||||
[activities]
|
||||
)
|
||||
|
||||
// ROI — calculado solo cuando hay ambos resultados
|
||||
const roi = useMemo(() => {
|
||||
const result = simulation?.result
|
||||
const resultAutomated = simulation?.resultAutomated
|
||||
if (!result || !resultAutomated || !process) return null
|
||||
return calculateRoi({
|
||||
costPerExecutionActual: result.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
}, [simulation, process])
|
||||
|
||||
// Estado y refs para el tab "Actual"
|
||||
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
|
||||
const heatmapRef = useRef<HeatmapCanvasHandle>(null)
|
||||
const heatmapSectionRef = useRef<HTMLDivElement>(null)
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Click en heatmap → resaltar fila en tabla + scroll a tabla
|
||||
const handleHeatmapClick = useCallback((bpmnElementId: string) => {
|
||||
setSelectedId(bpmnElementId)
|
||||
// Estado y refs para el tab "Automatizado"
|
||||
const [selectedIdAuto, setSelectedIdAuto] = useState<string | null>(null)
|
||||
const heatmapRefAuto = useRef<HeatmapCanvasHandle>(null)
|
||||
const heatmapSectionRefAuto = useRef<HTMLDivElement>(null)
|
||||
const tableSectionRefAuto = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
||||
const [isExportingCsv, setIsExportingCsv] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual')
|
||||
|
||||
// Tab "Actual" — cross-linking heatmap ↔ tabla
|
||||
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdActual(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
tableSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
// Click en fila de tabla → resaltar en canvas + scroll a heatmap
|
||||
const handleTableRowClick = useCallback((bpmnElementId: string) => {
|
||||
setSelectedId(bpmnElementId)
|
||||
const handleTableRowClickActual = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdActual(bpmnElementId)
|
||||
heatmapRef.current?.highlightElement(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
// Tab "Automatizado" — cross-linking heatmap ↔ tabla
|
||||
const handleHeatmapClickAuto = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdAuto(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
tableSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
const handleTableRowClickAuto = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdAuto(bpmnElementId)
|
||||
heatmapRefAuto.current?.highlightElement(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
heatmapSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
if (!process || !simulation || isExportingPdf) return
|
||||
setIsExportingPdf(true)
|
||||
try {
|
||||
const heatmapImageData = await heatmapRef.current?.captureImage() ?? null
|
||||
let heatmapImageData: string | null = null
|
||||
if (activeTab === 'actual') {
|
||||
heatmapImageData = await heatmapRef.current?.captureImage() ?? null
|
||||
} else if (activeTab === 'automatizado') {
|
||||
heatmapImageData = await heatmapRefAuto.current?.captureImage() ?? null
|
||||
}
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process, simulation, resources, heatmapImageData })
|
||||
await exportToPdf({ process, simulation, resources, heatmapImageData, tab: activeTab, activities })
|
||||
toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' })
|
||||
} catch (err) {
|
||||
console.error('PDF export error:', err)
|
||||
@@ -66,14 +123,14 @@ export function ReportPage() {
|
||||
} finally {
|
||||
setIsExportingPdf(false)
|
||||
}
|
||||
}, [process, simulation, resources, isExportingPdf, toast])
|
||||
}, [process, simulation, resources, isExportingPdf, toast, activeTab, activities])
|
||||
|
||||
const handleExportCsv = useCallback(async () => {
|
||||
if (!process || !simulation || isExportingCsv) return
|
||||
setIsExportingCsv(true)
|
||||
try {
|
||||
const { exportToCsv } = await import('@/lib/export/csv-export')
|
||||
exportToCsv({ process, simulation, resources })
|
||||
exportToCsv({ process, simulation, resources, tab: activeTab, activities })
|
||||
toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' })
|
||||
} catch (err) {
|
||||
console.error('CSV export error:', err)
|
||||
@@ -81,14 +138,13 @@ export function ReportPage() {
|
||||
} finally {
|
||||
setIsExportingCsv(false)
|
||||
}
|
||||
}, [process, simulation, resources, isExportingCsv, toast])
|
||||
}, [process, simulation, resources, isExportingCsv, toast, activeTab, activities])
|
||||
|
||||
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-screen-xl mx-auto px-6 py-10 animate-pulse">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-20 bg-slate-200 rounded" />
|
||||
@@ -100,7 +156,6 @@ export function ReportPage() {
|
||||
<div className="h-9 w-32 bg-slate-200 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
{/* KPI skeleton */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 flex gap-4">
|
||||
@@ -113,12 +168,10 @@ export function ReportPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Canvas skeleton */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="h-5 w-48 bg-slate-200 rounded mb-4" />
|
||||
<div className="h-72 bg-slate-100 rounded-lg" />
|
||||
</div>
|
||||
{/* Table skeleton */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="h-4 w-40 bg-slate-200 rounded mb-4" />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
@@ -159,14 +212,19 @@ export function ReportPage() {
|
||||
}
|
||||
|
||||
const result = simulation.result
|
||||
const perActivity = result.perActivity
|
||||
const resultAutomated = simulation.resultAutomated ?? null
|
||||
const hasAutomatedResult = resultAutomated !== null
|
||||
|
||||
// Tabs de escenarios se habilitan solo cuando hay resultado automatizado Y hay actividades
|
||||
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
|
||||
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-screen-xl mx-auto px-6 py-10">
|
||||
|
||||
{/* ── 1. Header ─────────────────────────────────────────────── */}
|
||||
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
|
||||
<ReportHeader
|
||||
process={process}
|
||||
simulatedAt={simulation.executedAt}
|
||||
@@ -177,102 +235,328 @@ export function ReportPage() {
|
||||
isExportingCsv={isExportingCsv}
|
||||
/>
|
||||
|
||||
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
|
||||
<KpiBar result={result} currency={process.currency} />
|
||||
{/* ── Tabs ─────────────────────────────────────────────────────── */}
|
||||
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="actual">Actual</TabsTrigger>
|
||||
|
||||
{/* ── 3. Mapa de Calor ──────────────────────────────────────── */}
|
||||
<div ref={heatmapSectionRef} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800">Mapa de calor de costos</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Hacé click en una actividad para ver su detalle en la tabla
|
||||
</p>
|
||||
</div>
|
||||
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="contents">
|
||||
<TabsTrigger value="automatizado" disabled={!hasAutomatedScenario}>
|
||||
Automatizado
|
||||
</TabsTrigger>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasAutomatedScenario && (
|
||||
<TooltipContent side="bottom" className="text-xs max-w-56">
|
||||
{!hasAutomatedResult
|
||||
? 'Re-simulá el proceso para generar el escenario automatizado'
|
||||
: 'Marcá al menos una actividad como automatizable en el workspace para ver este escenario'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<HeatmapCanvas
|
||||
ref={heatmapRef}
|
||||
xml={process.bpmnXml}
|
||||
perActivity={perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
selectedId={selectedId}
|
||||
onActivityClick={handleHeatmapClick}
|
||||
minHeight={520}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="contents">
|
||||
<TabsTrigger value="roi" disabled={!hasAutomatedScenario}>
|
||||
Comparación + ROI
|
||||
</TabsTrigger>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasAutomatedScenario && (
|
||||
<TooltipContent side="bottom" className="text-xs max-w-56">
|
||||
{!hasAutomatedResult
|
||||
? 'Re-simulá el proceso para generar el análisis de ROI'
|
||||
: 'Marcá al menos una actividad como automatizable en el workspace para calcular el ROI'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
|
||||
<HeatmapLegend
|
||||
perActivity={perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
{/* ──────────────── TAB: ACTUAL ──────────────────────────────── */}
|
||||
<TabsContent value="actual">
|
||||
<KpiBar result={result} currency={process.currency} />
|
||||
|
||||
{/* ── 4. Warnings ───────────────────────────────────────────── */}
|
||||
<WarningsBanner warnings={result.warnings} />
|
||||
<div ref={heatmapSectionRef} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800">Mapa de calor de costos</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Hacé click en una actividad para ver su detalle en la tabla
|
||||
</p>
|
||||
</div>
|
||||
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
|
||||
</div>
|
||||
|
||||
{/* ── 5. Análisis — 3 gráficos ──────────────────────────────── */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Top actividades por costo
|
||||
</h3>
|
||||
<TopActivitiesChart
|
||||
perActivity={perActivity}
|
||||
<HeatmapCanvas
|
||||
ref={heatmapRef}
|
||||
xml={process.bpmnXml}
|
||||
perActivity={result.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
selectedId={selectedIdActual}
|
||||
onActivityClick={handleHeatmapClickActual}
|
||||
minHeight={520}
|
||||
/>
|
||||
|
||||
<HeatmapLegend
|
||||
perActivity={result.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Composición directo / overhead
|
||||
</h3>
|
||||
<CostCompositionChart result={result} currency={process.currency} />
|
||||
<WarningsBanner warnings={result.warnings} />
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Top actividades por costo
|
||||
</h3>
|
||||
<TopActivitiesChart
|
||||
perActivity={result.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Composición directo / overhead
|
||||
</h3>
|
||||
<CostCompositionChart result={result} currency={process.currency} />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Costo por tipo de recurso
|
||||
</h3>
|
||||
<CostByResourceChart
|
||||
perActivity={result.perActivity}
|
||||
resources={resources}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Costo por tipo de recurso
|
||||
</h3>
|
||||
<CostByResourceChart
|
||||
perActivity={perActivity}
|
||||
resources={resources}
|
||||
<div ref={tableSectionRef} className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
Detalle por actividad
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
({result.perActivity.length} actividades)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={result.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedIdActual}
|
||||
onRowClick={handleTableRowClickActual}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 6. Tabla detalle ──────────────────────────────────────── */}
|
||||
<div ref={tableSectionRef} className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
Detalle por actividad
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">({perActivity.length} actividades)</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedId}
|
||||
onRowClick={handleTableRowClick}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<MethodologyFooter process={process} />
|
||||
<AppFooter />
|
||||
</TabsContent>
|
||||
|
||||
<Separator className="my-4" />
|
||||
{/* ──────────────── TAB: AUTOMATIZADO ───────────────────────── */}
|
||||
<TabsContent value="automatizado">
|
||||
{!hasAutomatedResult ? (
|
||||
<NeedsResimulate processId={processId} />
|
||||
) : (
|
||||
<>
|
||||
<KpiBar result={resultAutomated!} currency={process.currency} />
|
||||
|
||||
{/* ── 7. Footer metodológico ────────────────────────────────── */}
|
||||
<MethodologyFooter process={process} />
|
||||
<div ref={heatmapSectionRefAuto} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800">Mapa de calor — Escenario Automatizado</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Hacé click en una actividad para ver su detalle en la tabla
|
||||
</p>
|
||||
</div>
|
||||
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
|
||||
</div>
|
||||
|
||||
<HeatmapCanvas
|
||||
ref={heatmapRefAuto}
|
||||
xml={process.bpmnXml}
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
selectedId={selectedIdAuto}
|
||||
onActivityClick={handleHeatmapClickAuto}
|
||||
minHeight={520}
|
||||
/>
|
||||
|
||||
<HeatmapLegend
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
|
||||
<AutomationScenarioNote />
|
||||
</div>
|
||||
|
||||
<WarningsBanner warnings={resultAutomated!.warnings} />
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Top actividades por costo
|
||||
</h3>
|
||||
<TopActivitiesChart
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Composición directo / overhead
|
||||
</h3>
|
||||
<CostCompositionChart result={resultAutomated!} currency={process.currency} />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Costo por tipo de recurso
|
||||
</h3>
|
||||
<CostByResourceChart
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
resources={resources}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={tableSectionRefAuto} className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
Detalle por actividad — Automatizado
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
({resultAutomated!.perActivity.length} actividades)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedIdAuto}
|
||||
onRowClick={handleTableRowClickAuto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
<MethodologyFooter process={process} />
|
||||
<AppFooter />
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ──────────────── TAB: COMPARACIÓN + ROI ──────────────────── */}
|
||||
<TabsContent value="roi">
|
||||
{!hasAutomatedResult || !roi ? (
|
||||
<NeedsResimulate processId={processId} />
|
||||
) : (
|
||||
<>
|
||||
{/* KPIs de ROI */}
|
||||
<RoiKpiBar
|
||||
roi={roi}
|
||||
currency={process.currency}
|
||||
horizonYears={process.analysisHorizonYears}
|
||||
/>
|
||||
|
||||
{/* Tabla de comparación actividad por actividad */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
||||
Comparación por actividad
|
||||
</h2>
|
||||
<ComparisonTable
|
||||
perActivityActual={result.perActivity}
|
||||
perActivityAutomated={resultAutomated!.perActivity}
|
||||
automatableIds={automatableIds}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráficos: ahorro acumulado + top ahorros */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Ahorro acumulado neto ({process.analysisHorizonYears} años)
|
||||
</h3>
|
||||
<CumulativeSavingsChart
|
||||
roi={roi}
|
||||
horizonYears={process.analysisHorizonYears}
|
||||
currency={process.currency}
|
||||
investment={process.automationInvestment}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Top actividades por ahorro
|
||||
</h3>
|
||||
<TopSavingsChart
|
||||
perActivityActual={result.perActivity}
|
||||
perActivityAutomated={resultAutomated!.perActivity}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transparencia metodológica (condicional) */}
|
||||
<div className="mb-6">
|
||||
<TransparencySection
|
||||
activities={activities}
|
||||
investment={process.automationInvestment}
|
||||
roi={roi ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
<MethodologyFooter process={process} />
|
||||
<AppFooter />
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Componente auxiliar: aviso cuando falta re-simular ───────────────────────
|
||||
|
||||
function NeedsResimulate({ processId }: { processId: string }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
<div className="w-12 h-12 bg-amber-50 rounded-full flex items-center justify-center">
|
||||
<Info className="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-1">
|
||||
Esta simulación no incluye el escenario automatizado
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 max-w-sm">
|
||||
Volvé al workspace y presioná "Simular" para generar ambos escenarios.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
|
||||
Ir al workspace
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
110
src/features/report/RoiKpiBar.tsx
Normal file
110
src/features/report/RoiKpiBar.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
interface RoiKpiBarProps {
|
||||
roi: RoiResult
|
||||
currency: string
|
||||
horizonYears: number
|
||||
}
|
||||
|
||||
function RoiKpiCard({
|
||||
label, value, sub, icon, iconColor, valueColor,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
icon: React.ReactNode
|
||||
iconColor: string
|
||||
valueColor?: string
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="roi-kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 flex items-start gap-4">
|
||||
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
|
||||
<p className={`text-2xl font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-slate-400 mt-0.5 leading-snug">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
const paybackStr = !isFinite(roi.paybackMonths)
|
||||
? 'No se recupera'
|
||||
: roi.paybackMonths === 0
|
||||
? 'Inmediato'
|
||||
: roi.paybackMonths < 24
|
||||
? `${roi.paybackMonths.toFixed(1)} meses`
|
||||
: `${roi.paybackYears.toFixed(1)} años`
|
||||
|
||||
const roiStr = !isFinite(roi.roiAnnualPercent)
|
||||
? '∞%'
|
||||
: formatPercent(roi.roiAnnualPercent)
|
||||
|
||||
const savingsPosColor = roi.savingsPerExecution > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.savingsPerExecution < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
const annualPosColor = roi.annualSavings > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.annualSavings < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
const cumulativePosColor = roi.cumulativeSavingsHorizon > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.cumulativeSavingsHorizon < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||
<RoiKpiCard
|
||||
label="Ahorro por ejecución"
|
||||
value={formatCurrency(roi.savingsPerExecution, currency)}
|
||||
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
|
||||
icon={<TrendingDown className="h-5 w-5 text-emerald-600" />}
|
||||
iconColor="bg-emerald-50"
|
||||
valueColor={savingsPosColor}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="Ahorro anual"
|
||||
value={formatCurrency(roi.annualSavings, currency)}
|
||||
sub="sobre la frecuencia configurada"
|
||||
icon={<DollarSign className="h-5 w-5 text-emerald-600" />}
|
||||
iconColor="bg-emerald-50"
|
||||
valueColor={annualPosColor}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="Payback"
|
||||
value={paybackStr}
|
||||
sub={roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte de análisis'}
|
||||
icon={<Calendar className="h-5 w-5 text-primary" />}
|
||||
iconColor="bg-primary/10"
|
||||
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label={`Acumulado (${horizonYears} años)`}
|
||||
value={formatCurrency(roi.cumulativeSavingsHorizon, currency)}
|
||||
sub="ahorro neto de inversión inicial"
|
||||
icon={<BarChart3 className="h-5 w-5 text-violet-600" />}
|
||||
iconColor="bg-violet-50"
|
||||
valueColor={cumulativePosColor}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="ROI anual"
|
||||
value={roiStr}
|
||||
sub="retorno anual sobre la inversión"
|
||||
icon={<Percent className="h-5 w-5 text-amber-600" />}
|
||||
iconColor="bg-amber-50"
|
||||
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/features/report/TopSavingsChart.tsx
Normal file
95
src/features/report/TopSavingsChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
interface TopSavingsChartProps {
|
||||
perActivityActual: ActivitySimResult[]
|
||||
perActivityAutomated: ActivitySimResult[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number) {
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
||||
}
|
||||
|
||||
export function TopSavingsChart({
|
||||
perActivityActual,
|
||||
perActivityAutomated,
|
||||
currency,
|
||||
}: TopSavingsChartProps) {
|
||||
const option = useMemo(() => {
|
||||
const automatedMap = new Map(perActivityAutomated.map((a) => [a.activityId, a]))
|
||||
|
||||
const top5 = perActivityActual
|
||||
.map((act) => {
|
||||
const auto = automatedMap.get(act.activityId)
|
||||
const savings = act.expectedTotalCost - (auto?.expectedTotalCost ?? act.expectedTotalCost)
|
||||
return { name: act.activityName, value: savings }
|
||||
})
|
||||
.filter((s) => s.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 5)
|
||||
|
||||
if (top5.length === 0) return null
|
||||
|
||||
const reversed = [...top5].reverse()
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { name: string; value: number }
|
||||
return `<div style="font-size:12px"><b>${p.name}</b><br/>Ahorro: <strong>${formatCurrency(p.value, currency)}</strong></div>`
|
||||
},
|
||||
},
|
||||
grid: { left: 12, right: 32, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
formatter: (v: number) => {
|
||||
if (v === 0) return '0'
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
||||
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: reversed.map((s) => truncate(s.name, 22)),
|
||||
axisLabel: { fontSize: 10, color: '#64748b' },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: reversed.map((s) => s.value),
|
||||
barMaxWidth: 32,
|
||||
itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 10,
|
||||
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
|
||||
color: '#10b981',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [perActivityActual, perActivityAutomated, currency])
|
||||
|
||||
if (!option) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-xs text-slate-400">
|
||||
Sin actividades con ahorro positivo
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ReactECharts option={option} style={{ height: Math.max(180, option.yAxis.data.length * 48) }} notMerge />
|
||||
}
|
||||
91
src/features/report/TransparencySection.tsx
Normal file
91
src/features/report/TransparencySection.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
import type { Activity } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
// Umbral de ROI a partir del cual se dispara la advertencia de verosimilitud.
|
||||
// Un ROI > 1000% anual es matemáticamente posible pero comercialmente inverosímil:
|
||||
// indica casi siempre un input incorrecto (frecuencia sobreestimada, costo auto subestimado
|
||||
// o inversión subestimada). Documentado en METHODOLOGY_AUTOMATED_COST.md.
|
||||
const ROI_HIGH_THRESHOLD = 1000
|
||||
|
||||
interface TransparencySectionProps {
|
||||
activities: Activity[]
|
||||
investment: number
|
||||
roi?: RoiResult
|
||||
}
|
||||
|
||||
export function TransparencySection({ activities, investment, roi }: TransparencySectionProps) {
|
||||
const zeroCostAutomatable = activities.filter(
|
||||
(a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0
|
||||
)
|
||||
const investmentIsZero = investment === 0
|
||||
|
||||
// Caso 4: ROI > 1000% finito (Infinity no aplica — ya lo cubre el caso de investment=0)
|
||||
const roiIsUnusuallyHigh =
|
||||
roi !== undefined &&
|
||||
Number.isFinite(roi.roiAnnualPercent) &&
|
||||
roi.roiAnnualPercent > ROI_HIGH_THRESHOLD
|
||||
|
||||
if (zeroCostAutomatable.length === 0 && !investmentIsZero && !roiIsUnusuallyHigh) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 shrink-0" />
|
||||
<h3 className="text-sm font-semibold text-amber-800">Transparencia metodológica</h3>
|
||||
</div>
|
||||
|
||||
{zeroCostAutomatable.length > 0 && (
|
||||
<div className="text-xs text-amber-700 leading-relaxed space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{zeroCostAutomatable.length === 1
|
||||
? '1 actividad automatizable sin costo configurado:'
|
||||
: `${zeroCostAutomatable.length} actividades automatizables sin costo configurado:`}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
{zeroCostAutomatable.map((a) => (
|
||||
<li key={a.id} className="font-mono">
|
||||
{a.name || a.bpmnElementId}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-amber-600">
|
||||
Estas actividades se incluyen en el escenario automatizado con costo cero, lo que puede
|
||||
inflar artificialmente el ahorro calculado. Configurá sus costos en el workspace para
|
||||
un análisis realista.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{investmentIsZero && (
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
<span className="font-medium">Inversión en automatización no configurada.</span>{' '}
|
||||
El payback aparece como inmediato y el ROI como infinito porque no hay inversión inicial.
|
||||
Configurá la inversión total en la tab "Global" del workspace para un análisis
|
||||
financiero completo.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roiIsUnusuallyHigh && roi && (
|
||||
<div className="text-xs text-amber-700 leading-relaxed space-y-1.5">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
ROI inusualmente alto detectado: {formatPercent(roi.roiAnnualPercent)}.
|
||||
</span>{' '}
|
||||
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar uno de estos casos:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
<li>Frecuencia anual del proceso sobreestimada</li>
|
||||
<li>Costo automatizado por ejecución subestimado</li>
|
||||
<li>Inversión en automatización subestimada</li>
|
||||
</ul>
|
||||
<p className="text-amber-600">
|
||||
Revisá estos inputs antes de presentar el reporte al cliente.
|
||||
Ver <span className="font-mono">METHODOLOGY_AUTOMATED_COST.md</span> para guía de estimación.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useCallback } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { runSimulation } from '@/domain/simulation'
|
||||
import { simulationRepo } from '@/persistence/repositories'
|
||||
import type { SimulationInput } from '@/domain/types'
|
||||
|
||||
export function useSimulate() {
|
||||
const { currentProcess, activities, gateways, resources } = useProcessStore()
|
||||
const { setResult, setRunning, setError } = useSimulationStore()
|
||||
const { persistResults, setRunning, setError } = useSimulationStore()
|
||||
|
||||
const simulate = useCallback(async () => {
|
||||
if (!currentProcess) {
|
||||
@@ -20,7 +18,7 @@ export function useSimulate() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const input: SimulationInput = {
|
||||
const baseInput: Omit<SimulationInput, 'scenario'> = {
|
||||
processXml: currentProcess.bpmnXml,
|
||||
activities: new Map(activities.map((a) => [a.bpmnElementId, a])),
|
||||
gateways: new Map(gateways.map((g) => [g.bpmnElementId, g])),
|
||||
@@ -31,26 +29,20 @@ export function useSimulate() {
|
||||
},
|
||||
}
|
||||
|
||||
const result = runSimulation(input)
|
||||
// Las dos simulaciones se computan y persisten SIEMPRE en conjunto (regla dura).
|
||||
const resultActual = runSimulation({ ...baseInput, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' })
|
||||
|
||||
const simulation = {
|
||||
id: uuidv4(),
|
||||
processId: currentProcess.id,
|
||||
executedAt: Date.now(),
|
||||
result,
|
||||
}
|
||||
|
||||
await simulationRepo.save(simulation)
|
||||
setResult(result)
|
||||
await persistResults(currentProcess.id, resultActual, resultAutomated)
|
||||
setRunning(false)
|
||||
return result
|
||||
return { resultActual, resultAutomated }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error en la simulación'
|
||||
setError(message)
|
||||
setRunning(false)
|
||||
return null
|
||||
}
|
||||
}, [currentProcess, activities, gateways, resources, setResult, setRunning, setError])
|
||||
}, [currentProcess, activities, gateways, resources, persistResults, setRunning, setError])
|
||||
|
||||
return { simulate }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PlusCircle, Trash2, Info } from 'lucide-react'
|
||||
import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -7,6 +7,8 @@ import { Slider } from '@/components/ui/slider'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Activity } from '@/domain/types'
|
||||
@@ -226,6 +228,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sección: Automatización ──────────────────────────────────── */}
|
||||
<div>
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Automatización</p>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="automatable-toggle" className="text-xs font-medium cursor-pointer">
|
||||
¿Es automatizable?
|
||||
</Label>
|
||||
<Switch
|
||||
id="automatable-toggle"
|
||||
checked={localActivity.automatable}
|
||||
onCheckedChange={(v) => handleChange('automatable', v)}
|
||||
aria-label="Marcar actividad como automatizable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campos — aparecen con transición suave cuando toggle=ON */}
|
||||
<div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-80 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
|
||||
{/* Costo automatizado */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="automated-cost" className="text-xs font-medium">
|
||||
Costo automatizado
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-56 text-xs">
|
||||
Costo recurrente por ejecución automatizada. No incluye la inversión inicial del proyecto.
|
||||
Ver METHODOLOGY_AUTOMATED_COST.md para la guía de cálculo.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="automated-cost"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={localActivity.automatedCostFixed === 0 ? '' : localActivity.automatedCostFixed}
|
||||
onChange={(e) => handleChange('automatedCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tiempo automatizado */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="automated-time" className="text-xs font-medium">
|
||||
Tiempo automatizado (min)
|
||||
</Label>
|
||||
<Input
|
||||
id="automated-time"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={localActivity.automatedTimeMinutes === 0 ? '' : localActivity.automatedTimeMinutes}
|
||||
onChange={(e) => handleChange('automatedTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Helper ámbar — siempre en el DOM para que la transición funcione
|
||||
al tipear un valor (no desaparece instantáneo sino con fade).
|
||||
La condición controla opacity + max-h, no mount/unmount. */}
|
||||
{(() => {
|
||||
const showHelper = localActivity.automatable
|
||||
&& localActivity.automatedCostFixed === 0
|
||||
&& localActivity.automatedTimeMinutes === 0
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!showHelper}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${
|
||||
showHelper ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-700 leading-snug">
|
||||
Esta actividad aparecerá en "Transparencia metodológica" del reporte hasta que cargues valores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
|
||||
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
||||
@@ -19,37 +19,117 @@ function classifyElement(type: string): BpmnElementCategory {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// SVG inline del ícono Bot de Lucide (12×12 px, color ámbar #f59e0b).
|
||||
// Copiado de lucide.dev/icons/bot — robot con antena, cuerpo rectangular y ojos.
|
||||
const BOT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4H8"/>
|
||||
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
||||
<path d="M2 14h2"/>
|
||||
<path d="M20 14h2"/>
|
||||
<path d="M15 13v2"/>
|
||||
<path d="M9 13v2"/>
|
||||
</svg>`
|
||||
|
||||
// HTML del badge de automatización.
|
||||
// Fondo #fffbeb (amber-50): identidad cromática con el ícono Bot ámbar,
|
||||
// contrasta con el fondo blanco de los nodos BPMN.
|
||||
function makeBadgeHtml(): string {
|
||||
return `<div title="Actividad marcada como automatizable" style="
|
||||
width:20px;height:20px;
|
||||
border-radius:50%;
|
||||
background:#fffbeb;
|
||||
border:1px solid #e2e8f0;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.12);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:default;
|
||||
pointer-events:none;
|
||||
">${BOT_SVG}</div>`
|
||||
}
|
||||
|
||||
// Identificador del tipo de overlay para poder remover solo los nuestros.
|
||||
const OVERLAY_TYPE = 'automation-indicator'
|
||||
|
||||
interface BpmnCanvasProps {
|
||||
xml: string
|
||||
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
|
||||
selectedElementId?: string | null
|
||||
automatableElementIds?: string[] // IDs de actividades marcadas como automatizables
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
|
||||
export function BpmnCanvas({
|
||||
xml,
|
||||
onElementClick,
|
||||
selectedElementId,
|
||||
automatableElementIds = [],
|
||||
className,
|
||||
}: BpmnCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
|
||||
const isImportedRef = useRef(false)
|
||||
|
||||
// ─── Inicializar viewer ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const viewer = new BpmnViewer({ container: containerRef.current })
|
||||
viewerRef.current = viewer
|
||||
return () => {
|
||||
isImportedRef.current = false
|
||||
viewer.destroy()
|
||||
viewerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ─── Sincronizar overlays de automatización ───────────────────────────────
|
||||
const syncAutomationOverlays = useCallback(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !isImportedRef.current) return
|
||||
|
||||
const overlays = viewer.get('overlays') as any
|
||||
const reg = viewer.get('elementRegistry') as any
|
||||
|
||||
// Remover overlays anteriores del tipo propio antes de re-agregar
|
||||
overlays.remove({ type: OVERLAY_TYPE })
|
||||
|
||||
for (const elementId of automatableElementIds) {
|
||||
try {
|
||||
// Leer el ancho real del elemento para posicionar el badge en la
|
||||
// esquina superior derecha con overlap: left = width - 12 pone el
|
||||
// borde derecho del badge 8px fuera del borde derecho del nodo.
|
||||
// (right: -8 en la API tiene semántica distinta: left = -right + width
|
||||
// con -8 daría left = 8 + width, enteramente fuera del nodo.)
|
||||
const el = reg?.get(elementId)
|
||||
const width = el?.width ?? 100
|
||||
overlays.add(elementId, OVERLAY_TYPE, {
|
||||
position: { top: -8, left: width - 12 },
|
||||
html: makeBadgeHtml(),
|
||||
})
|
||||
} catch {
|
||||
// El elemento puede no existir en el diagrama actual — ignorar
|
||||
}
|
||||
}
|
||||
}, [automatableElementIds])
|
||||
|
||||
// ─── Importar XML ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !xml) return
|
||||
isImportedRef.current = false
|
||||
viewer.importXML(xml).then(({ warnings }) => {
|
||||
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
|
||||
const canvas = viewer.get('canvas') as any
|
||||
canvas.zoom('fit-viewport', 'auto')
|
||||
isImportedRef.current = true
|
||||
syncAutomationOverlays()
|
||||
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
|
||||
}, [xml])
|
||||
}, [xml, syncAutomationOverlays])
|
||||
|
||||
// ─── Re-sincronizar overlays cuando cambia la lista de automatizables ─────
|
||||
useEffect(() => {
|
||||
syncAutomationOverlays()
|
||||
}, [syncAutomationOverlays])
|
||||
|
||||
// ─── Click handler ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !onElementClick) return
|
||||
@@ -67,6 +147,7 @@ export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }
|
||||
return () => eventBus.off('element.click', handler)
|
||||
}, [onElementClick])
|
||||
|
||||
// ─── Highlight del elemento seleccionado ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
@@ -27,6 +28,9 @@ export function GlobalSettingsPanel() {
|
||||
const [localClient, setLocalClient] = useState('')
|
||||
const [localCurrency, setLocalCurrency] = useState('USD')
|
||||
const [localOverhead, setLocalOverhead] = useState(20)
|
||||
const [localFrequency, setLocalFrequency] = useState(1000)
|
||||
const [localHorizon, setLocalHorizon] = useState(3)
|
||||
const [localInvestment, setLocalInvestment] = useState(0)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,18 +39,26 @@ export function GlobalSettingsPanel() {
|
||||
setLocalClient(currentProcess.clientName)
|
||||
setLocalCurrency(currentProcess.currency)
|
||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||
setLocalFrequency(currentProcess.annualFrequency)
|
||||
setLocalHorizon(currentProcess.analysisHorizonYears)
|
||||
setLocalInvestment(currentProcess.automationInvestment)
|
||||
setIsDirty(false)
|
||||
}, [currentProcess])
|
||||
|
||||
function markDirty() { setIsDirty(true) }
|
||||
|
||||
async function handleSave() {
|
||||
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
||||
await updateProcess({
|
||||
name: localName.trim() || 'Proceso sin nombre',
|
||||
clientName: localClient.trim(),
|
||||
currency: localCurrency,
|
||||
overheadPercentage: localOverhead / 100,
|
||||
annualFrequency: Math.max(1, localFrequency),
|
||||
analysisHorizonYears: clampedHorizon,
|
||||
automationInvestment: Math.max(0, localInvestment),
|
||||
})
|
||||
setLocalHorizon(clampedHorizon)
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
@@ -125,6 +137,99 @@ export function GlobalSettingsPanel() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Sección: Análisis de Impacto ──────────────────────────────── */}
|
||||
<div>
|
||||
<Separator className="mb-4" />
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Análisis de Impacto</p>
|
||||
|
||||
{/* Frecuencia anual */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="annual-frequency" className="text-xs font-medium">
|
||||
Frecuencia anual
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-52 text-xs">
|
||||
Cantidad de veces que se ejecuta el proceso completo por año. Se usa para anualizar los costos y calcular el ahorro.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="annual-frequency"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10_000_000}
|
||||
step={1}
|
||||
value={localFrequency === 0 ? '' : localFrequency}
|
||||
onChange={(e) => { setLocalFrequency(Math.max(1, parseInt(e.target.value) || 1)); markDirty() }}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="1000"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Ejemplos: 12 (mensual) · 52 (semanal) · 250 (diario laboral) · 8760 (cada hora)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Horizonte de análisis */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs font-medium">Horizonte de análisis</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-52 text-xs">
|
||||
Años hasta los que se proyecta el ahorro acumulado. Determina el eje X del gráfico de ROI.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-sm font-mono font-semibold text-slate-700">
|
||||
{localHorizon} {localHorizon === 1 ? 'año' : 'años'}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localHorizon]}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={([v]) => { setLocalHorizon(v); markDirty() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inversión en automatización */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="automation-investment" className="text-xs font-medium">
|
||||
Inversión en automatización
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-56 text-xs">
|
||||
Costo único total del proyecto: desarrollo, licencias, implementación, capacitación. No incluir costos recurrentes.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="automation-investment"
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={localInvestment === 0 ? '' : localInvestment}
|
||||
onChange={(e) => { setLocalInvestment(Math.max(0, parseFloat(e.target.value) || 0)); markDirty() }}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle,
|
||||
@@ -23,8 +23,8 @@ type SelectedCategory = 'activity' | 'gateway' | null
|
||||
export function WorkspacePage() {
|
||||
const { processId } = useParams({ from: '/workspace/$processId' })
|
||||
const navigate = useNavigate()
|
||||
const { currentProcess, isLoading, error, loadProcess, gateways } = useProcessStore()
|
||||
const { isRunning, result, error: simError, selectActivity } = useSimulationStore()
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -64,6 +64,14 @@ export function WorkspacePage() {
|
||||
|
||||
const canSimulate = !isRunning && xorValidation.valid
|
||||
|
||||
// IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
|
||||
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
|
||||
const automatableElementIds = useMemo(
|
||||
() => activities.filter((a) => a.automatable).map((a) => a.bpmnElementId),
|
||||
[activities]
|
||||
)
|
||||
const deferredAutomatableIds = useDeferredValue(automatableElementIds)
|
||||
|
||||
const handleElementClick = useCallback((
|
||||
elementId: string,
|
||||
elementName: string,
|
||||
@@ -91,6 +99,8 @@ export function WorkspacePage() {
|
||||
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -161,7 +171,7 @@ export function WorkspacePage() {
|
||||
)}
|
||||
|
||||
{/* Ver reporte si ya hay simulación */}
|
||||
{result && (
|
||||
{resultActual && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -223,6 +233,7 @@ export function WorkspacePage() {
|
||||
xml={currentProcess.bpmnXml}
|
||||
onElementClick={handleElementClick}
|
||||
selectedElementId={selectedElementId}
|
||||
automatableElementIds={deferredAutomatableIds}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Papa from 'papaparse'
|
||||
import { formatSimulationTimestamp } from '@/lib/format'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import { buildFileName } from './slug'
|
||||
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
|
||||
import type { Process, Simulation, Resource, Activity, ActivitySimResult } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
|
||||
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
|
||||
@@ -18,19 +21,11 @@ function formatDecimalForCsv(value: number, currency: string): string {
|
||||
return formatted
|
||||
}
|
||||
|
||||
function buildResourcesCell(
|
||||
act: ActivitySimResult,
|
||||
resources: Resource[]
|
||||
): string {
|
||||
function buildResourcesCell(act: ActivitySimResult, resources: Resource[]): string {
|
||||
if (act.resourceCostBreakdown.length === 0) return ''
|
||||
const resourceById = new Map(resources.map((r) => [r.id, r]))
|
||||
return act.resourceCostBreakdown
|
||||
.map((rb) => {
|
||||
const res = resourceById.get(rb.resourceId)
|
||||
if (!res) return rb.resourceName
|
||||
// Calcular % de utilización desde el costo relativo
|
||||
return res.name
|
||||
})
|
||||
.map((rb) => resourceById.get(rb.resourceId)?.name ?? rb.resourceName)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
@@ -38,42 +33,43 @@ export interface CsvExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
resources: Resource[]
|
||||
tab?: ReportTab // default: 'actual'
|
||||
activities?: Activity[] // requerido para tab='roi'
|
||||
}
|
||||
|
||||
export function generateCsvContent(params: CsvExportParams): string {
|
||||
const { process, simulation, resources } = params
|
||||
const { process, simulation, resources, tab = 'actual', activities = [] } = params
|
||||
const { currency } = process
|
||||
const result = simulation.result
|
||||
|
||||
if (tab === 'roi') {
|
||||
return generateRoiCsvContent(process, simulation, resources, activities, currency)
|
||||
}
|
||||
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
|
||||
const delimiter = getDelimiter(currency)
|
||||
const { date } = formatSimulationTimestamp(simulation.executedAt)
|
||||
|
||||
// ── Filas de metadata como comentarios ──────────────────────────────────
|
||||
const meta = [
|
||||
`# Proceso: ${process.name}`,
|
||||
`# Cliente: ${process.clientName || 'N/A'}`,
|
||||
`# Fecha simulación: ${date}`,
|
||||
`# Moneda: ${currency}`,
|
||||
`# Escenario: ${tab === 'automatizado' ? 'Automatizado' : 'Actual'}`,
|
||||
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
|
||||
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
|
||||
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
// ── Headers ──────────────────────────────────────────────────────────────
|
||||
const headers = [
|
||||
'ID BPMN',
|
||||
'Actividad',
|
||||
'Tipo',
|
||||
`Costo directo (${currency})`,
|
||||
`Costo indirecto (${currency})`,
|
||||
`Costo total (${currency})`,
|
||||
'% del total',
|
||||
'Tiempo (min)',
|
||||
'Ejecuciones esperadas',
|
||||
'Recursos asignados',
|
||||
'ID BPMN', 'Actividad', 'Tipo',
|
||||
`Costo directo (${currency})`, `Costo indirecto (${currency})`, `Costo total (${currency})`,
|
||||
'% del total', 'Tiempo (min)', 'Ejecuciones esperadas', 'Recursos asignados',
|
||||
]
|
||||
|
||||
// ── Filas ─────────────────────────────────────────────────────────────────
|
||||
const rows = result.perActivity.map((act) => [
|
||||
act.bpmnElementId,
|
||||
act.activityName,
|
||||
@@ -87,22 +83,135 @@ export function generateCsvContent(params: CsvExportParams): string {
|
||||
buildResourcesCell(act, resources),
|
||||
])
|
||||
|
||||
const csvBody = Papa.unparse(
|
||||
{ fields: headers, data: rows },
|
||||
{ delimiter, newline: '\r\n', quotes: true }
|
||||
)
|
||||
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
|
||||
return '' + meta + csvBody
|
||||
}
|
||||
|
||||
// BOM UTF-8 + metadata + datos
|
||||
// ─── CSV de ROI: 16 columnas + metadata extendida ────────────────────────────
|
||||
|
||||
function generateRoiCsvContent(
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
): string {
|
||||
const resultActual = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? simulation.result
|
||||
const delimiter = getDelimiter(currency)
|
||||
const { date } = formatSimulationTimestamp(simulation.executedAt)
|
||||
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
|
||||
const paybackStr = !Number.isFinite(roi.paybackMonths)
|
||||
? 'N/A'
|
||||
: roi.paybackMonths === 0
|
||||
? '0'
|
||||
: formatDecimalForCsv(roi.paybackMonths, currency)
|
||||
|
||||
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
|
||||
? 'Infinito'
|
||||
: formatDecimalForCsv(roi.roiAnnualPercent, currency)
|
||||
|
||||
const meta = [
|
||||
`# Proceso: ${process.name}`,
|
||||
`# Cliente: ${process.clientName || 'N/A'}`,
|
||||
`# Fecha simulación: ${date}`,
|
||||
`# Moneda: ${currency}`,
|
||||
`# Costo total actual (${currency}): ${formatDecimalForCsv(resultActual.totalCost, currency)}`,
|
||||
`# Costo total automatizado (${currency}): ${formatDecimalForCsv(resultAutomated.totalCost, currency)}`,
|
||||
`# Ahorro por ejecución (${currency}): ${formatDecimalForCsv(roi.savingsPerExecution, currency)}`,
|
||||
`# Ahorro anual (${currency}): ${formatDecimalForCsv(roi.annualSavings, currency)}`,
|
||||
`# Payback (meses): ${paybackStr}`,
|
||||
`# Ahorro acumulado a ${process.analysisHorizonYears} años (${currency}): ${formatDecimalForCsv(roi.cumulativeSavingsHorizon, currency)}`,
|
||||
`# ROI anualizado (%): ${roiStr}`,
|
||||
`# Frecuencia anual: ${process.annualFrequency}`,
|
||||
`# Horizonte de análisis: ${process.analysisHorizonYears} años`,
|
||||
`# Inversión en automatización (${currency}): ${formatDecimalForCsv(process.automationInvestment, currency)}`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
// Mapas para lookup eficiente
|
||||
const autoByBpmnId = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a]))
|
||||
const actByBpmnId = new Map(activities.map((a) => [a.bpmnElementId, a]))
|
||||
|
||||
const headers = [
|
||||
'ID BPMN',
|
||||
'Actividad',
|
||||
'Tipo',
|
||||
'Automatizable',
|
||||
'Probabilidad (%)',
|
||||
`Costo actual directo (${currency})`,
|
||||
`Costo actual indirecto (${currency})`,
|
||||
`Costo actual total (${currency})`,
|
||||
'Tiempo actual (min)',
|
||||
`Costo automatizado directo (${currency})`,
|
||||
`Costo automatizado indirecto (${currency})`,
|
||||
`Costo automatizado total (${currency})`,
|
||||
'Tiempo automatizado (min)',
|
||||
`Ahorro (${currency})`,
|
||||
'Ahorro (%)',
|
||||
'Recursos asignados',
|
||||
]
|
||||
|
||||
const rows = [...resultActual.perActivity]
|
||||
.sort((a, b) => {
|
||||
const aSavings = a.expectedTotalCost - (autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost)
|
||||
const bSavings = b.expectedTotalCost - (autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost)
|
||||
return bSavings - aSavings
|
||||
})
|
||||
.map((act) => {
|
||||
const autoAct = autoByBpmnId.get(act.bpmnElementId)
|
||||
const domainAct = actByBpmnId.get(act.bpmnElementId)
|
||||
const isAutomatable = domainAct?.automatable ?? false
|
||||
const automatedTime = domainAct?.automatedTimeMinutes ?? act.executionTimeMinutes
|
||||
|
||||
const autoCostDirect = autoAct?.expectedDirectCost ?? act.expectedDirectCost
|
||||
const autoCostIndirect = autoAct?.expectedIndirectCost ?? act.expectedIndirectCost
|
||||
const autoCostTotal = autoAct?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - autoCostTotal
|
||||
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
|
||||
return [
|
||||
act.bpmnElementId,
|
||||
act.activityName,
|
||||
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
|
||||
isAutomatable ? 'true' : 'false',
|
||||
formatDecimalForCsv(act.executionProbability * 100, currency),
|
||||
formatDecimalForCsv(act.expectedDirectCost, currency),
|
||||
formatDecimalForCsv(act.expectedIndirectCost, currency),
|
||||
formatDecimalForCsv(act.expectedTotalCost, currency),
|
||||
formatDecimalForCsv(act.executionTimeMinutes, currency),
|
||||
formatDecimalForCsv(autoCostDirect, currency),
|
||||
formatDecimalForCsv(autoCostIndirect, currency),
|
||||
formatDecimalForCsv(autoCostTotal, currency),
|
||||
formatDecimalForCsv(automatedTime, currency),
|
||||
formatDecimalForCsv(savings, currency),
|
||||
formatDecimalForCsv(savingsPct, currency),
|
||||
buildResourcesCell(act, resources),
|
||||
]
|
||||
})
|
||||
|
||||
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
|
||||
return '' + meta + csvBody
|
||||
}
|
||||
|
||||
export function exportToCsv(params: CsvExportParams): void {
|
||||
const content = generateCsvContent(params)
|
||||
const tab = params.tab ?? 'actual'
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
const fileName = buildFileName(
|
||||
params.process.name,
|
||||
params.process.clientName,
|
||||
new Date(params.simulation.executedAt),
|
||||
'csv'
|
||||
'csv',
|
||||
suffix
|
||||
)
|
||||
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { buildFileName } from './slug'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
|
||||
} from './pdf-sections'
|
||||
import type { Process, Simulation, Resource } from '@/domain/types'
|
||||
import {
|
||||
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection,
|
||||
} from './pdf-sections-roi'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
export interface PdfExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
resources: Resource[]
|
||||
heatmapImageData: string | null // data URL de la imagen capturada del canvas
|
||||
heatmapImageData: string | null
|
||||
tab?: ReportTab // default: 'actual'
|
||||
activities?: Activity[] // requerido para tab='roi' (transparencia + tabla)
|
||||
}
|
||||
|
||||
export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
const { process, simulation, resources, heatmapImageData } = params
|
||||
const result = simulation.result
|
||||
const { process, simulation, resources, heatmapImageData, tab = 'actual', activities = [] } = params
|
||||
const currency = process.currency
|
||||
|
||||
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
|
||||
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable'),
|
||||
@@ -25,8 +32,36 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
|
||||
|
||||
// Metadata del PDF
|
||||
doc.setProperties({
|
||||
if (tab === 'roi') {
|
||||
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency)
|
||||
} else {
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency)
|
||||
}
|
||||
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
const fileName = buildFileName(process.name, process.clientName, new Date(simulation.executedAt), 'pdf', suffix)
|
||||
doc.save(fileName)
|
||||
}
|
||||
|
||||
// ─── Escenario único (actual o automatizado) ──────────────────────────────────
|
||||
|
||||
async function exportScenarioPdf(
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
result: Simulation['result'],
|
||||
resources: Resource[],
|
||||
heatmapImageData: string | null,
|
||||
currency: string
|
||||
): Promise<void> {
|
||||
const docAny = doc as any
|
||||
|
||||
docAny.setProperties({
|
||||
title: `Análisis de costos - ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Simulación de proceso BPMN',
|
||||
@@ -34,44 +69,37 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
keywords: 'bpmn, costos, simulación',
|
||||
})
|
||||
|
||||
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
|
||||
let y = drawHeader(doc as any, process, simulation.executedAt)
|
||||
y = drawKpiCards(doc as any, result, currency, y)
|
||||
let y = drawHeader(docAny, process, simulation.executedAt)
|
||||
y = drawKpiCards(docAny, result, currency, y)
|
||||
y += 4
|
||||
|
||||
// Título del heatmap
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Mapa de calor de costos', PDF.margin, y)
|
||||
docAny.setFont('helvetica', 'bold')
|
||||
docAny.setFontSize(10)
|
||||
docAny.setTextColor(...PDF.slate900)
|
||||
docAny.text('Mapa de calor de costos', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
y = drawHeatmapImage(doc as any, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(doc as any, y)
|
||||
y = drawHeatmapImage(docAny, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(docAny, y)
|
||||
y += 4
|
||||
|
||||
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
docAny.addPage()
|
||||
y = PDF.margin
|
||||
y = drawAnalysisSection(doc as any, result, currency, y)
|
||||
y = drawAnalysisSection(docAny, result, currency, y)
|
||||
y += 6
|
||||
|
||||
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
|
||||
void resources // disponible para uso futuro (recursos en tabla de actividades)
|
||||
void resources
|
||||
|
||||
const RA = 'right' as const
|
||||
|
||||
const tableHead = [
|
||||
[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
|
||||
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
],
|
||||
]
|
||||
const tableHead = [[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
|
||||
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
]]
|
||||
|
||||
const tableBody = result.perActivity.map((act) => [
|
||||
{ content: act.activityName },
|
||||
@@ -83,40 +111,92 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
|
||||
])
|
||||
|
||||
autoTable(doc, {
|
||||
autoTable(docAny, {
|
||||
head: tableHead,
|
||||
body: tableBody,
|
||||
startY: y,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
|
||||
headStyles: {
|
||||
fillColor: PDF.blue,
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
fontSize: 8,
|
||||
},
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
|
||||
alternateRowStyles: { fillColor: PDF.slate50 },
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
didDrawPage: () => {
|
||||
// Placeholder — los footers se añaden al final con addPageNumbers
|
||||
},
|
||||
})
|
||||
|
||||
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
|
||||
docAny.addPage()
|
||||
drawMethodologySection(docAny, process, PDF.margin)
|
||||
addPageNumbers(docAny, 'Process Cost Platform', simulation.executedAt)
|
||||
}
|
||||
|
||||
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
|
||||
|
||||
async function exportRoiPdf(
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
): Promise<void> {
|
||||
const result = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? result
|
||||
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: result.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
|
||||
doc.setProperties({
|
||||
title: `Análisis de ROI - ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Análisis de retorno de inversión para automatización BPMN',
|
||||
creator: 'Process Cost Platform',
|
||||
keywords: 'bpmn, roi, automatización, payback',
|
||||
})
|
||||
|
||||
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
|
||||
let y = drawHeader(doc, process, simulation.executedAt)
|
||||
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
|
||||
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
|
||||
|
||||
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawMethodologySection(doc as any, process, PDF.margin)
|
||||
y = PDF.margin
|
||||
|
||||
// ── FOOTER en todas las páginas ───────────────────────────────────────────
|
||||
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Comparación por actividad', PDF.margin, y)
|
||||
y += 4
|
||||
|
||||
// ── Descargar ─────────────────────────────────────────────────────────────
|
||||
const fileName = buildFileName(
|
||||
process.name,
|
||||
process.clientName,
|
||||
new Date(simulation.executedAt),
|
||||
'pdf'
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text('★ Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
|
||||
y += 8
|
||||
|
||||
const tableConfig = buildComparisonTableConfig(
|
||||
result.perActivity,
|
||||
resultAutomated.perActivity,
|
||||
activities,
|
||||
currency,
|
||||
y
|
||||
)
|
||||
doc.save(fileName)
|
||||
autoTable(doc, tableConfig)
|
||||
|
||||
// ── Página 3: Análisis textual + transparencia ─────────────────────────────
|
||||
doc.addPage()
|
||||
y = PDF.margin
|
||||
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
|
||||
|
||||
// ── Página 4: Metodología ──────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawRoiMethodologySection(doc, process, PDF.margin)
|
||||
|
||||
addPageNumbers(doc, 'Process Cost Platform · ROI', simulation.executedAt)
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
|
||||
458
src/lib/export/pdf-sections-roi.ts
Normal file
458
src/lib/export/pdf-sections-roi.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
import { PDF, type DocLike } from './pdf-sections'
|
||||
|
||||
const ROI_HIGH_THRESHOLD = 1000
|
||||
|
||||
// ─── Resumen ejecutivo ────────────────────────────────────────────────────────
|
||||
|
||||
export function drawRoiExecutiveSummary(
|
||||
doc: DocLike,
|
||||
process: Process,
|
||||
roi: RoiResult,
|
||||
currency: string,
|
||||
startY: number
|
||||
): number {
|
||||
let y = startY
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Análisis de retorno de inversión', PDF.margin, y)
|
||||
y += 6
|
||||
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
|
||||
const investmentStr = process.automationInvestment > 0
|
||||
? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}`
|
||||
: 'inversión aún no configurada'
|
||||
|
||||
const summary = `Análisis de retorno de inversión para automatización del proceso "${process.name}"` +
|
||||
(process.clientName ? ` del cliente ${process.clientName}` : '') +
|
||||
`, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` +
|
||||
` horizonte de ${process.analysisHorizonYears} años e ${investmentStr}.`
|
||||
|
||||
const summaryLines = doc.splitTextToSize(summary, PDF.contentW)
|
||||
doc.text(summaryLines, PDF.margin, y)
|
||||
y += summaryLines.length * 4.5 + 3
|
||||
|
||||
// Sub-resumen de resultado
|
||||
const resultText = roi.savingsPerExecution > 0
|
||||
? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución (${formatPercent(roi.savingsPerExecutionPercent)} de reducción). ` +
|
||||
(Number.isFinite(roi.paybackMonths)
|
||||
? roi.breaksEvenInHorizon
|
||||
? `La inversión se recupera en ${roi.paybackMonths < 24 ? `${roi.paybackMonths.toFixed(1)} meses` : `${roi.paybackYears.toFixed(1)} años`}, dentro del horizonte de análisis.`
|
||||
: `El payback ocurre en ${roi.paybackYears.toFixed(1)} años, fuera del horizonte analizado.`
|
||||
: 'Sin ahorro neto — la automatización no se recupera con los datos actuales.')
|
||||
: 'El escenario automatizado no genera ahorro con los datos actuales. Revisá los costos configurados.'
|
||||
|
||||
const resultLines = doc.splitTextToSize(resultText, PDF.contentW)
|
||||
doc.setFont('helvetica', 'italic')
|
||||
doc.setFontSize(8.5)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text(resultLines, PDF.margin, y)
|
||||
y += resultLines.length * 4 + 4
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
return y
|
||||
}
|
||||
|
||||
// ─── 5 KPI cards (grid 2×3 — 5 celdas, 6ta vacía o con meta-info) ────────────
|
||||
|
||||
export function drawRoiKpiCards(
|
||||
doc: DocLike,
|
||||
roi: RoiResult,
|
||||
currency: string,
|
||||
horizonYears: number,
|
||||
startY: number
|
||||
): number {
|
||||
const cardW = 52, cardH = 28, gapX = 7, gapY = 5
|
||||
const col1 = PDF.margin, col2 = PDF.margin + cardW + gapX, col3 = PDF.margin + (cardW + gapX) * 2
|
||||
|
||||
const paybackStr = !Number.isFinite(roi.paybackMonths)
|
||||
? 'No se recupera'
|
||||
: roi.paybackMonths === 0
|
||||
? 'Inmediato'
|
||||
: roi.paybackMonths < 24
|
||||
? `${roi.paybackMonths.toFixed(1)} meses`
|
||||
: `${roi.paybackYears.toFixed(1)} años`
|
||||
|
||||
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
|
||||
? '∞'
|
||||
: `${roi.roiAnnualPercent.toFixed(0)}%`
|
||||
|
||||
const kpis = [
|
||||
// Fila 1: 3 cards
|
||||
{ label: 'AHORRO POR EJECUCIÓN', value: formatCurrency(roi.savingsPerExecution, currency), sub: `${formatPercent(roi.savingsPerExecutionPercent)} vs. actual`, positive: roi.savingsPerExecution >= 0 },
|
||||
{ label: 'AHORRO ANUAL', value: formatCurrency(roi.annualSavings, currency), sub: `× ${horizonYears} años`, positive: roi.annualSavings >= 0 },
|
||||
{ label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon },
|
||||
// Fila 2: 2 cards + 1 vacía
|
||||
{ label: `ACUMULADO (${horizonYears} AÑOS)`, value: formatCurrency(roi.cumulativeSavingsHorizon, currency), sub: 'neto de inversión', positive: roi.cumulativeSavingsHorizon >= 0 },
|
||||
{ label: 'ROI ANUAL', value: roiStr, sub: 'retorno sobre la inversión', positive: roi.roiAnnualPercent >= 0 },
|
||||
]
|
||||
|
||||
const cols = [col1, col2, col3]
|
||||
let maxY = startY
|
||||
|
||||
kpis.forEach((kpi, idx) => {
|
||||
const row = Math.floor(idx / 3)
|
||||
const col = idx % 3
|
||||
const x = cols[col]
|
||||
const y = startY + row * (cardH + gapY)
|
||||
|
||||
// Fondo
|
||||
doc.setFillColor(...PDF.slate50)
|
||||
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
|
||||
|
||||
// Borde
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S')
|
||||
|
||||
// Label
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(5.5)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text(kpi.label, x + 3, y + 5.5)
|
||||
|
||||
// Valor
|
||||
const valueColor = kpi.positive ? [16, 185, 129] as [number,number,number] : [239, 68, 68] as [number,number,number]
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(kpi.value.length > 14 ? 9 : 11)
|
||||
doc.setTextColor(...valueColor)
|
||||
doc.text(kpi.value, x + 3, y + 16)
|
||||
|
||||
// Sub-texto
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(6)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
const subLines = doc.splitTextToSize(kpi.sub, cardW - 6)
|
||||
doc.text(subLines, x + 3, y + 22)
|
||||
|
||||
maxY = Math.max(maxY, y + cardH)
|
||||
})
|
||||
|
||||
return maxY + 6
|
||||
}
|
||||
|
||||
// ─── Config de autotable para la tabla comparativa ────────────────────────────
|
||||
// Se llama desde pdf-export.ts donde autoTable ya está importado dinámicamente.
|
||||
|
||||
export function buildComparisonTableConfig(
|
||||
perActivityActual: ActivitySimResult[],
|
||||
perActivityAutomated: ActivitySimResult[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
): Record<string, unknown> {
|
||||
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
|
||||
const isAutomatableMap = new Map(activities.map((a) => [a.bpmnElementId, a.automatable]))
|
||||
|
||||
const RA = 'right' as const
|
||||
|
||||
const head = [[
|
||||
{ content: 'Actividad', styles: { cellWidth: 42 } },
|
||||
{ content: `Act. (${currency})`, styles: { halign: RA, cellWidth: 22 } },
|
||||
{ content: `Auto. (${currency})`, styles: { halign: RA, cellWidth: 22 } },
|
||||
{ content: `Ahorro (${currency})`, styles: { halign: RA, cellWidth: 22 } },
|
||||
{ content: 'Ahorro %', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
]]
|
||||
|
||||
const body = [...perActivityActual]
|
||||
.sort((a, b) => {
|
||||
const aCost = autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost
|
||||
const bCost = autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost
|
||||
return (b.expectedTotalCost - bCost) - (a.expectedTotalCost - aCost)
|
||||
})
|
||||
.map((act) => {
|
||||
const auto = autoByBpmnId.get(act.bpmnElementId)
|
||||
const autoCost = auto?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - autoCost
|
||||
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
const isAutomatable = isAutomatableMap.get(act.bpmnElementId) ?? false
|
||||
|
||||
const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139]
|
||||
const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252]
|
||||
|
||||
return [
|
||||
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const } },
|
||||
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
|
||||
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
|
||||
{ content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } },
|
||||
{ content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } },
|
||||
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill } },
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
startY,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
|
||||
columnStyles: { 0: { cellWidth: 42 } },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Página 3: resúmenes textuales + transparencia ────────────────────────────
|
||||
|
||||
export function drawRoiAnalysisPage(
|
||||
doc: DocLike,
|
||||
roi: RoiResult,
|
||||
process: Process,
|
||||
perActivityActual: ActivitySimResult[],
|
||||
perActivityAutomated: ActivitySimResult[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
): number {
|
||||
let y = startY
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Análisis del ahorro', PDF.margin, y)
|
||||
y += 6
|
||||
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
|
||||
y += 6
|
||||
|
||||
// ── Composición del ahorro ──────────────────────────────────────────────────
|
||||
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
|
||||
const savingsPerAct = perActivityActual
|
||||
.map((a) => {
|
||||
const auto = autoByBpmnId.get(a.bpmnElementId)
|
||||
return { name: a.activityName, savings: a.expectedTotalCost - (auto?.expectedTotalCost ?? a.expectedTotalCost) }
|
||||
})
|
||||
.filter((s) => s.savings > 0)
|
||||
.sort((a, b) => b.savings - a.savings)
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
doc.text('Composición del ahorro', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8.5)
|
||||
|
||||
if (savingsPerAct.length > 0) {
|
||||
const top3 = savingsPerAct.slice(0, 3)
|
||||
const top3TotalSavings = top3.reduce((s, a) => s + a.savings, 0)
|
||||
const totalSavingsAll = savingsPerAct.reduce((s, a) => s + a.savings, 0)
|
||||
const top3Pct = totalSavingsAll > 0 ? (top3TotalSavings / totalSavingsAll * 100).toFixed(0) : '0'
|
||||
|
||||
const compositionText = `El ${top3Pct}% del ahorro total proviene de las primeras ${Math.min(3, savingsPerAct.length)} actividades automatizadas:`
|
||||
const compositionLines = doc.splitTextToSize(compositionText, PDF.contentW)
|
||||
doc.text(compositionLines, PDF.margin, y)
|
||||
y += compositionLines.length * 4.5 + 2
|
||||
|
||||
for (const act of top3) {
|
||||
const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0'
|
||||
doc.text(` • ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, PDF.margin, y)
|
||||
y += 4.5
|
||||
}
|
||||
} else {
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text('Sin actividades con ahorro positivo en el escenario actual.', PDF.margin, y)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
y += 5
|
||||
}
|
||||
y += 4
|
||||
|
||||
// ── Trayectoria del ahorro ──────────────────────────────────────────────────
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.text('Trayectoria del ahorro acumulado', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8.5)
|
||||
|
||||
let trajectoryText: string
|
||||
if (process.automationInvestment > 0 && Number.isFinite(roi.paybackMonths)) {
|
||||
const recoveryStr = roi.paybackMonths < 24
|
||||
? `${roi.paybackMonths.toFixed(1)} meses`
|
||||
: `${roi.paybackYears.toFixed(1)} años`
|
||||
const multiple = process.automationInvestment > 0
|
||||
? (roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)
|
||||
: '∞'
|
||||
trajectoryText =
|
||||
`La inversión de ${formatCurrency(process.automationInvestment, currency)} se recupera en ${recoveryStr}. ` +
|
||||
`A ${process.analysisHorizonYears} años, el ahorro acumulado proyectado es ` +
|
||||
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial.`
|
||||
} else if (process.automationInvestment === 0) {
|
||||
trajectoryText =
|
||||
`Sin inversión configurada — el ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
|
||||
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años.`
|
||||
} else {
|
||||
trajectoryText = 'Sin ahorro proyectado con los datos actuales. Revisá los costos automatizados configurados.'
|
||||
}
|
||||
|
||||
const trajectoryLines = doc.splitTextToSize(trajectoryText, PDF.contentW)
|
||||
doc.text(trajectoryLines, PDF.margin, y)
|
||||
y += trajectoryLines.length * 4.5 + 6
|
||||
|
||||
// ── Transparencia metodológica (si aplica) ────────────────────────────────
|
||||
y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y)
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
export function drawTransparencyBlock(
|
||||
doc: DocLike,
|
||||
activities: Activity[],
|
||||
investment: number,
|
||||
roi: RoiResult,
|
||||
startY: number
|
||||
): number {
|
||||
const zeroCost = activities.filter(
|
||||
(a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0
|
||||
)
|
||||
const investmentZero = investment === 0
|
||||
const roiHigh = Number.isFinite(roi.roiAnnualPercent) && roi.roiAnnualPercent > ROI_HIGH_THRESHOLD
|
||||
|
||||
if (zeroCost.length === 0 && !investmentZero && !roiHigh) return startY
|
||||
|
||||
const boxX = PDF.margin
|
||||
const boxW = PDF.contentW
|
||||
|
||||
// ── Paso 1: recopilar todo el contenido para calcular altura ─────────────
|
||||
type TextBlock = { text: string[]; bold?: boolean; fontSize: number }
|
||||
const blocks: TextBlock[] = []
|
||||
|
||||
blocks.push({ text: ['Transparencia metodológica'], bold: true, fontSize: 9 })
|
||||
|
||||
if (zeroCost.length > 0) {
|
||||
const msg = zeroCost.length === 1
|
||||
? `1 actividad automatizable sin costo configurado: "${zeroCost[0].name || zeroCost[0].bpmnElementId}". El ahorro puede estar inflado artificialmente.`
|
||||
: `${zeroCost.length} actividades sin costo automatizado: ${zeroCost.map((a) => a.name || a.bpmnElementId).join(', ')}. El ahorro puede estar inflado.`
|
||||
blocks.push({ text: doc.splitTextToSize(msg, boxW - 8), fontSize: 8 })
|
||||
}
|
||||
|
||||
if (investmentZero) {
|
||||
blocks.push({
|
||||
text: doc.splitTextToSize(
|
||||
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión en el workspace para un análisis realista.',
|
||||
boxW - 8
|
||||
),
|
||||
fontSize: 8,
|
||||
})
|
||||
}
|
||||
|
||||
if (roiHigh) {
|
||||
blocks.push({
|
||||
text: doc.splitTextToSize(
|
||||
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
|
||||
boxW - 8
|
||||
),
|
||||
fontSize: 8,
|
||||
})
|
||||
}
|
||||
|
||||
// Calcular altura total del bloque
|
||||
let height = 6 // padding top
|
||||
for (const b of blocks) {
|
||||
height += b.text.length * (b.bold ? 4.5 : 3.8) + 3
|
||||
}
|
||||
height += 3 // padding bottom
|
||||
|
||||
// ── Paso 2: dibujar fondo y borde ────────────────────────────────────────
|
||||
doc.setFillColor(255, 251, 235) // amber-50
|
||||
doc.setDrawColor(253, 230, 138) // amber-200
|
||||
doc.setLineWidth(0.3)
|
||||
doc.rect(boxX, startY, boxW, height, 'FD')
|
||||
|
||||
// ── Paso 3: dibujar texto encima del rectángulo ───────────────────────────
|
||||
let y = startY + 6
|
||||
|
||||
for (const b of blocks) {
|
||||
if (b.bold) {
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(b.fontSize)
|
||||
doc.setTextColor(146, 64, 14) // amber-800
|
||||
} else {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(b.fontSize)
|
||||
doc.setTextColor(180, 83, 9) // amber-700
|
||||
}
|
||||
doc.text(b.text, boxX + 4, y)
|
||||
y += b.text.length * (b.bold ? 4.5 : 3.8) + 3
|
||||
}
|
||||
|
||||
return startY + height + 4
|
||||
}
|
||||
|
||||
// ─── Sección metodológica ampliada para el tab ROI ────────────────────────────
|
||||
|
||||
export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number {
|
||||
let y = startY
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Costo por ejecución actual:',
|
||||
body: `Σ(actividades) [costo_fijo × prob_ejecución] × (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagación hacia adelante en el grafo BPMN.`,
|
||||
},
|
||||
{
|
||||
title: 'Costo por ejecución automatizado:',
|
||||
body: 'Mismo modelo, pero las actividades marcadas como automatizables usan su costo automatizado configurado. Las no marcadas mantienen el costo actual.',
|
||||
},
|
||||
{
|
||||
title: 'Ahorro anual:',
|
||||
body: `(Costo actual − Costo automatizado) × ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones/año.`,
|
||||
},
|
||||
{
|
||||
title: 'Payback (meses):',
|
||||
body: `(Inversión / Ahorro anual) × 12. Nominal simple — no incluye tasa de descuento ni inflación.`,
|
||||
},
|
||||
{
|
||||
title: 'Ahorro acumulado:',
|
||||
body: `Ahorro anual × ${process.analysisHorizonYears} años − Inversión inicial. Nominal, sin VP.`,
|
||||
},
|
||||
{
|
||||
title: 'ROI anualizado:',
|
||||
body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
|
||||
},
|
||||
]
|
||||
|
||||
for (const s of sections) {
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(s.title, PDF.margin, y)
|
||||
y += 4
|
||||
doc.setFont('helvetica', 'normal')
|
||||
const lines = doc.splitTextToSize(s.body, PDF.contentW - 4)
|
||||
doc.text(lines, PDF.margin + 4, y)
|
||||
y += lines.length * 3.8 + 2
|
||||
}
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
// ─── Helpers locales ──────────────────────────────────────────────────────────
|
||||
|
||||
function fmtNum(n: number): string {
|
||||
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const PDF = {
|
||||
}
|
||||
|
||||
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
|
||||
type DocLike = {
|
||||
export type DocLike = {
|
||||
setFont: (name: string, style: string) => void
|
||||
setFontSize: (size: number) => void
|
||||
setTextColor: (...args: number[]) => void
|
||||
|
||||
@@ -12,7 +12,8 @@ export function buildFileName(
|
||||
processName: string,
|
||||
clientName: string | undefined,
|
||||
date: Date,
|
||||
ext: 'pdf' | 'csv'
|
||||
ext: 'pdf' | 'csv',
|
||||
suffix = '' // '_actual' | '_automatizado' | '_roi' | ''
|
||||
): string {
|
||||
const dateStr = [
|
||||
date.getFullYear(),
|
||||
@@ -24,5 +25,5 @@ export function buildFileName(
|
||||
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
|
||||
parts.push(dateStr)
|
||||
|
||||
return `${parts.join('_')}.${ext}`
|
||||
return `${parts.join('_')}${suffix}.${ext}`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,29 @@ export class ProcessCostDb extends Dexie {
|
||||
resources: 'id, processId, name',
|
||||
simulations: 'id, processId, executedAt',
|
||||
})
|
||||
|
||||
// Sprint 1: agrega campos de automatización a Activity y volumetría a Process.
|
||||
// El upgrade popula datos existentes con defaults para garantizar invariante de tipo.
|
||||
this.version(2)
|
||||
.stores({
|
||||
processes: 'id, name, updatedAt',
|
||||
activities: 'id, processId, bpmnElementId',
|
||||
gateways: 'id, processId, bpmnElementId',
|
||||
resources: 'id, processId, name',
|
||||
simulations: 'id, processId, executedAt',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
await tx.table('activities').toCollection().modify((act) => {
|
||||
if (act.automatable === undefined) act.automatable = false
|
||||
if (act.automatedCostFixed === undefined) act.automatedCostFixed = 0
|
||||
if (act.automatedTimeMinutes === undefined) act.automatedTimeMinutes = 0
|
||||
})
|
||||
await tx.table('processes').toCollection().modify((proc) => {
|
||||
if (proc.annualFrequency === undefined) proc.annualFrequency = 1000
|
||||
if (proc.analysisHorizonYears === undefined) proc.analysisHorizonYears = 3
|
||||
if (proc.automationInvestment === undefined) proc.automationInvestment = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Repositorios de acceso a IndexedDB (Dexie).
|
||||
*
|
||||
* REGLA ARQUITECTÓNICA: este módulo solo debe importarse desde `src/store/**`.
|
||||
* Las features y hooks deben mutar datos a través de los stores (useProcessStore,
|
||||
* useSimulationStore), que garantizan la invalidación del resultado de simulación
|
||||
* cuando los datos cambian.
|
||||
*
|
||||
* Excepciones permitidas (ver eslint.config.js):
|
||||
* - src/features/import/** → operación de importación inicial (no hay simulación previa)
|
||||
* - src/features/report/useReportData.ts → solo lectura, sin mutaciones
|
||||
* - tests/** → acceso directo controlado en contexto de testing
|
||||
*
|
||||
* Cualquier import desde fuera de estos paths falla `npm run lint`.
|
||||
*/
|
||||
|
||||
import { db } from './db'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
|
||||
|
||||
// ─── Process ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const processRepo = {
|
||||
async save(process: Process): Promise<void> {
|
||||
await db.processes.put(process)
|
||||
@@ -29,6 +48,7 @@ export const processRepo = {
|
||||
|
||||
// ─── Activity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const activityRepo = {
|
||||
async save(activity: Activity): Promise<void> {
|
||||
await db.activities.put(activity)
|
||||
@@ -56,6 +76,7 @@ export const activityRepo = {
|
||||
|
||||
// ─── GatewayConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const gatewayRepo = {
|
||||
async save(gateway: GatewayConfig): Promise<void> {
|
||||
await db.gateways.put(gateway)
|
||||
@@ -76,6 +97,7 @@ export const gatewayRepo = {
|
||||
|
||||
// ─── Resource ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const resourceRepo = {
|
||||
async save(resource: Resource): Promise<void> {
|
||||
await db.resources.put(resource)
|
||||
@@ -96,6 +118,7 @@ export const resourceRepo = {
|
||||
|
||||
// ─── Simulation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/simulation-store.ts */
|
||||
export const simulationRepo = {
|
||||
async save(simulation: Simulation): Promise<void> {
|
||||
await db.simulations.put(simulation)
|
||||
|
||||
@@ -1,28 +1,76 @@
|
||||
import { create } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { SimulationResult } from '@/domain/types'
|
||||
import { simulationRepo } from '@/persistence/repositories'
|
||||
|
||||
// Excepción arquitectónica documentada: simulation-store importa process-store en una dirección
|
||||
// para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa.
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
|
||||
interface SimulationState {
|
||||
result: SimulationResult | null
|
||||
resultActual: SimulationResult | null // resultado del escenario actual
|
||||
resultAutomated: SimulationResult | null // resultado del escenario automatizado
|
||||
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
|
||||
isRunning: boolean
|
||||
error: string | null
|
||||
selectedActivityId: string | null
|
||||
|
||||
setResult: (result: SimulationResult) => void
|
||||
// Persiste ambos resultados en IndexedDB y actualiza el estado en memoria.
|
||||
// REGLA: los dos resultados siempre se guardan y actualizan en conjunto.
|
||||
persistResults: (processId: string, actual: SimulationResult, automated: SimulationResult) => Promise<void>
|
||||
setRunning: (running: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
selectActivity: (bpmnElementId: string | null) => void
|
||||
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
|
||||
invalidateResults: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useSimulationStore = create<SimulationState>((set) => ({
|
||||
result: null,
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
|
||||
setResult: (result) => set({ result, error: null }),
|
||||
persistResults: async (processId, actual, automated) => {
|
||||
await simulationRepo.save({
|
||||
id: uuidv4(),
|
||||
processId,
|
||||
executedAt: Date.now(),
|
||||
result: actual,
|
||||
resultAutomated: automated,
|
||||
})
|
||||
set({ resultActual: actual, resultAutomated: automated, lastSimulatedAt: new Date(), error: null })
|
||||
},
|
||||
|
||||
setRunning: (isRunning) => set({ isRunning }),
|
||||
setError: (error) => set({ error }),
|
||||
selectActivity: (selectedActivityId) => set({ selectedActivityId }),
|
||||
reset: () => set({ result: null, isRunning: false, error: null, selectedActivityId: null }),
|
||||
|
||||
invalidateResults: () =>
|
||||
set({ resultActual: null, resultAutomated: null, lastSimulatedAt: null }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
|
||||
// invalida los resultados para garantizar que nunca haya resultados "viejos" visibles.
|
||||
// La comparación por referencia de Zustand asegura que solo fires en cambios reales.
|
||||
useProcessStore.subscribe((state, prevState) => {
|
||||
if (
|
||||
state.activities !== prevState.activities ||
|
||||
state.currentProcess !== prevState.currentProcess
|
||||
) {
|
||||
useSimulationStore.getState().invalidateResults()
|
||||
}
|
||||
})
|
||||
|
||||
161
tests/domain/roi.test.ts
Normal file
161
tests/domain/roi.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Tests del módulo domain/roi.ts.
|
||||
* Cubren: happy path, edge cases de inversión cero, ahorro cero/negativo,
|
||||
* división por cero, escenarios de largo plazo y corto plazo.
|
||||
* Todas las fórmulas son nominales simples (ADR-014).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import type { RoiInput } from '@/domain/roi'
|
||||
|
||||
// ─── Fixture base ─────────────────────────────────────────────────────────────
|
||||
|
||||
function baseInput(overrides: Partial<RoiInput> = {}): RoiInput {
|
||||
return {
|
||||
costPerExecutionActual: 100,
|
||||
costPerExecutionAutomated: 20,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 50_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateRoi — happy path', () => {
|
||||
it('calcula ahorro por ejecución correctamente', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.savingsPerExecution).toBeCloseTo(80)
|
||||
})
|
||||
|
||||
it('calcula ahorro por ejecución en %', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.savingsPerExecutionPercent).toBeCloseTo(80) // 80/100 = 80%
|
||||
})
|
||||
|
||||
it('calcula ahorro anual = ahorro/ejecución × frecuencia', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.annualSavings).toBeCloseTo(80_000) // 80 × 1000
|
||||
})
|
||||
|
||||
it('calcula payback en meses correctamente', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
// $50.000 / $80.000/año × 12 = 7.5 meses
|
||||
expect(r.paybackMonths).toBeCloseTo(7.5)
|
||||
})
|
||||
|
||||
it('calcula payback en años', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.paybackYears).toBeCloseTo(0.625)
|
||||
})
|
||||
|
||||
it('calcula ahorro acumulado neto al horizonte', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
// ($80.000 × 3 años) - $50.000 = $190.000
|
||||
expect(r.cumulativeSavingsHorizon).toBeCloseTo(190_000)
|
||||
})
|
||||
|
||||
it('el payback dentro del horizonte es true cuando paybackYears < horizonYears', () => {
|
||||
const r = calculateRoi(baseInput()) // payback ~0.625 < 3 años
|
||||
expect(r.breaksEvenInHorizon).toBe(true)
|
||||
})
|
||||
|
||||
it('calcula ROI anualizado %', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
// ($80.000 / $50.000) × 100 = 160%
|
||||
expect(r.roiAnnualPercent).toBeCloseTo(160)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Edge case: inversión cero con ahorro positivo ────────────────────────────
|
||||
|
||||
describe('calculateRoi — inversión cero', () => {
|
||||
it('payback es 0 meses (recuperación instantánea)', () => {
|
||||
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
|
||||
expect(r.paybackMonths).toBe(0)
|
||||
expect(r.paybackYears).toBe(0)
|
||||
})
|
||||
|
||||
it('breaksEvenInHorizon es true', () => {
|
||||
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
|
||||
expect(r.breaksEvenInHorizon).toBe(true)
|
||||
})
|
||||
|
||||
it('roiAnnualPercent es Infinity', () => {
|
||||
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
|
||||
expect(r.roiAnnualPercent).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('inversión cero con ahorro cero: roiAnnualPercent es 0', () => {
|
||||
const r = calculateRoi(baseInput({
|
||||
automationInvestment: 0,
|
||||
costPerExecutionActual: 100,
|
||||
costPerExecutionAutomated: 100,
|
||||
}))
|
||||
expect(r.roiAnnualPercent).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Edge case: ahorro cero o negativo ────────────────────────────────────────
|
||||
|
||||
describe('calculateRoi — ahorro negativo o cero', () => {
|
||||
it('si el costo automatizado = costo actual, ahorro = 0', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
|
||||
expect(r.savingsPerExecution).toBeCloseTo(0)
|
||||
expect(r.annualSavings).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('ahorro cero → payback Infinity', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
|
||||
expect(r.paybackMonths).toBe(Infinity)
|
||||
expect(r.paybackYears).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('ahorro cero → breaksEvenInHorizon false', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
|
||||
expect(r.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
|
||||
it('ahorro negativo (automatizado más caro): payback Infinity', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 150 }))
|
||||
expect(r.annualSavings).toBeLessThan(0)
|
||||
expect(r.paybackMonths).toBe(Infinity)
|
||||
expect(r.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
|
||||
it('ahorro negativo: cumulativeSavings también es negativo', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 150 }))
|
||||
expect(r.cumulativeSavingsHorizon).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Edge case: costo actual cero (evitar div/0) ──────────────────────────────
|
||||
|
||||
describe('calculateRoi — costo actual cero', () => {
|
||||
it('savingsPerExecutionPercent es 0, no NaN ni Infinity', () => {
|
||||
const r = calculateRoi(baseInput({
|
||||
costPerExecutionActual: 0,
|
||||
costPerExecutionAutomated: 0,
|
||||
}))
|
||||
expect(r.savingsPerExecutionPercent).toBe(0)
|
||||
expect(Number.isFinite(r.savingsPerExecutionPercent)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Escenario de largo plazo (horizonte 10 años) ─────────────────────────────
|
||||
|
||||
describe('calculateRoi — horizonte extendido', () => {
|
||||
it('payback fuera del horizonte corto → breaksEvenInHorizon false', () => {
|
||||
// savingsPerExec=10, freq=1000 → annualSavings=$10.000; payback=$500.000/$10.000=50 años
|
||||
const r = calculateRoi(baseInput({
|
||||
automationInvestment: 500_000,
|
||||
costPerExecutionActual: 100,
|
||||
costPerExecutionAutomated: 90,
|
||||
annualFrequency: 1000,
|
||||
}))
|
||||
expect(r.paybackYears).toBeGreaterThan(3)
|
||||
expect(r.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,9 @@ describe('runSimulation', () => {
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: minutes,
|
||||
assignedResources: resources,
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
})
|
||||
|
||||
it('calcula costo total sin recursos — proceso lineal', () => {
|
||||
@@ -208,3 +211,182 @@ describe('runSimulation', () => {
|
||||
expect(result.perActivity[1].bpmnElementId).toBe('taskA')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Sprint 1: escenario 'automated' + actividades automatizables ─────────────
|
||||
|
||||
describe('runSimulation — scenario automated', () => {
|
||||
const makeAutoActivity = (
|
||||
bpmnElementId: string,
|
||||
opts: {
|
||||
directCost?: number; directTime?: number
|
||||
automatable?: boolean; automatedCost?: number; automatedTime?: number
|
||||
} = {}
|
||||
): Activity => ({
|
||||
id: `act-${bpmnElementId}`,
|
||||
processId: 'proc1',
|
||||
bpmnElementId,
|
||||
name: bpmnElementId,
|
||||
type: 'task',
|
||||
directCostFixed: opts.directCost ?? 100,
|
||||
executionTimeMinutes: opts.directTime ?? 60,
|
||||
assignedResources: [],
|
||||
automatable: opts.automatable ?? false,
|
||||
automatedCostFixed: opts.automatedCost ?? 0,
|
||||
automatedTimeMinutes: opts.automatedTime ?? 0,
|
||||
})
|
||||
|
||||
// ─── scenario=automated con costos en cero ────────────────────────────────
|
||||
|
||||
it('scenario=automated con automatedCostFixed=0 y automatedTimeMinutes=0 devuelve cost=0 time=0 sin NaN', () => {
|
||||
const act = makeAutoActivity('taskA', {
|
||||
directCost: 500, directTime: 60,
|
||||
automatable: true, automatedCost: 0, automatedTime: 0,
|
||||
})
|
||||
|
||||
const result = runSimulation({
|
||||
processXml: LINEAR_BPMN,
|
||||
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
|
||||
gateways: new Map(),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
scenario: 'automated',
|
||||
})
|
||||
|
||||
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
expect(actResult).toBeDefined()
|
||||
expect(actResult.expectedDirectCost).toBe(0)
|
||||
expect(actResult.executionTimeMinutes).toBe(0)
|
||||
|
||||
// Verificación explícita de ausencia de NaN en todos los campos numéricos
|
||||
expect(Number.isNaN(actResult.expectedDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(actResult.expectedIndirectCost)).toBe(false)
|
||||
expect(Number.isNaN(actResult.expectedTotalCost)).toBe(false)
|
||||
expect(Number.isNaN(actResult.percentOfTotal)).toBe(false)
|
||||
expect(Number.isNaN(result.totalCost)).toBe(false)
|
||||
})
|
||||
|
||||
it('actividad NO automatable mantiene costos originales en scenario=automated', () => {
|
||||
const act = makeAutoActivity('taskA', {
|
||||
directCost: 200, directTime: 30,
|
||||
automatable: false, automatedCost: 0, automatedTime: 0,
|
||||
})
|
||||
|
||||
const result = runSimulation({
|
||||
processXml: LINEAR_BPMN,
|
||||
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
|
||||
gateways: new Map(),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
scenario: 'automated',
|
||||
})
|
||||
|
||||
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
// automatable=false → usa costo original
|
||||
expect(actResult.expectedDirectCost).toBeCloseTo(200)
|
||||
expect(actResult.executionTimeMinutes).toBe(30)
|
||||
})
|
||||
|
||||
// ─── La probabilidad de ejecución NO depende del scenario ────────────────
|
||||
|
||||
it('probabilidad de ejecución NO cambia entre scenario=actual y scenario=automated', () => {
|
||||
// Usamos el BPMN XOR: taskB tiene prob 0.7, taskC tiene prob 0.3
|
||||
const XOR_BPMN_LOCAL = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
|
||||
<process id="p" isExecutable="false">
|
||||
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
|
||||
<task id="taskA" name="A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
|
||||
<exclusiveGateway id="gw1"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
|
||||
<task id="taskB" name="B"><incoming>f3</incoming><outgoing>f5</outgoing></task>
|
||||
<task id="taskC" name="C"><incoming>f4</incoming><outgoing>f6</outgoing></task>
|
||||
<endEvent id="end"><incoming>f5</incoming><incoming>f6</incoming></endEvent>
|
||||
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
|
||||
<sequenceFlow id="f2" sourceRef="taskA" targetRef="gw1"/>
|
||||
<sequenceFlow id="f3" sourceRef="gw1" targetRef="taskB"/>
|
||||
<sequenceFlow id="f4" sourceRef="gw1" targetRef="taskC"/>
|
||||
<sequenceFlow id="f5" sourceRef="taskB" targetRef="end"/>
|
||||
<sequenceFlow id="f6" sourceRef="taskC" targetRef="end"/>
|
||||
</process>
|
||||
</definitions>`
|
||||
|
||||
const activities = new Map([
|
||||
['taskA', makeAutoActivity('taskA', { automatable: true, automatedCost: 5, automatedTime: 2 })],
|
||||
['taskB', makeAutoActivity('taskB', { automatable: true, automatedCost: 0, automatedTime: 0 })],
|
||||
['taskC', makeAutoActivity('taskC', { automatable: false })],
|
||||
])
|
||||
|
||||
const gateways = new Map([['gw1', {
|
||||
id: 'gw1', processId: 'proc1', bpmnElementId: 'gw1', gatewayType: 'exclusive' as const,
|
||||
branches: [
|
||||
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
|
||||
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
|
||||
],
|
||||
}]])
|
||||
|
||||
const baseInput = {
|
||||
processXml: XOR_BPMN_LOCAL,
|
||||
activities,
|
||||
gateways,
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
}
|
||||
|
||||
const resultActual = runSimulation({ ...baseInput, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' })
|
||||
|
||||
// taskA tiene prob 1.0 en ambos
|
||||
const aActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
const aAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
expect(aActual.executionProbability).toBeCloseTo(1.0)
|
||||
expect(aAuto.executionProbability).toBeCloseTo(1.0)
|
||||
|
||||
// taskB tiene prob 0.7 en ambos (el scenario no cambia el grafo)
|
||||
const bActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskB')!
|
||||
const bAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskB')!
|
||||
expect(bActual.executionProbability).toBeCloseTo(0.7)
|
||||
expect(bAuto.executionProbability).toBeCloseTo(0.7)
|
||||
|
||||
// taskC tiene prob 0.3 en ambos
|
||||
const cActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskC')!
|
||||
const cAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskC')!
|
||||
expect(cActual.executionProbability).toBeCloseTo(0.3)
|
||||
expect(cAuto.executionProbability).toBeCloseTo(0.3)
|
||||
|
||||
// Los costos SÍ cambian para actividades automatable=true
|
||||
// taskB en actual: directCostFixed=100; en automated: automatedCostFixed=0
|
||||
expect(bActual.expectedDirectCost).toBeGreaterThan(0)
|
||||
expect(bAuto.expectedDirectCost).toBe(0)
|
||||
|
||||
// taskC no es automatable → costos iguales en ambos
|
||||
expect(cActual.expectedDirectCost).toBeCloseTo(cAuto.expectedDirectCost)
|
||||
})
|
||||
|
||||
it('NaN no aparece en ningún campo cuando TODOS los costos son cero', () => {
|
||||
const activities = new Map([
|
||||
['taskA', makeAutoActivity('taskA', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
|
||||
['taskB', makeAutoActivity('taskB', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
|
||||
])
|
||||
|
||||
for (const scenario of ['actual', 'automated'] as const) {
|
||||
const result = runSimulation({
|
||||
processXml: LINEAR_BPMN,
|
||||
activities,
|
||||
gateways: new Map(),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
||||
scenario,
|
||||
})
|
||||
|
||||
expect(Number.isNaN(result.totalCost)).toBe(false)
|
||||
expect(Number.isNaN(result.totalDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(result.totalIndirectCost)).toBe(false)
|
||||
|
||||
for (const act of result.perActivity) {
|
||||
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(act.expectedIndirectCost)).toBe(false)
|
||||
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
|
||||
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
|
||||
expect(Number.isNaN(act.executionProbability)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
280
tests/e2e/export-roi.spec.ts
Normal file
280
tests/e2e/export-roi.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* E2E: Verifica el export PDF y CSV del tab "Comparación + ROI".
|
||||
*
|
||||
* Flujo completo:
|
||||
* 1. Importar medium-with-gateways
|
||||
* 2. Configurar task_recv como automatizable ($50 de costo, 5 min)
|
||||
* 3. Configurar volumetría e inversión en el tab Global
|
||||
* 4. Simular ambos escenarios
|
||||
* 5. Navegar al tab "Comparación + ROI"
|
||||
* 6. Exportar PDF → verificar tamaño, páginas, keywords, acentos
|
||||
* 7. Exportar CSV → verificar BOM, 16 columnas, metadata ROI
|
||||
*
|
||||
* Test adicional: con BPMN sin automatizables, los tabs ROI y Automatizado
|
||||
* están disabled y los botones de export funcionan para el tab actual.
|
||||
*
|
||||
* Ejecutar: npx playwright test tests/e2e/export-roi.spec.ts
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync, mkdirSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { PDFParse } from 'pdf-parse'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
|
||||
const OUTPUT_DIR = resolve(__dirname, '__output__')
|
||||
|
||||
test.beforeAll(() => {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
async function parsePdf(buffer: Buffer): Promise<{ numpages: number; text: string }> {
|
||||
const parser = new PDFParse({ data: buffer })
|
||||
const result = await parser.getText()
|
||||
await parser.destroy()
|
||||
return { numpages: result.total, text: result.text }
|
||||
}
|
||||
|
||||
// ─── Helper: importar BPMN y llegar al workspace ──────────────────────────────
|
||||
|
||||
async function importBpmn(page: import('@playwright/test').Page, bpmnFile: string) {
|
||||
await page.goto('/')
|
||||
const fileInput = page.locator('#bpmn-file-input')
|
||||
await fileInput.setInputFiles(resolve(BPMN_DIR, bpmnFile))
|
||||
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
|
||||
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
|
||||
await page.waitForTimeout(1_500)
|
||||
}
|
||||
|
||||
// ─── Helper: configurar una actividad como automatizable ──────────────────────
|
||||
|
||||
async function makeAutomatable(
|
||||
page: import('@playwright/test').Page,
|
||||
elementId: string,
|
||||
cost: number,
|
||||
timeMin: number
|
||||
) {
|
||||
// Click en el elemento BPMN en el canvas
|
||||
await page.locator(`[data-element-id="${elementId}"]`).first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Activar el toggle de automatizable
|
||||
const toggle = page.getByRole('switch', { name: /automatizable/i })
|
||||
if (await toggle.getAttribute('aria-checked') === 'false') {
|
||||
await toggle.click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// Ingresar costo automatizado
|
||||
const costInput = page.getByLabel(/Costo automatizado/i)
|
||||
await costInput.fill(String(cost))
|
||||
|
||||
// Ingresar tiempo automatizado
|
||||
const timeInput = page.getByLabel(/Tiempo automatizado/i)
|
||||
await timeInput.fill(String(timeMin))
|
||||
|
||||
// Guardar
|
||||
const guardarBtn = page.getByRole('button', { name: /Guardar/i })
|
||||
await guardarBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// ─── Helper: configurar volumetría en tab Global ──────────────────────────────
|
||||
|
||||
async function configureGlobal(
|
||||
page: import('@playwright/test').Page,
|
||||
annualFrequency: number,
|
||||
investment: number
|
||||
) {
|
||||
// Ir al tab Global
|
||||
const globalTab = page.getByRole('tab', { name: /Global/i })
|
||||
await globalTab.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Frecuencia anual
|
||||
const freqInput = page.getByLabel(/Frecuencia anual/i)
|
||||
await freqInput.fill(String(annualFrequency))
|
||||
|
||||
// Inversión
|
||||
const investInput = page.getByLabel(/Inversión en automatización/i)
|
||||
await investInput.fill(String(investment))
|
||||
|
||||
// Guardar
|
||||
const guardarBtn = page.getByRole('button', { name: /Guardar/i })
|
||||
await guardarBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// ─── Helper: simular y llegar al reporte ──────────────────────────────────────
|
||||
|
||||
async function simulateAndGoToReport(page: import('@playwright/test').Page) {
|
||||
const simBtn = page.getByRole('button', { name: 'Simular' })
|
||||
await expect(simBtn).not.toBeDisabled({ timeout: 5_000 })
|
||||
await simBtn.click()
|
||||
await page.waitForURL(/\/report\//, { timeout: 20_000 })
|
||||
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
|
||||
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
|
||||
}
|
||||
|
||||
// ─── Test principal: flujo ROI completo ───────────────────────────────────────
|
||||
|
||||
test('Export ROI — flujo completo con medium-with-gateways', async ({ page }) => {
|
||||
await importBpmn(page, 'medium-with-gateways.bpmn')
|
||||
|
||||
// Configurar task_recv como automatizable ($50, 5 min)
|
||||
await makeAutomatable(page, 'task_recv', 50, 5)
|
||||
|
||||
// Configurar volumetría e inversión
|
||||
await configureGlobal(page, 12_000, 50_000)
|
||||
|
||||
// Simular
|
||||
await simulateAndGoToReport(page)
|
||||
|
||||
// Navegar al tab ROI
|
||||
const roiTab = page.getByRole('tab', { name: /Comparación/i })
|
||||
await expect(roiTab).not.toBeDisabled({ timeout: 5_000 })
|
||||
await roiTab.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// ── PDF ROI ────────────────────────────────────────────────────────────────
|
||||
const pdfPath = resolve(OUTPUT_DIR, 'roi-test.pdf')
|
||||
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
|
||||
await page.getByRole('button', { name: 'Exportar PDF' }).click()
|
||||
const dlPdf = await pdfDownload
|
||||
await dlPdf.saveAs(pdfPath)
|
||||
|
||||
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
|
||||
|
||||
const pdfBuffer = readFileSync(pdfPath)
|
||||
const pdfBytes = pdfBuffer.length
|
||||
console.log(`\n📊 ROI PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
|
||||
|
||||
// Tamaño razonable: < 400 KB
|
||||
expect(pdfBytes).toBeGreaterThan(30_000)
|
||||
expect(pdfBytes).toBeLessThan(400_000)
|
||||
|
||||
// Nombre con sufijo _roi
|
||||
const downloadName = dlPdf.suggestedFilename()
|
||||
console.log(` Nombre archivo: ${downloadName}`)
|
||||
expect(downloadName).toMatch(/_roi\.pdf$/)
|
||||
|
||||
// Contenido mínimo
|
||||
const { numpages, text } = await parsePdf(pdfBuffer)
|
||||
console.log(` Páginas: ${numpages}`)
|
||||
console.log(` Primeros 300 chars: ${text.slice(0, 300).replace(/\n/g, ' ')}`)
|
||||
|
||||
// Al menos 3 páginas (hay 4 en el ROI PDF)
|
||||
expect(numpages).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Keywords que deben estar en el texto del PDF
|
||||
expect(text).toMatch(/ROI|retorno|automatizaci/i)
|
||||
expect(text).toMatch(/Payback|payback|recupera/i)
|
||||
expect(text).toMatch(/ahorro|Ahorro/i)
|
||||
expect(text).toContain('USD')
|
||||
|
||||
// ── CSV ROI ────────────────────────────────────────────────────────────────
|
||||
const csvPath = resolve(OUTPUT_DIR, 'roi-test.csv')
|
||||
const csvDownload = page.waitForEvent('download', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: 'Exportar CSV' }).click()
|
||||
const dlCsv = await csvDownload
|
||||
await dlCsv.saveAs(csvPath)
|
||||
|
||||
const csvBuffer = readFileSync(csvPath)
|
||||
const csvBytes = csvBuffer.length
|
||||
console.log(` CSV: ${csvBytes} bytes`)
|
||||
console.log(` Nombre archivo CSV: ${dlCsv.suggestedFilename()}`)
|
||||
|
||||
// Nombre con sufijo _roi
|
||||
expect(dlCsv.suggestedFilename()).toMatch(/_roi\.csv$/)
|
||||
|
||||
const csvText = csvBuffer.toString('utf-8')
|
||||
|
||||
// BOM UTF-8
|
||||
expect(csvText).toMatch(/^/)
|
||||
|
||||
// Metadata ROI presente
|
||||
expect(csvText).toContain('# Ahorro por ejecución')
|
||||
expect(csvText).toContain('# Payback (meses)')
|
||||
expect(csvText).toContain('# ROI anualizado')
|
||||
expect(csvText).toContain('# Frecuencia anual: 12000')
|
||||
|
||||
// 16 columnas en el header de datos
|
||||
const csvNoBom = csvText.startsWith('') ? csvText.slice(1) : csvText
|
||||
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
const colCount = headerLine.split('","').length
|
||||
console.log(` Columnas CSV: ${colCount}`)
|
||||
expect(colCount).toBe(16)
|
||||
|
||||
// Columna Automatizable presente
|
||||
expect(csvText).toContain('Automatizable')
|
||||
})
|
||||
|
||||
// ─── Test: sin automatizables → tab ROI disabled, export de "Actual" OK ──────
|
||||
|
||||
test('Tabs ROI/Automatizado disabled cuando no hay actividades automatizables', async ({ page }) => {
|
||||
await importBpmn(page, 'simple-linear.bpmn')
|
||||
await simulateAndGoToReport(page)
|
||||
|
||||
// Los tabs deben estar disabled
|
||||
const autoTab = page.getByRole('tab', { name: /Automatizado/i })
|
||||
const roiTab = page.getByRole('tab', { name: /Comparación/i })
|
||||
|
||||
await expect(autoTab).toBeDisabled()
|
||||
await expect(roiTab).toBeDisabled()
|
||||
|
||||
// El export PDF/CSV del tab "Actual" sigue funcionando
|
||||
const pdfPath = resolve(OUTPUT_DIR, 'no-automatable.pdf')
|
||||
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
|
||||
await page.getByRole('button', { name: 'Exportar PDF' }).click()
|
||||
const dlPdf = await pdfDownload
|
||||
await dlPdf.saveAs(pdfPath)
|
||||
|
||||
const downloadName = dlPdf.suggestedFilename()
|
||||
console.log(`\n📄 Sin automatizables — nombre PDF: ${downloadName}`)
|
||||
expect(downloadName).toMatch(/_actual\.pdf$/)
|
||||
|
||||
const pdfBuffer = readFileSync(pdfPath)
|
||||
expect(pdfBuffer.length).toBeGreaterThan(30_000)
|
||||
|
||||
const csvDownload = page.waitForEvent('download', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: 'Exportar CSV' }).click()
|
||||
const dlCsv = await csvDownload
|
||||
|
||||
console.log(` Nombre CSV: ${dlCsv.suggestedFilename()}`)
|
||||
expect(dlCsv.suggestedFilename()).toMatch(/_actual\.csv$/)
|
||||
})
|
||||
|
||||
// ─── Test: tab Automatizado → PDF y CSV con sufijo correcto ───────────────────
|
||||
|
||||
test('Export Automatizado — sufijo _automatizado en nombres de archivo', async ({ page }) => {
|
||||
await importBpmn(page, 'medium-with-gateways.bpmn')
|
||||
await makeAutomatable(page, 'task_recv', 30, 5)
|
||||
await simulateAndGoToReport(page)
|
||||
|
||||
// Ir al tab Automatizado
|
||||
const autoTab = page.getByRole('tab', { name: /^Automatizado$/i })
|
||||
await expect(autoTab).not.toBeDisabled()
|
||||
await autoTab.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// PDF con sufijo _automatizado
|
||||
const pdfPath = resolve(OUTPUT_DIR, 'automatizado.pdf')
|
||||
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
|
||||
await page.getByRole('button', { name: 'Exportar PDF' }).click()
|
||||
const dlPdf = await pdfDownload
|
||||
await dlPdf.saveAs(pdfPath)
|
||||
|
||||
const pdfName = dlPdf.suggestedFilename()
|
||||
console.log(`\n⚙️ Automatizado PDF: ${pdfName}`)
|
||||
expect(pdfName).toMatch(/_automatizado\.pdf$/)
|
||||
|
||||
const pdfBuffer = readFileSync(pdfPath)
|
||||
expect(pdfBuffer.length).toBeGreaterThan(30_000)
|
||||
|
||||
// CSV con sufijo _automatizado
|
||||
const csvDownload = page.waitForEvent('download', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: 'Exportar CSV' }).click()
|
||||
const dlCsv = await csvDownload
|
||||
expect(dlCsv.suggestedFilename()).toMatch(/_automatizado\.csv$/)
|
||||
})
|
||||
411
tests/e2e/validate-dod-etapa4.spec.ts
Normal file
411
tests/e2e/validate-dod-etapa4.spec.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Validación programática del DoD de Etapa 4 — Export PDF y CSV con ROI.
|
||||
*
|
||||
* Genera los 6 archivos para medium-with-gateways con configuración representativa:
|
||||
* - 3 actividades automatable: task_recv, task_score, task_analisis
|
||||
* - Costos actuales dispares (para heatmap con gradiente real)
|
||||
* - Costos automatizados: $20 / 5 min para las automatable
|
||||
* - Volumetría: annualFrequency=12000, horizon=3, investment=50000
|
||||
*
|
||||
* Archivos generados en tests/e2e/__output__/:
|
||||
* medium-gw_actual.pdf / medium-gw_actual.csv
|
||||
* medium-gw_automatizado.pdf / medium-gw_automatizado.csv
|
||||
* medium-gw_roi.pdf / medium-gw_roi.csv
|
||||
*
|
||||
* Ejecutar: npx playwright test tests/e2e/validate-dod-etapa4.spec.ts
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { PDFParse } from 'pdf-parse'
|
||||
import sharp from 'sharp'
|
||||
import { PDFDocument, PDFName, PDFRawStream } from 'pdf-lib'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
|
||||
const OUTPUT_DIR = resolve(__dirname, '__output__')
|
||||
|
||||
test.beforeAll(() => {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
// ─── Helpers (mismos que export-pdf.spec.ts) ──────────────────────────────────
|
||||
|
||||
async function parsePdf(buffer: Buffer): Promise<{ numpages: number; text: string }> {
|
||||
const parser = new PDFParse({ data: buffer })
|
||||
const result = await parser.getText()
|
||||
await parser.destroy()
|
||||
return { numpages: result.total, text: result.text }
|
||||
}
|
||||
|
||||
async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const doc = await PDFDocument.load(pdfBuf, { ignoreEncryption: true })
|
||||
for (const [, obj] of doc.context.enumerateIndirectObjects()) {
|
||||
if (obj instanceof PDFRawStream) {
|
||||
const subtype = obj.dict.lookupMaybe(PDFName.of('Subtype'), PDFName)
|
||||
const filter = obj.dict.lookupMaybe(PDFName.of('Filter'), PDFName)
|
||||
if (subtype?.toString() === '/Image' && filter?.toString() === '/DCTDecode') {
|
||||
return Buffer.from(obj.contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
const soi = Buffer.from([0xff, 0xd8, 0xff])
|
||||
const start = pdfBuf.indexOf(soi)
|
||||
if (start === -1) throw new Error('No se encontró JPEG en el PDF')
|
||||
const endstreamBuf = Buffer.from('endstream')
|
||||
const endstreamPos = pdfBuf.indexOf(endstreamBuf, start)
|
||||
if (endstreamPos === -1) throw new Error('No se encontró endstream')
|
||||
let eoi = endstreamPos - 1
|
||||
while (eoi > start + 2) {
|
||||
if (pdfBuf[eoi] === 0xd9 && pdfBuf[eoi - 1] === 0xff) { eoi++; break }
|
||||
eoi--
|
||||
}
|
||||
const jpeg = pdfBuf.subarray(start, eoi)
|
||||
if (jpeg.length < 100) throw new Error(`JPEG muy pequeño: ${jpeg.length} bytes`)
|
||||
return jpeg
|
||||
}
|
||||
|
||||
async function analyzeJpegColors(jpegBuf: Buffer) {
|
||||
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
|
||||
const ch = info.channels
|
||||
let greenPx = 0, amberPx = 0, redPx = 0
|
||||
for (let i = 0; i < data.length; i += ch) {
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2]
|
||||
if (g > r * 1.4 && g > 120 && g > b) greenPx++
|
||||
else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) amberPx++
|
||||
else if (r > g * 1.8 && r > b * 1.8 && r > 180) redPx++
|
||||
}
|
||||
return { greenPx, amberPx, redPx, total: data.length / ch }
|
||||
}
|
||||
|
||||
// ─── Configuración del proceso ────────────────────────────────────────────────
|
||||
|
||||
// Costos dispares para heatmap con gradiente real (mismo patrón que export-pdf.spec.ts)
|
||||
const ACTUAL_COST: Record<string, number> = {
|
||||
task_recv: 10_000, // prob 1.0 → $10.000 → ROJO
|
||||
task_valid: 200, // prob 1.0 → $200 → VERDE
|
||||
task_score: 400, // prob 0.5 → $200 → VERDE
|
||||
task_pedir_docs: 200, // prob 0.5 → $100 → VERDE
|
||||
task_bureau: 200, // prob 0.5 → $100 → VERDE
|
||||
task_empleador: 200, // prob 0.5 → $100 → VERDE
|
||||
task_analisis: 5_000, // prob 1.0 → $5.000 → ÁMBAR
|
||||
task_aprobar: 200, // prob 0.5 → $100 → VERDE
|
||||
task_rechazar: 200, // prob 0.5 → $100 → VERDE
|
||||
task_desembolso: 200, // prob 0.5 → $100 → VERDE
|
||||
task_archivo: 200, // prob 1.0 → $200 → VERDE
|
||||
}
|
||||
|
||||
// Automatable: task_recv, task_score, task_analisis con costo=$20, tiempo=5min
|
||||
const AUTOMATABLE = new Set(['task_recv', 'task_score', 'task_analisis'])
|
||||
const AUTO_COST = 20
|
||||
const AUTO_TIME = 5
|
||||
|
||||
// ─── Test único que genera y valida los 6 archivos ───────────────────────────
|
||||
|
||||
test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-gateways', async ({ page }) => {
|
||||
|
||||
// ── Fase 1: importar BPMN e inyectar configuración en IndexedDB ─────────────
|
||||
await page.goto('/')
|
||||
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
|
||||
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
|
||||
const processId = page.url().split('/workspace/')[1]
|
||||
await page.waitForTimeout(1_500)
|
||||
|
||||
// Inyectar costos de actividades y flags de automatización en IndexedDB
|
||||
await page.evaluate(async ({ procId, actualCosts, automatable, autoCost, autoTime }) => {
|
||||
const DB_NAME = 'ProcessCostPlatform'
|
||||
const db = await new Promise<IDBDatabase>((res, rej) => {
|
||||
const req = indexedDB.open(DB_NAME)
|
||||
req.onsuccess = () => res(req.result)
|
||||
req.onerror = () => rej(req.error)
|
||||
})
|
||||
const acts = await new Promise<any[]>((res, rej) => {
|
||||
const tx = db.transaction('activities', 'readonly')
|
||||
const req = tx.objectStore('activities').index('processId').getAll(procId)
|
||||
req.onsuccess = () => res(req.result)
|
||||
req.onerror = () => rej(req.error)
|
||||
})
|
||||
await new Promise<void>((res, rej) => {
|
||||
const tx = db.transaction('activities', 'readwrite')
|
||||
const store = tx.objectStore('activities')
|
||||
let pending = acts.length
|
||||
if (pending === 0) { res(); return }
|
||||
for (const act of acts) {
|
||||
const isAuto = (automatable as string[]).includes(act.bpmnElementId)
|
||||
const req = store.put({
|
||||
...act,
|
||||
directCostFixed: (actualCosts as Record<string, number>)[act.bpmnElementId] ?? 200,
|
||||
executionTimeMinutes: 60,
|
||||
automatable: isAuto,
|
||||
automatedCostFixed: isAuto ? autoCost : 0,
|
||||
automatedTimeMinutes: isAuto ? autoTime : 0,
|
||||
})
|
||||
req.onsuccess = () => { if (--pending === 0) res() }
|
||||
req.onerror = () => rej(req.error)
|
||||
}
|
||||
})
|
||||
|
||||
// Inyectar volumetría en el proceso
|
||||
const proc = await new Promise<any>((res, rej) => {
|
||||
const tx = db.transaction('processes', 'readonly')
|
||||
const req = tx.objectStore('processes').get(procId)
|
||||
req.onsuccess = () => res(req.result)
|
||||
req.onerror = () => rej(req.error)
|
||||
})
|
||||
if (proc) {
|
||||
await new Promise<void>((res, rej) => {
|
||||
const tx = db.transaction('processes', 'readwrite')
|
||||
const req = tx.objectStore('processes').put({
|
||||
...proc,
|
||||
annualFrequency: 12_000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 50_000,
|
||||
})
|
||||
req.onsuccess = () => res()
|
||||
req.onerror = () => rej(req.error)
|
||||
})
|
||||
}
|
||||
db.close()
|
||||
}, { procId: processId, actualCosts: ACTUAL_COST, automatable: [...AUTOMATABLE], autoCost: AUTO_COST, autoTime: AUTO_TIME })
|
||||
|
||||
// Recargar para que el store de Zustand relea desde Dexie
|
||||
await page.reload()
|
||||
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
|
||||
await page.waitForTimeout(1_500)
|
||||
|
||||
// ── Fase 2: simular y llegar al reporte ──────────────────────────────────────
|
||||
const simBtn = page.getByRole('button', { name: 'Simular' })
|
||||
await expect(simBtn).not.toBeDisabled({ timeout: 5_000 })
|
||||
await simBtn.click()
|
||||
await page.waitForURL(/\/report\//, { timeout: 20_000 })
|
||||
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
|
||||
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
|
||||
|
||||
// Esperar a que el heatmap tenga colores reales
|
||||
await page.waitForFunction(() => {
|
||||
const rects = Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
|
||||
return rects.some((r) => {
|
||||
const fill = (r as HTMLElement).style.fill
|
||||
return fill && fill !== '#cbd5e1'
|
||||
})
|
||||
}, { timeout: 30_000 })
|
||||
|
||||
// ── Fase 3: exportar desde tab "Actual" ──────────────────────────────────────
|
||||
// Tab "Actual" está activo por defecto
|
||||
|
||||
const actualPdfPath = resolve(OUTPUT_DIR, 'medium-gw_actual.pdf')
|
||||
const actualCsvPath = resolve(OUTPUT_DIR, 'medium-gw_actual.csv')
|
||||
|
||||
let dl = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 90_000 }),
|
||||
page.getByRole('button', { name: 'Exportar PDF' }).click(),
|
||||
])
|
||||
await dl[0].saveAs(actualPdfPath)
|
||||
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
|
||||
|
||||
let dlCsv = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30_000 }),
|
||||
page.getByRole('button', { name: 'Exportar CSV' }).click(),
|
||||
])
|
||||
await dlCsv[0].saveAs(actualCsvPath)
|
||||
|
||||
// ── Fase 4: exportar desde tab "Automatizado" ─────────────────────────────────
|
||||
await page.getByRole('tab', { name: /^Automatizado$/i }).click()
|
||||
await page.waitForTimeout(1_000)
|
||||
|
||||
const autoPdfPath = resolve(OUTPUT_DIR, 'medium-gw_automatizado.pdf')
|
||||
const autoCsvPath = resolve(OUTPUT_DIR, 'medium-gw_automatizado.csv')
|
||||
|
||||
dl = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 90_000 }),
|
||||
page.getByRole('button', { name: 'Exportar PDF' }).click(),
|
||||
])
|
||||
await dl[0].saveAs(autoPdfPath)
|
||||
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
|
||||
|
||||
dlCsv = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30_000 }),
|
||||
page.getByRole('button', { name: 'Exportar CSV' }).click(),
|
||||
])
|
||||
await dlCsv[0].saveAs(autoCsvPath)
|
||||
|
||||
// ── Fase 5: exportar desde tab "Comparación + ROI" ───────────────────────────
|
||||
await page.getByRole('tab', { name: /Comparación/i }).click()
|
||||
await page.waitForTimeout(1_000)
|
||||
|
||||
const roiPdfPath = resolve(OUTPUT_DIR, 'medium-gw_roi.pdf')
|
||||
const roiCsvPath = resolve(OUTPUT_DIR, 'medium-gw_roi.csv')
|
||||
|
||||
dl = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 90_000 }),
|
||||
page.getByRole('button', { name: 'Exportar PDF' }).click(),
|
||||
])
|
||||
await dl[0].saveAs(roiPdfPath)
|
||||
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
|
||||
|
||||
dlCsv = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30_000 }),
|
||||
page.getByRole('button', { name: 'Exportar CSV' }).click(),
|
||||
])
|
||||
await dlCsv[0].saveAs(roiCsvPath)
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// ── VALIDACIÓN PROGRAMÁTICA ──────────────────────────────────────────────────
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const results: string[] = [
|
||||
'╔══════════════════════════════════════════════════════════════════════╗',
|
||||
'║ DoD Etapa 4 — Validación programática de los 6 archivos ║',
|
||||
'╠══════════════════════════════════════════════════════════════════════╣',
|
||||
]
|
||||
|
||||
// ── Validar actual.pdf ────────────────────────────────────────────────────────
|
||||
const actualPdfBuf = readFileSync(actualPdfPath)
|
||||
const { numpages: actualPages, text: actualText } = await parsePdf(actualPdfBuf)
|
||||
const actualKb = (actualPdfBuf.length / 1024).toFixed(1)
|
||||
|
||||
const actualHasJpeg = actualPdfBuf.includes(Buffer.from([0xff, 0xd8, 0xff]))
|
||||
const actualJpeg = await extractJpegFromPdf(actualPdfBuf)
|
||||
const actualPixels = await analyzeJpegColors(actualJpeg)
|
||||
|
||||
const actualOk =
|
||||
actualPdfBuf.length > 40_000 &&
|
||||
actualPages >= 2 &&
|
||||
actualText.includes('COSTO TOTAL') &&
|
||||
actualHasJpeg &&
|
||||
actualPixels.redPx >= 100 &&
|
||||
actualPixels.greenPx >= 100
|
||||
|
||||
results.push(`║ actual.pdf │ ${actualKb.padEnd(6)} KB │ ${String(actualPages).padEnd(6)} pgs │ ${actualOk ? '✓ OK' : '✗ FAIL'} ║`)
|
||||
results.push(`║ JPEG: ${actualHasJpeg ? '✓' : '✗'} Verde:${actualPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${actualPixels.redPx.toLocaleString().padStart(5)}px ║`)
|
||||
|
||||
// ── Validar automatizado.pdf ──────────────────────────────────────────────────
|
||||
const autoPdfBuf = readFileSync(autoPdfPath)
|
||||
const { numpages: autoPages, text: autoText } = await parsePdf(autoPdfBuf)
|
||||
const autoKb = (autoPdfBuf.length / 1024).toFixed(1)
|
||||
|
||||
const autoHasJpeg = autoPdfBuf.includes(Buffer.from([0xff, 0xd8, 0xff]))
|
||||
const autoJpeg = await extractJpegFromPdf(autoPdfBuf)
|
||||
const autoPixels = await analyzeJpegColors(autoJpeg)
|
||||
|
||||
const autoOk =
|
||||
autoPdfBuf.length > 40_000 &&
|
||||
autoPages >= 2 &&
|
||||
autoText.includes('COSTO TOTAL') &&
|
||||
autoHasJpeg &&
|
||||
autoPixels.greenPx >= 100
|
||||
|
||||
results.push(`║ automatizado.pdf│ ${autoKb.padEnd(6)} KB │ ${String(autoPages).padEnd(6)} pgs │ ${autoOk ? '✓ OK' : '✗ FAIL'} ║`)
|
||||
results.push(`║ JPEG: ${autoHasJpeg ? '✓' : '✗'} Verde:${autoPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${autoPixels.redPx.toLocaleString().padStart(5)}px ║`)
|
||||
|
||||
// ── Validar roi.pdf ───────────────────────────────────────────────────────────
|
||||
const roiPdfBuf = readFileSync(roiPdfPath)
|
||||
const { numpages: roiPages, text: roiText } = await parsePdf(roiPdfBuf)
|
||||
const roiKb = (roiPdfBuf.length / 1024).toFixed(1)
|
||||
|
||||
// El PDF de ROI NO debe tener heatmap JPEG (solo texto y tablas)
|
||||
const roiHasJpeg = roiPdfBuf.includes(Buffer.from([0xff, 0xd8, 0xff]))
|
||||
|
||||
const roiOk =
|
||||
roiPdfBuf.length > 20_000 &&
|
||||
roiPages >= 3 &&
|
||||
roiText.match(/retorno|ROI|ahorro/i) !== null &&
|
||||
!roiHasJpeg // confirmamos que no tiene JPEG (spec correcto)
|
||||
|
||||
results.push(`║ roi.pdf │ ${roiKb.padEnd(6)} KB │ ${String(roiPages).padEnd(6)} pgs │ ${roiOk ? '✓ OK' : '✗ FAIL'} ║`)
|
||||
results.push(`║ Sin heatmap JPEG (correcto): ${roiHasJpeg ? '✗ TIENE JPEG (inesperado)' : '✓ sin JPEG'} ║`)
|
||||
|
||||
// ── Validar actual.csv ────────────────────────────────────────────────────────
|
||||
const actualCsvBuf = readFileSync(actualCsvPath)
|
||||
const actualCsvText = actualCsvBuf.toString('utf-8')
|
||||
const actualCsvBom = actualCsvBuf[0] === 0xEF || actualCsvText.charCodeAt(0) === 0xFEFF
|
||||
const actualCsvNoBom = actualCsvText.startsWith('') ? actualCsvText.slice(1) : actualCsvText
|
||||
const actualHeaderLine = actualCsvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
const actualCsvCols = actualHeaderLine ? actualHeaderLine.split('","').length : 0
|
||||
|
||||
const actualCsvOk = actualCsvBuf.length > 500 && actualCsvBom && actualCsvCols === 10
|
||||
results.push(`║ actual.csv │ ${actualCsvBuf.length} B │ ${actualCsvCols} cols │ ${actualCsvOk ? '✓ OK' : '✗ FAIL'} ║`)
|
||||
results.push(`║ BOM: ${actualCsvBom ? '✓' : '✗'} Columnas: ${actualCsvCols} (esperado: 10) ║`)
|
||||
|
||||
// ── Validar automatizado.csv ──────────────────────────────────────────────────
|
||||
const autoCsvBuf = readFileSync(autoCsvPath)
|
||||
const autoCsvText = autoCsvBuf.toString('utf-8')
|
||||
const autoCsvBom = autoCsvBuf[0] === 0xEF || autoCsvText.charCodeAt(0) === 0xFEFF
|
||||
const autoCsvNoBom = autoCsvText.startsWith('') ? autoCsvText.slice(1) : autoCsvText
|
||||
const autoHeaderLine = autoCsvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
const autoCsvCols = autoHeaderLine ? autoHeaderLine.split('","').length : 0
|
||||
const autoCsvHasEscenario = autoCsvText.includes('Automatizado')
|
||||
|
||||
const autoCsvOk = autoCsvBuf.length > 500 && autoCsvBom && autoCsvCols === 10 && autoCsvHasEscenario
|
||||
results.push(`║ automatizado.csv│ ${autoCsvBuf.length} B │ ${autoCsvCols} cols │ ${autoCsvOk ? '✓ OK' : '✗ FAIL'} ║`)
|
||||
results.push(`║ BOM: ${autoCsvBom ? '✓' : '✗'} Columnas: ${autoCsvCols} (esperado: 10) Escenario: ${autoCsvHasEscenario ? '✓' : '✗'} ║`)
|
||||
|
||||
// ── Validar roi.csv ───────────────────────────────────────────────────────────
|
||||
const roiCsvBuf = readFileSync(roiCsvPath)
|
||||
const roiCsvText = roiCsvBuf.toString('utf-8')
|
||||
const roiCsvBom = roiCsvBuf[0] === 0xEF || roiCsvText.charCodeAt(0) === 0xFEFF
|
||||
const roiCsvNoBom = roiCsvText.startsWith('') ? roiCsvText.slice(1) : roiCsvText
|
||||
const roiHeaderLine = roiCsvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
const roiCsvCols = roiHeaderLine ? roiHeaderLine.split('","').length : 0
|
||||
const roiHasPayback = roiCsvText.includes('Payback')
|
||||
const roiHasROI = roiCsvText.includes('ROI anualizado')
|
||||
const roiHasFreq = roiCsvText.includes('12000')
|
||||
const roiHasAutomatable = roiCsvText.includes('Automatizable')
|
||||
|
||||
const roiCsvOk = roiCsvBuf.length > 1000 && roiCsvBom && roiCsvCols === 16 && roiHasPayback && roiHasROI
|
||||
|
||||
results.push(`║ roi.csv │ ${roiCsvBuf.length} B │ ${roiCsvCols} cols│ ${roiCsvOk ? '✓ OK' : '✗ FAIL'} ║`)
|
||||
results.push(`║ BOM: ${roiCsvBom ? '✓' : '✗'} Cols: ${roiCsvCols}/16 Payback: ${roiHasPayback ? '✓' : '✗'} ROI: ${roiHasROI ? '✓' : '✗'} Freq: ${roiHasFreq ? '✓' : '✗'} AutoCol: ${roiHasAutomatable ? '✓' : '✗'} ║`)
|
||||
|
||||
results.push('╚══════════════════════════════════════════════════════════════════════╝')
|
||||
|
||||
console.log('\n' + results.join('\n'))
|
||||
|
||||
// ── Texto del roi.pdf — primeras 20 líneas ────────────────────────────────────
|
||||
const roiLines = roiText.split('\n').filter((l) => l.trim()).slice(0, 20)
|
||||
console.log('\n── Texto extraído roi.pdf (primeras 20 líneas) ──')
|
||||
roiLines.forEach((l, i) => console.log(` ${String(i + 1).padStart(2)}: ${l}`))
|
||||
|
||||
// ── Guardar reporte de validación como artefacto ──────────────────────────────
|
||||
writeFileSync(
|
||||
resolve(OUTPUT_DIR, 'dod-etapa4-report.txt'),
|
||||
results.join('\n') + '\n\n── roi.pdf (primeras 20 líneas) ──\n' + roiLines.join('\n')
|
||||
)
|
||||
|
||||
// ── Assertions duras — cualquier falla detiene Etapa 5 ────────────────────────
|
||||
expect(actualPdfBuf.length, 'actual.pdf debe ser > 40 KB').toBeGreaterThan(40_000)
|
||||
expect(actualPages, 'actual.pdf debe tener ≥ 2 páginas').toBeGreaterThanOrEqual(2)
|
||||
expect(actualText, 'actual.pdf debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
|
||||
expect(actualHasJpeg, 'actual.pdf debe tener heatmap JPEG embebido').toBe(true)
|
||||
expect(actualPixels.redPx, 'actual.pdf JPEG debe tener píxeles rojos (≥100)').toBeGreaterThanOrEqual(100)
|
||||
expect(actualPixels.greenPx, 'actual.pdf JPEG debe tener píxeles verdes (≥100)').toBeGreaterThanOrEqual(100)
|
||||
|
||||
expect(autoPdfBuf.length, 'automatizado.pdf debe ser > 40 KB').toBeGreaterThan(40_000)
|
||||
expect(autoPages, 'automatizado.pdf debe tener ≥ 2 páginas').toBeGreaterThanOrEqual(2)
|
||||
expect(autoText, 'automatizado.pdf debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
|
||||
expect(autoHasJpeg, 'automatizado.pdf debe tener heatmap JPEG embebido').toBe(true)
|
||||
expect(autoPixels.greenPx, 'automatizado.pdf JPEG debe tener píxeles verdes (≥100)').toBeGreaterThanOrEqual(100)
|
||||
|
||||
expect(roiPdfBuf.length, 'roi.pdf debe ser > 20 KB').toBeGreaterThan(20_000)
|
||||
expect(roiPages, 'roi.pdf debe tener ≥ 3 páginas').toBeGreaterThanOrEqual(3)
|
||||
expect(roiText, 'roi.pdf debe contener keyword de ROI').toMatch(/retorno|ROI|ahorro/i)
|
||||
expect(roiHasJpeg, 'roi.pdf NO debe contener heatmap JPEG (solo texto)').toBe(false)
|
||||
|
||||
expect(actualCsvBom, 'actual.csv BOM UTF-8').toBe(true)
|
||||
expect(actualCsvCols, 'actual.csv debe tener 10 columnas').toBe(10)
|
||||
|
||||
expect(autoCsvBom, 'automatizado.csv BOM UTF-8').toBe(true)
|
||||
expect(autoCsvCols, 'automatizado.csv debe tener 10 columnas').toBe(10)
|
||||
expect(autoCsvHasEscenario, 'automatizado.csv debe indicar escenario Automatizado').toBe(true)
|
||||
|
||||
expect(roiCsvBom, 'roi.csv BOM UTF-8').toBe(true)
|
||||
expect(roiCsvCols, 'roi.csv debe tener 16 columnas').toBe(16)
|
||||
expect(roiHasPayback, 'roi.csv debe tener metadata de Payback').toBe(true)
|
||||
expect(roiHasROI, 'roi.csv debe tener metadata de ROI anualizado').toBe(true)
|
||||
expect(roiHasFreq, 'roi.csv debe tener frecuencia anual 12000').toBe(true)
|
||||
expect(roiHasAutomatable, 'roi.csv debe tener columna Automatizable').toBe(true)
|
||||
})
|
||||
@@ -226,7 +226,8 @@ describe('MethodologyFooter', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||
bpmnXml: '<def/>', currency: 'USD',
|
||||
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
|
||||
})
|
||||
@@ -235,7 +236,8 @@ describe('MethodologyFooter', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.25, createdAt: 0, updatedAt: 0,
|
||||
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -246,7 +248,8 @@ describe('MethodologyFooter', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -326,6 +329,9 @@ describe('ReportPage — con datos completos', () => {
|
||||
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
createdAt: Date.now() - 1000 * 60 * 30,
|
||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: 30,
|
||||
assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
792
tests/features/report/roi-components.test.tsx
Normal file
792
tests/features/report/roi-components.test.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
/**
|
||||
* Tests de los componentes de ROI del reporte (Sprint 1 — Etapa 3).
|
||||
*
|
||||
* Cubre: RoiKpiBar, ComparisonTable, TransparencySection, TopSavingsChart,
|
||||
* CumulativeSavingsChart, y el comportamiento de tabs de ReportPage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import React from 'react'
|
||||
|
||||
// ─── Mocks de dependencias del browser ───────────────────────────────────────
|
||||
|
||||
vi.mock('bpmn-js/lib/Viewer', () => ({
|
||||
default: class MockViewer {
|
||||
importXML = vi.fn().mockResolvedValue({ warnings: [] })
|
||||
get = vi.fn().mockImplementation((svc: string) => {
|
||||
if (svc === 'canvas') return { zoom: vi.fn(), addMarker: vi.fn(), removeMarker: vi.fn() }
|
||||
if (svc === 'elementRegistry') return { forEach: vi.fn(), getGraphics: vi.fn().mockReturnValue(null), get: vi.fn().mockReturnValue({ width: 100 }) }
|
||||
if (svc === 'overlays') return { add: vi.fn(), remove: vi.fn() }
|
||||
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
|
||||
return {}
|
||||
})
|
||||
destroy = vi.fn()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: ({ style }: { option: unknown; style?: React.CSSProperties }) => (
|
||||
<div data-testid="echarts-chart" style={style} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/report/useReportData', () => ({
|
||||
useReportData: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@tanstack/react-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: vi.fn().mockReturnValue({ processId: 'test-proc-id' }),
|
||||
useNavigate: vi.fn().mockReturnValue(vi.fn()),
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
const makeActivity = (id: string, name: string, cost: number): ActivitySimResult => ({
|
||||
activityId: id,
|
||||
bpmnElementId: `bpmn_${id}`,
|
||||
activityName: name,
|
||||
expectedDirectCost: cost * 0.83,
|
||||
expectedIndirectCost: cost * 0.17,
|
||||
expectedTotalCost: cost,
|
||||
percentOfTotal: 33,
|
||||
executionProbability: 1.0,
|
||||
expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60,
|
||||
resourceCostBreakdown: [],
|
||||
})
|
||||
|
||||
const perActivityActual: ActivitySimResult[] = [
|
||||
makeActivity('a1', 'Recibir solicitud', 600),
|
||||
makeActivity('a2', 'Calcular score', 300),
|
||||
makeActivity('a3', 'Analizar riesgo', 100),
|
||||
]
|
||||
|
||||
const perActivityAutomated: ActivitySimResult[] = [
|
||||
makeActivity('a1', 'Recibir solicitud', 60), // 90% ahorro
|
||||
makeActivity('a2', 'Calcular score', 30), // 90% ahorro
|
||||
makeActivity('a3', 'Analizar riesgo', 100), // sin cambio (no automatable)
|
||||
]
|
||||
|
||||
const mockResult: SimulationResult = {
|
||||
totalCost: 1000,
|
||||
totalDirectCost: 833,
|
||||
totalIndirectCost: 167,
|
||||
totalTimeMinutes: 180,
|
||||
perActivity: perActivityActual,
|
||||
perResource: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const mockResultAutomated: SimulationResult = {
|
||||
totalCost: 190,
|
||||
totalDirectCost: 158,
|
||||
totalIndirectCost: 32,
|
||||
totalTimeMinutes: 30,
|
||||
perActivity: perActivityAutomated,
|
||||
perResource: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const mockRoi: RoiResult = {
|
||||
savingsPerExecution: 810,
|
||||
savingsPerExecutionPercent: 81,
|
||||
annualSavings: 9_720_000,
|
||||
paybackMonths: 0.617,
|
||||
paybackYears: 0.051,
|
||||
cumulativeSavingsHorizon: 29_110_000,
|
||||
breaksEvenInHorizon: true,
|
||||
roiAnnualPercent: 19440,
|
||||
}
|
||||
|
||||
const roiNoSavings: RoiResult = {
|
||||
savingsPerExecution: -50,
|
||||
savingsPerExecutionPercent: -10,
|
||||
annualSavings: -60_000,
|
||||
paybackMonths: Infinity,
|
||||
paybackYears: Infinity,
|
||||
cumulativeSavingsHorizon: -230_000,
|
||||
breaksEvenInHorizon: false,
|
||||
roiAnnualPercent: -60,
|
||||
}
|
||||
|
||||
const roiZeroInvestment: RoiResult = {
|
||||
savingsPerExecution: 200,
|
||||
savingsPerExecutionPercent: 50,
|
||||
annualSavings: 240_000,
|
||||
paybackMonths: 0,
|
||||
paybackYears: 0,
|
||||
cumulativeSavingsHorizon: 720_000,
|
||||
breaksEvenInHorizon: true,
|
||||
roiAnnualPercent: Infinity,
|
||||
}
|
||||
|
||||
// ─── RoiKpiBar ────────────────────────────────────────────────────────────────
|
||||
|
||||
import { RoiKpiBar } from '@/features/report/RoiKpiBar'
|
||||
|
||||
describe('RoiKpiBar', () => {
|
||||
it('renderiza exactamente 5 ROI KPI cards', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('muestra el ahorro por ejecución formateado con moneda', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
// $810.00 debe aparecer
|
||||
expect(text).toMatch(/810/)
|
||||
})
|
||||
|
||||
it('payback Infinity → muestra "No se recupera"', () => {
|
||||
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/No se recupera/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('payback 0 (inversión cero) → muestra "Inmediato"', () => {
|
||||
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/Inmediato/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ROI Infinity (inversión cero) → muestra "∞%"', () => {
|
||||
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText('∞%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => {
|
||||
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/Fuera del horizonte/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('breaksEvenInHorizon = true → subtexto dentro del horizonte', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
expect(screen.getByText(/Dentro del horizonte/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('horizonYears se refleja en el label del KPI de acumulado', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={5} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/5 años/)
|
||||
})
|
||||
|
||||
it('ningún KPI muestra NaN', () => {
|
||||
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).not.toContain('NaN')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ComparisonTable ──────────────────────────────────────────────────────────
|
||||
|
||||
import { ComparisonTable } from '@/features/report/ComparisonTable'
|
||||
|
||||
const automatableIds = new Set(['bpmn_a1', 'bpmn_a2']) // a1 y a2 son automatizables
|
||||
const noAutomatableIds = new Set<string>()
|
||||
|
||||
describe('ComparisonTable', () => {
|
||||
it('renderiza N+1 rows (N actividades + 1 header)', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(perActivityActual.length + 1)
|
||||
})
|
||||
|
||||
it('actividades automatable muestran ícono Bot', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// a1 y a2 son automatable → 2 íconos Bot
|
||||
const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]')
|
||||
expect(botIcons).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('actividades NO automatable NO muestran ícono Bot', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={noAutomatableIds} // ninguna automatable
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]')
|
||||
expect(botIcons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ahorro de a1: 600 - 60 = 540 aparece en la tabla', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/540/)
|
||||
})
|
||||
|
||||
it('tabla vacía: muestra mensaje sin actividades', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={[]}
|
||||
perActivityAutomated={[]}
|
||||
automatableIds={noAutomatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Sin actividades para comparar/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ahorro negativo: se muestra sin prefijo +', () => {
|
||||
const negativeActual = [makeActivity('x1', 'Tarea cara auto', 100)]
|
||||
const negativeAuto = [makeActivity('x1', 'Tarea cara auto', 200)] // costo auto > actual
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={negativeActual}
|
||||
perActivityAutomated={negativeAuto}
|
||||
automatableIds={new Set(['bpmn_x1'])}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// -100 de ahorro, sin prefijo '+'
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).not.toMatch(/\+.*-/)
|
||||
})
|
||||
|
||||
it('sort toggle: clic en encabezado "Ahorro" invierte el orden', () => {
|
||||
render(
|
||||
<ComparisonTable
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
automatableIds={automatableIds}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// Buscar el th que contiene "Ahorro" (hay múltiples elementos con ese texto)
|
||||
const headers = screen.getAllByRole('columnheader')
|
||||
const ahorroHeader = headers.find((h) => /Ahorro/i.test(h.textContent ?? ''))
|
||||
expect(ahorroHeader).toBeDefined()
|
||||
|
||||
// Antes del click: orden desc (a1=540 primero)
|
||||
const rowsBefore = screen.getAllByRole('row').slice(1)
|
||||
const firstNameBefore = rowsBefore[0].textContent
|
||||
|
||||
fireEvent.click(ahorroHeader!)
|
||||
// Después del click: orden asc (a3=0 primero)
|
||||
const rowsAfter = screen.getAllByRole('row').slice(1)
|
||||
const firstNameAfter = rowsAfter[0].textContent
|
||||
|
||||
expect(firstNameBefore).not.toBe(firstNameAfter)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TransparencySection ──────────────────────────────────────────────────────
|
||||
|
||||
import { TransparencySection } from '@/features/report/TransparencySection'
|
||||
|
||||
const makeActivityDomain = (id: string, name: string, automatable: boolean, zeroCosts: boolean): Activity => ({
|
||||
id, processId: 'p1', bpmnElementId: `bpmn_${id}`, name, type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable,
|
||||
automatedCostFixed: zeroCosts ? 0 : 50,
|
||||
automatedTimeMinutes: zeroCosts ? 0 : 10,
|
||||
})
|
||||
|
||||
describe('TransparencySection', () => {
|
||||
it('retorna null cuando no hay issues — no renderiza nada', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Tarea', true, false)]} // automatable con costos configurados
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('Caso 1: actividad automatable con costos en cero → sección visible', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // automatable, costo=0
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Facturación')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 1: mensaje explica que el ahorro puede estar inflado', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]}
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/inflar artificialmente el ahorro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 1: plural correcto cuando hay múltiples actividades en cero', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[
|
||||
makeActivityDomain('a1', 'Tarea 1', true, true),
|
||||
makeActivityDomain('a2', 'Tarea 2', true, true),
|
||||
]}
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/2 actividades automatizables sin costo configurado/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 2: investment=0 → muestra aviso de inversión no configurada', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Tarea', true, false)]} // costos OK
|
||||
investment={0}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/payback aparece como inmediato/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 3: ambos issues coexisten — se muestran ambos mensajes en la misma sección', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // costo cero
|
||||
investment={0} // inversión cero
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Facturación')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('actividades no automatable no generan issue aunque tengan costos cero', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Manual', false, true)]} // no automatable
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
// ─── Caso 4: ROI inusualmente alto ────────────────────────────────────────
|
||||
|
||||
const makeRoi = (roiAnnualPercent: number): RoiResult => ({
|
||||
savingsPerExecution: 100,
|
||||
savingsPerExecutionPercent: 50,
|
||||
annualSavings: 1_200_000,
|
||||
paybackMonths: 0.5,
|
||||
paybackYears: 0.04,
|
||||
cumulativeSavingsHorizon: 3_550_000,
|
||||
breaksEvenInHorizon: true,
|
||||
roiAnnualPercent,
|
||||
})
|
||||
|
||||
it('Caso 4: ROI al 999% NO dispara el aviso de ROI inusual', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={50_000}
|
||||
roi={makeRoi(999)}
|
||||
/>
|
||||
)
|
||||
// No hay otros issues → el componente no renderiza (999% no supera el umbral)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('Caso 4: ROI al 1001% SÍ dispara el aviso de ROI inusual', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={50_000}
|
||||
roi={makeRoi(1001)}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument()
|
||||
// El texto incluye el valor formateado (>1000%)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/1\.001/)
|
||||
})
|
||||
|
||||
it('Caso 4: ROI Infinity (investment=0) NO dispara este aviso — lo cubre el caso 2', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={0} // caso 2 activo
|
||||
roi={makeRoi(Infinity)}
|
||||
/>
|
||||
)
|
||||
// El caso 2 (investment=0) sí aparece, pero NO el aviso de "ROI inusualmente alto"
|
||||
expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument()
|
||||
// El caso 2 sí está presente
|
||||
expect(container.firstChild).not.toBeNull()
|
||||
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 4: ROI negativo NO dispara el aviso de ROI inusual', () => {
|
||||
const { container } = render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'T', true, false)]}
|
||||
investment={50_000}
|
||||
roi={makeRoi(-50)}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Caso 4 coexiste con Caso 1: ROI alto Y actividad con costo=0 → ambos mensajes', () => {
|
||||
render(
|
||||
<TransparencySection
|
||||
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // costo=0
|
||||
investment={50_000} // no es cero
|
||||
roi={makeRoi(5000)} // > 1000 y finite
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Facturación')).toBeInTheDocument() // Caso 1
|
||||
expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument() // Caso 4
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TopSavingsChart ──────────────────────────────────────────────────────────
|
||||
|
||||
import { TopSavingsChart } from '@/features/report/TopSavingsChart'
|
||||
|
||||
describe('TopSavingsChart', () => {
|
||||
it('muestra empty state cuando no hay ahorros positivos', () => {
|
||||
// perActivityAutomated con costos mayores → ahorro negativo
|
||||
const actualCosts = [makeActivity('a1', 'Tarea', 100)]
|
||||
const autoCosts = [makeActivity('a1', 'Tarea', 200)]
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={actualCosts}
|
||||
perActivityAutomated={autoCosts}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Sin actividades con ahorro positivo/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('con ahorros positivos: renderiza el chart ECharts', () => {
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excluye actividades con ahorro cero o negativo del chart', () => {
|
||||
// a3 no tiene ahorro (costos iguales) → no debe aparecer en el chart
|
||||
// Solo a1 y a2 tienen ahorro positivo
|
||||
// Si renderiza chart → hay ≥1 actividad con ahorro > 0
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={perActivityActual}
|
||||
perActivityAutomated={perActivityAutomated}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// Chart renderizado → hay actividades con ahorro positivo
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('con más de 5 actividades: sigue renderizando chart (top-5 filtrado internamente)', () => {
|
||||
const manyActual = Array.from({ length: 8 }, (_, i) =>
|
||||
makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 100)
|
||||
)
|
||||
const manyAuto = Array.from({ length: 8 }, (_, i) =>
|
||||
makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 10)
|
||||
)
|
||||
render(
|
||||
<TopSavingsChart
|
||||
perActivityActual={manyActual}
|
||||
perActivityAutomated={manyAuto}
|
||||
currency="USD"
|
||||
/>
|
||||
)
|
||||
// No debe tirar error ni mostrar más de 5 en el chart (ECharts mockeado — no podemos inspeccionar option)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CumulativeSavingsChart ───────────────────────────────────────────────────
|
||||
|
||||
import { CumulativeSavingsChart } from '@/features/report/CumulativeSavingsChart'
|
||||
|
||||
describe('CumulativeSavingsChart', () => {
|
||||
it('renderiza el chart sin crash (payback dentro del horizonte)', () => {
|
||||
render(
|
||||
<CumulativeSavingsChart
|
||||
roi={mockRoi}
|
||||
horizonYears={3}
|
||||
currency="USD"
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renderiza el chart sin crash cuando payback = Infinity (sin ahorro)', () => {
|
||||
render(
|
||||
<CumulativeSavingsChart
|
||||
roi={roiNoSavings}
|
||||
horizonYears={3}
|
||||
currency="USD"
|
||||
investment={50_000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renderiza el chart sin crash cuando investment = 0', () => {
|
||||
render(
|
||||
<CumulativeSavingsChart
|
||||
roi={roiZeroInvestment}
|
||||
horizonYears={3}
|
||||
currency="USD"
|
||||
investment={0}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ReportPage — comportamiento de tabs ──────────────────────────────────────
|
||||
|
||||
import { useReportData } from '@/features/report/useReportData'
|
||||
import { ReportPage } from '@/features/report/ReportPage'
|
||||
|
||||
const mockProcessBase = {
|
||||
id: 'test-proc-id', name: 'Proceso Test', clientName: 'Cliente',
|
||||
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
||||
}
|
||||
|
||||
const mockSimulationBase = {
|
||||
id: 'sim-1', processId: 'test-proc-id',
|
||||
executedAt: Date.now() - 600_000,
|
||||
result: mockResult,
|
||||
}
|
||||
|
||||
const automtableActivity: Activity = {
|
||||
id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud',
|
||||
type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5,
|
||||
}
|
||||
|
||||
const nonAutomatableActivity: Activity = {
|
||||
...automtableActivity, id: 'act-2', bpmnElementId: 'bpmn_a2', name: 'Procesar',
|
||||
automatable: false,
|
||||
}
|
||||
|
||||
describe('ReportPage — comportamiento de tabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sin resultAutomated (snapshot viejo): tabs "Automatizado" y "ROI" están disabled', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase }, // sin resultAutomated
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
|
||||
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
|
||||
expect(tabAuto).toBeDisabled()
|
||||
expect(tabRoi).toBeDisabled()
|
||||
})
|
||||
|
||||
it('con resultAutomated pero SIN actividades automatizables: tabs disabled', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [nonAutomatableActivity], // no hay automatable
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
|
||||
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
|
||||
expect(tabAuto).toBeDisabled()
|
||||
expect(tabRoi).toBeDisabled()
|
||||
})
|
||||
|
||||
it('con resultAutomated Y actividades automatizables: tabs habilitados', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity], // hay 1 automatable
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
|
||||
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
|
||||
expect(tabAuto).not.toBeDisabled()
|
||||
expect(tabRoi).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('tab "Actual" activo por defecto: muestra los 4 KPI cards del costo actual', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
// Tab "Actual" activo por defecto → 4 kpi-cards (no 5 roi-kpi-cards)
|
||||
expect(screen.getAllByTestId('kpi-card')).toHaveLength(4)
|
||||
expect(screen.queryByTestId('roi-kpi-card')).toBeNull()
|
||||
})
|
||||
|
||||
it('click en tab "Comparación + ROI": muestra 5 ROI KPI cards', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /comparación/i }))
|
||||
await waitFor(() => expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5))
|
||||
})
|
||||
|
||||
it('click en tab "Comparación + ROI": la tabla de comparación tiene N+1 rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /comparación/i }))
|
||||
// mockResult tiene 3 actividades → 3 data rows + 1 header
|
||||
await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(perActivityActual.length + 1))
|
||||
})
|
||||
|
||||
it('click en tab "Comparación + ROI" con investment=0: TransparencySection visible', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: { ...mockProcessBase, automationInvestment: 0 },
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /comparación/i }))
|
||||
await waitFor(() => expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('tab "Automatizado": muestra los 4 KPI cards del escenario automatizado', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: /automatizado/i }))
|
||||
// El tab automatizado también muestra 4 kpi-cards (del escenario automatizado)
|
||||
const kpiCards = screen.getAllByTestId('kpi-card')
|
||||
// Puede haber 4 del tab actual (ocultos) + 4 del tab automatizado = 8
|
||||
// O solo 4 si Radix desmonta el tab inactivo
|
||||
expect(kpiCards.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('tab "Automatizado": la nota de AutomationScenarioNote está presente', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcessBase,
|
||||
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
|
||||
activities: [automtableActivity],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReportPage />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await user.click(screen.getByRole('tab', { name: /automatizado/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/actividades no marcadas como automatizables/i)).toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
})
|
||||
157
tests/features/workspace/ActivityPanel.test.tsx
Normal file
157
tests/features/workspace/ActivityPanel.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests de la sección "Automatización" del ActivityPanel.
|
||||
*
|
||||
* Los mocks del store usan vi.hoisted() para crear referencias estables:
|
||||
* si el mock devuelve una nueva referencia de activities en cada render,
|
||||
* el useEffect de ActivityPanel entra en loop infinito.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
// ─── Mocks con referencias estables vía vi.hoisted ───────────────────────────
|
||||
|
||||
const processStoreMock = vi.hoisted(() => {
|
||||
const activity = {
|
||||
id: 'act-1', processId: 'p1', bpmnElementId: 'task1', name: 'Facturación',
|
||||
type: 'task' as const,
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}
|
||||
return {
|
||||
activities: [activity], // referencia estable — no cambia entre renders
|
||||
resources: [],
|
||||
updateActivity: vi.fn(),
|
||||
subscribe: vi.fn(), // requerido por simulation-store.ts al importarse
|
||||
}
|
||||
})
|
||||
|
||||
const simulationStoreMock = vi.hoisted(() => ({
|
||||
resultActual: null as null | object,
|
||||
resultAutomated: null as null | object,
|
||||
invalidateResults: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/store/process-store', () => ({
|
||||
useProcessStore: () => processStoreMock,
|
||||
// Zustand también expone .subscribe() como método estático del hook
|
||||
useProcessStore_subscribe: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/store/simulation-store', () => ({
|
||||
useSimulationStore: () => simulationStoreMock,
|
||||
}))
|
||||
|
||||
import { ActivityPanel } from '@/features/workspace/ActivityPanel'
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderPanel(selectedElementId = 'task1') {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<ActivityPanel selectedElementId={selectedElementId} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
// Resetear el mock de updateActivity y restaurar la actividad base
|
||||
processStoreMock.updateActivity.mockReset()
|
||||
processStoreMock.updateActivity.mockResolvedValue(undefined)
|
||||
processStoreMock.activities[0].automatable = false
|
||||
processStoreMock.activities[0].automatedCostFixed = 0
|
||||
processStoreMock.activities[0].automatedTimeMinutes = 0
|
||||
simulationStoreMock.resultActual = null
|
||||
simulationStoreMock.resultAutomated = null
|
||||
simulationStoreMock.invalidateResults.mockReset()
|
||||
})
|
||||
|
||||
describe('ActivityPanel — sección Automatización', () => {
|
||||
it('sección Automatización está presente y toggle en OFF por defecto', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByText('¿Es automatizable?')).toBeInTheDocument()
|
||||
const toggle = screen.getByRole('switch', { name: /automatizable/i })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('Toggle ON: aria-checked cambia a true', async () => {
|
||||
renderPanel()
|
||||
const toggle = screen.getByRole('switch', { name: /automatizable/i })
|
||||
|
||||
await act(async () => { fireEvent.click(toggle) })
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('Toggle ON con ambos campos en 0: helper ámbar está en el DOM y visible (opacity-100)', async () => {
|
||||
renderPanel()
|
||||
const toggle = screen.getByRole('switch', { name: /automatizable/i })
|
||||
|
||||
await act(async () => { fireEvent.click(toggle) })
|
||||
|
||||
// El helper siempre está en el DOM — verificamos que el texto existe y el contenedor es visible
|
||||
const helperText = screen.getByText(/Transparencia metodológica/i)
|
||||
expect(helperText).toBeInTheDocument()
|
||||
// El contenedor padre tiene opacity-100 cuando showHelper=true
|
||||
const helperContainer = helperText.closest('[aria-hidden]')
|
||||
expect(helperContainer).toHaveAttribute('aria-hidden', 'false')
|
||||
})
|
||||
|
||||
it('Toggle ON con costo > 0: helper ámbar está en el DOM pero oculto (aria-hidden=true)', async () => {
|
||||
renderPanel()
|
||||
const toggle = screen.getByRole('switch', { name: /automatizable/i })
|
||||
await act(async () => { fireEvent.click(toggle) })
|
||||
|
||||
const costoInput = screen.getByLabelText(/Costo automatizado/i)
|
||||
await act(async () => {
|
||||
fireEvent.change(costoInput, { target: { value: '50' } })
|
||||
})
|
||||
|
||||
// El texto sigue en el DOM (transición CSS, no unmount)
|
||||
const helperText = screen.getByText(/Transparencia metodológica/i)
|
||||
expect(helperText).toBeInTheDocument()
|
||||
// Pero el contenedor está marcado aria-hidden=true (oculto visualmente)
|
||||
const helperContainer = helperText.closest('[aria-hidden]')
|
||||
expect(helperContainer).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('Editar costo automatizado y guardar llama updateActivity con el valor correcto', async () => {
|
||||
renderPanel()
|
||||
const toggle = screen.getByRole('switch', { name: /automatizable/i })
|
||||
await act(async () => { fireEvent.click(toggle) })
|
||||
|
||||
const costoInput = screen.getByLabelText(/Costo automatizado/i)
|
||||
await act(async () => {
|
||||
fireEvent.change(costoInput, { target: { value: '25' } })
|
||||
})
|
||||
|
||||
const guardarBtn = screen.getByRole('button', { name: /Guardar/i })
|
||||
await act(async () => { fireEvent.click(guardarBtn) })
|
||||
|
||||
expect(processStoreMock.updateActivity).toHaveBeenCalledOnce()
|
||||
const arg = processStoreMock.updateActivity.mock.calls[0][0]
|
||||
expect(arg.automatedCostFixed).toBe(25)
|
||||
expect(arg.automatable).toBe(true)
|
||||
})
|
||||
|
||||
it('Guardar llama updateActivity (la invalidación reactiva es responsabilidad del store)', async () => {
|
||||
renderPanel()
|
||||
const toggle = screen.getByRole('switch', { name: /automatizable/i })
|
||||
await act(async () => { fireEvent.click(toggle) })
|
||||
|
||||
const tiempoInput = screen.getByLabelText(/Tiempo automatizado/i)
|
||||
await act(async () => {
|
||||
fireEvent.change(tiempoInput, { target: { value: '5' } })
|
||||
})
|
||||
|
||||
const guardarBtn = screen.getByRole('button', { name: /Guardar/i })
|
||||
await act(async () => { fireEvent.click(guardarBtn) })
|
||||
|
||||
expect(processStoreMock.updateActivity).toHaveBeenCalledOnce()
|
||||
const arg = processStoreMock.updateActivity.mock.calls[0][0]
|
||||
expect(arg.automatedTimeMinutes).toBe(5)
|
||||
})
|
||||
})
|
||||
131
tests/features/workspace/BpmnCanvas.test.tsx
Normal file
131
tests/features/workspace/BpmnCanvas.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tests del overlay de automatización en BpmnCanvas.
|
||||
*
|
||||
* bpmn-js se mockea completamente. El elementRegistry.get() devuelve
|
||||
* elementos con width=100 para verificar el posicionamiento left=width-12=88.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, act } from '@testing-library/react'
|
||||
|
||||
// ─── Mock de bpmn-js ──────────────────────────────────────────────────────────
|
||||
|
||||
const mockOverlaysAdd = vi.fn()
|
||||
const mockOverlaysRemove = vi.fn()
|
||||
const mockCanvasZoom = vi.fn()
|
||||
const mockRegForEach = vi.fn()
|
||||
// elementRegistry.get() devuelve un elemento con width=100 (nodo estándar de bpmn-js)
|
||||
const mockRegGet = vi.fn().mockReturnValue({ width: 100, height: 80 })
|
||||
|
||||
vi.mock('bpmn-js/lib/Viewer', () => ({
|
||||
default: class MockViewer {
|
||||
importXML = vi.fn().mockResolvedValue({ warnings: [] })
|
||||
|
||||
get = vi.fn().mockImplementation((svc: string) => {
|
||||
if (svc === 'overlays') return { add: mockOverlaysAdd, remove: mockOverlaysRemove }
|
||||
if (svc === 'canvas') return { zoom: mockCanvasZoom, addMarker: vi.fn(), removeMarker: vi.fn() }
|
||||
if (svc === 'elementRegistry') return { forEach: mockRegForEach, get: mockRegGet }
|
||||
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
|
||||
return {}
|
||||
})
|
||||
|
||||
destroy = vi.fn()
|
||||
},
|
||||
}))
|
||||
|
||||
import { BpmnCanvas } from '@/features/workspace/BpmnCanvas'
|
||||
|
||||
// ─── Fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SIMPLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
|
||||
<process id="p"><startEvent id="s"/></process>
|
||||
</definitions>`
|
||||
|
||||
// Con width=100 de mockRegGet, left = 100 - 12 = 88
|
||||
const EXPECTED_LEFT = 88
|
||||
const EXPECTED_TOP = -8
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRegGet.mockReturnValue({ width: 100, height: 80 })
|
||||
})
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BpmnCanvas — overlay de automatización', () => {
|
||||
it('sin actividades automatizables: overlays.add NO se llama, remove sí', async () => {
|
||||
await act(async () => {
|
||||
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={[]} />)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockOverlaysAdd).not.toHaveBeenCalled()
|
||||
expect(mockOverlaysRemove).toHaveBeenCalledWith({ type: 'automation-indicator' })
|
||||
})
|
||||
|
||||
it('posición: usa elementRegistry para calcular left=width-12, top=-8 (esquina superior derecha)', async () => {
|
||||
await act(async () => {
|
||||
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a']} />)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockOverlaysAdd).toHaveBeenCalledOnce()
|
||||
const position = mockOverlaysAdd.mock.calls[0][2].position
|
||||
|
||||
// top: -8 — badge 8px por encima del borde superior del nodo
|
||||
expect(position.top).toBe(EXPECTED_TOP)
|
||||
// left: width-12 = 88 — badge en la región derecha, 8px fuera del borde derecho (20-12=8px sticking out)
|
||||
expect(position.left).toBe(EXPECTED_LEFT)
|
||||
// No usa right (semántica confusa en diagram-js: right:-8 → left=8+width, enteramente fuera)
|
||||
expect(position.right).toBeUndefined()
|
||||
})
|
||||
|
||||
it('con 2 actividades automatizables: overlays.add llamado 2 veces con posición correcta', async () => {
|
||||
await act(async () => {
|
||||
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a', 'task_b']} />)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockOverlaysAdd).toHaveBeenCalledTimes(2)
|
||||
for (const call of mockOverlaysAdd.mock.calls) {
|
||||
expect(call[2].position).toEqual({ top: EXPECTED_TOP, left: EXPECTED_LEFT })
|
||||
}
|
||||
})
|
||||
|
||||
it('HTML del overlay: title correcto, SVG ámbar (#f59e0b), fondo #fffbeb', async () => {
|
||||
await act(async () => {
|
||||
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_1']} />)
|
||||
})
|
||||
await flush()
|
||||
|
||||
const html: string = mockOverlaysAdd.mock.calls[0][2].html
|
||||
|
||||
expect(html).toContain('Actividad marcada como automatizable')
|
||||
expect(html).toContain('<svg')
|
||||
expect(html).toContain('#f59e0b') // color ámbar del ícono Bot
|
||||
expect(html).toContain('#fffbeb') // fondo amber-50 del círculo
|
||||
})
|
||||
|
||||
it('al quitar una actividad: remove() vuelve a llamarse y add() refleja la lista nueva', async () => {
|
||||
const { rerender } = render(
|
||||
<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a', 'task_b']} />
|
||||
)
|
||||
await flush()
|
||||
|
||||
expect(mockOverlaysAdd).toHaveBeenCalledTimes(2)
|
||||
const removeCalls1 = mockOverlaysRemove.mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
rerender(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a']} />)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockOverlaysRemove.mock.calls.length).toBeGreaterThan(removeCalls1)
|
||||
expect(mockOverlaysAdd).toHaveBeenCalledTimes(3) // 2 + 1
|
||||
})
|
||||
})
|
||||
132
tests/features/workspace/GlobalSettingsPanel.test.tsx
Normal file
132
tests/features/workspace/GlobalSettingsPanel.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests de la sección "Análisis de Impacto" del GlobalSettingsPanel.
|
||||
*
|
||||
* Los mocks usan vi.hoisted() para que currentProcess sea una referencia
|
||||
* estable entre renders (evita el loop infinito del useEffect de sync).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
// ─── Mocks con referencia estable ─────────────────────────────────────────────
|
||||
|
||||
const processStoreMock = vi.hoisted(() => ({
|
||||
currentProcess: {
|
||||
id: 'p1', name: 'Proceso test', clientName: 'Cliente A',
|
||||
bpmnXml: '<def/>', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
},
|
||||
updateProcess: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/store/process-store', () => ({
|
||||
useProcessStore: () => processStoreMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/store/simulation-store', () => ({
|
||||
useSimulationStore: () => ({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
invalidateResults: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { GlobalSettingsPanel } from '@/features/workspace/GlobalSettingsPanel'
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<GlobalSettingsPanel />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
processStoreMock.updateProcess.mockReset()
|
||||
processStoreMock.updateProcess.mockResolvedValue(undefined)
|
||||
// Restaurar defaults del proceso
|
||||
processStoreMock.currentProcess.annualFrequency = 1000
|
||||
processStoreMock.currentProcess.analysisHorizonYears = 3
|
||||
processStoreMock.currentProcess.automationInvestment = 0
|
||||
})
|
||||
|
||||
describe('GlobalSettingsPanel — sección Análisis de Impacto', () => {
|
||||
it('renderiza los 3 campos de impacto con sus labels correctos', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByLabelText(/Frecuencia anual/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Horizonte de análisis/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Inversión en automatización/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('frecuencia anual muestra el default 1000 del proceso', () => {
|
||||
renderPanel()
|
||||
const freqInput = screen.getByLabelText(/Frecuencia anual/i) as HTMLInputElement
|
||||
expect(freqInput.value).toBe('1000')
|
||||
})
|
||||
|
||||
it('editar frecuencia y guardar incluye el nuevo valor en updateProcess', async () => {
|
||||
renderPanel()
|
||||
const freqInput = screen.getByLabelText(/Frecuencia anual/i)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(freqInput, { target: { value: '5000' } })
|
||||
})
|
||||
|
||||
// Después de cambiar, el botón debería mostrar "Guardar configuración"
|
||||
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
|
||||
await act(async () => { fireEvent.click(guardarBtn) })
|
||||
|
||||
expect(processStoreMock.updateProcess).toHaveBeenCalledOnce()
|
||||
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
|
||||
expect(calledWith.annualFrequency).toBe(5000)
|
||||
})
|
||||
|
||||
it('horizonte de análisis se muestra en texto (default 3 años)', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByText('3 años')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('guardar incluye horizonte actual en updateProcess', async () => {
|
||||
renderPanel()
|
||||
const freqInput = screen.getByLabelText(/Frecuencia anual/i)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(freqInput, { target: { value: '1200' } })
|
||||
})
|
||||
|
||||
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
|
||||
await act(async () => { fireEvent.click(guardarBtn) })
|
||||
|
||||
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
|
||||
expect(calledWith.analysisHorizonYears).toBe(3)
|
||||
expect(calledWith.analysisHorizonYears).toBeGreaterThanOrEqual(1)
|
||||
expect(calledWith.analysisHorizonYears).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('editar inversión en automatización y guardar la incluye correctamente', async () => {
|
||||
renderPanel()
|
||||
const investInput = screen.getByLabelText(/Inversión en automatización/i)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(investInput, { target: { value: '75000' } })
|
||||
})
|
||||
|
||||
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
|
||||
await act(async () => { fireEvent.click(guardarBtn) })
|
||||
|
||||
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
|
||||
expect(calledWith.automationInvestment).toBe(75000)
|
||||
})
|
||||
|
||||
it('sección "ANÁLISIS DE IMPACTO" aparece con su label de sección', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByText(/Análisis de Impacto/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
145
tests/integration/dexie-migration.test.ts
Normal file
145
tests/integration/dexie-migration.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests de migración Dexie v1 → v2 (Sprint 1).
|
||||
*
|
||||
* Verifican que:
|
||||
* 1. Registros sin los campos nuevos reciben defaults correctos tras el upgrade.
|
||||
* 2. Registros con los campos ya presentes no son modificados.
|
||||
* 3. Los nuevos campos se pueden leer y persistir correctamente.
|
||||
*
|
||||
* Usan fake-indexeddb (parcheado en tests/setup.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db } from '@/persistence/db'
|
||||
import { activityRepo, processRepo } from '@/persistence/repositories'
|
||||
import type { Activity, Process } from '@/domain/types'
|
||||
|
||||
// ─── Fixtures de registros "viejos" (sin campos Sprint 1) ─────────────────────
|
||||
|
||||
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes'> {
|
||||
return {
|
||||
id, processId,
|
||||
bpmnElementId: `task_${id}`,
|
||||
name: `Tarea ${id}`,
|
||||
type: 'task',
|
||||
directCostFixed: 500,
|
||||
executionTimeMinutes: 45,
|
||||
assignedResources: [],
|
||||
}
|
||||
}
|
||||
|
||||
function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment'> {
|
||||
return {
|
||||
id, name: 'Proceso Legacy', clientName: 'Cliente Viejo',
|
||||
bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15,
|
||||
createdAt: 1000, updatedAt: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.open()
|
||||
await db.transaction('rw', [db.processes, db.activities], async () => {
|
||||
await db.processes.clear()
|
||||
await db.activities.clear()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close()
|
||||
})
|
||||
|
||||
// ─── Tests de defaults tras migración ─────────────────────────────────────────
|
||||
|
||||
describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => {
|
||||
it('actividades nuevas tienen automatable=false por defecto', async () => {
|
||||
const act: Activity = {
|
||||
...makeLegacyActivity('a1', 'p1'),
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
const retrieved = await activityRepo.getByProcess('p1')
|
||||
expect(retrieved[0].automatable).toBe(false)
|
||||
expect(retrieved[0].automatedCostFixed).toBe(0)
|
||||
expect(retrieved[0].automatedTimeMinutes).toBe(0)
|
||||
})
|
||||
|
||||
it('los campos de automatización persisten con valores no-default', async () => {
|
||||
const act: Activity = {
|
||||
...makeLegacyActivity('a2', 'p1'),
|
||||
automatable: true,
|
||||
automatedCostFixed: 12.5,
|
||||
automatedTimeMinutes: 3,
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
const [retrieved] = await activityRepo.getByProcess('p1')
|
||||
expect(retrieved.automatable).toBe(true)
|
||||
expect(retrieved.automatedCostFixed).toBe(12.5)
|
||||
expect(retrieved.automatedTimeMinutes).toBe(3)
|
||||
})
|
||||
|
||||
it('update de actividad preserva los campos de automatización existentes', async () => {
|
||||
const act: Activity = {
|
||||
...makeLegacyActivity('a3', 'p1'),
|
||||
automatable: true,
|
||||
automatedCostFixed: 5,
|
||||
automatedTimeMinutes: 10,
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar
|
||||
await activityRepo.save({ ...act, directCostFixed: 999 })
|
||||
const [updated] = await activityRepo.getByProcess('p1')
|
||||
expect(updated.directCostFixed).toBe(999)
|
||||
expect(updated.automatable).toBe(true)
|
||||
expect(updated.automatedCostFixed).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
it('procesos nuevos tienen annualFrequency=1000 por defecto', async () => {
|
||||
const proc: Process = {
|
||||
...makeLegacyProcess('proc-1'),
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-1')
|
||||
expect(retrieved!.annualFrequency).toBe(1000)
|
||||
expect(retrieved!.analysisHorizonYears).toBe(3)
|
||||
expect(retrieved!.automationInvestment).toBe(0)
|
||||
})
|
||||
|
||||
it('los campos de volumetría persisten con valores configurados', async () => {
|
||||
const proc: Process = {
|
||||
...makeLegacyProcess('proc-2'),
|
||||
annualFrequency: 5000,
|
||||
analysisHorizonYears: 5,
|
||||
automationInvestment: 75_000,
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-2')
|
||||
expect(retrieved!.annualFrequency).toBe(5000)
|
||||
expect(retrieved!.analysisHorizonYears).toBe(5)
|
||||
expect(retrieved!.automationInvestment).toBe(75_000)
|
||||
})
|
||||
|
||||
it('update de proceso preserva campos de volumetría', async () => {
|
||||
const proc: Process = {
|
||||
...makeLegacyProcess('proc-3'),
|
||||
annualFrequency: 2000,
|
||||
analysisHorizonYears: 4,
|
||||
automationInvestment: 30_000,
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
// Actualizar solo el nombre — los campos de volumetría no deben cambiar
|
||||
await processRepo.save({ ...proc, name: 'Nombre Actualizado' })
|
||||
const updated = await processRepo.getById('proc-3')
|
||||
expect(updated!.name).toBe('Nombre Actualizado')
|
||||
expect(updated!.annualFrequency).toBe(2000)
|
||||
expect(updated!.automationInvestment).toBe(30_000)
|
||||
})
|
||||
})
|
||||
@@ -85,6 +85,7 @@ describe('motor / propagación con GatewayConfig correcta', () => {
|
||||
actIds.map((id) => [id, {
|
||||
id: uuidv4(), processId, bpmnElementId: id, name: id,
|
||||
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ function makeProcess(id = 'proc-1'): Process {
|
||||
bpmnXml: '<definitions/>',
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
}
|
||||
@@ -42,6 +45,9 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act
|
||||
directCostFixed: 100,
|
||||
executionTimeMinutes: 30,
|
||||
assignedResources: [{ resourceId: 'res-1', utilizationPercent: 0.5 }],
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ function buildInput(
|
||||
directCostFixed,
|
||||
executionTimeMinutes,
|
||||
assignedResources: [],
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
232
tests/integration/sprint1-flow.test.ts
Normal file
232
tests/integration/sprint1-flow.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Test integrador del flujo completo del Sprint 1.
|
||||
*
|
||||
* Verifica que las piezas del dominio conectan correctamente:
|
||||
* parseBpmnXml → extractActivityElements → runSimulation (actual + automated)
|
||||
* → calculateRoi → KPIs correctos
|
||||
*
|
||||
* BPMN base: medium-with-gateways (Aprobación de crédito)
|
||||
* — 11 actividades, 3 gateways (2 XOR + 1 AND), 5 flujos condicionales
|
||||
*
|
||||
* Costos usados (deliberadamente simples para assertions deterministas):
|
||||
* - Todas las actividades: directCostFixed=$200, executionTimeMinutes=60
|
||||
* - Automatable: solo task_recv y task_score (las más caras por prob alta)
|
||||
* - automatedCostFixed=$20 (90% de ahorro), automatedTimeMinutes=5
|
||||
* - annualFrequency=1200 (100 ejecuciones/mes), horizonte=3 años, inversión=$50.000
|
||||
*/
|
||||
|
||||
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 { calculateRoi } from '@/domain/roi'
|
||||
import { bpmnTypeToGatewayType } from '@/domain/types'
|
||||
import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types'
|
||||
|
||||
const MEDIUM_XML = readFileSync(
|
||||
resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
// Actividades automatable y sus costos automatizados
|
||||
const AUTOMATABLE_IDS = new Set(['task_recv', 'task_score'])
|
||||
const AUTOMATED_COST = 20
|
||||
const AUTOMATED_TIME = 5
|
||||
|
||||
function buildActivities(xml: string): Map<string, Activity> {
|
||||
const graph = parseBpmnXml(xml)
|
||||
const elements = extractActivityElements(graph)
|
||||
return new Map(
|
||||
elements.map((el) => [el.bpmnElementId, {
|
||||
id: uuidv4(),
|
||||
processId: 'sprint1-test',
|
||||
bpmnElementId: el.bpmnElementId,
|
||||
name: el.name,
|
||||
type: el.type,
|
||||
directCostFixed: 200,
|
||||
executionTimeMinutes: 60,
|
||||
assignedResources: [],
|
||||
automatable: AUTOMATABLE_IDS.has(el.bpmnElementId),
|
||||
automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0,
|
||||
automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0,
|
||||
}])
|
||||
)
|
||||
}
|
||||
|
||||
function buildGateways(xml: string): Map<string, GatewayConfig> {
|
||||
const graph = parseBpmnXml(xml)
|
||||
const elements = extractGatewayElements(graph)
|
||||
return new Map(
|
||||
elements.map((el) => {
|
||||
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
|
||||
const n = el.outgoing.length
|
||||
return [el.bpmnElementId, {
|
||||
id: uuidv4(),
|
||||
processId: 'sprint1-test',
|
||||
bpmnElementId: el.bpmnElementId,
|
||||
gatewayType: gwType,
|
||||
branches: el.outgoing.map((flowId) => ({
|
||||
flowId,
|
||||
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
|
||||
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
|
||||
})),
|
||||
}]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const BASE_INPUT: Omit<SimulationInput, 'scenario'> = {
|
||||
processXml: MEDIUM_XML,
|
||||
activities: buildActivities(MEDIUM_XML),
|
||||
gateways: buildGateways(MEDIUM_XML),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
||||
}
|
||||
|
||||
const VOLUMETRY = {
|
||||
annualFrequency: 1200,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 50_000,
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sprint 1 — flujo integrado: simulación dual + ROI', () => {
|
||||
const resultActual = runSimulation({ ...BASE_INPUT, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...BASE_INPUT, scenario: 'automated' })
|
||||
|
||||
// ─── Simulación actual ──────────────────────────────────────────────────
|
||||
|
||||
it('simulación actual produce resultado no nulo con actividades reales', () => {
|
||||
expect(resultActual.totalCost).toBeGreaterThan(0)
|
||||
expect(resultActual.perActivity.length).toBeGreaterThan(0)
|
||||
expect(resultActual.warnings).toBeDefined()
|
||||
})
|
||||
|
||||
it('simulación actual: task_recv tiene probabilidad 1.0 (primer nodo del proceso)', () => {
|
||||
const recv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv')
|
||||
expect(recv).toBeDefined()
|
||||
expect(recv!.executionProbability).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('simulación actual: las actividades con costos tienen expectedDirectCost > 0', () => {
|
||||
for (const act of resultActual.perActivity) {
|
||||
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
||||
expect(act.expectedDirectCost).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Simulación automatizada ────────────────────────────────────────────
|
||||
|
||||
it('simulación automatizada produce totalCost menor que actual', () => {
|
||||
// Hay actividades automatable con costo menor → total debe bajar
|
||||
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
|
||||
})
|
||||
|
||||
it('probabilidades de ejecución son idénticas entre escenarios', () => {
|
||||
for (const actActual of resultActual.perActivity) {
|
||||
const actAuto = resultAutomated.perActivity.find(
|
||||
(a) => a.bpmnElementId === actActual.bpmnElementId
|
||||
)
|
||||
expect(actAuto).toBeDefined()
|
||||
expect(actAuto!.executionProbability).toBeCloseTo(actActual.executionProbability, 5)
|
||||
}
|
||||
})
|
||||
|
||||
it('actividades automatable tienen expectedDirectCost menor en escenario automatizado', () => {
|
||||
for (const bpmnElementId of AUTOMATABLE_IDS) {
|
||||
const actual = resultActual.perActivity.find((a) => a.bpmnElementId === bpmnElementId)
|
||||
const automated = resultAutomated.perActivity.find((a) => a.bpmnElementId === bpmnElementId)
|
||||
if (!actual || !automated) continue // podría no aparecer si prob=0
|
||||
expect(automated.expectedDirectCost).toBeLessThan(actual.expectedDirectCost)
|
||||
}
|
||||
})
|
||||
|
||||
it('actividades no automatable tienen el mismo costo en ambos escenarios', () => {
|
||||
for (const act of resultActual.perActivity) {
|
||||
if (AUTOMATABLE_IDS.has(act.bpmnElementId)) continue
|
||||
const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === act.bpmnElementId)!
|
||||
expect(auto.expectedDirectCost).toBeCloseTo(act.expectedDirectCost, 5)
|
||||
}
|
||||
})
|
||||
|
||||
it('ningún campo numérico del resultado automatizado es NaN', () => {
|
||||
expect(Number.isNaN(resultAutomated.totalCost)).toBe(false)
|
||||
expect(Number.isNaN(resultAutomated.totalDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(resultAutomated.totalIndirectCost)).toBe(false)
|
||||
for (const act of resultAutomated.perActivity) {
|
||||
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
|
||||
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── calculateRoi conecta con los resultados de simulación ─────────────
|
||||
|
||||
it('calculateRoi recibe costos de ambas simulaciones y devuelve KPIs válidos', () => {
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOLUMETRY,
|
||||
})
|
||||
|
||||
// Ahorro positivo (automated < actual)
|
||||
expect(roi.savingsPerExecution).toBeGreaterThan(0)
|
||||
expect(roi.annualSavings).toBeGreaterThan(0)
|
||||
|
||||
// Payback finito y positivo
|
||||
expect(roi.paybackMonths).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(roi.paybackMonths)).toBe(true)
|
||||
|
||||
// Con inversión $50.000 y ahorro anual razonable, debería pagar dentro del horizonte de 3 años
|
||||
// (esto depende de los costos calculados, pero verificamos que es un número razonable)
|
||||
expect(roi.paybackYears).toBeGreaterThan(0)
|
||||
|
||||
// ROI anualizado positivo (ahorro > 0, inversión > 0)
|
||||
expect(roi.roiAnnualPercent).toBeGreaterThan(0)
|
||||
expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false)
|
||||
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
|
||||
})
|
||||
|
||||
it('ahorro acumulado a 3 años supera la inversión si hay ahorro positivo suficiente', () => {
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOLUMETRY,
|
||||
})
|
||||
|
||||
if (roi.annualSavings * VOLUMETRY.analysisHorizonYears > VOLUMETRY.automationInvestment) {
|
||||
expect(roi.cumulativeSavingsHorizon).toBeGreaterThan(0)
|
||||
expect(roi.breaksEvenInHorizon).toBe(true)
|
||||
} else {
|
||||
// Si el ahorro es menor que la inversión en 3 años, el test es informativo
|
||||
expect(roi.cumulativeSavingsHorizon).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Transparencia metodológica: actividades automatable con costo cero ─
|
||||
|
||||
it('detecta actividades automatable con automatedCostFixed=0 para transparencia', () => {
|
||||
// En nuestro fixture solo task_recv y task_score son automatable con costo > 0.
|
||||
// Si alguien marca una actividad como automatable pero no pone costo,
|
||||
// el resultado automatizado de esa actividad debe tener cost=0.
|
||||
const activities = buildActivities(MEDIUM_XML)
|
||||
|
||||
// Forzar una actividad automatable con costo cero (simula campo no configurado)
|
||||
const taskValid = activities.get('task_valid')
|
||||
if (taskValid) {
|
||||
activities.set('task_valid', { ...taskValid, automatable: true, automatedCostFixed: 0, automatedTimeMinutes: 0 })
|
||||
}
|
||||
|
||||
const result = runSimulation({ ...BASE_INPUT, activities, scenario: 'automated' })
|
||||
const validResult = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
|
||||
|
||||
if (validResult && validResult.executionProbability > 0) {
|
||||
// automatable=true pero costos en 0 → contribuye con $0 al total automatizado
|
||||
expect(validResult.expectedDirectCost).toBe(0)
|
||||
expect(Number.isNaN(validResult.expectedDirectCost)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
447
tests/integration/sprint1-report-roi.test.ts
Normal file
447
tests/integration/sprint1-report-roi.test.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Test integrador del flujo ROI completo — Sprint 1 Etapa 3.
|
||||
*
|
||||
* Para cada sample BPMN verifica que el pipeline:
|
||||
* parseBpmnXml → runSimulation (dual) → calculateRoi
|
||||
* produce KPIs numéricos válidos y detecta las condiciones de transparencia
|
||||
* metodológica correctamente.
|
||||
*
|
||||
* Los 4 casos:
|
||||
* A) simple-linear SIN automatizables → automatableIds.size = 0
|
||||
* B) medium-with-gateways, 3 automatable → KPIs positivos, payback razonable
|
||||
* C) medium-with-gateways, 1 automatable en ceros + investment=0 → transparencia doble
|
||||
* D) complex-with-loop → warnings preservados en ambos escenarios
|
||||
*/
|
||||
|
||||
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 { calculateRoi } from '@/domain/roi'
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import { bpmnTypeToGatewayType } from '@/domain/types'
|
||||
import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadBpmn(name: string): string {
|
||||
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
|
||||
}
|
||||
|
||||
function buildSimInput(
|
||||
xml: string,
|
||||
directCost: number,
|
||||
automatableIds: Set<string>,
|
||||
automatedCost: number,
|
||||
automatedTime: number,
|
||||
): SimulationInput {
|
||||
const graph = parseBpmnXml(xml)
|
||||
const actEls = extractActivityElements(graph)
|
||||
const gwEls = extractGatewayElements(graph)
|
||||
const processId = 'roi-test'
|
||||
|
||||
const activities = new Map<string, Activity>(
|
||||
actEls.map((el) => [el.bpmnElementId, {
|
||||
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId,
|
||||
name: el.name, type: el.type,
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: 60,
|
||||
assignedResources: [],
|
||||
automatable: automatableIds.has(el.bpmnElementId),
|
||||
automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0,
|
||||
automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 0,
|
||||
}])
|
||||
)
|
||||
|
||||
const gateways = new Map<string, GatewayConfig>(
|
||||
gwEls.map((el) => {
|
||||
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
|
||||
const n = el.outgoing.length
|
||||
return [el.bpmnElementId, {
|
||||
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId,
|
||||
gatewayType: gwType,
|
||||
branches: el.outgoing.map((flowId) => ({
|
||||
flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
|
||||
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
|
||||
})),
|
||||
}]
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
processXml: xml,
|
||||
activities,
|
||||
gateways,
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
||||
}
|
||||
}
|
||||
|
||||
// Volumetría estándar para todos los tests (la especificada en el brief)
|
||||
const VOL = { annualFrequency: 12_000, analysisHorizonYears: 3, automationInvestment: 50_000 }
|
||||
|
||||
// ─── CASO A: simple-linear SIN automatizables ─────────────────────────────────
|
||||
|
||||
describe('Caso A — simple-linear SIN automatizables', () => {
|
||||
const xml = loadBpmn('simple-linear.bpmn')
|
||||
const noAutomatable = new Set<string>()
|
||||
const input = buildSimInput(xml, 200, noAutomatable, 0, 0)
|
||||
|
||||
const resultActual = runSimulation({ ...input, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
|
||||
|
||||
it('simple-linear tiene 5 actividades', () => {
|
||||
const graph = parseBpmnXml(xml)
|
||||
const actEls = extractActivityElements(graph)
|
||||
expect(actEls).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('automatableIds.size === 0: ninguna actividad marcada como automatizable', () => {
|
||||
const graph = parseBpmnXml(xml)
|
||||
const actEls = extractActivityElements(graph)
|
||||
const automatableIds = actEls.filter((el) => noAutomatable.has(el.bpmnElementId))
|
||||
expect(automatableIds).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('resultActual y resultAutomated tienen costos idénticos (sin automatizables)', () => {
|
||||
expect(resultActual.totalCost).toBeCloseTo(resultAutomated.totalCost, 4)
|
||||
})
|
||||
|
||||
it('ROI con costos idénticos: savingsPerExecution = 0, annualSavings = 0', () => {
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOL,
|
||||
})
|
||||
expect(roi.savingsPerExecution).toBeCloseTo(0, 4)
|
||||
expect(roi.annualSavings).toBeCloseTo(0, 2)
|
||||
})
|
||||
|
||||
it('con ahorro cero: paybackMonths = Infinity', () => {
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOL,
|
||||
})
|
||||
expect(roi.paybackMonths).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('con ahorro cero: breaksEvenInHorizon = false', () => {
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOL,
|
||||
})
|
||||
expect(roi.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CASO B: medium-with-gateways con 3 automatizables ───────────────────────
|
||||
|
||||
describe('Caso B — medium-with-gateways, 3 automatizables, valores plausibles', () => {
|
||||
const xml = loadBpmn('medium-with-gateways.bpmn')
|
||||
// task_recv (prob≈1.0), task_score (prob≈0.5), task_analisis (prob≈0.5 post-parallel)
|
||||
const automatable3 = new Set(['task_recv', 'task_score', 'task_analisis'])
|
||||
const input = buildSimInput(xml, 200, automatable3, 20, 5)
|
||||
|
||||
const resultActual = runSimulation({ ...input, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOL,
|
||||
})
|
||||
|
||||
it('reporta los KPIs reales del Caso B para inspección manual', () => {
|
||||
// Este test siempre pasa — su propósito es mostrar valores en la consola
|
||||
console.log('\n╔══════════════════════════════════════════════════════════╗')
|
||||
console.log('║ CASO B: medium-with-gateways — KPIs del sistema ║')
|
||||
console.log('╠══════════════════════════════════════════════════════════╣')
|
||||
console.log(`║ Costo actual por ejecución: ${formatCurrency(resultActual.totalCost, 'USD').padEnd(26)} ║`)
|
||||
console.log(`║ Costo automático por ejec.: ${formatCurrency(resultAutomated.totalCost, 'USD').padEnd(26)} ║`)
|
||||
console.log('╠══════════════════════════════════════════════════════════╣')
|
||||
console.log(`║ Ahorro por ejecución: ${formatCurrency(roi.savingsPerExecution, 'USD').padEnd(30)} ║`)
|
||||
console.log(`║ Reducción %: ${formatPercent(roi.savingsPerExecutionPercent).padEnd(30)} ║`)
|
||||
console.log(`║ Ahorro anual: ${formatCurrency(roi.annualSavings, 'USD').padEnd(30)} ║`)
|
||||
console.log(`║ Payback: ${roi.paybackMonths.toFixed(1)} meses`.padEnd(57) + '║')
|
||||
console.log(`║ Acumulado a 3 años: ${formatCurrency(roi.cumulativeSavingsHorizon, 'USD').padEnd(30)} ║`)
|
||||
console.log(`║ ROI anualizado: ${formatPercent(roi.roiAnnualPercent).padEnd(30)} ║`)
|
||||
console.log('╚══════════════════════════════════════════════════════════╝\n')
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('dump de costos por actividad — reconciliación aritmética completa', () => {
|
||||
const autoByElem = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a]))
|
||||
|
||||
// Construcción de la tabla en orden de procesamiento topológico
|
||||
const all = resultActual.perActivity
|
||||
.slice()
|
||||
.sort((a, b) => b.executionProbability - a.executionProbability || a.activityName.localeCompare(b.activityName))
|
||||
|
||||
const AUTOMATABLE = new Set(['task_recv', 'task_score', 'task_analisis'])
|
||||
|
||||
let directActualSum = 0
|
||||
let directAutoSum = 0
|
||||
|
||||
console.log('\n┌────────────────────────────────┬──────┬──────────────┬──────────────┐')
|
||||
console.log('│ bpmnElementId │ prob │ directo act. │ directo auto │')
|
||||
console.log('├────────────────────────────────┼──────┼──────────────┼──────────────┤')
|
||||
for (const act of all) {
|
||||
const aAuto = autoByElem.get(act.bpmnElementId)
|
||||
const prob = act.executionProbability
|
||||
const dAct = act.expectedDirectCost
|
||||
const dAuto = aAuto?.expectedDirectCost ?? 0
|
||||
const auto = AUTOMATABLE.has(act.bpmnElementId) ? '★' : ' '
|
||||
directActualSum += dAct
|
||||
directAutoSum += dAuto
|
||||
const idPadded = act.bpmnElementId.padEnd(30)
|
||||
const probStr = prob.toFixed(2).padStart(4)
|
||||
const actStr = dAct.toFixed(2).padStart(12)
|
||||
const autoStr = dAuto.toFixed(2).padStart(12)
|
||||
console.log(`│ ${auto}${idPadded} │ ${probStr} │ ${actStr} │ ${autoStr} │`)
|
||||
}
|
||||
console.log('├────────────────────────────────┼──────┼──────────────┼──────────────┤')
|
||||
console.log(`│ SUMA DIRECTA │ │ ${directActualSum.toFixed(2).padStart(12)} │ ${directAutoSum.toFixed(2).padStart(12)} │`)
|
||||
console.log(`│ + overhead 20% │ │ ${(directActualSum * 1.2).toFixed(2).padStart(12)} │ ${(directAutoSum * 1.2).toFixed(2).padStart(12)} │`)
|
||||
console.log('└────────────────────────────────┴──────┴──────────────┴──────────────┘\n')
|
||||
|
||||
// Verificaciones aritméticas exactas
|
||||
// sum probs de actividades: 1+1+0.5+0.5+0.5+0.5+1+0.5+0.5+0.5+1 = 7.5
|
||||
const sumProbs = all.reduce((s, a) => s + a.executionProbability, 0)
|
||||
expect(sumProbs).toBeCloseTo(7.5, 4)
|
||||
|
||||
// Directo actual = 7.5 × $200 = $1.500
|
||||
expect(directActualSum).toBeCloseTo(1500, 2)
|
||||
|
||||
// Total con overhead = $1.800
|
||||
expect(resultActual.totalCost).toBeCloseTo(1800, 2)
|
||||
|
||||
// Directo automatizado:
|
||||
// task_recv(1×$20)+task_valid(1×$200)+task_score(0.5×$20)+task_pedir_docs(0.5×$200)
|
||||
// +task_bureau(0.5×$200)+task_empleador(0.5×$200)+task_analisis(1×$20)
|
||||
// +task_aprobar(0.5×$200)+task_rechazar(0.5×$200)+task_desembolso(0.5×$200)+task_archivo(1×$200)
|
||||
// = $20+$200+$10+$100+$100+$100+$20+$100+$100+$100+$200 = $1.050
|
||||
expect(directAutoSum).toBeCloseTo(1050, 2)
|
||||
|
||||
// Total automatizado con overhead = $1.260
|
||||
expect(resultAutomated.totalCost).toBeCloseTo(1260, 2)
|
||||
})
|
||||
|
||||
it('costAutomated < costActual (hay ahorro)', () => {
|
||||
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
|
||||
})
|
||||
|
||||
it('savingsPerExecution > 0 (ahorro positivo por ejecución)', () => {
|
||||
expect(roi.savingsPerExecution).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('annualSavings > 0 (ahorro anual positivo)', () => {
|
||||
expect(roi.annualSavings).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('paybackMonths es finito y positivo', () => {
|
||||
expect(Number.isFinite(roi.paybackMonths)).toBe(true)
|
||||
expect(roi.paybackMonths).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('paybackMonths < 24 (recupera la inversión en menos de 2 años con datos plausibles)', () => {
|
||||
expect(roi.paybackMonths).toBeLessThan(24)
|
||||
})
|
||||
|
||||
it('ROI anualizado > 100% (inversión se multiplica en el horizonte)', () => {
|
||||
expect(roi.roiAnnualPercent).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it('breaksEvenInHorizon = true (se recupera dentro de 3 años)', () => {
|
||||
expect(roi.breaksEvenInHorizon).toBe(true)
|
||||
})
|
||||
|
||||
it('ningún KPI es NaN', () => {
|
||||
expect(Number.isNaN(roi.savingsPerExecution)).toBe(false)
|
||||
expect(Number.isNaN(roi.annualSavings)).toBe(false)
|
||||
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
|
||||
expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false)
|
||||
expect(Number.isNaN(roi.paybackMonths)).toBe(false)
|
||||
})
|
||||
|
||||
it('tabla de comparación: task_recv tiene ahorro diferente de cero', () => {
|
||||
const actualRecv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv')
|
||||
const autoRecv = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'task_recv')
|
||||
expect(actualRecv).toBeDefined()
|
||||
expect(autoRecv).toBeDefined()
|
||||
expect(autoRecv!.expectedDirectCost).toBeLessThan(actualRecv!.expectedDirectCost)
|
||||
})
|
||||
|
||||
it('tabla de comparación: actividades no automatable tienen costos idénticos en ambos escenarios', () => {
|
||||
const nonAutomatableIds = ['task_valid', 'task_bureau', 'task_empleador', 'task_archivo']
|
||||
for (const id of nonAutomatableIds) {
|
||||
const actual = resultActual.perActivity.find((a) => a.bpmnElementId === id)
|
||||
const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === id)
|
||||
if (!actual || !auto) continue
|
||||
expect(auto.expectedDirectCost).toBeCloseTo(actual.expectedDirectCost, 4)
|
||||
}
|
||||
})
|
||||
|
||||
it('probabilidades de ejecución idénticas entre escenarios (el grafo no cambia)', () => {
|
||||
for (const actActual of resultActual.perActivity) {
|
||||
const actAuto = resultAutomated.perActivity.find(
|
||||
(a) => a.bpmnElementId === actActual.bpmnElementId
|
||||
)
|
||||
expect(actAuto).toBeDefined()
|
||||
expect(actAuto!.executionProbability).toBeCloseTo(actActual.executionProbability, 5)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CASO C: transparencia metodológica (ambas condiciones) ──────────────────
|
||||
|
||||
describe('Caso C — medium-with-gateways, 1 automatable en ceros + investment=0', () => {
|
||||
const xml = loadBpmn('medium-with-gateways.bpmn')
|
||||
const graph = parseBpmnXml(xml)
|
||||
const actEls = extractActivityElements(graph)
|
||||
const gwEls = extractGatewayElements(graph)
|
||||
|
||||
// Construir actividades: task_valid marcada como automatable pero con costo=0 y tiempo=0
|
||||
const activities = new Map<string, Activity>(
|
||||
actEls.map((el) => [el.bpmnElementId, {
|
||||
id: uuidv4(), processId: 'c-test', bpmnElementId: el.bpmnElementId,
|
||||
name: el.name, type: el.type,
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: el.bpmnElementId === 'task_valid',
|
||||
automatedCostFixed: 0, // cero — trigger de transparencia
|
||||
automatedTimeMinutes: 0, // cero — trigger de transparencia
|
||||
}])
|
||||
)
|
||||
|
||||
const gateways = new Map<string, GatewayConfig>(
|
||||
gwEls.map((el) => {
|
||||
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
|
||||
const n = el.outgoing.length
|
||||
return [el.bpmnElementId, {
|
||||
id: uuidv4(), processId: 'c-test', bpmnElementId: el.bpmnElementId,
|
||||
gatewayType: gwType,
|
||||
branches: el.outgoing.map((flowId) => ({
|
||||
flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
|
||||
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
|
||||
})),
|
||||
}]
|
||||
})
|
||||
)
|
||||
|
||||
const input: SimulationInput = {
|
||||
processXml: xml, activities, gateways, resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
||||
}
|
||||
|
||||
const resultActual = runSimulation({ ...input, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
|
||||
|
||||
// investment=0 para el segundo trigger de transparencia
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: 12_000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0, // ← trigger de transparencia
|
||||
})
|
||||
|
||||
it('task_valid automatable con costo=0 → en escenario automatizado, task_valid contribuye $0 de costo directo', () => {
|
||||
const taskValidAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'task_valid')
|
||||
expect(taskValidAuto).toBeDefined()
|
||||
// automatable=true pero costo=0 → contribuye con $0 al directo automatizado
|
||||
expect(taskValidAuto!.expectedDirectCost).toBeCloseTo(0, 4)
|
||||
})
|
||||
|
||||
it('TransparencySection Condición 1: hay 1 actividad automatable con costos en cero', () => {
|
||||
const zeroCostAutomatable = actEls.filter((el) => {
|
||||
const act = activities.get(el.bpmnElementId)
|
||||
return act?.automatable && act.automatedCostFixed === 0 && act.automatedTimeMinutes === 0
|
||||
})
|
||||
expect(zeroCostAutomatable).toHaveLength(1)
|
||||
expect(zeroCostAutomatable[0].bpmnElementId).toBe('task_valid')
|
||||
})
|
||||
|
||||
it('TransparencySection Condición 2: investment=0 → paybackMonths=0 (inmediato)', () => {
|
||||
expect(roi.paybackMonths).toBe(0)
|
||||
})
|
||||
|
||||
it('TransparencySection Condición 2: investment=0 → roiAnnualPercent=Infinity', () => {
|
||||
expect(roi.roiAnnualPercent).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('ambas condiciones de transparencia coexisten en el mismo proceso', () => {
|
||||
const zeroCostAutomatable = actEls.filter((el) => {
|
||||
const act = activities.get(el.bpmnElementId)
|
||||
return act?.automatable && act.automatedCostFixed === 0 && act.automatedTimeMinutes === 0
|
||||
})
|
||||
// Condición 1: al menos 1 automatable con costo=0
|
||||
expect(zeroCostAutomatable.length).toBeGreaterThan(0)
|
||||
// Condición 2: investment=0
|
||||
expect(roi.paybackMonths).toBe(0)
|
||||
expect(roi.roiAnnualPercent).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('el ahorro sigue siendo positivo aunque task_valid tenga costo=0 en automático (la reducción es real)', () => {
|
||||
// task_valid automatable con costo=0 reduce el total del escenario automatizado
|
||||
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
|
||||
expect(roi.savingsPerExecution).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CASO D: complex-with-loop + warnings ─────────────────────────────────────
|
||||
|
||||
describe('Caso D — complex-with-loop, warnings preservados en ambos escenarios', () => {
|
||||
const xml = loadBpmn('complex-with-loop.bpmn')
|
||||
const automatable = new Set(['task_preparar', 'task_inspeccion'])
|
||||
const input = buildSimInput(xml, 200, automatable, 25, 5)
|
||||
|
||||
const resultActual = runSimulation({ ...input, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
|
||||
|
||||
it('el escenario actual produce warnings (loop detectado)', () => {
|
||||
expect(resultActual.warnings.length).toBeGreaterThan(0)
|
||||
const loopWarning = resultActual.warnings.some(
|
||||
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo') || w.toLowerCase().includes('visitad')
|
||||
)
|
||||
expect(loopWarning).toBe(true)
|
||||
})
|
||||
|
||||
it('el escenario automatizado también tiene los mismos warnings de loop', () => {
|
||||
expect(resultAutomated.warnings.length).toBeGreaterThan(0)
|
||||
// La estructura del grafo no cambia entre escenarios → mismo warning
|
||||
expect(resultAutomated.warnings.length).toBe(resultActual.warnings.length)
|
||||
})
|
||||
|
||||
it('el tab ROI (cambio de tab) NO silencia el warning — los datos de warnings coexisten', () => {
|
||||
// Este test valida que el SimulationResult preserva el array de warnings
|
||||
// independientemente del escenario. La UI del tab ROI no tiene WarningsBanner
|
||||
// (solo Actual y Automatizado lo tienen), pero los datos existen.
|
||||
expect(resultActual.warnings).toBeDefined()
|
||||
expect(Array.isArray(resultActual.warnings)).toBe(true)
|
||||
expect(resultAutomated.warnings).toBeDefined()
|
||||
expect(Array.isArray(resultAutomated.warnings)).toBe(true)
|
||||
})
|
||||
|
||||
it('aún con loop, el totalCost no es NaN', () => {
|
||||
expect(Number.isNaN(resultActual.totalCost)).toBe(false)
|
||||
expect(Number.isNaN(resultAutomated.totalCost)).toBe(false)
|
||||
})
|
||||
|
||||
it('aún con loop, la diferencia de costos entre escenarios es real y positiva', () => {
|
||||
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
|
||||
})
|
||||
|
||||
it('aún con loop, calculateRoi no produce NaN ni errores', () => {
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
...VOL,
|
||||
})
|
||||
expect(Number.isNaN(roi.savingsPerExecution)).toBe(false)
|
||||
expect(Number.isNaN(roi.annualSavings)).toBe(false)
|
||||
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
|
||||
expect(roi.savingsPerExecution).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
216
tests/lib/export/csv-export-roi.test.ts
Normal file
216
tests/lib/export/csv-export-roi.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Tests del export CSV para el tab "Comparación + ROI" (Sprint 1 — Etapa 4).
|
||||
* Verifica las 16 columnas, la metadata extendida, sufijos de archivo y
|
||||
* compatibilidad con el comportamiento anterior (tabs actual y automatizado).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateCsvContent } from '@/lib/export/csv-export'
|
||||
import type { Process, Simulation, Activity, SimulationResult } from '@/domain/types'
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeProcess(extras: Partial<Process> = {}): Process {
|
||||
return {
|
||||
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0, ...extras,
|
||||
}
|
||||
}
|
||||
|
||||
const perActivityActual: SimulationResult['perActivity'] = [
|
||||
{
|
||||
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 240,
|
||||
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60, resourceCostBreakdown: [],
|
||||
},
|
||||
{
|
||||
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
|
||||
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
|
||||
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
|
||||
executionTimeMinutes: 30, resourceCostBreakdown: [],
|
||||
},
|
||||
]
|
||||
|
||||
const perActivityAutomated: SimulationResult['perActivity'] = [
|
||||
{
|
||||
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 20, expectedIndirectCost: 4, expectedTotalCost: 24,
|
||||
percentOfTotal: 40, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 5, resourceCostBreakdown: [],
|
||||
},
|
||||
{
|
||||
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
|
||||
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
|
||||
percentOfTotal: 60, executionProbability: 0.8, expectedExecutions: 0.8,
|
||||
executionTimeMinutes: 30, resourceCostBreakdown: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
id: 'sim1', processId: 'p1',
|
||||
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
|
||||
result: {
|
||||
totalCost: 360, totalDirectCost: 300, totalIndirectCost: 60, totalTimeMinutes: 84,
|
||||
perActivity: perActivityActual, perResource: [], warnings: [],
|
||||
},
|
||||
resultAutomated: {
|
||||
totalCost: 144, totalDirectCost: 120, totalIndirectCost: 24, totalTimeMinutes: 8,
|
||||
perActivity: perActivityAutomated, perResource: [], warnings: [],
|
||||
},
|
||||
}
|
||||
|
||||
const mockActivities: Activity[] = [
|
||||
{
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
|
||||
},
|
||||
{
|
||||
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
|
||||
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
},
|
||||
]
|
||||
|
||||
// ─── CSV ROI: estructura de columnas ──────────────────────────────────────────
|
||||
|
||||
describe('CSV tab=roi — 16 columnas', () => {
|
||||
it('tiene exactamente 16 columnas en el header', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
|
||||
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
// Papa.unparse envuelve en comillas — contar separadores + 1
|
||||
const cols = headerLine.split('","').length
|
||||
expect(cols).toBe(16)
|
||||
})
|
||||
|
||||
it('tiene los headers esperados (muestra)', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv).toContain('ID BPMN')
|
||||
expect(csv).toContain('Automatizable')
|
||||
expect(csv).toContain('Costo actual total')
|
||||
expect(csv).toContain('Costo automatizado total')
|
||||
expect(csv).toContain('Ahorro')
|
||||
expect(csv).toContain('Tiempo automatizado')
|
||||
expect(csv).toContain('Recursos asignados')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSV tab=roi — metadata extendida', () => {
|
||||
it('contiene campos de ROI en la metadata', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv).toContain('# Ahorro por ejecución')
|
||||
expect(csv).toContain('# Ahorro anual')
|
||||
expect(csv).toContain('# Payback (meses)')
|
||||
expect(csv).toContain('# ROI anualizado')
|
||||
expect(csv).toContain('# Frecuencia anual')
|
||||
expect(csv).toContain('# Horizonte de análisis')
|
||||
expect(csv).toContain('# Inversión en automatización')
|
||||
})
|
||||
|
||||
it('costo total actual y automatizado están en la metadata', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv).toContain('# Costo total actual')
|
||||
expect(csv).toContain('# Costo total automatizado')
|
||||
// Valores concretos del fixture: 360 y 144
|
||||
expect(csv).toContain('360.00')
|
||||
expect(csv).toContain('144.00')
|
||||
})
|
||||
|
||||
it('metadata incluye el horizonte y frecuencia configurados', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv).toContain('# Frecuencia anual: 1200')
|
||||
expect(csv).toContain('# Horizonte de análisis: 3 años')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSV tab=roi — datos de actividades', () => {
|
||||
it('actividad automatable tiene "true" en la columna Automatizable', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
// task_recv es automatable=true
|
||||
expect(csv).toContain('"true"')
|
||||
})
|
||||
|
||||
it('actividad NO automatable tiene "false" en la columna Automatizable', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv).toContain('"false"')
|
||||
})
|
||||
|
||||
it('ahorro de task_recv: 240 - 24 = 216 aparece en los datos', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv).toContain('216.00')
|
||||
})
|
||||
|
||||
it('actividad no automatable tiene ahorro 0 (costos iguales)', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
// task_valid: 120 - 120 = 0
|
||||
expect(csv).toContain('0.00')
|
||||
})
|
||||
|
||||
it('N+1 filas (N actividades + 1 header)', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
|
||||
const dataLines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
|
||||
expect(dataLines).toHaveLength(perActivityActual.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSV tab=roi — separadores y BOM', () => {
|
||||
it('BOM UTF-8 presente', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
expect(csv.charCodeAt(0)).toBe(0xFEFF)
|
||||
})
|
||||
|
||||
it('USD: separador coma', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess({ currency: 'USD' }), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
|
||||
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
expect(headerLine).toContain('","')
|
||||
})
|
||||
|
||||
it('PYG: separador punto y coma', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess({ currency: 'PYG' }), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
|
||||
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
|
||||
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
expect(headerLine).toContain('";"')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CSV tab=automatizado ─────────────────────────────────────────────────────
|
||||
|
||||
describe('CSV tab=automatizado', () => {
|
||||
it('usa los datos del escenario automatizado (totalCost=144)', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'automatizado' })
|
||||
expect(csv).toContain('# Escenario: Automatizado')
|
||||
expect(csv).toContain('144.00')
|
||||
})
|
||||
|
||||
it('tiene 10 columnas (mismo formato que actual)', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'automatizado' })
|
||||
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
|
||||
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
const cols = headerLine.split('","').length
|
||||
expect(cols).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CSV tab=actual — regresión ───────────────────────────────────────────────
|
||||
|
||||
describe('CSV tab=actual (regresión)', () => {
|
||||
it('tab=actual funciona igual que antes (escenario actual, 10 cols)', () => {
|
||||
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'actual' })
|
||||
expect(csv).toContain('# Escenario: Actual')
|
||||
expect(csv).toContain('360.00')
|
||||
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
|
||||
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
|
||||
expect(headerLine.split('","').length).toBe(10)
|
||||
})
|
||||
|
||||
it('tab=undefined → igual que tab=actual', () => {
|
||||
const csvDefault = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [] })
|
||||
const csvActual = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'actual' })
|
||||
expect(csvDefault).toBe(csvActual)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
|
||||
return {
|
||||
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
|
||||
bpmnXml: '', currency, overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0, ...extras,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function buildInput(xml: string, directCost: number): SimulationInput {
|
||||
actEls.map((el) => [el.bpmnElementId, {
|
||||
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name,
|
||||
type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}])
|
||||
)
|
||||
|
||||
@@ -63,6 +64,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
|
||||
const process: Process = {
|
||||
id: 'p1', name: bpmn.name, clientName: bpmn.client,
|
||||
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
|
||||
|
||||
206
tests/lib/export/pdf-export-roi.test.ts
Normal file
206
tests/lib/export/pdf-export-roi.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Tests del export PDF para el tab "Comparación + ROI" (Sprint 1 — Etapa 4).
|
||||
* También verifica sufijos de nombre de archivo para los 3 tabs.
|
||||
* jsPDF y autotable se mockean.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ─── Mock de jsPDF ────────────────────────────────────────────────────────────
|
||||
|
||||
const mockDoc = {
|
||||
setProperties: vi.fn(),
|
||||
setFont: vi.fn(),
|
||||
setFontSize: vi.fn(),
|
||||
setTextColor: vi.fn(),
|
||||
setFillColor: vi.fn(),
|
||||
setDrawColor: vi.fn(),
|
||||
setLineWidth: vi.fn(),
|
||||
text: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
roundedRect: vi.fn(),
|
||||
line: vi.fn(),
|
||||
addImage: vi.fn(),
|
||||
addPage: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]),
|
||||
internal: { getNumberOfPages: vi.fn().mockReturnValue(4) },
|
||||
}
|
||||
|
||||
vi.mock('jspdf', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function MockJsPDF(this: any) { return mockDoc }
|
||||
return { jsPDF: MockJsPDF }
|
||||
})
|
||||
|
||||
const mockAutoTable = vi.fn()
|
||||
vi.mock('jspdf-autotable', () => ({ default: mockAutoTable }))
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Process, Simulation, Activity } from '@/domain/types'
|
||||
|
||||
const mockProcess: Process = {
|
||||
id: 'p1', name: 'Proceso de Aprobación de Crédito', clientName: 'Banco XYZ',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
|
||||
const perActivityActual = [
|
||||
{
|
||||
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 240,
|
||||
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60, resourceCostBreakdown: [],
|
||||
},
|
||||
{
|
||||
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
|
||||
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
|
||||
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
|
||||
executionTimeMinutes: 30, resourceCostBreakdown: [],
|
||||
},
|
||||
]
|
||||
|
||||
const perActivityAutomated = [
|
||||
{
|
||||
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 20, expectedIndirectCost: 4, expectedTotalCost: 24,
|
||||
percentOfTotal: 40, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 5, resourceCostBreakdown: [],
|
||||
},
|
||||
{
|
||||
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
|
||||
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
|
||||
percentOfTotal: 60, executionProbability: 0.8, expectedExecutions: 0.8,
|
||||
executionTimeMinutes: 30, resourceCostBreakdown: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
id: 'sim1', processId: 'p1',
|
||||
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
|
||||
result: {
|
||||
totalCost: 360, totalDirectCost: 300, totalIndirectCost: 60, totalTimeMinutes: 84,
|
||||
perActivity: perActivityActual, perResource: [], warnings: [],
|
||||
},
|
||||
resultAutomated: {
|
||||
totalCost: 144, totalDirectCost: 120, totalIndirectCost: 24, totalTimeMinutes: 8,
|
||||
perActivity: perActivityAutomated, perResource: [], warnings: [],
|
||||
},
|
||||
}
|
||||
|
||||
const mockActivities: Activity[] = [
|
||||
{
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
|
||||
},
|
||||
{
|
||||
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
|
||||
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('exportToPdf — tab=roi', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('no lanza error con tab=roi y parámetros válidos', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await expect(
|
||||
exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('tab=roi: llama a addPage al menos 3 veces (4 páginas)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
|
||||
expect(mockDoc.addPage.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('tab=roi: autoTable llamado con 6 columnas en el head (tabla comparativa)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
|
||||
// autoTable puede llamarse múltiples veces; la tabla de comparación tiene 6 cols en head
|
||||
const roiTableCall = mockAutoTable.mock.calls.find((call) => {
|
||||
const head = call[1]?.head
|
||||
return Array.isArray(head) && Array.isArray(head[0]) && head[0].length === 6
|
||||
})
|
||||
expect(roiTableCall).toBeDefined()
|
||||
})
|
||||
|
||||
it('tab=roi: nombre de archivo termina en _roi.pdf', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
|
||||
const saveName = mockDoc.save.mock.calls[0][0] as string
|
||||
expect(saveName).toMatch(/_roi\.pdf$/)
|
||||
})
|
||||
|
||||
it('tab=roi: setProperties incluye "retorno de inversión" en subject', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
|
||||
expect(mockDoc.setProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ subject: expect.stringContaining('retorno') })
|
||||
)
|
||||
})
|
||||
|
||||
it('tab=roi: NO llama a addImage (el tab ROI no tiene heatmap)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
const fakeDataUrl = 'data:image/jpeg;base64,/9j/fake'
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: fakeDataUrl, tab: 'roi', activities: mockActivities })
|
||||
// El ROI PDF no embebe heatmap aunque se pase imageData
|
||||
expect(mockDoc.addImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tab=roi sin resultAutomated: no lanza error (usa result como fallback)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
const simSinAuto = { ...mockSimulation, resultAutomated: undefined }
|
||||
await expect(
|
||||
exportToPdf({ process: mockProcess, simulation: simSinAuto, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportToPdf — tab=actual', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('tab=actual: nombre de archivo termina en _actual.pdf', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'actual' })
|
||||
expect(mockDoc.save.mock.calls[0][0]).toMatch(/_actual\.pdf$/)
|
||||
})
|
||||
|
||||
it('tab=actual: contiene el proceso en el título (setProperties)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'actual' })
|
||||
expect(mockDoc.setProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: expect.stringContaining('Proceso de Aprobación de Crédito') })
|
||||
)
|
||||
})
|
||||
|
||||
it('tab=undefined (default): se comporta como actual', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
|
||||
expect(mockDoc.save.mock.calls[0][0]).toMatch(/_actual\.pdf$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportToPdf — tab=automatizado', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('tab=automatizado: nombre de archivo termina en _automatizado.pdf', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'automatizado' })
|
||||
expect(mockDoc.save.mock.calls[0][0]).toMatch(/_automatizado\.pdf$/)
|
||||
})
|
||||
|
||||
it('tab=automatizado: no lanza error', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await expect(
|
||||
exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'automatizado' })
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,7 @@ const mockProcess: Process = {
|
||||
clientName: 'Banco XYZ',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,3 +6,19 @@ import '@testing-library/jest-dom'
|
||||
if (typeof HTMLElement !== 'undefined') {
|
||||
HTMLElement.prototype.scrollIntoView = () => {}
|
||||
}
|
||||
|
||||
// jsdom no implementa ResizeObserver (usado por Radix UI Slider y otros)
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom no implementa PointerEvent (usado por Radix UI primitivos)
|
||||
if (typeof PointerEvent === 'undefined') {
|
||||
global.PointerEvent = class PointerEvent extends MouseEvent {
|
||||
constructor(type: string, init?: PointerEventInit) { super(type, init) }
|
||||
} as typeof PointerEvent
|
||||
}
|
||||
|
||||
136
tests/store/simulation-store-invalidation.test.ts
Normal file
136
tests/store/simulation-store-invalidation.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests de invalidación del simulation store.
|
||||
*
|
||||
* Regla dura del Sprint 1: cualquier mutación en Activity o Process invalida
|
||||
* AMBOS resultados (resultActual y resultAutomated) a null simultáneamente.
|
||||
* Nunca puede haber un resultado "nuevo" y el otro "viejo".
|
||||
*
|
||||
* Nota: los tests poblan el store con setState() directamente (sin Dexie)
|
||||
* porque prueban el comportamiento en memoria, no la persistencia.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import type { SimulationResult, Activity, Process } from '@/domain/types'
|
||||
|
||||
// Resultado mock mínimo para poblar el store sin Dexie
|
||||
function makeMockResult(totalCost = 1000): SimulationResult {
|
||||
return {
|
||||
totalCost, totalDirectCost: totalCost * 0.8, totalIndirectCost: totalCost * 0.2,
|
||||
totalTimeMinutes: 60, perActivity: [], perResource: [], warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
function setMockResults(actual = makeMockResult(), automated = makeMockResult(500)) {
|
||||
useSimulationStore.setState({
|
||||
resultActual: actual,
|
||||
resultAutomated: automated,
|
||||
lastSimulatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Resetear ambos stores antes de cada test para aislamiento
|
||||
useSimulationStore.getState().reset()
|
||||
useProcessStore.setState({
|
||||
currentProcess: null, activities: [], gateways: [], resources: [],
|
||||
isLoading: false, error: null,
|
||||
})
|
||||
})
|
||||
|
||||
// ─── invalidateResults() directa ─────────────────────────────────────────────
|
||||
|
||||
describe('simulation store — invalidateResults()', () => {
|
||||
it('pone resultActual y resultAutomated a null', () => {
|
||||
setMockResults()
|
||||
expect(useSimulationStore.getState().resultActual).not.toBeNull()
|
||||
expect(useSimulationStore.getState().resultAutomated).not.toBeNull()
|
||||
|
||||
useSimulationStore.getState().invalidateResults()
|
||||
|
||||
expect(useSimulationStore.getState().resultActual).toBeNull()
|
||||
expect(useSimulationStore.getState().resultAutomated).toBeNull()
|
||||
})
|
||||
|
||||
it('también limpia lastSimulatedAt', () => {
|
||||
setMockResults()
|
||||
expect(useSimulationStore.getState().lastSimulatedAt).not.toBeNull()
|
||||
|
||||
useSimulationStore.getState().invalidateResults()
|
||||
expect(useSimulationStore.getState().lastSimulatedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('setState directo establece resultados independientes para actual y automated', () => {
|
||||
const mockA = makeMockResult(1000)
|
||||
const mockB = makeMockResult(500)
|
||||
setMockResults(mockA, mockB)
|
||||
|
||||
const state = useSimulationStore.getState()
|
||||
expect(state.resultActual!.totalCost).toBe(1000)
|
||||
expect(state.resultAutomated!.totalCost).toBe(500)
|
||||
expect(state.lastSimulatedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Invalidación reactiva vía suscripción al process store ──────────────────
|
||||
|
||||
describe('simulation store — invalidación por cambio en process store', () => {
|
||||
it('cambio en activities invalida ambos resultados', () => {
|
||||
setMockResults()
|
||||
expect(useSimulationStore.getState().resultActual).not.toBeNull()
|
||||
|
||||
useProcessStore.setState({ activities: [] })
|
||||
|
||||
expect(useSimulationStore.getState().resultActual).toBeNull()
|
||||
expect(useSimulationStore.getState().resultAutomated).toBeNull()
|
||||
})
|
||||
|
||||
it('cambio en currentProcess invalida ambos resultados', () => {
|
||||
setMockResults()
|
||||
|
||||
const proc: Process = {
|
||||
id: 'new-proc', name: 'Nuevo', clientName: '', bpmnXml: '<def/>',
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
|
||||
createdAt: Date.now(), updatedAt: Date.now(),
|
||||
}
|
||||
useProcessStore.setState({ currentProcess: proc })
|
||||
|
||||
expect(useSimulationStore.getState().resultActual).toBeNull()
|
||||
expect(useSimulationStore.getState().resultAutomated).toBeNull()
|
||||
})
|
||||
|
||||
it('editar campo de actividad invalida ambos resultados', () => {
|
||||
// Test explícito pedido en el brief: simula lo que hace updateActivity
|
||||
setMockResults()
|
||||
|
||||
const newActivities: Activity[] = [{
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_1', name: 'Tarea',
|
||||
type: 'task',
|
||||
directCostFixed: 999, // campo modificado
|
||||
executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}]
|
||||
useProcessStore.setState({ activities: newActivities })
|
||||
|
||||
expect(useSimulationStore.getState().resultActual).toBeNull()
|
||||
expect(useSimulationStore.getState().resultAutomated).toBeNull()
|
||||
})
|
||||
|
||||
it('setState con las MISMAS activities (mismo reference) NO invalida', () => {
|
||||
const activities: Activity[] = [{
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T',
|
||||
type: 'task', directCostFixed: 100, executionTimeMinutes: 30,
|
||||
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}]
|
||||
|
||||
useProcessStore.setState({ activities })
|
||||
setMockResults()
|
||||
|
||||
// Misma referencia: no debe invalidar
|
||||
useProcessStore.setState({ activities })
|
||||
|
||||
expect(useSimulationStore.getState().resultActual).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user