SQL Server'da FIFO Fatura-Tahsilat Eşleştirme: Kümülatif Aralık Kesişim Algoritması

Bir fatura 3 farklı tahsilatla kapanırsa her parçanın ödeme süresini nasıl hesaplarsınız? Window Function ile kümülatif toplam, aralık kesişim formülü ve Cursor'a gerek kalmadan set-based FIFO eşleştirme algoritması.

Problem: Bir Fatura Üç Farklı Tahsilatla Kapanırsa Ne Olur?

Bir müşterinize 100.000 TL’lik fatura kestiniz. Müşteri şu şekilde ödedi:

ÖdemeTarihTutarFatura Sonrası
1. Ödeme15 gün sonra30.000 TL15 gün
2. Ödeme45 gün sonra50.000 TL45 gün
3. Ödeme80 gün sonra20.000 TL80 gün

Bu fatura ortalama kaç günde ödendi?

Yanlış hesap: (15 + 45 + 80) / 3 = 46,7 gün

Doğru hesap (ağırlıklı): (30.000 × 15 + 50.000 × 45 + 20.000 × 80) / 100.000 = 35,5 gün

Peki ya bu tahsilatlar başka faturaları da kapsıyorsa? Yani 2. ödeme (50.000 TL) hem bu faturanın kalan 20.000 TL’sini hem de bir sonraki faturanın 30.000 TL’sini kapatıyorsa?

İşte bu sorunun çözümü FIFO Kümülatif Aralık Kesişim Algoritmasıdır.

Naif Yaklaşım: Cursor (Neden Kötü?)

-- ❌ CURSOR ile naive yaklasim (YAVAS ve karmasik)
DECLARE @KalanOdeme DECIMAL(18,2)
DECLARE @OdemeTarihi DATE
DECLARE @OdemeTutar DECIMAL(18,2)

DECLARE odeme_cursor CURSOR FOR
    SELECT OdemeTarihi, Tutar FROM #Odemeler ORDER BY OdemeTarihi

OPEN odeme_cursor
FETCH NEXT FROM odeme_cursor INTO @OdemeTarihi, @OdemeTutar

WHILE @@FETCH_STATUS = 0
BEGIN
    SET @KalanOdeme = @OdemeTutar
    -- Her odeme icin tum acik faturalari tara...
    -- En eski faturadan baslayarak kalanini dus...
    -- N odeme x M fatura = N*M islem!
    FETCH NEXT FROM odeme_cursor INTO @OdemeTarihi, @OdemeTutar
END
CLOSE odeme_cursor
DEALLOCATE odeme_cursor

Sorunları:

  • O(N × M) karmaşıklık: 500 ödeme × 300 fatura = 150.000 işlem
  • Satır satır işlem: SQL Server’ın set-based gücünden yararlanmaz
  • Bakımı zor: Hata ayıklama, değişiklik yapmak kabus
  • Paralel çalışamaz: Cursor seri çalışır, birden fazla CPU kullanamaz

Set-Based Çözüm: Window Functions ile FIFO

Aynı işi tek bir SELECT ile yapabiliriz. Anahtar fikir: fatura ve tahsilatları kümülatif toplamlarla birer sayı doğrusu aralığına dönüştürmek, sonra aralıkların kesişimini bulmak.

Adım 0: Test Verisi Oluştur

Aşağıdaki test verisini kendi SQL Server’ınızda çalıştırabilirsiniz — Mikro ERP gerekmez:

-- =============================================
-- TEST VERISI - Kendi DB'nizde calistirabilirsiniz
-- =============================================
IF OBJECT_ID('tempdb..#TestFaturalar') IS NOT NULL DROP TABLE #TestFaturalar;
IF OBJECT_ID('tempdb..#TestOdemeler') IS NOT NULL DROP TABLE #TestOdemeler;

CREATE TABLE #TestFaturalar (
    FaturaNo INT,
    Tarih DATE,
    Tutar DECIMAL(18,2)
);
INSERT INTO #TestFaturalar VALUES
    (1, '2026-01-15', 50000),   -- 50.000 TL
    (2, '2026-02-10', 80000),   -- 80.000 TL
    (3, '2026-03-05', 30000);   -- 30.000 TL
                                -- Toplam: 160.000 TL

CREATE TABLE #TestOdemeler (
    OdemeNo INT,
    Tarih DATE,
    Tutar DECIMAL(18,2)
);
INSERT INTO #TestOdemeler VALUES
    (1, '2026-02-01', 40000),   -- 40.000 TL
    (2, '2026-03-15', 60000),   -- 60.000 TL
    (3, '2026-04-20', 35000);   -- 35.000 TL
                                -- Toplam: 135.000 TL
                                -- Acik kalan: 25.000 TL

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

;WITH FaturaCum AS (
    SELECT
        FaturaNo,
        Tarih AS FaturaTarihi,
        Tutar,
        SUM(Tutar) OVER (
            ORDER BY Tarih, FaturaNo
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS CumTutar
    FROM #TestFaturalar
),
OdemeCum AS (
    SELECT
        OdemeNo,
        Tarih AS OdemeTarihi,
        Tutar,
        SUM(Tutar) OVER (
            ORDER BY Tarih, OdemeNo
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS CumTutar
    FROM #TestOdemeler
),

Bu adımdan sonra veriler şöyle görünür:

Faturalar (kümülatif):

FaturaNoTutarCumTutarAralık
150.00050.000[0 — 50.000]
280.000130.000[50.000 — 130.000]
330.000160.000[130.000 — 160.000]

Ödemeler (kümülatif):

OdemeNoTutarCumTutarAralık
140.00040.000[0 — 40.000]
260.000100.000[40.000 — 100.000]
335.000135.000[100.000 — 135.000]

Adım 2: Aralık Kesişimini Hesapla (Çekirdek Formül)

İki aralığın kesişim uzunluğu:

Kesişim = min(ÜstSınır_A, ÜstSınır_B) - max(AltSınır_A, AltSınır_B)

Pozitifse kesişim var, negatif veya sıfırsa yok.

Kesisim AS (
    SELECT
        f.FaturaNo,
        o.OdemeNo,
        f.FaturaTarihi,
        o.OdemeTarihi,
        f.Tutar AS FaturaTutar,
        o.Tutar AS OdemeTutar,
        -- Kesisim formulu
        CASE
            WHEN (CASE WHEN f.CumTutar < o.CumTutar
                       THEN f.CumTutar ELSE o.CumTutar END)
               - (CASE WHEN f.CumTutar - f.Tutar > o.CumTutar - o.Tutar
                       THEN f.CumTutar - f.Tutar
                       ELSE o.CumTutar - o.Tutar END) > 0
            THEN (CASE WHEN f.CumTutar < o.CumTutar
                       THEN f.CumTutar ELSE o.CumTutar END)
               - (CASE WHEN f.CumTutar - f.Tutar > o.CumTutar - o.Tutar
                       THEN f.CumTutar - f.Tutar
                       ELSE o.CumTutar - o.Tutar END)
            ELSE 0
        END AS KesisimTutar
    FROM FaturaCum f
    INNER JOIN OdemeCum o
        ON o.CumTutar > f.CumTutar - f.Tutar  -- Odeme aralik sonu > Fatura aralik basi
        AND o.CumTutar - o.Tutar < f.CumTutar  -- Odeme aralik basi < Fatura aralik sonu
)

SQL Server 2022 ve üzeri kullananlar iç içe CASE yerine LEAST() ve GREATEST() kullanabilir:

LEAST(f.CumTutar, o.CumTutar)
  - GREATEST(f.CumTutar - f.Tutar, o.CumTutar - o.Tutar)

Görsel Açıklama: Sayı Doğrusu

Kümülatif aralıkları bir sayı doğrusu üzerinde düşünün:

Sayı Doğrusu (TL)
0         40K       50K        100K      130K  135K   160K
|          |         |           |         |     |      |
├── F1 ────┤─────────┤           |         |     |      |
|  [0—50K] |         |           |         |     |      |
|          |         ├── F2 ─────┤─────────┤     |      |
|          |         |  [50K—130K]         |     |      |
|          |         |           |         ├─ F3 ┤──────┤
|          |         |           |         | [130K—160K]|
|          |         |           |         |     |      |
├── O1 ────┤         |           |         |     |      |
|  [0—40K] |         |           |         |     |      |
|          ├── O2 ───┤───────────┤         |     |      |
|          | [40K—100K]          |         |     |      |
|          |         |           ├── O3 ───┤─────┤      |
|          |         |           |[100K—135K]    |      |

Kesişimler:

F1 ∩ O1 = min(50K, 40K) - max(0, 0)         = 40.000 TL  ✓
F1 ∩ O2 = min(50K, 100K) - max(0, 40K)      = 10.000 TL  ✓
F2 ∩ O2 = min(130K, 100K) - max(50K, 40K)   = 50.000 TL  ✓
F2 ∩ O3 = min(130K, 135K) - max(50K, 100K)  = 30.000 TL  ✓
F3 ∩ O3 = min(160K, 135K) - max(130K, 100K) =  5.000 TL  ✓

Kontrol: 40K + 10K + 50K + 30K + 5K = 135.000 TL = Toplam ödeme ✓

Adım 3: Fatura Bazlı Ağırlıklı Ödeme Süresi

SELECT
    k.FaturaNo,
    k.FaturaTarihi,
    SUM(k.KesisimTutar) AS KapananTutar,
    (SELECT Tutar FROM #TestFaturalar
     WHERE FaturaNo = k.FaturaNo) AS FaturaTutar,
    (SELECT Tutar FROM #TestFaturalar
     WHERE FaturaNo = k.FaturaNo)
        - SUM(k.KesisimTutar) AS AcikKalan,
    -- Agirlikli ortalama odeme gunu
    SUM(k.KesisimTutar * DATEDIFF(DAY, k.FaturaTarihi, k.OdemeTarihi))
        / NULLIF(SUM(k.KesisimTutar), 0) AS AgirlikliOdemeGun
FROM Kesisim k
WHERE k.KesisimTutar > 0
GROUP BY k.FaturaNo, k.FaturaTarihi
ORDER BY k.FaturaNo;

Beklenen Output

FaturaNoFatura TarihiKapananFatura TutarAçık KalanAğırlıklı Gün
12026-01-1550.00050.000025
22026-02-1080.00080.000047
32026-03-055.00030.00025.00046

Fatura 1: 40K’sı 17 günde (O1), 10K’sı 59 günde (O2) kapandı → (40K×17 + 10K×59)/50K = 25,4 gün

Fatura 2: 50K’sı 33 günde (O2), 30K’sı 69 günde (O3) kapandı → (50K×33 + 30K×69)/80K = 46,5 gün

Fatura 3: Sadece 5K’sı 46 günde (O3) kapandı, 25K hâlâ açık.

Mikro ERP Uyarlaması

Test verisinde konsepti anladıysanız, Mikro ERP tablolarına uyarlamak kolaydır:

-- Mikro ERP icin: #TestFaturalar yerine gercek faturalar
DECLARE @CariKod VARCHAR(50) = '120.001';

-- Faturalar (Borc tarafi)
SELECT
    ROW_NUMBER() OVER (ORDER BY cha_tarihi ASC, cha_create_date ASC) AS FaturaNo,
    CAST(cha_tarihi AS date) AS Tarih,
    cha_meblag AS Tutar
INTO #MikroFaturalar
FROM CARI_HESAP_HAREKETLERI WITH (NOLOCK)
WHERE cha_kod = @CariKod
  AND cha_tip = 0          -- Borc
  AND cha_iptal = 0
  AND cha_meblag > 1;      -- Cok kucuk tutarlari filtrele

-- Tahsilatlar (Alacak tarafi)
SELECT
    ROW_NUMBER() OVER (ORDER BY cha_tarihi ASC, cha_create_date ASC) AS OdemeNo,
    CAST(cha_tarihi AS date) AS Tarih,
    cha_meblag AS Tutar
INTO #MikroOdemeler
FROM CARI_HESAP_HAREKETLERI WITH (NOLOCK)
WHERE cha_kod = @CariKod
  AND cha_tip = 1          -- Alacak
  AND cha_iptal = 0
  AND cha_meblag > 1;

-- Sonra ayni CTE yapisini #MikroFaturalar ve #MikroOdemeler ile kullanin

Dikkat edilecekler:

  • cha_tip = 0 → Borç (fatura), cha_tip = 1 → Alacak (tahsilat)
  • cha_ciro_cari_kodu — Ciro hareketleri için ek filtre gerekebilir
  • cha_evrak_tip = 59 hariç tutulmalı (gereksiz evrak tipi)
  • Daha hassas meblağ hesabı için fn_Aysm_v2_CariHesapMeblag fonksiyonu kullanılabilir

Performans Karşılaştırması

Yöntem300 Fatura × 200 Ödeme1.000 × 800Karmaşıklık
Cursor~2,4 sn~32 snO(N × M)
Window Function~0,1 sn~0,8 snO(N log N)
Fark24x hızlı40x hızlı

Window Function yaklaşımı SQL Server’ın sıralama ve pencere işlemlerini paralel çalıştırabilmesi sayesinde büyük veri setlerinde dramatik fark yaratır.

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

Test verisi dahil, herhangi bir veritabanında çalışır. Kendi Mikro DB’nizde kullanmak için #Faturalar ve #Odemeler kısımlarını gerçek tabloyla değiştirin:

-- =============================================
-- FIFO FATURA-TAHSILAT ESLESTIRME (TEST VERISI DAHIL)
-- Kullanim: Direkt F5 basin, sonuc gelir
-- Uyumluluk: SQL Server 2016+
-- =============================================

-- TEST VERISI
IF OBJECT_ID('tempdb..#Faturalar') IS NOT NULL DROP TABLE #Faturalar;
IF OBJECT_ID('tempdb..#Odemeler') IS NOT NULL DROP TABLE #Odemeler;

CREATE TABLE #Faturalar (FaturaNo VARCHAR(10), Tarih DATE, Tutar DECIMAL(18,2));
CREATE TABLE #Odemeler (OdemeNo VARCHAR(10), Tarih DATE, Tutar DECIMAL(18,2));

INSERT INTO #Faturalar VALUES
    ('F001', '2026-01-10', 50000),
    ('F002', '2026-01-20', 80000),
    ('F003', '2026-02-05', 30000);

INSERT INTO #Odemeler VALUES
    ('T001', '2026-02-15', 60000),
    ('T002', '2026-03-10', 70000);

-- FIFO ESLESTIRME
;WITH BorcCum AS (
    SELECT
        FaturaNo, Tarih AS FaturaTarihi, Tutar,
        SUM(Tutar) OVER (ORDER BY Tarih, FaturaNo
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS CumTutar
    FROM #Faturalar
),
AlacakCum AS (
    SELECT
        OdemeNo, Tarih AS OdemeTarihi, Tutar,
        SUM(Tutar) OVER (ORDER BY Tarih, OdemeNo
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS CumTutar
    FROM #Odemeler
),
Kesisim AS (
    SELECT
        b.FaturaNo, b.FaturaTarihi, b.Tutar AS FaturaTutar,
        a.OdemeNo, a.OdemeTarihi, a.Tutar AS OdemeTutar,
        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
    FaturaNo, FaturaTarihi, FaturaTutar,
    OdemeNo, OdemeTarihi, OdemeTutar,
    CONVERT(decimal(18,2), KapananParca) AS [Kapanan Parca],
    GunFarki AS [Gun Farki]
FROM Kesisim
WHERE KapananParca > 0
ORDER BY FaturaNo, OdemeNo;

-- TEMIZLIK
DROP TABLE #Faturalar;
DROP TABLE #Odemeler;

Mikro ERP’de kullanmak için: #Faturalar yerine CARI_HESAP_HAREKETLERI WHERE cha_tip = 0, #Odemeler yerine WHERE cha_tip = 1 kullanın. Detaylar yukarıdaki “Mikro ERP Uyarlaması” bölümünde.

Edge Cases (İstisnai Durumlar)

  1. Negatif tutarlar: İade faturaları veya düzeltme kayıtları negatif olabilir. Bunları ana analizden hariç tutun (cha_meblag > 0).

  2. Aynı tutarlı aynı tarihli evraklar: ORDER BY sıralamasında çakışma olabilir. cha_create_date ve cha_Guid ile kırılım sağlayın.

  3. Çek/Senet gerçek tahsilat tarihi: Bu sorguda cha_tarihi kullanılıyor. Çek vadesini baz almak için Çek ve Senet Gerçek Tahsilat Tarihi yazısına bakın.

  4. Toplam ödeme > toplam fatura: Fazla ödeme varsa avans kaydı oluşur. Kesişim algoritması bunu otomatik ele alır — fazla kısım hiçbir faturaya eşleşmez.

İlgili Yazılar


Bu yazı AstaFlow Case Study serisinin bir parçasıdır — parça ağırlıklı FIFO eşleştirme algoritması cari yaşlandırma ve ortalama tahsilat süresi modüllerinin temelini oluşturur.

📚 İlgili Yazılar