Tüm yazılar
Veri Otomasyonu 5 dk okuma

Şablondan otomatik rapor üretimi: tek komutla HTML ve PDF çıktı

Aylık raporu tek komutluk boru hattına çevirin: SQL sorgusu, Jinja2 şablonu, base64 gömülü grafik ve PDF çıktı; sayı biçimi, boş veri ve kesim saati dahil.

#python #automation #data-pipeline

Her ayın ilk iş günü aynı ritüel: sorguyu çalıştır, sonucu Excel'e yapıştır, grafiği yeniden çiz, Word'e taşı, PDF'e çevir, gönder. Bu döngüyü kendi işlerimde birkaç kez yaşadıktan sonra kendime bir kural koydum: elle üçüncü kez hazırlanan rapor artık betiğe dönüşür. Şablondan otomatik rapor üretimi tam olarak bunu yapar: veri bir sorgudan çıkar, Jinja2 şablonuna akar, grafik dosyanın içine gömülür ve tek komutla hem HTML hem PDF çıktı alınır.

Kazanç yalnızca zaman değil. Elle hazırlanan raporun en sinsi hatası, sayının yanlış hücreden kopyalanması ya da grafiğin geçen aydan kalmasıdır; okuyan kişi bunu fark edemez. Otomatik boru hattında metin, tablo ve grafik aynı veri sözlüğünden beslendiği için birbirinden kopamaz. Üstelik rapor "ne zaman üretildim, hangi saate kadarki veriyi içeriyorum" sorusuna kendisi cevap verir; bu iki satır, okuyanın rapora duyduğu güveni tek başına değiştirir.

Bu yazıda kendi kullandığım yapıyı çalışan kod parçalarıyla anlatacağım: veri sözlüğünü şablona basan üretici betik ve grafiği base64 olarak gömüp tek dosyalık rapor çıkaran yardımcı. Örnekler Python 3.9 ve üzeri, Jinja2, matplotlib ve WeasyPrint ile yazıldı; hepsi pip ile kurulur.

Boru hattı dört parçadan oluşur

Her rapor üreticisini aynı iskelet üzerine kuruyorum. Parçalar birbirinden bağımsız olduğu için veri kaynağı değişince şablona, şablon değişince sorguya dokunmam gerekmiyor.

  • Veri katmanı: parametreli bir sorgu dönem ve kesim saatini alır, satır listesi döner. Ben veriyi çoğunlukla SQLite'ta biriktiriyorum; tek dosya olduğu için rapor betiği de aynı makinede koşuyor.
  • Veri sözlüğü: şablonun göreceği her şey tek bir dict içinde toplanır: dönem, satırlar, toplam, üretim zamanı, kesim saati, grafik. Bu sözlük veri ile şablon arasındaki sözleşmedir; ikisini birbirinden bağımsız test edebilmenizi sağlar.
  • Şablon: Jinja2 ile yazılmış bir HTML dosyası. Görünüm burada yaşar; betikte tek satır HTML yoktur.
  • Çıktı: önce tek dosyalık HTML, istenirse ondan PDF. HTML'i tarayıcıda açıp kontrol etmek, her denemede PDF üretmekten çok daha hızlı.

Veri kaynağı bir Excel tablosuysa sorgu adımının yerini okuma ve doğrulama adımı alır; bozuk hücrelerin rapora sızmaması için Excel kalite kontrol otomasyonunda anlattığım denetimleri tam bu noktaya koyuyorum.

Üretici betik: veri sözlüğünü şablona basmak

Aşağıdaki betik tek komutla çalışır: python rapor_uret.py --donem 2026-08. Sorguyu koşturur, sözlüğü kurar, şablonu işler ve HTML dosyasını yazar. Sayı biçimlendirme şablona filtre olarak veriliyor; böylece "binlik ayracı nokta, ondalık ayracı virgül" kuralı tek yerde duruyor.

import argparse
import sqlite3
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

from jinja2 import Environment, FileSystemLoader, select_autoescape

from grafik import cubuk_grafik_base64   # ikinci örnekteki yardımcı

TZ = ZoneInfo("Europe/Istanbul")


def tr_sayi(deger, ondalik=0):
    """1234567.891 -> '1.234.568'  |  None -> '—'"""
    if deger is None:
        return "—"
    metin = f"{deger:,.{ondalik}f}"                # 1,234,568
    return metin.replace(",", "X").replace(".", ",").replace("X", ".")


def veri_topla(db_yolu, donem, kesim):
    conn = sqlite3.connect(db_yolu)
    conn.row_factory = sqlite3.Row
    satirlar = conn.execute(
        """
        SELECT musteri, SUM(tutar) AS tutar, COUNT(*) AS adet
        FROM satislar
        WHERE strftime('%Y-%m', tarih) = ? AND olusturma < ?
        GROUP BY musteri
        ORDER BY tutar DESC
        """,
        (donem, kesim.strftime("%Y-%m-%d %H:%M:%S")),
    ).fetchall()
    conn.close()
    return [dict(s) for s in satirlar]


def baglam_kur(donem, satirlar, kesim):
    return {
        "donem": donem,
        "uretim_zamani": datetime.now(TZ),
        "veri_kesim": kesim,
        "satirlar": satirlar,
        "toplam": sum(s["tutar"] for s in satirlar),
        "grafik": cubuk_grafik_base64(satirlar) if satirlar else None,
    }


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--donem", required=True, help="YYYY-AA, örn. 2026-08")
    p.add_argument("--db", default="veri.db")
    args = p.parse_args()

    # kesim = içinde bulunulan saatin başı; betik 09:03'te bitse de rapor 09:00 der
    kesim = datetime.now(TZ).replace(minute=0, second=0, microsecond=0)
    satirlar = veri_topla(args.db, args.donem, kesim)

    env = Environment(
        loader=FileSystemLoader("sablonlar"),
        autoescape=select_autoescape(["html"]),
    )
    env.filters["sayi"] = tr_sayi
    env.filters["tarih"] = lambda d: d.strftime("%d.%m.%Y %H:%M")

    html = env.get_template("rapor.html").render(baglam_kur(args.donem, satirlar, kesim))
    cikti = Path("cikti") / f"rapor-{args.donem}.html"
    cikti.parent.mkdir(exist_ok=True)
    cikti.write_text(html, encoding="utf-8")
    print(f"yazıldı: {cikti} ({len(satirlar)} satır, kesim {kesim:%d.%m.%Y %H:%M})")


if __name__ == "__main__":
    main()

Birkaç noktaya dikkat:

  • autoescape açık; müşteri adında & ya da < geçse bile HTML bozulmaz.
  • Kesim saati sorguya parametre olarak gidiyor. Betik 09:00'da başlayıp 09:03'te bitse bile aradaki üç dakikada eklenen kayıt rapora girmez; rapor "09:00 itibarıyla" der ve bu cümle doğrudur.
  • tr_sayi, Python'un standart biçimlendirmesini kullanıp ayraçları yer değiştiriyor. locale modülüne dayanmıyorum çünkü sunucuda Türkçe yerel ayarı kurulu olmayabiliyor; bu yöntem her makinede aynı sonucu verir.
  • Saat dilimi zoneinfo ile açıkça verildi. Windows'ta sistem saat dilimi veritabanı olmadığı için tzdata paketini de kurmak gerekiyor; yoksa ilk çalıştırmada hata alırsınız.

Grafiği base64 ile gömmek: tek dosyalık rapor

Raporu e-postayla gönderdiğinizde yanındaki PNG dosyası yolda kaybolur; HTML'de src="grafik.png" yazıyorsa alıcıda kırık resim simgesi çıkar. Çözüm grafiği dosyanın içine gömmek: matplotlib çıktısını belleğe alıp base64'e çeviriyor, data: URI olarak doğrudan src özniteliğine yazıyorum. Sonuç, tek başına taşınabilen bir HTML.

import base64
import io

import matplotlib
matplotlib.use("Agg")            # ekran yok; cron ve Görev Zamanlayıcı altında da çalışsın
import matplotlib.pyplot as plt


def cubuk_grafik_base64(satirlar, etiket="musteri", deger="tutar", en_fazla=10):
    """Satır listesinden yatay çubuk grafik üretir, data URI döner."""
    ilk = satirlar[:en_fazla]
    adlar = [s[etiket] for s in ilk][::-1]      # en büyük değer en üstte
    degerler = [s[deger] for s in ilk][::-1]

    fig, ax = plt.subplots(figsize=(7, 0.4 * len(ilk) + 1))
    ax.barh(adlar, degerler)
    ax.set_xlabel("Tutar (TL)")
    ax.grid(axis="x", alpha=0.3)
    for kenar in ("top", "right"):
        ax.spines[kenar].set_visible(False)
    fig.tight_layout()

    tampon = io.BytesIO()
    fig.savefig(tampon, format="png", dpi=150)
    plt.close(fig)                               # figür bellekte kalmasın

    kod = base64.b64encode(tampon.getvalue()).decode("ascii")
    return f"data:image/png;base64,{kod}"

matplotlib.use("Agg") satırı önemli: zamanlayıcı altında ekran yoktur, varsayılan arka uç bunu hata olarak döndürebilir. plt.close(fig) ise her çağrıda bir figür nesnesinin bellekte kalmasını önler; ayda bir çalışan betikte fark etmez ama aynı yardımcıyı yüz müşteri için döngüde çağırdığınızda eder. Yazdırınca grafik bulanık çıkıyorsa format="svg" ile aynı yöntem data:image/svg+xml;base64, ön ekiyle çalışır ve çözünürlükten bağımsız sonuç verir.

Şablon ve güven veren ayrıntılar

Raporun teknik olarak doğru olması yetmez; okuyanın ona güvenmesi gerekir. Bunu sağlayan şeyler küçük ama her rapora koyuyorum. Şablonun ilgili bölümü şöyle:

<p><strong>Aylık satış raporu · {{ donem }}</strong><br>
Üretim: {{ uretim_zamani | tarih }} · Veri kesimi: {{ veri_kesim | tarih }} itibarıyla</p>

{% if satirlar %}
<img src="{{ grafik }}" alt="Müşteri bazında tutar">
<table>
  <tr><th>Müşteri</th><th>Adet</th><th>Tutar (TL)</th></tr>
  {% for s in satirlar %}
  <tr><td>{{ s.musteri }}</td><td>{{ s.adet | sayi }}</td><td>{{ s.tutar | sayi(2) }}</td></tr>
  {% endfor %}
  <tr><th>Toplam</th><th></th><th>{{ toplam | sayi(2) }}</th></tr>
</table>
{% else %}
<p>{{ donem }} dönemi için {{ veri_kesim | tarih }} itibarıyla kayıt bulunamadı.</p>
{% endif %}
  • Üretim zamanı ile veri kesimi ayrı ayrı yazılır. "1 Eylül 09:03'te üretildi, 09:00'a kadarki veriyi içerir" bilgisi, "bunlar geçen ayın rakamları mı, bu sabahın mı" sorusunu ortadan kaldırır.
  • Boş veri bir hata değil, bir sonuçtur. Dönem için kayıt yoksa betik çökmez, boş grafik de çizmez; "kayıt bulunamadı" satırıyla yine rapor üretir. Böylece raporun gelmemesi ile verinin olmaması birbirinden ayrılır; ilki arıza, ikincisi bilgidir.
  • Sayı biçimi tek filtreden geçer. Tabloda 12.450,00 yazarken toplamda 12,450.00 görünmesi, okuyanın gözünde raporun tamamını şüpheli yapar. Filtre bu tutarsızlığı imkânsız kılar.
  • Eksik değer için sabit bir işaret vardır. Sıfır ile "bilinmiyor" aynı hücrede aynı görünmez; None uzun tire olarak basılır.

PDF çıktısı ve tek komut

HTML tarayıcıda doğru görünüyorsa PDF'e çevirmek için iki yol kullanıyorum. İlki WeasyPrint: betiğe bir --pdf bayrağı ekleyip aynı HTML metnini ona veriyorum. Windows'ta WeasyPrint'in Pango kütüphanelerine ihtiyacı var; GTK çalışma zamanı kurulmadan çalışmıyor, bunu ilk kurulumda öğrenmek bana biraz zaman kaybettirdi.

from weasyprint import HTML

if args.pdf:
    pdf_yolu = cikti.with_suffix(".pdf")
    HTML(string=html, base_url=str(cikti.parent)).write_pdf(str(pdf_yolu))

İkincisi, ek paket istemeyen yol: kurulu Chrome'un başsız kipi. Windows'ta chrome yerine chrome.exe'nin tam yolunu vermek gerekiyor.

chrome --headless --no-pdf-header-footer --print-to-pdf=cikti/rapor-2026-08.pdf cikti/rapor-2026-08.html

Her iki yolda da yazdırma düzenini şablonun içindeki CSS belirler: @page { size: A4; margin: 18mm; } sayfa boyutunu ayarlar, tablo satırlarında page-break-inside: avoid bir satırın iki sayfaya bölünmesini engeller. Grafik zaten gömülü olduğu için PDF üreticisinin dış dosyaya erişmesi gerekmez; "HTML'de grafik var, PDF'de yok" şikâyeti kökten kapanır. Komutun tamamı artık şu: python rapor_uret.py --donem 2026-08 --pdf. Zamanlayıcıya bunu yazın, ayın ilk sabahı çıktı klasöründe iki dosya sizi bekler.

Bu yapının asıl getirisi, raporun içeriğini tartışırken artık üretim sürecini tartışmıyor olmanız. Bir sayı yanlışsa sorgu yanlıştır, bir başlık eksikse şablon eksiktir; ikisi ayrı dosyada durduğu için düzeltmesi de ayrıdır. Aynı iskeleti haftalık özetten yıllık kapanışa kadar farklı dönemlere, farklı veri kaynaklarına taşıyabilirsiniz; değişen yalnızca sorgu ile şablon olur.

Tekrar eden raporları bu tür boru hatlarına çevirmek veri ve otomasyon danışmanlığı işimin sık yaptığım parçalarından. Elinizde her ay elle hazırlanan bir rapor varsa iletişim bölümünden bana yazın; hangi adımın otomatikleşeceğini, hangisinin insan gözü istediğini birlikte netleştiririz.