Skynex Pide presupuesto

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:

datos/ventas.csvCSV
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:

informe.py1 de 5
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:

informe.py2 de 5
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=str y keep_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 COLUMNAS hace 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:

informe.py3 de 5
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:

informe.py4 de 5
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:

informe.py5 de 5
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

  • UnicodeDecodeError al 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 con encoding="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.
  • PermissionError al 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 BASE las 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 COLUMNAS para 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.

¿Tienes una tarea que te roba horas?

Cuéntamela en dos líneas. Te respondo con preguntas concretas y, si encaja, con un presupuesto cerrado por escrito. Sin compromiso.