Mikro ERP Alış-Satış Kâr Analizi: Ürün Bazlı Brüt Kâr/Zarar SQL Sorgusu

Mikro ERP'de brüt kâr nasıl garantili hesaplanır? Alış maliyeti ve satış gelirini karşılaştıran CTE analizleri, bedelsiz/promosyon ürünlerinin kârlılık...

İş Problemi: “Sattıkça Neden Para Kaybediyoruz?”

Patronların ERP raporlarında en sık tartışma çıkardığı tablo “Brüt Kâr” raporudur. Yönetim paneli, bir ürünün %35 kârla satıldığını söyler ancak yıl sonu mizanları bambaşka bir tablo çıkartır. Sorun genelde SQL’in yanlış yazılmasıdır.

Neden Standart Kâr Yaklaşımı Çalışmaz? Çünkü basit bir “Ciro - Ortalama Alış” denklemi şu durumları hesaba katmaz:

  1. Promosyon/Bedelsiz Alım: Tedarikçiden 10 adet telefonu normal fiyata, 1 adedini bedelsiz hediye (sth_tutar = 0) aldınız. Satışta bu hediyeyi de normal fiyattan satarsınız. Eğer bedelsiz faturayı maliyet ortalamasına dahil ederseniz (sayısal olarak maliyeti sıfıra çekeceğinden) o ürünün birim maliyeti yalan yanlış bir seviyeye düşer.
  2. Müşteri İadeleri: Müşteri ürünü iade ettiğinde (sth_normal_iade = 1), bunu bir “alış” veya “maliyet” kalemi olarak değil, satış cirosundan düşen bir eksi kalem olarak yorumlamalısınız.
  3. Satış İskontoları: Satır iskontosu mu (Satır toplamı) yoksa evrak altı iskonto mu (Genel Tutar)? Sadece sth_tutar alanına bakılmazsa iskonto maliyetleri gözden kaçar.

Somut Hata: Kârlılık İllüzyonu

Yanlış yazılmış bir SQL sorgusunun vereceği yanıltıcı çıktı:

Ürün: Kırmızı T-Shirt Satış Cirosu: 10.000 TL Tahmini Maliyet: 1.000 TL Brüt Kâr Marjı: %90 (?) Gerçeklik: Aslında ürünün maliyeti 8.000 TL’ydi. Sadece geçen ay tedarikçi faturayı “sıfır bedelli hediye” kestiği için sistemin maliyet ortalaması dibe çöktü!

Çözüm: Promosyon ve İade Farkındalıklı CTE Sorgusu

Doğru bir ürün bazlı kâr analizi kurmak için, bedelsiz alımları maliyet hesabından (ama stok sayısından değil!) dışlamak şarttır:

;WITH SafAlisMaliyeti AS (
    SELECT 
        sth_stok_kod,
        SUM(sth_tutar)  AS [GecerliTutar Toplam],
        -- SADECE Tutar > 0 olan (bedelsiz OLMAYAN) ürünlerin miktarını alıyoruz!
        SUM(CASE WHEN sth_tutar > 0 THEN sth_miktar ELSE 0 END) AS ParaliAlinanMiktar
    FROM STOK_HAREKETLERI WITH (NOLOCK)
    WHERE sth_tip = 0 AND sth_cins = 0 
      AND sth_normal_iade = 0 
      AND sth_iptal = 0
    GROUP BY sth_stok_kod
),
GercekSatisCirosu AS (
    SELECT 
        sth_stok_kod,
        -- İade edilmeyen net satış cirosu (Eğer iade ise cirodan düş!)
        SUM(CASE WHEN sth_normal_iade = 0 THEN sth_tutar ELSE -sth_tutar END) AS NetSatisCirosu,
        -- İade edilmeyen net satış miktarı
        SUM(CASE WHEN sth_normal_iade = 0 THEN sth_miktar ELSE -sth_miktar END) AS NetSatisMiktar
    FROM STOK_HAREKETLERI WITH (NOLOCK)
    WHERE sth_tip = 1 AND sth_cins = 0 
      AND sth_iptal = 0
    GROUP BY sth_stok_kod
)
SELECT 
    SA.sth_stok_kod                                AS [Stok Kodu],
    S.sto_isim                                     AS [Ürün Adı],
    SA.NetSatisMiktar                              AS [Net Satılan Miktar],
    SA.NetSatisCirosu                              AS [Net Satış Cirosu],
    
    -- Gerçekçi (Saf) Ortalama Maliyet Çıkarımı
    ROUND(ISNULL(A.[GecerliTutar Toplam], 0) / 
        NULLIF(A.ParaliAlinanMiktar, 0) * SA.NetSatisMiktar, 2) AS [Kanıtlı Satış Maliyeti],
        
    -- Net Brüt Kâr (Ciro - Kanıtlı Maliyet)
    SA.NetSatisCirosu - ROUND(ISNULL(A.[GecerliTutar Toplam], 0) / 
        NULLIF(A.ParaliAlinanMiktar, 0) * SA.NetSatisMiktar, 2) AS [Net Brüt Kâr],
        
    -- Kâr Marjı (%)
    CAST(ROUND(
        (SA.NetSatisCirosu - ISNULL(A.[GecerliTutar Toplam], 0) / 
            NULLIF(A.ParaliAlinanMiktar, 0) * SA.NetSatisMiktar) / 
            NULLIF(SA.NetSatisCirosu, 0) * 100, 1
    ) AS VARCHAR) + '%'                             AS [Kâr Marjı %],
    
    -- Acil Uyarı Sistemi
    CASE 
        WHEN SA.NetSatisCirosu - ISNULL(A.[GecerliTutar Toplam], 0) / NULLIF(A.ParaliAlinanMiktar, 0) * SA.NetSatisMiktar < 0 
        THEN 'ZARARINA SATIS'
        WHEN ((SA.NetSatisCirosu - ISNULL(A.[GecerliTutar Toplam], 0) / NULLIF(A.ParaliAlinanMiktar, 0) * SA.NetSatisMiktar) / NULLIF(SA.NetSatisCirosu, 0)) < 0.15 
        THEN 'MARJ <%15 (RISK)'
        ELSE 'KARLI ISLEM'
    END                                             AS [Durum Bildirgesi]
    
FROM GercekSatisCirosu SA
INNER JOIN STOKLAR S WITH (NOLOCK) ON SA.sth_stok_kod = S.sto_kod
LEFT JOIN SafAlisMaliyeti A ON SA.sth_stok_kod = A.sth_stok_kod
WHERE SA.NetSatisMiktar > 0 -- Hiç satılmayan (veya sadece iade olan) net miktar varsa listeleme
ORDER BY [Net Brüt Kâr] ASC; -- Özellikle zararları en üstte görmek için!

Performans / Ölçekleme Testi

AstaFlow dashboard mimarisinde, kâr analizlerinin anlık çalışması istenir. Bu sorgu son 1 yıl (sth_tarih >= DATEADD(YEAR, -1, GETDATE())) filtresi konulduğunda son derece performanslı çalışır (0.8 - 1.5 saniye). Ancak tarihsiz çalıştırılır ve 10 yıllık hareketler taranırsa, RAM üzerinde (Spooling) ciddi yük oluşturur. Kesinlikle tarih limitleri ile uygulanmalıdır.

Edge Cases (İstisnai Durumlar)

  1. FIFO (İlk Giren İlk Çıkar) Saplantısı: Yukardaki sorgu Ağırlıklı Ortalama Maliyet (Moving Average) yaklaşımıdır. Enflasyonun yüksek olduğu bizim gibi ekonomilerde ortalama maliyet yerine FIFO zorunludur. Ancak saf FIFO hesaplaması SQL’de son derece karmaşık cursor döngüleri gerektirir. Çözüm: Sorguya maliyet hesabını son 3 ayın alımlarıyla sınırlandıran bağımsız bir CTE eklemektir.
  2. Dövizli Alım - TL Satış Kur Makası: Kâr raporlarındaki gizli katil kur hareketleridir. Ürünü 1000$‘a alıp (O günkü kurla 30.000 TL), bugün 35.000 TL’ye saatığınızda SQL size “5000 TL Kâr Gözüküyor” diyecektir. Oysa dolar an itibariyla 34 TL olmuştur (1000$ = 34.000 TL). Gerçek kâr zarara dönmüştür! İleri düzey kâr analizi, geçmiş ciro tutarını o günün kuruyla dövize sabitleyip hesaplanmalıdır.

Bu Bilgiyi Nereden Biliyoruz? (Kaynaklar)

📚 İlgili Yazılar