# Capability: excel

CRUD de hojas de cálculo Excel (`.xlsx`) desde el registry con openpyxl: escribir libros multi-hoja, actualizar una hoja sin destruir las demás (preservando columnas editadas a mano), leer a estructuras en memoria o a markdown, añadir gráficos nativos, e ingerir una hoja a DuckDB.

Es el extremo Excel del **stack de datos** `Excel → DuckDB → Postgres → visualización`: el Excel sirve como entrada (lo que produce un humano o un export) y como entregable (un libro con gráficos que viaja por email/disco, sin servidor). El round-trip humano lo cubre `upsert_xlsx_sheet`, que conserva las columnas que las personas rellenan a mano mientras regenera las columnas calculadas.

## Funciones

| ID | Firma | Que hace |
|---|---|---|
| `write_xlsx_sheets_py_infra` | `write_xlsx_sheets(out_path, sheets, header_bold=True, autofit=True, freeze_header=True) -> str` | Escribe (o sobrescribe) un libro `.xlsx` multi-hoja desde un dict `{nombre_hoja: datos}`. Cada hoja acepta `list[list]` (primera fila = headers) o `{"headers": [...], "rows": [[...]]}`. Cabecera en negrita, auto-ancho, freeze de cabecera. Devuelve la ruta absoluta. |
| `upsert_xlsx_sheet_py_infra` | `upsert_xlsx_sheet(xlsx_path, sheet_name, records, columns, key_col="", preserve_cols=None, formulas=None, backup=True, ...) -> dict` | Actualiza NO destructivamente UNA hoja: reescribe solo `sheet_name` y conserva las demás. Antes de limpiar, lee por `key_col` las columnas de trabajo manual (`preserve_cols`) y las reescribe ganando sobre los datos nuevos. Cabecera estilizada, freeze, autofilter, fórmulas por columna, backup `.bak`. |
| `read_xlsx_py_infra` | `read_xlsx(path, sheet=None, max_rows=None, header=True) -> dict` | Lee un `.xlsx` a memoria (NO a markdown). Devuelve `{status, sheets: {nombre: {headers, rows}}}`. `sheet=None` lee todas. Tipos de celda: fechas→ISO, int/float, bool, None, fórmulas (valor calculado, `data_only=True`). Espejo en lectura de `write_xlsx_sheets`. |
| `excel_to_markdown_py_core` | `excel_to_markdown(path, max_rows_per_sheet=1000) -> str` | Convierte `.xlsx/.xls/.xlsm` a markdown, cada hoja como sección H2. Para inspección rápida / pegar en un prompt o nota. |
| `add_xlsx_chart_py_infra` | `add_xlsx_chart(xlsx_path, sheet_name, chart_type, data_range, cats_range=None, anchor='H2', title='', x_title='', y_title='') -> dict` | Añade un gráfico nativo (`bar`/`line`/`pie`/`scatter`) a una hoja EXISTENTE, refiriendo rangos de celdas ya escritos (notación Excel `'C1:C7'`). `anchor` = celda destino. La pieza para generar hojas Excel CON gráficos. |
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | Ingesta una hoja del `.xlsx` a una tabla DuckDB con la extensión nativa `excel` de DuckDB. Puente Excel→DuckDB. También etiquetada en el grupo `duckdb`. |

## Ejemplo canónico

Escribir un libro, añadirle un gráfico y releerlo a memoria (verificado):

```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from infra import write_xlsx_sheets, add_xlsx_chart, read_xlsx

xlsx = "/tmp/ventas.xlsx"
write_xlsx_sheets(xlsx, {"ventas": [
    ["mes", "categoria", "importe"],
    ["2026-01", "neumaticos", 12500.50],
    ["2026-02", "neumaticos", 15800.75],
    ["2026-03", "neumaticos", 18200.00],
]})

# Gráfico de barras del importe por mes, anclado en la celda G2
add_xlsx_chart(xlsx, "ventas", "bar", data_range="C1:C4", cats_range="A2:A4",
               anchor="G2", title="Importe por mes", y_title="EUR")

rd = read_xlsx(xlsx, sheet="ventas")
print(rd["sheets"]["ventas"]["headers"], len(rd["sheets"]["ventas"]["rows"]))
PYEOF
```

## Gotchas del grupo

- **openpyxl no evalúa fórmulas.** `read_xlsx` con `data_only=True` devuelve el valor **cacheado** por la última app que guardó el libro (Excel/LibreOffice). Un `.xlsx` con fórmulas escritas por openpyxl y nunca abierto en una hoja de cálculo devuelve `None` en esas celdas.
- **`add_xlsx_chart` exige libro y hoja existentes:** no crea el `.xlsx` ni escribe datos; los rangos deben apuntar a celdas ya escritas. Flujo: `write_xlsx_sheets` → `add_xlsx_chart`.
- **Rangos 1-indexed, notación Excel** (`'C1:C7'`). Si `data_range` incluye la fila de cabecera, el nombre de la serie sale de esa celda (`titles_from_data`). `scatter` usa `data_range` como Y y `cats_range` como X; `pie` ignora los títulos de eje.
- **Carga en memoria:** openpyxl carga el libro entero; para libros muy grandes considera ingerir a DuckDB (`excel_to_duckdb`) y consultar allí.
- **`upsert_xlsx_sheet` es la vía para datos editados por humanos:** si una persona rellena columnas a mano, pásalas en `preserve_cols` para que un re-volcado no las pise.

## Fronteras

- NO es una herramienta de BI ni de dashboards. Para visualización interactiva/compartida: Metabase, Evidence (sobre DuckDB) o gráficos embebidos con `add_xlsx_chart` para el caso "todo en el .xlsx".
- El análisis pesado (agregaciones, joins, histórico) NO se hace en Excel: ingiere a DuckDB con `excel_to_duckdb` y usa el grupo `duckdb`.
- NO cubre `.csv` de entrada con encodings legacy — eso es `safe_read_csv_fallback_py_core`.

## Relación con otros grupos

- `duckdb` — `excel_to_duckdb` es el puente de entrada; el motor analítico vive allí.
- `postgres` — la salida hacia BI pasa por `duckdb_to_postgres` (grupo `duckdb`/`postgres`).
- `metabase` — consume los datos una vez en Postgres.
