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:
107
BACKLOG.md
Normal file
107
BACKLOG.md
Normal 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
113
OBSERVATIONS.md
Normal 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
166
TECH_DEBT.md
Normal 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
91
TODO.md
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
193
methodology/TEMPLATES/CLAUDE_PERMISSIONS_TEMPLATE.md
Normal file
193
methodology/TEMPLATES/CLAUDE_PERMISSIONS_TEMPLATE.md
Normal 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)
|
||||
280
sprints/sprint-1-5/ETAPA_5_PROMPT.md
Normal file
280
sprints/sprint-1-5/ETAPA_5_PROMPT.md
Normal 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.
|
||||
480
sprints/sprint-1-5/ETAPA_6_PROMPT.md
Normal file
480
sprints/sprint-1-5/ETAPA_6_PROMPT.md
Normal 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.
|
||||
452
sprints/sprint-1-5/ETAPA_7_PROMPT.md
Normal file
452
sprints/sprint-1-5/ETAPA_7_PROMPT.md
Normal 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.
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
181
tests/e2e/etapa-4-pdfs.spec.ts
Normal file
181
tests/e2e/etapa-4-pdfs.spec.ts
Normal 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 (15–80 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')
|
||||
})
|
||||
180
tests/e2e/etapa-5-pdfs.spec.ts
Normal file
180
tests/e2e/etapa-5-pdfs.spec.ts
Normal 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'])
|
||||
})
|
||||
177
tests/e2e/etapa-6-pdfs.spec.ts
Normal file
177
tests/e2e/etapa-6-pdfs.spec.ts
Normal 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'])
|
||||
}
|
||||
})
|
||||
157
tests/e2e/etapa-7-pdfs.spec.ts
Normal file
157
tests/e2e/etapa-7-pdfs.spec.ts
Normal 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'])
|
||||
}
|
||||
})
|
||||
210
tests/lib/export/pdf-orientation.test.ts
Normal file
210
tests/lib/export/pdf-orientation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
232
tests/lib/export/pdf-roi-page1.test.ts
Normal file
232
tests/lib/export/pdf-roi-page1.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
291
tests/lib/export/pdf-roi-pages2-5.test.ts
Normal file
291
tests/lib/export/pdf-roi-pages2-5.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user