cuando termines y verifica que esté todo subido
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user