a90b7443e4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
8.2 KiB
Python
233 lines
8.2 KiB
Python
"""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)
|