# Planlama T1 — eğitim kaydı ve yaşam döngüsü

*29.07.2026 · Spec: `docs/specs/planlama-spec.md` v1.1 (heyet 165 APPROVED) · Önkoşul: `docs/plans/musteri-yonetimi-plan.md` (EGT-62) — kapalı eğitim firmaya bağlanır*

**Hedef:** planlamacının bir eğitimi baştan sona yönetebilmesi — katalogdan planlama, kapalı/açık görünürlük, kontenjan, yedek liste, talep kuyruğu, Bakanlık bildirimi, iptal.

**Mimari:** Katalog (`training_definitions`) chunk 10'da doğdu; bu tur ondan doğan **olgu** kaydını (`trainings`) ve yaşam döngüsünü kurar. Kontenjan sayaçları kayıt üzerinde denormalize tutulur ve sert sınır veritabanı kısıtıyla korunur. Ofis tarafı Inertia + Vue.

**Stack:** Laravel 13 · PHP 8.5 · PostgreSQL (RLS + CHECK kısıtı + satır kilidi) · Inertia + Vue 3 · Pest

⚠ **Bu turun kapsamı dışı, T2'ye adresli:** eğitmen ataması zinciri, yetkinlik bağları, maliyet kalemleri, donmuş kanıt seti, tedarikçiler, takvim ekranı. Gerekçe §"Neden iki tur" bölümünde.

---

## Neden iki tur

Spec 43 kabul kriteri taşıyor ve iki farklı şeyi tarif ediyor: **eğitimin kendisi** (doğuşu, statüsü, kontenjanı, bildirimi, iptali) ve **eğitime bağlananlar** (eğitmen, maliyet, tedarikçi, kanıt).

Birincisi tek başına çalışan bir üründür: planlamacı eğitim planlar, satışa açar, Bakanlığa bildirir, kontenjanı tutar, iptal eder. İkincisi onun üstüne oturur ve `egitmen-spec.md` §6-7'nin tamamını gerektirir.

⚠ Tek turda gitmek, on bir tablonun aynı anda doğması ve hiçbirinin bağımsız doğrulanamaması demekti. T1 bittiğinde ekranda gerçek bir akış olur; T2 ona eğitmen ve para ekler.

| Spec bölümü | Tur |
|---|---|
| §1 katalogdan doğuş · §1.1 yer · §1.2 mevzuat sürümü · §1.3 eğitim evrakları · §1.4 biçim | T1 |
| §2 görünürlük ve müşteri bağı · §2.3 kim görür | T1 |
| §3 PL→EG kodu · §4 statü makinesi (§4.4 abandoned dahil) | T1 |
| §5 kontenjan · §6 yedek liste · §7 talep kuyruğu | T1 |
| §8.1 mevzuat uyarıları · §10 Bakanlık bildirimi · §11 iptal | T1 |
| §8 eğitmen ataması ve maliyet bağları · §8.2 çakışma | **T2** |
| §9 yetkinlik kapısı ve donmuş kanıt (§9.1–9.6) | **T2** |
| §12 tedarikçiler | **T2** |
| §13 izinler | T1 (tamamı) — kullanıcısı T2'de gelen izinler dahil, tek migration |
| §14 ekranlar | T1: kokpit, sorgulama+kuyruk, sihirbaz, detay · **T2**: takvim + tedarikçiler |

---

## Doğrulanmış mevcut durum

⚠ Koddan okundu, bellekten değil.

| Ne | Durum | Kanıt |
|---|---|---|
| Eğitim kataloğu | ✓ `training_definitions` — `max_participants`, `min_participants`, `notification_type`, `notification_days`, `record_retention_years`, `regulation_id` | `2026_08_02_000100:70-147` |
| Müfredat sürümleme | ✓ `training_curricula` (`effective_from` benzersiz) | aynı migration:157-181 |
| Mevzuat sürümü | ✓ `regulation_versions` — sayılar burada da var | `2026_08_01_000100` |
| Referans kod üretimi | ✓ `ReferenceCodes::issue(string $prefix, int $year, ?Model $subject): ReferenceCode` | `app/Support/Shared/ReferenceCodes.php:45` |
| Kod formatı | ✓ `App\Domain\Shared\ReferenceCode::generate(string $prefix, int $year): string`; `FORBIDDEN = '01OILAEU'` | `app/Domain/Shared/ReferenceCode.php:37,69` |
| **Ön ek registry'si** | ❌ **YOK** — ön ek çağrı yerinde string olarak veriliyor. AD-19 *"prefix'ler kodda tek bir merkezi kayıtta tanımlanır"* diyor | `grep -rn "enum ReferencePrefix"` → boş |
| Katılımcı kaydı | ✓ `training_participants` — ⚠ `training_id` **FK'sız** | `2026_07_27_000200:313-326` |
| Görev sistemi | ✓ `tasks` + `OpenTask`, `ResolveTasks`, `MyTasks` | `2026_08_13_000300`, `app/Actions/Office/` |
| Yönetilen listeler | ✓ `option_lists` + `option_list_values`; **il listesi yok** | `2026_08_04_000100`, `OptionListSeeder.php` |
| Ayarlar | ✓ `settings` tablosu + `App\Support\Business\Settings` | `2026_07_28_080000` |
| Tenant bağlamı | ✓ `TenantContext::{run,forWrite}` | `app/Support/Tenancy/TenantContext.php:45,79` |
| **`trainings` tablosu** | ❌ YOK | — |

---

## Global kısıtlar

- **Dil:** kod İngilizce, arayüz Türkçe (`lang/tr/`).
- **Test grubu:** `planlama` (AD-26).
- **Çıplak `now()` yasak** — `Tests\FrozenClock::INSTANT`.
- **Kod yerleşimi (AD-25):** okuma `app/Queries/Office/`, yazma `app/Actions/Office/`, doğrulama `app/Http/Requests/Office/`, saf kural `app/Domain/`.
- **Hiçbir mevzuat sayısı koda yazılmaz** (kırmızı çizgi 14) — hepsi `training_definitions`'dan veya `settings`'ten okunur.
- **Audit** işlemle aynı transaction'da (AD-06).
- **Kritik kayıt silinmez** (AD-11) — iptal ve vazgeçme statüdür.
- **`OpaqueId`** — ham id tarayıcıya gitmez.
- **Kalite kapısı:** `pint --dirty` → `composer analyse` → `pest --group=planlama` → commit.

---

## Görev 1: Referans kod ön ek registry'si ve PL→EG geçişi

**Dosyalar:** `app/Domain/Shared/ReferencePrefix.php` (yeni) · `app/Support/Shared/ReferenceCodes.php` (değişen) · `tests/Feature/Planlama/ReferencePrefixTest.php` (yeni)

**Üretir:** `ReferencePrefix` enum · `ReferenceCodes::transfer(ReferenceCode $code, ReferencePrefix $to): ReferenceCode`

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

```php
<?php

declare(strict_types=1);

use App\Domain\Shared\ReferencePrefix;
use App\Support\Shared\ReferenceCodes;

it('keeps the random suffix when a plan becomes a training', function (): void {
    $plan = app(ReferenceCodes::class)->issue(ReferencePrefix::Plan->value, 2026);

    $training = app(ReferenceCodes::class)->transfer($plan, ReferencePrefix::Training);

    $suffix = fn (string $code): string => explode('-', $code)[2];

    expect($suffix($training->code))->toBe($suffix($plan->code))
        ->and($training->code)->toStartWith('EG-')
        ->and($plan->code)->toStartWith('PL-');
});

it('leaves the old code alive so it still points at the record', function (): void {
    // ⛔ AD-19: eski kod ölmez, yönlendirir. `pl-2026-7k4f@` adresine
    // dönüşümden sonra gelen bir acente cevabı aynı kayda düşmelidir.
    $plan = app(ReferenceCodes::class)->issue(ReferencePrefix::Plan->value, 2026);

    app(ReferenceCodes::class)->transfer($plan, ReferencePrefix::Training);

    expect(App\Models\ReferenceCode::query()->where('code', $plan->code)->exists())->toBeTrue();
});

it('refuses a prefix that is not in the registry', function (): void {
    expect(fn () => ReferencePrefix::from('XX'))->toThrow(ValueError::class);
});
```

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

`./vendor/bin/pest --group=planlama` → `Class "App\Domain\Shared\ReferencePrefix" does not exist`

- [ ] **1.3 — `ReferencePrefix` yaz**

```php
<?php

declare(strict_types=1);

namespace App\Domain\Shared;

/**
 * The public reference code prefixes (AD-19).
 *
 * ⛔ Two letters, never three: three collides with YouTrack ticket codes
 * (`EGT-123`) in every conversation where both appear.
 *
 * ⚠ AD-19 asks for exactly this — one central registry rather than strings at
 * call sites. Scattered literals produce prefix collisions that nothing
 * catches: the second `TK` is only discovered when two unrelated records
 * answer the same address.
 *
 * ⛔ `PL` is RESERVED and given to no other record type. It exists so a
 * planning stage can carry its own code and hand the suffix to the training
 * it becomes (§3 of the planning spec).
 */
enum ReferencePrefix: string
{
    case Plan = 'PL';
    case Training = 'EG';
    case Quote = 'TK';
    case Invoice = 'FT';

    public function label(): string
    {
        return __('references.prefixes.'.$this->value);
    }
}
```

- [ ] **1.4 — `transfer` metodunu yaz**

`app/Support/Shared/ReferenceCodes.php` içine:

```php
/**
 * Moves a code to another prefix, KEEPING the random suffix (AD-19's single
 * exception).
 *
 * ⚠ A new row rather than an update, and that is the whole point: the old code
 * must keep answering. Somebody outside holds `PL-2026-7K4F` — an agency, a
 * hotel — and their reply has to reach the same record after the plan became a
 * training.
 *
 * ⚠ Uniqueness lives in `(year, suffix)` space, not `(prefix, year, suffix)`:
 * issuing `PL-2026-7K4F` must already reserve `EG-2026-7K4F`, otherwise an
 * independently generated training code could take the target of a plan that
 * has not been approved yet — and the transfer would have nowhere to go.
 */
public function transfer(ReferenceCode $code, ReferencePrefix $to): ReferenceCode
{
    $suffix = Str::afterLast($code->code, '-');

    return ReferenceCode::query()->create([
        'code' => sprintf('%s-%d-%s', $to->value, $code->year, $suffix),
        'prefix' => $to->value,
        'year' => $code->year,
        'subject_type' => $code->subject_type,
        'subject_id' => $code->subject_id,
    ]);
}
```

⚠ `issue()` üretirken çakışma kontrolünün `(year, suffix)` uzayında yapıldığı **doğrulanacak**; `ReferenceCode::generate()` bugün yalnız `code` benzersizliğine bakıyorsa kontrol genişletilir ve bu görevin testine bir vaka eklenir:

```php
it('reserves the same suffix across prefixes', function (): void {
    $plan = app(ReferenceCodes::class)->issue(ReferencePrefix::Plan->value, 2026);
    $suffix = explode('-', $plan->code)[2];

    $codes = collect(range(1, 200))
        ->map(fn (): string => app(ReferenceCodes::class)->issue(ReferencePrefix::Training->value, 2026)->code)
        ->map(fn (string $code): string => explode('-', $code)[2]);

    expect($codes)->not->toContain($suffix);
});
```

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

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

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=planlama
git add app/Domain/Shared/ReferencePrefix.php app/Support/Shared/ReferenceCodes.php tests/Feature/Planlama/ReferencePrefixTest.php
git commit -m "#EGT-63 In Progress work 40m

feat(planlama): referans kod ön ek registry'si ve PL→EG geçişi"
```

---

## Görev 2: `trainings` tablosu

**Dosyalar:** `database/migrations/2026_08_14_000100_create_trainings.php` · `app/Models/Training.php` · `app/Support/Trainings/TrainingStatus.php` · `app/Support/Trainings/TrainingVisibility.php` · `app/Support/Trainings/DeliveryMode.php` (yeni)

**Üretir:** `Training` modeli · üç enum

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

```php
it('refuses a training whose confirmed seats exceed its capacity', function (): void {
    // ⛔ Kontenjan kilidi uygulama katmanında değil, veritabanında.
    $training = Training::factory()->create(['capacity' => 10, 'confirmed_seats' => 10]);

    expect(fn () => DB::table('trainings')->where('id', $training->getKey())
        ->update(['confirmed_seats' => 11]))->toThrow(QueryException::class);
});

it('lets pending seats exceed the capacity', function (): void {
    // ⚠ Bekleyen teklif bloklamaz, onay bloklar (spec §5, egitmen-spec §6.5).
    $training = Training::factory()->create(['capacity' => 10]);

    DB::table('trainings')->where('id', $training->getKey())->update(['pending_seats' => 25]);

    expect($training->fresh()->pending_seats)->toBe(25);
});

it('refuses to delete a training at the database', function (): void {
    $training = Training::factory()->create();

    expect(fn () => $training->delete())->toThrow(QueryException::class);
});
```

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

- [ ] **2.3 — Enum'ları yaz**

```php
<?php

declare(strict_types=1);

namespace App\Support\Trainings;

/**
 * Where a training is in its life (planlama-spec §4).
 *
 * ⚠ The ministry step is TWO statuses, not one (AD-15): notification and
 * approval are different regimes, and which applies comes from the training
 * definition's `notification_type`. A single "told the ministry" status would
 * make an unapproved DGY training indistinguishable from a notified IMDG one —
 * and the first is invalid while the second is fine.
 */
enum TrainingStatus: string
{
    case Draft = 'draft';
    case Abandoned = 'abandoned';
    case Planned = 'planned';
    case MinistryNotified = 'ministry_notified';
    case MinistryApproved = 'ministry_approved';
    case InProgress = 'in_progress';
    case Completed = 'completed';
    case CancellationRequested = 'cancellation_requested';
    case Cancelled = 'cancelled';

    /** @return list<self> */
    public function next(): array
    {
        return match ($this) {
            self::Draft => [self::Planned, self::Abandoned],
            self::Planned => [self::MinistryNotified, self::MinistryApproved, self::Draft, self::CancellationRequested],
            self::MinistryNotified, self::MinistryApproved => [self::InProgress, self::CancellationRequested],
            self::InProgress => [self::Completed, self::CancellationRequested],
            self::CancellationRequested => [self::Cancelled],
            self::Abandoned, self::Completed, self::Cancelled => [],
        };
    }

    public function isFinal(): bool
    {
        return $this->next() === [];
    }
}
```

```php
<?php

declare(strict_types=1);

namespace App\Support\Trainings;

/**
 * Who a training is for (planlama-spec §2).
 *
 * ⚠ Kept even when the customer list is empty. An empty list alone cannot tell
 * an open training from a closed one whose companies have not been chosen yet.
 */
enum TrainingVisibility: string
{
    case Closed = 'closed';
    case Open = 'open';
}
```

```php
<?php

declare(strict_types=1);

namespace App\Support\Trainings;

/**
 * How a training is delivered (planlama-spec §1.4).
 *
 * ⚠ Information only for now, by decision (Sets, 29.07.2026). No behaviour
 * branches on it until the regulation has been read: whether online delivery is
 * permitted, and under what conditions, is not written anywhere we have
 * verified.
 */
enum DeliveryMode: string
{
    case InPerson = 'in_person';
    case Online = 'online';
    case Hybrid = 'hybrid';
}
```

- [ ] **2.4 — Migration'ı yaz**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
 * A planned training — the catalogue definition, once (planlama-spec §1).
 *
 * ── Not the catalogue ──────────────────────────────────────────────────────
 *
 * `training_definitions` says "an IMDG task 4 training exists and is like
 * this". This says "on 3 September 2026, in İzmit, for ABC Lojistik, with
 * Mehmet, for 18 people". The first is a rule and the second is a fact;
 * instructor assignment, cost, correspondence, ministry notification and
 * cancellation all hang off the fact.
 *
 * ── Not tenant scoped ──────────────────────────────────────────────────────
 *
 * ⚠ A training belongs to 3E, not to a customer — an open training has no
 * customer at all and a multi-company one has several. Which companies may see
 * it is `training_customers` plus policy (spec §2.3), not row security on this
 * table. It is therefore listed in `config/tenancy.php` exempt_tables and the
 * count assertion in `TenancyTest` is updated with it.
 *
 * ── The capacity constraint is here, not in the application ────────────────
 *
 * ⛔ Two quotes approved in the same second both pass an application level
 * "is it full" check. `confirmed_seats <= capacity` is a CHECK constraint so
 * the second one fails at the database. Exceeding capacity is not merely a bug
 * here: it produces a training that breaches Y-2016 md.13/ç.
 *
 * ⚠ `pending_seats` is deliberately OUTSIDE that constraint. A pending quote
 * blocks nothing; only approval does (spec §5, egitmen-spec §6.5).
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('trainings', function (Blueprint $table): void {
            $table->bigIncrements('id');

            $table->unsignedBigInteger('training_definition_id');
            $table->foreign('training_definition_id')->references('id')->on('training_definitions')->restrictOnDelete();

            /*
             * Which version of the rules this was given under (çatı §16,
             * spec §1.2). ⚠ Derived at planning time and then FROZEN: a
             * circular published next year updates the definition, and the
             * question "under which rule was last year's training given" has to
             * survive that.
             */
            $table->unsignedBigInteger('regulation_version_id')->nullable();
            $table->foreign('regulation_version_id')->references('id')->on('regulation_versions')->restrictOnDelete();

            $table->unsignedBigInteger('training_curriculum_id')->nullable();
            $table->foreign('training_curriculum_id')->references('id')->on('training_curricula')->restrictOnDelete();

            $table->string('status', 32)->default('draft');
            $table->string('visibility', 16)->default('closed');
            $table->string('delivery_mode', 16)->default('in_person');

            $table->date('starts_on');
            $table->date('ends_on');

            // İl yönetilen listeden, adres serbest metin (spec §1.1).
            $table->unsignedBigInteger('city_option_value_id')->nullable();
            $table->foreign('city_option_value_id')->references('id')->on('option_list_values')->nullOnDelete();
            $table->text('address')->nullable();

            $table->unsignedSmallInteger('capacity');
            $table->unsignedSmallInteger('confirmed_seats')->default(0);
            $table->unsignedSmallInteger('pending_seats')->default(0);
            $table->unsignedSmallInteger('named_seats')->default(0);

            $table->unsignedBigInteger('created_by')->nullable();
            $table->foreign('created_by')->references('id')->on('users')->nullOnDelete();

            $table->text('notes')->nullable();

            $table->timestampTz('planned_at', precision: 6)->nullable();
            $table->timestampTz('cancelled_at', precision: 6)->nullable();
            $table->text('cancellation_reason')->nullable();

            $table->timestampsTz(precision: 6);

            $table->index(['status', 'starts_on']);
            $table->index(['training_definition_id', 'starts_on']);
            $table->index('city_option_value_id');
        });

        DB::statement('ALTER TABLE trainings ADD CONSTRAINT trainings_seats_within_capacity CHECK (confirmed_seats <= capacity)');
        DB::statement('ALTER TABLE trainings ADD CONSTRAINT trainings_named_within_confirmed CHECK (named_seats <= confirmed_seats)');
        DB::statement('ALTER TABLE trainings ADD CONSTRAINT trainings_dates_ordered CHECK (ends_on >= starts_on)');

        // ⛔ AD-11: a training is cancelled or abandoned, never deleted. Every
        // ministry notification and participant record naming it would lose its
        // subject.
        DB::statement(<<<'SQL'
            CREATE OR REPLACE FUNCTION app.deny_training_delete() RETURNS trigger
            LANGUAGE plpgsql AS $$
            BEGIN
              RAISE EXCEPTION 'trainings cannot be deleted: training %', OLD.id
                USING ERRCODE = '42501';
            END $$
        SQL);

        DB::statement('CREATE TRIGGER trainings_deny_delete BEFORE DELETE ON trainings FOR EACH ROW EXECUTE FUNCTION app.deny_training_delete()');
    }

    public function down(): void
    {
        DB::statement('DROP TRIGGER IF EXISTS trainings_deny_delete ON trainings');
        DB::statement('DROP FUNCTION IF EXISTS app.deny_training_delete()');
        Schema::dropIfExists('trainings');
    }
};
```

- [ ] **2.5 — `config/tenancy.php` muafiyet listesine ekle**

`trainings` bağlamsız bir tablodur; listeye eklenmezse arch testi (`TenancyTest` tam sayı doğrular) kırılır.

- [ ] **2.6 — `Training` modelini ve fabrikasını yaz**

Model: `Auditable`, enum cast'leri, `definition()`, `customers()`, `city()` ilişkileri. Fabrika: geçerli bir `training_definition_id`, `capacity: 20`, `starts_on`/`ends_on` frozen clock'tan.

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

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

```bash
./vendor/bin/pint --dirty && composer analyse && ./vendor/bin/pest --group=planlama && ./vendor/bin/pest --group=omurga
git add database/migrations app/Models/Training.php app/Support/Trainings config/tenancy.php database/factories tests/Feature/Planlama
git commit -m "#EGT-63 In Progress work 1h 15m

feat(planlama): trainings tablosu, statü/görünürlük/biçim enum'ları"
```

⚠ `omurga` grubu da koşulur: `config/tenancy.php` değişti ve muafiyet sayısı orada doğrulanıyor.

---

## Görev 3: Müşteri bağı ve görünürlük

**Dosyalar:** `database/migrations/2026_08_14_000200_create_training_customers.php` · `app/Policies/TrainingPolicy.php` · `app/Queries/Office/VisibleTrainings.php` (yeni)

**Üretir:** `TrainingPolicy::{viewAny,view,create,update,approve,cancel,cancelApprove,notifyMinistry}` · `VisibleTrainings::forOffice(User $viewer): Collection`

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

```php
it('hides a closed training from a salesperson who is not on its companies', function (): void {
    // ⛔ §2.1'in gerekçesinin testi: "tüm satışçılara düşmesin".
    $customer = Customer::factory()->create();
    $training = Training::factory()->create(['visibility' => 'closed']);
    $training->customers()->attach($customer->getKey());

    $stranger = userWithPermissions(['training.view.group']);

    expect(app(VisibleTrainings::class)->forOffice($stranger)->pluck('id'))
        ->not->toContain($training->reference_code);
});

it('shows a closed training to a salesperson assigned to its company', function (): void {
    $customer = Customer::factory()->create();
    $training = Training::factory()->create(['visibility' => 'closed']);
    $training->customers()->attach($customer->getKey());

    $salesperson = userWithPermissions(['training.view.group']);
    $salesperson->customers()->attach($customer->getKey());

    expect(app(VisibleTrainings::class)->forOffice($salesperson->fresh())->count())->toBe(1);
});

it('shows an open training to anybody holding the permission', function (): void {
    Training::factory()->create(['visibility' => 'open']);

    $viewer = userWithPermissions(['training.view.all']);

    expect(app(VisibleTrainings::class)->forOffice($viewer)->count())->toBe(1);
});
```

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

- [ ] **3.3 — Migration'ı yaz**

```php
Schema::create('training_customers', function (Blueprint $table): void {
    $table->unsignedBigInteger('training_id');
    $table->unsignedBigInteger('tenant_id');
    $table->timestampsTz(precision: 6);

    $table->foreign('training_id')->references('id')->on('trainings')->cascadeOnDelete();
    $table->foreign('tenant_id')->references('id')->on('customers')->restrictOnDelete();

    $table->primary(['training_id', 'tenant_id']);
    $table->index('tenant_id');
});
```

⚠ `cascadeOnDelete` eğitim tarafında zararsızdır çünkü eğitim silinemez (Görev 2 tetikleyicisi); müşteri tarafında `restrictOnDelete` çünkü müşteri de silinmez.

⚠ Bu tablo **tenant-kapsamlı değildir** — bir eğitimin hangi firmalara ait olduğu ofisin bilgisidir. Müşteri tarafındaki görünürlük (bir firmanın diğerini görmemesi, spec §2.3) **sorgu katmanında** kurulur: müşteri portalı kendi satırından başkasını hiç çekmez. `config/tenancy.php` muafiyet listesine eklenir.

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

```php
<?php

declare(strict_types=1);

namespace App\Policies;

use App\Domain\Authorization\ScopeResolver as TenantScope;
use App\Models\Training;
use App\Models\User;
use App\Support\Trainings\TrainingVisibility;

/**
 * Who may see and act on a planned training (planlama-spec §2.3, §13).
 *
 * ⚠ Two scopes meet here and they are different questions. AD-23's group scope
 * answers "whose work may this person touch"; the tenant scope answers "which
 * companies may they reach". A closed training needs BOTH: the permission, and
 * a reach that includes at least one of its companies.
 */
final class TrainingPolicy
{
    public function __construct(private readonly TenantScope $tenants) {}

    public function view(User $user, Training $training): bool
    {
        if (! $user->can('training.view.own') && ! $user->can('training.view.group') && ! $user->can('training.view.all')) {
            return false;
        }

        if ($training->visibility === TrainingVisibility::Open) {
            return true;
        }

        // ⛔ Kapalı eğitim: firmalarından en az birine erişimi olmalı.
        $reach = $this->tenants->accessibleTenantIds($user);

        return $training->customers()->whereIn('customers.id', $reach)->exists();
    }

    public function create(User $user): bool
    {
        return $user->can('training.create.group') || $user->can('training.create.all');
    }

    public function update(User $user, Training $training): bool
    {
        return $user->can('training.update.group') || $user->can('training.update.all')
            ? $this->view($user, $training)
            : false;
    }

    public function approve(User $user, Training $training): bool
    {
        return ($user->can('training.approve.group') || $user->can('training.approve.all'))
            && $this->view($user, $training);
    }

    public function cancel(User $user, Training $training): bool
    {
        return ($user->can('training.cancel.group') || $user->can('training.cancel.all'))
            && $this->view($user, $training);
    }

    public function cancelApprove(User $user, Training $training): bool
    {
        return ($user->can('training.cancel_approve.group') || $user->can('training.cancel_approve.all'))
            && $this->view($user, $training);
    }

    public function notifyMinistry(User $user, Training $training): bool
    {
        return ($user->can('training.notify_ministry.group') || $user->can('training.notify_ministry.all'))
            && $this->view($user, $training);
    }
}
```

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

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

---

## Görev 4: İzin vokabüleri ve il listesi

**Dosyalar:** `database/migrations/2026_08_14_000300_add_planning_permissions.php` · `config/authorization.php` · `app/Support/Authorization/PermissionMatrix.php` · `database/seeders/OptionListSeeder.php` (değişen)

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

```php
it('grants every planning permission in exactly one matrix cell', function (): void {
    // ⚠ `PermissionMatrixTest` iki yönü de kapatıyor; bu test yeni satırların
    // gerçekten matrise girdiğini sınar — config'de olup hücresi olmayan izin
    // ungrantable kalır ve bunu söyleyen hiçbir şey olmaz.
    $names = collect(config('authorization.families.office'))
        ->filter(fn (string $name): bool => str_starts_with($name, 'training.'));

    expect($names)->not->toBeEmpty();

    foreach ($names as $name) {
        expect(PermissionMatrix::cellFor($name))->not->toBeNull();
    }
});

it('offers the province list to the planner', function (): void {
    $this->seed(OptionListSeeder::class);

    expect(OptionList::query()->where('code', OptionList::PROVINCE)->exists())->toBeTrue();
});
```

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

- [ ] **4.3 — İzinleri ekle**

`config/authorization.php` ofis ailesine (spec §13, hepsi AD-23 kapsamlı):

```php
'training.view.own', 'training.view.group', 'training.view.all',
'training.create.group', 'training.create.all',
'training.update.own', 'training.update.group', 'training.update.all',
'training.approve.group', 'training.approve.all',
'training.cancel.own', 'training.cancel.group', 'training.cancel.all',
'training.cancel_approve.group', 'training.cancel_approve.all',
'training.assign_instructor.group', 'training.assign_instructor.all',
'training.notify_ministry.group', 'training.notify_ministry.all',
'training_request.view.own', 'training_request.view.group', 'training_request.view.all',
'supplier.view', 'supplier.create', 'supplier.update',
```

⚠ `training.create`'in `.own` değeri **yoktur** — kendisi için eğitim planlamak diye bir şey yoktur (T4'te `user.create` için kaydedilen aynı gerekçe).

⚠ `supplier.*` kapsamsızdır: tedarikçi listesi 3E'nin ortak verisidir, "kimin tedarikçisi" diye bir soru yoktur.

⚠ T2'nin kullanacağı izinler (`assign_instructor`, `supplier.*`) **bu turda** ekleniyor: tek migration, tek matris güncellemesi. Backbone teslimi — caller'ı T2'de gelir (onay kuralı 4'ün istisnası).

- [ ] **4.4 — `PermissionMatrix`'e satırları ekle**

`OFFICE_SCOPES` ve satır tanımları; `training` ve `training_request` kaynakları, `supplier` kaynağı kapsamsız.

- [ ] **4.5 — İl listesini seed et**

`OptionList::PROVINCE = 'IL'` sabiti + 81 il. ⚠ Değerler seeder'da, koda gömülü karar değil — liste backoffice'ten yönetilir (AD-08).

- [ ] **4.6 — Koş, geçtiğini gör** · **4.7 — Kalite kapısı ve commit**

---

## Görev 5: Eğitim açma ve taslak yaşamı

**Dosyalar:** `app/Actions/Office/PlanTraining.php` · `app/Actions/Office/UpdateTrainingPlan.php` · `app/Actions/Office/AbandonTraining.php` · `app/Data/Office/NewTraining.php` · `app/Http/Requests/Office/PlanTrainingRequest.php` (yeni)

**Üretir:** `PlanTraining::__invoke(User $actor, NewTraining $input): Training`

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

```php
it('opens a training as a draft carrying a PL code', function (): void {
    $definition = TrainingDefinition::factory()->create(['max_participants' => 25]);
    $actor = userWithPermissions(['training.create.all']);

    $training = app(PlanTraining::class)($actor, new NewTraining(
        definitionId: (int) $definition->getKey(),
        startsOn: '2026-09-03',
        endsOn: '2026-09-04',
        capacity: 18,
        visibility: TrainingVisibility::Closed,
        customerIds: [],
    ));

    expect($training->status)->toBe(TrainingStatus::Draft)
        ->and($training->reference_code)->toStartWith('PL-');
});

it('refuses a capacity above the definition maximum', function (): void {
    // ⛔ Sert sınır (spec §5.1): 30 kişiyi geçen IMDG semineri Y-2016 md.13/ç'ye
    // aykırıdır ve onaya götürmek işe yaramaz — onaylayan taraf İdare değildir.
    $definition = TrainingDefinition::factory()->create(['max_participants' => 25]);
    $actor = userWithPermissions(['training.create.all']);

    expect(fn (): Training => app(PlanTraining::class)($actor, new NewTraining(
        definitionId: (int) $definition->getKey(),
        startsOn: '2026-09-03',
        endsOn: '2026-09-04',
        capacity: 26,
        visibility: TrainingVisibility::Closed,
        customerIds: [],
    )))->toThrow(ValidationException::class);
});

it('falls back to the house ceiling when the definition sets none', function (): void {
    // ⚠ DGY ailesinde mevzuat üst sınır koymaz (mevzuat-kurallari §2).
    // 3E'nin kendi tavanı uygulanır ve o tavan AŞILABİLİR — uyarı üretir,
    // onaya gider (spec §5.1, §8.1).
    $definition = TrainingDefinition::factory()->create(['max_participants' => null]);
    $actor = userWithPermissions(['training.create.all']);

    $training = app(PlanTraining::class)($actor, new NewTraining(
        definitionId: (int) $definition->getKey(),
        startsOn: '2026-09-03',
        endsOn: '2026-09-04',
        capacity: 200,
        visibility: TrainingVisibility::Open,
        customerIds: [],
    ));

    expect($training->exists)->toBeTrue()
        ->and($training->warnings)->toContain('capacity_above_house_ceiling');
});

it('freezes the regulation and curriculum version at planning time', function (): void {
    $definition = TrainingDefinition::factory()->hasCurricula(1)->create();
    $actor = userWithPermissions(['training.create.all']);

    $training = app(PlanTraining::class)($actor, new NewTraining(
        definitionId: (int) $definition->getKey(),
        startsOn: '2026-09-03',
        endsOn: '2026-09-04',
        capacity: 10,
        visibility: TrainingVisibility::Open,
        customerIds: [],
    ));

    expect($training->training_curriculum_id)->not->toBeNull()
        ->and($training->regulation_version_id)->not->toBeNull();
});

it('abandons a draft without asking anybody', function (): void {
    // ⚠ Taslak hiçbir taahhüt taşımaz (spec §4.4).
    $training = Training::factory()->create(['status' => 'draft']);
    $actor = userWithPermissions(['training.update.all']);

    app(AbandonTraining::class)($actor, $training);

    expect($training->fresh()->status)->toBe(TrainingStatus::Abandoned);
});

it('refuses to abandon anything that is no longer a draft', function (): void {
    $training = Training::factory()->create(['status' => 'planned']);
    $actor = userWithPermissions(['training.update.all']);

    expect(fn () => app(AbandonTraining::class)($actor, $training))
        ->toThrow(ValidationException::class);
});
```

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

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

```php
<?php

declare(strict_types=1);

namespace App\Data\Office;

use App\Support\Trainings\DeliveryMode;
use App\Support\Trainings\TrainingVisibility;

final readonly class NewTraining
{
    /**
     * @param  list<int>  $customerIds
     */
    public function __construct(
        public int $definitionId,
        public string $startsOn,
        public string $endsOn,
        public int $capacity,
        public TrainingVisibility $visibility,
        public array $customerIds,
        public ?int $cityOptionValueId = null,
        public ?string $address = null,
        public DeliveryMode $deliveryMode = DeliveryMode::InPerson,
        public ?string $notes = null,
    ) {}
}
```

- [ ] **5.4 — Kapasite kuralını saf katmana yaz**

`app/Domain/Trainings/CapacityCeiling.php`:

```php
<?php

declare(strict_types=1);

namespace App\Domain\Trainings;

/**
 * How high a training's capacity may go (planlama-spec §5.1).
 *
 * ⚠ Two ceilings with two different regimes, and merging them would be wrong
 * in both directions:
 *
 *   - The DEFINITION's maximum comes from the regulation. It is hard: nobody
 *     can approve past it, because the party who would have to is the
 *     Administration.
 *   - The HOUSE ceiling is 3E's own number, used when the regulation sets none
 *     (DGY). It is a business parameter, so §8.1's rule applies — exceeding it
 *     warns and goes to approval, it does not block.
 *
 * ⛔ Neither number is in this file. Both are read from data.
 */
final readonly class CapacityCeiling
{
    public function __construct(
        private ?int $definitionMaximum,
        private int $houseCeiling,
    ) {}

    public function refuses(int $capacity): bool
    {
        return $this->definitionMaximum !== null && $capacity > $this->definitionMaximum;
    }

    public function warnsAbout(int $capacity): bool
    {
        return $this->definitionMaximum === null && $capacity > $this->houseCeiling;
    }
}
```

- [ ] **5.5 — `PlanTraining` yaz**

Adımları: `authorize('create')` → `CapacityCeiling` kontrolü (`refuses` → `ValidationException`, `warnsAbout` → uyarı listesine) → transaction içinde `Training` yaratılır, mevzuat ve müfredat sürümü dondurulur, `ReferenceCodes::issue(ReferencePrefix::Plan->value, ...)` çağrılır, kapalıysa `training_customers` doldurulur, audit satırı yazılır.

- [ ] **5.6 — Koş, geçtiğini gör** — PASS (16 test) · **5.7 — Kalite kapısı ve commit**

---

## Görev 6: Planlandı geçişi, geri çekme ve kod dönüşümü

**Dosyalar:** `app/Actions/Office/PublishTrainingPlan.php` · `app/Actions/Office/WithdrawTrainingPlan.php` (yeni)

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

```php
it('turns the PL code into an EG code keeping the suffix', function (): void {
    $training = Training::factory()->create(['status' => 'draft']);
    $actor = userWithPermissions(['training.update.all']);

    $before = $training->reference_code;

    app(PublishTrainingPlan::class)($actor, $training);

    $suffix = fn (string $code): string => explode('-', $code)[2];

    expect($training->fresh()->reference_code)->toStartWith('EG-')
        ->and($suffix($training->fresh()->reference_code))->toBe($suffix($before));
});

it('still answers on the old planning code', function (): void {
    $training = Training::factory()->create(['status' => 'draft']);
    $before = $training->reference_code;

    app(PublishTrainingPlan::class)(userWithPermissions(['training.update.all']), $training);

    expect(app(FindByReferenceCode::class)->find($before)?->is($training))->toBeTrue();
});

it('withdraws a planned training when no quote has been opened', function (): void {
    $training = Training::factory()->create(['status' => 'planned']);
    $actor = userWithPermissions(['training.update.all']);

    app(WithdrawTrainingPlan::class)($actor, $training);

    expect($training->fresh()->status)->toBe(TrainingStatus::Draft);
});

it('refuses to withdraw once a quote record exists', function (): void {
    // ⛔ Eşik teklifin OLUŞTURULMASIDIR, gönderilmesi değil (spec §4.3).
    $training = Training::factory()->create(['status' => 'planned']);
    $training->quotes()->create(['status' => 'draft']);

    expect(fn () => app(WithdrawTrainingPlan::class)(userWithPermissions(['training.update.all']), $training))
        ->toThrow(ValidationException::class);
});

it('drops an approval that had already been given', function (): void {
    $training = Training::factory()->create(['status' => 'planned', 'approved_at' => FrozenClock::INSTANT]);

    app(WithdrawTrainingPlan::class)(userWithPermissions(['training.update.all']), $training);

    expect($training->fresh()->approved_at)->toBeNull();
});
```

⚠ Dördüncü test **teklif tablosuna** dokunuyor ve o tablo EGT-26'nın işidir. Bu turda `Training::quotes()` ilişkisi **yalnız sözleşme olarak** tanımlanır: teklif tablosu gelene kadar sorgu boş döner ve test o hâliyle `skip` edilir, `EGT-26`'ya kapanış adresi yazılır. Sözleşme burada durur ki satış turu neye bağlanacağını bilsin.

- [ ] **6.2 — Koş, düştüğünü gör** · **6.3 — Action'ları yaz** · **6.4 — Koş, geçtiğini gör** · **6.5 — Commit**

---

## Görev 7: Kontenjan ve yedek liste

**Dosyalar:** `database/migrations/2026_08_14_000400_create_training_reserve_entries.php` · `app/Actions/Office/ReserveSeats.php` · `app/Actions/Office/AddToReserveList.php` · `app/Actions/Office/PromoteFromReserveList.php` · `app/Queries/Office/SeatCounts.php` (yeni)

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

```php
it('lets only one of two simultaneous reservations through', function (): void {
    // ⛔ Yarış durumunun testi (spec §5.3). Uygulama katmanındaki bir
    // "doldu mu" kontrolü ikisini de geçirirdi.
    $training = Training::factory()->create(['capacity' => 1]);

    $reserve = fn (): bool => rescue(
        fn (): bool => tap(true, fn () => app(ReserveSeats::class)($training->fresh(), 1)),
        false,
    );

    $results = [$reserve(), $reserve()];

    expect(array_filter($results))->toHaveCount(1)
        ->and($training->fresh()->confirmed_seats)->toBe(1);
});

it('puts a request on the reserve list without touching the counters', function (): void {
    $training = Training::factory()->create(['capacity' => 5, 'confirmed_seats' => 5]);
    $customer = Customer::factory()->create();

    app(AddToReserveList::class)($training, $customer, seats: 3);

    expect($training->fresh()->confirmed_seats)->toBe(5)
        ->and($training->reserveEntries()->count())->toBe(1);
});

it('refuses to promote without a reason', function (): void {
    // ⚠ Otomatik terfi yoktur; seçim gerekçesiyle audit'e yazılır (çatı §152).
    $training = Training::factory()->create(['capacity' => 5, 'confirmed_seats' => 4]);
    $entry = reserveEntryFor($training, seats: 1);

    expect(fn () => app(PromoteFromReserveList::class)(userWithPermissions(['training.update.all']), $entry, ''))
        ->toThrow(ValidationException::class);
});

it('keeps a no-show out of the promotion path', function (): void {
    // ⛔ Eğitim öncesi iptal ≠ eğitim günü gelmeyen (spec §6.1).
    $training = Training::factory()->create(['capacity' => 5, 'confirmed_seats' => 5, 'status' => 'in_progress']);
    $entry = reserveEntryFor($training, seats: 1);

    expect(fn () => app(PromoteFromReserveList::class)(userWithPermissions(['training.update.all']), $entry, 'Yerine geçsin'))
        ->toThrow(ValidationException::class);
});
```

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

- [ ] **7.3 — Migration'ı yaz**

```php
Schema::create('training_reserve_entries', function (Blueprint $table): void {
    $table->bigIncrements('id');

    $table->unsignedBigInteger('training_id');
    $table->foreign('training_id')->references('id')->on('trainings')->cascadeOnDelete();

    $table->unsignedBigInteger('tenant_id');
    $table->foreign('tenant_id')->references('id')->on('customers')->restrictOnDelete();

    // ⚠ Birim TALEP'tir, isimli kişi değil (spec §6): yedeğe girmek isteyen
    // taraf, tam da henüz kimseyi kesinleştirmemiş olan taraftır.
    $table->unsignedSmallInteger('seats');

    $table->unsignedSmallInteger('position');

    // waiting | promoted | withdrawn
    $table->string('status', 16)->default('waiting');

    $table->text('promotion_reason')->nullable();
    $table->unsignedBigInteger('promoted_by')->nullable();
    $table->foreign('promoted_by')->references('id')->on('users')->nullOnDelete();
    $table->timestampTz('promoted_at', precision: 6)->nullable();

    $table->timestampsTz(precision: 6);

    $table->unique(['training_id', 'position']);
    $table->index(['training_id', 'status']);
});
```

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

```php
/**
 * Turns approved seats into confirmed ones.
 *
 * ⛔ `lockForUpdate()` and then the CHECK constraint — two layers on purpose.
 * The lock serialises the read-modify-write; the constraint is what holds if a
 * future caller forgets the lock. Capacity here is not a preference: exceeding
 * it produces a training that breaches the regulation.
 */
public function __invoke(Training $training, int $seats): void
{
    DB::transaction(function () use ($training, $seats): void {
        $locked = Training::query()->whereKey($training->getKey())->lockForUpdate()->firstOrFail();

        $locked->confirmed_seats += $seats;
        $locked->save();
    });
}
```

- [ ] **7.5 — Kalan action'ları yaz** · **7.6 — Koş** · **7.7 — Commit**

---

## Görev 8: Talep kuyruğu

**Dosyalar:** `database/migrations/2026_08_14_000500_create_training_requests.php` · `app/Actions/Office/OpenTrainingRequest.php` · `app/Queries/Office/TrainingRequests.php` (yeni)

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

```php
it('shows a salesperson only the requests of their own companies', function (): void {
    $mine = Customer::factory()->create();
    $theirs = Customer::factory()->create();

    requestFor($mine);
    requestFor($theirs);

    $salesperson = userWithPermissions(['training_request.view.group']);
    $salesperson->customers()->attach($mine->getKey());

    expect(app(TrainingRequests::class)->visibleTo($salesperson->fresh())->count())->toBe(1);
});

it('closes a request when a training answers it', function (): void {
    $request = requestFor(Customer::factory()->create());
    $training = Training::factory()->create();

    app(AnswerTrainingRequest::class)(userWithPermissions(['training.create.all']), $request, $training);

    expect($request->fresh()->status)->toBe('planned')
        ->and($request->fresh()->training_id)->toBe($training->getKey());
});
```

- [ ] **8.2 — Migration'ı yaz**

```php
Schema::create('training_requests', function (Blueprint $table): void {
    $table->bigIncrements('id');

    $table->unsignedBigInteger('tenant_id');
    $table->foreign('tenant_id')->references('id')->on('customers')->restrictOnDelete();

    $table->unsignedBigInteger('training_definition_id');
    $table->foreign('training_definition_id')->references('id')->on('training_definitions')->restrictOnDelete();

    $table->unsignedSmallInteger('participant_count');

    $table->date('preferred_from')->nullable();
    $table->date('preferred_until')->nullable();
    $table->text('location_note')->nullable();
    $table->text('note')->nullable();

    // Polimorfik: bugün ofis kullanıcısı, yarın müşteri kullanıcısı.
    $table->string('opened_by_type');
    $table->unsignedBigInteger('opened_by_id');

    // open | planned | declined
    $table->string('status', 16)->default('open');

    $table->unsignedBigInteger('training_id')->nullable();
    $table->foreign('training_id')->references('id')->on('trainings')->nullOnDelete();

    $table->timestampsTz(precision: 6);

    $table->index(['status', 'created_at']);
    $table->index(['opened_by_type', 'opened_by_id']);
    $table->index('tenant_id');
});
```

⛔ **Teklife dair hiçbir alan yoktur** — fiyat, indirim, teklif metni. Talep planlamacının girdisidir; teklif satışın çıktısıdır ve kendi tablosunu EGT-26 kurar (spec §7).

- [ ] **8.3 — Action ve sorguyu yaz** · **8.4 — Koş** · **8.5 — Commit**

---

## Görev 9: Bakanlık bildirimi

**Dosyalar:** `database/migrations/2026_08_14_000600_create_training_ministry_notifications.php` · `app/Actions/Office/NotifyMinistry.php` · `app/Actions/Office/RecordMinistryApproval.php` · `app/Domain/Trainings/NotificationDeadline.php` · `app/Console/Commands/OpenMinistryNotificationTasks.php` (yeni)

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

```php
it('reads the notification model from the definition, not from code', function (): void {
    $approvalType = TrainingDefinition::factory()->create([
        'notification_type' => 'approval',
        'notification_days' => 15,
    ]);

    $training = Training::factory()->for($approvalType, 'definition')->create(['status' => 'planned']);

    app(NotifyMinistry::class)(userWithPermissions(['training.notify_ministry.all']), $training, 'KUMS-99');

    // ⛔ Onay modelinde bildirim tek başına ilerletmez (DG-2020 md.14/8).
    expect($training->fresh()->status)->toBe(TrainingStatus::Planned);
});

it('moves a notification-type training forward once the KUMS id is in', function (): void {
    $definition = TrainingDefinition::factory()->create([
        'notification_type' => 'notification',
        'notification_days' => 3,
    ]);

    $training = Training::factory()->for($definition, 'definition')->create(['status' => 'planned']);

    app(NotifyMinistry::class)(userWithPermissions(['training.notify_ministry.all']), $training, 'KUMS-42');

    expect($training->fresh()->status)->toBe(TrainingStatus::MinistryNotified);
});

it('opens a task as the deadline approaches', function (): void {
    $definition = TrainingDefinition::factory()->create(['notification_days' => 3]);

    $training = Training::factory()->for($definition, 'definition')->create([
        'status' => 'planned',
        'starts_on' => FrozenClock::INSTANT->addDays(3)->toDateString(),
    ]);

    app(OpenMinistryNotificationTasks::class)->handle();

    expect(Task::query()->open()->where('kind', TaskKind::MinistryNotification->value)->count())->toBe(1);
});

it('flags a training whose participant list changed after notification', function (): void {
    $training = Training::factory()->create(['status' => 'ministry_notified']);

    app(ReserveSeats::class)($training, 1);

    expect($training->fresh()->needs_kums_update)->toBeTrue();
});

it('refuses to start a training that needs a KUMS update', function (): void {
    $training = Training::factory()->create(['status' => 'ministry_notified', 'needs_kums_update' => true]);

    expect(fn () => app(StartTraining::class)(userWithPermissions(['training.update.all']), $training))
        ->toThrow(ValidationException::class);
});
```

- [ ] **9.2 — Migration'ı yaz**

```php
Schema::create('training_ministry_notifications', function (Blueprint $table): void {
    $table->bigIncrements('id');

    $table->unsignedBigInteger('training_id');
    $table->foreign('training_id')->references('id')->on('trainings')->cascadeOnDelete();

    // initial | update | cancellation
    $table->string('kind', 16);

    $table->timestampTz('sent_at', precision: 6);
    $table->unsignedBigInteger('sent_by');
    $table->foreign('sent_by')->references('id')->on('users')->restrictOnDelete();

    // ⚠ Bakanlığın kimliği, bizim ürettiğimiz bir kod değil (HANDOFF §7) —
    // referans kod mekanizmasından ayrı bir alan.
    $table->string('kums_reference')->nullable();

    $table->timestampTz('approved_at', precision: 6)->nullable();
    $table->text('evidence_path')->nullable();

    $table->timestampsTz(precision: 6);

    $table->index(['training_id', 'kind']);
});
```

Ve `trainings` tablosuna: `needs_kums_update` boolean (varsayılan `false`).

- [ ] **9.3 — `NotificationDeadline` yaz**

```php
/**
 * When the ministry has to have been told (planlama-spec §10.3).
 *
 * ⛔ The number of days is NOT here. It comes from the training definition
 * (`notification_days`) because the same system serves families with three days
 * and families with fifteen — Y-2016 md.13/g and DG-2020 md.14/8.
 *
 * ⛔ Calendar days, by decision (Sets, 18.08.2026 — EGT-43 kapatıldı: resmî
 * böyle bir liste yok ve sektör 7/24/365 çalışıyor). It is also the
 * CONSERVATIVE choice — the deadline arrives no later than the real one.
 */
```

- [ ] **9.4 — Action'ları ve zamanlanmış görevi yaz** · **9.5 — Koş** · **9.6 — Commit**

---

## Görev 10: İptal akışı

**Dosyalar:** `app/Actions/Office/RequestTrainingCancellation.php` · `app/Actions/Office/ApproveTrainingCancellation.php` · `app/Actions/Office/RejectTrainingCancellation.php` (yeni)

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

```php
it('refuses a cancellation request without a reason', function (): void {
    $training = Training::factory()->create(['status' => 'planned']);

    expect(fn () => app(RequestTrainingCancellation::class)(userWithPermissions(['training.cancel.all']), $training, ''))
        ->toThrow(ValidationException::class);
});

it('returns the training to where it came from when the cancellation is refused', function (): void {
    $training = Training::factory()->create(['status' => 'ministry_notified']);
    app(RequestTrainingCancellation::class)(userWithPermissions(['training.cancel.all']), $training, 'Müşteri vazgeçti');

    app(RejectTrainingCancellation::class)(userWithPermissions(['training.cancel_approve.all']), $training->fresh(), 'Eğitim yapılacak');

    expect($training->fresh()->status)->toBe(TrainingStatus::MinistryNotified);
});

it('opens a ministry notification task when a notified training is cancelled', function (): void {
    // ⛔ Uyarı değil görev (spec §11.2): Y-2016 md.16-17 idari para cezası
    // öngörüyor ve unutulan bir bildirimi hatırlatan tek şey bir uyarı metniyse
    // o bildirim unutulur.
    $training = Training::factory()->create(['status' => 'cancellation_requested', 'notified' => true]);

    app(ApproveTrainingCancellation::class)(userWithPermissions(['training.cancel_approve.all']), $training);

    expect(Task::query()->open()->where('kind', TaskKind::MinistryCancellationNotice->value)->count())->toBe(1);
});

it('never cancels a completed training', function (): void {
    $training = Training::factory()->create(['status' => 'completed']);

    expect(fn () => app(RequestTrainingCancellation::class)(userWithPermissions(['training.cancel.all']), $training, 'Gerekçe'))
        ->toThrow(ValidationException::class);
});
```

- [ ] **10.2 — Action'ları yaz** · **10.3 — Koş** · **10.4 — Commit**

---

## Görev 11: Ekranlar

**Dosyalar:** `app/Http/Controllers/Office/TrainingController.php` · `app/Http/Controllers/Office/TrainingRequestController.php` · `resources/js/pages/Office/Trainings/{Index,Show,Create}.vue` · `resources/js/organisms/TrainingWizard.vue` · `routes/office.php` · `OfficeNavigation.php`

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

```php
it('keeps a closed training off a stranger salesperson screen', function (): void {
    $training = Training::factory()->create(['visibility' => 'closed']);
    $training->customers()->attach(Customer::factory()->create()->getKey());

    $stranger = userWithPermissions(['training.view.group']);

    $this->actingAs($stranger, 'office')
        ->get(route('office.trainings.index'))
        ->assertInertia(fn (Assert $page) => $page->where('trainings', []));
});

it('walks the wizard from definition to draft', function (): void {
    $definition = TrainingDefinition::factory()->create(['max_participants' => 25]);
    $actor = userWithPermissions(['training.create.all']);

    $this->actingAs($actor, 'office')
        ->post(route('office.trainings.store'), [
            'training_definition_id' => $definition->getKey(),
            'starts_on' => '2026-09-03',
            'ends_on' => '2026-09-04',
            'capacity' => 18,
            'visibility' => 'closed',
            'customers' => [],
        ])
        ->assertRedirect();

    expect(Training::query()->count())->toBe(1);
});
```

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

```php
Route::get('/egitimler', [TrainingController::class, 'index'])->name('office.trainings.index');
Route::get('/egitimler/yeni', [TrainingController::class, 'create'])->name('office.trainings.create');
Route::post('/egitimler', [TrainingController::class, 'store'])->name('office.trainings.store');
Route::get('/egitimler/{training}', [TrainingController::class, 'show'])->name('office.trainings.show');
Route::put('/egitimler/{training}', [TrainingController::class, 'update'])->name('office.trainings.update');
Route::post('/egitimler/{training}/yayinla', [TrainingController::class, 'publish'])->name('office.trainings.publish');
Route::post('/egitimler/{training}/geri-cek', [TrainingController::class, 'withdraw'])->name('office.trainings.withdraw');
Route::post('/egitimler/{training}/vazgec', [TrainingController::class, 'abandon'])->name('office.trainings.abandon');
Route::post('/egitimler/{training}/bakanlik', [TrainingController::class, 'notifyMinistry'])->name('office.trainings.ministry');
Route::post('/egitimler/{training}/iptal', [TrainingController::class, 'requestCancellation'])->name('office.trainings.cancel');
Route::post('/egitimler/{training}/iptal/onay', [TrainingController::class, 'approveCancellation'])->name('office.trainings.cancel.approve');
Route::get('/talepler', [TrainingRequestController::class, 'index'])->name('office.training-requests.index');
```

⚠ Sihirbaz **kendi sayfasıdır**, drawer değil: beş adım ve bir maliyet modalı taşır (`21 Planlamaci Sihirbaz`), bu bir drawer'a sığmaz. Kullanıcı ekleme formunun drawer olması (T4) ile çelişmez — orada tek adımlık bir form vardı.

- [ ] **11.3 — Ekranları yaz**

| Ekran | Tasarım | İçerik |
|---|---|---|
| `Trainings/Index.vue` | `20 Planlamaci Sorgulama` | `DataTable`: kod, tanım, tarih, il, statü rozeti, kontenjan göstergesi (`8 kesinleşen · 10 teklif bekliyor / 25`) |
| `Trainings/Create.vue` | `21 Planlamaci Sihirbaz` | Beş adım: tanım → tarih/yer/biçim → görünürlük ve firmalar → kontenjan → özet. Adım başına doğrulama, `stepper` atomu |
| `Trainings/Show.vue` | `22 Planlamaci Egitim Detay` | Tek sayfa, tab yok: künye + kontenjan + yedek liste + bakanlık kartı (KUMS kilidi) + statü aksiyonları |
| `TrainingRequests/Index.vue` | `20`'nin kuyruk bölümü | Talepler, "eğitim planla" aksiyonu |

⛔ **Kilitler nedenini söyler** (`HANDOFF.md` §2): pasif buton + koşul metni. *"Bakanlık onayı bekleniyor"*, *"KUMS güncellemesi gerekli"*, *"Teklif açıldığı için geri çekilemez"*.

- [ ] **11.4 — Koş** · **11.5 — Commit**

---

## Görev 12: Kapanış

- [ ] **12.1 — Wire-up doğrulaması**

```bash
php artisan route:list | grep -E "egitimler|talepler"
grep -rn "PlanTraining\|PublishTrainingPlan\|WithdrawTrainingPlan\|AbandonTraining\|NotifyMinistry\|RequestTrainingCancellation" app/Http/ app/Console/
php artisan schedule:list | grep Ministry
```

Beklenen: on iki route, action'ların hepsi controller veya komuttan çağrılıyor, bildirim görevi zamanlamada.

- [ ] **12.2 — `training_participants.training_id` FK'sını kapat**

```php
Schema::table('training_participants', function (Blueprint $table): void {
    $table->foreign('training_id')->references('id')->on('trainings')->restrictOnDelete();
});
```

⚠ Bu, chunk 5'in açık bıraktığı kalemdir (*"işaret edeceği tablo başka bir planın kararı"*). Kararı bu tur verdi.

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

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

- [ ] **12.4 — Proje haritasını güncelle** — §4.3 planlamacı bölümü: omurga ✓, ekranlar ✓, T2'ye kalanlar işaretli.

- [ ] **12.5 — Ticket kapanışı** — `teamcity.sh trigger` → `wait` → yeşil.

---

## Kabul kriterleri

Spec kriterleriyle eşleşme; T2'ye kalanlar ayrıca işaretli.

| # | Kriter | Spec | Görev |
|---|---|---|---|
| T1-1 | Eğitim tanımı olmadan eğitim kaydı oluşturulamaz | P-1 | 5 |
| T1-2 | Kapalı eğitim yalnız firmalarına atanmış satışçılara ve kapsamı yeten yöneticilere görünür | P-2, P-35 | 3 |
| T1-3 | `draft`→`planned` geçişinde kod `PL`'den `EG`'ye döner, son ek korunur, eski kod aynı kayda yönlendirir | P-3 | 1, 6 |
| T1-4 | Teklif kaydı açılmışsa geri çekme reddedilir; açılmamışsa alınmış onay düşer | P-5 | 6 |
| T1-5 | `capacity` tanımdaki azamiyi aşamaz; tanım boşsa 3E tavanı uygulanır ve aşımı uyarı üretir | P-6, P-43 | 5 |
| T1-6 | `confirmed_seats > capacity` **veritabanında** reddedilir; eşzamanlı iki rezervasyondan biri geçer | P-7 | 2, 7 |
| T1-7 | `pending_seats` toplamı aşabilir, hiçbir geçişi engellemez | P-26 | 2 |
| T1-8 | `named_seats < confirmed_seats` uyarı üretir | P-8 | 7 |
| T1-9 | Kapasite dolu eğitime gelen talep yedek listesine girer, sayaçları değiştirmez | P-9 | 7 |
| T1-10 | Yedekten çağırma gerekçesiz yapılamaz ve audit'e yazılır | P-31 | 7 |
| T1-11 | No-show yedek terfi akışını tetiklemez | P-30 | 7 |
| T1-12 | Asgari katılımcı altındaki eğitim uyarı üretir, engellenmez | P-10 | 5 |
| T1-13 | Talep kaydı teklife dair alan taşımaz; karşılandığında `planned` olur ve eğitime bağlanır | P-11 | 8 |
| T1-14 | Satışçı yalnız atandığı firmaların taleplerini görür | P-37 | 8 |
| T1-15 | Bildirim tipi ve süresi tanımdan okunur; kodda gün sayısı yok | P-15 | 9 |
| T1-16 | `approval` tipinde onay kanıtı olmadan eğitim başlatılamaz | P-16 | 9 |
| T1-17 | Bildirilmiş eğitim `draft`'a geri çekilemez | P-17 | 6, 9 |
| T1-18 | Katılımcı listesi bildirimden sonra değişirse KUMS bayrağı çıkar ve `in_progress`'e geçilemez | P-18 | 9 |
| T1-19 | Bildirim son tarihi yaklaşırken görev açılır, geçince yükselir | P-40 | 9 |
| T1-20 | İptal gerekçesiz talep edilemez, onaysız gerçekleşmez | P-19 | 10 |
| T1-21 | İptal reddedilirse kayıt geldiği statüye döner | P-39 | 10 |
| T1-22 | Bildirilmiş eğitimin iptalinde bildirim görevi açılır | P-20 | 10 |
| T1-23 | Taslak onaysız `abandoned` yapılabilir; `completed` iptal edilemez | P-38 | 5, 10 |
| T1-24 | Eğitim hiçbir yoldan silinemez | P-21 | 2 |
| T1-25 | Eğitim verildiği mevzuat ve müfredat sürümünü saklar | P-28 | 5 |
| T1-26 | Yapılış biçimi kaydedilir, hiçbir davranışı değiştirmez | P-25 | 2 |
| T1-27 | Planlamacı kapsamı dışındaki eğitimi göremez ve değiştiremez | P-24 | 3 |
| T1-28 | Uyarıya rağmen verilen onay engellenmez ve audit'te görünür | P-34 | 5, 9 |

**T2'ye kalan spec kriterleri:** P-12, P-13, P-14 (yetkinlik ve kanıt seti) · P-22 (çakışma) · P-23 (tedarikçi fiyat tarihçesi) · P-29 (eğitim evrakları) · P-32 (kontenjan sahipliği görünürlüğü — satış ekranıyla birlikte) · P-33 (mevzuat uyarıları — sihirbazın eğitmen adımıyla) · P-36 (çoklu kurumsal firma listesi — müşteri portalı ekranıyla) · P-41, P-42 (kanıt seti erişimi ve saklaması)

---

## Kapanış adresi verilen

| Ne | Nerede |
|---|---|
| Eğitmen ataması, maliyet, kanıt seti, tedarikçiler, takvim | **T2** (`docs/plans/planlama-t2-plan.md`) |
| `Training::quotes()` ilişkisinin gerçek karşılığı | EGT-26 — sözleşme Görev 6'da yazıldı |
| Katılımcı listesi yönetimi, isim girişi | EGT-27 — `named_seats` sayacı bu turda doğdu |
| "İş günü" hesabı | ⛔ **Kapandı: iş günü diye bir hesap yok** (Sets, 18.08.2026, EGT-43 Won't fix). Süreler takvim günü sayılır |
| Açık eğitim duyuru sistemi | Satış turu, çatı §67 |

---

## Revizyon geçmişi

| Sürüm | Tarih | Değişiklik |
|---|---|---|
| v1.0 | 29.07.2026 | İlk sürüm. 12 görev, 28 kabul kriteri. Spec v1.1'in §1-§7, §10, §11, §13, §14 bölümleri. |
