# Ofis tarafı müşteri yönetimi — implementasyon planı

*29.07.2026 · Spec turu yoktur (Sets kararı, 29.07.2026: kurallar çatı §60-67, `omurga-spec.md` §3 ve AD-23'te belli) · Bağımlı iş: `docs/specs/planlama-spec.md` (heyet 165 APPROVED) kapalı eğitimi firmaya bağlıyor ve o firmanın ofisten yönetilebilmesini gerektiriyor*

**Hedef:** ofis kullanıcısının müşteri firmayı uçtan uca yönetebilmesi — listeleme, açma, düzenleme, pasifleştirme, firmanın kişilerini yönetme, portala davet etme, satışçı atama.

**Mimari:** Yeni tablo **yoktur**. Kimlik tarafı chunk 5'te doğdu (`customers`, `customer_users`, `magic_links`), atama tablosu chunk 6'da (`office_user_customers`). Bu plan o omurganın üstüne **ekranları, action'ları, policy'leri ve davet akışını** koyar. Ofis tarafı Inertia + Vue, mevcut `Office/Users` desenini izler.

**Stack:** Laravel 13 · PHP 8.5 · PostgreSQL (RLS) · Inertia + Vue 3 · Pest

---

## Doğrulanmış mevcut durum

⚠ Bu tablo **koddan okunarak** üretildi, bellekten değil. `ofis-t4-plan` v1.0'ın kök hatası bellekten yazmaktı.

| Ne | Durum | Kanıt |
|---|---|---|
| `customers` tablosu | ✓ var — `name`, `legal_type`, `tax_office`, `tax_number`, `default_locale`, `timezone`, `status`, `price_group_id` | `2026_07_27_000200_create_customer_identity.php:71-96`, `2026_07_28_090000_add_price_group_to_customers.php` |
| `customers` RLS | ✓ **kendi `id`'si üzerinden** — SELECT `id = ANY(app.tenant_ids())`, INSERT `WITH CHECK (true)`, UPDATE `id = app.write_tenant_id()`, **DELETE politikası yok** (default-deny) | `app/Support/Database/TenantPolicy.php:49-76` |
| `customer_users` | ✓ var — `email`/`phone`/`national_id` şifreli, `email_hash` root-keyed, `national_id_hash` tenant-keyed | `create_customer_identity.php:129-200` |
| `CustomerUser` modeli şifreleme | ✓ hash ve `key_version` senkronu modelde — `creating`/`saving` hook'ları | `app/Models/CustomerUser.php:101-135` |
| Satışçı → müşteri ataması | ✓ tablo var, adı **`office_user_customers`** (⚠ migration dosyası `create_office_customer_assignments` ama tablo adı bu) | `2026_07_28_000100:38-52` |
| Kapsam çözümleyici | ✓ `SessionScopeResolver::accessibleTenantIds()` — `customer.view.all` varsa `app.all_customer_ids()`, yoksa atanmış müşteriler | `app/Support/Tenancy/SessionScopeResolver.php:55-80` |
| Tenant bağlamı yazma | ✓ `TenantContext::forWrite(int $tenantId, Closure $callback)` | `app/Support/Tenancy/TenantContext.php:79` |
| Magic link üretimi | ✓ `IssueMagicLink(CustomerUser $user, string $purpose = 'sign_in')` → `['token' => string, 'link' => MagicLink]` | `app/Actions/Auth/IssueMagicLink.php:34` |
| İzinler | ✓ `customer.create`, `customer.update`, `customer.deactivate`, `customer.view.all`, `user.assign_customers` config'de | `config/authorization.php:62,74,93-95` |
| **`CustomerPolicy`** | ❌ **YOK** — `app/Policies/` altında yok | `ls app/Policies/` |
| Müşteri **yaratma** action'ı | ✓ **`App\Actions\Tenancy\CreateCustomer`** — RLS'nin `INSERT … RETURNING` tuzağını çözmüş (id sequence'ten alınır), `ProvisionTenantAuthorization` ile müşterinin kendi izin ailesini aynı transaction'da kurar. `NoAuthorizationRequired` ile işaretli ve gerekçesi *"çağıran ekran kriter 15a ile ertelendi; `customer.create` kontrolü ona ait"* | `app/Actions/Tenancy/CreateCustomer.php` |
| `CustomerFactory` | ✓ var — `legal_type` değerleri **`legal_entity`** / **`natural_person`** | `database/factories/CustomerFactory.php` |
| Ofis müşteri ekranı | ❌ YOK — `resources/js/pages/Office/` altında `Customers` yok | `ls resources/js/pages/Office/` |
| Müşteri davet akışı | ❌ YOK — bilinçli erteleme | `omurga-spec.md:724` (kriter 15a) |
| Ofis müşteri yönetimi tasarımı | ❌ YOK — tasarımda yalnız satışçı müşteri **kartı** var (`/satis/musteriler/...`) | `design/32 Satisci Musteri Karti.dc.html` |

---

## Global kısıtlar

Her görevin gereksinimleri bunları **kapsar**; görev metinlerinde tekrarlanmaz.

- **Dil:** kod tarafı İngilizce (tablo/kolon/sınıf/metot/route adı, yorum, test adı), arayüz Türkçe (`lang/tr/`). Koda gömülü kullanıcı metni yok.
- **Test grubu:** `musteri` (AD-26). Grup etiketi olmayan test arch testiyle reddedilir.
- **Çıplak `now()` yasak** — `Tests\FrozenClock::INSTANT`.
- **Kod yerleşimi (AD-25):** Controller'da query builder ve `validate()` yok; okuma `app/Queries/Office/`, yazma `app/Actions/Office/`, form doğrulaması `app/Http/Requests/Office/`.
- **Tarayıcıya id gitmez** — her kimlik `OpaqueId` üzerinden (chunk6 §6.10).
- **Hassas veri:** `customer_users.national_id` maskeli gösterilir; açık görüntüleme ayrı eylemdir ve erişim loguna yazılır (AD-16, AD-21).
- **Audit:** müşteri açma/düzenleme/pasifleştirme ve davet append-only loglanır, **işlemle aynı transaction'da** (AD-06, kırmızı çizgi 13).
- **Kritik kayıt silinmez:** müşteri pasifleştirilir (`customers`'ta DELETE politikası zaten yok).
- **Kalite kapısı:** `./vendor/bin/pint --dirty` → `composer analyse` → `./vendor/bin/pest --group=musteri` → commit.

---

## Kararlar

| # | Karar | Gerekçe |
|---|---|---|
| K-1 | Yeni tablo yok | Kimlik tarafı chunk 5'te, atama chunk 6'da doğdu. Bu plan yalnız yüzey ve akış ekler. |
| K-1b | Müşteri yaratma `Tenancy\CreateCustomer`'a **devredilir**, yeniden yazılmaz | O action RLS'nin `INSERT … RETURNING` tuzağını canlı veritabanında doğrulanmış bir çözümle geçiyor ve tenant izin ailesini aynı transaction'da kuruyor. İkinci bir yazıcı, o iki şeyi ikinci kez ve büyük olasılıkla eksik yapardı. Ofis katmanı **yetkiyi ve audit'i** ekler — action'ın kendi yorumunun adreslediği şey tam olarak budur. |
| K-2 | Müşteri **oluşturma** tenant bağlamı gerektirmez, **güncelleme** gerektirir | RLS: INSERT `WITH CHECK (true)`, UPDATE `id = app.write_tenant_id()`. Migration bunun gerekçesini yazıyor — yeni müşterinin id'si INSERT'ün kendisi tarafından üretilir, önceden kimsenin kapsamında olamaz. |
| K-3 | Ekran deseni `Office/Users`'tan alınır | Ofis müşteri yönetimi **tasarımda yok** — bu ekranın çizilmiş bir kompozisyonu olmadığı için mevcut çalışan desen tekrarlanır. ⚠ Gerekçe *"kompozisyon bağlayıcı değil"* DEĞİLDİR; o kural 30.07.2026'da düzeltildi, çizili tasarım varken eksiksiz uygulanır. |
| K-4 | Davet, ofis davetinin **aynısı değil** — magic link üzerinden | Ofis tarafında davet parola belirletir (`AcceptOfficeInvitation`). Müşteri tarafında parola **zorunlu değildir**: `customer_users.password` nullable ve katılımcı magic link'le girer (migration yorumu). Davet, `purpose: 'invitation'` taşıyan bir magic link'tir. |
| K-5 | Satışçı ataması müşteri detayından **ve** kullanıcı detayından yapılabilir | İki yönlü ilişki iki soruya cevap verir: "bu müşteriye kim bakıyor" ve "bu satışçı kimlere bakıyor". Tek Action, iki çağıran. |
| K-6 | Pasifleştirme gerekçe ister | Ofis hesabında olduğu gibi (`ChangeOfficeAccountStatus`): audit satırı bir yıl sonra "neden pasif" sorusuna cevap verebilmeli. |

---

## Dosya haritası

**Yeni**

| Dosya | Sorumluluk |
|---|---|
| `app/Policies/CustomerPolicy.php` | Müşteri kaydına yetki + kapsam |
| `app/Queries/Office/OfficeCustomers.php` | Liste — kapsamla filtreli |
| `app/Queries/Office/CustomerDetail.php` | Tek müşterinin detayı |
| `app/Queries/Office/CustomerPeople.php` | Firmanın kişileri (maskeli) |
| `app/Queries/Office/AssignableSalespeople.php` | Atanabilir ofis kullanıcıları |
| `app/Actions/Office/CreateCustomer.php` | Firma açma |
| `app/Actions/Office/UpdateCustomer.php` | Firma bilgisi düzenleme |
| `app/Actions/Office/ChangeCustomerStatus.php` | Pasifleştirme / yeniden aktifleştirme |
| `app/Actions/Office/CreateCustomerPerson.php` | Firmaya kişi ekleme |
| `app/Actions/Office/InviteCustomerPerson.php` | Portal daveti gönderme |
| `app/Actions/Office/AssignCustomersToUser.php` | Satışçı ↔ müşteri ataması |
| `app/Actions/Auth/AcceptCustomerInvitation.php` | Davetin karşılanması (müşteri tarafı) |
| `app/Notifications/CustomerPortalInvitation.php` | Davet e-postası |
| `app/Data/Office/NewCustomer.php` | Firma açma girdisi |
| `app/Data/Office/NewCustomerPerson.php` | Kişi ekleme girdisi |
| `app/Http/Controllers/Office/CustomerController.php` | Ofis müşteri ekranları |
| `app/Http/Controllers/Customer/InvitationController.php` | Davet karşılama ekranı |
| `app/Http/Requests/Office/CreateCustomerRequest.php` | |
| `app/Http/Requests/Office/UpdateCustomerRequest.php` | |
| `app/Http/Requests/Office/CreateCustomerPersonRequest.php` | |
| `app/Http/Requests/Office/AssignCustomersRequest.php` | |
| `resources/js/pages/Office/Customers/Index.vue` | Liste + açma drawer'ı |
| `resources/js/pages/Office/Customers/Show.vue` | Detay: bilgiler, kişiler, satışçılar |
| `resources/js/organisms/CustomerForm.vue` | Firma formu (açma + düzenleme) |
| `resources/js/organisms/CustomerPersonForm.vue` | Kişi formu |
| `resources/js/pages/Customer/AcceptInvitation.vue` | Davet karşılama |
| `tests/Feature/Musteri/CustomerManagementTest.php` | |
| `tests/Feature/Musteri/CustomerPeopleTest.php` | |
| `tests/Feature/Musteri/CustomerInvitationTest.php` | |
| `tests/Feature/Musteri/CustomerAssignmentTest.php` | |

**Değişen**

| Dosya | Ne |
|---|---|
| `routes/office.php` | Müşteri route'ları |
| `routes/web.php` | Davet karşılama route'u |
| `app/Queries/Office/OfficeNavigation.php` | Menüye "Müşteriler" |
| `lang/tr/*.php` | Arayüz metinleri |
| `app/Models/Customer.php` | `salespeople()` ilişkisi |
| `app/Models/User.php` | `customers()` ilişkisi |

---

## Görev 1: Müşteri policy'si ve görünürlük

**Dosyalar:** `app/Policies/CustomerPolicy.php` (yeni) · `app/Queries/Office/OfficeCustomers.php` (yeni) · `tests/Feature/Musteri/CustomerManagementTest.php` (yeni)

**Üretir:** `CustomerPolicy::{viewAny,view,create,update,deactivate}` · `OfficeCustomers::visibleTo(User $viewer): Collection`

- [ ] **1.1 — Düşen testi yaz**

`tests/Feature/Musteri/CustomerManagementTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Customer;
use App\Models\Permission;
use App\Models\User;
use App\Queries\Office\OfficeCustomers;
use App\Support\Authorization\PermissionFamilies;
use App\Support\Authorization\PermissionFamily;

it('shows every customer to somebody holding the wide scope', function (): void {
    $seen = Customer::factory()->create(['name' => 'ABC Lojistik']);
    $viewer = User::factory()->create();

    app(PermissionFamilies::class)->using(
        PermissionFamily::Office,
        fn () => $viewer->givePermissionTo(
            Permission::findOrCreate('customer.view.all', PermissionFamily::Office->guard())
        ),
    );

    $names = app(OfficeCustomers::class)->visibleTo($viewer->fresh())->pluck('name');

    expect($names)->toContain('ABC Lojistik');
});

it('shows only assigned customers to somebody without the wide scope', function (): void {
    $assigned = Customer::factory()->create(['name' => 'Atanan A.Ş.']);
    $other = Customer::factory()->create(['name' => 'Atanmayan A.Ş.']);

    $viewer = User::factory()->create();
    $viewer->customers()->attach($assigned->getKey());

    $names = app(OfficeCustomers::class)->visibleTo($viewer->fresh())->pluck('name');

    expect($names)->toContain('Atanan A.Ş.')
        ->and($names)->not->toContain('Atanmayan A.Ş.');
});
```

⚠ İkinci test **kırmızı çizgi 1'in testidir** — kapsamı olmayan kullanıcı başka müşterinin adını göremez.

Aynı dosyanın başına, sonraki görevlerin de kullanacağı yardımcılar:

```php
function userWithPermissions(array $permissions): User
{
    $user = User::factory()->create();

    app(PermissionFamilies::class)->using(PermissionFamily::Office, function () use ($user, $permissions): void {
        foreach ($permissions as $name) {
            $user->givePermissionTo(Permission::findOrCreate($name, PermissionFamily::Office->guard()));
        }
    });

    return $user->fresh();
}

function userWhoMayManage(Customer $customer, array $permissions): User
{
    $user = userWithPermissions($permissions);
    $user->customers()->attach($customer->getKey());

    return $user->fresh();
}

function personIn(Customer $customer): CustomerUser
{
    return TenantContext::forWrite((int) $customer->getKey(), fn (): CustomerUser => CustomerUser::query()->create([
        'tenant_id' => $customer->getKey(),
        'name' => 'Test Kişi',
        'email' => 'kisi'.$customer->getKey().'@abc.test',
        'status' => 'active',
    ]));
}

function issueInvitationFor(CustomerUser $person): string
{
    ['token' => $token] = app(IssueMagicLink::class)($person, 'invitation');

    return $token;
}
```

⚠ `personIn` **doğrudan model kullanır**, `CreateCustomerPerson` action'ını değil: bir testin kurulum adımı, sınadığı şeyin kendisine bağlı olmamalıdır. Aksi hâlde action bozulduğunda ilgisiz testler de kırmızıya döner ve hangisinin gerçek bulgu olduğu anlaşılmaz.

- [ ] **1.2 — Koş, düştüğünü gör**

`./vendor/bin/pest --group=musteri` → `Class "App\Queries\Office\OfficeCustomers" does not exist`

- [ ] **1.3 — `CustomerFactory`'yi kullan (zaten var)**

`database/factories/CustomerFactory.php` chunk5'te yazıldı ve `naturalPerson()` state'i taşıyor. ⚠ `legal_type` değerleri **`legal_entity`** ve **`natural_person`**'dır — `company`/`sole_trader` değil.

- [ ] **1.4 — İlişkileri ekle**

`app/Models/User.php` içine:

```php
/**
 * The customers this office person has been put in charge of (chunk6 §6.5.2).
 *
 * ⚠ NOT the same question as `customer.view.all`. This is a list; that is a
 * scope that needs no list. Somebody may hold either without the other.
 *
 * @return BelongsToMany<Customer, $this>
 */
public function customers(): BelongsToMany
{
    return $this->belongsToMany(Customer::class, 'office_user_customers', 'user_id', 'tenant_id')
        ->withTimestamps();
}
```

`app/Models/Customer.php` içine:

```php
/**
 * @return BelongsToMany<User, $this>
 */
public function salespeople(): BelongsToMany
{
    return $this->belongsToMany(User::class, 'office_user_customers', 'tenant_id', 'user_id')
        ->withTimestamps();
}
```

- [ ] **1.5 — `OfficeCustomers` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Queries\Office;

use App\Domain\Authorization\ScopeResolver;
use App\Models\Customer;
use App\Models\User;
use App\Support\Http\OpaqueId;
use App\Support\Tenancy\TenantContext;
use Illuminate\Support\Collection;

/**
 * The customer list an office person may see.
 *
 * ── The scope is not this class's invention ────────────────────────────────
 *
 * `SessionScopeResolver` already answers "which tenants may this identity
 * reach" (chunk6 §6.5.2): `customer.view.all` opens every active customer,
 * and without it the answer is the customers actually assigned. Asking that
 * question a second way here would produce a second answer.
 *
 * ⚠ `customers` is row secured on its own `id`, so the query must run inside
 * a tenant context — otherwise row security returns nothing and the screen
 * reports "no customers" for somebody who has several.
 */
final class OfficeCustomers
{
    public function __construct(
        private readonly OpaqueId $opaqueId,
        private readonly ScopeResolver $scope,
    ) {}

    /**
     * @return Collection<int, array<string, mixed>>
     */
    public function visibleTo(User $viewer): Collection
    {
        $tenantIds = $this->scope->accessibleTenantIds($viewer);

        if ($tenantIds === []) {
            return collect();
        }

        return TenantContext::run($tenantIds, fn (): Collection => Customer::query()
            ->withCount('users')
            ->orderBy('name')
            ->get()
            ->map(fn (Customer $customer): array => [
                'id' => $this->opaqueId->encode('customer', (int) $customer->getKey()),
                'name' => $customer->name,
                'legal_type' => $customer->legal_type,
                'tax_number' => $customer->tax_number,
                'status' => $customer->status,
                'people_count' => (int) $customer->users_count,
            ]));
    }
}
```

⚠ Arayüz **`App\Domain\Authorization\ScopeResolver`**'dır (`SessionScopeResolver` onu uygular). ⛔ `App\Support\Authorization\ScopeResolver` ile karıştırılmamalı — o **grup ağacı** kapsamıdır (T4), bu **tenant** kapsamıdır. İkisi farklı soruya cevap verir.

- [ ] **1.6 — `CustomerPolicy` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Policies;

use App\Models\Customer;
use App\Models\User;
use App\Domain\Authorization\ScopeResolver;

/**
 * Who may do what to a customer company (spec §3.4, chunk6 §6.5.2).
 *
 * ⚠ Two different questions live here and they are deliberately not merged:
 * "may this person act on customers at all" (the permission) and "is THIS
 * customer within their reach" (the scope). A permission without a reach check
 * would let somebody with `customer.update` edit a company they have never
 * been given.
 */
final class CustomerPolicy
{
    public function __construct(private readonly ScopeResolver $scope) {}

    public function viewAny(User $user): bool
    {
        return $user->can('customer.view.all') || $user->customers()->exists();
    }

    public function view(User $user, Customer $customer): bool
    {
        return $this->reaches($user, $customer);
    }

    public function create(User $user): bool
    {
        return $user->can('customer.create');
    }

    public function update(User $user, Customer $customer): bool
    {
        return $user->can('customer.update') && $this->reaches($user, $customer);
    }

    public function deactivate(User $user, Customer $customer): bool
    {
        return $user->can('customer.deactivate') && $this->reaches($user, $customer);
    }

    private function reaches(User $user, Customer $customer): bool
    {
        return in_array((int) $customer->getKey(), $this->scope->accessibleTenantIds($user), strict: true);
    }
}
```

- [ ] **1.7 — Koş, geçtiğini gör**

`./vendor/bin/pest --group=musteri` → PASS (2 test)

- [ ] **1.8 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri
git add app/Policies/CustomerPolicy.php app/Queries/Office/OfficeCustomers.php app/Models/User.php app/Models/Customer.php database/factories/CustomerFactory.php tests/Feature/Musteri/CustomerManagementTest.php
git commit -m "#EGT-62 In Progress work 45m

feat(musteri): müşteri policy'si ve kapsamlı liste sorgusu"
```

---

## Görev 2: Müşteri açma

**Dosyalar:** `app/Actions/Office/CreateCustomer.php` · `app/Data/Office/NewCustomer.php` · `app/Http/Requests/Office/CreateCustomerRequest.php` (hepsi yeni) · `tests/Feature/Musteri/CustomerManagementTest.php` (ekleme)

**Tüketir:** `CustomerPolicy::create` · **Üretir:** `CreateCustomer::__invoke(User $actor, NewCustomer $input): Customer`

- [ ] **2.1 — Düşen testi yaz**

```php
it('opens a customer and writes an audit row in the same transaction', function (): void {
    $actor = User::factory()->create();

    app(PermissionFamilies::class)->using(
        PermissionFamily::Office,
        fn () => $actor->givePermissionTo(
            Permission::findOrCreate('customer.create', PermissionFamily::Office->guard())
        ),
    );

    $customer = app(CreateCustomer::class)($actor->fresh(), new NewCustomer(
        name: 'Yeni Lojistik A.Ş.',
        legalType: 'legal_entity',
        taxOffice: 'Kadıköy',
        taxNumber: '1234567890',
    ));

    expect($customer->name)->toBe('Yeni Lojistik A.Ş.')
        ->and($customer->status)->toBe('active')
        ->and(AuditWrite::query()->where('event', 'customer.created')->exists())->toBeTrue();
});

it('refuses to open a customer without the permission', function (): void {
    $actor = User::factory()->create();

    expect(fn (): Customer => app(CreateCustomer::class)($actor, new NewCustomer(
        name: 'İzinsiz A.Ş.',
        legalType: 'legal_entity',
    )))->toThrow(AuthorizationException::class);
});
```

- [ ] **2.2 — Koş, düştüğünü gör**

Beklenen: `Class "App\Actions\Office\CreateCustomer" does not exist`

- [ ] **2.3 — `NewCustomer` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Data\Office;

/**
 * What opening a customer company needs.
 *
 * ⚠ No status field. A customer is opened active; freezing is a separate act
 * with its own reason (`ChangeCustomerStatus`), and an "open it frozen" option
 * would be a way of creating a record nobody can explain.
 */
final readonly class NewCustomer
{
    public function __construct(
        public string $name,
        public string $legalType,
        public ?string $taxOffice = null,
        public ?string $taxNumber = null,
        public ?int $priceGroupId = null,
        public string $locale = 'tr',
        public string $timezone = 'Europe/Istanbul',
    ) {}
}
```

- [ ] **2.4 — `CreateCustomer` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Office;

use App\Data\Office\NewCustomer;
use App\Models\Customer;
use App\Models\User;
use App\Support\Audit\AuditWriter;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\DB;

/**
 * Opening a customer company (çatı §60, spec §3.1).
 *
 * ── No tenant context, and that is the migration's own decision ────────────
 *
 * `customers` is row secured on its own `id` with an open INSERT policy. The
 * reason is written in `TenantPolicy::applyToCustomers`: a new customer's id is
 * produced by the INSERT itself, so `id = app.write_tenant_id()` would make
 * creating one permanently impossible. The control standing in for row security
 * here is `customer.create` — which is why this action authorises before it
 * writes and never trusts the screen to have done it.
 */
final class CreateCustomer
{
    public function __construct(private readonly Gate $gate) {}

    public function __invoke(User $actor, NewCustomer $input): Customer
    {
        $this->gate->forUser($actor)->authorize('create', Customer::class);

        return DB::transaction(function () use ($actor, $input): Customer {
            $customer = Customer::query()->create([
                'name' => $input->name,
                'legal_type' => $input->legalType,
                'tax_office' => $input->taxOffice,
                'tax_number' => $input->taxNumber,
                'price_group_id' => $input->priceGroupId,
                'default_locale' => $input->locale,
                'timezone' => $input->timezone,
                'status' => 'active',
            ]);

            AuditWriter::record(
                event: 'customer.created',
                subject: $customer,
                newValues: ['name' => $input->name, 'by' => $actor->getKey()],
            );

            return $customer;
        });
    }
}
```

- [ ] **2.5 — `CreateCustomerRequest` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Office;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class CreateCustomerRequest extends FormRequest
{
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'legal_type' => ['required', Rule::in(['legal_entity', 'natural_person'])],
            'tax_office' => ['nullable', 'string', 'max:255'],

            // ⚠ Ten digits for a company, eleven for a person: the second is a
            // national id and `customers` deliberately stores no encrypted
            // column, so the length is all the validation there is here.
            'tax_number' => ['nullable', 'string', 'max:32'],
            'price_group_id' => ['nullable', 'integer', Rule::exists('price_groups', 'id')],
        ];
    }
}
```

- [ ] **2.6 — Koş, geçtiğini gör**

`./vendor/bin/pest --group=musteri` → PASS (4 test)

- [ ] **2.7 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri
git add app/Actions/Office/CreateCustomer.php app/Data/Office/NewCustomer.php app/Http/Requests/Office/CreateCustomerRequest.php tests/Feature/Musteri/CustomerManagementTest.php
git commit -m "#EGT-62 In Progress work 40m

feat(musteri): müşteri açma action'ı ve doğrulaması"
```

---

## Görev 3: Düzenleme ve pasifleştirme

**Dosyalar:** `app/Actions/Office/UpdateCustomer.php` · `app/Actions/Office/ChangeCustomerStatus.php` · `app/Http/Requests/Office/UpdateCustomerRequest.php` (yeni)

**Üretir:** `UpdateCustomer::__invoke(User $actor, Customer $customer, NewCustomer $input): Customer` · `ChangeCustomerStatus::__invoke(User $actor, Customer $customer, string $to, string $reason): void`

- [ ] **3.1 — Düşen testi yaz**

```php
it('edits a customer inside its own tenant context', function (): void {
    $customer = Customer::factory()->create(['name' => 'Eski Ad']);
    $actor = userWhoMayManage($customer, ['customer.update']);

    app(UpdateCustomer::class)($actor, $customer, new NewCustomer(
        name: 'Yeni Ad',
        legalType: 'legal_entity',
    ));

    expect($customer->fresh()->name)->toBe('Yeni Ad');
});

it('refuses to deactivate without a reason', function (): void {
    $customer = Customer::factory()->create();
    $actor = userWhoMayManage($customer, ['customer.deactivate']);

    expect(fn () => app(ChangeCustomerStatus::class)($actor, $customer, 'inactive', ''))
        ->toThrow(ValidationException::class);
});

it('keeps the reason on the audit row', function (): void {
    $customer = Customer::factory()->create();
    $actor = userWhoMayManage($customer, ['customer.deactivate']);

    app(ChangeCustomerStatus::class)($actor, $customer, 'inactive', 'Sözleşme sona erdi');

    $row = AuditWrite::query()->where('event', 'customer.inactive')->latest('id')->first();

    expect($customer->fresh()->status)->toBe('inactive')
        ->and($row->new_values['reason'])->toBe('Sözleşme sona erdi');
});
```

- [ ] **3.2 — Koş, düştüğünü gör**

- [ ] **3.3 — `UpdateCustomer` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Office;

use App\Data\Office\NewCustomer;
use App\Models\Customer;
use App\Models\User;
use App\Support\Audit\AuditWriter;
use App\Support\Tenancy\TenantContext;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\DB;

/**
 * Editing a customer company.
 *
 * ⛔ Unlike opening one, this MUST run inside the customer's own tenant
 * context: the UPDATE policy is `id = app.write_tenant_id()` and a write
 * outside it does not fail loudly — row security simply matches nothing and
 * the update silently affects zero rows (chunk2 §2.4).
 */
final class UpdateCustomer
{
    public function __construct(private readonly Gate $gate) {}

    public function __invoke(User $actor, Customer $customer, NewCustomer $input): Customer
    {
        $this->gate->forUser($actor)->authorize('update', $customer);

        $before = $customer->only(['name', 'legal_type', 'tax_office', 'tax_number', 'price_group_id']);

        return TenantContext::forWrite((int) $customer->getKey(), fn (): Customer => DB::transaction(function () use ($customer, $input, $actor, $before): Customer {
            $customer->fill([
                'name' => $input->name,
                'legal_type' => $input->legalType,
                'tax_office' => $input->taxOffice,
                'tax_number' => $input->taxNumber,
                'price_group_id' => $input->priceGroupId,
            ])->save();

            AuditWriter::record(
                event: 'customer.updated',
                subject: $customer,
                oldValues: $before,
                newValues: $customer->only(array_keys($before)) + ['by' => $actor->getKey()],
            );

            return $customer;
        }));
    }
}
```

- [ ] **3.4 — `ChangeCustomerStatus` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Office;

use App\Models\Customer;
use App\Models\User;
use App\Support\Audit\AuditWriter;
use App\Support\Tenancy\TenantContext;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

/**
 * Freezing and reopening a customer company (AD-23, kırmızı çizgi 12).
 *
 * ⚠ The reason is required HERE, not only in the modal. A screen can be
 * bypassed; the audit row is the thing that has to answer "why is this company
 * inactive" a year later, and an empty answer is the one it must never give.
 *
 * ⛔ There is no deletion. `customers` carries no DELETE policy at all — every
 * training record, certificate and ministry notification naming this company
 * would otherwise lose its subject.
 */
final class ChangeCustomerStatus
{
    public function __construct(private readonly Gate $gate) {}

    public function __invoke(User $actor, Customer $customer, string $to, string $reason): void
    {
        $this->gate->forUser($actor)->authorize('deactivate', $customer);

        if (trim($reason) === '') {
            throw ValidationException::withMessages([
                'reason' => __('customers.status_errors.reason_required'),
            ]);
        }

        $from = (string) $customer->status;

        if ($from === $to) {
            return;
        }

        TenantContext::forWrite((int) $customer->getKey(), function () use ($customer, $to, $from, $reason, $actor): void {
            DB::transaction(function () use ($customer, $to, $from, $reason, $actor): void {
                $customer->status = $to;
                $customer->save();

                AuditWriter::record(
                    event: 'customer.'.$to,
                    subject: $customer,
                    oldValues: ['status' => $from],
                    newValues: ['status' => $to, 'reason' => $reason, 'by' => $actor->getKey()],
                );
            });
        });
    }
}
```

- [ ] **3.5 — `UpdateCustomerRequest` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Office;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

/**
 * ⚠ `status` burada YOK. Durum değişikliği kendi ucundan geçer
 * (`office.customers.status`) çünkü gerekçe ister; bu formda taşınsaydı
 * gerekçesiz bir yol açılmış olurdu.
 */
final class UpdateCustomerRequest extends FormRequest
{
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'legal_type' => ['required', Rule::in(['legal_entity', 'natural_person'])],
            'tax_office' => ['nullable', 'string', 'max:255'],
            'tax_number' => ['nullable', 'string', 'max:32'],
            'price_group_id' => ['nullable', 'integer', Rule::exists('price_groups', 'id')],
        ];
    }
}
```

- [ ] **3.6 — Koş, geçtiğini gör** — PASS (7 test)

- [ ] **3.7 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri
git add app/Actions/Office/UpdateCustomer.php app/Actions/Office/ChangeCustomerStatus.php app/Http/Requests/Office/UpdateCustomerRequest.php tests/Feature/Musteri/CustomerManagementTest.php
git commit -m "#EGT-62 In Progress work 45m

feat(musteri): müşteri düzenleme ve gerekçeli pasifleştirme"
```

---

## Görev 4: Firmanın kişileri

**Dosyalar:** `app/Queries/Office/CustomerPeople.php` · `app/Actions/Office/CreateCustomerPerson.php` · `app/Data/Office/NewCustomerPerson.php` · `app/Http/Requests/Office/CreateCustomerPersonRequest.php` · `tests/Feature/Musteri/CustomerPeopleTest.php` (hepsi yeni)

**Üretir:** `CustomerPeople::for(Customer $customer): Collection` · `CreateCustomerPerson::__invoke(User $actor, Customer $customer, NewCustomerPerson $input): CustomerUser`

- [ ] **4.1 — Düşen testi yaz**

```php
it('adds a person to a company and encrypts the identifiers', function (): void {
    $customer = Customer::factory()->create();
    $actor = userWhoMayManage($customer, ['customer.update']);

    $person = app(CreateCustomerPerson::class)($actor, $customer, new NewCustomerPerson(
        name: 'Ayşe Yılmaz',
        email: 'ayse@abc.test',
        phone: '+905321234567',
    ));

    $raw = DB::table('customer_users')->where('id', $person->getKey())->first();

    expect($raw->email)->not->toBe('ayse@abc.test')
        ->and($raw->email_hash)->toHaveLength(64)
        ->and($person->email)->toBe('ayse@abc.test');
});

it('masks the national id on the office screen', function (): void {
    $customer = Customer::factory()->create();
    $actor = userWhoMayManage($customer, ['customer.update']);

    app(CreateCustomerPerson::class)($actor, $customer, new NewCustomerPerson(
        name: 'Mehmet Demir',
        email: 'mehmet@abc.test',
        nationalId: '48212345316',
    ));

    $row = app(CustomerPeople::class)->for($customer)->first();

    expect($row['national_id'])->not->toContain('12345')
        ->and($row['national_id'])->toContain('•');
});

it('refuses a second person with the same address in the same company', function (): void {
    $customer = Customer::factory()->create();
    $actor = userWhoMayManage($customer, ['customer.update']);

    $add = fn (): CustomerUser => app(CreateCustomerPerson::class)($actor, $customer, new NewCustomerPerson(
        name: 'Aynı Kişi',
        email: 'ayni@abc.test',
    ));

    $add();

    expect($add)->toThrow(QueryException::class);
});
```

⚠ Üçüncü test **tenant içi** benzersizliği sınar. Aynı adresin başka bir firmada da bulunabilmesi (spec §13) ayrı bir davranıştır ve chunk5'te zaten test edilmiştir.

- [ ] **4.2 — Koş, düştüğünü gör**

- [ ] **4.3 — `NewCustomerPerson` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Data\Office;

final readonly class NewCustomerPerson
{
    public function __construct(
        public string $name,
        public string $email,
        public ?string $phone = null,
        public ?string $nationalId = null,
    ) {}
}
```

- [ ] **4.4 — `CreateCustomerPerson` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Office;

use App\Data\Office\NewCustomerPerson;
use App\Models\Customer;
use App\Models\CustomerUser;
use App\Models\User;
use App\Support\Audit\AuditWriter;
use App\Support\Tenancy\TenantContext;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\DB;

/**
 * Adding a person to a customer company (spec §3.0).
 *
 * ⚠ No password. `customer_users.password` is nullable on purpose: a person
 * reaches their own screens through a magic link, and inventing a password for
 * them would create a credential nobody asked for and nobody rotates. Panel
 * access is a separate, explicit act — the invitation (Görev 5).
 *
 * ⚠ The encryption is the MODEL's job, not this action's: `CustomerUser` keeps
 * `email_hash`, `national_id_hash` and `key_version` in step on write. Doing it
 * here would be a second implementation of the same rule.
 */
final class CreateCustomerPerson
{
    public function __construct(private readonly Gate $gate) {}

    public function __invoke(User $actor, Customer $customer, NewCustomerPerson $input): CustomerUser
    {
        $this->gate->forUser($actor)->authorize('update', $customer);

        return TenantContext::forWrite((int) $customer->getKey(), fn (): CustomerUser => DB::transaction(function () use ($customer, $input, $actor): CustomerUser {
            $person = CustomerUser::query()->create([
                'tenant_id' => $customer->getKey(),
                'name' => $input->name,
                'email' => $input->email,
                'phone' => $input->phone,
                'national_id' => $input->nationalId,
                'status' => 'active',
            ]);

            AuditWriter::record(
                event: 'customer_user.created',
                subject: $person,
                newValues: ['name' => $input->name, 'by' => $actor->getKey()],
            );

            return $person;
        }));
    }
}
```

- [ ] **4.5 — `CustomerPeople` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Queries\Office;

use App\Models\Customer;
use App\Models\CustomerUser;
use App\Support\Masking\Masker;
use App\Support\Http\OpaqueId;
use App\Support\Tenancy\TenantContext;
use Illuminate\Support\Collection;

/**
 * The people inside a customer company, as the office sees them.
 *
 * ⛔ The national id is MASKED here and nowhere else decides otherwise. Reading
 * it in the clear is a separate action that writes an access row (AD-16,
 * AD-21); a list screen is never that action.
 */
final class CustomerPeople
{
    public function __construct(private readonly OpaqueId $opaqueId) {}

    /**
     * @return Collection<int, array<string, mixed>>
     */
    public function for(Customer $customer): Collection
    {
        return TenantContext::run([(int) $customer->getKey()], fn (): Collection => CustomerUser::query()
            ->where('tenant_id', $customer->getKey())
            ->orderBy('name')
            ->get()
            ->map(fn (CustomerUser $person): array => [
                'id' => $this->opaqueId->encode('customer_user', (int) $person->getKey()),
                'name' => $person->name,
                'email' => $person->email,
                'phone' => $person->phone,
                'national_id' => $person->national_id === null ? null : Masker::nationalId($person->national_id),
                'status' => $person->status,
                'has_signed_in' => $person->email_verified_at !== null,
            ]));
    }
}
```

⚠ `App\Support\Masking\Masker::nationalId(string $value): string` chunk4'te kuruldu ve imzası doğrulandı. **Nullable değildir** — kolon nullable olduğu için çağrı `null` kontrolüyle sarılır.

- [ ] **4.6 — `CreateCustomerPersonRequest` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Office;

use App\Rules\WellFormedPhoneNumber;
use Illuminate\Foundation\Http\FormRequest;

/**
 * ⚠ E-posta benzersizliği burada **doğrulanmaz**. Adres şifreli saklanır ve
 * arama `email_hash` üzerinden yürür; `Rule::unique('customer_users', 'email')`
 * şifreli metni karşılaştırır ve hiçbir zaman eşleşme bulmaz — çalışıyor gibi
 * görünen bir kural, hiç çalışmayandan kötüdür. Tekilliği veritabanındaki
 * `(tenant_id, email_hash)` indeksi zorlar ve action'ın yakaladığı
 * `QueryException` kullanıcıya çevrilir.
 */
final class CreateCustomerPersonRequest extends FormRequest
{
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email:rfc,strict', 'max:255'],
            'phone' => ['nullable', 'string', 'max:20', new WellFormedPhoneNumber],

            // On bir hane, yalnız rakam. Doğrulama biçimseldir — TCKN'nin
            // gerçekliği sorgulanmaz, o İdare'nin işidir.
            'national_id' => ['nullable', 'string', 'digits:11'],
        ];
    }
}
```

- [ ] **4.7 — Koş, geçtiğini gör** — PASS (10 test)

- [ ] **4.8 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri
git add app/Queries/Office/CustomerPeople.php app/Actions/Office/CreateCustomerPerson.php app/Data/Office/NewCustomerPerson.php app/Http/Requests/Office/CreateCustomerPersonRequest.php tests/Feature/Musteri/CustomerPeopleTest.php
git commit -m "#EGT-62 In Progress work 50m

feat(musteri): firma kişileri — ekleme ve maskeli listeleme"
```

---

## Görev 5: Portal daveti

**Dosyalar:** `app/Actions/Office/InviteCustomerPerson.php` · `app/Actions/Auth/AcceptCustomerInvitation.php` · `app/Http/Controllers/Customer/InvitationController.php` · `resources/js/pages/Customer/AcceptInvitation.vue` · `tests/Feature/Musteri/CustomerInvitationTest.php` (yeni) · `routes/web.php` (değişen)

**Tüketir:** `IssueMagicLink::__invoke(CustomerUser $user, string $purpose): array{token: string, link: MagicLink}`
**Üretir:** `InviteCustomerPerson::__invoke(User $actor, CustomerUser $person): void`

- [ ] **5.1 — Düşen testi yaz**

```php
it('sends an invitation and does not grant panel access yet', function (): void {
    Notification::fake();

    $customer = Customer::factory()->create();
    $actor = userWhoMayManage($customer, ['customer.update']);
    $person = personIn($customer);

    app(InviteCustomerPerson::class)($actor, $person);

    expect(MagicLink::query()->where('purpose', 'invitation')->count())->toBe(1)
        ->and($person->fresh()->email_verified_at)->toBeNull();
});

it('marks the address proven when the invitation is followed', function (): void {
    $customer = Customer::factory()->create();
    $person = personIn($customer);

    $token = issueInvitationFor($person);

    app(AcceptCustomerInvitation::class)($token);

    expect($person->fresh()->email_verified_at)->not->toBeNull();
});

it('refuses an invitation that was already used', function (): void {
    $customer = Customer::factory()->create();
    $person = personIn($customer);

    $token = issueInvitationFor($person);
    app(AcceptCustomerInvitation::class)($token);

    expect(fn () => app(AcceptCustomerInvitation::class)($token))
        ->toThrow(AuthenticationRefused::class);
});
```

- [ ] **5.2 — Koş, düştüğünü gör**

- [ ] **5.3 — `InviteCustomerPerson` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Office;

use App\Actions\Auth\IssueMagicLink;
use App\Models\CustomerUser;
use App\Notifications\CustomerPortalInvitation;
use App\Models\User;
use App\Support\Audit\AuditWriter;
use App\Support\Tenancy\TenantContext;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\DB;

/**
 * Inviting a customer's person to the portal (omurga-spec kriter 15a).
 *
 * ── Not the office invitation ──────────────────────────────────────────────
 *
 * The office invitation makes somebody choose a password, because an office
 * account without one cannot be used. On the customer side the password is
 * optional by design (`customer_users.password` is nullable): a participant
 * reaches their screens through a magic link and may never set one. So this
 * invitation is a magic link with its own purpose, not a password ceremony.
 *
 * ⚠ The link proves the mailbox, not the person. What the office learns when
 * it is followed is that the address works — panel permissions remain a
 * separate, explicit grant (spec §3.0).
 */
final class InviteCustomerPerson
{
    public function __construct(
        private readonly Gate $gate,
        private readonly IssueMagicLink $issue,
    ) {}

    public function __invoke(User $actor, CustomerUser $person): void
    {
        $customer = $person->customer;

        $this->gate->forUser($actor)->authorize('update', $customer);

        TenantContext::forWrite((int) $person->tenant_id, function () use ($person, $actor): void {
            DB::transaction(function () use ($person, $actor): void {
                ['token' => $token] = ($this->issue)($person, 'invitation');

                $person->notify(new CustomerPortalInvitation($token));

                AuditWriter::record(
                    event: 'customer_user.invited',
                    subject: $person,
                    newValues: ['by' => $actor->getKey()],
                );
            });
        });
    }
}
```

- [ ] **5.4 — `AcceptCustomerInvitation` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Auth;

use App\Models\CustomerUser;
use App\Models\MagicLink;
use App\Support\Auth\AuthenticationRefused;
use App\Support\Auth\MagicLinkTokens;
use App\Support\Authorization\NoAuthorizationRequired;
use App\Support\Tenancy\TenantContext;
use Illuminate\Support\Facades\DB;

/**
 * Turning an invitation into a proven address.
 *
 * ⚠ Deliberately does NOT sign the person in. The office invitation does,
 * because the person sets a password in the same step and that is the moment
 * the account becomes theirs. Here nothing is set: following the link proves
 * the mailbox and nothing more, and the customer sign in flow (`ConsumeMagicLink`)
 * is the place where a session is created — with its own rules.
 */
#[NoAuthorizationRequired(
    'Runs before there is a session: the token is the only claim the visitor has.'
)]
final class AcceptCustomerInvitation
{
    public function __invoke(#[\SensitiveParameter] string $token): CustomerUser
    {
        $rows = DB::select(
            'SELECT id, tenant_id, customer_user_id, expires_at, used_at, purpose FROM app.magic_link_lookup(?)',
            [MagicLinkTokens::hash($token)],
        );

        $row = $rows[0] ?? null;

        if ($row === null || $row->purpose !== 'invitation' || $row->used_at !== null) {
            throw AuthenticationRefused::linkNoLongerValid();
        }

        return TenantContext::forWrite((int) $row->tenant_id, function () use ($row): CustomerUser {
            return DB::transaction(function () use ($row): CustomerUser {
                $person = CustomerUser::query()->findOrFail($row->customer_user_id);

                $person->forceFill(['email_verified_at' => now()])->save();

                MagicLink::query()->whereKey($row->id)->update(['used_at' => now()]);

                return $person->refresh();
            });
        });
    }
}
```

⚠ `app.magic_link_lookup` fonksiyonunun imzası `ConsumeMagicLink.php:46`'dan alındı — doğrulanmıştır.

- [ ] **5.5 — Bildirimi ve ekranı yaz**

`app/Notifications/CustomerPortalInvitation.php` — `app/Notifications/OfficeInvitation.php`'nin karşılığı (dizin düz, alt klasör yok; `CustomerMagicLink.php` emsali). Metin `lang/tr/notifications.php`'den. Route: `routes/web.php` içine

```php
Route::get('/davet/{token}', [InvitationController::class, 'show'])
    ->middleware('guest:customer')
    ->name('customer.invitation');
```

- [ ] **5.6 — Koş, geçtiğini gör** — PASS (13 test)

- [ ] **5.7 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri && npm run build
git add app/Actions/Office/InviteCustomerPerson.php app/Actions/Auth/AcceptCustomerInvitation.php app/Http/Controllers/Customer/InvitationController.php app/Notifications/CustomerPortalInvitation.php resources/js/pages/Customer/AcceptInvitation.vue routes/web.php lang/tr tests/Feature/Musteri/CustomerInvitationTest.php
git commit -m "#EGT-62 In Progress work 1h

feat(musteri): portal daveti — magic link üzerinden, parola zorlamadan"
```

---

## Görev 6: Satışçı ataması

**Dosyalar:** `app/Actions/Office/AssignCustomersToUser.php` · `app/Queries/Office/AssignableSalespeople.php` · `app/Http/Requests/Office/AssignCustomersRequest.php` · `tests/Feature/Musteri/CustomerAssignmentTest.php` (yeni)

**Üretir:** `AssignCustomersToUser::__invoke(User $actor, User $salesperson, array $customerIds): void`

- [ ] **6.1 — Düşen testi yaz**

```php
it('assigns customers to a salesperson', function (): void {
    $customer = Customer::factory()->create();
    $salesperson = User::factory()->create();
    $actor = userWithPermissions(['user.assign_customers', 'customer.view.all']);

    app(AssignCustomersToUser::class)($actor, $salesperson, [(int) $customer->getKey()]);

    expect($salesperson->customers()->count())->toBe(1);
});

it('refuses to assign a customer the granter cannot reach', function (): void {
    // ⛔ Y-4'ün müşteri tarafındaki karşılığı: kimse elinde olmayanı devretmez.
    $unreachable = Customer::factory()->create();
    $salesperson = User::factory()->create();
    $actor = userWithPermissions(['user.assign_customers']);

    expect(fn () => app(AssignCustomersToUser::class)($actor, $salesperson, [(int) $unreachable->getKey()]))
        ->toThrow(AuthorizationException::class);
});

it('records the change on the audit trail', function (): void {
    $customer = Customer::factory()->create();
    $salesperson = User::factory()->create();
    $actor = userWithPermissions(['user.assign_customers', 'customer.view.all']);

    app(AssignCustomersToUser::class)($actor, $salesperson, [(int) $customer->getKey()]);

    expect(AuditWrite::query()->where('event', 'user.customers_assigned')->exists())->toBeTrue();
});
```

- [ ] **6.2 — Koş, düştüğünü gör**

- [ ] **6.3 — `AssignCustomersToUser` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Office;

use App\Models\User;
use App\Support\Audit\AuditWriter;
use App\Domain\Authorization\ScopeResolver;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\DB;

/**
 * Putting customers on somebody's list (chunk6 §6.5.2).
 *
 * ⛔ Nobody hands on what they do not hold. The granter must be able to reach
 * every customer they are assigning — the same rule `AssignOfficePermissions`
 * applies to permissions (Y-4), applied to reach. Without it, somebody with
 * `user.assign_customers` and no customers of their own could give away every
 * company in the system by guessing ids.
 */
final class AssignCustomersToUser
{
    public function __construct(
        private readonly Gate $gate,
        private readonly ScopeResolver $scope,
    ) {}

    /**
     * @param  list<int>  $customerIds
     */
    public function __invoke(User $actor, User $salesperson, array $customerIds): void
    {
        $this->gate->forUser($actor)->authorize('user.assign_customers');

        $reachable = $this->scope->accessibleTenantIds($actor);
        $beyond = array_diff($customerIds, $reachable);

        if ($beyond !== []) {
            throw new AuthorizationException(__('customers.assignment_errors.beyond_reach'));
        }

        DB::transaction(function () use ($salesperson, $customerIds, $actor): void {
            $before = $salesperson->customers()->pluck('customers.id')->all();

            $salesperson->customers()->sync($customerIds);

            AuditWriter::record(
                event: 'user.customers_assigned',
                subject: $salesperson,
                oldValues: ['customers' => $before],
                newValues: ['customers' => $customerIds, 'by' => $actor->getKey()],
            );
        });
    }
}
```

- [ ] **6.4 — `AssignableSalespeople` ve isteğini yaz**

```php
<?php

declare(strict_types=1);

namespace App\Queries\Office;

use App\Models\User;
use App\Support\Accounts\AccountStatus;
use App\Support\Authorization\PermissionFamilies;
use App\Support\Authorization\PermissionFamily;
use App\Support\Http\OpaqueId;
use Illuminate\Support\Collection;

/**
 * Office people a customer may be handed to.
 *
 * ⚠ Only ACTIVE accounts. Assigning a customer to a frozen or closed account
 * produces a company nobody is looking after, and the screen would show a name
 * that cannot act.
 */
final class AssignableSalespeople
{
    public function __construct(
        private readonly OpaqueId $opaqueId,
        private readonly PermissionFamilies $families,
    ) {}

    /**
     * @return Collection<int, array<string, mixed>>
     */
    public function all(): Collection
    {
        return $this->families->using(PermissionFamily::Office, fn (): Collection => User::query()
            ->where('status', AccountStatus::Active->value)
            ->orderBy('name')
            ->get()
            ->map(fn (User $user): array => [
                'id' => $this->opaqueId->encode('user', (int) $user->getKey()),
                'name' => $user->name,
            ]));
    }
}
```

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Office;

use Illuminate\Foundation\Http\FormRequest;

final class AssignCustomersRequest extends FormRequest
{
    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'customers' => ['present', 'array'],

            // Opak kimlikler gelir; sayıya çevrilmesi controller'ın işidir
            // (chunk6 §6.10 — ham id tarayıcıya hiç gitmedi ki geri gelsin).
            'customers.*' => ['string'],
        ];
    }
}
```

⚠ `present` ama `required` değil: boş liste **geçerli bir girdidir** — bir satışçının bütün müşterilerini geri almak da bir karardır.

- [ ] **6.5 — Koş, geçtiğini gör** — PASS (16 test)

- [ ] **6.6 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri
git add app/Actions/Office/AssignCustomersToUser.php app/Queries/Office/AssignableSalespeople.php app/Http/Requests/Office/AssignCustomersRequest.php tests/Feature/Musteri/CustomerAssignmentTest.php
git commit -m "#EGT-62 In Progress work 40m

feat(musteri): satışçı ataması — kapsam dışına devir engelli"
```

---

## Görev 7: Ekranlar

**Dosyalar:** `app/Http/Controllers/Office/CustomerController.php` · `app/Queries/Office/CustomerDetail.php` · `resources/js/pages/Office/Customers/Index.vue` · `Show.vue` · `resources/js/organisms/CustomerForm.vue` · `CustomerPersonForm.vue` (yeni) · `routes/office.php` · `app/Queries/Office/OfficeNavigation.php` (değişen)

**Tüketir:** Görev 1–6'nın tamamı.

- [ ] **7.1 — Düşen testi yaz**

```php
it('lists customers on the office screen', function (): void {
    $customer = Customer::factory()->create(['name' => 'Ekranda Görünen A.Ş.']);
    $actor = userWhoMayManage($customer, ['customer.update']);

    $this->actingAs($actor, 'office')
        ->get(route('office.customers.index'))
        ->assertInertia(fn (Assert $page) => $page
            ->component('Office/Customers/Index')
            ->where('customers.0.name', 'Ekranda Görünen A.Ş.'));
});

it('keeps a customer out of reach off the screen', function (): void {
    Customer::factory()->create(['name' => 'Kapsam Dışı A.Ş.']);
    $actor = User::factory()->create();

    $this->actingAs($actor, 'office')
        ->get(route('office.customers.index'))
        ->assertForbidden();
});

it('opens a customer from the drawer', function (): void {
    $actor = userWithPermissions(['customer.create']);

    $this->actingAs($actor, 'office')
        ->post(route('office.customers.store'), [
            'name' => 'Drawer A.Ş.',
            'legal_type' => 'legal_entity',
        ])
        ->assertRedirect();

    expect(Customer::query()->where('name', 'Drawer A.Ş.')->exists())->toBeTrue();
});
```

- [ ] **7.2 — Koş, düştüğünü gör**

- [ ] **7.3 — Route'ları ekle**

`routes/office.php`, kullanıcı route'larının hemen ardına:

```php
Route::get('/musteriler', [CustomerController::class, 'index'])->name('office.customers.index');

/*
| Açma formu drawer'dır, sayfa değil — `Office/Users`'ın deseni (ofis-t4-plan
| K-3). GET karşılığı yoktur.
*/
Route::post('/musteriler', [CustomerController::class, 'store'])->name('office.customers.store');

Route::get('/musteriler/{customer}', [CustomerController::class, 'show'])->name('office.customers.show');
Route::put('/musteriler/{customer}', [CustomerController::class, 'update'])->name('office.customers.update');

Route::put('/musteriler/{customer}/durum', [CustomerController::class, 'changeStatus'])
    ->name('office.customers.status');

Route::post('/musteriler/{customer}/kisiler', [CustomerController::class, 'storePerson'])
    ->name('office.customers.people.store');

Route::post('/musteriler/{customer}/kisiler/{person}/davet', [CustomerController::class, 'invite'])
    ->name('office.customers.people.invite');

Route::put('/musteriler/{customer}/satiscilar', [CustomerController::class, 'assignSalespeople'])
    ->name('office.customers.salespeople');
```

- [ ] **7.4 — `CustomerDetail` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Queries\Office;

use App\Models\Customer;
use App\Models\User;
use App\Support\Http\OpaqueId;
use App\Support\Tenancy\TenantContext;

/**
 * One customer, as the office detail screen needs it.
 */
final class CustomerDetail
{
    public function __construct(
        private readonly OpaqueId $opaqueId,
        private readonly CustomerPeople $people,
    ) {}

    /**
     * @return array<string, mixed>
     */
    public function for(Customer $customer): array
    {
        return TenantContext::run([(int) $customer->getKey()], fn (): array => [
            'id' => $this->opaqueId->encode('customer', (int) $customer->getKey()),
            'name' => $customer->name,
            'legal_type' => $customer->legal_type,
            'tax_office' => $customer->tax_office,
            'tax_number' => $customer->tax_number,
            'price_group_id' => $customer->price_group_id,
            'status' => $customer->status,
            'people' => $this->people->for($customer),
            'salespeople' => $customer->salespeople()->orderBy('name')->get()
                ->map(fn (User $user): array => [
                    'id' => $this->opaqueId->encode('user', (int) $user->getKey()),
                    'name' => $user->name,
                ])->all(),
        ]);
    }
}
```

- [ ] **7.5 — `CustomerController` yaz**

`Office/UserController`'ın deseni: policy → query/action → Inertia. Sekiz metot:

```php
public function index(OfficeCustomers $customers): Response
{
    $this->authorize('viewAny', Customer::class);

    return Inertia::render('Office/Customers/Index', [
        'customers' => $customers->visibleTo($this->officeUser()),
        'priceGroups' => PriceGroup::query()->orderBy('name')->get(['id', 'name']),
        'mayCreate' => $this->officeUser()->can('create', Customer::class),
    ]);
}

public function store(CreateCustomerRequest $request, CreateCustomer $create): RedirectResponse
{
    $customer = $create($this->officeUser(), new NewCustomer(
        name: $request->string('name')->toString(),
        legalType: $request->string('legal_type')->toString(),
        taxOffice: $request->input('tax_office'),
        taxNumber: $request->input('tax_number'),
        priceGroupId: $request->integer('price_group_id') ?: null,
    ));

    return to_route('office.customers.show', $this->opaqueId->encode('customer', (int) $customer->getKey()));
}

public function show(string $customer, CustomerDetail $detail, AssignableSalespeople $salespeople): Response
{
    $record = $this->findOrFail($customer);

    $this->authorize('view', $record);

    return Inertia::render('Office/Customers/Show', [
        'customer' => $detail->for($record),
        'assignableSalespeople' => $salespeople->all(),
        'mayUpdate' => $this->officeUser()->can('update', $record),
        'mayDeactivate' => $this->officeUser()->can('deactivate', $record),
    ]);
}
```

Kalan beşi (`update`, `changeStatus`, `storePerson`, `invite`, `assignSalespeople`) aynı üç adımı izler: `findOrFail` → `authorize` → ilgili Action → `back()`.

⛔ **`findOrFail` opak kimliği çözer ve kapsam dışında 404 döner**, 403 değil — `NotFoundOnDenied` (`app/Support/Http/NotFoundOnDenied.php`) tam bunun için var. 403 dönmek *"böyle bir müşteri var ama sana kapalı"* bilgisini sızdırır; müşteri listesi ticari bilgidir.

- [ ] **7.6 — Vue ekranlarını yaz**

| Ekran | Bileşenler | Kaynak desen |
|---|---|---|
| `Customers/Index.vue` | `DataTable` (arama, sıralama, statü rozeti, boş durum, mobil kart dönüşümü) + `SlideOver` içinde `CustomerForm` | `Office/Users/Index.vue` |
| `Customers/Show.vue` | Firma bilgileri kartı (düzenleme drawer'ı) · kişiler tablosu (satır başına "Davet gönder") · satışçılar kartı (`SearchableSelect` çoklu seçim) · durum değiştirme modalı (gerekçe zorunlu) | `Office/Users/Show.vue` |
| `CustomerForm.vue` | Ad, hukuki tür (`segmented control`), vergi dairesi/no, fiyat grubu | `CreateUserForm.vue` |
| `CustomerPersonForm.vue` | Ad, e-posta, telefon (`PhoneInput`), TCKN | `CreateUserForm.vue` |

⛔ Yeni bileşen dili icat edilmez. `HANDOFF.md` **bileşen dilini, semantik rengi, erişilebilirlik ölçütlerini ve sayfa kompozisyonunu** bağlayıcı sayar *(kompozisyon maddesi 30.07.2026'da düzeltildi)*: `DataTable`, `SlideOver`, `SearchableSelect`, `PhoneInput` zaten yazılı ve aynen kullanılır.

⚠ Durum değiştirme modalı `04 Geri Bildirim`'in **gerekçeli destructive modal** desenidir — ofis hesabı dondurmada kullanılan aynı bileşen.

- [ ] **7.7 — Navigasyona ekle**

`OfficeNavigation.php` içine, "Kullanıcılar"ın yanına:

```php
[
    'label' => __('navigation.customers'),
    'route' => 'office.customers.index',
    'visible' => $user->can('viewAny', Customer::class),
],
```

- [ ] **7.8 — Koş, geçtiğini gör** — PASS (19 test) · `npm run build` temiz

- [ ] **7.9 — Kalite kapısı ve commit**

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=musteri && npm run build
git add app/Http/Controllers/Office/CustomerController.php app/Queries/Office/CustomerDetail.php resources/js routes/office.php app/Queries/Office/OfficeNavigation.php lang/tr tests/Feature/Musteri
git commit -m "#EGT-62 In Progress work 1h 30m

feat(musteri): ofis müşteri ekranları — liste, detay, kişiler, atama"
```

---

## Görev 8: Kapanış

- [ ] **8.1 — Wire-up doğrulaması (onay kuralı 4)**

Her yeni public symbol'ün production yolunda çağıranı var mı — reverse-grep ile:

```bash
php artisan route:list | grep musteri
grep -rn "CreateCustomer\|UpdateCustomer\|ChangeCustomerStatus\|CreateCustomerPerson\|InviteCustomerPerson\|AssignCustomersToUser" app/Http/
grep -rn "customers.index" app/Queries/Office/OfficeNavigation.php
```

Beklenen: sekiz route, altı action'ın hepsi controller'dan çağrılıyor, menü kalemi kayıtlı.

- [ ] **8.2 — Grup koşuları**

```bash
./vendor/bin/pest --group=musteri
./vendor/bin/pest --group=omurga
./vendor/bin/pest --group=backoffice
```

⚠ `omurga` ve `backoffice` de koşulur: `User` modeline ilişki eklendi ve navigasyon değişti — ikisi de o grupların kapsamında.

⚠ Lokal **tam** koşu yapılmaz (`omurga-rls` bağlantı yuvası tükenmesinden düşer, ölçüldü).

- [ ] **8.3 — Proje haritasını güncelle**

`proje-haritasi.md` §4.7 müşteri portalı bölümü: "ofis tarafında müşteri ekranı ✗" satırı ✓ olur, davet akışı satırı kapanır, yeni dosyalar veri/ekran satırlarına yazılır.

- [ ] **8.4 — Ticket kapanışı**

```bash
./deployment/scripts/teamcity.sh trigger
./deployment/scripts/teamcity.sh wait <id>
```

⛔ Yeşil full run olmadan ticket `Fixed`'e çekilmez.

---

## Kabul kriterleri

| # | Kriter | Görev |
|---|---|---|
| M-1 | `customer.view.all` taşımayan kullanıcı yalnız **atandığı** müşterileri görür; başkasının adı listede geçmez | 1 |
| M-2 | Kapsam dışı bir müşteriye erişim **404** döner, 403 değil — varlığı sızdırılmaz | 7 |
| M-3 | `customer.create` olmadan müşteri açılamaz | 2 |
| M-4 | Müşteri açma audit satırı işlemle **aynı transaction'da** yazılır | 2 |
| M-5 | Müşteri güncelleme kendi tenant bağlamında koşar; bağlamsız güncelleme sessizce sıfır satır etkilemez | 3 |
| M-6 | Pasifleştirme **gerekçesiz** tamamlanamaz; gerekçe audit satırında durur | 3 |
| M-7 | Müşteri hiçbir yoldan silinemez (DELETE politikası yok) | 3 |
| M-8 | Kişi eklenirken e-posta/telefon/TCKN şifrelenir, `email_hash` 64 karakter, model açık değeri döndürür | 4 |
| M-9 | TCKN ofis ekranında **maskeli** gelir | 4 |
| M-10 | Aynı firmada aynı e-posta iki kez kaydedilemez | 4 |
| M-11 | Davet parola **belirletmez**; magic link `purpose: 'invitation'` taşır | 5 |
| M-12 | Davet takip edilince adres kanıtlanmış olur; panel yetkisi **kendiliğinden gelmez** | 5 |
| M-13 | Kullanılmış davet ikinci kez çalışmaz | 5 |
| M-14 | Kimse kendi kapsamı dışındaki müşteriyi başkasına atayamaz | 6 |
| M-15 | Atama değişikliği audit'e yazılır | 6 |
| M-16 | Menüde "Müşteriler" yalnız `viewAny` geçen kullanıcıya görünür | 7 |

---

## Kapanış adresi verilen, bu turda yapılmayanlar

| Ne | Nerede |
|---|---|
| Müşterinin kendi organizasyonu (departman/grup ağacı, grup yöneticisi) | Müşteri portalı turu — çatı §60-62, tasarım `11` |
| Lokasyonlar | Aynı tur — tasarım `12` |
| Müşteri kullanıcısına **panel yetkisi** verme ekranı | Aynı tur; `customer_roles`/`customer_permissions` tabloları chunk 5'te hazır |
| Satışçı müşteri kartı (CRM, görevler, fırsat sinyalleri) | Satış turu — tasarım `32`, EGT-26 |
| Müşteri belgeleri ekranı | `customer_documents` hazır; evrak turu |
| TCKN'yi açık görüntüleme eylemi + erişim logu satırı | Bu turda maskeli gösterim var; açık görüntüleme ayrı bir eylem olarak evrak/KVKK turunda |

---

## Revizyon geçmişi

| Sürüm | Tarih | Değişiklik |
|---|---|---|
| v1.0 | 29.07.2026 | İlk sürüm. 8 görev, 16 kabul kriteri. Mevcut durum koddan doğrulandı. |
