Python · Excel
Cómo automatizar un informe de Excel con Python
Si cada semana exportas datos, los pegas en una plantilla, rehaces las tablas y lo envías por correo, esa tarea se puede convertir en un script de menos de cien líneas. Aquí tienes el proceso completo, con las trampas típicas de los ficheros que genera un programa de gestión en español.
El punto de partida
Tomemos un caso muy común: el programa de gestión exporta las ventas a un CSV y alguien, cada lunes, lo abre, lo pega en una plantilla de Excel, rehace una tabla con el total de cada comercial por mes y la envía a dirección. Son veinte minutos o dos horas, según el día, y cada vez existe la posibilidad de equivocarse de columna.
El fichero exportado tiene este aspecto:
fecha;comercial;cliente;importe
03/10/2026;Lucía Pérez;Talleres Norte;1.250,00
03/10/2026; lucía pérez ;Bar Central;89,90
06/10/2026;Marcos Gil;Talleres Norte;430,50
07/11/2026;Marcos Gil;Ferretería Sol;2.004,75
Fíjate en tres detalles que rompen muchos scripts escritos deprisa: el separador es el punto y coma, los importes usan punto para los miles y coma para los decimales, y el mismo comercial aparece escrito de dos formas («Lucía Pérez» y « lucía pérez »).
El objetivo: un Excel con una hoja Resumen (comerciales por mes, con totales) y una hoja Detalle (todas las ventas), con formato de moneda y fechas, cabecera fija y filtros.
Preparar el entorno
Necesitas Python 3.10 o posterior y dos librerías: pandas para leer y calcular, y openpyxl para escribir el Excel y darle formato. Lo mejor es un entorno virtual dentro de la carpeta del proyecto, para que nada dependa de lo que haya instalado en el equipo:
python -m venv .venv
.venv\Scripts\python -m pip install pandas openpyxl # Windows
.venv/bin/python -m pip install pandas openpyxl # Linux o macOS
Todo el código va en un único fichero, informe.py. Empezamos por las importaciones y la configuración:
from datetime import date
from pathlib import Path
import pandas as pd
from openpyxl.styles import Alignment, Font, PatternFill
BASE = Path(__file__).resolve().parent
ENTRADA = BASE / "datos" / "ventas.csv"
SALIDA = BASE / "informes" / f"ventas-{date.today():%Y-%m-%d}.xlsx"
COLUMNAS = {"fecha", "comercial", "cliente", "importe"}
BASE hace que las rutas sean relativas a la carpeta del script y no a la carpeta desde la que se lanza. Parece un detalle, pero es lo que hace que el script funcione igual cuando lo ejecuta una tarea programada.
Leer los datos sin sorpresas
La tentación es usar pd.read_csv(..., decimal=",", thousands=".") y confiar en que pandas convierta los números. Funciona hasta el día en que una sola celda trae algo raro («n/d», un espacio, un guion): entonces pandas deja toda la columna como texto sin convertir y los totales salen mal o fallan. Es más seguro leerlo todo como texto y convertir nosotros, columna a columna:
def leer(ruta: Path) -> pd.DataFrame:
df = pd.read_csv(ruta, sep=";", dtype=str, keep_default_na=False, encoding="utf-8-sig")
df.columns = df.columns.str.strip().str.lower()
faltan = COLUMNAS - set(df.columns)
if faltan:
raise SystemExit(f"A {ruta.name} le faltan columnas: {', '.join(sorted(faltan))}")
df["fecha"] = pd.to_datetime(df["fecha"].str.strip(), format="%d/%m/%Y", errors="coerce")
df["comercial"] = df["comercial"].str.strip().str.title()
df["importe"] = pd.to_numeric(
df["importe"].str.strip().str.replace(".", "", regex=False).str.replace(",", ".", regex=False),
errors="coerce",
)
malas = df["fecha"].isna() | df["importe"].isna()
if malas.any():
print(f"Aviso: filas descartadas por fecha o importe ilegible: {malas.sum()}")
return df[~malas].drop_duplicates()
Qué hace cada parte:
dtype=strykeep_default_na=False: todo llega como texto y las celdas vacías como texto vacío, sin conversiones automáticas.encoding="utf-8-sig": lee UTF-8 y se salta la marca invisible (BOM) que añaden algunos programas al principio del fichero.- La comprobación de
COLUMNAShace que el script se pare con un mensaje claro si alguien cambia la exportación, en lugar de generar un informe incompleto sin avisar. format="%d/%m/%Y": indicar el formato exacto evita que el 03/10 se lea como 10 de marzo..str.strip().str.title()unifica « lucía pérez » y «Lucía Pérez».- Los importes: primero se quitan los puntos de miles y después la coma decimal pasa a punto.
errors="coerce"convierte lo ilegible en vacío para poder contarlo y descartarlo con un aviso.
Calcular el resumen
Con los datos limpios, la tabla dinámica es una sola llamada. margins=True añade la fila y la columna de totales:
def resumir(df: pd.DataFrame) -> pd.DataFrame:
tabla = (
df.assign(mes=df["fecha"].dt.strftime("%Y-%m"))
.pivot_table(index="comercial", columns="mes", values="importe",
aggfunc="sum", fill_value=0, margins=True, margins_name="Total")
.round(2)
.reset_index()
)
tabla.columns.name = None
return tabla
El mes se guarda como 2026-10 para que las columnas salgan ordenadas cronológicamente. Si prefieres «octubre 2026», cambia el nombre de las columnas justo antes de escribir el Excel, no antes de ordenar.
Escribir un Excel con formato
pandas escribe los datos y openpyxl se encarga del aspecto: cabecera en negrita con fondo, formato de euros y de fecha, anchos de columna, cabecera fija y filtros. Una función de formato sirve para las dos hojas:
CABECERA = PatternFill("solid", fgColor="1F2A44")
EUROS = '#,##0.00 "€"'
def largo(valor) -> int:
return 10 if hasattr(valor, "strftime") else len(str(valor))
def dar_formato(hoja, columnas_euros: set[str]) -> None:
for celda in hoja[1]:
celda.font = Font(bold=True, color="FFFFFF")
celda.fill = CABECERA
celda.alignment = Alignment(horizontal="center")
for columna in hoja.iter_cols():
cabecera = columna[0].value
for celda in columna[1:]:
if cabecera in columnas_euros:
celda.number_format = EUROS
elif cabecera == "fecha":
celda.number_format = "DD/MM/YYYY"
ancho = max(largo(c.value) for c in columna if c.value is not None)
hoja.column_dimensions[columna[0].column_letter].width = min(ancho + 3, 40)
hoja.freeze_panes = "B2"
hoja.auto_filter.ref = hoja.dimensions
def escribir(tabla: pd.DataFrame, detalle: pd.DataFrame, ruta: Path) -> None:
ruta.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(ruta, engine="openpyxl") as xw:
tabla.to_excel(xw, sheet_name="Resumen", index=False)
detalle.to_excel(xw, sheet_name="Detalle", index=False)
dar_formato(xw.sheets["Resumen"], set(tabla.columns) - {"comercial"})
dar_formato(xw.sheets["Detalle"], {"importe"})
Un detalle que confunde a mucha gente: el formato #,##0.00 "€" se escribe siempre con coma para los miles y punto para los decimales, pero Excel lo muestra con la configuración regional de quien abre el fichero. En un equipo en español verás 1.250,00 €. Y como las celdas siguen siendo números, el destinatario puede sumarlas o filtrarlas sin problemas.
Solo falta la función principal:
def main() -> None:
ventas = leer(ENTRADA)
resumen = resumir(ventas)
try:
escribir(resumen, ventas, SALIDA)
except PermissionError:
raise SystemExit(f"No puedo escribir {SALIDA.name}: ¿está abierto en Excel?")
print(f"Informe listo: {SALIDA} ({len(ventas)} ventas)")
if __name__ == "__main__":
main()
Con .venv\Scripts\python informe.py tendrás en informes/ un Excel con el nombre del día. Ábrelo y compáralo con el que hacías a mano: es el momento de descubrir diferencias, no dentro de tres meses.
Enviarlo por correo (opcional)
Si el informe tiene que llegar a alguien, la biblioteca estándar de Python basta. Las credenciales, en variables de entorno y nunca escritas en el código:
import os
import smtplib
from email.message import EmailMessage
def enviar(ruta: Path) -> None:
msg = EmailMessage()
msg["Subject"] = f"Informe de ventas {date.today():%d/%m/%Y}"
msg["From"] = "informes@tuempresa.es"
msg["To"] = "direccion@tuempresa.es"
msg.set_content("Adjunto el informe de ventas de esta semana.")
msg.add_attachment(
ruta.read_bytes(),
maintype="application",
subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=ruta.name,
)
with smtplib.SMTP("smtp.tuempresa.es", 587) as smtp:
smtp.starttls()
smtp.login(os.environ["SMTP_USUARIO"], os.environ["SMTP_CLAVE"])
smtp.send_message(msg)
Llama a enviar(SALIDA) al final de main(). Si tu proveedor de correo exige verificación en dos pasos, tendrás que crear una contraseña de aplicación para el script.
Que se ejecute solo
En Windows, el Programador de tareas lo lanza cada lunes a las 8:00. Desde una consola:
schtasks /Create /TN "Informe de ventas" /SC WEEKLY /D MON /ST 08:00 ^
/TR "C:\informes\.venv\Scripts\python.exe C:\informes\informe.py"
En Linux, una línea en el crontab del usuario hace lo mismo y guarda la salida en un registro:
0 8 * * 1 /opt/informes/.venv/bin/python /opt/informes/informe.py >> /opt/informes/informe.log 2>&1
Ten en cuenta que la tarea solo se ejecuta si el equipo está encendido a esa hora. Para un informe del que depende alguien, es mejor que viva en un servidor.
Errores típicos y cómo evitarlos
UnicodeDecodeErroral leer el CSV. El fichero no está en UTF-8: los CSV que guarda Excel en un Windows en español suelen estar en Windows-1252. Prueba conencoding="cp1252".- Fechas con el día y el mes cambiados. Indica siempre el formato con
format=; no dejes que pandas lo adivine. - Totales a cero o en blanco. Casi siempre es una columna de importes que se ha quedado como texto. Por eso convertimos a mano y contamos las filas descartadas.
PermissionErroral guardar. El Excel del día ya está abierto. El script lo explica en lugar de mostrar un error críptico.- Funciona a mano, pero no en la tarea programada. Rutas relativas. Con
BASElas rutas dependen de la ubicación del script y no de la carpeta de trabajo. - Un día cambian el nombre de una columna. La comprobación de
COLUMNASpara el script con un mensaje claro antes de generar un informe incompleto.
¿Y si no quieres mantener el script?
Un script así es fácil de escribir; lo que cuesta es mantenerlo: que alguien sepa ejecutarlo, que no dependa del ordenador de una persona y que avise cuando algo cambia. Por eso las automatizaciones que entrego las dejo en Skynex Py: un botón «Ejecutar» en el navegador, ejecución programada en el servidor, historial y los ficheros listos para descargar.
Si tienes un informe parecido y prefieres que lo haga yo, cuéntamelo con un fichero de ejemplo y te digo cuánto costaría.