ERP Asistanı Dal Net
Mikro SQL

Mikro Müşteri Risk Raporu SQL: Bakiye ve Son Tahsilat

Mikro'da müşteri risk raporu: tek sorguda güncel bakiye, son tahsilat tarihi, son satış tarihi ve vadesi geçen tutar. CASE + MAX deseniyle T-SQL.

Erp Asistanı Erp Asistanı Editör ekibi 11 dk okuma
Mikro Müşteri Risk Raporu SQL: Bakiye ve Son Tahsilat

Müşteri risk raporu denince çoğu kişi tek bir sayı düşünür: cari ne kadar borçlu. Oysa tek başına bakiye yanıltıcıdır. 300 bin TL borcu olup dün ödeme yapan müşteri ile 80 bin TL borcu olup 7 aydır tek kuruş ödemeyen müşteri aynı sepete girmez. Risk kararı üç verinin birlikte okunmasıyla çıkar: güncel net bakiye, son tahsilat tarihi, son satış tarihi. Bu yazıda bu üçünü tek sorguda üreten T-SQL'i, ardından vadesi geçen tutarı ve risk segmentasyonunu veriyoruz. Tüm sorgular CARI_HESAP_HAREKETLERI ve CARI_HESAPLAR üzerinde çalışır; tablo bağlarını bilmiyorsanız Mikro tablo yapısı rehberi iyi bir başlangıçtır.

Genel kural yine geçerli: her sorguda cha_iptal = 0 ve cari_iptal = 0 filtresi vardır, iade satırları net hesapta düşülür.

Risk raporunun üç ayağı

Raporun doğru olması için üç metriğin de doğru kaynaktan gelmesi gerekir:

  • Net bakiye: SUM(CASE WHEN cha_tip = 0 THEN +tutar WHEN cha_tip = 1 THEN -tutar END). cha_tip borç/alacak yönüdür, tutar değil.
  • Son tahsilat: MAX(cha_tarihi) ancak cha_evrak_tip = 1 (Tahsilat Makbuzu) koşuluyla.
  • Son satış: MAX(cha_tarihi) ancak cha_evrak_tip = 63 (Satış Faturası) koşuluyla.

İkinci ve üçüncü maddede klasik tuzak şudur: cha_tip = 1 (alacak) filtresiyle tahsilat aramak. Her alacak satırı tahsilat değildir; iade, kur farkı, virman da alacak tarafına düşer. Tahsilat tarihi için evrak tipini kullanın, cha_tip'i değil.

Ana sorgu: bakiye + son tahsilat + son satış

Aşağıdaki sorgu tek geçişte üç metriği üretir. CASE içindeki MAX deseni, aynı satır kümesinden koşullu tarihleri çıkarmanın en kısa yoludur:

WITH Risk AS (
    SELECT
        ch.cha_kod,
        SUM(CASE
                WHEN ch.cha_tip = 0 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN ch.cha_meblag ELSE -ch.cha_meblag END
                WHEN ch.cha_tip = 1 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN -ch.cha_meblag ELSE ch.cha_meblag END
                ELSE 0
            END) AS NetBakiye,
        MAX(CASE WHEN ch.cha_evrak_tip = 1  THEN ch.cha_tarihi END) AS SonTahsilat,
        MAX(CASE WHEN ch.cha_evrak_tip = 63 THEN ch.cha_tarihi END) AS SonSatis
    FROM CARI_HESAP_HAREKETLERI ch
    WHERE ch.cha_iptal = 0
    GROUP BY ch.cha_kod
)
SELECT
    r.cha_kod,
    LTRIM(RTRIM(ISNULL(c.cari_unvan1, '') + ' ' + ISNULL(c.cari_unvan2, ''))) AS Unvan,
    r.NetBakiye,
    CAST(r.SonTahsilat AS date) AS SonTahsilatGunu,
    CAST(r.SonSatis AS date)    AS SonSatisGunu
FROM Risk r
JOIN CARI_HESAPLAR c ON c.cari_kod = r.cha_kod
WHERE c.cari_iptal = 0
  AND c.cari_hidden = 0
  AND c.cari_baglanti_tipi = 0
  AND r.NetBakiye <> 0
ORDER BY r.NetBakiye DESC;

Bu raporu SQL yazmadan, Türkçe sorarak alın: Mikro raporlama

Çıktıyı okurken neye baktığınızı bilin:

Kolon Ne söyler Karar için kullanımı
NetBakiye Bugünkü borç/alacak farkı Pozitifse müşteri borçlu, negatifse avanslı
SonTahsilatGunu En son ne zaman para girdi Boşsa hiç tahsilat yok, çok eskiyse risk yüksek
SonSatisGunu En son ne zaman satış yapıldı Satış durduysa tahsilat da durur

Adım 2: kendi verinizde tahsilat evrak tiplerini doğrulayın

Yukarıdaki sorgu yalnız cha_evrak_tip = 1 satırlarını tahsilat sayar. Çek, senet, kredi kartı ve dekont tahsilatları ayrı evrak tipleridir ve numaraları kurulumdan kuruluma değişebilir. Tahsilat tanımınızı genişletmeden önce kendi veritabanınızda hangi tiplerin gerçekten kullanıldığına bakın:

SELECT cha_evrak_tip,
       COUNT(*) AS SatirSayisi,
       MIN(cha_tarihi) AS IlkKayit,
       MAX(cha_tarihi) AS SonKayit
FROM CARI_HESAP_HAREKETLERI
WHERE cha_iptal = 0
GROUP BY cha_evrak_tip
ORDER BY SatirSayisi DESC;

Bu listeye bakmadan "çekle tahsilatı da sayıyorum" demek, raporu sessizce yanlış yapar. Liste boş gelen tipleri sorgunuza eklemeyin.

Riskli müşteriyi işaretlemek

Bakiye sıralaması tek başına risk sıralaması değildir. Aşağıdaki sorgu, bakiyesi pozitif olup son 60 gündür tahsilat görmeyen müşterileri çeker. DATEDIFF sonucu NULL ise hiç tahsilat yapılmamış demektir, bunu da listeye dahil ediyoruz:

WITH Risk AS (
    SELECT ch.cha_kod,
           SUM(CASE
                   WHEN ch.cha_tip = 0 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN ch.cha_meblag ELSE -ch.cha_meblag END
                   WHEN ch.cha_tip = 1 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN -ch.cha_meblag ELSE ch.cha_meblag END
                   ELSE 0
               END) AS NetBakiye,
           MAX(CASE WHEN ch.cha_evrak_tip = 1  THEN ch.cha_tarihi END) AS SonTahsilat,
           MAX(CASE WHEN ch.cha_evrak_tip = 63 THEN ch.cha_tarihi END) AS SonSatis
    FROM CARI_HESAP_HAREKETLERI ch
    WHERE ch.cha_iptal = 0
    GROUP BY ch.cha_kod
)
SELECT r.cha_kod,
       LTRIM(RTRIM(ISNULL(c.cari_unvan1, '') + ' ' + ISNULL(c.cari_unvan2, ''))) AS Unvan,
       r.NetBakiye,
       CAST(r.SonTahsilat AS date) AS SonTahsilatGunu,
       DATEDIFF(DAY, r.SonTahsilat, GETDATE()) AS TahsilattanGun
FROM Risk r
JOIN CARI_HESAPLAR c ON c.cari_kod = r.cha_kod
WHERE c.cari_iptal = 0 AND c.cari_hidden = 0 AND c.cari_baglanti_tipi = 0
  AND r.NetBakiye > 0
  AND (r.SonTahsilat IS NULL OR r.SonTahsilat < DATEADD(DAY, -60, GETDATE()))
ORDER BY r.NetBakiye DESC;

Aynı desene CASE ekleyerek segment üretebilirsiniz. Eşikleri işletmenize göre değiştirin, sorguya gömülü bırakmayın:

WITH Risk AS (
    SELECT ch.cha_kod,
           SUM(CASE WHEN ch.cha_tip = 0 THEN ch.cha_meblag ELSE -ch.cha_meblag END) AS NetBakiye,
           MAX(CASE WHEN ch.cha_evrak_tip = 1 THEN ch.cha_tarihi END) AS SonTahsilat
    FROM CARI_HESAP_HAREKETLERI ch
    WHERE ch.cha_iptal = 0
    GROUP BY ch.cha_kod
)
SELECT cha_kod,
       NetBakiye,
       CAST(SonTahsilat AS date) AS SonTahsilatGunu,
       CASE
           WHEN SonTahsilat IS NULL THEN 'Hic tahsilat yok'
           WHEN DATEDIFF(DAY, SonTahsilat, GETDATE()) > 180 THEN 'Kritik'
           WHEN DATEDIFF(DAY, SonTahsilat, GETDATE()) > 90  THEN 'Yuksek'
           WHEN DATEDIFF(DAY, SonTahsilat, GETDATE()) > 30  THEN 'Izle'
           ELSE 'Normal'
       END AS RiskSegmenti
FROM Risk
WHERE NetBakiye > 0
ORDER BY NetBakiye DESC;

Vadesi geçen tutar: cha_vade tuzağı

Buradaki en pahalı hata cha_vade kolonunu tarih sanmaktır. cha_vade bir vade KODUDUR: 0 peşin, negatif değer hareket tarihinden sonraki gün sayısı, 1..10000 arası ödeme planı numarası, 10000 üzeri ise YYYYMMDD biçiminde tarihtir. Doğrudan DATEADD(DAY, cha_vade, cha_tarihi) yazmak negatif vadelerde tarihi ters yöne kaydırır ve hata vermez; yani rapor sessizce yanlış çıkar. Güvenli çözüm:

WITH Vadeli AS (
    SELECT ch.cha_kod,
           CASE
               WHEN ch.cha_vade > 10000 THEN COALESCE(TRY_CONVERT(date, CAST(ch.cha_vade AS varchar(8)), 112), CAST(ch.cha_tarihi AS date))
               WHEN ch.cha_vade < 0     THEN DATEADD(DAY, ABS(ch.cha_vade), CAST(ch.cha_tarihi AS date))
               ELSE CAST(ch.cha_tarihi AS date)
           END AS VadeTarihi,
           CASE
               WHEN ch.cha_tip = 0 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN ch.cha_meblag ELSE -ch.cha_meblag END
               WHEN ch.cha_tip = 1 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN -ch.cha_meblag ELSE ch.cha_meblag END
               ELSE 0
           END AS NetTutar
    FROM CARI_HESAP_HAREKETLERI ch
    WHERE ch.cha_iptal = 0
)
SELECT cha_kod,
       SUM(CASE WHEN VadeTarihi < CAST(GETDATE() AS date) AND NetTutar > 0 THEN NetTutar ELSE 0 END) AS VadesiGecen
FROM Vadeli
GROUP BY cha_kod
ORDER BY VadesiGecen DESC;

Bunu dürüstçe çerçeveleyelim: bu hesap, vadesi geçmiş borç satırlarının toplamıdır; tahsilat satırlarıyla satır bazında mahsuplaşmaz. Yani üst sınır verir, kesin yaşlandırma değildir. Vade kırılımlı gerçek yaşlandırma için cari yaşlandırma raporu yazısındaki desenleri kullanın.

Hepsini tek sorguda birleştirmek

Son adım: bakiyeyi, vadesi geçen tutarı, son tahsilatı ve son satışı aynı satırda görmek. İki CTE'yi cari kodu üzerinden birleştiriyoruz:

WITH Risk AS (
    SELECT ch.cha_kod,
           SUM(CASE
                   WHEN ch.cha_tip = 0 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN ch.cha_meblag ELSE -ch.cha_meblag END
                   WHEN ch.cha_tip = 1 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN -ch.cha_meblag ELSE ch.cha_meblag END
                   ELSE 0
               END) AS NetBakiye,
           MAX(CASE WHEN ch.cha_evrak_tip = 1  THEN ch.cha_tarihi END) AS SonTahsilat,
           MAX(CASE WHEN ch.cha_evrak_tip = 63 THEN ch.cha_tarihi END) AS SonSatis
    FROM CARI_HESAP_HAREKETLERI ch
    WHERE ch.cha_iptal = 0
    GROUP BY ch.cha_kod
),
Vade AS (
    SELECT v.cha_kod,
           SUM(CASE WHEN v.VadeTarihi < CAST(GETDATE() AS date) AND v.NetTutar > 0 THEN v.NetTutar ELSE 0 END) AS VadesiGecen
    FROM (
        SELECT ch.cha_kod,
               CASE
                   WHEN ch.cha_vade > 10000 THEN COALESCE(TRY_CONVERT(date, CAST(ch.cha_vade AS varchar(8)), 112), CAST(ch.cha_tarihi AS date))
                   WHEN ch.cha_vade < 0     THEN DATEADD(DAY, ABS(ch.cha_vade), CAST(ch.cha_tarihi AS date))
                   ELSE CAST(ch.cha_tarihi AS date)
               END AS VadeTarihi,
               CASE
                   WHEN ch.cha_tip = 0 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN ch.cha_meblag ELSE -ch.cha_meblag END
                   WHEN ch.cha_tip = 1 THEN CASE WHEN ch.cha_normal_Iade = 0 THEN -ch.cha_meblag ELSE ch.cha_meblag END
                   ELSE 0
               END AS NetTutar
        FROM CARI_HESAP_HAREKETLERI ch
        WHERE ch.cha_iptal = 0
    ) v
    GROUP BY v.cha_kod
)
SELECT r.cha_kod,
       LTRIM(RTRIM(ISNULL(c.cari_unvan1, '') + ' ' + ISNULL(c.cari_unvan2, ''))) AS Unvan,
       r.NetBakiye,
       ISNULL(v.VadesiGecen, 0) AS VadesiGecen,
       CAST(r.SonTahsilat AS date) AS SonTahsilatGunu,
       CAST(r.SonSatis AS date)    AS SonSatisGunu,
       DATEDIFF(DAY, r.SonTahsilat, GETDATE()) AS TahsilattanGun
FROM Risk r
JOIN CARI_HESAPLAR c ON c.cari_kod = r.cha_kod
LEFT JOIN Vade v ON v.cha_kod = r.cha_kod
WHERE c.cari_iptal = 0 AND c.cari_hidden = 0 AND c.cari_baglanti_tipi = 0
  AND r.NetBakiye <> 0
ORDER BY r.NetBakiye DESC;

Not: cari_baglanti_tipi = 0 müşteri carilerini getirir. Kurulumunuzda bayiler ayrı tipte tutuluyorsa filtreyi genişletmeniz gerekir; yoksa listede bayi müşterileri görünmez.

Döviz uyarısı

cha_meblag hareketin ORİJİNAL döviz cinsindedir. TL ve USD carileri aynı raporda toplarsanız elma ile armutu toplamış olursunuz. Tek kur üzerinden tek para birimine indirmek istiyorsanız cha_d_cins ve cha_d_kur alanlarını hesaba katın; kesin bakiye için Mikro'nun fn_CariFoy / fn_CariHesapAnaDovizBakiye fonksiyonları daha güvenilirdir, çünkü döviz, KDV ve vade mantığını içeride çözerler.

Sık yapılan hatalar

  • Tahsilat tarihini cha_tip = 1 ile aramak. cha_tip yön verir; iade ve virman da alacaktır. Evrak tipini (cha_evrak_tip) kullanın.
  • cha_iptal = 0 filtresini unutmak. İptal edilmiş bir tahsilat makbuzu, kapanmış müşteriyi "ödeme yapmıyor" gibi gösterir.
  • cha_vade kolonunu tarih sanıp DATEADD(DAY, cha_vade, cha_tarihi) yazmak. Negatif vadede tarih ters yöne kayar ve hata vermez.
  • cha_normal_Iade düşümünü atlamak. İade faturaları bakiyeyi şişirir.
  • MAX(cha_tarihi) sonucunu gün bazına indirmemek. Saat bileşeni yüzünden aynı gün iki farklı tarih gibi görünür, CAST(... AS date) kullanın.
  • Kredi limiti kolonunu varmış gibi varsaymak. Cari kartta böyle bir alan standart değildir; limit karşılaştırması yapacaksanız önce kendi şemanızda alanın var olduğunu doğrulayın.
  • Bakiye ile ciroyu aynı sorguda karıştırmak. Ciro STOK_HAREKETLERI, bakiye CARI_HESAP_HAREKETLERI tarafındadır; ikisini aynı toplama koymak çift saymaya yol açar.

Sık sorulanlar

Son tahsilat tarihi boş gelen müşteri ne demek? Hiç tahsilat makbuzu olmayan müşteridir. Ama ödeme havale veya çekle geldiyse evrak tipi farklıdır; bu yüzden önce kendi verinizde cha_evrak_tip dağılımına bakın, sonra tahsilat tanımınızı genişletin.

Rapor neden bazı müşterileri hiç göstermiyor? cari_baglanti_tipi = 0 filtresi yalnız müşteri carilerini getirir, ayrıca cari_hidden = 0 gizli kartları eler. Bayi veya dağıtıcı tipindeki carileriniz varsa filtreyi gevşetin.

Vadesi geçen tutar neden bakiyeden büyük çıkıyor? Çünkü vadesi geçen hesabı brüt borç satırları üzerinden yapıyoruz, tahsilatlarla satır bazında mahsuplaşmıyor. Kesin yaşlandırma için fn_CariFoy veya vade kırılımlı yaşlandırma sorgusunu kullanın.

Bu sorgular v16'da da çalışır mı? Kullanılan tablolar ve kolonlar iki sürümde de aynı isimdedir; ancak cha_Guid gibi bağ alanları sürüme göre farklılık gösterir. Sürüm farkları için Mikro v17 tablo yapısı rehberi ve vadesi geçen alacak takibi yazılarına bakabilirsiniz.


Bakiye, son tahsilat ve son satış üçlüsü tek tabloda durduğunda risk listesi kendiliğinden çıkar; asıl iş bu listeyi her sabah otomatik görmek ve eşikleri işletmenize göre ayarlamaktır. Sorunuzu Türkçe yazın, şemanıza uygun SQL'i saniyeler içinde alın.

Ücretsiz üye olun, müşteri risk sorgunuzu doğal dille üretin →

Bu yazıyı paylaş
Mikro raporlama

Bu raporu SQL yazmadan, Türkçe sorarak alın

Türkçe sorunuzu yazın, şemanıza uygun çalışan SQL'i ya da hazır uygulamanızı saniyeler içinde alın.