Mikro ERP Stok Maliyet Hesabı: Son Giriş Fiyatı Neden Yetmez?

Mikro ERP'de stok değerlemesi için son giriş fiyatı kullanmak neden yanıltır? Reverse FIFO benzeri katman maliyeti yaklaşımı, SQL sorgusu ve pratik sapma analizi...

İş Problemi: “Stoğumuzun Toplam Değeri Nedir?”

Finans ve yönetim ekibi her ay sorar: “Depodaki malların toplam değeri kaç TL?” Bu sorunun doğru cevabı, bilanço doğruluğunu, kâr/zarar analizini ve bütçeleme kararlarını doğrudan etkiler.

İlk bakışta en kolay yaklaşım şudur:

  1. Stok kartının son giriş hareketini bul
  2. O satırın birim maliyetini hesapla
  3. Depodaki stok miktarıyla çarp

Bu yöntem hızlıdır. Ama çoğu zaman yanıltır. Neden mi?

Son Giriş Fiyatı Sapma Senaryosu

Bir üründe şu hareketler gerçekleşmiş olsun:

  • 100 adet ürün 70 TL’den alındı
  • 40 adet satıldı
  • 50 adet ürün 95 TL’den alındı
  • Elde 110 adet var

Sadece son giriş fiyatını kullanırsanız: 110 × 95 = 10.450 TL

Ama elde kalan stok aslında iki farklı maliyet katmanından oluşur. 60 adet 70 TL’lik + 50 adet 95 TL’lik partiden gelir. Gerçek maliyet: (60 × 70) + (50 × 95) = 8.950 TL — tam 1.500 TL fark!

Bu fark, yüzlerce stok kartı üzerinde toplandığında yüz binlerce TL’lik sapma demektir.

İlk Deneme: Son Giriş Hareketinden Birim Maliyet

İlk aşamada test ettiğimiz mantık, her stok için en son giriş hareketini bulup birim maliyetini kullanmaktı:

SELECT
    s.sto_kod AS StokKodu,
    s.sto_isim AS StokAdi,
    x.Giris_Tarih,
    x.Giris_Miktar,
    x.Giris_BirimMaliyet
FROM STOKLAR s
OUTER APPLY
(
    SELECT TOP 1
        sh.sth_tarih AS Giris_Tarih,
        sh.sth_miktar AS Giris_Miktar,
        sh.sth_tutar / NULLIF(sh.sth_miktar, 0) AS Giris_BirimMaliyet
    FROM STOK_HAREKETLERI sh
    WHERE sh.sth_stok_kod = s.sto_kod
      AND sh.sth_tip = 0                    -- Giriş hareketi
      AND ISNULL(sh.sth_iptal, 0) = 0       -- İptal olmayan
    ORDER BY sh.sth_tarih DESC,
             sh.sth_evrakno_sira DESC,
             sh.sth_guid DESC
) x
WHERE s.sto_isim <> ''
ORDER BY s.sto_kod;

Bu sorgu bazı kartlarda gerçeğe yaklaşır. Ama stok bitmeden araya yeni alım girdiyse sonuç hiç tutmaz.

Birim Maliyet Alanı Kararsızlığı: sth_tutar mı, sth_maliyet_ana mı?

Denemelerde iki farklı formül test edildi:

FormülKullanım AlanıNe Zaman Tercih Edilmeli?
sth_tutar / sth_miktarEvrak satır tutarı bazlıRapor fiyat alanı evrak tutarına yakınsa
sth_maliyet_ana / sth_miktarDöviz bazlı maliyetAna döviz bazlı maliyet hedefleniyorsa

Pratikte bazı stoklarda sth_tutar / sth_miktar sonucu orijinal rapora daha yakın verdi. Bu alan, ihtiyaca göre değiştirilebilir bir parametre olarak düşünülmelidir.

Asıl İçgörü: Mevcut Stok Tek Bir Girişten Oluşmuyor

Buradaki temel keşif şuydu:

Mevcut stok miktarını, geriye dönük giriş hareketlerinden oluşan katmanlardan bulmak gerekiyor.

Mantık şu şekilde kuruldu:

1. Depodaki güncel net stok miktarı alındı (fn_DepodakiMiktar)
2. Stok hareketleri en yeni girişten eskiye doğru sıralandı
3. Mevcut stok miktarını karşılayacak kadar giriş katmanı seçildi
4. Seçilen katmanların ağırlıklı ortalama birim maliyeti hesaplandı

Bu yöntem tam muhasebe motoru değildir ama tek son giriş fiyatına göre çok daha gerçekçi sonuç verir.

Reverse FIFO Benzeri Katman Hesabı

Bu yaklaşımın somut örneği:

  • Depoda 40 adet var
  • Son giriş 12 adet → tamamı maliyet katmanına alınır
  • Önceki giriş 35 adet → kalan 28 adet alınır
  • Toplam: 12 + 28 = 40 adet → katmanlar eşleşti

Bu katmanların birim maliyetleri ağırlıklı ortalama ile toplanarak gerçekçi stok değeri hesaplanır.

İlk Çalışan Katmanlı Maliyet Sorgusu

Aşağıdaki sorgu, mevcut stok miktarını açıklayan son giriş katmanlarını bulur ve toplam depo maliyetini hesaplar:

-- =============================================
-- ⚙️ AYARLAR: Kendi ortamınıza göre değiştirin
-- =============================================
DECLARE @DepoNo INT = 1;              -- ⚙️ Raporlanacak depo numarası (1=Merkez, 2=Şube vb.)

;WITH CurrentStock AS
(
    SELECT
        s.sto_kod,
        s.sto_isim,
        -- fn_DepodakiMiktar: Mikro'nun kendi fonksiyonu ile net stok
        CAST(ISNULL(dbo.fn_DepodakiMiktar(s.sto_kod, @DepoNo, ''), 0) 
             AS DECIMAL(18,6)) AS MevcutMiktar
    FROM dbo.STOKLAR s WITH (NOLOCK)
    WHERE s.sto_isim <> ''
      -- ⚙️ İSTEĞE BAĞLI: Rapordan hariç tutmak istediğiniz ana grupları ekleyin
      -- AND s.sto_anagrup_kod NOT IN ('SizinHaricGrup1','SizinHaricGrup2')
      -- ⚙️ İSTEĞE BAĞLI: Belirli stok kodu kalıplarını hariç tutun
      -- AND s.sto_kod NOT LIKE 'X-%'
),
PurchaseLayers AS
(
    SELECT
        sh.sth_stok_kod,
        sh.sth_tarih,
        sh.sth_evrakno_sira,
        sh.sth_Guid,
        CAST(sh.sth_miktar AS DECIMAL(18,6)) AS GirisMiktar,
        CAST(sh.sth_tutar / NULLIF(sh.sth_miktar, 0) 
             AS DECIMAL(18,6)) AS BirimMaliyet,
        -- Pencere fonksiyonu: en yeni girişten eskiye doğru birikimli toplam
        SUM(CAST(sh.sth_miktar AS DECIMAL(18,6))) OVER
        (
            PARTITION BY sh.sth_stok_kod
            ORDER BY sh.sth_tarih DESC, 
                     sh.sth_evrakno_sira DESC, 
                     sh.sth_Guid DESC
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS TersBirikimliGiris
    FROM dbo.STOK_HAREKETLERI sh WITH (NOLOCK)
    INNER JOIN CurrentStock cs
        ON cs.sto_kod = sh.sth_stok_kod
       AND cs.MevcutMiktar > 0
    WHERE sh.sth_tip = 0                           -- Giriş hareketi
      AND ISNULL(sh.sth_iptal, 0) = 0              -- İptal değil
      AND ISNULL(LTRIM(RTRIM(sh.sth_belge_no)), N'') <> N''
      AND sh.sth_giris_depo_no = @DepoNo           -- ⚙️ Yukarıda tanımlanan depo
),
RemainingLayers AS
(
    SELECT
        pl.sth_stok_kod,
        pl.sth_tarih,
        pl.BirimMaliyet,
        -- Mevcut stoğu karşılayan katman miktarını hesapla
        CASE
            WHEN pl.TersBirikimliGiris <= cs.MevcutMiktar
                THEN pl.GirisMiktar                    -- Tüm katman dahil
            WHEN (pl.TersBirikimliGiris - pl.GirisMiktar) < cs.MevcutMiktar
                THEN cs.MevcutMiktar 
                   - (pl.TersBirikimliGiris - pl.GirisMiktar) -- Kısmi katman
            ELSE 0                                     -- Bu katman dışarıda
        END AS KalanKatmanMiktar
    FROM PurchaseLayers pl
    INNER JOIN CurrentStock cs
        ON cs.sto_kod = pl.sth_stok_kod
    WHERE (pl.TersBirikimliGiris - pl.GirisMiktar) < cs.MevcutMiktar
)
SELECT
    cs.sto_kod                                      AS [Kod],
    cs.sto_isim                                     AS [Isim],
    cs.MevcutMiktar                                 AS [Merkez Stok],
    CAST(
        SUM(rl.KalanKatmanMiktar * rl.BirimMaliyet)
        / NULLIF(cs.MevcutMiktar, 0)
        AS DECIMAL(18,6)
    )                                               AS [Agirlikli Birim Maliyet],
    CAST(
        SUM(rl.KalanKatmanMiktar * rl.BirimMaliyet)
        AS DECIMAL(18,2)
    )                                               AS [Merkez Maliyet]
FROM CurrentStock cs
LEFT JOIN RemainingLayers rl
    ON rl.sth_stok_kod = cs.sto_kod
   AND rl.KalanKatmanMiktar > 0
WHERE cs.MevcutMiktar > 0
GROUP BY cs.sto_kod, cs.sto_isim, cs.MevcutMiktar
ORDER BY cs.sto_kod;

Bu sorgu, birçok stokta orijinal ekrandaki değere son giriş fiyatı yaklaşımından çok daha yakın sonuç verdi.

Kritik Ayrım: Bu Sorgu Çıkışları Tek Tek İzlemiyor

Bu sorgu tam bir muhasebe motoru değildir. Yapısal farkını anlamak önemlidir:

ÖzellikMuhasebe MotoruBu Yaklaşım
Çıkış eşleştirmeHer çıkışı bir girişle eşler❌ Yapmaz
Net stokHareket hareket hesaplarfn_DepodakiMiktar ile alır
Kalan katmanFIFO/LIFO motoru ileReverse birikimli toplama ile
Doğruluk%100 muhasebe uyumluRapor amaçlı yeterli

Tanım: Güncel net stoğu açıklayan reverse FIFO benzeri katman maliyeti yaklaşımı

Bu ayrım önemlidir — depo transferi, sayım farkı, iade ve stok açılış gibi hareketler devreye girdiğinde, hangi hareketleri maliyet katmanına dahil ettiğiniz sonucu ciddi biçimde değiştirir.

Virman ve Sayım Girişlerinin Etkisi

İlk sorguda sadece giriş faturaları baz alınmıştı. Sonra fark edildi ki bazı stoklarda maliyeti etkileyen hareketler sadece alış faturaları değil:

  • Virman girişleri — depolar arası transfer maliyeti
  • Sayım girişleri — sayım farkı düzeltmeleri
  • Stok devir/açılış — dönem başı değer aktarımları

Bu nedenle filtreyi dar tutmak yerine, hedef depoya giren tüm pozitif girişleri baz almak daha mantıklı hale geldi.

İade Tuzağı: Mutlaka Ayrıştırın!

Tüm girişleri alırsanız, iade kayıtları da maliyet katmanına karışır. Bu da bazı stoklarda tamamen yanlış sonuç üretir.

Müşteri tarafından iade edilen bir ürünün giriş fişi, o ürünün alım maliyetini temsil etmez. İade, genellikle satış fiyatı bazında işlenir.

Kritik filtre:

AND ISNULL(sh.sth_normal_iade, 0) = 0   -- İade girişlerini hariç tut

Nihai Sorgu: Virman Dahil, İade Hariç

Tüm tecrübelerin sonunda ulaşılan en dengeli yapı. Ek olarak maliyet alanında fallback mekanizması eklendi — sth_tutar sıfırsa sth_maliyet_ana kullanılır:

-- =============================================
-- ⚙️ AYARLAR: Kendi ortamınıza göre değiştirin
-- =============================================
DECLARE @DepoNo INT = 1;              -- ⚙️ Raporlanacak depo numarası (1=Merkez, 2=Şube vb.)

;WITH CurrentStock AS
(
    SELECT
        s.sto_kod,
        s.sto_isim,
        CAST(ISNULL(dbo.fn_DepodakiMiktar(s.sto_kod, @DepoNo, ''), 0) 
             AS DECIMAL(18,6)) AS MevcutMiktar
    FROM dbo.STOKLAR s WITH (NOLOCK)
    WHERE s.sto_isim <> ''
      -- ⚙️ İSTEĞE BAĞLI: Rapordan hariç tutmak istediğiniz ana grupları ekleyin
      -- AND s.sto_anagrup_kod NOT IN ('SizinHaricGrup1','SizinHaricGrup2')
),
PurchaseLayers AS
(
    SELECT
        sh.sth_stok_kod,
        sh.sth_tarih,
        sh.sth_evrakno_sira,
        sh.sth_Guid,
        CAST(sh.sth_miktar AS DECIMAL(18,6)) AS GirisMiktar,
        -- Fallback: sth_tutar sıfırsa sth_maliyet_ana kullan
        CAST(
            COALESCE(
                NULLIF(sh.sth_tutar, 0) / NULLIF(sh.sth_miktar, 0),
                NULLIF(sh.sth_maliyet_ana, 0) / NULLIF(sh.sth_miktar, 0),
                0
            ) AS DECIMAL(18,6)
        ) AS BirimMaliyet,
        SUM(CAST(sh.sth_miktar AS DECIMAL(18,6))) OVER
        (
            PARTITION BY sh.sth_stok_kod
            ORDER BY sh.sth_tarih DESC, 
                     sh.sth_evrakno_sira DESC, 
                     sh.sth_Guid DESC
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS TersBirikimliGiris
    FROM dbo.STOK_HAREKETLERI sh WITH (NOLOCK)
    INNER JOIN CurrentStock cs
        ON cs.sto_kod = sh.sth_stok_kod
       AND cs.MevcutMiktar > 0
    WHERE sh.sth_tip = 0                           -- Giriş hareketi
      AND sh.sth_giris_depo_no = @DepoNo           -- ⚙️ Yukarıda tanımlanan depo
      AND sh.sth_miktar > 0                        -- Pozitif miktar
      AND ISNULL(sh.sth_iptal, 0) = 0              -- İptal değil
      AND ISNULL(sh.sth_normal_iade, 0) = 0        -- İade değil
),
RemainingLayers AS
(
    SELECT
        pl.sth_stok_kod,
        pl.BirimMaliyet,
        CASE
            WHEN pl.TersBirikimliGiris <= cs.MevcutMiktar
                THEN pl.GirisMiktar
            WHEN (pl.TersBirikimliGiris - pl.GirisMiktar) < cs.MevcutMiktar
                THEN cs.MevcutMiktar 
                   - (pl.TersBirikimliGiris - pl.GirisMiktar)
            ELSE 0
        END AS KalanKatmanMiktar
    FROM PurchaseLayers pl
    INNER JOIN CurrentStock cs
        ON cs.sto_kod = pl.sth_stok_kod
    WHERE (pl.TersBirikimliGiris - pl.GirisMiktar) < cs.MevcutMiktar
)
SELECT
    cs.sto_kod                  AS [Kod],
    cs.sto_isim                 AS [Isim],
    ISNULL(
        CAST(SUM(rl.KalanKatmanMiktar * rl.BirimMaliyet) 
             AS DECIMAL(18,2)),
        0
    )                           AS [Merkez Maliyet]
FROM CurrentStock cs
LEFT JOIN RemainingLayers rl
    ON rl.sth_stok_kod = cs.sto_kod
   AND rl.KalanKatmanMiktar > 0
WHERE cs.MevcutMiktar > 0
GROUP BY cs.sto_kod, cs.sto_isim
ORDER BY cs.sto_kod;

Hangi Hareketler Dahil, Hangileri Hariç?

Bu çalışmada ulaşılan en önemli netliklerden biri budur:

DurumDahil mi?Neden?
Alış faturalarıBirincil maliyet kaynağı
Virman girişleriDepo transferi de maliyete etki eder
Sayım girişleriSayım farkı stok değerini değiştirir
Stok devir/açılışDönem başı değer aktarımı
İptal kayıtlarıGerçek hareketi temsil etmez
İade kayıtlarıAlım maliyetini temsil etmez
Sıfır/negatif miktarAnlamsız veri

Performans Notu

Bu sorgu hafif değildir. Her stok için pencere fonksiyonu çalışır, katmanlar hesaplanır:

  • Her stok için fn_DepodakiMiktar çağrılır
  • Hareketler stok bazında sıralanır
  • SUM() OVER() ile ters birikimli toplam hesaplanır
  • Kalan katman miktarı satır bazında üretilir

Büyük veritabanları için optimizasyon önerileri:

  1. Sadece belirli stok gruplarını raporlayın
  2. Sonuçları #TempTable veya snapshot tablosuna alın
  3. Tarih filtresi ekleyin: AND sh.sth_tarih >= DATEADD(YEAR, -3, GETDATE())
  4. Sık çalışan raporlar için maliyet snapshot tablosu oluşturun

Bu Yaklaşımın Sınırları

Bu yöntem rapor amaçlı çok güçlüdür ama her şeyi çözmez. Şu durumlarda sapma olabilir:

  1. Yoğun depo transferi — birden fazla depo arasında sürekli mal dolaşıyorsa
  2. Düzensiz sayım hareketleri — sayım fazlası/noksanı sık ve büyükse
  3. Maliyet yansıtma tabloları — ithalat masrafı, navlun dağıtımı varsa
  4. Üretim/fason maliyeti — hammadde + işçilik + genel giderler devredeyse
  5. Sistem maliyet hesaplama kuralları — Mikro’nun kendi maliyet motoru farklı kurallara sahipse

Sonuç: Bu yöntem birebir muhasebe motorunun yerine geçmez ama pratikte çok daha gerçekçi ve savunulabilir bir maliyet hesabı üretir.

Edge Cases (İstisnai Durumlar)

  1. Bedelsiz Girişler: Promosyon veya numune gelen stoklar sth_tutar = 0 döner. COALESCE fallback’i bu durumda sth_maliyet_ana alanına geçer. İkisi de sıfırsa o katman 0 maliyetli sayılır.
  2. Negatif Stoklar: fn_DepodakiMiktar eksi değer dönerse, WHERE cs.MevcutMiktar > 0 filtresi bu kartları otomatik dışarı atar.
  3. Çok Eski Girişler: 10 yıllık hareketlere kadar geri gitmek performansı düşürebilir. DATEADD(YEAR, -3, GETDATE()) filtresi ile sınırlama yapılabilir.

Bu Bilgiyi Nereden Biliyoruz? (Kaynaklar)

📚 İlgili Yazılar