---
title: "Mikro ERP'de Ortalama Tahsilat Süresi (DSO) Hesaplama: SQL ile Parça Ağırlıklı FIFO Yöntemi"
description: "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."
date: 2026-06-27
category: mikro-erp
tags: ["mikro-erp", "sql-server", "cari-hesap", "ortalama-tahsilat-suresi", "DSO", "FIFO", "agirlikli-ortalama", "tahsilat", "astaflow"]
url: https://mikroerp.dev/blog/mikro-erp-ortalama-tahsilat-suresi-dso-sql/
---

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

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

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

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

| Fatura | Tutar | Ödeme Süresi |
|---|---|---|
| Fatura A | 1.000 TL | 10 gün |
| Fatura B | 100.000 TL | 90 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)

```sql
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)

```sql
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:

```sql
;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

```sql
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:

```sql
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:
> ```sql
> 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](/blog/mikro-erp-fifo-fatura-tahsilat-eslestirme-sql/) yazımıza bakın.

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

```sql
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

```sql
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 Kodu | Toplam Fatura | Kapanan | Açık | Ort Tahsilat (Kapanan) | Ort Tahsilat (Tümü) |
|---|---|---|---|---|---|
| 120.001 | 47 | 39 | 8 | 34,2 gün | 41,7 gün |

## "Sadece Kapanan" vs "Tüm Faturalar" — Hangi Metriği Kullanmalı?

| Metrik | Ne Hesaplar | Ne Zaman Kullanılır |
|---|---|---|
| **Sadece Kapanan** | Tahsil edilen faturaların ağırlıklı ort. süresi | Tarihsel performans değerlendirmesi |
| **Tüm Faturalar** | Kapanan + 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ıra | Tarih | Tutar | Kümülatif |
|---|---|---|---|
| F1 | 1 Ocak | 50.000 TL | 50.000 |
| F2 | 1 Şubat | 80.000 TL | 130.000 |
| F3 | 1 Mart | 30.000 TL | 160.000 |

**Tahsilatlar:**

| Sıra | Tarih | Tutar | Kümülatif |
|---|---|---|---|
| T1 | 15 Şubat | 60.000 TL | 60.000 |
| T2 | 15 Nisan | 70.000 TL | 130.000 |

**FIFO Kesişimleri:**

| Fatura | Tahsilat | Kesişim Tutarı | Ödeme Günü |
|---|---|---|---|
| F1 (50K) | T1 (60K) | min(50K,60K) - max(0,0) = **50.000** | 45 gün |
| F2 (80K) | T1 (60K) | min(130K,60K) - max(50K,0) = **10.000** | 15 gün |
| F2 (80K) | T2 (130K) | min(130K,130K) - max(50K,60K) = **70.000** | 73 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:

```sql
-- =============================================
-- 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](/blog/mikro-erp-cek-senet-gercek-tahsilat-tarihi-sql/)

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

- [Cari Yaşlandırma SQL Sorgusu — FIFO Bakiye Dağıtımı](/blog/mikro-erp-cari-yaslandirma-sql-sorgusu/)
- [FIFO Fatura-Tahsilat Eşleştirme Algoritması](/blog/mikro-erp-fifo-fatura-tahsilat-eslestirme-sql/)
- [Çek ve Senet Gerçek Tahsilat Tarihi](/blog/mikro-erp-cek-senet-gercek-tahsilat-tarihi-sql/)
- [Vadesinde Ödeme Yüzdesi Analizi](/blog/mikro-erp-vadesinde-odeme-yuzdesi-sql/)
- [Cari Risk Raporu — Bakiye, Sipariş, Teminat](/blog/mikro-erp-cari-risk-raporu-sql/)
- [Tahsilat Detay Raporu](/blog/mikro-erp-cari-tahsilat-detay-raporu-sql/)
- [cha_vade Hesaplama Rehberi](/blog/mikro-erp-vade-hesaplama-cha-vade-sql/)

---

*Bu yazı [AstaFlow Case Study](/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.*