--- 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: [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.