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:
2026-06-15 01:33:35 +02:00
parent e1e9bb7499
commit a90b7443e4
20 changed files with 1855 additions and 2 deletions
+232
View File
@@ -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)