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:
- Stok kartının son giriş hareketini bul
- O satırın birim maliyetini hesapla
- 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ü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:
-- =============================================
-- ⚙️ 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:
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:
| 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:
- Sadece belirli stok gruplarını raporlayın
- Sonuçları
#TempTableveya snapshot tablosuna alın - Tarih filtresi ekleyin:
AND sh.sth_tarih >= DATEADD(YEAR, -3, GETDATE()) - 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:
- Yoğun depo transferi — birden fazla depo arasında sürekli mal dolaşıyorsa
- Düzensiz sayım hareketleri — sayım fazlası/noksanı sık ve büyükse
- Maliyet yansıtma tabloları — ithalat masrafı, navlun dağıtımı varsa
- Üretim/fason maliyeti — hammadde + işçilik + genel giderler devredeyse
- 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)
- Bedelsiz Girişler: Promosyon veya numune gelen stoklar
sth_tutar = 0döner.COALESCEfallback’i bu durumdasth_maliyet_anaalanına geçer. İkisi de sıfırsa o katman 0 maliyetli sayılır. - Negatif Stoklar:
fn_DepodakiMiktareksi değer dönerse,WHERE cs.MevcutMiktar > 0filtresi bu kartları otomatik dışarı atar. - Ç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ı
- Mikro ERP DB API: STOK_HAREKETLERI Tablo Şeması
- İlgili Yazı: Son Alış Fiyatını ve Son Giriş Tarihini Getiren SQL
- İlgili Yazı: Depo Bazlı Stok Bakiye Raporu: Hareket mi Özet mi?
- İlgili Araç: Evrak Tipi Decoder — sth_tip değerlerini anında çevirin