# FAZ 4 — Ortak Katalog ve Buybox

Sistemin okuma tarafını değiştiren faz. Veritabanı boşken yapıldığı için göç
adımı düştü ve hiç yanlış mimariye kod yazılmadı.

---

## 1. Ne değişti

**Önce:** her satıcı kendi ürününü açıyordu. Aynı nevresimi 40 satıcı satıyorsa
40 ayrı ürün sayfası, 40 ayrı yorum havuzu, 40 ayrı arama sonucu.

**Şimdi:** tek katalog kaydı, satıcılar ona teklif veriyor.

```
mg_catalog_items      kanonik ürün — GTIN, marka, model, özellik, görsel
      ▲
      │ (n teklif)
mg_product_offers     satıcı teklifi — fiyat, stok, kargo, teslim süresi
      ▲
      │ (1 kazanan)
mg_buybox_winners     hesaplanmış kazanan + GEREKÇE (önbellek)
```

### En önemli sonuç: fiyat ve stok artık üründe değil teklifte

`ec_products.price` katalog kaydı için anlamsızlaştı. Gerçek fiyat
`mg_product_offers.price`. Bu, okuma yolunun tamamını etkiliyor: kategori
listesi, arama, fiyat filtresi, "en düşük fiyat" sıralaması, sepet, favoriler.
Hepsi "ürünü oku" yerine "katalog + kazanan teklifi oku" olacak.

`ec_products` **silinmedi.** Satıcının teklifi hâlâ bir `ec_products` kaydına
dayanıyor — görselleri, varyantları, kargo bilgisi orada. `mg_product_offers`
ikisini bağlıyor, böylece eklentinin ürün yönetimi ekranları çalışmaya devam
ediyor.

---

## 2. Eşleştirme: barkod doğrulaması neden kataloğun temeli

`ec_products.barcode` alanı `varchar(150)`, **tekil indeksi yok ve hiçbir
format doğrulaması yok.** Mevcut sistem her şeyi kabul ediyor.

Bu buybox'ta veri bozulmasına dönüşür: yanlış yazılmış tek bir barkod, iki
farklı ürünü aynı katalog kaydında birleştirir. Sonuç — alakasız satıcılar
birbiriyle yarışır, müşteri nevresim sayfasında havlu fiyatı görür. Ve hata
sessizdir, müşteri yanlış ürün alana kadar kimse fark etmez.

`App\Magostore\Support\Gtin` bunu kapatıyor. Gerçek barkodlarla test ettim:

| Barkod | Sonuç |
|---|---|
| `8690632021126` | ✅ geçerli TR EAN-13 |
| `869 0632 021126` | ✅ boşluk temizlenip aynı sonuç |
| `8690632021127` | ❌ kontrol basamağı hatalı |
| `4006381333931` | ✅ geçerli Alman EAN-13 |
| `036000291452` | ✅ UPC-A → `0036000291452` olarak hizalanıyor |
| `96385074` | ✅ geçerli EAN-8 |
| `18690632021123` | ⚠️ **koli (ITF-14) barkodu** — ürün barkodu değil |

İki detay maliyeti düşürüyor:

**UPC-A hizalaması.** 12 haneli UPC başına sıfır eklenip GTIN-13'e çevriliyor.
Aynı ürünün iki formatta iki ayrı katalog kaydı oluşturması göçte en sık
çıkan çift kayıt sebebi; baştan kapatıyoruz.

**Koli barkodu tespiti.** Satıcılar sıklıkla koli barkodunu ürün barkodu sanıp
giriyor. Kabul edilirse tek ürün ile 12'li koli aynı kayıtta birleşir ve
fiyatlar 12 kat sapar.

### Temel kural: şüphede otomatik eşleştirme yok

Yalnızca **tek** durumda otomatik eşleştirme yapılıyor: kontrol basamağı
doğrulanmış GTIN'in kataloğa tam eşleşmesi.

Başlık benzerliği **asla** otomatik eşleştirmiyor — sadece öneri üretip
moderasyon kuyruğuna düşürüyor. Benzerlik Jaccard, %70 altı öneri olarak bile
gösterilmiyor. Basit ve şeffaf olması bilinçli: gömme modeli daha iyi eşleşirdi
ama moderatör "neden eşleşti" sorusuna cevap veremezdi.

Gerekçe basit: yanlış eşleştirmenin maliyeti, eşleştirmemenin maliyetinden kat
kat yüksek. Eşleşmeyen ürün sadece kuyrukta bekler.

---

## 3. Buybox: iki aşama, kapı sonra skor

### Kapılar (bunu geçemeyen skorlanmıyor)

- Teklif durumu aktif değil
- Mağaza aktif değil veya askıda
- **Satıcının IBAN veya alt üye kaydı eksik** ← Faz 3 bağlantısı
- Stok yok
- Fiyat kategori medyanının 1,5 katı üstü veya 0,5 katı altı

IBAN kapısı önemli: onay anında iyzico o satıcıya para gönderemez. Kazandırırsak
satışı alıp parasını alamayan bir satıcı ve çözülemeyen bir hakediş kaydı
üretmiş oluruz. Test ettim — her boyutta en iyi olan ama IBAN'ı olmayan teklif
eleniyor.

### Skor

```
skor = 0,40 × toplam_fiyat + 0,25 × teslim_süresi
     + 0,20 × satıcı_puanı  + 0,15 × karşılama_oranı
```

Alt skorlar **küme içinde** normalleştiriliyor, mutlak değere göre değil.
100 TL'lik üründe 5 TL fark önemli, 10.000 TL'likte değil.

`toplam_fiyat` = fiyat + kargo. Liste fiyatı değil, müşterinin ödediği.
1.189 ₺ + 59 ₺ kargo, 1.249 ₺ + bedava kargodan pahalıdır.

Ağırlıklar `mg_buybox_weights` tablosunda, tarih aralıklı. Ayar tablosunda
değil ayrı tabloda çünkü değişiklik geçmişi gerekiyor: "şubatta neden bu satıcı
kazanıyordu" sorusu bir satıcı itirazında cevaplanabilir olmalı.

### Test sonuçları

Tasarım mockup'ındaki dört teklifle çalıştırdım:

| Satıcı | Toplam | Gün | Puan | Skor |
|---|---|---|---|---|
| **Kandil Ev Tekstili** | 1.249 | 1 | 4,7 | **0,9806** ← kazanan |
| Ege Tekstil | 1.268 | 1 | 4,9 | 0,9075 |
| Pamuk Diyarı | 1.248 | 3 | 4,2 | 0,7045 |
| Denizli Tekstil Evi | 1.340 | 2 | 4,8 | 0,4625 |

**Algoritma, tasarım ekranında elle seçtiğim kazananı bağımsız olarak
doğruladı.** Pamuk Diyarı 1 ₺ daha ucuz ama 2 gün yavaş ve puanı 0,5 düşük.

Diğer testler:
- **Determinizm:** 200 kez karışık sırayla çalıştırıldı, kazanan hep aynı.
  Bu şart — buybox her hesaplamada zıplarsa satıcılar haklı olarak şikâyet eder.
- **Fiyat manipülasyonu:** medyan 1.200 ₺ iken 300 ₺'lik "sahte ucuz" ve
  2.500 ₺'lik "fahiş" teklif eleniyor.
- **Tam eşitlik:** her boyut aynıysa küçük `offer_id` kazanıyor (kıdem).

### Karar açıklanabilir olmak zorunda

`mg_buybox_winners.score_breakdown` alanında her boyutun katkısı ve elenen
tekliflerin gerekçeleri JSON olarak duruyor. `decided_by` alanı kararı tek
kelimeyle özetliyor.

Bu, ürün sayfasındaki "neden bu satıcı seçildi" metnini ve satıcı panelindeki
"neden kaybettim" ekranını besliyor. Kapalı kutu bir algoritma, satıcı
itirazına cevap verilemez hale getirir.

---

## 4. Yazılan kod

| Dosya | Görevi |
|---|---|
| `Support/Gtin.php` | GTIN doğrulama, normalleştirme, koli barkodu tespiti |
| `Catalog/CatalogMatcher.php` | Ürün → katalog eşleştirme + moderasyon kuyruğu |
| `Catalog/BuyboxScorer.php` | Saf skorlama, kapılar, eşitlik bozma |
| `Catalog/BuyboxRecomputer.php` | Veritabanı tarafı, önbelleğe yazma |
| `Catalog/OfferCandidate.php` | Yarışan teklif (değiştirilemez) |
| `Catalog/BuyboxWeights.php` | Ağırlıklar + normalleştirme kontrolü |
| `Catalog/BuyboxDecision.php` | Karar + gerekçe metni |
| `Commands/RecomputeBuybox.php` | Yeniden hesaplama komutu |
| `migrations/..._000007_create_catalog_and_buybox.php` | 5 tablo + varsayılan ağırlık |

### Cron

```
15 3 * * *  php artisan magostore:recompute-buybox --stale=24
```

Satıcı puanları gün içinde değişiyor, gece toplu tur gerekiyor. Fiyat ve stok
değişimlerinde tek kayıt için olay dinleyicisinden çağrılacak.

Komut, ağırlıklar 1,0 toplamıyorsa **hiç hesaplama yapmıyor** — skorlar
karşılaştırılamaz hale geleceği için sessizce yanlış sonuç üretmek yerine
duruyor.

---

## 5. Faz 4'te kalan iş

Bu paket kataloğun **motorunu** kuruyor. Kalan, okuma yolunun bağlanması:

| İş | Tahmin |
|---|---|
| Okuma yolu: liste, arama, filtre, ürün detay | 2–3 hafta |
| Satıcı tarafı: mevcut ürüne teklif ver, katalog arama | 1–2 hafta |
| Admin moderasyon: kuyruk, birleştir/ayır, geri al | 1 hafta |
| Arama indeksi (fiyat artık teklifte) | ~1 hafta |

Motor hazır olduğu için bunlar bağlama işi, tasarım kararı içermiyor.

---

## 6. Bir uyarı: yorum ve puan havuzu

Ortak katalogda yorumlar **katalog kaydına** ait olmalı, satıcı ürününe değil —
yoksa aynı ürünün 40 ayrı yorum havuzu olur ve hiçbiri anlamlı sayıya ulaşmaz.

Ama satıcı puanı **satıcıya** ait kalmalı: kargo hızı ve paketleme satıcının
performansı, ürünün değil.

Yani `ec_reviews` iki boyuta ayrılmalı: ürün puanı → katalog, satıcı puanı →
mağaza. Mevcut tabloda ikisi tek kayıtta. Bu, okuma yolu işinin içinde ele
alınacak ama ayrıca not düşüyorum çünkü atlanması kolay ve sonradan düzeltmesi
zor.
