# Chunk 4 — Teklif, fatura, içerik

*Index: `musteri-portali-plan.md` · Spec: §5, §6, §9.3, §9.5 · Tasarım: `13`, `14`, `31`, `16`*

Chunk 3'ün yazışma omurgası ve talep kuyruğu üstüne biner.

---

## 1. Migration'lar

### 1.1 `quotes`
`id` · `customer_id` · `reference_code_id` (`TK`) · `status` (`draft` \| `internal_review` \| `sent` \| `approved` \| `declined` \| `revision_requested`) · `owner_user_id` (satışçı) · `current_version_id` · timestamps.

⚠ **`valid_until` VAR** (K-33, spec §5.7) — `SendQuote` yazar; varsayılan admin ayarından, satışçı teklife özel verebilir.

⛔ **`expired` bir statü DEĞİLDİR.** Süre geçmesi `valid_until < now()` ile **türetilir** ve **uyarı** üretir; statüyü insan değiştirir. Statü listesine yalnız **`cancelled`** eklenir (satışçının kapatması) — `declined` müşterinin reddi, `cancelled` satışçının kapatması.

⛔ **Otomatik statü değiştiren zamanlanmış komut YOK.** `ExpireInstructorOffers` emsal **değildir**: orada teklifin süresi dolunca eğitmenin cevap hakkı biter (kapalı bir kapı); burada süre geçmesi bir **olgudur** ve satış kararı insanındır (spec §5.7).

⚠ Zamanlanmış komut yalnız **hatırlatma** gönderir: `quotes:send-validity-reminders` — geçerliliğe N gün kala (admin ayarı), kapatılabilir (tasarım `31`). ⛔ Statüye dokunmaz.
⛔ **Tenant policy YOK** — teklif ofiste yaşar, müşteriye policy + query ile açılır (spec §10.1, index P-4/P-5). Gerekçe migration yorumunda.
⚠ `customer_id` var ama tenant kolonu **değil**: müşteriyi işaret eder, RLS'i sürmez.

### 1.2 `quote_items`
`id` · `quote_id` · `training_id` · **`price_kind`** (`daily` \| `per_person`) · `price_band_id` · `price_list_version` · `unit_price` · `quantity` · `discount_type` (`percent` \| `amount`) · `discount_value` · `line_total` · **`allocation_status`** (`held` \| `pending_confirmation` \| `confirmed` \| `lost`) · timestamps.

⛔ **Fiyat DONDURULUR** (M-35, spec §5.2): `unit_price` listeden **kopyalanır**, `price_list_version` geldiği sürümü söyler. ⚠ Canlı listeden okuma **yok**.
⛔ **`price_kind` kalem başına** (M-11): bir teklifte günlük ve kişi başı kalemler karışabilir.
⛔ **`allocation_status` kalem başına** (M-13): tahsis eğitim bazlıdır; bir kalem `pending_confirmation`'da asılı kalırken diğerleri `confirmed` olabilir.

### 1.3 `quote_versions`
`id` · `quote_id` · `version` · `body` (üretilen metin) · **`content_hash`** · `created_by` · `sent_at` (nullable) · timestamps.
⛔ **Silinmez** (M-17, M-31, AD-11). ⚠ `sent_at` boş olan sürüm **taslaktır ve müşteriye görünmez** (M-14).

### 1.4 `quote_approvals`
`id` · `quote_id` · `quote_version_id` · `kind` (`internal` \| `customer`) · `decision` (`approved` \| `declined` \| `revision_requested`) · `decided_by` + `decider_type` · `decided_at` · `reason` · **`content_hash`** · timestamps.
⛔ Müşteri onayında **hash + zaman damgası + kullanıcı** (M-15, AD-14). ⚠ `content_hash` burada da tutulur: sürüm silinmese bile onayın **neyi** onayladığı satırın kendisinde okunur.

### 1.5 `invoices`, `invoice_items`, `invoice_notifications`
`invoices`: `id` · `customer_id` · `quote_id` (nullable — serbest kalem) · `reference_code_id` (`FT`) · `status` (`draft` \| `issued` \| `cancelled`) · `cancelled_reason` · **`replaces_invoice_id`** (nullable) · `issued_at` · timestamps.
⛔ **İptal silme değildir** (M-19, AD-11, kırmızı çizgi 12): `status='cancelled'` + gerekçe; yeni fatura `replaces_invoice_id` ile öncekini işaret eder.
`invoice_notifications`: `invoice_id` · `notified_at` · `notified_by` · `channel` · `to_address` · `thread_id` (chunk 3).

---

## 2. Domain

`app/Support/Business/DiscountCeiling.php` — ⛔ **yerinde düzeltilir, taşınmaz**
```
exceeds(float $lineDiscount, float $totalDiscount, float $globalMax, ?float $sellerMax): bool
```
⛔ **Engellemez, ESKALE EDER** (M-12, `fiyatlama-spec` §4.2): aşan teklif `internal_review`'a gider. ⛔ **İki seviye**: kalem **ve** toplam; hangisi aşılırsa.
⛔ **`min()` YANLIŞTIR** (heyet 174 F-P2). AD-09 (revize 28.07.2026): *"Genişletme engellenmez, onay akışına düşer."* Satışçıya global üstünde bir limit **tanımlanmışsa** (patron istisnası), `min()` onu sessizce globale kırpar — yani onaylı istisnayı yok sayar ve EGT-58'i **yanlış** kapatır.

Doğrusu **iki** sonuçlu: **etkin tavan `sellerMax ?? globalMax`**;
- aşım yok → normal akış (⛔ satışçıya %30 tanımlıysa %28'lik teklif **onaya düşmez** — istisna tanım anında verilmiştir)
- aşım var → **onaya düşer** ve onay ekranında **belirgin "limit aşımı" uyarısı** çıkar

⛔ Sets'in kararı (31.07.2026): *"tanımlanmış limitlerin üzerinde yapılan her işlem alert olarak gözükür onay anında… buna rağmen onaylanıyorsa onaylanır."* Onaylayan **görerek** onaylar; sistem engellemez. ⚠ Ret gerekçesi zaten gösteriliyor.

⚠ v1.0 üçüncü bir dal yazmıştı — *"genişletilmiş limitin içinde kalan teklif de her seferinde onaya düşer"*. Ne AD-09'da ne `fiyatlama-spec`'te vardı; **plan icadıydı** ve patronun istisnasını her teklifte yeniden onaylatıyordu (heyet 175 R2P-3). Kaldırıldı.

⚠ EGT-58'in tarif ettiği hata tam olarak *"ne hata veriyor ne onaya gönderiyor"*; düzeltme **onaya gönderme** yolunu kurmaktır.

⛔ **EGT-58'in iki yarısı vardır ve İKİSİ DE bu chunk'ta kapanır** *(Sets, 31.07.2026: "niye ayırıyorsun ki ikisi de aynı bütünün parçaları? tutarsızlık yaratmaz mı bölmek?")*.

| Yarı | Ne | Nerede |
|---|---|---|
| **Tanım anı** | Satışçıya **global üstünde** limit tanımlanırken onay doğar (AD-09, AD-23 mekanizması) | `UpdateBusinessSettings` / `user_discount_limits` yazma yolu |
| **Teklif anı** | Etkin tavanı **aşan** teklif onaya düşer, aşım belirgin gösterilir | `SubmitQuoteForApproval` |

⛔ **Bölmek delik açar:** teklif anı *"istisna tanım anında verilmiştir"* varsayımıyla çalışıyor. Tanım anında onay yoksa o cümle **dekordur** — kimse onaylamamış olur, ve tavan zaten yükseltildiği için teklif anı da hiç tetiklenmez. İkisi bir arada **tek** kural yapar; ayrı ayrı hiçbir şey yapmaz.

### 2.1 Tanım anı — `SetSellerDiscountLimit`

Yeni action: satışçıya limit tanımlar/değiştirir.
- Değer **globalin altında** ise → doğrudan yazılır (daraltma serbest, AD-09)
- Değer **globalin üstünde** ise → ⛔ **engellenmez**, kayıt `pending_approval` olarak açılır ve onay kuyruğuna düşer (`ApprovalKind`'a yeni tür)
- Onaylanana kadar **etkin tavan eski değerdir** — onaysız limit kullanılmaz

⚠ `user_discount_limits` bugün yalnız `user_id` + `max_percentage` taşıyor; `status` · `approved_by` · `approved_at` · `reason` eklenir (additive).

⛔ **`ApprovalKind::DiscountLimit` ZATEN VAR** (`ApprovalKind.php:33`) ve bilerek boş bırakılmış — docblock'u yazıyor: *"exists with nothing behind it today: the sales module is unwritten… the screen shows the tab with a zero."* ⚠ v1.0 *"yeni tür eklenir"* diyordu; doğrusu **mevcut tür doldurulur** (heyet 176 R3P-1).

⛔ **İzni de bilinçli olarak `null`** (`ApprovalKind.php:54` — *"no right to it yet"*). Tür devreye girerken bu chunk **izni tanımlar** ve `66`'nın indirim sekmesinin kime açılacağını yazar: `discount_limit.approve`. Sekme bugün sıfırla duruyor; bu chunk onu doldurur.

`app/Domain/Sales/QuoteAllocation.php` — kalem bazlı tahsis; `held` → `confirmed` \| `pending_confirmation` \| `lost`.
`app/Domain/Sales/VersionRace.php` — ⛔ onaylanan sürüm ile ekrandaki sürüm farklıysa onay **reddedilir** (M-15, spec §5.5).

---

## 3. Action'lar

| Action | Not |
|---|---|
| `CreateQuote`, `AddQuoteItem`, `RemoveQuoteItem` | ⛔ yalnız **planlanmış** eğitim (M-4/K-18); fiyat dondurulur |
| `SetSellerDiscountLimit` | ⛔ **tanım anı** (§2.1): global üstü değer onaya düşer, onaysız limit **etkin değildir** |
| `SubmitQuoteForApproval` | `DiscountCeiling` aşımı varsa **zorunlu**, yoksa opsiyonel |
| `DecideQuoteInternally` | 3E yöneticisi; gerekçe |
| `SendQuote` | sürüm üretir + hash + `sent_at`; ⛔ kontenjan yetiyorsa/yetmiyorsa **uyarı metne girer** (M-37) ve **hash'e dahildir**; çok eğitimli teklifte **kalem bazında** |
| `ApproveQuote` | ⛔ `VersionRace` kontrolü; delil yazar (M-15). Sonrasında: satışçıya bildirim, planlamacıya düşme, yöneticiye bilgi |
| `RequestQuoteRevision` | müşteri panelden; ⚠ maille gelen istek **durum üretmez** (M-21) — satışçı görür ve bu action'ı **o** çağırır |
| `IssueInvoice` | ⛔ yalnız `approved` tekliften ya da serbest kalemden (M-18); teklif **bütün olarak** |
| `CancelQuote` | ⛔ **satışçının kararı** — süresi geçmiş teklifi kapatır; gerekçe zorunlu. Kalemlerin `held` rezervasyonu **burada** düşer, süre dolduğunda değil |
| `CancelInvoice` | gerekçe zorunlu; ⛔ silme yok |
| `NotifyAccounting` | muhasebe adresi **backoffice ayarı**; thread açar (chunk 3), durum yazar (M-20) |

⚠ **`SetSellerDiscountLimit`'in caller'ı `61`'in satışçı düzenleme draweridir** ve bu chunk'a **dahildir** (heyet 176 R3P-3, P-7). Backbone teslim edilmiyor: alan, action ve onay sekmesi birlikte gidiyor. ⚠ Proje haritası `61`'in satışçı tarafını *"yok"* diye işaretliyor — bu chunk o parçanın **indirim limiti alanını** açar, satışçı draweri'nin tamamını değil; kalanı `61`'in kendi turudur.

⛔ **Satışçı devri** (M-44/M-45, spec §3.3.1):
- `SetCustomerSalespeople` — atama değişince yeni satışçı görür, eskisi görmez. ⚠ Erişim kuralı; audit değişmez.
- `ChangeOfficeAccountStatus` — ⛔ `closed`'a geçişte **atanmış işler devredilmemişse reddeder**; `suspended`'da **uyarı verir** ve devam eder (yönetimsel karar).

---

## 4. Query'ler

| Query | Not |
|---|---|
| `Office\SalesQuotes` | satışçının teklifleri; ⚠ eğitim listesi `VisibleTrainings` (chunk 3) ile süzülür |
| `Office\TrainingCostForSales` | ⛔ **M-36'nın karşılığı** — maliyet **planlamadan** gelir (`training_cost_items`, eğitmen ücreti, yol/konaklama) ve satışçıya **salt okunur** verilir. Query yalnız okur; satışçının maliyet yazan hiçbir action'ı **yoktur** (çatı §187, K-8). ⚠ Kâr = fiyat − maliyet burada hesaplanır ve satışçı bunu görür |
| `Office\OverdueQuotes` | süresi geçmiş teklifler — `valid_until < now()` ve statüsü hâlâ `sent`. ⚠ Satışçının ekranında **uyarı** olarak çıkar; kalemler `held` kalmaya devam eder |
| `Office\TrainingCapacity` | ⛔ üç sayı **kapsam dışı okunur** (M-39, çatı §149) — "teklif bekleyen" tüm satışçıları kapsar. ⚠ Satışçı **kimin teklifi olduğunu görmez** |
| `Customer\MyQuotes` | ⛔ yalnız `sent_at` dolu sürümler; iç onay, taslak, **maliyet** seçilmez (M-14) |
| `Customer\MyInvoices` | ⛔ bildirim durumu **seçilmez** (M-42) |

⛔ Alan düzeyi **query'de** (index P-5): müşteri sorgusu kapalı alanları `select` etmez. Vue'da gizlemek yasak.

---

## 5. Frontend

| Dosya | Tasarım |
|---|---|
| `pages/Office/Users/…` satışçı düzenleme draweri | `61` — ⛔ **indirim limiti alanı** buraya girer; `SetSellerDiscountLimit`'in caller'ı budur (P-7) |
| `pages/Office/Quotes/Index.vue` + `Show.vue` | `31` — son hamle satırı dahil |
| `pages/Portal/Quotes.vue` + `QuoteShow.vue` | `13` — scroll-to-accept, sürüm geçmişi, son hamle |
| `pages/Portal/QuoteRequest.vue` | `14` — üç adım, katılımcı grupları, iç onay |
| `pages/Portal/Invoices.vue` | `16` faturalar |
| `pages/Portal/Content.vue` | `16` içerik paketleri |
| `organisms/ScrollToAccept.vue` | ⛔ onay düğmesi metin **sonuna kadar okunmadan** açılmaz (AD-14) |

---

## 6. Test planı

| Grup | Senaryo |
|---|---|
| `omurga` (unit) | `DiscountCeiling`: kalem aşımı · toplam aşımı · ikisi de temiz · **aşım engellemez, eskale eder** · etkin tavan `sellerMax ?? globalMax` ve **onaysız geniş limit etkin değil** · `VersionRace` · `QuoteAllocation` |
| `satis` | teklif kur: planlanmamış eğitim reddedilir · karışık `price_kind` · fiyat **dondurulur** ve sürüm kaydedilir · aşım `internal_review`'a düşer · gönderimde uyarı metne girer ve hash'e dahil olur · kontenjan sayacı **kapsam dışı** |
| `satis` | devir: atama değişince yeni satışçı görür/eski görmez · `closed`'a geçiş devredilmemiş işle **reddedilir** · `suspended` uyarır ve geçer |
| `musteri` | onay: hash + zaman + kullanıcı yazılır · **sürüm yarışında reddedilir** · maildeki kabul onaylamaz · revizyon isteği sürüm doğurur · taslak/iç onay/maliyet **payload'da yok** |
| `musteri` | fatura: onaysız teklif faturalanamaz · teklif **bütün olarak** faturalanır (asılı kalem beklemez) · iptal + `replaces` zinciri · bildirim durumu müşteriye **görünmez** |
| `entegrasyon` | `NotifyAccounting` thread açar; muhasebe cevabı aynı thread'e `party='accounting'` düşer |
| `omurga-rls` | ⛔ teklif/fatura/thread **tenant tablosu değil**: müşteri A'nın kullanıcısı B'nin teklifini **policy** ile göremez — negatif test policy katmanında |
| `omurga` | ⛔ **audit** (AD-06): teklif gönderme/onay/ret, revizyon, fatura kesme/iptal `audit_writes` üretir · yazım **aynı transaction**'da (audit yazılamıyorsa işlem de olmaz) · hassas okuma `audit_access`'e düşer (AD-21) |
| `satis` | ⛔ **geçerlilik:** gönderimde tarih yazılır · süre geçince **statü değişmez** ve kalemler `held` kalır · uyarı çıkar · `CancelQuote` insan kararıyla rezervasyonu düşürür (M-46, M-47) |
| `satis` | ⛔ **onay sekmesi doluyor:** `discount_limit` türü kuyrukta görünür ve `discount_limit.approve` izni olan görür (`66`; tür bugün sıfırla duruyor) |
| `satis` | ⛔ **EGT-58 iki yarısı birden:** (a) globalin altında limit doğrudan yazılır · (b) global üstü limit **onaya düşer ve onaysız etkin olmaz** · (c) onaylanmış geniş limitin **içindeki** teklif normal akıştan gider · (d) etkin tavanı aşan teklif onaya düşer ve **aşım belirgin görünür** |
| `satis` | ⛔ maliyet **salt okunur**: satışçı maliyeti görür, yazan action yok (M-36, reverse-grep) |

---

## 7. Kapsama

| Spec | Karşılığı |
|---|---|
| M-11, M-35 | `quote_items` + fiyat dondurma |
| M-36 | `TrainingCostForSales` (salt okunur) + satışçıda maliyet action'ı olmadığının **reverse-grep** testi |
| M-12 | `DiscountCeiling` + `SetSellerDiscountLimit` — ⛔ **EGT-58 iki yarısıyla birlikte kapanır** |
| M-13 | `QuoteAllocation` |
| M-14, M-29 | `MyQuotes` alan süzgeci |
| M-15, M-16, M-17 | `ApproveQuote` + `VersionRace` + sürümler |
| M-18, M-19, M-20 | fatura |
| M-30, M-31, M-32 | kodlar · silinmezlik · audit |
| M-37, M-39 | gönderim uyarısı · sayaç kapsamı |
| M-46, M-47 | `valid_until` + `OverdueQuotes` + `CancelQuote`; ⛔ otomatik statü değişimi **yok** |
| M-44, M-45 | devir + hesap durumu |

⚠ M-33'ün ikinci yarısı burada: yedek listedeki talep için hazırlanan teklifin metninde **"yedek liste" ibaresi** bulunur (çatı §15).
