"""Actualiza de forma NO DESTRUCTIVA una hoja concreta de un archivo .xlsx. Generalizacion de la logica probada en afiliados/build_xlsx.py. La garantia central es que SOLO se reescribe la hoja indicada (sheet_name); cualquier otra hoja del libro (trabajo del usuario) se conserva intacta. Dentro de la hoja gestionada se preservan las columnas de trabajo manual (preserve_cols) haciendo match por una columna clave (key_col): si la celda ya tiene valor en el libro existente, el valor manual gana sobre el dato nuevo. Requiere openpyxl (instalado en python/.venv). """ import os import shutil from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter def _norm(value): """Normaliza una clave para el match (lowercase, espacios colapsados).""" return " ".join(str(value).lower().split()) def _read_preserved(ws, key_col, preserve_cols): """Lee de una hoja existente los valores manuales por clave. Localiza las columnas por su nombre en la cabecera (fila 1). Devuelve un dict {clave_normalizada: {nombre_columna: valor}} con solo las celdas no vacias de las columnas en preserve_cols. """ preserved = {} if not preserve_cols or not key_col or ws.max_row < 2: return preserved header = {} for c in range(1, ws.max_column + 1): name = ws.cell(row=1, column=c).value if name is not None: header[name] = c if key_col not in header: return preserved for r in range(2, ws.max_row + 1): raw_key = ws.cell(row=r, column=header[key_col]).value if raw_key in (None, ""): continue key = _norm(raw_key) vals = {} for col in preserve_cols: if col in header: v = ws.cell(row=r, column=header[col]).value if v not in (None, ""): vals[col] = v if vals: preserved[key] = vals return preserved def _style_objects(): return { "header_fill": PatternFill("solid", fgColor="1F4E78"), "header_font": Font(bold=True, color="FFFFFF", size=11), "border": Border(*[Side(style="thin", color="D9D9D9")] * 4), "center": Alignment(horizontal="center", vertical="center"), "wrap": Alignment(vertical="center", wrap_text=True), } def _normalize_formulas(formulas): """Acepta {col: "plantilla"} o {col: {"f": ..., "fmt": ...}} y normaliza siempre a {col: {"f": plantilla, "fmt": formato_o_None}}. """ out = {} if not formulas: return out for col, spec in formulas.items(): if isinstance(spec, dict): out[col] = {"f": spec.get("f", ""), "fmt": spec.get("fmt")} else: out[col] = {"f": str(spec), "fmt": None} return out def upsert_xlsx_sheet( xlsx_path: str, sheet_name: str, records: list, columns: list, key_col: str = "", preserve_cols=None, formulas=None, backup: bool = True, freeze: str = "A2", autofilter: bool = True, ) -> dict: """Reescribe una sola hoja de un .xlsx conservando el resto del libro. Args: xlsx_path: Ruta del archivo .xlsx. Si no existe se crea un libro nuevo. sheet_name: Nombre de la hoja a (re)escribir. Es la UNICA hoja tocada. records: Lista de dicts; cada dict es una fila, sus claves son nombres de columna. columns: Orden canonico de columnas (nombres de cabecera). Define que columnas se escriben y en que orden. key_col: Nombre de la columna clave usada para el match al preservar trabajo manual. Vacio (default) desactiva la preservacion. preserve_cols: Lista de columnas cuyos valores manuales se conservan si ya existian en el libro (ganan sobre el dato nuevo de records). formulas: Dict opcional {columna: {"f": plantilla, "fmt": formato}} o {columna: "plantilla"}. La plantilla admite {row} (numero de fila) y {NombreColumna} (letra de esa columna). Estas columnas se escriben como formula y NO se rellenan desde records. backup: Si True y el archivo existe, copia a xlsx_path + ".bak" antes de escribir. freeze: Celda para freeze_panes (default "A2"). autofilter: Si True activa auto_filter sobre el rango de datos. Returns: Dict con sheet, rows_written, other_sheets_preserved, manual_cells_preserved y backup_path. """ preserve_cols = preserve_cols or [] formulas = _normalize_formulas(formulas) backup_path = "" if os.path.exists(xlsx_path): if backup: backup_path = xlsx_path + ".bak" shutil.copy2(xlsx_path, backup_path) wb = load_workbook(xlsx_path) else: wb = Workbook() # Quitar la hoja por defecto vacia; se crearan/usaran las gestionadas. wb.remove(wb.active) # Indice de columna por nombre y letra correspondiente (basado en `columns`). col_index = {name: i + 1 for i, name in enumerate(columns)} col_letter = {name: get_column_letter(i + 1) for i, name in enumerate(columns)} formula_cols = set(formulas.keys()) # Preservar trabajo manual ANTES de limpiar la hoja. if sheet_name in wb.sheetnames: ws = wb[sheet_name] preserved = _read_preserved(ws, key_col, preserve_cols) if ws.max_row: ws.delete_rows(1, ws.max_row) else: ws = wb.create_sheet(title=sheet_name) preserved = {} manual_cells_preserved = sum(len(v) for v in preserved.values()) st = _style_objects() # Cabecera estilizada (fila 1). for name in columns: cell = ws.cell(row=1, column=col_index[name], value=name) cell.fill = st["header_fill"] cell.font = st["header_font"] cell.alignment = st["center"] cell.border = st["border"] # Filas de datos. for idx, record in enumerate(records): er = idx + 2 key = _norm(record.get(key_col, "")) if key_col else "" pres = preserved.get(key, {}) if key else {} for name in columns: c = ws.cell(row=er, column=col_index[name]) c.border = st["border"] c.alignment = st["wrap"] if name in formula_cols: # Sustituir {row} y {NombreColumna} -> letra de columna. template = formulas[name]["f"] rendered = template.format(row=er, **col_letter) c.value = rendered fmt = formulas[name]["fmt"] if fmt: c.number_format = fmt continue if name in preserve_cols and name in pres: c.value = pres[name] else: c.value = record.get(name, "") # Auto-ancho por columna: max(len(nombre)+2, 10) acotado a 48, mirando datos. for name in columns: max_len = len(name) for record in records: if name in formula_cols: break v = str(record.get(name, "")) if len(v) > max_len: max_len = len(v) ws.column_dimensions[col_letter[name]].width = min(max(max_len + 2, 10), 48) ws.freeze_panes = freeze if autofilter and columns: last_col = col_letter[columns[-1]] ws.auto_filter.ref = f"A1:{last_col}{max(len(records) + 1, 1)}" wb.save(xlsx_path) other_sheets = [s for s in wb.sheetnames if s != sheet_name] return { "sheet": sheet_name, "rows_written": len(records), "other_sheets_preserved": other_sheets, "manual_cells_preserved": manual_cells_preserved, "backup_path": backup_path, } if __name__ == "__main__": import tempfile tmp = os.path.join(tempfile.mkdtemp(), "demo.xlsx") result = upsert_xlsx_sheet( xlsx_path=tmp, sheet_name="Datos", records=[ {"Programa": "Alpha", "Clicks": 100, "Ingreso": 50}, {"Programa": "Beta", "Clicks": 200, "Ingreso": 80}, ], columns=["Programa", "Clicks", "Ingreso", "EPC"], key_col="Programa", preserve_cols=["Ingreso"], formulas={"EPC": {"f": '=IFERROR({Ingreso}{row}/{Clicks}{row},"")', "fmt": "0.00"}}, ) print(result)