Ana içeriğe geç
Kesit Python
Orta · Orta bitirme

Bitirme projesi: haftalık satış raporu otomasyonu

Anadolu Market'in günlük CSV dökümlerini okuyan, pydantic ile doğrulayıp tekrarları ayıklayan, collections/itertools ile özetleyen, Jinja2 ile HTML rapor ve EmailMessage taslağı üreten; log, dry-run ve argparse CLI ile zamanlanmaya hazır bir otomasyon aracı kurarsın.

Uygulamada aç, 19 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Proje tanımı

Anadolu Market'in 12 mağazası her gün kasa sisteminden bir CSV dökümü üretir ve bunları ortak bir klasöre bırakır. Bölge müdürü her pazartesi sabahı bu dosyaları Excel'de birleştirir, hatalı satırları ayıklar, mağaza ve ürün bazında özet çıkarır, bir e-posta yazıp tabloyu yapıştırır. Bu iş yaklaşık iki saat sürüyor ve geçen ay iki kez aynı dosya iki kere eklendiği için ciro şişik raporlandı.

Senin görevin bu süreci uçtan uca otomatikleştiren, cron ya da Görev Zamanlayıcı ile her pazartesi 07:45'te çalışacak bir komut satırı aracı yazmak: "Anadolu Market haftalık satış raporu otomasyonu". Bu proje orta seviyedeki otomasyon derslerinin hepsini birleştirir: Ofis ve iş akışı otomasyonu (pathlib, dry-run, log, EmailMessage), Veri doğrulama ve pydantic v2, collections ve itertools, Şablonla rapor üretme (Jinja2) ve Standart kütüphane turu (argparse).

GereksinimNasıl karşılanır
Klasördeki tüm günlük CSV'leri oku, başka dosyaları yok sayPath.glob("*.csv") + csv.DictReader
Hatalı satırları ayıkla, hangi dosyanın hangi satırı olduğunu raporlapydantic modeli + _kaynak alanı
Aynı satır iki kez gelirse bir kez say(sipariş no, ürün) anahtarlı küme
Mağaza cirosu, en çok satan 3 ürün, günlük ciroCounter, groupby
Okunur HTML rapor ve e-posta taslağıJinja2 (autoescape) + EmailMessage
Güvenli çalıştırma, iz bırakmaVarsayılan dry-run, --uygula bayrağı, logging
Zamanlayıcıdan parametreyle çağrılabilmeargparse CLI, çıkış kodu

Mimari: küçük, test edilebilir parçalar

Her adım tek bir iş yapan saf bir fonksiyon olsun: girdi alır, çıktı döndürür, mümkünse dosyaya ya da ağa dokunmaz. Yan etkiler (dosya yazma, e-posta) yalnızca en dıştaki calistir fonksiyonunda toplanır. Bu tasarım her parçayı ayrı test etmeni, dry-run'ı tek bir if ile kurmanı ve ileride bir parçayı (ör. CSV yerine veritabanı) değiştirmeni kolaylaştırır.

  1. klasoru_oku(klasor) → ham satır sözlükleri (her biri _kaynak: "dosya.csv:satır")
  2. SatisSatiri modeli → tek satırın kuralları
  3. temizle(satirlar) → (geçerli modeller, hata metinleri)
  4. ozetle(gecerli) → özet sözlüğü
  5. html_rapor(ozet, hafta, hatalar) → HTML metni
  6. eposta_taslagi(html, ozet, hafta, alicilar) → EmailMessage
  7. arguman_ayristir(argv) → komut satırı ayarları
  8. calistir(argv) → hepsini sırayla çağırır, loglar, dry-run değilse dosyaları yazar, çıkış kodu döndürür
Dosya düzeniGerçek projede bu kod haftalik_rapor/ paketinde okuma.py, model.py, ozet.py, rapor.py, cli.py ve templates/rapor.html olarak bölünür; tests/ klasöründe her modülün testi durur. Burada tek dosyada ilerliyoruz ki tarayıcıda çalıştırabilesin.

Adım 1: Klasörü okumak

Gerçek klasöre dokunmadan çalışmak için örnek veriyi geçici bir klasöre yazıyoruz. Veriye kasıtlı olarak sorunlar ekledik: virgüllü fiyat, boşluklu mağaza adı, boş kategori, iki kez gelmiş bir satır, adedi 0 olan satır, bozuk sipariş numarası ve klasöre karışmış bir .txt dosyası.

import tempfile
from pathlib import Path

BASLIK = "siparis_no,tarih,magaza,urun,kategori,adet,birim_fiyat\n"

def ornek_klasor():
    k = Path(tempfile.mkdtemp()) / "satis"
    k.mkdir()
    (k / "satis_2026-10-26.csv").write_text(BASLIK
        + 'AM-1001,2026-10-26,Kadıköy,Türk kahvesi,İçecek,2,"89,90"\n'
        + "AM-1002,2026-10-26,Çankaya,Çay,İçecek,5,45\n"
        + 'AM-1003,2026-10-26, Kadıköy ,Çaydanlık,Mutfak,1,"1.249,00"\n', encoding="utf-8")
    (k / "satis_2026-10-27.csv").write_text(BASLIK
        + "AM-1004,2026-10-27,Bornova,Çay,İçecek,10,45\n"
        + "AM-1002,2026-10-26,Çankaya,Çay,İçecek,5,45\n"
        + "AM-1005,2026-10-27,Çankaya,Filtre kahve,,3,120.5\n"
        + "AM-1006,2026-10-27,Bornova,Su,İçecek,0,7.5\n"
        + "X-1,2026-10-27,Konak,Su,İçecek,2,abc\n", encoding="utf-8")
    (k / "okubeni.txt").write_text("CSV değil, okunmamalı", encoding="utf-8")
    return k

import csv
from pathlib import Path

def klasoru_oku(klasor):
    """Klasördeki tüm .csv dosyalarını ad sırasıyla okur; her satıra _kaynak ekler."""
    satirlar = []
    for dosya in sorted(Path(klasor).glob("*.csv")):
        with dosya.open(encoding="utf-8", newline="") as f:
            for no, satir in enumerate(csv.DictReader(f), start=2):
                satir["_kaynak"] = f"{dosya.name}:{no}"
                satirlar.append(satir)
    return satirlar

satirlar = klasoru_oku(ornek_klasor())
print(len(satirlar), "satır")
for s in satirlar[:2]:
    print(s)

enumerate(..., start=2) satır numarasını dosyadaki gerçek satırla eşler (1. satır başlık). Hata raporunda "satis_2026-10-27.csv:5" yazmak, mağaza çalışanının sorunu Excel'de saniyede bulmasını sağlar. newline="" csv modülünün tırnak içindeki satır sonlarını doğru işlemesi için önerilen ayardır.

Adım 2: Model ve temizlik

Tek bir satırın kuralları pydantic modelinde durur: sipariş numarası AM- ile başlar, adet ve fiyat pozitiftir, Türkçe ondalık ("1.249,00") kabul edilir, boş kategori "Diğer" olur. Temizlik fonksiyonu modeli her satıra uygular, hatalıları ve tekrarları ayırır. Tekrarı tanımlayan anahtar (sipariş no, ürün): bir sipariş birden çok ürün içerebilir ama aynı ürün aynı siparişte iki satır olamaz.

from datetime import date
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

class SatisSatiri(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)
    siparis_no: str = Field(pattern=r"^AM-\d+$")
    tarih: date
    magaza: str = Field(min_length=1)
    urun: str = Field(min_length=1)
    kategori: str = "Diğer"
    adet: int = Field(gt=0)
    birim_fiyat: float = Field(gt=0)

    @field_validator("birim_fiyat", mode="before")
    @classmethod
    def tr_ondalik(cls, v):
        if isinstance(v, str) and "," in v:
            v = v.replace(".", "").replace(",", ".")
        return v

    @field_validator("kategori", mode="before")
    @classmethod
    def bos_kategori(cls, v):
        if v is None or (isinstance(v, str) and not v.strip()):
            return "Diğer"
        return v

    @property
    def tutar(self):
        return round(self.adet * self.birim_fiyat, 2)

def temizle(satirlar):
    """(geçerli SatisSatiri listesi, hata metinleri) döndürür."""
    gecerli, hatalar, gorulen = [], [], set()
    for satir in satirlar:
        kaynak = satir.get("_kaynak", "?")
        veri = {k: v for k, v in satir.items() if not k.startswith("_")}
        try:
            s = SatisSatiri.model_validate(veri)
        except ValidationError as e:
            alanlar = ",".join(".".join(map(str, h["loc"])) for h in e.errors())
            hatalar.append(f"{kaynak} geçersiz: {alanlar}")
            continue
        anahtar = (s.siparis_no, s.urun)
        if anahtar in gorulen:
            hatalar.append(f"{kaynak} tekrar: {s.siparis_no}/{s.urun}")
            continue
        gorulen.add(anahtar)
        gecerli.append(s)
    return gecerli, hatalar

ham = [
    {"siparis_no": "AM-1", "tarih": "2026-10-26", "magaza": " Kadıköy ", "urun": "Çay", "kategori": "", "adet": "2", "birim_fiyat": "1.249,50", "_kaynak": "a.csv:2"},
    {"siparis_no": "AM-1", "tarih": "2026-10-26", "magaza": "Kadıköy", "urun": "Çay", "kategori": "", "adet": "2", "birim_fiyat": "1249.5", "_kaynak": "b.csv:7"},
    {"siparis_no": "X-2", "tarih": "2026-10-26", "magaza": "Konak", "urun": "Su", "kategori": "İçecek", "adet": "0", "birim_fiyat": "7,5", "_kaynak": "b.csv:8"},
]
gecerli, hatalar = temizle(ham)
print(gecerli[0].magaza, gecerli[0].kategori, gecerli[0].birim_fiyat, gecerli[0].tutar)
print(hatalar)
Tekrarlı veri sessiz bir felakettirAynı dosyanın iki kez kopyalanması rapora hata olarak değil, daha yüksek ciro olarak yansır ve kimse şikâyet etmez. Tekrarları atmak kadar, atıldıklarını loglamak da önemlidir: log'da 300 "tekrar" satırı görmek, kaynakta bir sorun olduğunun işaretidir.

Adım 3: Özet

Özet bir sözlük: toplam ciro, sipariş ve satır sayısı, mağaza cirosu (büyükten küçüğe), en çok satan 3 ürün (adet), günlük ciro. Mağaza ve ürün için Counter, gün için itertools.groupby kullanıyoruz. groupby yalnızca ardışık eşit anahtarları gruplar; bu yüzden önce sıralamak zorunludur. Eşitlikte sıralamayı ada göre sabitlemek (key=lambda x: (-x[1], x[0])) raporun her çalıştırmada aynı çıkmasını sağlar; most_common eşitlikleri ekleme sırasına göre bırakır.

from collections import namedtuple
from datetime import date
S = namedtuple("S", "siparis_no tarih magaza urun adet tutar")
D = date.fromisoformat

from collections import Counter
from itertools import groupby

def ozetle(gecerli):
    magaza, urun_adet = Counter(), Counter()
    for s in gecerli:
        magaza[s.magaza] += s.tutar
        urun_adet[s.urun] += s.adet
    sirali = sorted(gecerli, key=lambda s: s.tarih)
    gunluk = {t.isoformat(): round(sum(s.tutar for s in grup), 2)
              for t, grup in groupby(sirali, key=lambda s: s.tarih)}
    return {
        "toplam_ciro": round(sum(magaza.values()), 2),
        "satir_sayisi": len(gecerli),
        "siparis_sayisi": len({s.siparis_no for s in gecerli}),
        "magaza_ciro": sorted(((m, round(c, 2)) for m, c in magaza.items()), key=lambda x: (-x[1], x[0])),
        "en_cok_satan": sorted(urun_adet.items(), key=lambda x: (-x[1], x[0]))[:3],
        "gunluk_ciro": gunluk,
    }

veri = [S("AM-1", D("2026-10-27"), "Kadıköy", "Çay", 5, 225.0),
        S("AM-2", D("2026-10-26"), "Çankaya", "Kahve", 2, 179.8),
        S("AM-2", D("2026-10-26"), "Çankaya", "Çay", 1, 45.0),
        S("AM-3", D("2026-10-27"), "Kadıköy", "Su", 6, 45.0)]
for anahtar, deger in ozetle(veri).items():
    print(f"{anahtar:15} {deger}")

Testlerde pydantic modeli yerine aynı niteliklere sahip bir namedtuple kullandık: ozetle yalnızca .magaza, .tutar gibi niteliklere ihtiyaç duyar (ördek tiplemesi, duck typing). Parçalar arası bağımlılık ne kadar azsa test o kadar kolaydır.

Adım 4: HTML rapor ve e-posta taslağı

Sunum Jinja şablonunda, biçim (tl filtresi) tek bir fonksiyonda. autoescape açık: mağaza ya da ürün adında < olsa bile rapor bozulmaz. E-posta taslağı düz metin + HTML alternatifi + mağaza cirosu CSV eki içerir; müdür isterse eki doğrudan Excel'de açar.

from jinja2 import Environment

def tl(x):
    return f"{x:,.2f}".translate(str.maketrans(",.", ".,")) + " TL"

ENV = Environment(autoescape=True, trim_blocks=True, lstrip_blocks=True)
ENV.filters["tl"] = tl
RAPOR = ENV.from_string("""<h1>Anadolu Market | {{ hafta }} haftalık satış raporu</h1>
<p>Toplam ciro: <b>{{ ozet.toplam_ciro|tl }}</b> ({{ ozet.siparis_sayisi }} sipariş, {{ ozet.satir_sayisi }} satır)</p>
<table>
{% for magaza, ciro in ozet.magaza_ciro %}
<tr><td>{{ magaza }}</td><td>{{ ciro|tl }}</td></tr>
{% endfor %}
</table>
<ol>
{% for urun, adet in ozet.en_cok_satan %}
<li>{{ urun }} ({{ adet }} adet)</li>
{% endfor %}
</ol>
{% if hatalar %}
<p>Uyarı: {{ hatalar|length }} satır rapora alınmadı.</p>
{% endif %}""")

def html_rapor(ozet, hafta, hatalar=()):
    return RAPOR.render(ozet=ozet, hafta=hafta, hatalar=hatalar)

from email.message import EmailMessage

def eposta_taslagi(html, ozet, hafta, alicilar):
    msg = EmailMessage()
    msg["From"] = "rapor@anadolumarket.com.tr"
    msg["To"] = ", ".join(alicilar)
    msg["Subject"] = f"{hafta} haftalık satış raporu - {tl(ozet['toplam_ciro'])}"
    msg.set_content(f"Merhaba,\n\n{hafta} haftasında toplam ciro {tl(ozet['toplam_ciro'])}.\nAyrıntılar HTML raporda ve ekteki CSV'de.\n")
    msg.add_alternative(html, subtype="html")
    satirlar = ["magaza,ciro"] + [f"{m},{c:.2f}" for m, c in ozet["magaza_ciro"]]
    msg.add_attachment("\n".join(satirlar) + "\n", subtype="csv", filename=f"magaza_ciro_{hafta}.csv")
    return msg

ozet = {"toplam_ciro": 2465.3, "satir_sayisi": 5, "siparis_sayisi": 5,
        "magaza_ciro": [("Kadıköy", 1428.8), ("Çankaya", 586.5), ("Bornova", 450.0)],
        "en_cok_satan": [("Çay", 15), ("Filtre kahve", 3), ("Türk kahvesi", 2)], "gunluk_ciro": {}}
html = html_rapor(ozet, "2026-W44", ["x.csv:3 tekrar"])
print(html)
msg = eposta_taslagi(html, ozet, "2026-W44", ["mudur@anadolumarket.com.tr"])
print(msg["Subject"])
print([p.get_content_type() for p in msg.iter_parts()], [p.get_filename() for p in msg.iter_attachments()])

Adım 5: Komut satırı, log ve dry-run

Zamanlayıcı betiği parametrelerle çağırır: python haftalik_rapor.py --klasor /veri/satis --hafta 2026-W44 --alici mudur@... --uygula. argparse argümanları ayrıştırır, --help çıktısını otomatik üretir ve hatalı girdide anlaşılır mesajla 2 koduyla çıkar. Önemli kararlar:

Tam çözüm

Aşağıdaki program tüm parçaları birleştirir ve önce dry-run, sonra gerçek çalıştırma yapar. Çalıştırıp log satırlarını, çıkış kodlarını ve üretilen HTML'i incele.

import tempfile
from pathlib import Path

BASLIK = "siparis_no,tarih,magaza,urun,kategori,adet,birim_fiyat\n"

def ornek_klasor():
    k = Path(tempfile.mkdtemp()) / "satis"
    k.mkdir()
    (k / "satis_2026-10-26.csv").write_text(BASLIK
        + 'AM-1001,2026-10-26,Kadıköy,Türk kahvesi,İçecek,2,"89,90"\n'
        + "AM-1002,2026-10-26,Çankaya,Çay,İçecek,5,45\n"
        + 'AM-1003,2026-10-26, Kadıköy ,Çaydanlık,Mutfak,1,"1.249,00"\n', encoding="utf-8")
    (k / "satis_2026-10-27.csv").write_text(BASLIK
        + "AM-1004,2026-10-27,Bornova,Çay,İçecek,10,45\n"
        + "AM-1002,2026-10-26,Çankaya,Çay,İçecek,5,45\n"
        + "AM-1005,2026-10-27,Çankaya,Filtre kahve,,3,120.5\n"
        + "AM-1006,2026-10-27,Bornova,Su,İçecek,0,7.5\n"
        + "X-1,2026-10-27,Konak,Su,İçecek,2,abc\n", encoding="utf-8")
    (k / "okubeni.txt").write_text("CSV değil, okunmamalı", encoding="utf-8")
    return k

import csv
from pathlib import Path

def klasoru_oku(klasor):
    """Klasördeki tüm .csv dosyalarını ad sırasıyla okur; her satıra _kaynak ekler."""
    satirlar = []
    for dosya in sorted(Path(klasor).glob("*.csv")):
        with dosya.open(encoding="utf-8", newline="") as f:
            for no, satir in enumerate(csv.DictReader(f), start=2):
                satir["_kaynak"] = f"{dosya.name}:{no}"
                satirlar.append(satir)
    return satirlar

from datetime import date
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

class SatisSatiri(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)
    siparis_no: str = Field(pattern=r"^AM-\d+$")
    tarih: date
    magaza: str = Field(min_length=1)
    urun: str = Field(min_length=1)
    kategori: str = "Diğer"
    adet: int = Field(gt=0)
    birim_fiyat: float = Field(gt=0)

    @field_validator("birim_fiyat", mode="before")
    @classmethod
    def tr_ondalik(cls, v):
        if isinstance(v, str) and "," in v:
            v = v.replace(".", "").replace(",", ".")
        return v

    @field_validator("kategori", mode="before")
    @classmethod
    def bos_kategori(cls, v):
        if v is None or (isinstance(v, str) and not v.strip()):
            return "Diğer"
        return v

    @property
    def tutar(self):
        return round(self.adet * self.birim_fiyat, 2)

def temizle(satirlar):
    """(geçerli SatisSatiri listesi, hata metinleri) döndürür."""
    gecerli, hatalar, gorulen = [], [], set()
    for satir in satirlar:
        kaynak = satir.get("_kaynak", "?")
        veri = {k: v for k, v in satir.items() if not k.startswith("_")}
        try:
            s = SatisSatiri.model_validate(veri)
        except ValidationError as e:
            alanlar = ",".join(".".join(map(str, h["loc"])) for h in e.errors())
            hatalar.append(f"{kaynak} geçersiz: {alanlar}")
            continue
        anahtar = (s.siparis_no, s.urun)
        if anahtar in gorulen:
            hatalar.append(f"{kaynak} tekrar: {s.siparis_no}/{s.urun}")
            continue
        gorulen.add(anahtar)
        gecerli.append(s)
    return gecerli, hatalar

from collections import Counter
from itertools import groupby

def ozetle(gecerli):
    magaza, urun_adet = Counter(), Counter()
    for s in gecerli:
        magaza[s.magaza] += s.tutar
        urun_adet[s.urun] += s.adet
    sirali = sorted(gecerli, key=lambda s: s.tarih)
    gunluk = {t.isoformat(): round(sum(s.tutar for s in grup), 2)
              for t, grup in groupby(sirali, key=lambda s: s.tarih)}
    return {
        "toplam_ciro": round(sum(magaza.values()), 2),
        "satir_sayisi": len(gecerli),
        "siparis_sayisi": len({s.siparis_no for s in gecerli}),
        "magaza_ciro": sorted(((m, round(c, 2)) for m, c in magaza.items()), key=lambda x: (-x[1], x[0])),
        "en_cok_satan": sorted(urun_adet.items(), key=lambda x: (-x[1], x[0]))[:3],
        "gunluk_ciro": gunluk,
    }

from jinja2 import Environment

def tl(x):
    return f"{x:,.2f}".translate(str.maketrans(",.", ".,")) + " TL"

ENV = Environment(autoescape=True, trim_blocks=True, lstrip_blocks=True)
ENV.filters["tl"] = tl
RAPOR = ENV.from_string("""<h1>Anadolu Market | {{ hafta }} haftalık satış raporu</h1>
<p>Toplam ciro: <b>{{ ozet.toplam_ciro|tl }}</b> ({{ ozet.siparis_sayisi }} sipariş, {{ ozet.satir_sayisi }} satır)</p>
<table>
{% for magaza, ciro in ozet.magaza_ciro %}
<tr><td>{{ magaza }}</td><td>{{ ciro|tl }}</td></tr>
{% endfor %}
</table>
<ol>
{% for urun, adet in ozet.en_cok_satan %}
<li>{{ urun }} ({{ adet }} adet)</li>
{% endfor %}
</ol>
{% if hatalar %}
<p>Uyarı: {{ hatalar|length }} satır rapora alınmadı.</p>
{% endif %}""")

def html_rapor(ozet, hafta, hatalar=()):
    return RAPOR.render(ozet=ozet, hafta=hafta, hatalar=hatalar)

from email.message import EmailMessage

def eposta_taslagi(html, ozet, hafta, alicilar):
    msg = EmailMessage()
    msg["From"] = "rapor@anadolumarket.com.tr"
    msg["To"] = ", ".join(alicilar)
    msg["Subject"] = f"{hafta} haftalık satış raporu - {tl(ozet['toplam_ciro'])}"
    msg.set_content(f"Merhaba,\n\n{hafta} haftasında toplam ciro {tl(ozet['toplam_ciro'])}.\nAyrıntılar HTML raporda ve ekteki CSV'de.\n")
    msg.add_alternative(html, subtype="html")
    satirlar = ["magaza,ciro"] + [f"{m},{c:.2f}" for m, c in ozet["magaza_ciro"]]
    msg.add_attachment("\n".join(satirlar) + "\n", subtype="csv", filename=f"magaza_ciro_{hafta}.csv")
    return msg

import argparse, re

def hafta_tipi(metin):
    if not re.fullmatch(r"\d{4}-W\d{2}", metin):
        raise argparse.ArgumentTypeError(f"hafta YYYY-Www biçiminde olmalı: {metin}")
    return metin

def arguman_ayristir(argv=None):
    p = argparse.ArgumentParser(prog="haftalik_rapor", description="Anadolu Market haftalık satış raporu")
    p.add_argument("--klasor", required=True, help="CSV dosyalarının klasörü")
    p.add_argument("--hafta", required=True, type=hafta_tipi, help="ör. 2026-W44")
    p.add_argument("--cikti", default="cikti", help="rapor klasörü")
    p.add_argument("--alici", action="append", default=[], dest="alicilar", help="birden çok kez verilebilir")
    p.add_argument("--uygula", action="store_true", help="dosyaları gerçekten yaz (varsayılan: dry-run)")
    p.add_argument("--log-seviye", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
    return p.parse_args(argv)

import logging

log = logging.getLogger("haftalik_rapor")

def calistir(argv=None):
    args = arguman_ayristir(argv)
    log.setLevel(args.log_seviye)
    satirlar = klasoru_oku(args.klasor)
    if not satirlar:
        log.error("klasörde CSV satırı yok: %s", args.klasor)
        return 1
    log.info("%d satır okundu", len(satirlar))
    gecerli, hatalar = temizle(satirlar)
    for h in hatalar:
        log.warning(h)
    ozet = ozetle(gecerli)
    html = html_rapor(ozet, args.hafta, hatalar)
    msg = eposta_taslagi(html, ozet, args.hafta, args.alicilar or ["mudurler@anadolumarket.com.tr"])
    cikti = Path(args.cikti)
    hedefler = [cikti / f"rapor_{args.hafta}.html", cikti / f"taslak_{args.hafta}.eml"]
    if not args.uygula:
        for h in hedefler:
            log.info("[DRY-RUN] yazılacaktı: %s", h.name)
        return 0
    cikti.mkdir(parents=True, exist_ok=True)
    hedefler[0].write_text(html, encoding="utf-8")
    hedefler[1].write_bytes(bytes(msg))
    log.info("yazıldı: %s", ", ".join(h.name for h in hedefler))
    return 0

if __name__ == "__main__":
    import sys
    logging.basicConfig(stream=sys.stdout, format="%(levelname)-7s %(message)s", force=True)
    klasor = ornek_klasor()
    cikti = klasor.parent / "cikti"
    print("Çıkış kodu:", calistir(["--klasor", str(klasor), "--hafta", "2026-W44", "--cikti", str(cikti)]))
    print("Çıkış kodu:", calistir(["--klasor", str(klasor), "--hafta", "2026-W44", "--cikti", str(cikti),
                                   "--alici", "mudur@anadolumarket.com.tr", "--uygula", "--log-seviye", "WARNING"]))
    print(sorted(p.name for p in cikti.iterdir()))
    print((cikti / "rapor_2026-W44.html").read_text(encoding="utf-8"))

Çıktıyı okumak

İlk çalıştırmada (dry-run) log önce okunan satır sayısını, ardından atılan üç satırı nedenleriyle gösterir: satis_2026-10-27.csv:3 önceki dosyadaki siparişin tekrarı, :5 adedi 0 olan satır, :6 hem sipariş numarası hem fiyatı bozuk satır. Sonra yazılacak iki dosyanın adını söyler ama klasör oluşturulmaz. İkinci çalıştırmada --uygula ve --log-seviye WARNING verdik: bilgi satırları kaybolur, yalnızca uyarılar kalır ve dosyalar yazılır. Bu, cron'daki tipik kullanımdır: her şey yolundaysa log sessizdir, bir sorun varsa yalnızca o görünür.

Rapordaki toplam ciro 2.465,30 TL'dir. Tekrar ayıklanmasaydı Çankaya'nın cirosu 225 TL fazla, yani yaklaşık %38 şişik görünecekti; bu, otomasyona eklediğin tek bir kümenin değerini somut olarak gösterir. Alıştırmalarda bu parçaları tek tek yazacak, ardından iki mini projeyle aracı genişleteceksin: tüm boru hattını (pipeline) birleştiren calistir ve geçen haftayla karşılaştırma tablosu. Meydan okuma görevindeki anomali tespiti de rapora "dikkat" bölümü eklemenin ilk adımıdır.

Kendi bilgisayarında bu dosyayı haftalik_rapor.py olarak kaydedip ornek_klasor() yerine gerçek klasörü vererek çalıştırabilirsin. Gönderim için Ofis ve iş akışı otomasyonu dersindeki smtplib.SMTP_SSL bloğunu --gonder bayrağının arkasına ekle; varsayılan davranış her zaman yalnızca taslak üretmek olsun.

# Linux/macOS cron (crontab -e): her pazartesi 07:45
45 7 * * 1 /opt/rapor/.venv/bin/python /opt/rapor/haftalik_rapor.py --klasor /veri/satis --hafta $(date +\%G-W\%V) --uygula >> /var/log/rapor.log 2>&1

# Windows Görev Zamanlayıcı
schtasks /create /sc weekly /d MON /st 07:45 /tn HaftalikRapor /tr "C:\rapor\.venv\Scripts\python.exe C:\rapor\haftalik_rapor.py --klasor D:\satis --hafta 2026-W44 --uygula"
Gerçek işteBu tür bir rapor otomasyonu genellikle küçük başlar ve hızla kritikleşir: birkaç ay sonra finans ekibi kararlarını o rapora göre verir. O noktada üç soru sorulur: "Rapor bu hafta çalıştı mı?" (log ve çıkış kodu izlemesi), "Rakam neden geçen haftadan farklı?" (atılan satırların raporu, tekrar sayısı) ve "Bunu kim değiştirdi?" (git, testler, kod incelemesi (code review)). Projeyi baştan bu sorulara cevap verecek biçimde kurmak, onu bir betikten bir ürüne dönüştürür.
Uzmanlaş

Bu projeyi üretime taşısaydın

  • Testler: Her parça için pytest testleri; tmp_path fikstürü ile geçici klasör; HTML çıktısı için anlık görüntü (snapshot) testi. calistir için uçtan uca test: örnek klasör → dry-run → çıkış kodu 0 ve hiç dosya yok.
  • Yapılandırma: Alıcılar, SMTP ve klasör yolları pydantic-settings ile ortam değişkenlerinden; şifre SecretStr.
  • İdempotentlik: Aynı hafta için rapor zaten gönderildiyse (durum dosyası ya da SQLite tablosu) ikinci çalıştırma göndermesin.
  • Ölçek: Dosyalar büyüdüğünde satır satır pydantic yerine pandas/polars ile vektörel doğrulama; özetleme groupby ile.
  • Gözlemlenebilirlik: Log'u JSON biçiminde yazıp merkezi bir sisteme gönder; "atılan satır oranı %5'i aştı" gibi eşiklerde uyarı üret.
  • Paketleme: pyproject.toml içinde [project.scripts] haftalik-rapor = "haftalik_rapor.cli:calistir" ile komut olarak kur.
Alıştırmalara geç (19)