---
name: upsert_xlsx_sheet
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def upsert_xlsx_sheet(xlsx_path: str, sheet_name: str, records: list[dict], columns: list[str], key_col: str = \"\", preserve_cols: list[str] | None = None, formulas: dict | None = None, backup: bool = True, freeze: str = \"A2\", autofilter: bool = True) -> dict"
description: "Actualiza de forma NO DESTRUCTIVA una hoja concreta de un archivo .xlsx con openpyxl. Reescribe SOLO la hoja indicada (sheet_name) y conserva intactas las demas hojas del libro. Antes de limpiar la hoja gestionada lee, por una columna clave (key_col), los valores de las columnas de trabajo manual (preserve_cols) y los reescribe ganando sobre los datos nuevos. Cabecera estilizada (negrita, relleno, texto blanco, borde, centrado), freeze_panes, autofilter, auto-ancho de columnas, formulas por columna con placeholders {row} y {NombreColumna}, y backup .bak opcional. Devuelve un resumen con filas escritas, hojas conservadas y celdas manuales preservadas."
tags: [excel, xlsx, openpyxl, spreadsheet, office, onlyoffice, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [openpyxl]
params:
  - name: xlsx_path
    desc: "Ruta del archivo .xlsx. Si existe se abre con openpyxl (y se respaldan las demas hojas); si no existe se crea un libro nuevo eliminando la hoja por defecto vacia."
  - name: sheet_name
    desc: "Nombre de la hoja a (re)escribir. Es la UNICA hoja que la funcion toca; el resto del libro queda intacto. Si la hoja no existe, se crea."
  - name: records
    desc: "Lista de dicts; cada dict es una fila y sus claves son nombres de columna. Se escribe una fila por record en el orden definido por columns."
  - name: columns
    desc: "Orden canonico de columnas (nombres de cabecera). Define que columnas se escriben y en que orden. Una clave de un record que no este en columns se ignora."
  - name: key_col
    desc: "Nombre de la columna clave usada para emparejar filas existentes con records al preservar trabajo manual. Vacio (default) desactiva la preservacion. El match normaliza el valor (lowercase + espacios colapsados)."
  - name: preserve_cols
    desc: "Lista de columnas cuyos valores manuales existentes en el libro se conservan: si una celda ya tenia valor para esa clave, ese valor gana sobre el de records. None o lista vacia desactiva la preservacion."
  - name: formulas
    desc: "Dict opcional {columna: \"plantilla\"} o {columna: {\"f\": plantilla, \"fmt\": formato_numerico}}. La plantilla admite {row} (numero de fila actual) y {NombreColumna} (letra de la columna con ese nombre). Estas columnas se escriben como formula en cada fila y NO se rellenan desde records."
  - name: backup
    desc: "Si True y el archivo existe, copia a xlsx_path + '.bak' antes de escribir. El .bak es rotativo: cada llamada lo sobrescribe. Default True."
  - name: freeze
    desc: "Celda para freeze_panes (inmoviliza filas/columnas por encima/izquierda). Default 'A2' (congela la cabecera)."
  - name: autofilter
    desc: "Si True activa auto_filter sobre el rango A1 hasta la ultima columna y fila de datos. Default True."
output: "Dict con: sheet (nombre de la hoja escrita), rows_written (numero de filas de datos), other_sheets_preserved (lista con los nombres de las demas hojas conservadas), manual_cells_preserved (cuantas celdas de trabajo manual se conservaron) y backup_path (ruta del .bak creado, o cadena vacia si no hubo backup)."
tested: true
tests: ["test_no_destructivo_y_preserva_trabajo_manual", "test_crea_libro_nuevo_si_no_existe"]
test_file_path: "python/functions/infra/upsert_xlsx_sheet_test.py"
file_path: "python/functions/infra/upsert_xlsx_sheet.py"
---

## Ejemplo

```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.upsert_xlsx_sheet import upsert_xlsx_sheet

result = upsert_xlsx_sheet(
    xlsx_path="/home/enmanuel/afiliados/programas_afiliados.xlsx",
    sheet_name="Datos",
    records=[
        {"Programa": "Awin", "Clicks": 1200, "Ingreso": 340},
        {"Programa": "CJ",   "Clicks": 800,  "Ingreso": 210},
    ],
    columns=["Programa", "Clicks", "Ingreso", "EPC"],
    key_col="Programa",
    preserve_cols=["Ingreso"],            # el ingreso anotado a mano gana sobre el dato nuevo
    formulas={
        "EPC": {"f": '=IFERROR({Ingreso}{row}/{Clicks}{row},"")', "fmt": "0.00"},
    },
)
print(result)
# {'sheet': 'Datos', 'rows_written': 2, 'other_sheets_preserved': ['Personal'],
#  'manual_cells_preserved': 1, 'backup_path': '/home/enmanuel/afiliados/programas_afiliados.xlsx.bak'}
```

La columna EPC se escribe como formula `=IFERROR(C2/B2,"")` (las letras se
resuelven a partir de la posicion de `Ingreso` y `Clicks` en `columns`), y la
hoja "Personal" del usuario no se toca.

## Cuando usarla

Usala cuando necesites regenerar UNA hoja de un .xlsx que el usuario tambien
edita a mano, sin destruir su trabajo: refrescar datos de research/scraping
manteniendo columnas anotadas manualmente, o publicar un dataset en una hoja
concreta de un libro que contiene otras hojas personales. Es la pieza de
escritura para flujos donde un editor (OnlyOffice/Excel) y un proceso
automatico comparten el mismo archivo.

## Gotchas

- **Impura — escribe en disco.** Pisa SOLO la hoja `sheet_name`; las demas hojas
  del libro se conservan tal cual (esa es la garantia central de la funcion).
- **Requiere openpyxl** (ya instalado en `python/.venv`).
- **El .bak es rotativo**: cada llamada con `backup=True` sobrescribe
  `xlsx_path + ".bak"`. No es un historial; es la copia de la version anterior.
- **Lee del disco.** Si el archivo esta abierto en OnlyOffice/Excel, GUARDA en
  el editor ANTES de llamar a la funcion: ella lee la version en disco y, al
  recargar el editor despues, perderias los cambios no guardados.
- **Las columnas se localizan por nombre en la cabecera (fila 1).** Si renombras
  una columna entre ejecuciones, el match de `preserve_cols`/`key_col` con la
  version anterior se rompe para esa columna (se trata como columna nueva).
- **`key_col` vacio o `preserve_cols` vacio** desactivan la preservacion: la hoja
  se reescribe por completo desde `records`.
- Las columnas declaradas en `formulas` se escriben siempre como formula y NO se
  rellenan desde `records` ni se preservan.
