Mikro ERP'de Ortalama Tahsilat Süresi (DSO) Hesaplama: SQL ile Parça Ağırlıklı FIFO Yöntemi

Mikro ERP'de ortalama tahsilat süresini (DSO) SQL ile nasıl hesaplarsınız? Basit AVG yetersizdir — parça ağırlıklı FIFO yöntemiyle fatura-tahsilat eşleştirmesi, cha_cinsi ile çek/senet ayrımı ve gerçek ödeme performansı analizi.

Sorun: Basit Ortalama Neden Yanlış Sonuç Verir?

Müşterinizin ortalama kaç günde ödeme yaptığını hesaplamak istiyorsunuz. İlk akla gelen yöntem:

-- ❌ Basit ama YANLIŞ yaklaşım
SELECT AVG(DATEDIFF(DAY, fatura_tarihi, odeme_tarihi)) AS OrtalamaGun
FROM OdemeKayitlari

Neden yanlış? Şu senaryoyu düşünün:

FaturaTutarÖdeme Süresi
Fatura A1.000 TL10 gün
Fatura B100.000 TL90 gün

Basit ortalama: (10 + 90) / 2 = 50 gün → Yanıltıcı!

Ağırlıklı ortalama: (1.000 × 10 + 100.000 × 90) / 101.000 = 89,2 gün → Gerçek!

100.000 TL’lik faturanın 90 günde ödenmesi, 1.000 TL’lik faturanın 10 günde ödenmesinden 100 kat daha etkilidir. Basit ortalama bunu görmezden gelir.

Finansal Formül vs SQL Yaklaşımı

Finans literatüründe Ortalama Tahsilat Süresi (Days Sales Outstanding — DSO) şu formülle hesaplanır:

Alacak Devir Hızı = Kredili Satışlar / Ortalama Ticari Alacaklar
DSO = 360 / Alacak Devir Hızı

Bu formül bir dönem ortalaması verir — “şirket genelinde alacaklar ortalama X günde tahsil ediliyor” der. Ancak:

  • Cari bazlı analiz yapamaz (“120.001 kodlu müşteri kaç günde ödüyor?”)
  • Fatura bazlı detay vermez (“hangi fatura kaç günde kapandı?”)
  • Trend analizi için yetersiz (“bu müşteri son 6 ayda yavaşladı mı?”)

Bu yazıda SQL ile fatura bazlı, parça ağırlıklı gerçek DSO hesabı yapacağız.

Adım Adım SQL Sorgusu (7 Adımlı)

Aşağıdaki sorguyu kendi Mikro ERP veritabanınızda çalıştırabilirsiniz. Tek değiştirmeniz gereken yer @CariKod parametresidir.

Adım 1: Borç Hareketlerini Çek (Faturalar)

DECLARE @CariKod VARCHAR(50) = '120.001'; -- Kendi cari kodunuzu yazin

SELECT
    ROW_NUMBER() OVER (ORDER BY ch.cha_tarihi ASC, ch.cha_create_date ASC) AS SiraNo,
    ch.cha_Guid,
    CAST(ch.cha_tarihi AS date) AS FaturaTarihi,
    ch.cha_meblag AS Tutar,
    ch.cha_evrakno_seri + '-' + CAST(ch.cha_evrakno_sira AS VARCHAR) AS EvrakNo,
    -- Vade tarihi cozumleme
    CASE
        WHEN ch.cha_vade BETWEEN 19000101 AND 20991231
        THEN TRY_CONVERT(date, CONVERT(char(8), ch.cha_vade), 112)
        WHEN ch.cha_vade BETWEEN -36500 AND -1
        THEN DATEADD(DAY, ABS(ch.cha_vade), CAST(ch.cha_tarihi AS date))
        ELSE NULL
    END AS VadeTarihi
INTO #Borclar
FROM CARI_HESAP_HAREKETLERI ch WITH (NOLOCK)
WHERE ch.cha_kod = @CariKod
  AND ch.cha_tip = 0          -- Borc (fatura tarafi)
  AND ch.cha_iptal = 0
  AND ch.cha_meblag > 0;

Not: cha_tip = 0 borç tarafını (faturalar) filtreler. cha_vade alanı Mikro’da iki formatta olabilir — tarih (20260315) veya gün sayısı (-30). CASE ifadesiyle her iki format da ele alınır.

Adım 2: Alacak Hareketlerini Çek (Tahsilatlar)

SELECT
    ROW_NUMBER() OVER (ORDER BY ch.cha_tarihi ASC, ch.cha_create_date ASC) AS SiraNo,
    ch.cha_Guid,
    CAST(ch.cha_tarihi AS date) AS OdemeTarihi,
    ch.cha_meblag AS Tutar,
    ch.cha_cinsi AS TahsilatAraci  -- 0=Nakit, 1=Cek, 2=Senet, 19=KK
INTO #Alacaklar
FROM CARI_HESAP_HAREKETLERI ch WITH (NOLOCK)
WHERE ch.cha_kod = @CariKod
  AND ch.cha_tip = 1          -- Alacak (tahsilat tarafi)
  AND ch.cha_iptal = 0
  AND ch.cha_meblag > 0;

Adım 3: Kümülatif Toplamları Hesapla

FIFO eşleştirmenin temelini oluşturan kümülatif toplamlar:

;WITH BorcCum AS (
    SELECT SiraNo, FaturaTarihi, VadeTarihi, Tutar, EvrakNo,
        SUM(Tutar) OVER (
            ORDER BY SiraNo
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS BorcCumTutar
    FROM #Borclar
),
AlacakCum AS (
    SELECT SiraNo, OdemeTarihi, Tutar,
        SUM(Tutar) OVER (
            ORDER BY SiraNo
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS AlacakCumTutar
    FROM #Alacaklar
),
ToplamAlacak AS (
    SELECT COALESCE(SUM(Tutar), 0) AS TopAlacak FROM #Alacaklar
),

Her satırda o ana kadar yapılmış toplam borç ve toplam tahsilat hesaplanır. Bu değerler FIFO eşleştirme için aralık sınırlarını oluşturur.

Adım 4: Her Faturanın Kapanma Durumunu Belirle

BorcDurum AS (
    SELECT
        b.SiraNo, b.FaturaTarihi, b.VadeTarihi,
        b.Tutar, b.EvrakNo,
        -- Kapanan tutar (FIFO)
        CASE
            WHEN ta.TopAlacak >= b.BorcCumTutar THEN b.Tutar
            WHEN ta.TopAlacak > b.BorcCumTutar - b.Tutar
                 THEN ta.TopAlacak - (b.BorcCumTutar - b.Tutar)
            ELSE 0
        END AS KapananTutar,
        -- Acik kalan tutar
        CASE
            WHEN ta.TopAlacak >= b.BorcCumTutar THEN 0
            WHEN ta.TopAlacak > b.BorcCumTutar - b.Tutar
                 THEN b.BorcCumTutar - ta.TopAlacak
            ELSE b.Tutar
        END AS AcikTutar,
        b.BorcCumTutar, ta.TopAlacak
    FROM BorcCum b
    CROSS JOIN ToplamAlacak ta
),

Üç durum:

  • TopAlacak >= BorcCumTutar → Fatura tam kapandı
  • TopAlacak > BorcCumTutar - Tutar → Fatura kısmen kapandı
  • Aksi halde → Fatura tamamen açık

Adım 5: FIFO Parça Ağırlıklı Kesişim (Çekirdek Algoritma)

Bu adım en kritik kısımdır. Bir fatura birden fazla tahsilatla kapanabilir. Her parçanın ödeme süresini ayrı ayrı hesaplamamız gerekir:

FaturaAlacakKesisim AS (
    SELECT
        bd.SiraNo,
        bd.FaturaTarihi,
        a.OdemeTarihi,
        -- Kesisim: bu alacagin bu borctan kapattigi tutar
        CASE
            WHEN (CASE WHEN bd.BorcCumTutar < a.AlacakCumTutar
                       THEN bd.BorcCumTutar ELSE a.AlacakCumTutar END)
               - (CASE WHEN bd.BorcCumTutar - bd.Tutar > a.AlacakCumTutar - a.Tutar
                       THEN bd.BorcCumTutar - bd.Tutar
                       ELSE a.AlacakCumTutar - a.Tutar END) > 0
            THEN (CASE WHEN bd.BorcCumTutar < a.AlacakCumTutar
                       THEN bd.BorcCumTutar ELSE a.AlacakCumTutar END)
               - (CASE WHEN bd.BorcCumTutar - bd.Tutar > a.AlacakCumTutar - a.Tutar
                       THEN bd.BorcCumTutar - bd.Tutar
                       ELSE a.AlacakCumTutar - a.Tutar END)
            ELSE 0
        END AS KapananParca
    FROM BorcDurum bd
    INNER JOIN AlacakCum a
        ON a.AlacakCumTutar > bd.BorcCumTutar - bd.Tutar
        AND a.AlacakCumTutar - a.Tutar < bd.BorcCumTutar
),

SQL Server 2022 kullananlar için: Yukarıdaki iç içe CASE ifadeleri yerine LEAST() ve GREATEST() fonksiyonlarını kullanabilirsiniz:

LEAST(bd.BorcCumTutar, a.AlacakCumTutar)
  - GREATEST(bd.BorcCumTutar - bd.Tutar, a.AlacakCumTutar - a.Tutar)

Kesişim formülü ne yapar? İki kümülatif aralığın örtüşen kısmını bulur. Detaylı açıklama için FIFO Fatura-Tahsilat Eşleştirme yazımıza bakın.

Adım 6: Fatura Bazlı Ağırlıklı Ödeme Günü

FaturaOdemeAgirlik AS (
    SELECT
        SiraNo,
        -- Her parcanin tutar agirlikli odeme gunu
        SUM(KapananParca * DATEDIFF(DAY, FaturaTarihi, OdemeTarihi))
            / NULLIF(SUM(KapananParca), 0) AS AgirlikliOdemeGun,
        SUM(KapananParca) AS ToplamKapananParca
    FROM FaturaAlacakKesisim
    WHERE KapananParca > 0
    GROUP BY SiraNo
)

Adım 7: Final Özet — İki Farklı Metrik

SELECT
    @CariKod AS [Cari Kodu],
    COUNT(*) AS [Toplam Fatura],
    SUM(CASE WHEN bd.AcikTutar = 0 THEN 1 ELSE 0 END) AS [Kapanan Fatura],
    SUM(CASE WHEN bd.AcikTutar > 0 THEN 1 ELSE 0 END) AS [Acik Fatura],
    CONVERT(decimal(18,2),
        SUM(bd.Tutar)) AS [Toplam Fatura Tutar],

    -- Metrik 1: Sadece kapanan faturalarin ort. odeme suresi
    CONVERT(decimal(10,1),
    CASE WHEN SUM(f.ToplamKapananParca) > 0
        THEN SUM(f.ToplamKapananParca * f.AgirlikliOdemeGun)
             / SUM(f.ToplamKapananParca)
        ELSE NULL END) AS [Ort Tahsilat Suresi Kapanan],

    -- Metrik 2: Tum faturalar (acik olanlar bugune kadar sayilir)
    CONVERT(decimal(10,1),
    (SUM(CASE WHEN f.ToplamKapananParca > 0
              THEN f.ToplamKapananParca * f.AgirlikliOdemeGun ELSE 0 END)
    + SUM(CASE WHEN bd.AcikTutar > 0
               THEN bd.AcikTutar * DATEDIFF(DAY, bd.FaturaTarihi, GETDATE())
               ELSE 0 END))
    / NULLIF(SUM(bd.Tutar), 0)) AS [Ort Tahsilat Suresi Tumu]

FROM BorcDurum bd
LEFT JOIN FaturaOdemeAgirlik f ON bd.SiraNo = f.SiraNo;

-- Gecici tablolari temizle
DROP TABLE IF EXISTS #Borclar;
DROP TABLE IF EXISTS #Alacaklar;

Beklenen Output

Cari KoduToplam FaturaKapananAçıkOrt Tahsilat (Kapanan)Ort Tahsilat (Tümü)
120.0014739834,2 gün41,7 gün

”Sadece Kapanan” vs “Tüm Faturalar” — Hangi Metriği Kullanmalı?

MetrikNe HesaplarNe Zaman Kullanılır
Sadece KapananTahsil edilen faturaların ağırlıklı ort. süresiTarihsel performans değerlendirmesi
Tüm FaturalarKapanan + açık faturaların birlikte ortalamasıGüncel risk değerlendirmesi

Neden iki metrik? Bir müşterinin 39 faturası ortalama 34 günde kapanmış olabilir ama şu anda 8 açık faturası 90+ gün bekliyor olabilir. “Sadece Kapanan” metriği bu sorunu gizler. “Tüm Faturalar” metriği açık faturaları bugüne kadar sayarak daha muhafazakâr bir resim çizer.

Somut Örnek: 3 Fatura, 2 Tahsilat

Algoritmanın nasıl çalıştığını adım adım izleyelim:

Faturalar:

SıraTarihTutarKümülatif
F11 Ocak50.000 TL50.000
F21 Şubat80.000 TL130.000
F31 Mart30.000 TL160.000

Tahsilatlar:

SıraTarihTutarKümülatif
T115 Şubat60.000 TL60.000
T215 Nisan70.000 TL130.000

FIFO Kesişimleri:

FaturaTahsilatKesişim TutarıÖdeme Günü
F1 (50K)T1 (60K)min(50K,60K) - max(0,0) = 50.00045 gün
F2 (80K)T1 (60K)min(130K,60K) - max(50K,0) = 10.00015 gün
F2 (80K)T2 (130K)min(130K,130K) - max(50K,60K) = 70.00073 gün

F1 ağırlıklı ödeme süresi: 50.000 × 45 / 50.000 = 45 gün

F2 ağırlıklı ödeme süresi: (10.000 × 15 + 70.000 × 73) / 80.000 = 65,75 gün

F3: Henüz hiç tahsilat almamış → Açık (bugüne kadar sayılır)

Genel ağırlıklı ortalama (kapanan): (50.000 × 45 + 80.000 × 65,75) / 130.000 = 57,8 gün

Nihai Tam Sorgu: Kopyala — Yapıştır — Çalıştır

Aşağıdaki sorguyu SSMS’de açıp sadece @CariKod değerini kendi cari kodunuzla değiştirerek çalıştırabilirsiniz:

-- =============================================
-- ORTALAMA TAHSILAT SURESI (DSO) - PARCA AGIRLIKLI FIFO
-- Kullanim: @CariKod degistirin, F5 basin
-- Uyumluluk: SQL Server 2016+
-- =============================================
DECLARE @CariKod VARCHAR(50) = '120.001'; -- << BURAYA KENDI CARI KODUNUZU YAZIN

;WITH Borclar AS (
    SELECT
        ROW_NUMBER() OVER (ORDER BY cha_tarihi ASC, cha_create_date ASC) AS SiraNo,
        CAST(cha_tarihi AS date) AS FaturaTarihi,
        cha_meblag AS Tutar,
        cha_evrakno_seri + '-' + CAST(cha_evrakno_sira AS VARCHAR) AS EvrakNo
    FROM CARI_HESAP_HAREKETLERI WITH (NOLOCK)
    WHERE cha_kod = @CariKod
      AND cha_tip = 0 AND cha_iptal = 0 AND cha_meblag > 0
),
Alacaklar AS (
    SELECT
        ROW_NUMBER() OVER (ORDER BY cha_tarihi ASC, cha_create_date ASC) AS SiraNo,
        CAST(cha_tarihi AS date) AS OdemeTarihi,
        cha_meblag AS Tutar
    FROM CARI_HESAP_HAREKETLERI WITH (NOLOCK)
    WHERE cha_kod = @CariKod
      AND cha_tip = 1 AND cha_iptal = 0 AND cha_meblag > 0
),
BorcCum AS (
    SELECT *, SUM(Tutar) OVER (ORDER BY SiraNo
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS CumTutar
    FROM Borclar
),
AlacakCum AS (
    SELECT *, SUM(Tutar) OVER (ORDER BY SiraNo
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS CumTutar
    FROM Alacaklar
),
Kesisim AS (
    SELECT
        b.FaturaTarihi, b.EvrakNo,
        a.OdemeTarihi,
        CASE
            WHEN (CASE WHEN b.CumTutar < a.CumTutar
                       THEN b.CumTutar ELSE a.CumTutar END)
               - (CASE WHEN b.CumTutar - b.Tutar > a.CumTutar - a.Tutar
                       THEN b.CumTutar - b.Tutar
                       ELSE a.CumTutar - a.Tutar END) > 0
            THEN (CASE WHEN b.CumTutar < a.CumTutar
                       THEN b.CumTutar ELSE a.CumTutar END)
               - (CASE WHEN b.CumTutar - b.Tutar > a.CumTutar - a.Tutar
                       THEN b.CumTutar - b.Tutar
                       ELSE a.CumTutar - a.Tutar END)
            ELSE 0
        END AS KapananParca,
        DATEDIFF(DAY, b.FaturaTarihi, a.OdemeTarihi) AS GunFarki
    FROM BorcCum b
    INNER JOIN AlacakCum a
        ON a.CumTutar > b.CumTutar - b.Tutar
        AND a.CumTutar - a.Tutar < b.CumTutar
)

SELECT
    @CariKod AS [Cari Kodu],
    CONVERT(decimal(18,0), SUM(KapananParca)) AS [Toplam Kapanan Tutar],
    CONVERT(decimal(10,1),
        SUM(KapananParca * GunFarki)
        / NULLIF(SUM(KapananParca), 0)
    ) AS [Agirlikli Ort Tahsilat Gun (DSO)],
    COUNT(*) AS [Parca Sayisi],
    MIN(GunFarki) AS [Min Gun],
    MAX(GunFarki) AS [Max Gun]
FROM Kesisim
WHERE KapananParca > 0;

Edge Cases (Dikkat Edilmesi Gerekenler)

  1. Çek/Senet farkı: cha_tarihi çekin alındığı tarihtir, gerçek tahsilat cha_vade tarihinde gerçekleşir. DSO hesabında hangi tarihi baz alacağınız sonucu dramatik değiştirir. Detaylar için: Çek ve Senet Gerçek Tahsilat Tarihi

  2. Dövizli cariler: Farklı döviz cinsindeki fatura ve tahsilatlar kur çevrimi olmadan eşleştirilemez. Bu durumda fn_Aysm_v2_CariHesapMeblag fonksiyonu ana dövize çevirme yapar.

  3. Ciro hareketleri: Bir cari başka bir carinin çekini ciro edebilir. Bu durumda hareket cha_ciro_cari_kodu alanında olabilir. Kapsamlı bir analiz için WHERE koşuluna OR cha_ciro_cari_kodu = @CariKod eklenmelidir.

  4. Çok küçük tutarlar: 1 TL altındaki hareketler yuvarlamadan kaynaklanan kayıtlar olabilir. AND cha_meblag > 1 filtresi eklenebilir.

İlgili Yazılar


Bu yazı AstaFlow Case Study serisinin bir parçasıdır — 8 şubeli bir yapıda cari ödeme performansı analizi sırasında geliştirdiğimiz sorgunun genelleştirilmiş halidir.

📚 İlgili Yazılar