# Müşteri portalı ve teklif dikeyi — implementasyon planı · index

*31.07.2026 · v1.0 · Upstream: `docs/specs/musteri-portali-spec.md` (45 kriter, heyet **173 APPROVED**) · Karar arşivi: `docs/design/musteri-portali-brainstorm.md` (31 karar, heyet 170 APPROVED)*

Bu belge **nasıl yapılacağını** tanımlar. Ne yapılacağı spec'te; kararlar brainstorm'da ve **yeniden tartışılmaz**.

⚠ **Chunk'lar bir bölme değil, dosyalama biçimidir.** Index + dört chunk **tek review'da bütün olarak** okunur. Sebep düzeltme ergonomisidir: 1500 satırlık tek dosyada sed/grep ile düzeltme hata üretir.

| Chunk | Kapsam | Spec |
|---|---|---|
| `_chunk1` | Müşteri organizasyonu · portal kabuğu · dashboard · personel | §2, §3, §9.1–9.2 |
| `_chunk2` | Katılımcı omurgası · eğitimlerim · sertifika kaydı | §8, §9.4–9.5 |
| `_chunk3` | Talep · yazışma omurgası | §4, §7 |
| `_chunk4` | Teklif · fatura · içerik | §5, §6, §9.3 |

---

## 1. Doğrulanmış zemin

⚠ Koddan okunarak üretildi.

| Ne | Durum | Kanıt |
|---|---|---|
| Müşteri kimliği, oturum, magic link, davet | ✓ | `2026_07_27_000200_create_customer_identity.php` |
| Panel kapısı (`CustomerPanelController`) | ✓ JSON stub — **ekran yok** | `app/Http/Controllers/CustomerPanelController.php:38` |
| `consents` (KVKK rıza kaydı) | ✓ koruma trigger'ıyla | `create_customer_identity.php:42` |
| `customers.legal_type` = `legal_entity` \| `natural_person` | ✓ | `CreateCustomerRequest.php:25` |
| `AccountStatus` = `invited` · `active` · `suspended` · `closed` | ✓ | `app/Support/Accounts/AccountStatus.php` |
| Fiyat matrisi `price_groups` · `price_bands` · `price_lists` · `prices` | ✓ | migration listesi |
| İndirim limiti `user_discount_limits` (`max_percentage`) | ✓ | `2026_07_28_080000_create_settings.php:60` |
| `training_requests` | ⚠ **yarım** — tek müşteri + tek tanım varsayıyor; `tenant_id` NOT NULL + RLS | `2026_08_14_000500` |
| `training_participants` | ⚠ **çıplak bağ** — tenant + kişi + eğitim; durum/görev/yoklama yok | `create_customer_identity.php:313` |
| `training_customers` (kapalı eğitimin firmaları) | ✓ | `2026_08_14_000300` |
| `tasks` + `TaskKind` | ✓ | `app/Support/Tasks/TaskKind.php` |
| Belge motoru + `document_type_scopes` (`musteri_firma`, `katilimci`) | ✓ | `app/Models/DocumentType.php:62,65` |
| `TenantPolicy::apply($table, $column='tenant_id')` | ✓ | `app/Support/Database/TenantPolicy.php:36` |
| Referans önekleri `TK` (teklif) · `FT` (fatura) | ✓ | `app/Domain/Shared/ReferencePrefix.php:50,51` |
| **Portal Vite girişi** | ❌ yok — `app.js` ve `office.js` var | `vite.config.js:17-21` |
| Portal ekranları, teklif, fatura, yazışma, müşteri birimi/lokasyonu | ❌ yok | — |

---

## 2. Mimari kararlar

| # | Karar | Gerekçe |
|---|---|---|
| **P-1** | **Portal kendi Vite girişini alır** (`resources/js/portal.js` + `resources/css/portal.css`), sayfaları `resources/js/pages/Portal/**`. `vite.config.js` input'una eklenir | `FrontendTest` "resolves only this panel from the office entry" kuralı zaten `Portal`'ı ayrı panel sayıyor; ofis girişini genişletmek müşteriye ofis ekranlarını göndermek olur (AD-25, kod-yerlesimi §3) |
| **P-2** | Portal controller'ları `app/Http/Controllers/Customer/` altında | Bugün orada yalnız `InvitationController` var; müşteri tarafı kendi ad alanını taşır. Ofis controller'ları `Office/` altında kalır |
| **P-3** | **Yeni tablo yazmadan önce mevcut olan genişletilir.** `training_participants` ve `training_requests` yerine yeni tablo açılmaz; şekilleri değiştirilir (§4, §5) | İki kayıt da yaşayan veriye ve RLS'e sahip; paralel tablo, hangisinin doğru olduğu sorusunu üretir |
| **P-4** | **Tenant-kapsamlı her yeni tabloya `TenantPolicy::apply`**; ofis tarafında yaşayan kayıtlara **uygulanmaz** ve gerekçesi migration yorumuna yazılır | Spec §10.1 hangi kaydın hangi tarafta olduğunu söylüyor; policy'nin yokluğu **bilinçli** olduğunda yazılı olmalı, yoksa sonraki okuyucu unutulmuş sanır (kırmızı çizgi 1) |
| **P-5** | **Görünürlük iki katmanlı:** RLS tenant'ı ayırır, **policy** alan/taraf düzeyini ayırır | Teklif ofiste yaşar ve müşteriye açılır (spec §5.4, §10.1); RLS bunu ifade edemez çünkü kayıt tenant-kapsamlı değil. Alan düzeyi **query katmanında** çözülür — müşteri sorgusu iç onayı ve maliyeti **seçmez** |
| **P-6** | **İş kuralı `app/Domain/`'de, framework'süz** — indirim eskalasyonu, tahsis, tamamlama kapısı, sürüm yarışı | AD-25; bunlar test edilebilir saf kurallar ve Eloquent'e bağlanmaları test maliyetini yükseltir |
| **P-7** | **Her yeni public symbol'ün caller'ı aynı chunk'ta** — backbone teslim edilen yerler hariç ve onlar plana **kapanış adresiyle** yazılır | Onay kuralı 4 ve istisnası |
| **P-9** | ⛔ **RLS kimlik alanını AYIRT ETMELİ.** Ofis tarafında yaşayan (tenant'sız) satırlar müşteri oturumunda **görünmez**. Mekanizma mevcut desenin aynısı: `SET LOCAL app.identity_domain` + `app.is_office()` boolean fonksiyonu — `app.audit_read_all()` bunu zaten böyle yapıyor (`2026_07_26_000100:281`). Policy: `USING ((tenant_id IS NULL AND app.is_office()) OR tenant_id = ANY(app.tenant_ids()))` | `app.tenant_ids()` müşteri oturumunda da doludur; `tenant_id IS NULL` şartı tek başına **müşteriye tüm ofis satırlarını açar** — kırmızı çizgi 1'in tam ortası (heyet 174 F-P1) |
| **P-8** | Migration'lar **ileri yönlü ve additive**; kolon drop/rename/type change **yok** | Kırmızı çizgi 5. `training_requests`'in yeniden şekillenmesi §5'te bunun istisnası değil, **additive geçiş** olarak kurgulanıyor |

---

## 3. Migration sırası

⛔ Sıra bağlayıcıdır: her adım kendinden öncekilere yaslanır.

| # | Migration | Chunk | Not |
|---|---|---|---|
| 1 | `customer_units`, `customer_unit_memberships` | 1 | tenant-kapsamlı → `TenantPolicy::apply` |
| 2 | `customer_locations` | 1 | tenant-kapsamlı |
| 3 | `customer_approval_settings` | 1 | tenant-kapsamlı; tek onaylayan (spec §2.3) |
| 4 | `training_sessions` | 2 | eğitime bağlı, tenant'sız (eğitim ofis verisi) |
| 5 | `training_enrolments` | 2 | **`training_participants`'ın yerine geçer** — §5 |
| 6 | `session_attendances`, `participant_declarations` | 2 | tenant-kapsamlı |
| 7 | `exam_results`, `training_completions`, `certificate_records` | 2 | tenant-kapsamlı |
| 7.5 | `app.is_office()` fonksiyonu + `app.identity_domain` GUC | 3 | ⛔ **P-9** — `training_requests` policy'sinden **önce** gelmeli |
| 8 | `training_requests` genişletmesi + yardımcı tabloları | 3 | **§5'teki additive geçiş**; yardımcılar da P-9 policy'si alır |
| 9 | `correspondence_threads`, `correspondence_messages`, `correspondence_attachments` | 3 | **ofis tarafı** — P-4 gereği policy YOK, gerekçe yorumda |
| 10 | `quotes`, `quote_items`, `quote_versions`, `quote_approvals` | 4 | ofis tarafı |
| 11 | `invoices`, `invoice_items`, `invoice_notifications` | 4 | ofis tarafı |

⚠ **4→5 sırası kritik:** oturumlar kayıttan önce doğar, çünkü yoklama oturum × kayıt kesişimidir.

---

## 4. `training_participants` → `training_enrolments`

⛔ Kırmızı çizgi 5 (onaysız data-loss migration) burada **tetiklenmiyor**: eski tablo düşürülmüyor, **yeni tablo açılıyor ve veri taşınıyor**.

1. `training_enrolments` açılır (kişi, eğitim, **görev/kapsam**, statü, yedekten mi geldi, tenant).
2. Mevcut `training_participants` satırları taşınır — bugün üretimde **veri yok** (özellik hiç kullanılmadı), ama migration yine de idempotent taşıma yazar.
3. `training_participants` **bırakılır ve kullanılmaz**; düşürülmesi ayrı bir ticket ve **Sets onayı** ister.

⚠ Kod yalnız yeni tabloyu okur. Eski tabloya yazan bir yol kalırsa iki gerçek doğar — bu yüzden §7'nin taraması bunu arar.

---

## 5. `training_requests` geçişi

Bugün: `tenant_id` **NOT NULL** + `customers` FK + `TenantPolicy::apply`, tek `training_definition_id`, `location_note` serbest metin, görünürlük ve davet listesi yok.

Gereken: müşterisiz (bölge bazlı) talep, çoğul tanım, il, görünürlük + kapsam, davet listesi.

⛔ **`tenant_id`'yi nullable yapmak RLS politikasını değiştirir** — kırmızı çizgi 1'in komşusu. Additive geçiş:

1. `training_request_definitions` (çoğul tanım), `training_request_invitees` (davet edilecek firmalar), `training_request_scopes` (K-30: tüm satışçılar / grup / kişi) açılır.
2. `training_requests`'e eklenir: `city_option_value_id`, `visibility`, `origin` (`customer` \| `sales`), `reference_code_id`.
3. `tenant_id` **nullable yapılır ve RLS politikası yeniden yazılır**: `origin='customer'` satırları tenant kuralına tabi, `origin='sales'` satırları **ofis tarafı** — policy `tenant_id IS NULL OR tenant_id = ANY(app.tenant_ids())`.
4. Mevcut satırların `origin`'i `customer` olarak doldurulur.

⛔ **3. adım bir RLS değişikliğidir ve ayrı gözden geçirilir.** Negatif test zorunlu: B tenant'ının kullanıcısı A'nın müşteri talebini göremez; ofis kullanıcısı kapsamı dışındaki satış talebini göremez.

---

## 6. Kesişen kurallar

### 6.1 Tenant bağlamı ve kimlik alanı
⛔ Yalnız transaction içinde `SET LOCAL` (AD-04, pgbouncer). ⛔ **`app.identity_domain` de aynı yerde kurulur** (P-9): `TenantContext` tenant listesini yazarken kimlik alanını da yazar, ikisi aynı transaction'da yaşar ve biter. Portal istekleri müşteri oturumundan tenant'ı çözer; **ofis tarafında yaşayan** kayıtlara (teklif, fatura, yazışma) erişim tenant bağlamıyla değil **policy + query kapsamıyla** çözülür (P-5).

### 6.2 Yetki yerleşimi
| Nerede | Ne |
|---|---|
| Route middleware | kimlik alanı + güçlü kimlik (`RequireStrongAuth`) |
| Policy | kayıt düzeyi: bu müşteri bu teklifi görebilir mi |
| Query | **alan düzeyi**: müşteri sorgusu iç onayı, taslak sürümü, maliyeti ve muhasebe bildirimini **seçmez** |
| Action | iş kuralı ve geçiş yetkisi (onaylama, revizyon isteme) |

⛔ Alan düzeyini Vue'da gizlemek **yasak** — gönderilmiş veri gönderilmiştir (`65`/`67` turunda aynı kural).

### 6.3 Parametrik noktalar (AD-08, kırmızı çizgi 9)
Muhasebe e-posta adresi · global azami indirim · sınav eşiği/asgari devam/saklama süresi (eğitim tanımında) · belge görev süresi · iç onay ayarı (müşteri tarafında). ⛔ Kodda sabit sayı yok.

### 6.4 Audit
Teklif üretimi/gönderimi/onayı/reddi, revizyon, fatura kesme/iptal, katılımcı kaydı, tamamlama, talep açma/planlama/ret → `audit_writes`, **işlemle aynı transaction** (AD-06). Hassas okuma → `audit_access` (AD-21).

### 6.5 Test stratejisi (AD-26)
| Tür | Nerede |
|---|---|
| **Unit** | `app/Domain/` kuralları: indirim eskalasyonu (kalem + toplam), tahsis, tamamlama kapısının dört unsuru, sürüm yarışı |
| **Feature** | ekran + action akışları; grup: **`musteri`** (portal), **`satis`** (teklif/talep), **`planlama`** (talep kuyruğu), **`katilimci`** (omurga) |
| **RLS** | `omurga-rls` grubu, **owner rolüyle koşulmaz**; her yeni tenant tablosu için negatif test |
| **Arch** | yeni Portal girişinin ofis sayfalarını taşımadığı; `app/Domain/` framework'süzlüğü |

⚠ Çıplak `now()` yok, `Http::preventStrayRequests()` açık (Postmark webhook testleri), retry ile geçirme yok.

### 6.6 Kuyruk ve worker
Muhasebe bildirimi ve davet mailleri kuyruğa gider. ⛔ **Audit kuyruğa gitmez** (AD-06). Postmark inbound webhook senkron işlenir, ağır ek indirme kuyruğa devredilir.

---

## 7. Teslimden önce tarama

⛔ Her chunk kapanışında, bellekten değil **gerçek koşumdan**:

1. `pint --dirty` → `composer analyse` → ilgili gruplar → commit öncesi tam koşu
2. **Reverse-grep:** yeni public symbol'ün caller'ı var mı (P-7); backbone ise kapanış adresi plana yazılı mı
3. **Eski tabloya yazan yol kaldı mı** — `training_participants` (§4)
4. **Tasarım kapsama matrisi** — `CLAUDE.md` Tasarım Uygulama Protokolü adım 3: tasarım öğesi | ✓/⚠/❌ | kanıt (dosya+satır)
5. **Spec kapsama matrisi** — o chunk'ın M kriterleri | ✓/⚠/❌ | kanıt

---

## 8. Rollout ve geri alma

Chunk sırası **1 → 2 → 3 → 4**. Her chunk kendi başına deploy edilebilir; portal girişi chunk 1'de açılır ve sonrakiler üstüne biner.

⛔ **Geri alma migration ile değil, kod ile:** migration'lar additive olduğu için geri alma **rota ve menü kapatmaktır**. Tablo düşürmek geri alma yolu **değildir** (kırmızı çizgi 5, AD-11).

⚠ Chunk 4'ün fatura tarafı canlıya çıkmadan önce muhasebe adresi backoffice'ten tanımlı olmalı; tanımsızsa bildirim butonu **kapalı** görünür ve sebebini yazar.
