Anlık fiyatlandırma motorunu nasıl kurduk?
MatbaaStore'da onlarca değişkenli baskı fiyatını milisaniyeler içinde hesaplayan motorun hikâyesi.
MatbaaStore'da bir kullanıcı kartvizit sayfasını açtığında önünde onlarca seçim var: ebat, kağıt türü, gramaj, tek yüz mü çift yüz mü basılacağı, selefon veya lak kaplama, yuvarlak köşe, adet. Bu seçimlerin her biri fiyatı değiştiriyor ve kullanıcı bir seçeneği değiştirir değiştirmez fiyatın anında güncellenmesini bekliyor. Geleneksel matbaacılıkta bu hesap bir ustanın kafasında ya da bir Excel tablosunda dakikalar, bazen saatler sürer. Biz aynı hesabı sunucu tarafında milisaniyeler içinde yapmak zorundaydık.
Bu yazıda anlık fiyatlandırma motorunu nasıl kurduğumuzu anlatacağız: bir baskı fiyatını gerçekte hangi değişkenlerin belirlediğini, hesabı katmanlara nasıl ayırdığımızı, kuralları Go'da nasıl modellediğimizi ve p99 gecikmeyi 15 milisaniyenin altında tutmak için Redis ile PostgreSQL'i nasıl birlikte kullandığımızı. Amacımız yalnızca doğru fiyatı üretmek değil, doğru fiyatı kullanıcı seçeneği değiştirdiği hızla üretmekti.
Bir baskı fiyatını neler belirler?
Fiyatlandırmaya oturmadan önce matbaanın maliyet yapısını çözmemiz gerekti. Baskı fiyatı tek bir sayı değil, birbirini besleyen birkaç kalemin toplamıdır. Motorun ilk işi bu kalemleri ayrı ayrı hesaplamak ve sonra birleştirmektir. Bir ürünün fiyatını etkileyen temel değişkenler şunlar:
- Kağıt maliyeti: tür (kuşe, bristol, kraft, sticker), gramaj (90–350 gr) ve tabaka başına fiyat. Kağıt çoğu üründe maliyetin en büyük kalemidir.
- Baskı yöntemi: düşük adetlerde dijital baskı, yüksek adetlerde ofset ekonomiktir. İkisinin başabaş noktası ürüne göre değişir; motor her iki senaryoyu da hesaplayıp ucuz olanı seçer.
- Renk sayısı ve yüz: tek yüz mü çift yüz mü, dört renk mi tek renk mi. Ofsette her renk ayrı bir kalıp (CTP) maliyeti demektir.
- Son işlem: mat/parlak selefon, lak, gofraj, yuvarlak köşe, delik. Her biri makine dakikası ve çoğu zaman ayrı bir sabit hazırlık maliyeti getirir.
- Adet: adet arttıkça sabit maliyetler (kalıp, hazırlık) birim başına dağılır ve birim fiyat düşer. Bu yüzden fiyat, adede doğrusal değil kademeli bağlıdır.
- Fire ve teslimat: her baskıda kesim ve ayar firesi vardır; teslim süresi (standart, ekspres) de fiyata girer.
Bu değişkenlerin çoğu birbirinden bağımsız değil. Örneğin selefon kaplama seçildiğinde kağıdın kuruma süresi değişir, bu da makine planını ve dolayısıyla dakika maliyetini etkiler. Motoru tasarlarken bu bağımlılıkları açıkça modellemek, sonradan gizli hatalarla uğraşmaktan çok daha ucuza geldi.
Katmanlı hesaplama modeli
Motoru tek dev bir formül yerine sıralı katmanlar halinde kurduk. Her katman bir önceki katmanın çıktısını girdi olarak alır ve fiyatı bir adım daha netleştirir. Bu yaklaşımın en büyük faydası test edilebilirliği: her katmanı ayrı ayrı doğrulayabiliyor, bir kalem yanlış çıktığında hatanın hangi katmanda olduğunu saniyede görebiliyoruz.
İlk katman yerleşim hesabı. Baskı makinesi belirli tabaka boyutlarıyla çalışır (örneğin 70×100 cm). Kullanıcının seçtiği ürün ebadı, kesim payları ve baskı yönü hesaba katılarak bir tabakaya kaç adet ürünün sığdığı bulunur. Bu sayı, bütün maliyet zincirinin çarpanıdır: 500 kartvizit istendiğinde bir tabakaya 24 kartvizit sığıyorsa, gereken tabaka sayısı yukarı yuvarlanarak 21 olur. Yanlış bir yerleşim hesabı bütün fiyatı bozar.
İkinci katman malzeme ve baskı maliyeti: tabaka sayısı × kağıt fiyatı, artı baskı yöntemine göre kalıp ve makine dakika maliyeti. Üçüncü katman son işlemler; dördüncü katman fire, işçilik ve genel gider payı; son katman ise kâr marjı ve yuvarlama. Marjı en sona bırakmak, kampanya ve müşteriye özel indirimleri tek bir yerde uygulayabilmemizi sağladı.
Fiyatlandırma motorunda en pahalı hatalar formül hatası değil, katman sırası hatasıdır. İndirimi fireden önce mi sonra mı uyguladığınız, aynı girdilerle bambaşka fiyatlar üretir.
Kural motorunu Go'da modellemek
Katmanları Go'da saf fonksiyonlar olarak yazdık. Her fonksiyon girdi olarak bir hesap bağlamı alır, hiçbir global duruma dokunmaz ve deterministik bir çıktı üretir. Bu saflık, motoru hem test etmeyi hem de yüksek eşzamanlılıkta kilit olmadan çalıştırmayı kolaylaştırdı. Aşağıda adede bağlı kademeli birim fiyatı üreten çekirdek fonksiyonun sadeleştirilmiş hali var:
func birimFiyat(t *Tabaka, adet int) float64 {
tabakaSayisi := yukariYuvarla(float64(adet) / t.AdetPerTabaka)
malzeme := tabakaSayisi * t.KagitFiyati
baski := t.KalipMaliyeti + tabakaSayisi*t.DakikaMaliyeti
// Sabit maliyetler adede dağıldıkça birim fiyat düşer
toplam := (malzeme + baski) * (1 + t.FireOrani)
return toplam / float64(adet)
}
Adet indirimlerini ayrı bir tabloyla değil, bu formülün doğal sonucu olarak elde ediyoruz. Kalıp ve hazırlık gibi sabit maliyetler adet arttıkça daha çok ürüne bölündüğü için birim fiyat kendiliğinden düşüyor. Yine de kullanıcıya gösterdiğimiz "500 / 1000 / 2000 adet" gibi basamaklar arasında pürüzsüz bir eğri istediğimizde, komşu basamaklar arasında doğrusal ara değerleme (interpolasyon) uyguluyoruz. Böylece 1250 adet gibi ara bir miktar istendiğinde fiyat sıçramadan, beklenen yerde çıkıyor.
Kuralların bir kısmı ise formülle değil, veriyle yönetiliyor. Kağıt fiyatları, dakika maliyetleri ve son işlem tarifeleri PostgreSQL'de tutuluyor ve matbaa ekibi bir yönetim panelinden güncelliyor. Motor bu tarifeleri koda gömmüyor; başlangıçta yükleyip bellekte tutuyor, değişiklik olduğunda ise bir bildirimle tazeliyor. Böylece kağıt zammı geldiğinde kod dağıtımı yapmadan, panelden tek bir değeri değiştirmek yetiyor.
Milisaniye hedefi: önbellek ve önhesaplama
Fiyat motorunun her tuş vuruşunda çalışması gerektiği için hız pazarlık konusu değildi. İki teknikle p99 gecikmeyi tek haneli milisaniyelere indirdik. Birincisi önhesaplama: en çok istenen ürün, kağıt ve adet kombinasyonları için fiyat matrisini gece toplu işiyle hesaplayıp Redis'e yazıyoruz. Kullanıcıların isteklerinin büyük çoğunluğu bu popüler kombinasyonlara denk geldiği için, çağrıların önemli bir kısmı hiç hesaplama yapmadan doğrudan önbellekten dönüyor.
İkincisi istek düzeyinde önbellek: önhesaplanmamış bir kombinasyon istendiğinde motor hesabı yapıyor ve sonucu, girdilerden türetilen deterministik bir anahtarla Redis'e kısa süreli yazıyor. Anahtarı ürün kimliği, kağıt, gramaj, son işlemler ve adetten üretiyoruz; aynı seçim tekrar geldiğinde artık hesaplamaya gerek kalmıyor. Tarife değiştiğinde bu önbellekleri sürüm numarasıyla topluca geçersiz kılıyoruz — anahtara tarife sürümünü de kattığımız için eski değerler kendiliğinden devre dışı kalıyor.
Hesabın kendisi zaten hızlı; asıl kazanç, aynı hesabı ikinci kez yapmamaktan geldi. Go tarafında ise fiyatlandırmayı ayrı bir servis yerine ürün API'sinin içinde tuttuk. Ağ üzerinden ekstra bir çağrı yapmak, hesabın kendisinden daha uzun sürüyordu; motoru veriye yakın tutmak en basit ve en etkili optimizasyon oldu.
Kenar durumlar ve doğrulama
Bir fiyat motorunun asıl olgunluğu ortalama durumda değil, kenarlarda belli olur. Kullanıcı A2 boyutunda bir afişi 350 gr sticker kağıda basmak isteyebilir — teknik olarak mümkün olmayan bir kombinasyon. Motor bu tür geçersiz bileşimleri, fiyat üretmeden önce kural katmanında yakalayıp anlaşılır bir uyarıya çeviriyor. Aynı şekilde minimum ve maksimum adetleri, üretim kapasitesini ve ekspres teslimatın mümkün olmadığı ürünleri de doğrulama aşamasında ele alıyoruz.
Bir de yönetimsel esneklik gerekiyordu. Bazı kurumsal müşterilere özel fiyat, bazı ürünlere geçici kampanya tanımlanabiliyor. Bunları motorun çekirdeğine dokunmadan, en son marj katmanında uyguladığımız kurallar olarak modelledik. Böylece kampanya mantığı fiyat hesabının temelini kirletmiyor; istediğimizde tek bir katmanı kapatarak "ham maliyet" fiyatını da görebiliyoruz.
Sonuç
Anlık fiyatlandırma motorunu çalışır kılan tek bir akıllı formül değil, üç sıradan kararın toplamıydı: hesabı test edilebilir katmanlara ayırmak, kuralları koddan değil veriden yönetmek ve aynı hesabı asla iki kez yapmamak. Bu üçlü sayesinde matbaa ekibi fiyatları koda dokunmadan yönetebiliyor, kullanıcı ise seçeneği değiştirdiği anda güncellenen bir fiyat görüyor. Karmaşık bir fiyatlandırma probleminiz varsa, önce maliyet yapısını kağıt üzerinde katmanlara ayırın; motoru yazmak, o katmanları koda dökmekten ibaret kalır.