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:
| 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)
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 = 0borç tarafını (faturalar) filtreler.cha_vadealanı 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()veGREATEST()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 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:
-- =============================================
-- 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)
-
Çek/Senet farkı:
cha_tarihiçekin alındığı tarihtir, gerçek tahsilatcha_vadetarihinde 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 -
Dövizli cariler: Farklı döviz cinsindeki fatura ve tahsilatlar kur çevrimi olmadan eşleştirilemez. Bu durumda
fn_Aysm_v2_CariHesapMeblagfonksiyonu ana dövize çevirme yapar. -
Ciro hareketleri: Bir cari başka bir carinin çekini ciro edebilir. Bu durumda hareket
cha_ciro_cari_kodualanında olabilir. Kapsamlı bir analiz için WHERE koşulunaOR cha_ciro_cari_kodu = @CariKodeklenmelidir. -
Çok küçük tutarlar: 1 TL altındaki hareketler yuvarlamadan kaynaklanan kayıtlar olabilir.
AND cha_meblag > 1filtresi eklenebilir.
İlgili Yazılar
- Cari Yaşlandırma SQL Sorgusu — FIFO Bakiye Dağıtımı
- FIFO Fatura-Tahsilat Eşleştirme Algoritması
- Çek ve Senet Gerçek Tahsilat Tarihi
- Vadesinde Ödeme Yüzdesi Analizi
- Cari Risk Raporu — Bakiye, Sipariş, Teminat
- Tahsilat Detay Raporu
- cha_vade Hesaplama Rehberi
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.