---
title: "Mikro ERP Stok Maliyet Hesabı: Son Giriş Fiyatı Neden Yetmez?"
description: "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..."
date: 2026-04-21
category: mikro-erp
tags: ["mikro-erp", "sql-server", "stok", "maliyet", "fifo", "envanter", "astaflow"]
url: https://mikroerp.dev/blog/mikro-erp-stok-maliyet-katman-hesabi-sql/
---

## İş 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ı:

```sql
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ül | Kullanım Alanı | Ne Zaman Tercih Edilmeli? |
|---|---|---|
| `sth_tutar / sth_miktar` | Evrak satır tutarı bazlı | Rapor fiyat alanı evrak tutarına yakınsa |
| `sth_maliyet_ana / sth_miktar` | Döviz bazlı maliyet | Ana 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:

```sql
-- =============================================
-- ⚙️ 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:

| Özellik | Muhasebe Motoru | Bu Yaklaşım |
|---|---|---|
| Çıkış eşleştirme | Her çıkışı bir girişle eşler | ❌ Yapmaz |
| Net stok | Hareket hareket hesaplar | `fn_DepodakiMiktar` ile alır |
| Kalan katman | FIFO/LIFO motoru ile | Reverse birikimli toplama ile |
| Doğruluk | %100 muhasebe uyumlu | **Rapor 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:

```sql
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:

```sql
-- =============================================
-- ⚙️ 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:

| Durum | Dahil mi? | Neden? |
|---|---|---|
| Alış faturaları | ✅ | Birincil maliyet kaynağı |
| Virman girişleri | ✅ | Depo transferi de maliyete etki eder |
| Sayım girişleri | ✅ | Sayı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 miktar | ❌ | Anlamsı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)

*   **AstaFlow Case Study:** [Stok Yönetimi ve Maliyet Takibi Altyapısı](/case-study/)
*   **Mikro ERP DB API:** [STOK_HAREKETLERI Tablo Şeması](https://apidocs.mikro.com.tr/tablo-alan-adlari/stok_hareketleri)
*   **İlgili Yazı:** [Son Alış Fiyatını ve Son Giriş Tarihini Getiren SQL](/blog/mikro-erp-son-alis-fiyati-sql/)
*   **İlgili Yazı:** [Depo Bazlı Stok Bakiye Raporu: Hareket mi Özet mi?](/blog/mikro-erp-depo-bazli-stok-bakiye-sql/)
*   **İlgili Araç:** [Evrak Tipi Decoder — sth_tip değerlerini anında çevirin](/araclar/evrak-tip-decoder/)