Mertcan Yüksel içinde ara

Pazartesi raporunu kim hazırlıyor? Yönetim raporlarını uçtan uca otomatiğe bağlamak

Her pa­zar­te­si elle ha­zır­la­nan Excel ra­po­ru­nu gü­ve­ni­lir bir oto­mas­yo­na dö­nüş­tür­me­nin uç­tan uca kur­gu­su: KPI'ları ta­nım­la­mak, ve­ri­yi gön­der­me­den önce doğ­ru­la­mak, HTML özet ve Excel eki üret­mek, alı­cı­la­rı yö­net­mek ve 'ra­por git­ti mi?' so­ru­su­nu kay­da bağ­la­mak.

Paylaş

İçindekiler

Pa­zar­te­si sa­ba­hı, saat do­kuz. Bi­ri­si kay­nak sis­tem­den ve­ri­yi dışa ak­ta­rı­yor, ge­çen haf­ta­nın Excel şab­lo­nu­na ya­pış­tı­rı­yor, pi­vot tab­lo­la­rı ye­ni­li­yor, bir­kaç ra­ka­mı elle dü­zel­ti­yor ve on bu­çuk­ta “Haf­ta­lık Sa­tış Ra­po­ru” ko­nu­lu e-pos­ta­yı gön­de­ri­yor. Ra­por her haf­ta gi­di­yor; ta ki o kişi izne çı­ka­na, şab­lon­da­ki bir for­mül bir sa­tır ka­ya­na ya da iki de­part­man aynı top­lan­tı­ya aynı KPI için iki fark­lı ra­kam­la ge­le­ne ka­dar.

Elle
09:00Dışa aktar, kopyala-yapıştır
09:40Pivotları yenile, elle düzelt
10:30E-postayla gönder
Kişiye bağlı · Tanımlar Excel'de saklı
Otomatik
07:30Veri çekildi ve doğrulandı
07:31Özet ve Excel eki üretildi
08:00Gelen kutusunda
Tek tanım · Kayıtlı · Kişisel veri içermez

Bu tab­lo ne­re­dey­se her şir­ket­te var. Gö­rü­nen ma­li­ye­ti za­man. Gö­rün­me­yen ma­li­ye­ti daha bü­yük: Ra­kam­lar ki­şi­ye bağ­lı­dır, KPI ta­nım­la­rı bi­ri­nin Excel dos­ya­sın­da sak­lı­dır ve ra­po­run doğ­ru­lu­ğu o sa­bah ki­min ha­zır­la­dı­ğı­na göre de­ği­şir.

Bu ya­zı­da yö­ne­tim ra­por­la­rı­nı uç­tan uca oto­ma­ti­ğe bağ­la­ma­nın kur­gu­su­nu an­la­tı­yo­rum: ra­po­ru ta­sar­la­mak, ve­ri­yi çek­mek, gön­der­me­den önce doğ­ru­la­mak, HTML özet ve Excel eki üret­mek, alı­cı­la­rı yö­net­mek ve her gön­de­ri­mi ka­yıt al­tı­na al­mak. Ör­nek­ler Python ve Pre­fect ile; ama il­ke­ler han­gi araç­la ku­rar­sa­nız ku­run ge­çer­li.

Neden otomatikleştirmeli?

Za­man. Haf­ta­da iki saat sü­ren bir ra­por yıl­da yüz sa­ati aşar. Beş ra­por, ne­re­dey­se üç ay­lık bir me­sai eder. Üs­te­lik bu sa­at­ler ge­nel­de eki­bin en de­ne­yim­li, en pa­ha­lı ki­şi­le­rin­den çı­kar.

Tek ta­nım. Oto­ma­tik bir ra­por­da “net sa­tış”ın ta­nı­mı bir ki­şi­nin Excel for­mü­lün­de de­ğil, kod­da ve sü­rüm kont­ro­lün­de du­rur. Her­kes aynı ra­ka­mı aynı ta­nım­la gö­rür; ta­nım de­ği­şir­se ne za­man ve ne­den de­ğiş­ti­ği de ka­yıt­lı­dır.

Ki­şi­ye ba­ğım­lı­lık. İzin, has­ta­lık ya da ay­rı­lık ra­po­ru dur­dur­maz. Ra­por, onu ha­zır­la­yan ki­şi­nin tak­vi­mi­ne de­ğil, iş tak­vi­mi­ne bağ­lı olur.

Za­man­la­ma. Ra­por, yö­ne­ti­ci ma­sa­ya otur­ma­dan, sa­bah se­kiz­de ge­len ku­tu­sun­da olur. Pa­zar­te­si top­lan­tı­sı tah­min­le de­ğil, ra­kam­la baş­lar.

Ama bir uya­rı: Kötü ku­rul­muş bir oto­mas­yon, elle ha­zır­la­nan ra­por­dan daha teh­li­ke­li­dir. Elle ha­zır­la­yan kişi tu­haf bir ra­ka­mı fark eder, du­rur, so­rar. Bir script fark et­mez; yan­lış ra­ka­mı her pa­zar­te­si aynı özen­le gön­de­rir. Bu yüz­den bu ya­zı­nın önem­li bir kıs­mı “gön­der­me­den önce” ile il­gi­li.

Önce raporu tasarlayın, sonra kodlayın

KPI sözlüğü

Kod yaz­ma­dan önce her met­ri­ğin ta­nı­mı­nı, kay­na­ğı­nı ve sa­hi­bi­ni ya­zın. Ra­por­la­ma­da­ki en pa­ha­lı ha­ta­lar kod ha­ta­la­rı de­ğil, ta­nım an­laş­maz­lık­la­rı­dır. “Ciro” fi­nans için KDV ha­riç net tu­tar­ken sa­tış eki­bi için brüt si­pa­riş tu­ta­rı ola­bi­lir.

MetrikTanımKaynakSahibi
Net satışİade ve iptaller düşülmüş, KDV hariç tutarSatış tablosuFinans
Sipariş sayısıİptal edilmemiş benzersiz siparişSipariş tablosuSatış operasyon
Ortalama sepetNet satış / sipariş sayısıTüretilmişFinans
Yeni müşteriİlk siparişini bu dönemde veren müşteriCRMPazarlama

Bu tab­lo­yu met­ri­ğin sa­hi­biy­le bir­lik­te dol­du­run. Sa­hi­bi ol­ma­yan bir met­rik, ilk tu­tar­sız­lık­ta kim­se­nin sa­vun­ma­dı­ğı bir ra­ka­ma dö­nü­şür.

Okuyucuyu tanıyın

Bir yö­ne­ti­ci ra­por e-pos­ta­sı­nı or­ta­la­ma ya­rım da­ki­ka­da okur. Bu yüz­den:

  • İlk ek­ran­da üç ila beş ra­kam ol­sun. Her bi­ri­nin ya­nın­da bir kar­şı­laş­tır­ma bu­lun­sun: ge­çen haf­ta, ge­çen yı­lın aynı haf­ta­sı ya da he­def. Kar­şı­laş­tır­ma­sı ol­ma­yan bir ra­kam bil­gi de­ğil­dir.
  • Özet göv­de­de, de­tay ekte. E-pos­ta göv­de­sin­de özet ve en faz­la bir­kaç sa­tır­lık “dik­kat edil­me­si ge­re­ken­ler” bu­lun­sun; ta­ma­mı Excel ekin­de. Kim­se e-pos­ta­da beş yüz sa­tır­lık bir tab­lo­yu oku­maz.
  • E-pos­ta ile pano ra­kip de­ğil­dir. E-pos­ta ri­tim ve dik­kat sağ­lar: Bil­gi­yi ki­şi­ye ken­di­si gö­tü­rür. Pano ise ke­şif için­dir: Kişi me­rak et­ti­ğin­de gi­der, kı­rı­lım­la­ra iner. İki­si de aynı SQL ta­nım­la­rın­dan bes­len­me­li; e-pos­ta­nın so­nu­na pa­no­ya gi­den bir bağ­lan­tı koy­mak, iki­si­ni bir­bi­ri­ne bağ­la­ma­nın en ko­lay yo­lu­dur.

Kapanmış dönemi raporlayın

Ra­por­la­nan dö­nem ta­mam­lan­mış ol­ma­lı. Pa­zar­te­si sa­ba­hı ça­lı­şan haf­ta­lık ra­por, “bu haf­ta”yı de­ğil, bir ön­ce­ki pa­zar­te­si–pa­zar ara­lı­ğı­nı ra­por­lar. Ayın ilk günü ça­lı­şan ay­lık ra­por da bir ön­ce­ki ayı. Dö­nem sı­nır­la­rı­nı tek bir yer­de he­sap­la­yın:

from datetime import date, timedelta


def onceki_hafta(bugun: date) -> tuple[date, date]:
    """Bugünden önceki son tamamlanmış pazartesi–pazar aralığı."""
    bu_haftanin_pazartesisi = bugun - timedelta(days=bugun.weekday())
    baslangic = bu_haftanin_pazartesisi - timedelta(days=7)
    return baslangic, baslangic + timedelta(days=6)

Bu fonk­si­yon haf­ta­nın han­gi günü ça­lı­şır­sa ça­lış­sın son ta­mam­lan­mış haf­ta­yı ve­rir; ra­por bir gün ge­cik­se bile yan­lış dö­ne­mi ra­por­la­maz. “Bu­gün”ü de su­nu­cu­nun sa­ati­ne göre de­ğil, iş ye­ri­nin saat di­li­mi­ne göre alın: datetime.now(ZoneInfo("Europe/Istanbul")).date(). Su­nu­cu UTC’de ça­lı­şı­yor­sa pa­zar­te­si sa­ba­hı 01:00, UTC’de hala pa­zar ge­ce­si­dir ve ra­por bir ön­ce­ki haf­ta­yı ra­por­lar.

Sor­gu­da da dö­ne­min son gü­nü­nü ek­sik­siz al­mak için yarı açık ara­lık kul­la­nın:

SELECT
    SUM(net_tutar)              AS net_satis,
    COUNT(DISTINCT siparis_id)  AS siparis_sayisi
FROM satislar
WHERE siparis_tarihi >= :baslangic
  AND siparis_tarihi <  :bitis_ertesi   -- pazar gününün tamamı dahil
  AND durum <> 'IPTAL';

BETWEEN :baslangic AND :bitis yaz­mak, ta­rih ala­nı saat de içe­ri­yor­sa pa­zar gü­nü­nün yal­nız­ca gece ya­rı­sı­na ka­dar olan anı­nı kap­sar; pa­zar günü ya­pı­lan sa­tış­lar ra­por­dan ses­siz­ce dü­şer.

Akışın iskeleti

Bir ra­por oto­mas­yo­nu beş adım­dan olu­şur:

çek → doğrula → üret (HTML + Excel) → dağıt (e-posta) → kaydet
          │
          └─ veri sorunu varsa: rapor bekletilir, veri ekibine alarm gider

Pre­fect ile her adım bir task, ra­por da bir flow olur:

from datetime import date, datetime
from zoneinfo import ZoneInfo

from prefect import flow

RAPOR = "haftalik-satis"


@flow(name="haftalik-satis-raporu", retries=0)
def haftalik_satis_raporu(bugun: date | None = None) -> str:
    simdi = datetime.now(ZoneInfo("Europe/Istanbul"))
    baslangic, bitis = onceki_hafta(bugun or simdi.date())
    donem = baslangic.isoformat()

    if daha_once_gonderildi(RAPOR, donem):
        return "Bu dönem zaten gönderilmiş; atlandı."

    kpi = kpi_cek(baslangic, bitis)
    gecmis = gecmis_haftalar_cek(baslangic, hafta_sayisi=8)
    sorunlar = dogrula(kpi, gecmis, son_yukleme=kaynak_tazeligi(), simdi=simdi)

    if any(s.engelleyici for s in sorunlar):
        alarm_gonder(RAPOR, donem, sorunlar)  # veri ekibine; yöneticilere değil
        gonderim_kaydet(RAPOR, donem, durum="bekletildi")
        raise RuntimeError(f"{RAPOR} {donem} bekletildi: {sorunlar}")

    html = html_ozet(kpi, gecmis, uyarilar=sorunlar)
    excel = excel_uret(detay_cek(baslangic, bitis), "Haftalık Satış Raporu", donem)
    message_id = rapor_gonder(alicilar(RAPOR), html, excel, donem)
    gonderim_kaydet(RAPOR, donem, durum="gonderildi", message_id=message_id)
    return f"{donem} raporu gönderildi."

Flow dos­ya­sı oku­nun­ca ne ya­pıl­dı­ğı an­la­şıl­ma­lı; bağ­lan­tı de­tay­la­rı, SMTP ayar­la­rı ve HTML şab­lon­la­rı ken­di fonk­si­yon­la­rın­da du­rur. Bu, ilk ya­zı­da an­lat­tı­ğım kat­man­lı ya­pı­nın ay­nı­sı. Aşa­ğı­da adım­la­rı tek tek açı­yo­rum.

Göndermeden önce doğrulayın

Bu, oto­ma­tik bir ra­po­run en önem­li adı­mı. Üç kont­rol ya­pın:

  1. Boş­luk. Dö­nem­de hiç ka­yıt yok­sa ra­por git­mez. “Bu haf­ta sa­tış sı­fır” bir iş ger­çe­ği ola­bi­lir; ama çok daha ola­sı açık­la­ma, veri akı­şı­nın dur­muş ol­ma­sı­dır.
  2. Ta­ze­lik. Kay­nak ve­ri­nin en son ne za­man gün­cel­len­di­ği­ne ba­kın. Gece ça­lı­şan yük­le­me işi ge­cik­tiy­se pa­zar günü ek­sik gö­rü­nür; ra­por ek­sik ra­kam­la de­ğil, ge­cik­mey­le git­me­li­dir.
  3. Ano­ma­li. Bu haf­ta­nın de­ğe­ri son se­kiz haf­ta­nın or­ta­la­ma­sın­dan çok sa­par­sa bunu işa­ret­le­yin.

Bu­ra­da kri­tik bir ay­rım var: Veri so­ru­nu ile iş sin­ya­li­ni ayı­rın. Veri boş­sa ya da ba­yat­sa so­run ve­ri­de­dir; ra­por bek­le­ti­lir ve veri eki­bi uya­rı­lır. Ama sa­tış ger­çek­ten yüz­de kırk düş­tüy­se bu tam ola­rak yö­ne­ti­ci­nin en hız­lı gör­me­si ge­re­ken şey­dir; ra­por bek­le­til­mez, dü­şüş vur­gu­la­na­rak gön­de­ri­lir. Ano­ma­li­yi en­gel ya­pan bir oto­mas­yon, en önem­li ha­ber­le­ri ge­cik­ti­rir.

from dataclasses import dataclass
from datetime import datetime, timedelta


@dataclass
class Sorun:
    mesaj: str
    engelleyici: bool  # True: rapor bekletilir. False: rapora uyarı olarak eklenir.


def dogrula(kpi: dict, gecmis: list[dict], son_yukleme: datetime, simdi: datetime) -> list[Sorun]:
    sorunlar = []

    if kpi["siparis_sayisi"] == 0:
        sorunlar.append(Sorun("Dönemde hiç sipariş yok; veri akışını kontrol edin.", True))

    if simdi - son_yukleme > timedelta(hours=12):
        sorunlar.append(Sorun(f"Kaynak veri {son_yukleme:%d.%m %H:%M} tarihinden beri güncellenmemiş.", True))

    if gecmis:
        ortalama = sum(hafta["net_satis"] for hafta in gecmis) / len(gecmis)
        if ortalama and abs(kpi["net_satis"] - ortalama) / ortalama > 0.4:
            sorunlar.append(Sorun("Net satış son 8 haftanın ortalamasından %40'tan fazla sapıyor.", False))

    return sorunlar

Eşik­le­ri (on iki saat, yüz­de kırk) koda göm­me­yin; ilk ya­zı­da an­lat­tı­ğım gibi ayar dos­ya­sın­da, her­ke­sin gö­re­bi­le­ce­ği bir yer­de tu­tun. Her ra­po­run nor­mal dal­ga­lan­ma­sı fark­lı­dır.

E-posta gövdesi: özet, karşılaştırma, uyarı

E-pos­ta HTML’i ya­zar­ken web say­fa­sı yaz­ma­dı­ğı­nı­zı unut­ma­yın. E-pos­ta is­tem­ci­le­ri CSS’in çok eski bir alt kü­me­si­ni des­tek­ler; Outlook’un ma­sa­üs­tü sü­rü­mü e-pos­ta­yı bir ke­li­me iş­lem­ci mo­to­ruy­la çi­zer. Ku­ral­lar:

  • Sa­tır içi stil ve tab­lo dü­ze­ni. Flexbox, grid, ha­ri­ci stil dos­ya­sı ve JavaScript kul­lan­ma­yın.
  • Uzak gör­sel­le­ri kul­lan­ma­yın. Çoğu is­tem­ci on­la­rı var­sa­yı­lan ola­rak en­gel­ler. Gra­fik ge­re­ki­yor­sa e-pos­ta­ya gö­mü­lü bir PNG kul­la­nın ya da gra­fi­ği pa­no­ya bı­ra­kın.
  • Düz me­tin al­ter­na­ti­fi ek­le­yin. HTML’i gös­ter­me­yen is­tem­ci­ler ve öniz­le­me sa­tı­rı için.
  • Ra­kam­la­rı Türk­çe bi­çim­le­yin. Python var­sa­yı­lan ola­rak 1,234,567.89 ya­zar; oku­yu­cu­nuz 1.234.567,89 bek­ler.

Bi­çim­le­me için kü­çük bir yar­dım­cı ye­ter­li:

def tr_sayi(deger: float, ondalik: int = 0) -> str:
    """1234567.891 → '1.234.567,89' (ondalik=2)"""
    metin = f"{deger:,.{ondalik}f}"  # '1,234,567.89'
    return metin.replace(",", "_").replace(".", ",").replace("_", ".")

Kar­şı­laş­tır­ma­yı gös­te­rir­ken bir tu­za­ğa dik­kat edin: Her ar­tış iyi de­ğil­dir. Sa­tış ar­tı­şı ye­şil ol­ma­lı, ama iade ora­nı ya da müş­te­ri şi­ka­ye­ti ar­tı­şı kır­mı­zı. Her met­rik için “ar­tış iyi mi?” bil­gi­si­ni KPI söz­lü­ğün­de tu­tun:

def degisim(bu: float, onceki: float, artis_iyi: bool = True) -> str:
    if not onceki:
        return '<span style="color:#6b6b73">—</span>'
    oran = (bu - onceki) / onceki * 100
    iyi = (oran >= 0) == artis_iyi
    renk = "#1a7f37" if iyi else "#cf222e"
    ok = "▲" if oran >= 0 else "▼"
    return f'<span style="color:{renk}">{ok} %{tr_sayi(abs(oran), 1)}</span>'

Doğ­ru­la­ma adı­mın­da en­gel­le­yi­ci ol­ma­yan so­run­lar çık­tıy­sa (ör­ne­ğin sert bir dü­şüş), bun­la­rı e-pos­ta­nın en üs­tün­de, renk­li bir ku­tu­da gös­te­rin. Yö­ne­ti­ci “bu ra­kam doğ­ru mu?” diye sor­ma­dan önce ce­va­bı ora­da gör­me­li: “Net sa­tış son 8 haf­ta­nın or­ta­la­ma­sın­dan %42 dü­şük; kay­nak veri gün­cel­dir.”

Excel eki: detay ve analiz

Ek dos­ya de­ta­yın asıl ye­ri­dir. Ama aynı za­man­da ra­po­run en risk­li par­ça­sı­dır: Bir e-pos­ta eki, kont­ro­lü­nüz­den çı­kan bir veri kop­ya­sı­dır. İle­ti­lir, in­di­ri­lir, ki­şi­sel bil­gi­sa­yar­la­ra kay­de­di­lir ve yıl­lar­ca ge­len ku­tu­la­rın­da du­rur. Bu yüz­den önce ne­yin dos­ya­ya gi­re­ce­ği­ne, son­ra na­sıl gö­rü­ne­ce­ği­ne ka­rar ve­rin.

Önce veri güvenliği: ham veri dışarı çıkmaz

Sa­tış ve­ri­sin­de müş­te­ri adı, abo­ne adı, te­le­fon ya da e-pos­ta ad­re­si Excel dos­ya­la­rın­da do­laş­ma­ma­lı. KVKK’nın te­mel il­ke­le­rin­den biri, ki­şi­sel ve­ri­nin “iş­len­dik­le­ri amaç­la bağ­lan­tı­lı, sı­nır­lı ve öl­çü­lü” ol­ma­sı­dır. Bir yö­ne­tim ra­po­ru­nun ama­cı için müş­te­ri­nin adı­na ih­ti­yaç yok­tur. Beş ku­ral:

1. Top­lu­laş­tı­rın. Yö­ne­tim ra­po­ru müş­te­ri se­vi­ye­sin­de sa­tır içer­mez; böl­ge, ürün ka­te­go­ri­si, ka­nal ya da haf­ta gibi kı­rı­lım­lar­la ge­lir. Ham ve­ri­ye ger­çek­ten ih­ti­yaç du­yan yet­ki­li kişi, onu pa­no­dan ya da kay­nak sis­tem­den ken­di yet­ki­siy­le gö­rür.

2. İzin lis­te­si kul­la­nın, ya­sak lis­te­si de­ğil. Excel’e ya­zı­la­cak sü­tun­la­rı tek tek ta­nım­la­yın; lis­te­de ol­ma­yan hiç­bir alan dos­ya­ya gir­me­sin. “Has­sas sü­tun­la­rı çı­kar” yak­la­şı­mı, kay­nak tab­lo­ya yeni bir sü­tun ek­len­di­ği gün de­li­nir; izin lis­te­si ise yeni sü­tu­nu var­sa­yı­lan ola­rak dı­şa­rı­da bı­ra­kır. Ra­por sor­gu­la­rın­da SELECT * kul­lan­ma­yın.

3. Kü­çük grup­la­rı giz­le­yin. Top­lu­laş­tı­rıl­mış veri bile ki­şi­yi ele ve­re­bi­lir: “İz­mir – Ku­rum­sal – 2 müş­te­ri” sa­tı­rı, o iki müş­te­ri­nin ne ka­dar alış­ve­riş yap­tı­ğı­nı fi­ilen gös­te­rir. Az sa­yı­da ki­şi­den olu­şan grup­la­rı ra­po­ra yaz­ma­yın:

SELECT
    bolge,
    urun_kategori,
    COUNT(DISTINCT musteri_id)  AS musteri_sayisi,
    COUNT(DISTINCT siparis_id)  AS siparis_sayisi,
    SUM(net_tutar)              AS net_satis
FROM satislar
WHERE siparis_tarihi >= :baslangic
  AND siparis_tarihi <  :bitis_ertesi
  AND durum <> 'IPTAL'
GROUP BY bolge, urun_kategori
HAVING COUNT(DISTINCT musteri_id) >= 5;   -- 5'ten az müşteriden oluşan grup yazılmaz

Giz­le­nen grup­lar yü­zün­den sa­tır top­la­mı ge­nel top­la­mı tut­ma­ya­cak­tır. Bunu dos­ya­da tek sa­tır­lık bir not­la be­lir­tin ya da giz­le­nen grup­la­rı “Di­ğer” adlı tek bir sa­tır­da bir­leş­ti­rin; oku­yu­cu top­lam­la­rın ne­den fark­lı ol­du­ğu­nu me­rak et­me­sin.

4. Kişi se­vi­ye­si şart­sa tak­ma ad ya da mas­ke­le­me kul­la­nın. Ba­zen kişi se­vi­ye­si­ne in­mek ger­çek­ten ge­re­kir; ör­ne­ğin bir sa­tış tem­sil­ci­si­ne ken­di müş­te­ri­le­ri­nin lis­te­si­ni gön­de­rir­ken. Bu du­rum­da müş­te­ri nu­ma­ra­sı­nın ye­ri­ne bir tak­ma ad, adın ye­ri­ne mas­ke­len­miş bir hali kul­la­nın:

import hashlib
import hmac


def takma_ad(musteri_id: str, gizli_anahtar: bytes) -> str:
    """Aynı müşteri her raporda aynı takma adı alır; takma addan kimliğe dönülemez."""
    ozet = hmac.new(gizli_anahtar, musteri_id.encode(), hashlib.sha256).hexdigest()
    return f"MUS-{ozet[:10].upper()}"


def maskele(ad_soyad: str) -> str:
    """'Ayşe Yılmaz' → 'Ay*** Yı***'"""
    return " ".join(parca[:2] + "***" for parca in ad_soyad.split())

İki önem­li ay­rın­tı. Bi­rin­ci­si, müş­te­ri nu­ma­ra­sı­nı anah­tar­sız bir özet fonk­si­yo­nun­dan (ör­ne­ğin düz SHA-256) ge­çir­mek ye­ter­li de­ğil­dir: Müş­te­ri nu­ma­ra­la­rı tah­min edi­le­bi­lir ol­du­ğu için tüm ola­sı­lık­lar de­ne­ne­rek geri çö­zü­le­bi­lir. Giz­li anah­tar­lı HMAC bunu en­gel­ler; anah­tar da kod­da de­ğil, giz­li bil­gi yö­ne­ti­ci­sin­de du­rur. İkin­ci­si, mas­ke­le­me ano­nim­leş­tir­me de­ğil­dir: İl, yaş gru­bu ve tu­tar gibi baş­ka sü­tun­lar­la bir­le­şin­ce kişi yine ta­nı­na­bi­lir. Tak­ma ad ya da mas­ke­le­me an­cak top­lu­laş­tır­ma müm­kün ol­ma­dı­ğın­da baş­vu­ru­la­cak bir ye­dek çö­züm­dür.

5. Giz­le­mek sil­mek de­ğil­dir. Giz­li sü­tun, be­yaz yazı, say­fa ko­ru­ma­sı ya da Excel pa­ro­la­sı veri gü­ven­li­ği sağ­la­maz. Veri dos­ya­nın için­de­dir ve bir­kaç tık­la­may­la gö­rü­nür hale ge­lir. Gü­ven­li olan tek şey, ve­ri­yi dos­ya­ya hiç koy­ma­mak­tır.

Kaliteli bir Excel çıktısı

Alı­cı­nın dos­ya­yı aç­tı­ğın­da hiç­bir şeyi dü­zelt­me­den kul­la­na­bil­me­si ge­re­kir:

  • Baş­lık ve bağ­lam. En üst­te ra­por adı ve dö­nem, al­tın­da dos­ya­nın ne za­man üre­til­di­ği ve “ki­şi­sel veri içer­mez” notu. Dos­ya ile­til­di­ğin­de bile ne ol­du­ğu an­la­şıl­sın.
  • Ger­çek bir Excel tab­lo­su. Şe­rit­li sa­tır­lar ve ha­zır filt­re ge­lir; alı­cı tek tık­la sı­ra­lar, filt­re­ler, pi­vot ya­par.
  • Sü­tun ba­zın­da bi­çim. Tu­tar­lar TL, oran­lar yüz­de, adet­ler bin­lik ay­raç­lı sayı ola­rak gö­rü­nür. Ra­kam­lar me­tin ola­rak de­ğil sayı ola­rak ya­zı­lır; Türk­çe Excel #,##0.00 bi­çi­mi­ni ken­di­li­ğin­den 1.234,56 ola­rak gös­te­rir. Yüz­de­le­ri 0,125 gibi ke­sir ola­rak ya­zın; 0.0% bi­çi­mi onu %12,5 ola­rak gös­te­rir.
  • Oku­na­bi­lir dü­zen. Sü­tun ge­niş­lik­le­ri içe­ri­ğe göre ayar­la­nır, baş­lık sa­tı­rı sa­bit­le­nir, say­fa ya­tay yaz­dı­rı­lır.
  • Bel­lek­te üre­tim. Dis­ke ge­çi­ci dos­ya yaz­mak, te­miz­len­me­di­ğin­de dis­ki dol­du­rur; aynı anda ça­lı­şan iki ra­por aynı dos­ya adı­nı kul­la­nır­sa bir­bi­ri­nin çık­tı­sı­nı ezer.

İzin lis­te­si ile bi­çim­le­ri aynı yer­de ta­nım­la­mak, iki işi bir­den gö­rür: Han­gi sü­tu­nun dos­ya­ya gi­re­ce­ği de, na­sıl gö­rü­ne­ce­ği de tek bir söz­lük­te, kod in­ce­le­me­sin­de gö­rü­nür hal­de du­rur:

from datetime import datetime
from io import BytesIO

from openpyxl import Workbook
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo

# İzin listesi: Excel'e sadece burada tanımlı sütunlar, bu sırayla, bu başlık
# ve bu biçimle yazılır. Listede olmayan hiçbir alan dosyaya girmez.
SUTUNLAR = {
    "bolge": ("Bölge", None),
    "urun_kategori": ("Ürün kategorisi", None),
    "musteri_sayisi": ("Müşteri", "#,##0"),
    "siparis_sayisi": ("Sipariş", "#,##0"),
    "net_satis": ("Net satış", '#,##0.00 "TL"'),
    "degisim": ("Geçen haftaya göre", "0.0%"),
}


def excel_uret(satirlar: list[dict], baslik: str, donem: str) -> bytes:
    if not satirlar:
        raise ValueError("Boş rapor için Excel üretilmez.")
    anahtarlar = [anahtar for anahtar in SUTUNLAR if anahtar in satirlar[0]]

    wb = Workbook()
    wb.properties.title = f"{baslik} – {donem}"
    wb.properties.creator = "Raporlama otomasyonu"
    ws = wb.active
    ws.title = "Detay"

    # Başlık ve bağlam satırları
    ws.append([f"{baslik} – {donem}"])
    ws["A1"].font = Font(bold=True, size=14)
    ws.append([f"{datetime.now():%d.%m.%Y %H:%M} tarihinde otomatik üretildi. "
               "Kişisel veri içermez; dağıtımı sınırlıdır."])
    ws["A2"].font = Font(italic=True, color="6B6B73")
    ws.append([])

    # Tablo başlığı 4. satırda, veri altında
    ws.append([SUTUNLAR[anahtar][0] for anahtar in anahtarlar])
    for satir in satirlar:
        ws.append([satir[anahtar] for anahtar in anahtarlar])
    ilk, son = 4, 4 + len(satirlar)

    # Sütun biçimleri ve genişlikleri
    for sira, anahtar in enumerate(anahtarlar, start=1):
        harf = get_column_letter(sira)
        bicim = SUTUNLAR[anahtar][1]
        if bicim:
            for satir_no in range(ilk + 1, son + 1):
                ws[f"{harf}{satir_no}"].number_format = bicim
        en_uzun = max(len(str(ws[f"{harf}{satir_no}"].value or "")) for satir_no in range(ilk, son + 1))
        ws.column_dimensions[harf].width = min(en_uzun + 4, 40)

    # Excel tablosu: şeritli satırlar ve hazır filtre
    alan = f"A{ilk}:{get_column_letter(len(anahtarlar))}{son}"
    tablo = Table(displayName="DetayTablosu", ref=alan)
    tablo.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True)
    ws.add_table(tablo)

    ws.freeze_panes = f"A{ilk + 1}"
    ws.page_setup.orientation = "landscape"

    buffer = BytesIO()
    wb.save(buffer)
    return buffer.getvalue()

satirlar lis­te­si yu­ka­rı­da­ki top­lu­laş­tı­rıl­mış sor­gu­dan ge­lir. Sor­gu bir gün yan­lış­lık­la musteri_adi sü­tu­nu­nu da dön­dür­se bile, izin lis­te­sin­de ol­ma­dı­ğı için dos­ya­ya ya­zıl­maz.

Dağıtım: alıcılar ve gönderim

Alıcı listeleri iş biriminin elinde olsun

“Bu ra­por kime gi­di­yor?” so­ru­su­nun ce­va­bı sık de­ği­şir: biri eki­be ka­tı­lır, biri ay­rı­lır, bir mü­dür ra­po­ru yar­dım­cı­sı­na da is­ter. Alı­cı­la­rı koda gö­mer­se­niz her de­ği­şik­lik bir ge­liş­tir­me ta­le­bi­ne dö­nü­şür. Ba­sit bir tab­lo ye­ter­li: ra­por adı, e-pos­ta ad­re­si, alı­cı tipi (to ya da cc), ak­tif mi? Bu tab­lo­yu dü­zen­le­ye­bi­le­cek­le­ri ba­sit bir yö­ne­tim ek­ra­nı (ör­ne­ğin Di­rec­tus gibi bir araç­la bir­kaç da­ki­ka­da ku­ru­la­bi­lir) iş bi­ri­mi­ni si­zin iş kuy­ru­ğu­nuz­dan çı­ka­rır.

Ay­rı­lan bir ça­lı­şa­nın ad­re­si­ni sil­mek ye­ri­ne pa­sif ya­pın; ki­min han­gi ra­po­ru ne za­ma­na ka­dar al­dı­ğı ka­yıt­ta kal­sın. Kay­nak sis­tem­den ge­len ad­res­le­ri de gön­der­me­den önce doğ­ru­la­yın; boş ya da bo­zuk bir ad­res tüm gön­de­ri­mi dü­şür­me­sin.

Kişiye özel dağıtım

Her­ke­se gi­den bir lis­te, kim­se­nin so­rum­lu­lu­ğun­da de­ğil­dir. Elli sa­tır­lık or­tak bir “ge­ci­ken iş­ler” lis­te­si göz ardı edi­lir; “si­zin üç işi­niz” baş­lık­lı bir e-pos­ta iş­lem gö­rür. Aynı akış­la, ve­ri­yi so­rum­lu­ya göre grup­la­yıp her ki­şi­ye ken­di lis­te­si­ni gön­de­re­bi­lir­si­niz: böl­ge mü­dü­rü­ne ken­di böl­ge­si, sa­tış tem­sil­ci­si­ne ken­di müş­te­ri­le­ri. Bir alı­cı­ya gön­de­rim ba­şa­rı­sız olur­sa di­ğer­le­ri de­vam et­me­li; hata, dön­gü­nün için­de ya­ka­la­nıp kay­de­dil­me­li.

Gönderim

Python’un stan­dart kütüphanesi bu iş için ye­ter­li. HTML göv­de, düz me­tin al­ter­na­ti­fi ve Excel eki tek bir me­saj­da:

import smtplib
from email.message import EmailMessage
from email.utils import make_msgid


def rapor_gonder(alicilar: list[str], konu: str, html: str, duz_metin: str,
                 ek: bytes, ek_adi: str, smtp: dict) -> str:
    msg = EmailMessage()
    msg["From"] = smtp["gonderen"]
    msg["To"] = ", ".join(alicilar)
    msg["Subject"] = konu
    message_id = make_msgid(domain=smtp["alan_adi"])
    msg["Message-ID"] = message_id

    msg.set_content(duz_metin)                 # HTML göremeyen istemciler için
    msg.add_alternative(html, subtype="html")
    msg.add_attachment(
        ek,
        maintype="application",
        subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        filename=ek_adi,
    )

    with smtplib.SMTP(smtp["host"], smtp["port"]) as sunucu:
        sunucu.starttls()
        sunucu.login(smtp["kullanici"], smtp["sifre"])
        sunucu.send_message(msg)
    return message_id

SMTP bil­gi­le­ri­ni koda yaz­ma­yın; ilk ya­zı­da an­lat­tı­ğım gibi bir Sec­ret block’tan ya da şir­ke­ti­ni­zin giz­li bil­gi yö­ne­ti­ci­sin­den oku­yun. Konu sa­tı­rı­na dö­ne­mi ya­zın (“Haf­ta­lık Sa­tış Ra­po­ru – 22–28 Ey­lül”); alı­cı ara­dı­ğın­da bul­sun.

”Rapor gitti mi?” sorusunu kayda bağlayın

Ra­por oto­mas­yo­nu­nun en sık du­yu­lan cüm­le­si “ra­por gel­me­di”dir. Ce­va­bı tah­min de­ğil, ka­yıt ol­ma­lı. Her gön­de­ri­mi, ba­şa­rı­lı ya da ba­şa­rı­sız, bir tab­lo­ya ya­zın:

CREATE TABLE rapor_gonderim (
    id            BIGSERIAL   PRIMARY KEY,   -- SQL Server'da: BIGINT IDENTITY
    rapor         TEXT        NOT NULL,
    donem         TEXT        NOT NULL,
    zaman         TIMESTAMPTZ NOT NULL DEFAULT now(),
    alici_sayisi  INT,
    durum         TEXT        NOT NULL,      -- 'gonderildi' | 'hata' | 'bekletildi'
    hata          TEXT,
    message_id    TEXT
);

-- Aynı dönemin raporu iki kez "gönderildi" olarak kaydedilemez.
CREATE UNIQUE INDEX rapor_gonderim_tekil
    ON rapor_gonderim (rapor, donem)
    WHERE durum = 'gonderildi';

Bu tab­lo iki iş gö­rür:

  • Des­tek di­ya­lo­ğu­nu bi­ti­rir. “Ra­por 08:00’de üç alı­cı­ya gön­de­ril­miş gö­rü­nü­yor, spam kla­sö­rü­nü­ze ba­kar mı­sı­nız?” Sa­de­ce ba­şa­rı­lı gön­de­rim­le­ri kay­de­der­se­niz, “gel­me­di” şi­ka­ye­tin­de eli­niz­de ses­siz­lik­ten baş­ka bir şey ol­maz; ba­şa­rı­sız­lık kay­dı ses­siz­lik­ten çok daha bil­gi­len­di­ri­ci­dir.
  • Aynı ra­po­run iki kez git­me­si­ni en­gel­ler. Flow’un ba­şın­da­ki daha_once_gonderildi kont­ro­lü bu tab­lo­ya ba­kar. Flow ye­ni­den de­nen­se ya da biri elle te­tik­le­se de yö­ne­ti­ci aynı ra­po­ru iki kez al­maz. Ko­şul­lu ben­zer­siz in­deks (SQL Server’da filt­re­li in­deks) aynı ku­ra­lı ve­ri­ta­ba­nı se­vi­ye­sin­de de ga­ran­ti eder.

Gön­de­rim kay­dı­nı e-pos­ta gi­der git­mez ya­zın. Gön­de­rim ile ka­yıt ara­sın­da sü­reç çö­ker­se çok kü­çük bir ih­ti­mal­le aynı ra­por iki kez gi­de­bi­lir; bu, ra­po­run hiç git­me­me­sin­den her za­man daha iyi­dir.

Zamanlama ve hata yönetimi

Ra­po­ru iş ye­ri­nin saat di­li­miy­le za­man­la­yın. Pa­zar­te­si 07:30 için:

from prefect.schedules import Cron

haftalik_satis_raporu.serve(
    name="haftalik-satis",
    schedule=Cron("30 7 * * 1", timezone="Europe/Istanbul"),
)

Hata du­ru­mun­da üç ku­ral:

  • Ra­por ve alarm ayrı söz­leş­me­ler­dir. Yö­ne­ti­ci­ye hata ay­rın­tı­sı gön­de­ril­mez; iş bi­ri­mi­ne stack tra­ce gön­der­mek, iki haf­ta için­de e-pos­ta­la­rı­nı­zın filt­re­len­me­siy­le so­nuç­la­nır. Hata alar­mı veri eki­bi­ne gi­der. İs­ter­se­niz yö­ne­ti­ci­ye sa­de­ce “Bu haf­ta­nın ra­po­ru veri ge­cik­me­si ne­de­niy­le öğ­le­den son­ra ge­le­cek” gibi kısa bir not gön­de­rin.
  • Ses­siz­li­ği yo­rum­la­mak zo­run­da kal­ma­yın. “Hiç e-pos­ta gel­mi­yor” iki şey an­la­mı­na ge­le­bi­lir: Her şey yo­lun­da ya da sis­tem hiç ça­lış­mı­yor. Ra­po­run 08:00’e ka­dar gön­de­ril­me­di­ği­ni fark eden bir ku­ral ku­run. Prefect’in “bek­le­nen bir olay gel­mez­se te­tik­len” tü­rün­de­ki oto­mas­yon­la­rı ya da gön­de­rim tab­lo­su­na ba­kan kü­çük bir kont­rol bu iş için ye­ter­li.
  • Retry’ı doğ­ru yere ko­yun. Veri çek­me adı­mı ge­çi­ci bağ­lan­tı ha­ta­la­rın­da ye­ni­den de­ne­ne­bi­lir. Gön­de­rim adı­mı ise an­cak yu­ka­rı­da­ki tek­rar gön­de­rim kont­ro­lü var­sa gü­ven­le ye­ni­den de­ne­nir.

PDF mi, pano mu, e-posta mı?

  • E-pos­ta (HTML + Excel): Haf­ta­lık ve ay­lık yö­ne­tim ra­por­la­rı­nın çoğu için doğ­ru var­sa­yı­lan. Hız­lı oku­nur, ek üze­rin­de ana­liz ya­pı­la­bi­lir.
  • PDF: Bi­çi­min bi­re­bir ko­run­ma­sı ge­re­ken, im­za­la­nan, ar­şiv­le­nen ya da dı­şa­rı­ya gön­de­ri­len ra­por­lar için. Ma­li­ye­ti yük­sek­tir: Ge­nel­de bir headless ta­ra­yı­cıy­la çi­zi­lir; Türk­çe ka­rak­ter­ler için doğ­ru font­la­rı kur­ma­nız, say­fa kı­rıl­ma­la­rı­nı yö­net­me­niz ge­re­kir. “Bi­re­bir aynı gö­rün­sün” is­te­ği ger­çek­ten bir ih­ti­yaç de­ğil­se PDF’e gir­me­yin.
  • Pano (Power BI, Me­ta­ba­se, Su­per­set vb.): Ke­şif ve de­rin in­ce­le­me için. E-pos­ta­nın ye­ri­ne de­ğil, ya­nı­na ko­yun; e-pos­ta dik­kat çe­ker, pano so­ru­yu ce­vap­lar.

Kontrol listesi

  • Her KPI’ın ta­nı­mı, kay­na­ğı ve sa­hi­bi ya­zı­lı.
  • Ra­por ka­pan­mış dö­ne­mi ra­por­lu­yor; dö­nem iş ye­ri­nin saat di­li­mi­ne göre he­sap­la­nı­yor.
  • Boş­luk, ta­ze­lik ve ano­ma­li kont­rol­le­ri var; veri so­ru­nu ra­po­ru bek­le­ti­yor, iş sin­ya­li uya­rı ola­rak gö­rü­nü­yor.
  • E-pos­ta göv­de­si ya­rım da­ki­ka­da oku­nu­yor ve her ra­ka­mın ya­nın­da bir kar­şı­laş­tır­ma var.
  • Excel’e sa­de­ce izin lis­te­sin­de­ki sü­tun­lar ya­zı­lı­yor; ra­kam­lar sayı ola­rak du­ru­yor.
  • Ekte müş­te­ri se­vi­ye­sin­de ham veri ve ki­şi­sel veri yok; kü­çük grup­lar giz­le­ni­yor.
  • Alı­cı lis­te­le­ri kod­dan ba­ğım­sız ve iş bi­ri­mi ta­ra­fın­dan yö­ne­ti­le­bi­li­yor.
  • Her gön­de­rim ka­yıt al­tın­da ve aynı dö­nem iki kez gön­de­ri­le­mi­yor.
  • Hata alar­mı veri eki­bi­ne gi­di­yor, yö­ne­ti­ci­ye de­ğil.
  • Ra­por za­ma­nın­da git­mez­se bi­ri­le­ri ha­ber­dar olu­yor.

Bonus: Bir yapay zeka asistanına kurdurmak

Aşa­ğı­da­ki prompt’u Cla­ude Code, Cur­sor ya da ben­ze­ri bir kod­la­ma asis­ta­nı­na ve­re­bi­lir­si­niz. Kö­şe­li pa­ran­tez­li yer­le­ri ken­di ra­po­ru­nu­za göre dol­du­run:

Sen kıdemli bir veri mühendisisin. Python ve Prefect 3 ile haftalık bir yönetim
raporu otomasyonu kuracağız. Aşağıdaki kurallara harfiyen uy; bir kuralı
esnetmen gerekirse önce bana sor.

## Rapor

- Ad: [ör. haftalik-satis]
- KPI'lar: [ör. net satış, sipariş sayısı, ortalama sepet, yeni müşteri].
  Her biri için tanım, kaynak tablo ve "artış iyi mi?" bilgisi:
  [KPI sözlüğünü buraya yapıştır]
- Kaynak veritabanı: [ör. PostgreSQL / SQL Server]
- Zamanlama: her pazartesi 07:30, Europe/Istanbul. Rapor bir önceki
  pazartesi–pazar aralığını kapsar.

## Kurallar

1. Dönem sınırları tek bir fonksiyonda hesaplanır; "bugün" Europe/Istanbul
   saat dilimine göre alınır. Sorgular yarı açık tarih aralığı kullanır.
2. Göndermeden önce doğrula: dönem boşsa veya kaynak verinin son güncellemesi
   [12] saatten eskiyse rapor bekletilir ve veri ekibine alarm gider. Net satış
   son 8 haftanın ortalamasından [%40]'tan fazla saparsa rapor gönderilir ama
   üstte vurgulu bir uyarı kutusu gösterilir. Eşikler ayar dosyasında durur.
3. E-posta gövdesi: satır içi CSS ve tablo düzeni; üstte 3–5 KPI, her birinin
   yanında geçen haftaya göre değişim (artışın iyi ya da kötü olmasına göre
   renk). Rakamlar Türkçe biçimlenir (1.234.567,89). Düz metin alternatifi de
   eklenir. Panoya bir bağlantı: [URL]
4. Excel eki bellekte üretilir (openpyxl, BytesIO). Dosyaya yazılacak sütunlar
   bir izin listesinde (sütun → başlık, sayı biçimi) tanımlanır; listede olmayan
   alan asla yazılmaz. Veri toplulaştırılmış gelir; 5'ten az müşteriden oluşan
   gruplar HAVING ile elenir. Müşteri adı, telefon, e-posta gibi kişisel veri
   eke girmez; kişi seviyesi gerekirse HMAC ile takma ad kullanılır (anahtar
   Secret block'ta). Dosyada başlık ve dönem satırı, şeritli bir Excel tablosu,
   sütun bazında TL/yüzde/sayı biçimi, otomatik sütun genişliği ve sabit başlık
   satırı olur.
5. Alıcılar bir veritabanı tablosundan okunur (rapor, eposta, tip, aktif).
6. Her gönderim rapor_gonderim tablosuna yazılır. (rapor, dönem) için başarılı
   bir kayıt varsa rapor tekrar gönderilmez; bunu koşullu benzersiz indeksle
   veritabanında da garanti et.
7. SMTP bilgileri Prefect Secret block'tan okunur. Hata alarmı yalnızca veri
   ekibine gider; yöneticilere hata ayrıntısı gönderilmez.

## İlk görev

Klasör yapısını, SQL tablolarını, flow'u ve deployment tanımını oluştur. Örnek
veriyle yerelde çalıştırılabilir olsun. Her adımdan sonra ne yaptığını kısaca
özetle ve varsayım yaptığın yerleri açıkça belirt.

Özet

  • Oto­mas­yon­dan önce ta­nım ge­lir: KPI söz­lü­ğü, oku­yu­cu ve ka­pan­mış dö­nem.
  • Gön­der­me­den önce doğ­ru­la­yın: Veri so­ru­nu ra­po­ru bek­le­tir, iş sin­ya­li uya­rı ola­rak gö­rü­nür.
  • Özet göv­de­de, de­tay ekte: Sa­tır içi stil, Türk­çe ra­kam­lar, her ra­ka­mın ya­nın­da bir kar­şı­laş­tır­ma; Excel’de sa­yı­lar sayı ola­rak.
  • Ham veri dı­şa­rı çık­maz: Excel’e sa­de­ce izin lis­te­sin­de­ki top­lu­laş­tı­rıl­mış sü­tun­lar gi­rer; müş­te­ri adı, abo­ne adı gibi ki­şi­sel ve­ri­ler dos­ya­da do­laş­maz, kü­çük grup­lar giz­le­nir. Giz­le­mek sil­mek de­ğil­dir.
  • Alı­cı­lar iş bi­ri­mi­nin elin­de, gön­de­rim­ler ka­yıt al­tın­da ve aynı ra­por iki kez git­mi­yor.
  • Ra­por ile alarm ayrı: Yö­ne­ti­ci ra­kam gö­rür, veri eki­bi hata.

Oto­ma­tik bir ra­po­run asıl de­ğe­ri za­man ka­zan­cı de­ğil, gü­ven­dir: Yö­ne­ti­ci pa­zar­te­si sa­ba­hı ge­len ra­ka­ma, o haf­ta ki­min ha­zır­la­dı­ğı­nı dü­şün­me­den gü­ve­ne­bil­me­li.

Okumaya devam