feat(sprint-1.5): rediseño visual PDF ROI + identidad InQ (Etapas 4-7)

- Header/footer compartidos con paleta InQ naranja en los 3 PDFs (Etapa 4)
- Layout híbrido portrait+landscape en PDFs Actual/Automatizado:
  página 2 landscape para diagrama BPMN heatmap (Etapa 5)
- Página 1 PDF ROI rediseñada: header denso, banner narrativo, KPI Hero
  con gradiente naranja→magenta, grid 2×2 de KPIs secundarios, Top 3
  actividades por ahorro (Etapa 6)
- Páginas 2-5 PDF ROI con identidad InQ: tabla con paleta naranja,
  análisis del ahorro, nota metodológica, trayectoria del ahorro
  acumulado con gráfico SVG nativo (Etapa 7)
- 500 tests verdes (+ 34 tests nuevos del Sprint 1.5)
- BACKLOG.md, TECH_DEBT.md, OBSERVATIONS.md: sistema de planificación
  reemplaza TODO.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:45:01 -03:00
parent 63c4fbac99
commit 2f4633a33c
20 changed files with 4163 additions and 117 deletions

107
BACKLOG.md Normal file
View File

@@ -0,0 +1,107 @@
# BACKLOG — InQ ROI
> Features de producto pendientes. Decisión del director (qué entra, cuándo).
> No incluye deuda técnica ni hallazgos de sprint actual (ver TECH_DEBT.md y OBSERVATIONS.md).
---
## Convención de etiquetas
Cada ítem tiene una etiqueta de estado:
- `[SPRINT-2]` — Comprometido para el próximo sprint
- `[BACKLOG]` — Disponible para asignar en planning de sprint futuro
- `[FASE-2]` — Versión 2.0 del producto, más allá del horizonte actual
- `[DEFERRED]` — Pospuesto sin sprint asignado, requiere decisión
Al cerrar un sprint, los ítems `[SPRINT-N]` se promocionan a `[SPRINT-(N+1)]` o vuelven a `[BACKLOG]`.
---
## Features de alta prioridad
### [BACKLOG] Múltiples simulaciones por proceso
Historial de simulaciones con comparación de escenarios.
### [BACKLOG] Recursos con costo por hora
Link actividad ↔ recurso (rol, persona, sistema) con cálculo automático del costo agregado.
### [BACKLOG] Edición del nombre de proceso y cliente desde el workspace
Hoy requiere ir a configuración global. Mover edición inline al workspace.
### [BACKLOG] Gateway OR inclusivo nativo
Actualmente se simula como XOR. Implementar propagación OR real con probabilidades independientes.
### [BACKLOG] Distribuciones de probabilidad
Gamma/triangular por actividad en lugar de valores fijos. Permite análisis de sensibilidad.
---
## Features de media prioridad
### [BACKLOG] Export a Excel (.xlsx)
Múltiples hojas: resumen + por actividad + metodología. Útil para clientes que necesitan análisis adicional en planillas.
### [BACKLOG] Modo oscuro
Dark mode para el web app.
### [BACKLOG] i18n: inglés + español configurable
Necesario cuando InQuality empiece a vender fuera de LATAM.
### [BACKLOG] PWA installable
Manifest + service worker para uso offline.
### [BACKLOG] Zoom/pan del heatmap desde el reporte
Hoy es visualización estática. Permitir interacción.
### [BACKLOG] Detalle de recursos por actividad en el PDF
Tabla de costos por recurso asignado dentro de cada actividad.
### [BACKLOG] Importación por URL
Cargar BPMN desde enlace directo (GitHub, Confluence, etc.).
### [BACKLOG] 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".
---
## Features Fase 2 (versión 2.0 del producto)
### [FASE-2] Login y multi-tenant
Persistencia en la nube, colaboración entre múltiples usuarios.
### [FASE-2] Versionado de procesos
Historial de cambios con diff visual.
### [FASE-2] Gobernanza / RACI
Asignación de responsables por actividad (Responsible, Accountable, Consulted, Informed).
### [FASE-2] Jerarquía organizacional
Integración con orgchart para asignar costos por área de la empresa.
### [FASE-2] IA / asistente
Sugerencias de optimización, benchmarking automático.
### [FASE-2] Marketplace de procesos
Biblioteca de BPMNs precostos por industria.
### [FASE-2] Elementos RPA y agentes
Soporte para costos de bots y workflows automatizados.
### [FASE-2] Tutoriales interactivos
Onboarding guiado para usuarios nuevos.
---
## Decisiones de producto pendientes
### [DEFERRED] Footer "Hecho desde Paraguay 🇵🇾"
Mantener en web app o eliminar cuando InQuality venda a clientes internacionales no-LATAM.
**A favor:** identidad regional, toque humano, diferenciador.
**En contra:** sesgo gringo en clientes corporativos.
**Decisión pendiente para:** Sprint 3-4 con feedback de uso real en talleres y demos.
### [DEFERRED] Ícono de InQuality en header de PDFs
Requiere assets de marca consolidados que aún no existen. Re-evaluar cuando InQuality tenga manual de marca completo.

113
OBSERVATIONS.md Normal file
View File

@@ -0,0 +1,113 @@
# OBSERVATIONS — Sprint 1.5 (Rediseño visual)
> Hallazgos descubiertos durante validación visual del sprint actual.
> Este archivo se vacía al cerrar el sprint: los hallazgos se promocionan a TECH_DEBT.md o BACKLOG.md, o quedan resueltos.
---
## Convención de etiquetas
- `[CURRENT-SPRINT]` — Se resuelve en este sprint (Sprint 1.5)
- `[DEFERRED-TECH-DEBT]` — Se va a TECH_DEBT.md al cerrar el sprint
- `[DEFERRED-BACKLOG]` — Se va a BACKLOG.md al cerrar el sprint
- `[RESOLVED]` — Resuelto durante el sprint, archivar al cierre
---
## Hallazgos activos
### [CURRENT-SPRINT — POLISH FINAL] Refinamientos visuales página 1 PDF ROI
Detectados en validación visual de Etapa 6.
Se resuelven en polish final del sprint (Etapa 11).
**Lista de refinamientos:**
1. **KPI Hero**: spacing label-cifra, tamaño cifra, esquinas redondeadas
2. **Banner narrativo**: destacar cifras y keywords tipográficamente
3. **Grid 2x2 KPIs secundarios**: inconsistencia tipográfica entre cards
4. **Top 3 actividades**: título pequeño, barras sin contraste de fondo
**Decisión:** todos los refinamientos van a Etapa 11 (polish final), no se abordan ahora para mantener ritmo del sprint.
**Origen:** validación visual del director en cierre de Etapa 6.
---
## Hallazgos resueltos durante el sprint
### [RESOLVED] Carácter Unicode `↓` roto en card AHORRO POR EJECUCIÓN
**Síntoma:** mostraba `!"` en lugar de `↓`.
**Resolución:** reemplazado por texto plano en Etapa 6 (fix post-validación).
**Sprint cerrado en:** Sprint 1.5 Etapa 6.
### [RESOLVED] PDF generado del tab equivocado en E2E
**Síntoma:** test E2E generaba PDF del tab "Actual" en lugar de "Comparación + ROI".
**Resolución:** helper `setupRealisticScenario()` ahora navega al tab correcto antes del export.
**Sprint cerrado en:** Sprint 1.5 Etapa 2.
### [RESOLVED] Strings de marca "Process Cost Platform" en footers PDF
**Síntoma:** footers seguían diciendo "Process Cost Platform" en lugar de "InQ ROI · Powered by InQuality".
**Resolución:** strings reemplazados en pdf-export.ts y pdf-sections-roi.ts.
**Sprint cerrado en:** Sprint 1.5 Etapa 2 (adelanto de Etapa 4).
### [RESOLVED] Threshold "1.000,0%" con decimal en sección Transparencia
**Síntoma:** umbral de ROI inusual aparecía con decimal innecesario.
**Resolución:** usar `formatPercent(ROI_HIGH_THRESHOLD, 0)`.
**Sprint cerrado en:** Sprint 1.5 Etapa 2.
### [RESOLVED] Decisión proactiva "Hecho desde Paraguay 🇵🇾" sin consulta
**Síntoma:** Claude Code agregó texto al footer del web app sin reportar.
**Resolución:** mantener decisión (aprobada retroactivamente). El cambio de método mayor fue la creación del Patrón C.6 (iniciativa agéntica con consulta clasificada).
**Sprint cerrado en:** Sprint 1.5 Etapa 3.
### [RESOLVED] Compactación no autorizada de página 3 PDFs Actual/Automatizado
**Síntoma:** Claude Code fusionó "Análisis de composición + Tabla + Nota metodológica" en una sola página, truncando la nota metodológica.
**Resolución:** restauración de estructura 4 páginas con nota metodológica en página separada.
**Aprendizaje:** la decisión fue clasificada como Nivel 1 cuando era Nivel 2 mínimo. Reforzó el Patrón C.6 con criterios de clasificación explícitos.
**Sprint cerrado en:** Sprint 1.5 Etapa 5.
---
## Hallazgos diferidos (promocionar al cerrar sprint)
### [DEFERRED-TECH-DEBT] Fórmulas truncadas + Unicode roto en notas metodológicas
Ya migrado a `TECH_DEBT.md` sección "Deuda crítica". Se mantiene acá como referencia hasta el cierre del sprint.
**Plan:** Sprint 2, etapa dedicada a font embedding + text wrapping.
### [DEFERRED-BACKLOG] Footer "Hecho desde Paraguay 🇵🇾" — destino final
Ya migrado a `BACKLOG.md` sección "Decisiones de producto pendientes".
**Plan:** decidir en Sprint 3-4 con feedback real.
### [DEFERRED-BACKLOG] CumulativeSavingsChart web — empezar en -inversión
Ya migrado a `TECH_DEBT.md` sección "Refinamientos UX".
**Plan:** próxima etapa que toque el componente, o Sprint 2.
### [DEFERRED-BACKLOG] Label "Payback" superpuesto en CumulativeSavingsChart
Ya migrado a `TECH_DEBT.md` sección "Refinamientos UX".
**Plan:** próxima etapa que toque el componente.
---
## Notas operativas del sprint
- Los hallazgos resueltos quedan acá hasta el cierre del sprint. Al cerrar, se archiva el documento o se purga.
- Los hallazgos sin resolver se promocionan a `TECH_DEBT.md` o `BACKLOG.md` según corresponda.
- Este archivo NO contiene roadmap futuro ni deuda histórica — solo hallazgos del sprint en curso.
---
## Cierre del sprint (checklist)
Cuando Sprint 1.5 cierre, ejecutar:
```
[ ] Verificar que todos los items [CURRENT-SPRINT] están RESOLVED
[ ] Mover items [DEFERRED-TECH-DEBT] a TECH_DEBT.md (si no están ya)
[ ] Mover items [DEFERRED-BACKLOG] a BACKLOG.md (si no están ya)
[ ] Archivar o purgar este archivo
[ ] Crear OBSERVATIONS.md vacío para Sprint 2
```

166
TECH_DEBT.md Normal file
View File

@@ -0,0 +1,166 @@
# TECH_DEBT — InQ ROI
> Deuda técnica, refactors postergados y decisiones arquitectónicas pendientes.
> Requiere consulta entre director y técnico para resolver.
> No incluye features de producto (ver BACKLOG.md) ni hallazgos de sprint actual (ver OBSERVATIONS.md).
---
## Convención de etiquetas
- `[SPRINT-2]` — Comprometido para resolución en el próximo sprint
- `[BACKLOG]` — Disponible para asignar
- `[DEFERRED]` — Pospuesto, requiere decisión arquitectónica
- `[INVESTIGATING]` — Necesita análisis técnico antes de planear
Cada ítem incluye:
- Estado actual del problema
- Causa raíz hipotetizada
- Impacto si no se resuelve
- Solución propuesta
- Archivos afectados (cuando aplica)
---
## Deuda crítica — afecta calidad del entregable
### [SPRINT-2] Fórmulas truncadas + caracteres Unicode rotos en notas metodológicas de PDFs
**Estado:** las fórmulas matemáticas largas se cortan en el margen derecho. Los caracteres Σ (sumatoria) y (menos matemático) renderizan como £ (libra) y " (comilla) respectivamente.
**Ejemplo 1 — PDFs Actual/Automatizado, página 4:**
```
"Costo directo por actividad = (costo_fijo + £(recurso × costo_hora
× tiempo × utilización / 60)"
```
Faltan `))` y `× probabilidad_de_ejecución.`
**Ejemplo 2 — PDF ROI, página 4:**
```
"...Probabilidades calculada"
```
Falta `del grafo BPMN.`
**Causa raíz hipotetizada:**
- jsPDF con fuente WinAnsi por defecto no soporta esos puntos Unicode (Σ, )
- Falta wrapping automático de líneas largas
**Impacto:** afecta credibilidad del entregable. Cliente ve fórmula incompleta o caracteres extraños.
**Severidad:** alta — es deuda consciente aceptada como bug heredado durante Sprint 1.5.
**Solución propuesta para Sprint 2:**
- Etapa dedicada a "PDF: font embedding y text wrapping"
- Embeber Roboto Unicode completo con `doc.addFileToVFS()` + `doc.addFont()`
- Usar `doc.splitTextToSize()` para todas las notas metodológicas
- Tests que validen que ninguna línea supera el ancho disponible
**Workaround temporal (si surge demo o entrega urgente antes de Sprint 2):**
- Reformular fórmulas como multi-línea en el texto fuente
- Costo: 10-15 min de trabajo
- Soluciona el síntoma sin embeber fuente
**Archivos afectados:**
- `src/lib/export/pdf-sections.ts` (notas metodológicas Actual/Automatizado)
- `src/lib/export/pdf-sections-roi.ts` (nota metodológica ROI)
---
## Deuda media — afecta DX o evolución del producto
### [INVESTIGATING] Dexie live queries (sincronización store ↔ db)
**Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`.
**Riesgo a futuro:** si en Sprint 2 la grilla de edición masiva requiere escrituras de alta frecuencia directas a IndexedDB (por performance), la convención actual no escala.
**Solución a evaluar:** migración a live queries de Dexie que invaliden el store automáticamente.
**Acción pendiente:** análisis técnico de costo/beneficio cuando se planee la grilla de edición masiva.
**Origen:** auditoría de Mayo 2026.
### [BACKLOG] Indicador visual en reporte de actividades automatable
**Estado:** Sprint 1 usa solo leyenda de texto para diferenciar actividades automatables. **Acción:** evaluar si el cliente necesita más claridad visual (borde punteado o ícono diferenciador) cuando compara Actual vs Automatizado en reuniones. Decidir con feedback de uso real.
### [BACKLOG] Paleta del heatmap — saturado vs pastel
**Estado:** los colores saturados actuales (`#10B981`, `#F59E0B`, `#EF4444`) funcionan bien en pantallas grandes de talleres InQuality. El brief de Sprint 1.5 propuso pasteles "más consultivos" (`#BBF7D0`, `#FED7AA`, `#FECACA`).
**Decisión actual:** mantener saturados (Sprint 1.5 Etapa 1).
**Impacto del cambio futuro:** requiere recalibrar tests E2E de análisis de píxeles (umbrales de verde/ámbar/rojo).
**Acción:** decidir con feedback real de uso en talleres InQuality. Sprint 3-4.
---
## Refinamientos UX
### [BACKLOG] Mejorar animación de expansión de sección "Automatización" en ActivityPanel
**Estado:** actualmente usa max-h trick (funciona, pero la aceleración no es lineal en los últimos píxeles).
**Solución sofisticada:** `useRef + scrollHeight` para animar a la altura real del contenido.
**Costo:** medio. **Beneficio:** micro-pulido visual.
### [BACKLOG] Label "Payback" superpuesto con eje Y en CumulativeSavingsChart (web)
**Estado:** bug visual menor. El texto del marker vertical "Payback · X meses" se superpone con los labels del eje Y.
**Solución:** ajustar offset del label del marker payback.
**Costo:** 10 min en próxima etapa que toque el componente.
**Prioridad:** baja, no afecta funcionalidad.
### [BACKLOG] CumulativeSavingsChart — empezar en `-inversión`, no en 0 (web)
**Estado:** el gráfico web parte de `(Año 0, 0)`. **Decisión arquitectónica:** cambiar a `(Año 0, -inversión)` para ser financieramente honesto.
**Justificación:** mostrar el punto inicial como `-USD 80.000` hace visible el "valle de retorno". La línea cruza el cero exactamente en el mes de payback. Es la representación estándar en análisis financiero.
**Impacto:** consistencia con el chart del PDF (que ya se implementó así en Sprint 1.5 Etapa 7).
**Archivos:** `src/features/report/CumulativeSavingsChart.tsx`.
---
## Refinamientos visuales del PDF — derivados de validaciones de Sprint 1.5
> Estos hallazgos surgieron en validaciones visuales del director durante Sprint 1.5. Se difirieron a "polish final" del mismo sprint o a Sprint 2.
### [SPRINT-1.5 — POLISH FINAL] Refinamientos página 1 PDF ROI
**KPI Hero:**
- Spacing entre label "AHORRO PROYECTADO A 3 AÑOS" y la cifra
- Tamaño de la cifra (evaluar reducir ligeramente)
- Esquinas redondeadas (si se logra técnicamente — jsPDF no hace clip en addImage)
**Banner narrativo:**
- Destacar tipográficamente cifras y palabras clave (negrita en USD, %, meses)
- Considerar bullets o estructura más visual
**Grid 2x2 KPIs secundarios:**
- Inconsistencia tipográfica detectada — investigar y armonizar
- Verificar que los 4 cards sigan el mismo patrón visual
**Top 3 actividades:**
- Aumentar tamaño del título "TOP 3 ACTIVIDADES POR AHORRO"
- Agregar fondo claro a las barras para mejor contraste
- Reconsiderar diseño completo del bloque
---
## Decisiones arquitectónicas tomadas en Sprint 1.5 (cerradas, archivadas)
> Sección informativa. Estas decisiones ya están aplicadas en el código. Se mantienen acá como referencia.
### Subtítulo "Análisis de retorno de inversión" solo en página 1 ROI
Decidido: página 1 tiene subtítulo descriptivo, páginas 2-4 no. Patrón estándar de reportes financieros (página 1 con título extendido).
### KPI cards en página 1 de PDFs Actual/Automatizado — color naranja
Decidido: el KPI "Costo total" se migró a naranja durante refactor de Sprint 1.5. Antes era azul. Decisión retroactiva del audit.
### ComparisonTable — actividades no automatizables muestran em dash
Decidido: en filas donde `isAutomatable === false`, mostrar "—" (em dash) en columnas Ahorro y % Ahorro en lugar de "USD 0,00" y "0,0%". Aplicado en Sprint 1.5.
### Versión "v0.1.0" en footer — fuera de PDFs, presente en web
Decidido: la versión es útil para usuarios internos en web. En PDFs entregados a clientes genera dudas de estabilidad. Aplicado en Sprint 1.5.

91
TODO.md
View File

@@ -99,8 +99,97 @@
---
## Observaciones de Etapa 4 (Sprint 1.5)
### 1. Subtítulo "Análisis de retorno de inversión" solo en página 1 ROI
**Estado:** decisión visual aprobada. Página 1 tiene subtítulo descriptivo,
páginas 2-4 no.
**Acción:** evaluar en Etapa 6 si el subtítulo se refina o se mantiene.
### 2. Punto de partida del KPI card en página 1 de PDFs Actual/Automatizado
**Estado:** color del KPI "Costo total" migrado a naranja en Etapa 4
(decisión retroactiva del audit).
**Acción:** en Etapa 6, el rediseño del KPI card debe asumir punto de
partida naranja, no azul.
### 3. Unicode roto en notas metodológicas — investigar fuente
**Estado:** caracteres Σ (sumatoria), (menos matemático) renderizan
como £ (libra) y " (comilla) respectivamente. Pre-existente.
**Causa probable:** jsPDF con fuente WinAnsi no soporta esos puntos
Unicode.
**Solución estándar:** embeber fuente Unicode (Roboto o similar) con
doc.addFileToVFS() + doc.addFont().
**Acción:** Sprint 2 — etapa dedicada a font embedding en PDFs. NO
abordar en Sprint 1.5 (riesgo de regresión en este punto del sprint).
---
### Notas operativas
- Las 4 observaciones se descubrieron en validación visual del director (no en tests ni reporte automático).
- Refuerzan el valor del **Patrón C.1** (validación visual mandatoria del artefacto final).
- Punto 1 dio origen al **Patrón C.6** documentado en `methodology/` (iniciativa agéntica con consulta).
- Punto 1 dio origen al **Patrón C.6** documentado en `methodology/` (iniciativa agéntica con consulta).
### Sprint 2 — Nota metodológica de PDFs: fórmulas truncadas + Unicode roto
**Severidad:** afecta calidad del entregable al cliente. Mantenido como
deuda consciente, no como descuido.
**Síntomas observados en Sprint 1.5 Etapa 5:**
1. Fórmulas matemáticas largas se cortan en el margen derecho.
Ejemplo en PDFs Actual y Automatizado, página 4:
"Costo directo por actividad = (costo_fijo + £(recurso × costo_hora
× tiempo × utilización / 60)"
→ faltan "))" y "× probabilidad_de_ejecución."
Ejemplo en PDF ROI, página 4:
"...Probabilidades calculada" → falta "del grafo BPMN."
2. Caracteres Unicode renderizan mal:
Σ (sumatoria) → £ (libra)
(menos matemático) → "
**Causa raíz hipotetizada:**
- jsPDF con fuente WinAnsi no soporta esos puntos Unicode (caracteres rotos)
- Falta de wrapping automático de líneas largas en notas metodológicas
**Resolución propuesta para Sprint 2:**
- Etapa dedicada a "PDF: font embedding y text wrapping"
- Embeber Roboto Unicode completo (doc.addFileToVFS + doc.addFont)
- Usar doc.splitTextToSize() para todas las notas metodológicas
- Validar con tests que ninguna línea supera el ancho disponible
**Workaround temporal (si surge demo o entrega antes de Sprint 2):**
- Reformular fórmulas como multi-línea en el texto fuente
- 10-15 min de trabajo, soluciona el síntoma sin embeber fuente
**Archivos afectados:**
- src/lib/export/pdf-sections.ts (notas metodológicas Actual/Automatizado)
- src/lib/export/pdf-sections-roi.ts (nota metodológica ROI)
## Refinamientos visuales para Etapa 11 (polish final) — derivados de Etapa 6
### Página 1 del PDF ROI
1. **KPI Hero**:
- Spacing entre label "AHORRO PROYECTADO A 3 AÑOS" y la cifra
- Tamaño de la cifra (evaluar reducir ligeramente)
- Esquinas redondeadas (si se logra técnicamente)
2. **Banner narrativo**:
- Destacar tipográficamente cifras y palabras clave (negrita en USD, %, meses)
- Considerar bullets o estructura más visual
3. **Grid 2x2 KPIs secundarios**:
- Inconsistencia tipográfica detectada — investigar y armonizar
- Verificar que los 4 cards sigan el mismo patrón visual
4. **Top 3 actividades**:
- Aumentar tamaño del título "TOP 3 ACTIVIDADES POR AHORRO"
- Agregar fondo claro a las barras para mejor contraste
- Reconsiderar diseño completo del bloque
### Decisiones diferidas a Sprint 3-4
- Ícono de InQuality en header (requiere assets de marca)
- Esquinas redondeadas del KPI Hero (técnicamente complejo en jsPDF)

View File

@@ -88,6 +88,45 @@ Caso real (InQ ROI Sprint 1.5): el primer intento fue pegar el BRIEF.md completo
---
## AP.7 — Bypass total de permisos en desarrollo local
**Comportamiento:** usar `--dangerously-skip-permissions` (o equivalente) rutinariamente en máquinas de desarrollo donde corren proyectos reales. El motivo declarado suele ser "evitar la fricción de aprobar cada comando".
**Daño concreto:**
1. **Pérdida de auditoría en tiempo real:** cuando un agente ejecuta una acción no intencionada (borrar archivos, modificar git history, instalar dependencias rotas), el humano lo descubre por consecuencia, no por la pausa de aprobación.
2. **Datos perdidos en repos en evolución:** un `rm -rf` accidental sobre el directorio equivocado puede borrar trabajo no comiteado, configuraciones locales, o assets generados que no están en git.
3. **Confianza falsa en el agente:** el dev se acostumbra a "todo funciona" y deja de auditar. Cuando aparece un comando raro, el reflejo de pausar ya no existe.
4. **Imposibilidad de saber cuándo empezó un problema:** sin puntos de control, debuggear "¿cuándo pasó X?" requiere reconstruir desde logs en vez de tener una traza de aprobaciones.
**Caso real (no en este equipo, observado en otros proyectos):** un desarrollador con bypass total dejó al agente refactorizar carpetas con `mv` masivos. Una iteración movió archivos a una ruta equivocada por un typo del agente en la ruta destino. Sin permisos, los `mv` se ejecutaron en cadena. Recuperar tomó 4 horas porque los archivos no estaban en stash ni en commits.
**Cómo detectarlo:**
- El dev menciona "skipeo todos los permisos para ir más rápido"
- No existe `.claude/settings.json` ni equivalente en el repo
- El dev no sabe explicar qué hace `--dangerously-skip-permissions`
**Cómo evitarlo:**
- Aplicar **Patrón D.4** (permisos versionados en `.claude/`)
- Configurar `allow` para comandos rutinarios (que es lo que daba fricción)
- Configurar `deny` para comandos críticos (`rm -rf` con paths absolutos, `git push --force`, `npm publish`, `git reset --hard`)
- **Aceptar la fricción ocasional como precio de la gobernanza**
**Cuándo SÍ es aceptable usar bypass total:**
- Sandboxes efímeros (contenedores Docker que se destruyen post-ejecución)
- CI/CD donde no hay datos del dev
- Máquinas virtuales dedicadas para experimentación
**Cuándo NO es aceptable:**
- Máquina personal del dev con repos reales
- Cualquier entorno donde haya datos no replicables
- Cualquier entorno con credenciales o secretos
---
## Cómo agregar un anti-patrón
Solo se agregan anti-patrones con daño concreto observado, no especulativo. Estructura obligatoria:

View File

@@ -276,6 +276,52 @@ Las decisiones arquitectónicas importantes se registran como ADR (Architectural
---
### D.4 Permisos del agente versionados en `.claude/`
**Estado:** propuesto · **Deriva de:** Principios 4 y 5
Las decisiones sobre qué comandos puede ejecutar el agente sin consultar al humano se versionan en archivos de configuración del repo. Esto evita dos extremos: la fricción de aprobar cada comando manualmente, y el bypass total que abre la puerta a acciones no intencionadas.
**Estructura recomendada:**
```
repo/
└── .claude/
├── settings.json ← commitable: deny patterns críticos
└── settings.local.json ← gitignored: allow personales del dev
```
**Reglas:**
1. **`settings.json` es del proyecto** — contiene patrones `deny` (comandos peligrosos prohibidos para todos: `rm -rf /`, `git push --force`, `npm publish`, etc.). Se comitea al repo.
2. **`settings.local.json` es del desarrollador** — contiene patrones `allow` personales (cada uno decide qué comandos pre-aprueba). Va en `.gitignore`. Cuando un dev nuevo arranca, **debe construir su propio** archivo siguiendo el template, no copiar el de otro.
3. **El equipo hereda las protecciones, no las preferencias** — los `deny` son compartidos, los `allow` son individuales.
**Por qué:**
- La fricción de aprobar cada `npm install` destruye el flow de trabajo
- Pero el bypass total elimina los puntos de control donde se detectan acciones no intencionadas
- La gobernanza está en clasificar comandos por riesgo, no en aprobar todo o aprobar nada
**Indicador de aplicación correcta:**
- En cada sesión de Claude Code, los comandos rutinarios pasan sin pedir aprobación
- Los comandos no rutinarios siguen pidiendo aprobación (es bueno — ahí prestás atención)
- Si un comando peligroso aparece, está bloqueado por `deny` o pide aprobación explícita
**Indicador de violación:**
- El dev usa `--dangerously-skip-permissions` rutinariamente
- El archivo `settings.json` contiene `allow` que deberían ser personales (paga la complejidad de mantenerlo sincronizado entre devs)
- No hay archivo de configuración y cada dev aprueba todo manualmente cada vez (fricción crónica, señal de que falta este patrón)
**Template:** ver `methodology/TEMPLATES/CLAUDE_PERMISSIONS_TEMPLATE.md`.
**Relación con otros patrones:**
- Complementa **B.4** (reglas no negociables en CLAUDE.md) — pero B.4 es sobre comportamiento del agente, D.4 es sobre permisos del sistema operativo
- Refuerza **Principio 5** (humano director) — sin permisos configurados, el dev se vuelve operador de aprobación
**Casos reales que valida:**
- InQ ROI: el director identificó fatiga de aprobación después de ~30 etapas. Configurar permisos eliminó la fricción sin perder gobernanza.
## Categoría E — Comunicación humano ↔ agente
### E.1 Separar lo validado de lo asumido

View File

@@ -0,0 +1,193 @@
# Template — Configuración de permisos para Claude Code
> Setup recomendado para `.claude/settings.json` y `.claude/settings.local.json` en cualquier proyecto.
>
> Implementa el **Patrón D.4** (Permisos del agente versionados).
---
## Estructura final esperada en tu repo
```
repo/
├── .claude/
│ ├── settings.json ← versionado: solo deny patterns
│ └── settings.local.json ← gitignored: tus allow personales
└── .gitignore ← incluye .claude/settings.local.json
```
---
## Archivo 1: `.claude/settings.json` (versionado, compartido con el equipo)
Contiene **únicamente patrones `deny`**. Son las protecciones que aplicamos a todos.
```json
{
"permissions": {
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/*)",
"Bash(rm -rf .git)",
"Bash(rm -rf .git/*)",
"Bash(git push --force *)",
"Bash(git push -f *)",
"Bash(git reset --hard *)",
"Bash(git clean -fdx *)",
"Bash(npm publish *)",
"Bash(npm publish)"
]
}
}
```
**Si tu proyecto usa otras herramientas críticas, agregá los denies correspondientes.** Ejemplos:
- Si usás Postgres: `"Bash(dropdb *)"`, `"Bash(psql * -c \"DROP DATABASE *\")"`
- Si usás Docker: `"Bash(docker system prune *)"`
- Si usás Terraform: `"Bash(terraform destroy *)"`
- Si usás kubectl: `"Bash(kubectl delete namespace *)"`
---
## Archivo 2: `.claude/settings.local.json` (gitignored, personal)
Contiene **únicamente patrones `allow`**. Son los comandos que pre-aprobás para tu propio uso.
**Punto de partida recomendado (ajustá a tu workflow):**
```json
{
"permissions": {
"allow": [
"Bash(npm install)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(npm test)",
"Bash(npm test *)",
"Bash(npm run build)",
"Bash(npm run lint)",
"Bash(npm run dev)",
"Bash(npm run typecheck)",
"Bash(npx *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git tag *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git stash *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(grep *)",
"Bash(find *)",
"Bash(tar *)"
]
}
}
```
**Lo que NO incluí a propósito:**
- `Bash(rm *)``rm` está afuera porque incluso un `rm archivo.txt` puede ser destructivo si el archivo no estaba en git. Si querés permitirlo, ponelo explícito.
- `Bash(git push *)` — el push interactivo te da el momento para revisar qué estás subiendo
- `Bash(curl *)` y `Bash(wget *)` — afuera porque pueden bajar y ejecutar código de internet
- `Bash(chmod *)` y `Bash(chown *)` — cambios de permisos quedan fuera para no automatizar accidentalmente
Si alguno de estos te resulta valioso para tu flujo, agregalo conscientemente.
---
## Archivo 3: `.gitignore` (asegurate que esté)
```gitignore
# Claude Code local settings
.claude/settings.local.json
```
---
## Setup inicial (paso a paso)
Desde la raíz del repo:
```bash
# 1. Crear directorio
mkdir -p .claude
# 2. Crear settings.json (versionado)
cat > .claude/settings.json << 'EOF'
{
"permissions": {
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/*)",
"Bash(rm -rf .git)",
"Bash(rm -rf .git/*)",
"Bash(git push --force *)",
"Bash(git push -f *)",
"Bash(git reset --hard *)",
"Bash(git clean -fdx *)",
"Bash(npm publish *)",
"Bash(npm publish)"
]
}
}
EOF
# 3. Crear settings.local.json (personal, gitignored)
# [pegar contenido del Archivo 2 arriba]
# 4. Asegurar gitignore
grep "settings.local.json" .gitignore || echo ".claude/settings.local.json" >> .gitignore
# 5. Comitear solo el compartido
git add .claude/settings.json .gitignore
git commit -m "chore(claude-code): configurar permisos compartidos
- settings.json: deny patterns críticos
- settings.local.json en gitignore (cada dev configura sus allows)
Refs: methodology/PATTERNS.md D.4"
```
---
## Mantenimiento del archivo personal
**Cuando ver que aprobás repetidamente un comando que no está en la lista:**
1. Agregalo a `.claude/settings.local.json`
2. Reiniciá la sesión de Claude Code
**Cuando un comando de la lista te causó un problema:**
1. Sacalo de la lista
2. Aceptá la fricción ocasional como costo de la seguridad
**Revisión periódica recomendada:** cada 4-6 semanas, abrir `settings.local.json` y verificar:
- ¿Sigo usando todos los comandos de la lista?
- ¿Hay alguno que ya no uso y conviene sacar para reducir superficie?
---
## Cuando empieces a transmitirle esto al equipo
Para cada miembro nuevo del equipo:
1. Que clone el repo (recibe `.claude/settings.json` automáticamente)
2. Que cree su propio `.claude/settings.local.json` desde este template
3. Que **NO copie el de otro dev** — cada uno aprueba lo que usa, no lo que el otro usa
4. Verificar que entendió la diferencia entre `allow` (su decisión) y `deny` (decisión del equipo)
**Errores comunes a vigilar en miembros nuevos:**
- Copiar el `settings.local.json` de otro y agregarse cosas "por las dudas"
- Comitear accidentalmente el `settings.local.json` (verificar gitignore antes de su primer commit)
- Usar `--dangerously-skip-permissions` porque "es más rápido" (ver AP.7)

View File

@@ -0,0 +1,280 @@
# Sprint 1.5 — Etapa 5: PDF — Layout híbrido portrait + landscape
> **Objetivo:** cambiar la orientación de la página que contiene el diagrama BPMN (heatmap) a landscape, mientras el resto del PDF se mantiene en portrait. Aplica a los 3 PDFs (Actual, Automatizado, ROI).
>
> **Pre-requisitos:** Etapa 4 cerrada — header y footer compartidos, paleta InQ aplicada en headers/footers.
>
> **Etapa siguiente:** Etapa 6 — Rediseño completo de página 1 del PDF ROI (KPI Hero gradiente, top 3, header denso).
---
## 1. Por qué este cambio
El diagrama BPMN con heatmap es el elemento visual más importante del PDF. En layout vertical A4 (210mm de ancho útil), procesos con más de 7-8 actividades no caben legiblemente — el diagrama se renderiza chiquito y los nombres de las actividades se truncan.
En landscape A4 (297mm de ancho útil), el diagrama tiene 41% más espacio horizontal. Los procesos típicos de InQuality (10-15 actividades con gateways) se ven legibles. **Es la diferencia entre un PDF que se entiende y uno que se descarga y nadie lee.**
---
## 2. Alcance estricto de Etapa 5
### Sí entra
- ✅ La página del diagrama BPMN (heatmap) cambia de portrait a landscape en los 3 PDFs (Actual, Automatizado, ROI)
- ✅ Las demás páginas (resumen ejecutivo, tabla comparativa, metodología) se mantienen en portrait
- ✅ El header y footer compartidos (creados en Etapa 4) se adaptan al ancho de la página landscape
- ✅ El diagrama BPMN renderizado ocupa MÁS espacio horizontal (no queda con padding excesivo)
- ✅ Tests E2E verifican que cada PDF tiene la mezcla correcta de orientaciones
### NO entra
- ❌ Rediseño del contenido del diagrama (Etapa 6 o posterior)
- ❌ Cambio de la leyenda del heatmap (bajo costo / alto costo) — solo se adapta al ancho landscape
- ❌ Cambio de los KPI cards de la página 1 (Etapa 6)
- ❌ Cambio de la tabla comparativa (Etapa 7)
- ❌ Embebido de fonts Unicode (Sprint 2, anotado en TODO)
- ❌ Tocar el resto de páginas del PDF
---
## 3. Decisiones técnicas obligatorias
### 3.1 Método correcto para cambiar orientación en jsPDF
**SOLO usar este método** — los otros tienen bugs conocidos:
```typescript
// ✅ CORRECTO — usar el parámetro de orientación en addPage
doc.addPage('a4', 'landscape');
// Para volver a portrait después:
doc.addPage('a4', 'portrait');
```
**Métodos prohibidos:**
```typescript
// ❌ NO USAR — método deprecated y comportamiento inconsistente
doc.addPage([842, 595]); // dimensiones manuales sin orientación
// ❌ NO USAR — rota el ordering de páginas y autotable
doc.internal.pageSize.width = 842;
doc.internal.pageSize.height = 595;
// ❌ NO USAR — transform/rotate de contenido sobre página portrait
// "Rotar el contenido 90° en una página portrait" se ve mal y rompe
// el cálculo de posiciones de texto
```
### 3.2 Detección del ancho disponible en cada página
El módulo `pdf-sections-shared.ts` ya recibe el `doc` y debería usar:
```typescript
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
```
Esto retorna dinámicamente el ancho de la página ACTUAL (portrait o landscape). **NO hardcodear `595` (A4 portrait) ni `842` (A4 landscape).** Si encontrás esos números mágicos en el código, reemplazalos.
### 3.3 Generación del diagrama BPMN en landscape
El diagrama BPMN se renderiza con html2canvas → imagen JPEG embebida en el PDF. La función actual probablemente está calculando dimensiones basadas en página portrait. **Hay que ajustarla para landscape:**
```typescript
// Antes (asumiendo portrait):
const maxImageWidth = pageWidth - 2 * marginX; // ~170mm
const imageHeight = maxImageWidth * (canvas.height / canvas.width);
// Después (funciona en ambas orientaciones):
const pageWidth = doc.internal.pageSize.getWidth(); // dinámico
const pageHeight = doc.internal.pageSize.getHeight(); // dinámico
const usableWidth = pageWidth - 2 * marginX;
const usableHeight = pageHeight - headerHeight - footerHeight - 2 * marginY;
// Calcular escala que cabe en ambas dimensiones
const scaleX = usableWidth / canvas.width;
const scaleY = usableHeight / canvas.height;
const scale = Math.min(scaleX, scaleY);
const imageWidth = canvas.width * scale;
const imageHeight = canvas.height * scale;
```
Esto garantiza que el diagrama:
- Aprovecha el espacio horizontal en landscape
- No se sale de los márgenes
- Mantiene aspect ratio del canvas original
### 3.4 Páginas con qué orientación — orden y dimensiones
**PDF Actual y Automatizado (3 páginas):**
| Página | Contenido | Orientación |
|---|---|---|
| 1 | Resumen ejecutivo (KPI cards) | Portrait |
| 2 | Diagrama BPMN con heatmap | **Landscape** |
| 3 | Tabla de costos + nota metodológica | Portrait |
**PDF ROI (4 páginas):**
| Página | Contenido | Orientación |
|---|---|---|
| 1 | Resumen ejecutivo ROI (KPI hero + cards) | Portrait |
| 2 | Comparación por actividad (tabla) | Portrait |
| 3 | Composición + trayectoria + transparencia | Portrait |
| 4 | Nota metodológica | Portrait |
**Decisión arquitectónica:** el PDF ROI **NO incluye página con diagrama BPMN** en este sprint. El BPMN está en los PDFs Actual y Automatizado. Si en el futuro se decide incluir BPMN en ROI, se replica el patrón landscape.
### 3.5 Header y footer en página landscape
El header y footer compartidos ya están en `pdf-sections-shared.ts` (Etapa 4). Deben:
- Adaptarse al nuevo ancho automáticamente (porque usan `getWidth()`, no constantes)
- Mantener la misma estructura visual (logo izquierda, metadata derecha, border-bottom naranja)
- **NO requieren código nuevo si la implementación de Etapa 4 fue correcta**
Si necesitás modificar `drawSharedHeader` o `drawSharedFooter` para soportar landscape, **PARÁ y consultá**. Probablemente significa que la implementación de Etapa 4 tiene un hardcode que se nos pasó.
---
## 4. Validaciones automatizadas mandatorias
Antes de `present_files`, ejecutá y confirmá:
```
Para cada PDF generado:
1. Cantidad de páginas correcta:
- actual: 3 páginas
- automatizado: 3 páginas
- roi: 4 páginas
2. Orientación de cada página (usando pdf-parse o pdfjs-dist para detectar):
- actual: portrait, LANDSCAPE, portrait
- automatizado: portrait, LANDSCAPE, portrait
- roi: portrait, portrait, portrait, portrait
3. Header y footer presentes en TODAS las páginas (incluyendo landscape):
- "InQ ROI" en header de cada página
- "Powered by InQuality" en footer de cada página
- Paginación correcta "Página X de Y"
4. Análisis de píxeles del diagrama BPMN en página 2 (actual/automatizado):
- El ancho del diagrama supera 200mm (40% más que en portrait)
- Heatmap presente con verde + ámbar + rojo
- Texto de las actividades legible (no truncado a "...")
5. Tests previos verdes: >= 469
6. NO hay regresiones en marcas:
- "InQ ROI" presente en todas las páginas
- "Process Cost Platform" ausente
- "v0.1.0" ausente
```
Si CUALQUIER validación falla, NO uses `present_files`. Reportá qué falló y esperá instrucciones.
---
## 5. Entregables (Definition of Done)
```
[ ] Página BPMN del PDF Actual ahora en landscape
[ ] Página BPMN del PDF Automatizado ahora en landscape
[ ] PDF ROI mantiene las 4 páginas en portrait (no tiene página BPMN)
[ ] Header y footer compartidos funcionan correctamente en ambas
orientaciones SIN modificar pdf-sections-shared.ts
(si requirió modificación, justificá la causa)
[ ] Diagrama BPMN aprovecha el ancho landscape (mínimo 200mm de ancho)
[ ] 6 validaciones automatizadas confirmadas
[ ] PDFs presentados con present_files
[ ] Tests previos siguen verdes (469+)
[ ] Tests nuevos para verificar orientación de páginas (3-5 casos mínimo)
[ ] npm run build limpio
[ ] Decisiones tomadas por iniciativa documentadas en reporte (Patrón C.6)
```
---
## 6. Tests obligatorios
### 6.1 Tests unitarios
Crear `tests/lib/export/pdf-orientation.test.ts`:
```typescript
describe('PDF orientation', () => {
it('el PDF actual tiene mezcla portrait-landscape-portrait', async () => {
const pdf = await generateActualPdf(mockData);
const orientations = getPageOrientations(pdf);
expect(orientations).toEqual(['portrait', 'landscape', 'portrait']);
});
it('el PDF automatizado tiene mezcla portrait-landscape-portrait', async () => {
const pdf = await generateAutomatedPdf(mockData);
const orientations = getPageOrientations(pdf);
expect(orientations).toEqual(['portrait', 'landscape', 'portrait']);
});
it('el PDF ROI mantiene las 4 páginas en portrait', async () => {
const pdf = await generateRoiPdf(mockData);
const orientations = getPageOrientations(pdf);
expect(orientations).toEqual(['portrait', 'portrait', 'portrait', 'portrait']);
});
it('el diagrama BPMN en landscape ocupa más de 200mm de ancho', async () => {
const pdf = await generateActualPdf(mockData);
const bpmnImageWidth = getImageWidthOnPage(pdf, 2);
expect(bpmnImageWidth).toBeGreaterThan(200);
});
it('header funciona en página landscape sin modificación', async () => {
// verificar que drawSharedHeader se llama desde página landscape
// y produce output coherente (logo izquierda, fecha derecha)
});
});
```
---
## 7. Reporte final a entregar
Cuando termines, devolveme:
1. **Tabla de archivos modificados/creados** con líneas
2. **Resultado de las 6 validaciones automatizadas**
3. **Cantidad final de tests verdes** (esperado 474+)
4. **3 PDFs presentados con `present_files`** para validación visual
5. **Comparación de ancho del diagrama**: ¿antes (portrait) vs ahora (landscape)?
6. **Hallazgos no esperados**
7. **Decisiones tomadas por iniciativa** según política CLAUDE.md
NO arranques Etapa 6 sin mi OK explícito.
---
## 8. Reglas operativas (recordatorios)
1. **Usar SOLO `doc.addPage('a4', 'landscape')`**. Los otros métodos están prohibidos por gotchas conocidos.
2. **No hardcodear dimensiones de página**. Usar `doc.internal.pageSize.getWidth()` y `getHeight()` siempre.
3. **No modificar pdf-sections-shared.ts** salvo causa documentada. Si la implementación de Etapa 4 fue correcta, el header/footer deben funcionar en landscape sin tocar.
4. **No tocar contenido de páginas que NO son la BPMN**. El resto del PDF se mantiene visualmente igual.
5. **No racionalizar problemas de renderizado**. Si el diagrama se ve mal en landscape, investigar causa raíz (probablemente cálculo de escala).
6. **Política de iniciativa proactiva (CLAUDE.md)**:
- Cambios Nivel 1 (refactor interno, mejor naming): ejecutar
- Cambios Nivel 2 (visible pero reversible): ejecutar y reportar
- Cambios Nivel 3 (marca, arquitectura, datos): CONSULTAR antes
7. **Si encontrás contradicción** entre este prompt y la realidad del código: **PARÁ y consultá**.
---
## 9. Riesgo conocido — orientation switching en jsPDF
Tres bugs históricos en jsPDF al cambiar orientación entre páginas:
1. **Bug del autotable que se reinicia mal**: si la página landscape contiene una tabla autotable y la siguiente página vuelve a portrait, autotable puede continuar en el ancho de la landscape. **Solución**: la página landscape de esta etapa contiene solo el diagrama BPMN como imagen, no tablas. Si en el futuro hay una tabla en landscape, requiere atención adicional.
2. **Bug de imagen recortada al cambiar orientación**: si después de `addPage('a4', 'landscape')` no se recalcula el ancho disponible, la imagen se inserta con el ancho anterior (portrait) y se ve chiquita o se corta. **Solución**: siempre llamar `getWidth()` después de `addPage`.
3. **Footer absoluto en posición incorrecta**: si el footer se posiciona con `doc.internal.pageSize.height - 20`, en landscape eso es ~575px, pero la altura landscape A4 es ~595px. **Solución**: usar `getHeight()` dinámico, no asumir altura.
Si durante la implementación encontrás algún comportamiento extraño en jsPDF que no esté en esta lista, **investigá causa raíz antes de aplicar workaround**. Documentá el hallazgo en el reporte final.

View File

@@ -0,0 +1,480 @@
# Sprint 1.5 — Etapa 6: PDF ROI — Rediseño de página 1 con identidad InQ
> **Objetivo:** transformar la página 1 del PDF ROI en la "página de discusión" del reporte. Implementa KPI Hero con gradiente naranja → magenta, banner narrativo, KPIs secundarios con paleta InQ, y Top 3 actividades por ahorro. La trayectoria del ahorro se posterga a Etapa 7.
>
> **Pre-requisitos:** Etapas 1-5 cerradas. Módulo `pdf-sections-shared.ts` con header/footer compartidos. Layout híbrido funcionando en PDFs Actual/Automatizado.
>
> **Etapa siguiente:** Etapa 7 — PDF ROI páginas 2-5 (tabla con paleta, trayectoria del ahorro, transparencia rediseñada, nota metodológica).
---
## 1. Decisión arquitectónica de esta etapa
**El PDF ROI pasa de 4 páginas a 5 páginas.**
| Página | Contenido actual (Etapa 5) | Contenido nuevo (Etapa 6) |
|---|---|---|
| 1 | KPIs (header simple) | **Rediseño completo: header denso + banner + KPI Hero + 4 KPIs + Top 3** |
| 2 | Comparación por actividad | Comparación por actividad (sin cambios — Etapa 7) |
| 3 | Análisis del ahorro | Análisis del ahorro (sin cambios — Etapa 7) |
| 4 | Nota metodológica | Nota metodológica (sin cambios — Etapa 7) |
| 5 | — | NUEVA: trayectoria del ahorro acumulado (Etapa 7) |
**En Etapa 6 solo tocamos la página 1.** Las páginas 2-4 quedan iguales que en Etapa 5. La página 5 se agrega en Etapa 7.
---
## 2. Alcance estricto
### Sí entra
- ✅ Rediseño completo de la página 1 del PDF ROI con la estructura del brief sección 3.2
- ✅ Header denso de página 1 con metadata integrada (proceso, cliente, fecha)
- ✅ Banner narrativo con resumen ejecutivo en `#FFF8ED` y border-left naranja
- ✅ KPI Hero con gradiente naranja → magenta (cifra de Ahorro proyectado a N años)
- ✅ Grid 2x2 de KPIs secundarios con paleta `#FEF3E2` (Ahorro/ejecución, Ahorro anual, Payback, ROI anual)
- ✅ Top 3 actividades por ahorro con barras horizontales naranjas
- ✅ Footer estándar (sin cambios, ya creado en Etapa 4)
### NO entra
- ❌ Trayectoria del ahorro acumulado (gráfico SVG nativo) — Etapa 7
- ❌ Cambios en página 2 (Comparación por actividad) — Etapa 7
- ❌ Cambios en página 3 (Análisis del ahorro) — Etapa 7
- ❌ Cambios en página 4 (Nota metodológica) — Etapa 7
- ❌ Rediseño de PDFs Actual y Automatizado — Etapa 8
- ❌ Resolver fórmulas truncadas o Unicode roto — Sprint 2
---
## 3. Decisiones ya tomadas (no consultar de nuevo)
| Decisión | Justificación |
|---|---|
| Gradiente KPI Hero: `linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)` | Brief sección 2.2 |
| KPI Hero muestra el ahorro acumulado a N años (no por ejecución) | El número que importa al ejecutivo |
| KPIs secundarios: Ahorro/ejecución, Ahorro anual, Payback, ROI anual | Brief sección 3.2 |
| Top 3 con barras horizontales naranjas, sin grid pesado | Visual ligero, foco en magnitud relativa |
| Banner narrativo con `#FFF8ED` + border-left 3px `#F59845` | Brief sección 2.3 |
| Trayectoria del ahorro POSTERGADA a página 5 (Etapa 7) | Decisión Camino B de planificación previa |
| PDF ROI pasa a 5 páginas (con `Página X de 5` en footers) | Consecuencia del Camino B — se aplica desde Etapa 6 con placeholder en página 5 |
| Sin carátula | Brief sección 3.1 — info integrada en header denso |
---
## 4. Estructura visual de la página 1 (nueva)
Layout vertical de la página 1 (de arriba hacia abajo):
```
┌─────────────────────────────────────────────────────────┐
│ HEADER DENSO (15% del alto) │
│ │
│ InQ ROI UN PRODUCTO DE │
│ InQ │
│ ANÁLISIS DE RETORNO DE INVERSIÓN InQuality │
│ │
│ ───────────────────────────────────────────────── 3px │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ STRIP DE METADATA (5% del alto, fondo #FAFBFC) │
│ Proceso: medium-with-gateways | Cliente: — | Fecha │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ BANNER NARRATIVO (10% del alto, fondo #FFF8ED + border) │
│ ▌ Resumen ejecutivo: automatización de 3 actividades │
│ ▌ sobre proceso de 11 etapas. Frecuencia 5.000/año, │
│ ▌ horizonte 3 años, inversión USD 80.000. │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ KPI HERO (25% del alto, gradiente naranja → magenta) │
│ │
│ AHORRO PROYECTADO A 3 AÑOS │
│ USD 8.020.000 │
│ neto de inversión · 101× retorno │
│ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ GRID 2x2 KPIs SECUNDARIOS (20% del alto, fondo #FEF3E2)│
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AHORRO POR EJEC.│ │ AHORRO ANUAL │ │
│ │ USD 540 │ │ USD 2.700.000 │ │
│ │ ↓ 90% vs actual │ │ × 3 años │ │
│ └─────────────────┘ └─────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ PAYBACK │ │ ROI ANUAL │ │
│ │ < 1 mes │ │ 3.375% │ │
│ │ Dentro horizonte│ │ retorno anual │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ TOP 3 AHORROS (20% del alto) │
│ │
│ TOP 3 ACTIVIDADES POR AHORRO │
│ │
│ Analizar riesgo crediticio ████████████ USD 216 (40%) │
│ Recepcionar solicitud ████████████ USD 216 (40%) │
│ Calcular score crediticio ██████ USD 108 (20%) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ FOOTER (5% del alto) │
│ InQ ROI · Powered by InQuality · — · 17 may 2026 │
│ Página 1 de 5 │
└─────────────────────────────────────────────────────────┘
```
**Proporciones aproximadas:** header 15%, metadata 5%, banner 10%, hero 25%, grid 20%, top 3 20%, footer 5%.
Si en la práctica algún bloque no entra con esas proporciones, **PARÁ y consultá**. NO comprimas elementos arbitrariamente.
---
## 5. Especificación detallada por bloque
### 5.1 Header denso de página 1
```
Layout: flex justify-between, border-bottom 3px solid #F59845
Padding-bottom: 12mm
Margin-bottom: 14mm
Izquierda (60% ancho):
Línea 1: "InQ ROI"
- Tamaño: 22pt
- Peso: 700 (bold)
- Color: #F59845 (inq-orange)
- Tracking: -0.5pt
Línea 2: "ANÁLISIS DE RETORNO DE INVERSIÓN"
- Tamaño: 9pt
- Peso: 500
- Color: #94A3B8 (text-tertiary)
- Letter-spacing: 1.5pt
- Margin-top: 4mm
Derecha (40% ancho):
Línea 1: "UN PRODUCTO DE"
- Tamaño: 9pt
- Color: #94A3B8
- Letter-spacing: 1pt
- Align: right
Línea 2: "InQ"
- Tamaño: 16pt
- Peso: 700
- Color: #475569 (text-secondary)
- Align: right
Línea 3: "InQuality"
- Tamaño: 9pt
- Color: #64748B
- Align: right
```
**Importante:** "InQ" siempre I-n-Q (regla no negociable de marca).
### 5.2 Strip de metadata
```
Fondo: #FAFBFC
Padding: 8mm horizontal, 5mm vertical
Border-radius: 4mm
Layout: grid 3 columnas (proceso 2fr, cliente 1fr, fecha 1fr)
Margin-bottom: 8mm
Cada celda:
Label (top): 8pt, color #94A3B8, uppercase, letter-spacing 1pt
Valor (bottom): 11pt, peso 500, color #0F172A
```
**Datos a mostrar:**
- Proceso: nombre del proceso BPMN (ej. "medium-with-gateways")
- Cliente: "—" si no hay cliente configurado (se completa cuando agregemos campo cliente en Sprint 2)
- Fecha: fecha de simulación formato "17 mayo 2026"
### 5.3 Banner narrativo
```
Fondo: #FFF8ED (inq-orange-lighter)
Border-left: 3px solid #F59845
Padding: 10mm 12mm
Border-radius: 0 4mm 4mm 0
Margin-bottom: 10mm
Texto:
- Tamaño: 11pt
- Line-height: 1.55
- Color: #475569 (text-secondary)
- Peso: 400 (regular)
Contenido (plantilla):
"Análisis de retorno de inversión para automatización del proceso
[nombreProceso], con frecuencia anual de [N.NNN] ejecuciones,
horizonte de [N] años e inversión estimada de USD [N.NNN].
Resultado: ahorro de USD [N.NNN] por ejecución ([NN]% de reducción).
La inversión se recupera en [formatPayback], dentro del horizonte
de análisis."
```
Usar la función `formatPayback` ya implementada en Etapa 2.
### 5.4 KPI Hero (gradiente naranja → magenta)
```
Fondo: linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)
Padding: 16mm 18mm
Border-radius: 6mm
Margin-bottom: 10mm
Altura aproximada: 50mm
Label superior:
- Texto: "AHORRO PROYECTADO A [N] AÑOS"
- Tamaño: 10pt
- Peso: 500
- Color: white con opacity 0.9
- Letter-spacing: 1.2pt
- Margin-bottom: 6mm
Cifra principal:
- Texto: "USD 8.020.000" (formateado con formatCurrency)
- Tamaño: 30pt
- Peso: 700
- Color: white
- Tracking: -1pt
- Margin-bottom: 4mm
Subtítulo:
- Texto: "neto de inversión · [N.N]× retorno"
- Tamaño: 11pt
- Color: white con opacity 0.85
- Peso: 400
```
**Implementación técnica del gradiente en jsPDF:**
jsPDF NO soporta gradientes nativos. Hay dos opciones:
**Opción A — Renderizar como imagen embedida (recomendado):**
1. Crear un canvas en JS con el gradiente usando `CanvasRenderingContext2D.createLinearGradient()`
2. Dibujar texto sobre el canvas
3. Convertir canvas a base64 PNG
4. Embed en jsPDF con `doc.addImage()`
**Opción B — Simulación con múltiples rectángulos:**
- Dibujar ~50 rectángulos verticales de 4mm cada uno, cada uno con color interpolado
- Visualmente aceptable pero menos suave
- Más rápido de implementar pero menos calidad
**Elegí Opción A.** Justificación: el KPI Hero es el elemento visual más importante del PDF. Vale la pena el costo técnico de hacerlo bien. La Opción B se ve "computada", no "diseñada".
Si Opción A tiene complicación técnica que requiere más de 1 hora, **PARÁ y consultá**.
### 5.5 Grid 2x2 de KPIs secundarios
```
Layout: grid 2 columnas × 2 filas
Gap: 4mm horizontal, 4mm vertical
Margin-bottom: 10mm
Cada card:
Fondo: #FEF3E2 (inq-orange-light)
Padding: 8mm 10mm
Border-radius: 4mm
Label superior:
- Tamaño: 8pt
- Peso: 500
- Color: #92400E (inq-orange-dark)
- Letter-spacing: 1pt
- Uppercase
- Margin-bottom: 4mm
Cifra:
- Tamaño: 16pt
- Peso: 700 (bold para WCAG)
- Color: #0F172A (text-primary)
- Font-family: mono (para alineación de números)
Delta/contexto:
- Tamaño: 9pt
- Peso: 500
- Color: #B45309 (inq-orange-dark)
- Margin-top: 3mm
```
**Contenido de cada card:**
| Card | Label | Cifra | Delta/contexto |
|---|---|---|---|
| 1 | AHORRO POR EJECUCIÓN | `formatCurrency(savingsPerExec)` | `↓ {savingsPercent}% vs. actual` |
| 2 | AHORRO ANUAL | `formatCurrency(annualSavings)` | `× {horizonYears} años` |
| 3 | PAYBACK | `formatPayback(paybackMonths)` | `Dentro del horizonte` o `Fuera` |
| 4 | ROI ANUAL | `formatPercent(roi.annualizedRoi, 0)` | `retorno anual` |
### 5.6 Top 3 actividades por ahorro
```
Margin-bottom: 8mm
Título:
- Texto: "TOP 3 ACTIVIDADES POR AHORRO"
- Tamaño: 9pt
- Peso: 500
- Color: #94A3B8
- Letter-spacing: 1.2pt
- Uppercase
- Margin-bottom: 6mm
Cada fila (3 filas):
Layout: grid 3 columnas
Col 1 (40%): nombre actividad
Col 2 (40%): barra horizontal
Col 3 (20%): cifra + porcentaje
Margin-bottom: 4mm
Nombre actividad:
- Tamaño: 10pt
- Color: #0F172A
- Truncar a 32 caracteres con "..."
Barra horizontal:
- Altura: 6mm
- Fondo de la barra: #FED7AA (inq-orange-100)
- Fill: #F59845 (inq-orange)
- Ancho de fill: proporcional al ahorro (la actividad #1 tiene ancho completo, las demás proporcionales)
- Border-radius: 1mm
Cifra + porcentaje:
- Texto: "USD 216 (40%)"
- Tamaño: 10pt
- Peso: 500
- Color: #B45309
- Font-family: mono
- Align: right
```
**Cálculo del ancho de la barra:**
- Buscar el ahorro máximo entre las 3 actividades
- Ancho de cada barra = (ahorro / ahorro_max) × ancho_max_disponible
- Esto hace que la actividad #1 siempre tenga la barra completa, lo cual es intuitivo
---
## 6. Validaciones automatizadas mandatorias
Antes de `present_files`, ejecutá y confirmá:
```
PDF ROI debe cumplir:
1. Cantidad de páginas: 5 (era 4 en Etapa 5, ahora 5 con placeholder página 5)
NOTA: la página 5 va vacía o con texto "Trayectoria del ahorro
(rediseño en Etapa 7)". NO dejar página 4 sin nota metodológica.
2. Orientaciones: 5 páginas en portrait
3. Footers correctos:
- Página 1: "Página 1 de 5"
- Página 2: "Página 2 de 5"
- Página 3: "Página 3 de 5"
- Página 4: "Página 4 de 5"
- Página 5: "Página 5 de 5"
4. Página 1 contiene los siguientes elementos detectables:
- Texto "ANÁLISIS DE RETORNO DE INVERSIÓN" en header
- Texto "AHORRO PROYECTADO A 3 AÑOS" en KPI Hero
- Texto "TOP 3 ACTIVIDADES POR AHORRO"
- Al menos 3 nombres de actividades
- Análisis de píxeles confirma:
- Gradiente naranja → magenta (al menos 100 píxeles naranja Y al menos 50 píxeles magenta)
- Paleta naranja InQ en cards (al menos 500 píxeles #FEF3E2 o similar)
- Color blanco prominente en KPI Hero (texto sobre gradiente)
5. Tamaño de página 1: significativamente mayor que Etapa 5
(esperado 35-60 KB vs ~10 KB anterior, por imagen del gradiente embedida)
6. Validaciones globales sin regresión:
- "InQ ROI" presente
- "Powered by InQuality" presente
- "Process Cost Platform" ausente
- "v0.1.0" ausente
- "INQ", "Inq", "inq" (mayúsculas/minúsculas erróneas) ausentes
7. PDFs Actual y Automatizado: SIN CAMBIOS respecto a Etapa 5
- Sigue siendo 4 páginas cada uno
- Orientaciones: ['portrait', 'landscape', 'portrait', 'portrait']
- Tamaños similares a Etapa 5
8. Tests previos verdes: >= 476
```
Si CUALQUIER validación falla, NO uses `present_files`. Reportá qué falló.
---
## 7. Entregables (Definition of Done)
```
[ ] Página 1 del PDF ROI rediseñada según especificación de §4 y §5
[ ] PDF ROI tiene 5 páginas (página 5 con placeholder de trayectoria)
[ ] Footer de TODAS las páginas dice "Página X de 5"
[ ] KPI Hero con gradiente naranja → magenta visible y legible
[ ] Grid 2x2 con paleta naranja InQ aplicada
[ ] Top 3 con barras horizontales proporcionales al ahorro
[ ] Banner narrativo con border-left naranja
[ ] Header denso con metadata integrada
[ ] PDFs Actual y Automatizado NO modificados
[ ] 8 validaciones automatizadas confirmadas
[ ] PDFs presentados con present_files
[ ] Tests previos verdes (476+) sin regresiones
[ ] Tests nuevos para los componentes de página 1 ROI (al menos 4 casos)
[ ] npm run build limpio
[ ] Decisiones tomadas por iniciativa documentadas (Patrón C.6 —
con criterios explícitos: si cambia layout, paginación, o contenido
visible más allá del scope, es mínimo Nivel 2)
```
---
## 8. Reporte final a entregar
1. **Tabla de archivos modificados/creados**
2. **8 validaciones automatizadas confirmadas**
3. **Cantidad final de tests verdes**
4. **PDF ROI presentado con `present_files`** + PDFs Actual y Automatizado para confirmar no-regresión
5. **Captura específica de página 1 del ROI**: detalle visual del KPI Hero y Top 3 si es posible
6. **Hallazgos no esperados**
7. **Decisiones tomadas por iniciativa** según política CLAUDE.md, con clasificación explícita por nivel
---
## 9. Reglas operativas
1. **Solo tocar página 1 del PDF ROI.** Las otras 3 páginas existentes quedan iguales. La página 5 es placeholder.
2. **NO tocar PDFs Actual y Automatizado.** El alcance de Etapa 6 es solo el PDF ROI.
3. **NO resolver fórmulas truncadas ni Unicode.** Esos hallazgos están en TODO para Sprint 2. Si te tienta arreglarlos, pará — no es Etapa 6.
4. **Si el gradiente requiere más de 1 hora de implementación técnica, consultá.** No quemes 3 horas peleándote con canvas → PNG → addImage. Si bloqueás, presentá alternativas.
5. **Política de iniciativa proactiva (CLAUDE.md), con criterios explícitos refinados después de Etapa 5:**
- Si el cambio modifica cantidad de páginas, layout, o contenido visible más allá del scope: mínimo Nivel 2
- Si choca con una restricción explícita del prompt (ej. "NO entra"): Nivel 3 — consultar antes
- Si afecta marca, datos, dependencias o arquitectura: Nivel 3 — consultar antes
- Si nada de lo anterior: Nivel 1 — ejecutar y mencionar al final si fueron varios cambios acumulados
6. **Si encontrás contradicción** entre prompt y código: PARÁ y consultá.
---
## 10. Esta es la etapa visualmente más importante del Sprint
La página 1 del PDF ROI es **el primer artefacto que un cliente de InQuality va a ver del producto en una reunión**. Es la "página de discusión" del reporte. Tiene que verse profesional, identitaria, y comunicar el valor en 5 segundos.
Si algo de la especificación visual no te queda claro, **es mejor consultar que improvisar**. El brief y los mockups previos son ground truth — no inventes interpretaciones nuevas.
Cuando termines, validá vos mismo abriendo el PDF en Preview antes de declarar la etapa terminada. Si vos no pondrías eso al frente de un cliente bancario, retrabaja antes de present_files.

View File

@@ -0,0 +1,452 @@
# Sprint 1.5 — Etapa 7: PDF ROI — Páginas 2-5 con identidad InQ
> **Objetivo:** completar el rediseño del PDF ROI aplicando identidad InQ a las páginas 2, 3 y 4. Implementar la trayectoria del ahorro acumulado en la página 5 (que en Etapa 6 quedó como placeholder).
>
> **Pre-requisitos:** Etapa 6 cerrada — Página 1 del PDF ROI con KPI Hero gradiente, banner narrativo, grid 2x2, Top 3. PDF ROI con 5 páginas (página 5 placeholder).
>
> **Etapa siguiente:** Etapa 8 — PDFs Actual y Automatizado con identidad InQ aplicada (rediseño consistente con el ROI).
---
## 1. Alcance estricto
### Sí entra
- ✅ Página 2 (Comparación por actividad) — aplicar paleta naranja a tabla, mantener estructura
- ✅ Página 3 (Análisis del ahorro + Transparencia metodológica) — rediseñar con paleta InQ
- ✅ Página 4 (Nota metodológica) — paleta InQ aplicada
- ✅ Página 5 (NUEVA — Trayectoria del ahorro acumulado) — gráfico SVG nativo en jsPDF (sin html2canvas)
- ✅ Mantener consistencia visual con la página 1 ya rediseñada
### NO entra
- ❌ Modificar la página 1 (cerrada en Etapa 6)
- ❌ Refinamientos visuales del KPI Hero, Top 3, banner narrativo (agendados para Etapa 11 — polish final)
- ❌ Resolver fórmulas truncadas en nota metodológica (Sprint 2 — font embedding)
- ❌ Resolver caracteres Unicode rotos en general (Sprint 2)
- ❌ Modificar PDFs Actual y Automatizado (Etapa 8)
---
## 2. Decisiones ya tomadas (no consultar de nuevo)
| Decisión | Justificación |
|---|---|
| Trayectoria del ahorro: SVG nativo en jsPDF (no html2canvas) | Brief sección 3.2 — html2canvas borrosea el chart |
| Trayectoria empieza en (Año 0, -inversión), no en (0, 0) | Decisión Etapa 3 — TODO.md |
| Tabla comparativa con header naranja `#FFF8ED` + border-bottom `#F59845` | Brief sección 2.5 |
| Sección Transparencia con fondo `#FFFBEB` + border `#F59E0B` | Brief sección 2.4 (warning) |
| PDF ROI mantiene 5 páginas | Decisión Etapa 6 |
| Nota metodológica con caracteres Unicode rotos se mantiene | Sprint 2 |
---
## 3. Especificación detallada por página
### 3.1 Página 2 — Comparación por actividad
**Estado actual (post-Etapa 6):**
- Header denso ✓
- Tabla con `Actividad / Act. (USD) / Auto. (USD) / Ahorro (USD) / Ahorro % / Prob.`
- Texto "(*) Actividades en negrita están marcadas como automatizables"
- Footer "Página 2 de 5"
**Cambios en Etapa 7:**
```
TABLA (autotable):
Header row:
- Fondo: #FFF8ED (inq-orange-lighter)
- Border-bottom: 1.5pt solid #F59845
- Padding: 8pt 6pt
- Texto: 10pt / peso 500 / color #92400E (inq-orange-dark)
- Letter-spacing: 0.5pt
- Uppercase
Body row (default / actividad automatizable):
- Fondo: white
- Border-bottom: 0.5pt solid #E2E8F0
- Padding: 7pt 6pt
- Texto: 10pt / peso 400 / color #0F172A
- Números: font mono, color #0F172A
Body row (actividad NO automatizable):
- Fondo: #F8FAFC (slate-50)
- Texto: color #94A3B8 (más tenue)
- Em dash "—" en columnas Ahorro y Ahorro %
Total row (si aplica):
- Fondo: #FFF8ED
- Border-top: 1.5pt solid #F59845
- Texto: 10pt / peso 500 / color #0F172A
```
**Mantener el texto "(*) Actividades en negrita..."** con el formato actual.
### 3.2 Página 3 — Análisis del ahorro + Transparencia
**Estado actual (post-Etapa 6):**
- Título "Análisis del ahorro"
- Sección "Composición del ahorro" con 3 bullets de actividades
- Sección "Trayectoria del ahorro acumulado" (texto narrativo)
- Sección "Transparencia metodológica" con caja ámbar (cuando aplica)
- Footer "Página 3 de 5"
**Cambios en Etapa 7:**
**Sección "Composición del ahorro":**
```
Título:
- Texto: "COMPOSICIÓN DEL AHORRO"
- Tamaño: 11pt
- Peso: 500
- Color: #92400E (inq-orange-dark)
- Letter-spacing: 1pt
- Uppercase
- Margin-bottom: 8mm
Texto descriptivo:
- Tamaño: 11pt
- Color: #0F172A
- Line-height: 1.5
- Margin-bottom: 6mm
Bullets:
- Bullet symbol: "•"
- Texto: 10pt / color #475569
- Línea: " • [actividad]: USD [N] ([NN]% del ahorro total)"
- Margin-bottom: 3mm cada uno
```
**Sección "Trayectoria del ahorro acumulado" (texto):**
```
Mantener el bloque narrativo actual. Aplicar paleta:
- Título: 11pt / peso 500 / color #92400E / uppercase
- Texto: 11pt / color #475569 / line-height 1.5
NOTA: el gráfico de la trayectoria va en página 5, no acá. En esta
página solo el texto narrativo descriptivo.
```
**Sección "Transparencia metodológica" (cuando aplica):**
```
Caja:
- Fondo: #FFFBEB (warning-bg)
- Border: 0.5pt solid #F59E0B (warning)
- Padding: 12pt 14pt
- Border-radius: 6pt
- Margin-top: 10mm
Título:
- Texto: "⚠ Transparencia metodológica"
- Tamaño: 11pt
- Peso: 500
- Color: #92400E
- NOTA: el ⚠ (U+26A0) puede no renderizar. Si no funciona, usar
texto plano "[!]" o "Transparencia metodológica" sin símbolo.
Texto:
- Tamaño: 10pt
- Peso: 400
- Color: #78350F (text on warning)
- Line-height: 1.6
Bullets de casos (cuando ROI > 1000%):
- Frecuencia anual del proceso sobreestimada
- Costo automatizado por ejecución subestimado
- Inversión en automatización subestimada
Formato:
- "• [caso]" con bullet symbol •
- Margin-bottom 3mm
```
### 3.3 Página 4 — Nota metodológica
**Estado actual (post-Etapa 6):**
- Sigue con caracteres `£` (Σ) y `"` () rotos. Decisión Sprint 2.
**Cambios en Etapa 7:**
```
Título:
- Texto: "NOTA METODOLÓGICA — ANÁLISIS DE ROI"
- Tamaño: 11pt
- Peso: 500
- Color: #92400E (inq-orange-dark)
- Letter-spacing: 1pt
- Uppercase
- Margin-bottom: 10mm
Caja contenedora (opcional, sutil):
- Fondo: #F8FAFC (slate-50)
- Padding: 14pt
- Border-radius: 4pt
- Border-left: 2pt solid #94A3B8 (text-tertiary)
Cada fórmula (5-6 bloques):
Sub-título:
- Tamaño: 10pt
- Peso: 600
- Color: #0F172A
- Margin-bottom: 2mm
Fórmula:
- Tamaño: 10pt
- Font-family: mono (para alineación)
- Color: #475569
- Line-height: 1.5
- Margin-bottom: 6mm
```
**Mantener el contenido textual actual:**
- Costo por ejecución actual
- Costo por ejecución automatizado
- Ahorro anual
- Payback (meses)
- Ahorro acumulado
- ROI anualizado
### 3.4 Página 5 — Trayectoria del ahorro acumulado (NUEVA)
Esta es la página técnicamente más compleja de Etapa 7. Reemplaza el placeholder de Etapa 6.
**Layout vertical de la página 5:**
```
┌─────────────────────────────────────────────────────────┐
│ HEADER ESTÁNDAR │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ TÍTULO: TRAYECTORIA DEL AHORRO ACUMULADO │
│ │
│ Subtítulo narrativo (1-2 líneas): │
│ "Evolución del ahorro neto acumulado a lo largo del │
│ horizonte de [N] años. El payback se alcanza en el │
│ mes [N]." │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ GRÁFICO SVG NATIVO (60-70% del alto disponible) │
│ │
│ Eje Y (USD acumulado): │
│ - 0 al medio │
│ - Rango: de -inversion a +max_acumulado │
│ │
│ Eje X (tiempo en meses o años): │
│ - 0 a horizon_months │
│ - Labels: "Año 0", "Año 1", "Año 2", "Año 3" │
│ │
│ Curva: │
│ - Inicio: (Año 0, -inversion) → punto rojo │
│ - Cruza el cero en payback_month → marker │
│ - Final: (Año N, ahorro_acumulado_final) │
│ │
│ Área NEGATIVA (antes del payback): │
│ - Fill: #FEE2E2 (light red) │
│ │
│ Área POSITIVA (después del payback): │
│ - Fill: #FED7AA (light orange) │
│ │
│ Línea principal: │
│ - Color: #F59845 (inq-orange) │
│ - Grosor: 1.5pt │
│ │
│ Marker vertical en payback: │
│ - Línea vertical punteada, color #B45309 │
│ - Label "Payback: [N.N] meses" (offset 4mm a la │
│ derecha del marker para no superponer eje Y) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ANOTACIONES (al pie del gráfico): │
│ - Inversión inicial: USD [N] │
│ - Ahorro acumulado al final: USD [N] │
│ - Multiplicador de retorno: [N.N]× la inversión │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ FOOTER: Página 5 de 5 │
└─────────────────────────────────────────────────────────┘
```
**Implementación técnica del gráfico SVG nativo:**
Usar primitivas de jsPDF directamente (`doc.line`, `doc.rect`, `doc.text`, `doc.setFillColor`, `doc.setDrawColor`). **NO usar html2canvas** — el chart se ve borroso con conversión a imagen.
Pseudocódigo:
```typescript
function drawTrajectoryChart(doc, config) {
const { x, y, width, height, data, paybackMonth, currency } = config;
// 1. Dibujar ejes
const yZero = y + (height * (data.maxNegative / (data.maxPositive - data.maxNegative)));
doc.setDrawColor(148, 163, 184); // text-tertiary
doc.line(x, yZero, x + width, yZero); // eje X (en y=0)
doc.line(x, y, x, y + height); // eje Y
// 2. Dibujar área negativa (antes del payback)
doc.setFillColor(254, 226, 226); // #FEE2E2
// ... path complejo
// 3. Dibujar área positiva (después del payback)
doc.setFillColor(254, 215, 170); // #FED7AA
// ... path complejo
// 4. Dibujar línea principal
doc.setDrawColor(245, 152, 69); // #F59845
doc.setLineWidth(1.5);
// doc.lines() para path de la línea
// 5. Dibujar marker de payback
doc.setDrawColor(180, 83, 9); // #B45309
doc.setLineDashPattern([2, 2], 0);
// línea vertical en x = payback
// 6. Labels de ejes
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
// labels "Año 0", "Año 1", etc.
// 7. Label del payback (offset a la derecha)
doc.text("Payback: " + formatPayback(paybackMonth),
x_payback + 4, y_payback);
}
```
**Datos para construir el gráfico:**
```typescript
// Cálculo de la trayectoria
const monthsArray = Array.from({length: horizonMonths + 1}, (_, i) => i);
const trajectoryData = monthsArray.map(month => ({
month,
acumulado: -inversion + (annualSavings / 12) * month
}));
// Encontrar el mes exacto del payback (cuando acumulado cruza 0)
const paybackMonth = inversion / (annualSavings / 12);
```
---
## 4. Validaciones automatizadas mandatorias
```
PDF ROI debe cumplir:
1. 5 páginas, todas portrait
2. Página 5 ahora tiene contenido real (NO placeholder):
- Título "TRAYECTORIA DEL AHORRO ACUMULADO" presente
- Gráfico SVG embebido (detectado por análisis de píxeles: presencia
de naranja #F59845 + áreas rojas/naranjas claras)
- Anotaciones al pie con cifras
3. Página 2 (Tabla):
- Header de tabla con fondo naranja claro (#FFF8ED visible en análisis)
- Border-bottom naranja en header
- Em dash "—" en filas no automatizables (Ahorro y Ahorro %)
4. Página 3 (Análisis + Transparencia):
- Título "COMPOSICIÓN DEL AHORRO" en naranja oscuro
- Caja de Transparencia presente cuando ROI > 1000% (fondo ámbar)
- Bullets de casos (cuando aplica)
5. Página 4 (Nota metodológica):
- Título "NOTA METODOLÓGICA — ANÁLISIS DE ROI" en naranja oscuro
- 5-6 sub-secciones con fórmulas
- Caracteres £ y " todavía rotos (Sprint 2) — esperado
6. Sin regresiones:
- Página 1 SIN CAMBIOS visualmente
- Footers correctos en todas las páginas (1, 2, 3, 4, 5 de 5)
- "InQ ROI", "Powered by InQuality" presentes
- "Process Cost Platform" ausente
7. PDFs Actual y Automatizado: idénticos a Etapa 6
8. Tests previos verdes: ≥ 483 + nuevos de Etapa 7
```
---
## 5. Entregables (Definition of Done)
```
[ ] Página 2 (Tabla) con paleta InQ aplicada
[ ] Página 3 (Análisis + Transparencia) con paleta InQ aplicada
[ ] Página 4 (Nota metodológica) con paleta InQ aplicada
[ ] Página 5 con gráfico SVG nativo de trayectoria del ahorro
[ ] Trayectoria empieza en (Año 0, -inversión), cruza 0 en payback,
termina en (Año N, ahorro_acumulado_final)
[ ] Áreas roja y naranja diferenciadas (antes/después del payback)
[ ] Marker vertical en payback con label "Payback: N.N meses"
[ ] Anotaciones al pie del gráfico (inversión, acumulado, multiplicador)
[ ] Página 1 NO MODIFICADA
[ ] PDFs Actual y Automatizado NO MODIFICADOS
[ ] 8 validaciones automatizadas confirmadas
[ ] Tests previos verdes + nuevos tests del gráfico SVG (al menos 4 casos)
[ ] npm run build limpio
[ ] PDF presentado para validación visual
[ ] Decisiones por iniciativa documentadas según política CLAUDE.md
```
---
## 6. Reporte final a entregar
1. Tabla de archivos modificados/creados
2. 8 validaciones automatizadas confirmadas (resultado de cada una)
3. Cantidad final de tests verdes
4. **PDF ROI con paths absolutos del filesystem para que el usuario abra en Finder**
- NO usar enlaces vscode-webview, que no funcionan fuera de VS Code
- Path tipo `/Users/marcos/.../tests/e2e/__output__/etapa-7-roi.pdf`
5. PDFs Actual y Automatizado regenerados (para confirmar no-regresión)
6. **Detalle del gráfico SVG de trayectoria:**
- Coordenadas del punto de inicio (debería ser y < 0)
- Coordenadas del payback (debería ser y = 0)
- Coordenadas del final (debería ser y > 0)
- Esto valida que la curva está bien construida matemáticamente
7. Hallazgos no esperados
8. Decisiones tomadas por iniciativa (con clasificación según niveles)
---
## 7. Reglas operativas
1. **NO TOCAR LA PÁGINA 1.** Cerrada en Etapa 6. Cualquier cambio visual ahí es Nivel 3 (consultar previa). Refinamientos están agendados para Etapa 11.
2. **NO TOCAR PDFs Actual y Automatizado.** Etapa 8 los rediseña.
3. **NO ARREGLAR FÓRMULAS TRUNCADAS O UNICODE.** Sprint 2 — font embedding. Si te tienta, pará.
4. **Para el gráfico SVG, usar SOLO primitivas de jsPDF.** Sin html2canvas, sin canvas, sin librerías externas. El chart se construye con `doc.line`, `doc.rect`, `doc.text`.
5. **Política de iniciativa proactiva refinada (de Etapa 5):**
- Cualquier cambio que afecte layout, paginación, o contenido visible más allá del scope: mínimo Nivel 2 (ejecutar y reportar)
- Cambio que choca con restricción explícita del prompt: Nivel 3 (consultar previo)
- Cambio en página 1 o en PDFs no-ROI: SIEMPRE Nivel 3 en esta etapa
6. **Si la implementación del gráfico SVG nativo se complica más de 2 horas, consultá.** Hay caminos intermedios (chart como imagen pre-renderizada del web app, etc.) que podemos evaluar.
7. **Si encontrás contradicción** entre prompt y código: PARÁ y consultá.
---
## 8. Por qué esta etapa importa
La página 5 es donde el reporte demuestra **rigor técnico**. El cliente que abra el PDF en una reunión va a ver:
- Página 1: el resumen ejecutivo con el número grande (KPI Hero)
- Página 5: la curva financiera que justifica ese número
Sin la página 5, el KPI Hero es "te creo o no". Con la página 5, es **evidencia visual** de cómo se construye el ahorro a lo largo del tiempo. Es la diferencia entre vendedor y consultor.
Si la curva queda mal, el reporte pierde credibilidad. Si queda bien, vale como diferenciador frente a competencia.
Apuntá alto.

View File

@@ -1,12 +1,13 @@
import { buildFileName } from './slug'
import { formatPayback } from '@/lib/format'
import { calculateRoi } from '@/domain/roi'
import {
drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, PDF,
} from './pdf-sections'
import {
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection,
drawRoiPage1, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection, drawTrajectoryChart,
} from './pdf-sections-roi'
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
@@ -72,24 +73,25 @@ async function exportScenarioPdf(
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
// Página 1
// ── Página 1 (portrait): KPI cards ──────────────────────────────────────────
let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 })
y = drawKpiCards(docAny, result, currency, y)
y += 4
// ── Página 2 (landscape): Diagrama BPMN con heatmap ─────────────────────────
// Método obligatorio: doc.addPage('a4', 'landscape') — ver ETAPA_5_PROMPT §3.1
docAny.addPage('a4', 'landscape')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
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(docAny, heatmapImageData, y)
y = drawHeatmapLegend(docAny, y)
y += 4
// Página 2 — análisis + tabla de costos
docAny.addPage()
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
// ── Página 3 (portrait): Análisis + tabla + metodología ──────────────────────
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
y = drawAnalysisSection(docAny, result, currency, y)
y += 6
@@ -127,14 +129,15 @@ async function exportScenarioPdf(
columnStyles: { 0: { cellWidth: 48 } },
})
// Página 3 — nota metodológica
docAny.addPage()
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
// ── Página 4 (portrait): Nota metodológica ────────────────────────────────────
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
drawMethodologySection(docAny, process, y)
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
}
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
// ─── ROI — 5 páginas (Etapa 6) ───────────────────────────────────────────────
async function exportRoiPdf(
doc: any,
@@ -166,14 +169,12 @@ async function exportRoiPdf(
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 1 })
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
// ── Página 1: Rediseño completo InQ (Etapa 6) ───────────────────────────────
drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency)
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
doc.addPage()
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
@@ -206,6 +207,30 @@ async function exportRoiPdf(
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 4 })
drawRoiMethodologySection(doc, process, y)
// ── Página 5: Trayectoria del ahorro acumulado (gráfico SVG nativo) ──────────
doc.addPage()
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 5 })
// Título y subtítulo de la página
doc.setFont('helvetica', 'bold')
doc.setFontSize(11)
doc.setTextColor(146, 64, 14) // #92400E naranja oscuro
doc.text('TRAYECTORIA DEL AHORRO ACUMULADO', PDF.margin, y)
y += 6
const subtitleText = roi.breaksEvenInHorizon
? `Evolución del ahorro neto acumulado a lo largo de ${process.analysisHorizonYears} años. El payback se alcanza en ${formatPayback(roi.paybackMonths)}.`
: `Evolución del ahorro neto acumulado a lo largo de ${process.analysisHorizonYears} años. El payback no se alcanza en el horizonte analizado.`
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate500)
const subtitleLines = doc.splitTextToSize(subtitleText, PDF.contentW)
doc.text(subtitleLines, PDF.margin, y)
y += subtitleLines.length * 4.5 + 6
drawTrajectoryChart(doc, roi, process, currency, PDF.margin, PDF.contentW, y)
applyFootersToAllPages(doc, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
}

View File

@@ -1,9 +1,421 @@
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import { formatCurrency, formatPayback, formatPercent, formatSimulationTimestamp } from '@/lib/format'
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
import { PDF, type DocLike } from './pdf-sections'
import { PDF_COLORS } from './pdf-sections-shared'
// ─── Colores locales de página 1 ROI ─────────────────────────────────────────
const C = {
heroOrange: [245, 152, 69] as [number, number, number], // #F59845
heroMagenta: [237, 62, 143] as [number, number, number], // #ED3E8F
orangeLight: [254, 243, 226] as [number, number, number], // #FEF3E2
orangeLighter: [255, 248, 237] as [number, number, number], // #FFF8ED
orangeBar: [254, 215, 170] as [number, number, number], // #FED7AA
metaBg: [250, 251, 252] as [number, number, number], // #FAFBFC
orangeDark: [180, 83, 9] as [number, number, number], // #B45309
orangeDarker: [146, 64, 14] as [number, number, number], // #92400E
white: [255, 255, 255] as [number, number, number],
}
// ─── Página 1 ROI — diseño completo (Etapa 6) ────────────────────────────────
export function drawRoiPage1(
doc: DocLike,
process: Process,
roi: RoiResult,
simulatedAt: number,
perActivityActual: ActivitySimResult[],
perActivityAutomated: ActivitySimResult[],
currency: string
): void {
const pageW = doc.internal.pageSize.getWidth()
const m = 20 // margin mm
const cW = pageW - 2 * m // content width
let y = m
y = _drawDenseHeader(doc, pageW, m, cW, y)
y = _drawMetadataStrip(doc, process, simulatedAt, m, cW, y)
y = _drawNarrativeBanner(doc, process, roi, currency, m, cW, y)
y = _drawHeroCard(doc, roi, process, currency, m, cW, y)
y = _drawSecondaryGrid(doc, roi, process, currency, m, cW, y)
_drawTop3Activities(doc, perActivityActual, perActivityAutomated, currency, m, cW, y)
}
// ── 5.1 Header denso de página 1 ─────────────────────────────────────────────
function _drawDenseHeader(
doc: DocLike, pageW: number, m: number, cW: number, y: number
): number {
const right = pageW - m
// Izquierda: "InQ ROI" 22pt bold naranja
doc.setFont('helvetica', 'bold')
doc.setFontSize(22)
doc.setTextColor(...C.heroOrange)
doc.text('InQ ROI', m, y)
// Derecha: "UN PRODUCTO DE" 9pt slate-400
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('UN PRODUCTO DE', right, y, { align: 'right' } as Record<string, unknown>)
y += 9
// Izquierda: subtítulo 9pt slate-400 (margin-top 4mm ya incluido en line-height)
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('ANÁLISIS DE RETORNO DE INVERSIÓN', m, y)
// Derecha: "InQ" 16pt bold slate-600
doc.setFont('helvetica', 'bold')
doc.setFontSize(16)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text('InQ', right, y, { align: 'right' } as Record<string, unknown>)
y += 6
// Derecha: "InQuality" 9pt slate-500
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('InQuality', right, y, { align: 'right' } as Record<string, unknown>)
y += 10
// Border-bottom naranja 0.8mm
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.8)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 10 // margin-bottom below border
return y
}
// ── 5.2 Strip de metadata ─────────────────────────────────────────────────────
function _drawMetadataStrip(
doc: DocLike, process: Process, simulatedAt: number,
m: number, cW: number, y: number
): number {
const h = 16, r = 2
doc.setFillColor(...C.metaBg)
doc.roundedRect(m, y, cW, h, r, r, 'F')
const { date } = formatSimulationTimestamp(simulatedAt)
const col2 = m + cW * 0.5
const col3 = m + cW * 0.75
const cells = [
{ label: 'PROCESO', value: process.name, x: m + 4 },
{ label: 'CLIENTE', value: process.clientName || '—', x: col2 + 4 },
{ label: 'FECHA', value: date, x: col3 + 4 },
]
for (const cell of cells) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(cell.label, cell.x, y + 5)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
const truncated = cell.value.length > 28 ? cell.value.slice(0, 26) + '…' : cell.value
doc.text(truncated, cell.x, y + 12)
}
return y + h + 6
}
// ── 5.3 Banner narrativo ──────────────────────────────────────────────────────
function _drawNarrativeBanner(
doc: DocLike, process: Process, roi: RoiResult, currency: string,
m: number, cW: number, y: number
): number {
const padH = 5, padV = 8, borderW = 3
const investStr = process.automationInvestment > 0
? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}`
: 'inversión aún no configurada'
const line1 = `Análisis de retorno de inversión para automatización del proceso` +
(process.clientName ? ` del cliente "${process.clientName}"` : '') +
`, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` +
` horizonte de ${process.analysisHorizonYears} años e ${investStr}.`
const resultStr = roi.savingsPerExecution > 0
? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución` +
` (${formatPercent(roi.savingsPerExecutionPercent)} de reducción).` +
(Number.isFinite(roi.paybackMonths)
? ` La inversión se recupera en ${formatPayback(roi.paybackMonths)}, ` +
(roi.breaksEvenInHorizon ? 'dentro del horizonte de análisis.' : 'fuera del horizonte analizado.')
: ' Sin ahorro neto con los datos actuales.')
: 'El escenario automatizado no genera ahorro con los datos actuales.'
doc.setFontSize(9)
const l1 = doc.splitTextToSize(line1, cW - padH * 2 - borderW - 4)
const l2 = doc.splitTextToSize(resultStr, cW - padH * 2 - borderW - 4)
const totalLines = l1.length + 1 + l2.length
const textH = totalLines * 4.2
const boxH = textH + padV * 2
// Fondo naranja ligero
doc.setFillColor(...C.orangeLighter)
doc.setDrawColor(...C.orangeLighter)
doc.rect(m + borderW, y, cW - borderW, boxH, 'F')
// Border-left naranja
doc.setFillColor(...C.heroOrange)
doc.rect(m, y, borderW, boxH, 'F')
const tx = m + borderW + padH
let ty = y + padV
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text(l1, tx, ty)
ty += l1.length * 4.2 + 3
doc.text(l2, tx, ty)
return y + boxH + 8
}
// ── 5.4 KPI Hero con gradiente ────────────────────────────────────────────────
function _createGradientDataUrl(wMm: number, hMm: number): string | null {
if (typeof document === 'undefined') return null
try {
const scale = 3
const w = Math.round(wMm * scale)
const h = Math.round(hMm * scale)
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return null
const grad = ctx.createLinearGradient(0, 0, w, h) // 135deg approx
grad.addColorStop(0, '#F59845')
grad.addColorStop(1, '#ED3E8F')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
return canvas.toDataURL('image/png')
} catch {
return null
}
}
function _drawGradientFallback(
doc: DocLike, x: number, y: number, w: number, h: number
): void {
const steps = 50
const segW = w / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
const r = Math.round(245 + (237 - 245) * t)
const g = Math.round(152 + (62 - 152) * t)
const b = Math.round(69 + (143 - 69) * t)
doc.setFillColor(r, g, b)
doc.rect(x + i * segW, y, segW + 0.1, h, 'F')
}
}
function _drawHeroCard(
doc: DocLike, roi: RoiResult, process: Process, currency: string,
m: number, cW: number, y: number
): number {
const heroH = 46
// Gradiente de fondo
const dataUrl = _createGradientDataUrl(cW, heroH)
if (dataUrl) {
doc.addImage(dataUrl, 'PNG', m, y, cW, heroH)
} else {
_drawGradientFallback(doc, m, y, cW, heroH)
}
// Esquinas redondeadas — simular con rect blanco en las esquinas (Nivel 1)
// jsPDF no soporta clip en addImage, así que el gradiente queda sin border-radius.
// Aceptable para esta etapa.
// Texto sobre el gradiente
const heroX = m + 14
let ty = y + 12
// Label superior
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(255, 255, 255)
doc.text(`AHORRO PROYECTADO A ${process.analysisHorizonYears} AÑOS`, heroX, ty)
ty += 8
// Cifra principal 28pt
doc.setFont('helvetica', 'bold')
doc.setFontSize(28)
doc.setTextColor(...C.white)
doc.text(formatCurrency(roi.cumulativeSavingsHorizon, currency), heroX, ty)
ty += 9
// Subtítulo
const multiple = process.automationInvestment > 0 && Number.isFinite(roi.roiAnnualPercent)
? `neto de inversión · ${(roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)}× retorno`
: 'neto de inversión'
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(255, 255, 255)
doc.text(multiple, heroX, ty)
return y + heroH + 8
}
// ── 5.5 Grid 2x2 KPIs secundarios ────────────────────────────────────────────
function _drawSecondaryGrid(
doc: DocLike, roi: RoiResult, process: Process, currency: string,
m: number, cW: number, y: number
): number {
const cardW = (cW - 4) / 2
const cardH = 24
const gapX = 4, gapY = 4
const r = 2
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !Number.isFinite(roi.roiAnnualPercent) ? 'sin inv.' : `${roi.roiAnnualPercent.toFixed(0)}%`
const savingsPct = formatPercent(roi.savingsPerExecutionPercent)
const cards = [
{
label: 'AHORRO POR EJECUCIÓN',
value: formatCurrency(roi.savingsPerExecution, currency),
delta: `-${savingsPct} vs. actual`,
col: 0, row: 0,
},
{
label: 'AHORRO ANUAL',
value: formatCurrency(roi.annualSavings, currency),
delta: `× ${process.analysisHorizonYears} años`,
col: 1, row: 0,
},
{
label: 'PAYBACK',
value: paybackStr,
delta: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte',
col: 0, row: 1,
},
{
label: 'ROI ANUAL',
value: roiStr,
delta: 'retorno anual',
col: 1, row: 1,
},
]
let maxY = y
for (const card of cards) {
const cx = m + card.col * (cardW + gapX)
const cy = y + card.row * (cardH + gapY)
doc.setFillColor(...C.orangeLight)
doc.roundedRect(cx, cy, cardW, cardH, r, r, 'F')
// Label
doc.setFont('helvetica', 'bold')
doc.setFontSize(6.5)
doc.setTextColor(...C.orangeDarker)
doc.text(card.label, cx + 5, cy + 6)
// Valor
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(card.value, cx + 5, cy + 14)
// Delta
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(...C.orangeDark)
doc.text(card.delta, cx + 5, cy + 20)
maxY = Math.max(maxY, cy + cardH)
}
return maxY + 8
}
// ── 5.6 Top 3 actividades por ahorro ─────────────────────────────────────────
function _drawTop3Activities(
doc: DocLike,
perActivityActual: ActivitySimResult[],
perActivityAutomated: ActivitySimResult[],
currency: string,
m: number, cW: number, y: number
): void {
const autoMap = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
const top3 = perActivityActual
.map((a) => ({
name: a.activityName,
savings: a.expectedTotalCost - (autoMap.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost),
}))
.filter((s) => s.savings > 0)
.sort((a, b) => b.savings - a.savings)
.slice(0, 3)
// Título
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('TOP 3 ACTIVIDADES POR AHORRO', m, y)
y += 7
if (top3.length === 0) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('Sin actividades con ahorro positivo con los datos actuales.', m, y)
return
}
const maxSavings = top3[0].savings
const totalSavings = top3.reduce((s, a) => s + a.savings, 0)
const nameColW = cW * 0.38
const barColW = cW * 0.38
const valColW = cW * 0.24
const barH = 5
for (const act of top3) {
const pct = totalSavings > 0 ? (act.savings / totalSavings * 100).toFixed(0) : '0'
const barFill = maxSavings > 0 ? (act.savings / maxSavings) * barColW : 0
// Nombre (truncado a 30 chars)
const name = act.name.length > 30 ? act.name.slice(0, 28) + '…' : act.name
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(name, m, y + barH - 1)
// Barra de fondo
doc.setFillColor(...C.orangeBar)
doc.roundedRect(m + nameColW, y, barColW, barH, 1, 1, 'F')
// Barra de fill
if (barFill > 0) {
doc.setFillColor(...C.heroOrange)
doc.roundedRect(m + nameColW, y, barFill, barH, 1, 1, 'F')
}
// Valor + porcentaje
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.setTextColor(...C.orangeDark)
const valStr = `${formatCurrency(act.savings, currency)} (${pct}%)`
doc.text(valStr, m + nameColW + barColW + valColW, y + barH - 1, { align: 'right' } as Record<string, unknown>)
y += barH + 5
}
}
const ROI_HIGH_THRESHOLD = 1000
// ─── Resumen ejecutivo ────────────────────────────────────────────────────────
@@ -178,14 +590,22 @@ export function buildComparisonTableConfig(
const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139]
const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252]
const nonAutoText = [148, 163, 184] // slate-400, más tenue
const savingsCell = isAutomatable
? { content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } }
: { content: '—', styles: { halign: RA, fillColor: rowFill, textColor: nonAutoText } }
const savingsPctCell = isAutomatable
? { content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } }
: { content: '—', styles: { halign: RA, fillColor: rowFill, textColor: nonAutoText } }
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 } },
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
savingsCell,
savingsPctCell,
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
]
})
@@ -194,8 +614,15 @@ export function buildComparisonTableConfig(
body,
startY,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
styles: { fontSize: 7.5, cellPadding: 2.5, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.2 },
headStyles: {
fillColor: [255, 248, 237], // #FFF8ED naranja claro
textColor: [146, 64, 14], // #92400E naranja oscuro
fontStyle: 'bold',
fontSize: 8,
lineColor: [245, 152, 69], // #F59845 border naranja
lineWidth: 0.5,
},
columnStyles: { 0: { cellWidth: 42 } },
}
}
@@ -213,19 +640,28 @@ export function drawRoiAnalysisPage(
startY: number
): number {
let y = startY
const m = PDF.margin, cW = PDF.contentW
// Título de página
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Análisis del ahorro', PDF.margin, y)
y += 6
doc.setFontSize(11)
doc.setTextColor(...C.orangeDarker)
doc.text('ANÁLISIS DEL AHORRO', m, y)
y += 5
doc.setDrawColor(...PDF.slate200)
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 6
y += 7
// ── COMPOSICIÓN DEL AHORRO ────────────────────────────────────────────────
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...C.orangeDarker)
doc.text('COMPOSICIÓN DEL AHORRO', m, y)
y += 7
// ── Composición del ahorro ──────────────────────────────────────────────────
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
const savingsPerAct = perActivityActual
.map((a) => {
@@ -235,14 +671,9 @@ export function drawRoiAnalysisPage(
.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)
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
if (savingsPerAct.length > 0) {
const top3 = savingsPerAct.slice(0, 3)
@@ -251,55 +682,57 @@ export function drawRoiAnalysisPage(
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
const compositionLines = doc.splitTextToSize(compositionText, cW)
doc.text(compositionLines, m, y)
y += compositionLines.length * 4.5 + 4
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
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)
doc.text(` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, m, 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)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('Sin actividades con ahorro positivo en el escenario actual.', m, y)
y += 5
}
y += 4
y += 6
// ── Trayectoria del ahorro ──────────────────────────────────────────────────
// ── TRAYECTORIA DEL AHORRO ACUMULADO (texto narrativo) ───────────────────
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)
doc.setTextColor(...C.orangeDarker)
doc.text('TRAYECTORIA DEL AHORRO ACUMULADO', m, y)
y += 7
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)
: '∞'
const multiple = (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.`
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial. ` +
`Ver gráfico en página 5.`
} 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.`
`Sin inversión configurada. El ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años. ` +
`Ver gráfico en página 5.`
} 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
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
const trajectoryLines = doc.splitTextToSize(trajectoryText, cW)
doc.text(trajectoryLines, m, y)
y += trajectoryLines.length * 4.5 + 8
// ── Transparencia metodológica (si aplica) ────────────────────────────────
y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y)
@@ -324,8 +757,9 @@ export function drawTransparencyBlock(
const boxX = PDF.margin
const boxW = PDF.contentW
const padH = 5, padV = 6
// ── Paso 1: recopilar todo el contenido para calcular altura ─────────────
// ── Paso 1: recopilar contenido para calcular altura ─────────────────────
type TextBlock = { text: string[]; bold?: boolean; fontSize: number }
const blocks: TextBlock[] = []
@@ -333,121 +767,310 @@ export function drawTransparencyBlock(
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.`
? `Actividad "${zeroCost[0].name || zeroCost[0].bpmnElementId}" automatizable sin costo configurado. 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 })
blocks.push({ text: doc.splitTextToSize(msg, boxW - padH * 2), fontSize: 8.5 })
}
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
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión para un análisis realista.',
boxW - padH * 2
),
fontSize: 8,
fontSize: 8.5,
})
}
if (roiHigh) {
blocks.push({
text: doc.splitTextToSize(
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
boxW - 8
`ROI inusualmente alto: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente.`,
boxW - padH * 2
),
fontSize: 8,
fontSize: 8.5,
})
}
// Calcular altura total del bloque
let height = 6 // padding top
// Calcular altura total
let height = padV
for (const b of blocks) {
height += b.text.length * (b.bold ? 4.5 : 3.8) + 3
height += b.text.length * (b.bold ? 5 : 4.2) + 3
}
height += 3 // padding bottom
height += padV
// ── Paso 2: dibujar fondo y borde ────────────────────────────────────────
doc.setFillColor(255, 251, 235) // amber-50
doc.setDrawColor(253, 230, 138) // amber-200
doc.setLineWidth(0.3)
// ── Paso 2: fondo + borde ─────────────────────────────────────────────────
doc.setFillColor(255, 251, 235) // #FFFBEB warning-bg
doc.setDrawColor(245, 158, 11) // #F59E0B warning-border
doc.setLineWidth(0.4)
doc.rect(boxX, startY, boxW, height, 'FD')
// ── Paso 3: dibujar texto encima del rectángulo ───────────────────────────
let y = startY + 6
// ── Paso 3: texto ─────────────────────────────────────────────────────────
let y = startY + padV
for (const b of blocks) {
if (b.bold) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(b.fontSize)
doc.setTextColor(146, 64, 14) // amber-800
doc.setTextColor(...C.orangeDarker)
} else {
doc.setFont('helvetica', 'normal')
doc.setFontSize(b.fontSize)
doc.setTextColor(180, 83, 9) // amber-700
doc.setTextColor(120, 53, 15) // #78350F
}
doc.text(b.text, boxX + 4, y)
y += b.text.length * (b.bold ? 4.5 : 3.8) + 3
doc.text(b.text, boxX + padH, y)
y += b.text.length * (b.bold ? 5 : 4.2) + 3
}
return startY + height + 4
return startY + height + 6
}
// ─── Sección metodológica ampliada para el tab ROI ────────────────────────────
// ─── Página 4: nota metodológica con identidad InQ ───────────────────────────
export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number {
let y = startY
const m = PDF.margin, cW = PDF.contentW
const boxPad = 6
// Título uppercase naranja oscuro
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y)
doc.setFontSize(11)
doc.setTextColor(...C.orangeDarker)
doc.text('NOTA METODOLOGICA - ANALISIS DE ROI', m, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 8
// Caja contenedora: fondo slate-50 + border-left #94A3B8
const boxX = m
const boxW = cW
const contentX = m + boxPad + 3 // +3 para el border-left visual
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 ejecucion actual:',
body: `SUM(actividades) [costo_fijo x prob_ejecucion] x (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagacion hacia adelante en el grafo BPMN.`,
},
{
title: 'Costo por ejecución automatizado:',
title: 'Costo por ejecucion 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.`,
body: `(Costo actual - Costo automatizado) x ${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.`,
body: '(Inversion / Ahorro anual) x 12. Nominal simple, sin tasa de descuento ni inflacion.',
},
{
title: 'Ahorro acumulado:',
body: `Ahorro anual × ${process.analysisHorizonYears} años Inversión inicial. Nominal, sin VP.`,
body: `Ahorro anual x ${process.analysisHorizonYears} años - Inversion inicial. Nominal, sin VP.`,
},
{
title: 'ROI anualizado:',
body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
body: '(Ahorro anual / Inversion) x 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
},
]
// Pre-calcular altura total para dibujar el fondo primero
let boxHeight = boxPad * 2
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
const bodyLines = doc.splitTextToSize(s.body, boxW - boxPad * 2 - 4)
boxHeight += 5 + bodyLines.length * 4.2 + 5
}
return y
doc.setFillColor(248, 250, 252) // #F8FAFC slate-50
doc.rect(boxX, y, boxW, boxHeight, 'F')
doc.setFillColor(...C.metaBg)
doc.rect(boxX, y, 2, boxHeight, 'F') // border-left visual: 2mm rect gris
let ty = y + boxPad
for (const s of sections) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(s.title, contentX, ty)
ty += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setTextColor(...PDF_COLORS.textSecondary)
const lines = doc.splitTextToSize(s.body, boxW - boxPad * 2 - 4)
doc.text(lines, contentX, ty)
ty += lines.length * 4.2 + 5
}
return y + boxHeight + 4
}
// ─── Página 5: Trayectoria del ahorro acumulado (SVG nativo) ─────────────────
export function drawTrajectoryChart(
doc: DocLike,
roi: RoiResult,
process: Process,
currency: string,
m: number,
cW: number,
startY: number
): number {
const horizonMonths = process.analysisHorizonYears * 12
const investment = process.automationInvestment
const annualSavings = roi.annualSavings
const finalCumulative = roi.cumulativeSavingsHorizon
// Función de la trayectoria: lineal desde -investment hasta finalCumulative
const valueAt = (month: number) => -investment + (annualSavings / 12) * month
// Área del gráfico
const labelColW = 20 // mm para etiquetas eje Y
const chartLeft = m + labelColW
const chartRight = m + cW
const chartWidth = chartRight - chartLeft
const chartTop = startY
const chartHeight = 90 // mm — 60-70% del alto disponible
const chartBottom = chartTop + chartHeight
// Rango de valores Y
const yMin = Math.min(-investment, 0)
const yMax = Math.max(finalCumulative, 0)
const yRange = yMax - yMin || 1
// Conversiones coordenadas
const toX = (month: number) => chartLeft + (month / (horizonMonths || 1)) * chartWidth
const toY = (value: number) => chartBottom - ((value - yMin) / yRange) * chartHeight
const zeroY = toY(0)
const paybackX = roi.breaksEvenInHorizon && roi.paybackMonths > 0
? toX(roi.paybackMonths)
: chartLeft // sin payback: toda el área es negativa o positiva
// 1. Fondo del gráfico (slate-50)
doc.setFillColor(248, 250, 252)
doc.rect(chartLeft, chartTop, chartWidth, chartHeight, 'F')
// 2. Área negativa (rojo claro) y positiva (naranja claro) — 100 tiras verticales
const strips = 100
for (let i = 0; i < strips; i++) {
const m1 = (i / strips) * horizonMonths
const m2 = ((i + 1) / strips) * horizonMonths
const mMid = (m1 + m2) / 2
const val = valueAt(mMid)
const x1 = toX(m1)
const x2 = toX(m2)
const cy = toY(val)
const yTop = Math.max(Math.min(cy, zeroY), chartTop)
const yBot = Math.min(Math.max(cy, zeroY), chartBottom)
const h = yBot - yTop
if (h > 0.1) {
if (val < 0) {
doc.setFillColor(254, 226, 226) // #FEE2E2 rojo claro
} else {
doc.setFillColor(254, 215, 170) // #FED7AA naranja claro
}
doc.rect(x1, yTop, Math.max(x2 - x1, 0.1), h, 'F')
}
}
// 3. Línea principal naranja (segmentos mensuales)
doc.setDrawColor(245, 152, 69) // #F59845
doc.setLineWidth(0.5)
for (let mo = 0; mo < horizonMonths; mo++) {
doc.line(toX(mo), toY(valueAt(mo)), toX(mo + 1), toY(valueAt(mo + 1)))
}
// 4. Ejes
doc.setDrawColor(148, 163, 184) // slate-400
doc.setLineWidth(0.3)
doc.line(chartLeft, chartTop, chartLeft, chartBottom) // eje Y
doc.line(chartLeft, zeroY, chartRight, zeroY) // eje X (línea cero)
// 5. Labels eje X: Año 0, 1, 2, ... N
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(100, 116, 139) // slate-500
for (let yr = 0; yr <= process.analysisHorizonYears; yr++) {
const lx = toX(yr * 12)
doc.text(`Año ${yr}`, lx, chartBottom + 5, { align: 'center' } as Record<string, unknown>)
doc.setDrawColor(200, 200, 200)
doc.setLineWidth(0.2)
doc.line(lx, chartBottom, lx, chartBottom + 2)
}
// 6. Labels eje Y: valor mínimo (abajo), 0 (línea cero), valor máximo (arriba)
doc.setDrawColor(148, 163, 184)
doc.setLineWidth(0.2)
doc.setFont('helvetica', 'normal')
doc.setFontSize(6.5)
doc.setTextColor(100, 116, 139)
// Máximo
doc.text(_fmtAxisVal(yMax), chartLeft - 2, chartTop + 4, { align: 'right' } as Record<string, unknown>)
// Cero (solo si hay rango negativo)
if (yMin < 0) {
doc.text('0', chartLeft - 2, zeroY + 1, { align: 'right' } as Record<string, unknown>)
// Mínimo
doc.text(_fmtAxisVal(yMin), chartLeft - 2, chartBottom, { align: 'right' } as Record<string, unknown>)
}
// 7. Marker de payback (línea vertical punteada, solo si hay payback en horizonte)
if (roi.breaksEvenInHorizon && roi.paybackMonths > 0 && roi.paybackMonths <= horizonMonths) {
doc.setDrawColor(180, 83, 9) // #B45309
doc.setLineWidth(0.4)
const dashLen = 2.5, gapLen = 1.8
let dy = chartTop
while (dy < chartBottom) {
doc.line(paybackX, dy, paybackX, Math.min(dy + dashLen, chartBottom))
dy += dashLen + gapLen
}
// Label del payback (offset a la derecha para no tapar el eje Y)
const labelX = Math.min(paybackX + 3, chartLeft + chartWidth - 35)
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
doc.setTextColor(180, 83, 9)
doc.text(`Payback: ${formatPayback(roi.paybackMonths)}`, labelX, Math.max(zeroY - 3, chartTop + 5))
}
doc.setLineWidth(0.2)
// 8. Anotaciones al pie del gráfico
const annY = chartBottom + 12
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textSecondary)
const annotations: string[] = []
if (investment > 0) {
annotations.push(`Inversión inicial: ${formatCurrency(investment, currency)}`)
}
annotations.push(`Ahorro acumulado al año ${process.analysisHorizonYears}: ${formatCurrency(finalCumulative, currency)}`)
if (investment > 0 && finalCumulative > 0) {
const mult = (finalCumulative / investment + 1).toFixed(1)
annotations.push(`Multiplicador de retorno: ${mult}x la inversión`)
}
const annotText = annotations.join(' | ')
const annotLines = doc.splitTextToSize(annotText, cW)
doc.text(annotLines, m, annY)
return annY + annotLines.length * 4.5 + 4
}
// ─── Helpers locales ──────────────────────────────────────────────────────────
function _fmtAxisVal(n: number): string {
const abs = Math.abs(n)
const sign = n < 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 `${sign}${abs.toFixed(0)}`
}
function fmtNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}

View File

@@ -111,12 +111,16 @@ export function drawHeatmapImage(
): number {
if (!imageDataUrl) return startY
// Calcular altura proporcional (asumiendo aspect ratio ~2:1 del canvas BPMN)
const imgW = PDF.contentW
const imgH = Math.min(imgW / 2.2, 80) // máximo 80mm de alto
// Usar dimensiones dinámicas — funciona en portrait (210mm) y landscape (297mm)
const pageW = doc.internal.pageSize.getWidth()
const pageH = doc.internal.pageSize.getHeight()
const imgW = pageW - 2 * PDF.margin
// Altura: proporción ~2.2:1 del canvas BPMN, limitada por espacio disponible hasta el footer
const maxH = pageH - startY - PDF.margin - 20 // 20mm reservado para footer
const imgH = Math.min(imgW / 2.2, maxH)
const x = PDF.margin
if (startY + imgH > PDF.pageH - PDF.margin - 10) {
if (startY + imgH > pageH - PDF.margin - 10) {
doc.addPage()
startY = PDF.margin
}
@@ -131,7 +135,8 @@ export function drawHeatmapImage(
}
export function drawHeatmapLegend(doc: DocLike, startY: number): number {
const x = PDF.margin, barW = PDF.contentW, barH = 4
const pageW = doc.internal.pageSize.getWidth()
const x = PDF.margin, barW = pageW - 2 * PDF.margin, barH = 4
const labelY = startY + barH + 3.5
doc.setFontSize(7)

View File

@@ -0,0 +1,181 @@
/**
* Etapa 4 Sprint 1.5 — Validación de PDFs con header/footer compartido InQ.
*
* Genera los 3 PDFs con setup realista y valida:
* 1. Palabras clave presentes ("InQ ROI", "Powered by InQuality")
* 2. Términos prohibidos ausentes ("Process Cost Platform", "v0.1.0", "INQ", "Inq")
* 3. Cantidad de páginas (actual=3, automatizado=3, roi=4)
* 4. Tamaño por página (1580 KB por página)
*/
import { test, expect } from '@playwright/test'
import { resolve } from 'path'
import { mkdirSync, readFileSync } from 'fs'
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 OUT_DIR = resolve(__dirname, './__output__')
test.beforeAll(() => { mkdirSync(OUT_DIR, { recursive: true }) })
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function importBpmn(page: import('@playwright/test').Page, filename: string) {
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, filename))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
await page.waitForSelector('button:has-text("Simular")', { timeout: 10_000 })
await page.waitForTimeout(800)
}
async function setActivityCosts(
page: import('@playwright/test').Page,
elementId: string, directCost: number, automatedCost: number
) {
await page.locator(`[data-element-id="${elementId}"]`).first().click()
await page.waitForTimeout(400)
const toggle = page.getByRole('switch', { name: /automatizable/i })
if (await toggle.getAttribute('aria-checked') === 'false') await toggle.click()
await page.waitForTimeout(200)
await page.getByLabel(/Costo directo fijo/i).fill(String(directCost))
await page.getByLabel(/Costo automatizado/i).fill(String(automatedCost))
await page.getByLabel(/Tiempo automatizado/i).fill('10')
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
async function configureGlobal(page: import('@playwright/test').Page, freq: number, inv: number) {
await page.getByRole('tab', { name: /Global/i }).click()
await page.waitForTimeout(300)
await page.getByLabel(/Frecuencia anual/i).fill(String(freq))
await page.getByLabel(/Inversión en automatización/i).fill(String(inv))
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
async function setupAndSimulate(page: import('@playwright/test').Page) {
await importBpmn(page, 'medium-with-gateways.bpmn')
await setActivityCosts(page, 'task_recv', 200, 20)
await setActivityCosts(page, 'task_score', 200, 20)
await setActivityCosts(page, 'task_analisis', 200, 20)
await configureGlobal(page, 5000, 80000)
await page.getByRole('button', { name: 'Simular' }).click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 15_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
await page.waitForTimeout(800)
}
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 downloadPdf(
page: import('@playwright/test').Page,
targetTab: 'actual' | 'automatizado' | 'roi',
outPath: string
) {
if (targetTab === 'actual') {
await page.getByRole('tab', { name: 'Actual' }).click()
await page.waitForTimeout(400)
} else if (targetTab === 'automatizado') {
await page.getByRole('tab', { name: 'Automatizado' }).click()
await page.waitForTimeout(400)
} else {
await page.getByRole('tab', { name: /Comparación/i }).click()
await page.waitForSelector('[data-testid="roi-kpi-card"]', { timeout: 10_000 })
await page.waitForTimeout(500)
}
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await download.saveAs(outPath)
return download.suggestedFilename()
}
function validatePdfText(text: string, filename: string) {
// Palabras clave obligatorias
expect(text, `${filename}: debe contener "InQ ROI"`).toMatch(/InQ ROI/)
expect(text, `${filename}: debe contener "Powered by InQuality"`).toMatch(/Powered by InQuality/)
// Términos prohibidos
expect(text, `${filename}: no debe contener "Process Cost Platform"`).not.toContain('Process Cost Platform')
expect(text, `${filename}: no debe contener "v0.1.0"`).not.toContain('v0.1.0')
expect(text, `${filename}: no debe contener "INQ" (mal ortografía)`).not.toContain('INQ')
expect(text, `${filename}: no debe contener "Inq" (mal ortografía)`).not.toContain('Inq')
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test('etapa-4-actual.pdf — 3 páginas, marca InQ, sin términos prohibidos', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-4-actual.pdf')
const filename = await downloadPdf(page, 'actual', outPath)
console.log('Nombre archivo:', filename)
expect(filename).toMatch(/_actual\.pdf$/)
const buffer = readFileSync(outPath)
const sizeKB = buffer.length / 1024
console.log(`Tamaño: ${sizeKB.toFixed(1)} KB`)
expect(sizeKB, 'Tamaño total > 15 KB por página').toBeGreaterThan(15 * 3)
expect(sizeKB, 'Tamaño total < 80 KB por página (3 págs)').toBeLessThan(80 * 3)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas: ${numpages}`)
console.log(`Primeros 300 chars: ${text.slice(0, 300).replace(/\n/g, ' ')}`)
expect(numpages).toBe(3)
validatePdfText(text, 'etapa-4-actual.pdf')
})
test('etapa-4-automatizado.pdf — 3 páginas, marca InQ, sin términos prohibidos', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-4-automatizado.pdf')
const filename = await downloadPdf(page, 'automatizado', outPath)
expect(filename).toMatch(/_automatizado\.pdf$/)
const buffer = readFileSync(outPath)
const sizeKB = buffer.length / 1024
console.log(`etapa-4-automatizado.pdf: ${sizeKB.toFixed(1)} KB`)
expect(sizeKB).toBeGreaterThan(15 * 3)
expect(sizeKB).toBeLessThan(80 * 3)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas: ${numpages}`)
expect(numpages).toBe(3)
validatePdfText(text, 'etapa-4-automatizado.pdf')
})
test('etapa-4-roi.pdf — 4 páginas, marca InQ, keywords ROI, sin términos prohibidos', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-4-roi.pdf')
const filename = await downloadPdf(page, 'roi', outPath)
expect(filename).toMatch(/_roi\.pdf$/)
const buffer = readFileSync(outPath)
const sizeKB = buffer.length / 1024
console.log(`etapa-4-roi.pdf: ${sizeKB.toFixed(1)} KB`)
// ROI PDF no tiene imagen BPMN embebida → tamaño por página es menor (~8-12 KB texto puro)
expect(sizeKB, 'ROI PDF debe tener contenido mínimo (4 páginas texto)').toBeGreaterThan(20)
expect(sizeKB, 'ROI PDF no debe ser irrazonablemente grande').toBeLessThan(300)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas: ${numpages}`)
console.log(`Primeros 400 chars: ${text.slice(0, 400).replace(/\n/g, ' ')}`)
expect(numpages).toBe(4)
validatePdfText(text, 'etapa-4-roi.pdf')
// Keywords específicos del PDF de ROI
expect(text).toMatch(/Payback|payback/)
expect(text).toMatch(/ahorro|Ahorro/)
expect(text).toContain('USD')
})

View File

@@ -0,0 +1,180 @@
/**
* Etapa 5 Sprint 1.5 — Validación de PDFs con layout híbrido portrait+landscape.
*
* Validaciones mandatorias:
* 1. Páginas: actual=3, automatizado=3, roi=4
* 2. Orientación: actual/auto = portrait/LANDSCAPE/portrait; roi = portrait×4
* 3. "InQ ROI" y "Powered by InQuality" en todas las páginas
* 4. Diagrama BPMN en página 2 del actual (verificado por tamaño PDF y texto)
* 5. Tests previos ≥ 469
* 6. Sin regresiones de marca
*/
import { test, expect } from '@playwright/test'
import { resolve } from 'path'
import { mkdirSync, readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { PDFParse } from 'pdf-parse'
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUT_DIR = resolve(__dirname, './__output__')
test.beforeAll(() => { mkdirSync(OUT_DIR, { recursive: true }) })
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 getPageOrientations(buffer: Buffer): Promise<Array<'portrait' | 'landscape'>> {
const uint8 = new Uint8Array(buffer)
const pdf = await getDocument({ data: uint8 }).promise
const orientations: Array<'portrait' | 'landscape'> = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: 1 })
orientations.push(viewport.width > viewport.height ? 'landscape' : 'portrait')
}
return orientations
}
async function importBpmn(page: import('@playwright/test').Page, filename: string) {
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, filename))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
await page.waitForSelector('button:has-text("Simular")', { timeout: 10_000 })
await page.waitForTimeout(800)
}
async function setActivityCosts(
page: import('@playwright/test').Page,
elementId: string, directCost: number, automatedCost: number
) {
await page.locator(`[data-element-id="${elementId}"]`).first().click()
await page.waitForTimeout(400)
const toggle = page.getByRole('switch', { name: /automatizable/i })
if (await toggle.getAttribute('aria-checked') === 'false') await toggle.click()
await page.waitForTimeout(200)
await page.getByLabel(/Costo directo fijo/i).fill(String(directCost))
await page.getByLabel(/Costo automatizado/i).fill(String(automatedCost))
await page.getByLabel(/Tiempo automatizado/i).fill('10')
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
async function configureGlobal(page: import('@playwright/test').Page, freq: number, inv: number) {
await page.getByRole('tab', { name: /Global/i }).click()
await page.waitForTimeout(300)
await page.getByLabel(/Frecuencia anual/i).fill(String(freq))
await page.getByLabel(/Inversión en automatización/i).fill(String(inv))
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
async function setupAndSimulate(page: import('@playwright/test').Page) {
await importBpmn(page, 'medium-with-gateways.bpmn')
await setActivityCosts(page, 'task_recv', 200, 20)
await setActivityCosts(page, 'task_score', 200, 20)
await setActivityCosts(page, 'task_analisis', 200, 20)
await configureGlobal(page, 5000, 80000)
await page.getByRole('button', { name: 'Simular' }).click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 15_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
await page.waitForTimeout(800)
}
async function downloadPdf(
page: import('@playwright/test').Page,
targetTab: 'actual' | 'automatizado' | 'roi',
outPath: string
) {
if (targetTab === 'actual') {
await page.getByRole('tab', { name: 'Actual' }).click()
await page.waitForTimeout(400)
} else if (targetTab === 'automatizado') {
await page.getByRole('tab', { name: 'Automatizado' }).click()
await page.waitForTimeout(400)
} else {
await page.getByRole('tab', { name: /Comparación/i }).click()
await page.waitForSelector('[data-testid="roi-kpi-card"]', { timeout: 10_000 })
await page.waitForTimeout(500)
}
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await download.saveAs(outPath)
return download.suggestedFilename()
}
function validateBrandStrings(text: string, filename: string) {
expect(text, `${filename}: debe contener "InQ ROI"`).toMatch(/InQ ROI/)
expect(text, `${filename}: debe contener "Powered by InQuality"`).toMatch(/Powered by InQuality/)
expect(text, `${filename}: no debe contener "Process Cost Platform"`).not.toContain('Process Cost Platform')
expect(text, `${filename}: no debe contener "v0.1.0"`).not.toContain('v0.1.0')
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
test('etapa-5-actual.pdf — 4 págs, portrait-landscape-portrait-portrait, marca InQ', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-5-actual.pdf')
const filename = await downloadPdf(page, 'actual', outPath)
console.log('Nombre:', filename)
const buffer = readFileSync(outPath)
const sizeKB = buffer.length / 1024
console.log(`Tamaño: ${sizeKB.toFixed(1)} KB`)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas: ${numpages}`)
console.log(`Texto primeros 200: ${text.slice(0, 200).replace(/\n/g, ' ')}`)
expect(numpages).toBe(4)
validateBrandStrings(text, 'etapa-5-actual.pdf')
// Validar orientaciones con pdfjs
const orientations = await getPageOrientations(buffer)
console.log('Orientaciones:', orientations)
expect(orientations).toEqual(['portrait', 'landscape', 'portrait', 'portrait'])
// La página 2 (landscape) debe ser más ancha que alta en dimensiones reales
expect(orientations[1]).toBe('landscape')
})
test('etapa-5-automatizado.pdf — 4 págs, portrait-landscape-portrait-portrait, marca InQ', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-5-automatizado.pdf')
await downloadPdf(page, 'automatizado', outPath)
const buffer = readFileSync(outPath)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas automatizado: ${numpages}`)
expect(numpages).toBe(4)
validateBrandStrings(text, 'etapa-5-automatizado.pdf')
const orientations = await getPageOrientations(buffer)
console.log('Orientaciones automatizado:', orientations)
expect(orientations).toEqual(['portrait', 'landscape', 'portrait', 'portrait'])
})
test('etapa-5-roi.pdf — 5 págs todas portrait, sin regresiones', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-5-roi.pdf')
await downloadPdf(page, 'roi', outPath)
const buffer = readFileSync(outPath)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas ROI: ${numpages}`)
expect(numpages).toBe(5)
validateBrandStrings(text, 'etapa-5-roi.pdf')
const orientations = await getPageOrientations(buffer)
console.log('Orientaciones ROI:', orientations)
expect(orientations).toEqual(['portrait', 'portrait', 'portrait', 'portrait', 'portrait'])
})

View File

@@ -0,0 +1,177 @@
/**
* Etapa 6 Sprint 1.5 — PDF ROI: Rediseño de página 1 con identidad InQ.
*
* Validaciones mandatorias:
* 1. PDF ROI tiene 5 páginas, todas portrait
* 2. Footer en todas las páginas: "InQ ROI", "Powered by InQuality", paginación "X de 5"
* 3. Página 1 contiene: "ANÁLISIS DE RETORNO DE INVERSIÓN", "AHORRO PROYECTADO A",
* "TOP 3 ACTIVIDADES POR AHORRO", "UN PRODUCTO DE", "InQuality"
* 4. Página 1 contiene KPI Hero con cifra de ahorro acumulado (USD)
* 5. PDFs Actual y Automatizado sin cambios: 4 páginas, ['portrait','landscape','portrait','portrait']
* 6. Sin regresiones de marca
* 7. Tests previos >= 483
*/
import { test, expect } from '@playwright/test'
import { resolve } from 'path'
import { mkdirSync, readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { PDFParse } from 'pdf-parse'
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUT_DIR = resolve(__dirname, './__output__')
test.beforeAll(() => { mkdirSync(OUT_DIR, { recursive: true }) })
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 getPageOrientations(buffer: Buffer): Promise<Array<'portrait' | 'landscape'>> {
const uint8 = new Uint8Array(buffer)
const pdf = await getDocument({ data: uint8 }).promise
const orientations: Array<'portrait' | 'landscape'> = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: 1 })
orientations.push(viewport.width > viewport.height ? 'landscape' : 'portrait')
}
return orientations
}
async function importBpmn(page: import('@playwright/test').Page, filename: string) {
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, filename))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
await page.waitForSelector('button:has-text("Simular")', { timeout: 10_000 })
await page.waitForTimeout(800)
}
async function setActivityCosts(
page: import('@playwright/test').Page,
elementId: string, directCost: number, automatedCost: number
) {
await page.locator(`[data-element-id="${elementId}"]`).first().click()
await page.waitForTimeout(400)
const toggle = page.getByRole('switch', { name: /automatizable/i })
if (await toggle.getAttribute('aria-checked') === 'false') await toggle.click()
await page.waitForTimeout(200)
await page.getByLabel(/Costo directo fijo/i).fill(String(directCost))
await page.getByLabel(/Costo automatizado/i).fill(String(automatedCost))
await page.getByLabel(/Tiempo automatizado/i).fill('10')
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
async function configureGlobal(page: import('@playwright/test').Page, freq: number, inv: number) {
await page.getByRole('tab', { name: /Global/i }).click()
await page.waitForTimeout(300)
await page.getByLabel(/Frecuencia anual/i).fill(String(freq))
await page.getByLabel(/Inversión en automatización/i).fill(String(inv))
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
async function setupAndSimulate(page: import('@playwright/test').Page) {
await importBpmn(page, 'medium-with-gateways.bpmn')
await setActivityCosts(page, 'task_recv', 200, 20)
await setActivityCosts(page, 'task_score', 200, 20)
await setActivityCosts(page, 'task_analisis', 200, 20)
await configureGlobal(page, 5000, 80000)
await page.getByRole('button', { name: 'Simular' }).click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 15_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
await page.waitForTimeout(800)
}
async function downloadPdf(
page: import('@playwright/test').Page,
targetTab: 'actual' | 'automatizado' | 'roi',
outPath: string
) {
if (targetTab === 'actual') {
await page.getByRole('tab', { name: 'Actual' }).click()
await page.waitForTimeout(400)
} else if (targetTab === 'automatizado') {
await page.getByRole('tab', { name: 'Automatizado' }).click()
await page.waitForTimeout(400)
} else {
await page.getByRole('tab', { name: /Comparación/i }).click()
await page.waitForSelector('[data-testid="roi-kpi-card"]', { timeout: 10_000 })
await page.waitForTimeout(500)
}
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await download.saveAs(outPath)
return download.suggestedFilename()
}
function validateBrandStrings(text: string, filename: string) {
expect(text, `${filename}: debe contener "InQ ROI"`).toMatch(/InQ ROI/)
expect(text, `${filename}: debe contener "Powered by InQuality"`).toMatch(/Powered by InQuality/)
expect(text, `${filename}: no debe contener "Process Cost Platform"`).not.toContain('Process Cost Platform')
expect(text, `${filename}: no debe contener "v0.1.0"`).not.toContain('v0.1.0')
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
test('etapa-6-roi.pdf — 5 páginas, todas portrait, página 1 rediseñada', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-6-roi.pdf')
await downloadPdf(page, 'roi', outPath)
const buffer = readFileSync(outPath)
const sizeKB = buffer.length / 1024
console.log(`Tamaño ROI: ${sizeKB.toFixed(1)} KB`)
expect(sizeKB, 'PDF ROI debe tener > 20 KB con imagen de gradiente').toBeGreaterThan(20)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas ROI: ${numpages}`)
console.log(`Texto primeros 400: ${text.slice(0, 400).replace(/\n/g, ' ')}`)
expect(numpages).toBe(5)
validateBrandStrings(text, 'etapa-6-roi.pdf')
// Validaciones de contenido de página 1
expect(text, 'Debe contener cabecera del análisis').toMatch(/ANÁLISIS DE RETORNO DE INVERSIÓN/)
expect(text, 'Debe contener label del KPI Hero').toMatch(/AHORRO PROYECTADO A/)
expect(text, 'Debe contener título del Top3').toMatch(/TOP 3 ACTIVIDADES POR AHORRO/)
expect(text, 'Debe contener "UN PRODUCTO DE"').toMatch(/UN PRODUCTO DE/)
expect(text, 'Debe contener "InQuality"').toMatch(/InQuality/)
// Orientaciones
const orientations = await getPageOrientations(buffer)
console.log('Orientaciones ROI:', orientations)
expect(orientations).toEqual(['portrait', 'portrait', 'portrait', 'portrait', 'portrait'])
})
test('etapa-6: PDFs Actual y Automatizado sin cambios (4 págs, landscape en p.2)', async ({ page }) => {
await setupAndSimulate(page)
const actualPath = resolve(OUT_DIR, 'etapa-6-actual.pdf')
const autoPath = resolve(OUT_DIR, 'etapa-6-automatizado.pdf')
await downloadPdf(page, 'actual', actualPath)
await downloadPdf(page, 'automatizado', autoPath)
for (const [path, label] of [[actualPath, 'actual'], [autoPath, 'automatizado']] as const) {
const buffer = readFileSync(path)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas ${label}: ${numpages}`)
expect(numpages, `${label}: debe tener 4 páginas`).toBe(4)
validateBrandStrings(text, `etapa-6-${label}.pdf`)
const orientations = await getPageOrientations(buffer)
console.log(`Orientaciones ${label}:`, orientations)
expect(orientations, `${label}: layout portrait-landscape-portrait-portrait`)
.toEqual(['portrait', 'landscape', 'portrait', 'portrait'])
}
})

View File

@@ -0,0 +1,157 @@
/**
* Etapa 7 Sprint 1.5 — PDF ROI páginas 2-5 con identidad InQ.
*
* Validaciones mandatorias:
* 1. PDF ROI: 5 páginas, todas portrait
* 2. Página 5: "TRAYECTORIA DEL AHORRO ACUMULADO" presente (gráfico real, no placeholder)
* 3. Página 3: "COMPOSICIÓN DEL AHORRO", "TRAYECTORIA DEL AHORRO ACUMULADO" (uppercase)
* 4. Página 4: "NOTA METODOLOGICA" (sin caracteres Unicode)
* 5. Tamaño ROI > 30 KB (gráfico SVG + imagen gradiente)
* 6. Sin regresiones de marca: "InQ ROI", "Powered by InQuality"
* 7. PDFs Actual y Automatizado sin cambios: 4 páginas, ['portrait','landscape','portrait','portrait']
* 8. Tests previos >= 500
*/
import { test, expect } from '@playwright/test'
import { resolve } from 'path'
import { mkdirSync, readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { PDFParse } from 'pdf-parse'
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUT_DIR = resolve(__dirname, './__output__')
test.beforeAll(() => { mkdirSync(OUT_DIR, { recursive: true }) })
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 getPageOrientations(buffer: Buffer): Promise<Array<'portrait' | 'landscape'>> {
const uint8 = new Uint8Array(buffer)
const pdf = await getDocument({ data: uint8 }).promise
const orientations: Array<'portrait' | 'landscape'> = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: 1 })
orientations.push(viewport.width > viewport.height ? 'landscape' : 'portrait')
}
return orientations
}
async function setupAndSimulate(page: import('@playwright/test').Page) {
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
await page.waitForSelector('button:has-text("Simular")', { timeout: 10_000 })
await page.waitForTimeout(800)
for (const [id, cost, auto] of [['task_recv', 200, 20], ['task_score', 200, 20], ['task_analisis', 200, 20]] as [string, number, number][]) {
await page.locator(`[data-element-id="${id}"]`).first().click()
await page.waitForTimeout(400)
const toggle = page.getByRole('switch', { name: /automatizable/i })
if (await toggle.getAttribute('aria-checked') === 'false') await toggle.click()
await page.waitForTimeout(200)
await page.getByLabel(/Costo directo fijo/i).fill(String(cost))
await page.getByLabel(/Costo automatizado/i).fill(String(auto))
await page.getByLabel(/Tiempo automatizado/i).fill('10')
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
}
await page.getByRole('tab', { name: /Global/i }).click()
await page.waitForTimeout(300)
await page.getByLabel(/Frecuencia anual/i).fill('5000')
await page.getByLabel(/Inversión en automatización/i).fill('80000')
await page.getByRole('button', { name: /Guardar/i }).click()
await page.waitForTimeout(400)
await page.getByRole('button', { name: 'Simular' }).click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 15_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
await page.waitForTimeout(800)
}
async function downloadPdf(page: import('@playwright/test').Page, tab: 'actual' | 'automatizado' | 'roi', outPath: string) {
if (tab === 'actual') {
await page.getByRole('tab', { name: 'Actual' }).click()
await page.waitForTimeout(400)
} else if (tab === 'automatizado') {
await page.getByRole('tab', { name: 'Automatizado' }).click()
await page.waitForTimeout(400)
} else {
await page.getByRole('tab', { name: /Comparación/i }).click()
await page.waitForSelector('[data-testid="roi-kpi-card"]', { timeout: 10_000 })
await page.waitForTimeout(500)
}
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await download.saveAs(outPath)
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
test('etapa-7-roi.pdf — 5 págs, gráfico SVG real p.5, paleta InQ p.2-4', async ({ page }) => {
await setupAndSimulate(page)
const outPath = resolve(OUT_DIR, 'etapa-7-roi.pdf')
await downloadPdf(page, 'roi', outPath)
const buffer = readFileSync(outPath)
const sizeKB = buffer.length / 1024
console.log(`Tamaño ROI: ${sizeKB.toFixed(1)} KB`)
expect(sizeKB, 'PDF ROI debe superar 30 KB').toBeGreaterThan(30)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas: ${numpages}`)
console.log(`Texto primeros 600: ${text.slice(0, 600).replace(/\n/g, ' ')}`)
expect(numpages).toBe(5)
// Marcas globales
expect(text).toMatch(/InQ ROI/)
expect(text).toMatch(/Powered by InQuality/)
expect(text).not.toContain('Process Cost Platform')
expect(text).not.toContain('v0.1.0')
// Página 5: gráfico real (no placeholder)
expect(text, 'p.5 debe tener título del gráfico').toMatch(/TRAYECTORIA DEL AHORRO ACUMULADO/)
expect(text, 'p.5 no debe tener texto placeholder de Etapa 6').not.toContain('próxima versión del reporte')
// Página 3: títulos uppercase InQ
expect(text, 'p.3 debe tener sección COMPOSICIÓN').toMatch(/COMPOSICIÓN DEL AHORRO|COMPOSICION DEL AHORRO/)
// Página 4: nota metodológica
expect(text, 'p.4 debe tener nota metodológica').toMatch(/NOTA METODOLOGICA|NOTA METODOLÓGICA/)
const orientations = await getPageOrientations(buffer)
console.log('Orientaciones ROI:', orientations)
expect(orientations).toEqual(['portrait', 'portrait', 'portrait', 'portrait', 'portrait'])
})
test('etapa-7: PDFs Actual y Automatizado intactos (no-regresión)', async ({ page }) => {
await setupAndSimulate(page)
for (const [tab, label] of [['actual', 'actual'], ['automatizado', 'automatizado']] as ['actual' | 'automatizado', string][]) {
const outPath = resolve(OUT_DIR, `etapa-7-${label}.pdf`)
await downloadPdf(page, tab, outPath)
const buffer = readFileSync(outPath)
const { numpages, text } = await parsePdf(buffer)
console.log(`Páginas ${label}: ${numpages}`)
expect(numpages, `${label}: 4 páginas`).toBe(4)
expect(text).toMatch(/InQ ROI/)
expect(text).toMatch(/Powered by InQuality/)
const orientations = await getPageOrientations(buffer)
console.log(`Orientaciones ${label}:`, orientations)
expect(orientations).toEqual(['portrait', 'landscape', 'portrait', 'portrait'])
}
})

View File

@@ -0,0 +1,210 @@
/**
* Tests de orientación de páginas PDF — Etapa 5 Sprint 1.5.
*
* Verifica que:
* - PDF Actual/Automatizado: portrait → landscape → portrait
* - PDF ROI: todas portrait (no tiene página BPMN)
* - addPage se llama con el argumento correcto en cada caso
*
* Nota: estos tests usan mocks de jsPDF y verifican las llamadas a addPage.
* La orientación real del PDF se valida en los tests E2E (etapa-5-pdfs.spec.ts).
*/
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((t: string) => [t]),
lastAutoTable: { finalY: 150 },
internal: {
getNumberOfPages: vi.fn().mockReturnValue(3),
pageSize: {
getWidth: vi.fn().mockReturnValue(210),
getHeight: vi.fn().mockReturnValue(297),
},
},
}
vi.mock('jspdf', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MockJsPDF(this: any) { return mockDoc }
return { jsPDF: MockJsPDF }
})
vi.mock('jspdf-autotable', () => ({
default: vi.fn(),
}))
import type { Process, Simulation } from '@/domain/types'
const mockProcess: Process = {
id: 'p1', name: 'Test Process',
clientName: 'Test Client',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
createdAt: 0, updatedAt: 0,
}
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-17T10:00:00Z').getTime(),
result: {
totalCost: 1200, totalDirectCost: 1000, totalIndirectCost: 200,
totalTimeMinutes: 90,
perActivity: [{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea 1',
expectedDirectCost: 1200, expectedIndirectCost: 200, expectedTotalCost: 1200,
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
}],
perResource: [], warnings: [],
},
resultAutomated: {
totalCost: 300, totalDirectCost: 250, totalIndirectCost: 50,
totalTimeMinutes: 20,
perActivity: [{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea 1',
expectedDirectCost: 300, expectedIndirectCost: 50, expectedTotalCost: 300,
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 10, resourceCostBreakdown: [],
}],
perResource: [], warnings: [],
},
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe('PDF orientation — Etapa 5', () => {
beforeEach(() => {
vi.clearAllMocks()
// Simular que getWidth/getHeight devuelven 210×297 por defecto (portrait)
// En landscape, jsPDF realmente cambia los valores internamente
mockDoc.internal.pageSize.getWidth.mockReturnValue(210)
mockDoc.internal.pageSize.getHeight.mockReturnValue(297)
})
describe('PDF Actual/Automatizado — mezcla portrait-landscape-portrait-portrait', () => {
it('llama addPage exactamente 3 veces para 4 páginas', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'actual',
})
expect(mockDoc.addPage).toHaveBeenCalledTimes(3)
})
it('primera addPage usa landscape (página BPMN)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'actual',
})
const firstCall = mockDoc.addPage.mock.calls[0]
expect(firstCall[0]).toBe('a4')
expect(firstCall[1]).toBe('landscape')
})
it('segunda addPage usa portrait (análisis + tabla)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'actual',
})
const secondCall = mockDoc.addPage.mock.calls[1]
expect(secondCall[0]).toBe('a4')
expect(secondCall[1]).toBe('portrait')
})
it('tercera addPage usa portrait (nota metodológica)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'actual',
})
const thirdCall = mockDoc.addPage.mock.calls[2]
expect(thirdCall[0]).toBe('a4')
expect(thirdCall[1]).toBe('portrait')
})
it('tab automatizado también usa landscape en página 2', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'automatizado',
})
const firstCall = mockDoc.addPage.mock.calls[0]
expect(firstCall[1]).toBe('landscape')
})
})
describe('PDF ROI — todas portrait', () => {
it('llama addPage 4 veces para 5 páginas (todas sin landscape)', async () => {
mockDoc.internal.getNumberOfPages.mockReturnValue(5)
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'roi',
activities: [],
})
expect(mockDoc.addPage).toHaveBeenCalledTimes(4)
// Ninguna llamada debe usar landscape
const hasLandscape = mockDoc.addPage.mock.calls.some(
(c: unknown[]) => c[1] === 'landscape'
)
expect(hasLandscape).toBe(false)
})
})
describe('drawHeatmapImage — dimensiones dinámicas', () => {
it('usa getWidth() del doc, no constante hardcodeada', async () => {
const { drawHeatmapImage } = await import('@/lib/export/pdf-sections')
// Simular página landscape (297mm wide)
mockDoc.internal.pageSize.getWidth.mockReturnValue(297)
mockDoc.internal.pageSize.getHeight.mockReturnValue(210)
const fakeDataUrl = 'data:image/jpeg;base64,/9j/test'
drawHeatmapImage(mockDoc as any, fakeDataUrl, 40)
expect(mockDoc.internal.pageSize.getWidth).toHaveBeenCalled()
// La imagen debe ser más ancha que en portrait (170mm)
const imgCallArgs = mockDoc.addImage.mock.calls[0]
// args: [dataUrl, format, x, y, width, height]
const imgWidth = imgCallArgs[4]
expect(imgWidth).toBeGreaterThan(170) // más ancho que portrait (297 - 40 = 257mm)
})
})
})

View File

@@ -0,0 +1,232 @@
/**
* Tests para la página 1 rediseñada del PDF ROI — Etapa 6 Sprint 1.5.
*
* Verifica:
* - drawRoiPage1 invoca los bloques clave (hero, top3, grid, banner)
* - exportToPdf ROI llama addPage 4 veces (5 páginas)
* - Ninguna página ROI usa landscape
* - El hero llama setFontSize(28) para la cifra principal
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
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((t: string) => [t]),
lastAutoTable: { finalY: 150 },
internal: {
getNumberOfPages: vi.fn().mockReturnValue(5),
pageSize: {
getWidth: vi.fn().mockReturnValue(210),
getHeight: vi.fn().mockReturnValue(297),
},
},
}
vi.mock('jspdf', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MockJsPDF(this: any) { return mockDoc }
return { jsPDF: MockJsPDF }
})
vi.mock('jspdf-autotable', () => ({
default: vi.fn(),
}))
import type { Process, Simulation } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
const mockProcess: Process = {
id: 'p1', name: 'Test Process',
clientName: 'Test Client',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0,
}
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-17T10:00:00Z').getTime(),
result: {
totalCost: 600, totalDirectCost: 500, totalIndirectCost: 100,
totalTimeMinutes: 90,
perActivity: [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea Alpha',
expectedDirectCost: 400, expectedIndirectCost: 80, expectedTotalCost: 400,
percentOfTotal: 66, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Tarea Beta',
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 200,
percentOfTotal: 34, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 20, resourceCostBreakdown: [],
},
],
perResource: [], warnings: [],
},
resultAutomated: {
totalCost: 60, totalDirectCost: 50, totalIndirectCost: 10,
totalTimeMinutes: 10,
perActivity: [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea Alpha',
expectedDirectCost: 40, expectedIndirectCost: 10, expectedTotalCost: 40,
percentOfTotal: 66, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 5, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Tarea Beta',
expectedDirectCost: 20, expectedIndirectCost: 5, expectedTotalCost: 20,
percentOfTotal: 34, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 5, resourceCostBreakdown: [],
},
],
perResource: [], warnings: [],
},
}
const mockRoi: RoiResult = {
savingsPerExecution: 540,
savingsPerExecutionPercent: 90,
annualSavings: 2_700_000,
paybackMonths: 0.36,
paybackYears: 0.03,
cumulativeSavingsHorizon: 8_020_000,
breaksEvenInHorizon: true,
roiAnnualPercent: 3375,
}
describe('PDF ROI — Página 1 rediseñada (Etapa 6)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDoc.internal.pageSize.getWidth.mockReturnValue(210)
mockDoc.internal.pageSize.getHeight.mockReturnValue(297)
mockDoc.internal.getNumberOfPages.mockReturnValue(5)
})
it('drawRoiPage1 completa sin errores y llama text() múltiples veces', async () => {
const { drawRoiPage1 } = await import('@/lib/export/pdf-sections-roi')
expect(() =>
drawRoiPage1(
mockDoc as any,
mockProcess,
mockRoi,
mockSimulation.executedAt,
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
'USD'
)
).not.toThrow()
expect(mockDoc.text.mock.calls.length).toBeGreaterThan(5)
})
it('drawRoiPage1 incluye texto "InQ ROI" en el header denso', async () => {
const { drawRoiPage1 } = await import('@/lib/export/pdf-sections-roi')
drawRoiPage1(
mockDoc as any,
mockProcess,
mockRoi,
mockSimulation.executedAt,
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
'USD'
)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0])
expect(textCalls).toContain('InQ ROI')
})
it('drawRoiPage1 incluye texto "TOP 3 ACTIVIDADES POR AHORRO"', async () => {
const { drawRoiPage1 } = await import('@/lib/export/pdf-sections-roi')
drawRoiPage1(
mockDoc as any,
mockProcess,
mockRoi,
mockSimulation.executedAt,
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
'USD'
)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0])
expect(textCalls).toContain('TOP 3 ACTIVIDADES POR AHORRO')
})
it('drawRoiPage1 usa setFontSize(28) para la cifra del KPI Hero', async () => {
const { drawRoiPage1 } = await import('@/lib/export/pdf-sections-roi')
drawRoiPage1(
mockDoc as any,
mockProcess,
mockRoi,
mockSimulation.executedAt,
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
'USD'
)
const fontSizes = mockDoc.setFontSize.mock.calls.map((c: number[]) => c[0])
expect(fontSizes).toContain(28)
})
it('drawRoiPage1 dibuja rectángulos para la grilla 2x2 y barras top3', async () => {
const { drawRoiPage1 } = await import('@/lib/export/pdf-sections-roi')
drawRoiPage1(
mockDoc as any,
mockProcess,
mockRoi,
mockSimulation.executedAt,
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
'USD'
)
// roundedRect para cards de KPI secundarios (4 cards) + metadata strip
expect(mockDoc.roundedRect).toHaveBeenCalled()
// rect para elementos del banner y barras top3
expect(mockDoc.rect).toHaveBeenCalled()
})
it('exportToPdf ROI: 4 addPage calls (5 páginas) sin landscape', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
tab: 'roi',
activities: [],
})
expect(mockDoc.addPage).toHaveBeenCalledTimes(4)
const hasLandscape = mockDoc.addPage.mock.calls.some(
(c: unknown[]) => c[1] === 'landscape'
)
expect(hasLandscape).toBe(false)
})
it('drawRoiPage1 dibuja nombre de actividad del top3 para actividades con ahorro', async () => {
const { drawRoiPage1 } = await import('@/lib/export/pdf-sections-roi')
drawRoiPage1(
mockDoc as any,
mockProcess,
mockRoi,
mockSimulation.executedAt,
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
'USD'
)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0])
expect(textCalls).toContain('Tarea Alpha')
})
})

View File

@@ -0,0 +1,291 @@
/**
* Tests Etapa 7 — PDF ROI páginas 2-5 con identidad InQ.
*
* Verifica:
* - Tabla (p.2): headStyles con fondo naranja claro, texto #92400E
* - Análisis (p.3): títulos uppercase naranja oscuro, "COMPOSICIÓN DEL AHORRO"
* - Metodología (p.4): título uppercase naranja oscuro, caja contenedora
* - Gráfico SVG (p.5): líneas, áreas, marker payback, anotaciones
* - Coordenadas del gráfico: inicio negativo, payback en 0, final positivo
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
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((t: string) => [t]),
lastAutoTable: { finalY: 150 },
internal: {
getNumberOfPages: vi.fn().mockReturnValue(5),
pageSize: {
getWidth: vi.fn().mockReturnValue(210),
getHeight: vi.fn().mockReturnValue(297),
},
},
}
vi.mock('jspdf', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MockJsPDF(this: any) { return mockDoc }
return { jsPDF: MockJsPDF }
})
vi.mock('jspdf-autotable', () => ({ default: vi.fn() }))
import type { Process, Simulation, Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
const mockProcess: Process = {
id: 'p1', name: 'Proceso Test', clientName: 'Cliente Test',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0,
}
const mockRoi: RoiResult = {
savingsPerExecution: 540,
savingsPerExecutionPercent: 90,
annualSavings: 2_700_000,
paybackMonths: 0.356,
paybackYears: 0.03,
cumulativeSavingsHorizon: 8_020_000,
breaksEvenInHorizon: true,
roiAnnualPercent: 3375,
}
const mockSimulation: Simulation = {
id: 's1', processId: 'p1',
executedAt: new Date('2026-05-17T10:00:00Z').getTime(),
result: {
totalCost: 600, totalDirectCost: 500, totalIndirectCost: 100,
totalTimeMinutes: 90,
perActivity: [
{ activityId: 'a1', bpmnElementId: 't1', activityName: 'Tarea A',
expectedDirectCost: 400, expectedIndirectCost: 80, expectedTotalCost: 400,
percentOfTotal: 66, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 30, resourceCostBreakdown: [] },
{ activityId: 'a2', bpmnElementId: 't2', activityName: 'Tarea B',
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 200,
percentOfTotal: 34, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 20, resourceCostBreakdown: [] },
],
perResource: [], warnings: [],
},
resultAutomated: {
totalCost: 60, totalDirectCost: 50, totalIndirectCost: 10,
totalTimeMinutes: 10,
perActivity: [
{ activityId: 'a1', bpmnElementId: 't1', activityName: 'Tarea A',
expectedDirectCost: 40, expectedIndirectCost: 10, expectedTotalCost: 40,
percentOfTotal: 66, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 5, resourceCostBreakdown: [] },
{ activityId: 'a2', bpmnElementId: 't2', activityName: 'Tarea B',
expectedDirectCost: 20, expectedIndirectCost: 5, expectedTotalCost: 20,
percentOfTotal: 34, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 5, resourceCostBreakdown: [] },
],
perResource: [], warnings: [],
},
}
const mockActivities: Activity[] = [
{ id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task',
automatable: true, directCostFixed: 400, executionTimeMinutes: 30,
automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [] },
{ id: 'a2', processId: 'p1', bpmnElementId: 't2', name: 'Tarea B', type: 'task',
automatable: false, directCostFixed: 200, executionTimeMinutes: 20,
automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [] },
]
describe('PDF ROI — Etapa 7 páginas 2-5', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDoc.internal.pageSize.getWidth.mockReturnValue(210)
mockDoc.internal.pageSize.getHeight.mockReturnValue(297)
mockDoc.internal.getNumberOfPages.mockReturnValue(5)
mockDoc.splitTextToSize.mockImplementation((t: string) => [t])
})
// ─── Página 2: tabla comparativa ────────────────────────────────────────────
describe('buildComparisonTableConfig — header paleta InQ', () => {
it('headStyles usa fondo #FFF8ED [255,248,237] (no naranja sólido)', async () => {
const { buildComparisonTableConfig } = await import('@/lib/export/pdf-sections-roi')
const config = buildComparisonTableConfig(
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
mockActivities, 'USD', 50
) as any
expect(config.headStyles.fillColor).toEqual([255, 248, 237])
})
it('headStyles textColor es #92400E [146,64,14]', async () => {
const { buildComparisonTableConfig } = await import('@/lib/export/pdf-sections-roi')
const config = buildComparisonTableConfig(
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
mockActivities, 'USD', 50
) as any
expect(config.headStyles.textColor).toEqual([146, 64, 14])
})
it('fila no-automatizable muestra "—" en columna Ahorro', async () => {
const { buildComparisonTableConfig } = await import('@/lib/export/pdf-sections-roi')
const config = buildComparisonTableConfig(
mockSimulation.result.perActivity,
mockSimulation.resultAutomated!.perActivity,
mockActivities, 'USD', 50
) as any
// Tarea B es no-automatizable — debe tener "—" en ahorro (columna índice 3)
const tareaB = (config.body as any[]).find((row: any[]) => row[0].content === 'Tarea B')
expect(tareaB).toBeDefined()
expect(tareaB[3].content).toBe('—')
expect(tareaB[4].content).toBe('—')
})
})
// ─── Página 3: análisis del ahorro ──────────────────────────────────────────
describe('drawRoiAnalysisPage — títulos InQ', () => {
it('dibuja "COMPOSICIÓN DEL AHORRO" (uppercase)', async () => {
const { drawRoiAnalysisPage } = await import('@/lib/export/pdf-sections-roi')
drawRoiAnalysisPage(mockDoc as any, mockRoi, mockProcess,
mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity,
mockActivities, 'USD', 50)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0])
expect(textCalls).toContain('COMPOSICIÓN DEL AHORRO')
})
it('dibuja "TRAYECTORIA DEL AHORRO ACUMULADO" (uppercase)', async () => {
const { drawRoiAnalysisPage } = await import('@/lib/export/pdf-sections-roi')
drawRoiAnalysisPage(mockDoc as any, mockRoi, mockProcess,
mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity,
mockActivities, 'USD', 50)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0])
expect(textCalls).toContain('TRAYECTORIA DEL AHORRO ACUMULADO')
})
it('dibuja nombre de actividad en bullets del top3', async () => {
const { drawRoiAnalysisPage } = await import('@/lib/export/pdf-sections-roi')
drawRoiAnalysisPage(mockDoc as any, mockRoi, mockProcess,
mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity,
mockActivities, 'USD', 50)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => String(c[0]))
const hasActivity = textCalls.some(t => t.includes('Tarea A') || t.includes('Tarea B'))
expect(hasActivity).toBe(true)
})
})
// ─── Página 4: nota metodológica ────────────────────────────────────────────
describe('drawRoiMethodologySection — paleta InQ', () => {
it('dibuja título "NOTA METODOLOGICA" (uppercase, sin caracteres Unicode)', async () => {
const { drawRoiMethodologySection } = await import('@/lib/export/pdf-sections-roi')
drawRoiMethodologySection(mockDoc as any, mockProcess, 50)
const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0])
const hasTitle = textCalls.some(t => String(t).includes('NOTA METODOLOGICA'))
expect(hasTitle).toBe(true)
})
it('dibuja caja contenedora (rect con relleno slate-50)', async () => {
const { drawRoiMethodologySection } = await import('@/lib/export/pdf-sections-roi')
drawRoiMethodologySection(mockDoc as any, mockProcess, 50)
// setFillColor para [248,250,252] slate-50 debe haberse llamado
const fillCalls = mockDoc.setFillColor.mock.calls
const hasSlate50 = fillCalls.some((c: number[]) => c[0] === 248 && c[1] === 250 && c[2] === 252)
expect(hasSlate50).toBe(true)
})
it('dibuja al menos 5 secciones (text calls para títulos de fórmulas)', async () => {
const { drawRoiMethodologySection } = await import('@/lib/export/pdf-sections-roi')
drawRoiMethodologySection(mockDoc as any, mockProcess, 50)
expect(mockDoc.text.mock.calls.length).toBeGreaterThan(6)
})
})
// ─── Página 5: gráfico SVG nativo ───────────────────────────────────────────
describe('drawTrajectoryChart — coordenadas matemáticas', () => {
it('dibuja líneas (eje Y, eje X, segmentos de curva)', async () => {
const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi')
drawTrajectoryChart(mockDoc as any, mockRoi, mockProcess, 'USD', 20, 170, 50)
expect(mockDoc.line.mock.calls.length).toBeGreaterThan(10)
})
it('dibuja rectángulos de área (relleno para negativo y positivo)', async () => {
const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi')
drawTrajectoryChart(mockDoc as any, mockRoi, mockProcess, 'USD', 20, 170, 50)
expect(mockDoc.rect.mock.calls.length).toBeGreaterThan(5)
})
it('punto inicial (mes 0) tiene valor = -inversión', () => {
// Con investment=80000 y annualSavings=2700000, valueAt(0) = -80000
const investment = 80000, annualSavings = 2_700_000
const valueAt = (month: number) => -investment + (annualSavings / 12) * month
expect(valueAt(0)).toBe(-80000)
})
it('punto de payback tiene valor = 0', () => {
const investment = 80000, annualSavings = 2_700_000
const paybackMonths = investment / (annualSavings / 12) // = 0.3556
const valueAt = (month: number) => -investment + (annualSavings / 12) * month
expect(valueAt(paybackMonths)).toBeCloseTo(0, 5)
})
it('punto final (mes 36) tiene valor = cumulativeSavingsHorizon', () => {
const investment = 80000, annualSavings = 2_700_000
const horizonMonths = 36
const valueAt = (month: number) => -investment + (annualSavings / 12) * month
const finalValue = valueAt(horizonMonths)
// annualSavings * 3 - investment = 8100000 - 80000 = 8020000
expect(finalValue).toBeCloseTo(8_020_000, 0)
})
it('incluye texto de anotaciones (inversión y ahorro acumulado)', async () => {
const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi')
drawTrajectoryChart(mockDoc as any, mockRoi, mockProcess, 'USD', 20, 170, 50)
// splitTextToSize se llama con el texto de anotaciones
expect(mockDoc.splitTextToSize).toHaveBeenCalled()
// text() se llama para etiquetas de ejes y anotaciones
expect(mockDoc.text.mock.calls.length).toBeGreaterThan(4)
})
it('sin payback en horizonte: no dibuja marker de payback', async () => {
const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi')
const roiSinPayback: RoiResult = {
...mockRoi,
breaksEvenInHorizon: false,
paybackMonths: Infinity,
}
// Solo verificar que no lanza error
expect(() =>
drawTrajectoryChart(mockDoc as any, roiSinPayback, mockProcess, 'USD', 20, 170, 50)
).not.toThrow()
})
})
// ─── exportToPdf ROI integración ────────────────────────────────────────────
describe('exportToPdf ROI — integración Etapa 7', () => {
it('sigue siendo 5 páginas (4 addPage calls) sin regresión', 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).toHaveBeenCalledTimes(4)
})
})
})