# Planlama T2 — eğitmen ataması, maliyet ve tedarikçiler

*29.07.2026 · Spec'ler: `docs/specs/planlama-spec.md` v1.1 (heyet 165) §8, §9, §12 · `docs/specs/egitmen-spec.md` (heyet 158) §3, §4, §6, §7 · Önkoşul: `docs/plans/planlama-t1-plan.md` (EGT-63) — eğitim kaydı olmadan hiçbiri bağlanamaz*

**Hedef:** T1'in kurduğu eğitim kaydına eğitmeni, parayı ve tedarikçiyi bağlamak — yetkinlik kapısı, aday listesi, atama teklifi ve kabulü, planlama maliyet kalemleri, donmuş kanıt seti, tedarikçi kaydı ve bölgesel fiyat tarihçesi.

**Mimari:** `egitmen-spec` §6'nın zinciri (ihtiyaç → N adaya teklif → kabul → kesinleşmiş atama) üç tabloda yaşar. Kontenjan gibi burada da **kabul atomiktir ve veritabanında kapanır**. Maliyet kalemleri iki tutar taşır (planlanan + gerçekleşen) ve sapma gerekçesizse kaydedilmez.

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

---

## Doğrulanmış mevcut durum

| Ne | Durum | Kanıt |
|---|---|---|
| Eğitmen dosyası | ✓ `instructors` — `category`, `rate_unit`, `rate_amount`, `certificate_valid_until`, `authorised_institutions`, `last_taught_on` | `2026_08_13_000400` |
| Eğitmen belgeleri | ✓ `instructor_documents` + versiyonlar; kritik belge DB'de silinemez | `2026_08_13_000500` |
| Şart kaydı | ✓ `instructor_requirements` — `requires_document`, `regulation_id`, `article_reference` | `2026_08_03_000100:73-88`, `2026_08_05_000100` |
| Eğitim ↔ şart | ✓ `training_definition_instructor_requirement` pivot | `2026_08_03_000100:93-105` |
| **Eğitmen ↔ verebileceği eğitim** | ❌ YOK — `create_instructors.php:36` bunu açıkça erteliyor | — |
| **Şart ↔ kabul edilen belge türü** | ❌ YOK — `requires_document` bayrağı var, *hangi* belge olduğu yok | — |
| Belge görevi mekanizması | ✓ `OpenDocumentTasks`, `TaskKind::InstructorDocumentUpload` | `app/Actions/Office/OpenDocumentTasks.php` |
| Evrak türü kapsamları | ✓ `document_type_scopes.scope` — değerler **Türkçe** (`SCOPE_INSTRUCTOR = 'egitmen'`) | `app/Models/DocumentType.php` |
| Dosya deposu | ✓ `App\Support\Files\SecureFileStore` | `app/Support/Files/SecureFileStore.php` |
| Ayarlar | ✓ `App\Support\Business\Settings` | `app/Support/Business/Settings.php` |
| Eğitim kaydı | T1'de doğuyor — `trainings`, `TrainingStatus`, `TrainingPolicy` | EGT-63 |

---

## Global kısıtlar

T1 planındakilerin **tamamı** geçerlidir (dil, test grubu `planlama`, frozen clock, AD-25 yerleşimi, audit, `OpaqueId`, kalite kapısı). Ek olarak:

- **Hiçbir ücret, limit veya süre koda yazılmaz** — hepsi `settings` veya eğitmen kaydından (AD-08, kırmızı çizgi 14).
- **Hassas veri:** kanıt seti kopyaları orijinalle aynı erişim rejimine tabidir (spec §9.6, AD-05, AD-21).
- **Kesinleşmiş atama, onaylanmış masraf ve ödeme kaydı hard delete edilemez** (`egitmen-spec` K-25).

---

## Görev 1: Yetkinlik kapısının iki bağı

**Dosyalar:** `database/migrations/2026_08_15_000100_bind_instructor_competence.php` · `app/Queries/Office/InstructorCompetence.php` (yeni)

**Üretir:** `instructor_training_definition` ve `instructor_requirement_document_type` tabloları · `InstructorCompetence::requirementsFor(Instructor $instructor): Collection`

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

```php
it('derives the required papers from the trainings an instructor may teach', function (): void {
    // ⚠ Şart ayrıca işaretlenmez — eğitim seçilir, şart oradan türer
    // (spec §9.1). İki kez veri girmek iki kaynağın sapması demektir.
    $requirement = InstructorRequirement::factory()->create(['requires_document' => true]);
    $definition = TrainingDefinition::factory()->create();
    $definition->instructorRequirements()->attach($requirement->getKey());

    $instructor = Instructor::factory()->create();
    $instructor->trainingDefinitions()->attach($definition->getKey());

    $names = app(InstructorCompetence::class)->requirementsFor($instructor)->pluck('code');

    expect($names)->toContain($requirement->code);
});

it('accepts any one of the document types a requirement allows', function (): void {
    // ⚠ Y-2016 md.7/2 üç alternatif sunuyor; biri yeterlidir (spec §9.2).
    $requirement = InstructorRequirement::factory()->create(['requires_document' => true]);
    $master = DocumentType::factory()->create(['code' => 'UZAKYOL-KAPTAN']);
    $mate = DocumentType::factory()->create(['code' => 'UZAKYOL-BIRINCI-ZABIT']);

    $requirement->acceptedDocumentTypes()->attach([$master->getKey(), $mate->getKey()]);

    $instructor = Instructor::factory()->create();
    documentFor($instructor, $mate, expiresOn: FrozenClock::INSTANT->addYear());

    expect(app(InstructorCompetence::class)->satisfies($instructor, $requirement))->toBeTrue();
});

it('drops the requirement when the proving paper expires', function (): void {
    $requirement = InstructorRequirement::factory()->create(['requires_document' => true]);
    $type = DocumentType::factory()->create();
    $requirement->acceptedDocumentTypes()->attach($type->getKey());

    $instructor = Instructor::factory()->create();
    documentFor($instructor, $type, expiresOn: FrozenClock::INSTANT->subDay());

    expect(app(InstructorCompetence::class)->satisfies($instructor, $requirement))->toBeFalse();
});

it('counts a requirement that needs no paper as met', function (): void {
    // ⚠ "İki yıl 3E'de eğitim vermiş olmak" gibi operasyonel şartlar belgesizdir
    // ve tablo bunu gerçek bir kategori sayıyor (migration yorumu).
    $requirement = InstructorRequirement::factory()->create(['requires_document' => false]);
    $instructor = Instructor::factory()->create();

    expect(app(InstructorCompetence::class)->satisfies($instructor, $requirement))->toBeTrue();
});
```

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

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

```php
<?php

declare(strict_types=1);

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

/**
 * The two links that close the competence gate (planlama-spec §9).
 *
 * ── Why the gate was half built ────────────────────────────────────────────
 *
 * chunk10 produced the requirement record and bound it to the training
 * definition, so the catalogue can say "teaching this needs an IMDG instructor
 * card". What it could not say is whether a given person holds one — and
 * `create_instructors.php` recorded that gap in as many words: no competence
 * matrix, it belongs to the catalogue's turn.
 *
 * ── Requirements are DERIVED, never entered twice ──────────────────────────
 *
 * ⚠ An instructor is linked to the TRAININGS they may teach; the papers follow
 * from the definitions' requirements. Ticking requirements separately would
 * produce two sources for one fact, and they would drift.
 *
 * ── One requirement, several acceptable papers ─────────────────────────────
 *
 * Y-2016 md.7/2 sets a threshold, not a document: an uzakyol master's licence
 * and an uzakyol first mate's licence both clear md.7/2-a, and md.7/2-b and
 * md.7/2-c clear the same requirement with service records instead.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('instructor_training_definition', function (Blueprint $table): void {
            $table->unsignedBigInteger('instructor_id');
            $table->unsignedBigInteger('training_definition_id');
            $table->timestampsTz(precision: 6);

            $table->foreign('instructor_id', 'itd_instructor_foreign')
                ->references('id')->on('instructors')->cascadeOnDelete();

            $table->foreign('training_definition_id', 'itd_definition_foreign')
                ->references('id')->on('training_definitions')->cascadeOnDelete();

            $table->primary(['instructor_id', 'training_definition_id'], 'itd_primary');
        });

        Schema::create('instructor_requirement_document_type', function (Blueprint $table): void {
            $table->unsignedBigInteger('instructor_requirement_id');
            $table->unsignedBigInteger('document_type_id');

            $table->foreign('instructor_requirement_id', 'irdt_requirement_foreign')
                ->references('id')->on('instructor_requirements')->cascadeOnDelete();

            $table->foreign('document_type_id', 'irdt_type_foreign')
                ->references('id')->on('document_types')->cascadeOnDelete();

            $table->primary(['instructor_requirement_id', 'document_type_id'], 'irdt_primary');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('instructor_requirement_document_type');
        Schema::dropIfExists('instructor_training_definition');
    }
};
```

- [ ] **1.4 — `InstructorCompetence` yaz**

`requirementsFor()` eğitmenin verebileceği eğitimlerin şartlarını toplar (tekilleştirerek); `satisfies()` bir şartın karşılanıp karşılanmadığını söyler: belgesiz şart her zaman karşılanır, belgeli şart için kabul edilen türlerden **herhangi birinde** süresi geçmemiş bir belge aranır.

- [ ] **1.5 — Belge görevlerini bu bağdan aç**

`OpenDocumentTasks` bugün `document_type_scopes` üzerinden çalışıyor. Eğitmenin **seçtiği eğitimlerden** türeyen şartlar için de görev açacak şekilde genişletilir; görev bağlamına `instructor_requirement_id` eklenir ki hangi şart için istendiği belli olsun.

⚠ `OpenTask`'ın `distinctBy` parametresi zaten var (T4'te bir hata sonrası eklendi: ikinci belge ilkinin kopyası sayılıp sessizce yutuluyordu). Yeni bağlam anahtarı ona eklenir.

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

---

## Görev 2: Aday listesi

**Dosyalar:** `app/Queries/Office/EligibleInstructors.php` · `app/Domain/Instructors/Availability.php` (yeni)

**Üretir:** `EligibleInstructors::for(Training $training, User $planner): Collection`

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

```php
it('hides an instructor whose certificate expires before the training', function (): void {
    // egitmen-spec §3.1, K-1
    $training = Training::factory()->create(['starts_on' => '2026-09-03']);
    instructorAbleToTeach($training, ['certificate_valid_until' => '2026-08-01']);

    expect(app(EligibleInstructors::class)->for($training, planner()))->toBeEmpty();
});

it('hides an instructor who is missing a required paper', function (): void {
    $training = Training::factory()->create();
    $requirement = requirementOn($training->definition, requiresDocument: true);
    instructorAbleToTeach($training); // belge yüklenmedi

    expect(app(EligibleInstructors::class)->for($training, planner()))->toBeEmpty();
});

it('hides an instructor already confirmed on another training that day', function (): void {
    // ⛔ Çakışmanın tek kaynağı kesinleşmiş atamalardır (egitmen-spec §6.5, K-4).
    $instructor = instructorAbleToTeach($first = Training::factory()->create(['starts_on' => '2026-09-03', 'ends_on' => '2026-09-03']));
    confirmAssignment($first, $instructor);

    $second = Training::factory()->create(['starts_on' => '2026-09-03', 'ends_on' => '2026-09-03']);
    allowToTeach($instructor, $second);

    expect(app(EligibleInstructors::class)->for($second, planner()))->toBeEmpty();
});

it('does not treat a pending offer as a conflict', function (): void {
    // ⛔ Bekleyen teklif takvimi bloklamaz (egitmen-spec §6.5).
    $instructor = instructorAbleToTeach($first = Training::factory()->create(['starts_on' => '2026-09-03']));
    offerTo($first, $instructor);

    $second = Training::factory()->create(['starts_on' => '2026-09-03']);
    allowToTeach($instructor, $second);

    expect(app(EligibleInstructors::class)->for($second, planner())->count())->toBe(1);
});

it('narrows the list to the planner pool when their setting says so', function (): void {
    // egitmen-spec §6.3: planlamacı ayarı — havuzum / tümü. Havuz AD-23'ün
    // ağacından gelir; eğitmen kaydında ikinci bir "sorumlu" alanı AÇILMAZ.
    $training = Training::factory()->create();
    $outsider = instructorAbleToTeach($training);

    $planner = planner(['instructor_visibility' => 'own_pool']);

    expect(app(EligibleInstructors::class)->for($training, $planner))->toBeEmpty();
});
```

- [ ] **2.2 — Koş, düştüğünü gör** · **2.3 — Yaz** · **2.4 — Koş** · **2.5 — Commit**

⚠ Uygunluk kuralları `app/Domain/Instructors/Availability.php`'de **framework'süz** durur (AD-25): belge süresi, sertifika iptali, iki kuruluş sınırı, yetkinlik. Sorgu onu çağırır, kuralı kendi içinde tekrar etmez.

---

## Görev 3: Atama zinciri

**Dosyalar:** `database/migrations/2026_08_15_000200_create_instructor_assignments.php` · `app/Actions/Office/OpenInstructorRequest.php` · `app/Actions/Office/SendInstructorOffers.php` · `app/Actions/Office/AcceptInstructorOffer.php` · `app/Actions/Office/DeclineInstructorOffer.php` · `app/Actions/Office/SelectInstructor.php` · `app/Console/Commands/ExpireInstructorOffers.php` (yeni)

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

```php
it('lets only one instructor through when the mode is first accepted', function (): void {
    // ⛔ egitmen-spec §6.4, K-3: kabul atomiktir ve veritabanında kapanır.
    $training = Training::factory()->create();
    $request = requestFor($training, needed: 1, mode: 'first_accepted');

    $a = offerTo($request, instructorAbleToTeach($training));
    $b = offerTo($request, instructorAbleToTeach($training));

    $accept = fn ($offer): bool => rescue(fn (): bool => tap(true, fn () => app(AcceptInstructorOffer::class)($offer)), false);

    expect(array_filter([$accept($a), $accept($b)]))->toHaveCount(1);
});

it('leaves the seat open until somebody is selected in selection mode', function (): void {
    // egitmen-spec §6.2: kontenjanı seçim kapatır, kabul değil.
    $training = Training::factory()->create();
    $request = requestFor($training, needed: 1, mode: 'selection');

    app(AcceptInstructorOffer::class)(offerTo($request, instructorAbleToTeach($training)));

    expect($request->fresh()->confirmed_count)->toBe(0);
});

it('marks the unchosen accepters not_selected and tells them', function (): void {
    // egitmen-spec §6.2, K-5: cevap verip karşılık alamamak, hiç sorulmamaktan kötüdür.
    Notification::fake();

    $training = Training::factory()->create();
    $request = requestFor($training, needed: 1, mode: 'selection');

    $chosen = offerTo($request, instructorAbleToTeach($training));
    $other = offerTo($request, instructorAbleToTeach($training));

    app(AcceptInstructorOffer::class)($chosen);
    app(AcceptInstructorOffer::class)($other);
    app(SelectInstructor::class)(planner(), $chosen->fresh());

    expect($other->fresh()->status)->toBe('not_selected');
    Notification::assertSentTo($other->instructor->user, InstructorOfferClosed::class);
});

it('escalates when every candidate declines', function (): void {
    // egitmen-spec §6.7, K-8
    $training = Training::factory()->create();
    $request = requestFor($training, needed: 1, mode: 'first_accepted');

    foreach (range(1, 2) as $i) {
        app(DeclineInstructorOffer::class)(offerTo($request, instructorAbleToTeach($training)), 'Müsait değilim');
    }

    expect($request->fresh()->status)->toBe('escalated');
});

it('expires an offer from its own send time', function (): void {
    // ⚠ egitmen-spec §6.7: süre HER TEKLİFİN kendi gönderim anından işler.
    $training = Training::factory()->create();
    $request = requestFor($training, needed: 1, mode: 'first_accepted');
    $offer = offerTo($request, instructorAbleToTeach($training), sentAt: FrozenClock::INSTANT->subDays(5));

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

    expect($offer->fresh()->status)->toBe('expired');
});

it('withdraws open offers once the need is met', function (): void {
    // egitmen-spec §6.8, K-9
    Notification::fake();

    $training = Training::factory()->create();
    $request = requestFor($training, needed: 1, mode: 'first_accepted');

    $winner = offerTo($request, instructorAbleToTeach($training));
    $loser = offerTo($request, instructorAbleToTeach($training));

    app(AcceptInstructorOffer::class)($winner);

    expect($loser->fresh()->status)->toBe('withdrawn');
});
```

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

Üç tablo:

```php
// İhtiyaç: kaç eğitmen, hangi mod
Schema::create('training_instructor_requests', function (Blueprint $table): void {
    $table->bigIncrements('id');
    $table->unsignedBigInteger('training_id');
    $table->foreign('training_id')->references('id')->on('trainings')->cascadeOnDelete();

    $table->unsignedTinyInteger('needed_count');

    // ⚠ Mod GLOBAL AYAR DEĞİL, istek bazında seçim (egitmen-spec §6.2):
    // aynı firma iki durumu da yaşar.
    $table->string('mode', 20); // first_accepted | selection

    $table->unsignedTinyInteger('confirmed_count')->default(0);
    $table->string('status', 20)->default('open'); // open | filled | escalated | cancelled

    $table->timestampsTz(precision: 6);
    $table->index(['training_id', 'status']);
});

// Teklif: adaya gönderilen
Schema::create('training_instructor_offers', function (Blueprint $table): void {
    $table->bigIncrements('id');
    $table->unsignedBigInteger('request_id');
    $table->foreign('request_id')->references('id')->on('training_instructor_requests')->cascadeOnDelete();

    $table->unsignedBigInteger('instructor_id');
    $table->foreign('instructor_id')->references('id')->on('instructors')->restrictOnDelete();

    // pending | accepted | declined | not_selected | withdrawn | expired
    $table->string('status', 20)->default('pending');

    $table->timestampTz('sent_at', precision: 6);
    $table->timestampTz('responds_by', precision: 6);
    $table->timestampTz('responded_at', precision: 6)->nullable();
    $table->text('decline_reason')->nullable();

    $table->timestampsTz(precision: 6);

    $table->unique(['request_id', 'instructor_id']);
    $table->index(['status', 'responds_by']);
});

// Kesinleşmiş atama
Schema::create('training_instructor_assignments', function (Blueprint $table): void {
    $table->bigIncrements('id');
    $table->unsignedBigInteger('training_id');
    $table->foreign('training_id')->references('id')->on('trainings')->cascadeOnDelete();

    $table->unsignedBigInteger('instructor_id');
    $table->foreign('instructor_id')->references('id')->on('instructors')->restrictOnDelete();

    $table->unsignedBigInteger('offer_id')->nullable();
    $table->foreign('offer_id')->references('id')->on('training_instructor_offers')->nullOnDelete();

    // confirmed | reconfirming | released
    $table->string('status', 20)->default('confirmed');

    $table->timestampTz('confirmed_at', precision: 6);
    $table->timestampsTz(precision: 6);

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

⛔ **Kesinleşmiş atama silinemez** (`egitmen-spec` K-25): T1'in `deny_training_delete` deseninde bir tetikleyici eklenir — `status = 'confirmed'` satırı DELETE reddedilir.

- [ ] **3.3 — Kabul kilidini yaz**

```php
/**
 * ⛔ The seat closes in the DATABASE, not in a "is it full" check.
 *
 * egitmen-spec §6.4 says it plainly: if two instructors press accept in the
 * same second, an application level check accepts both and two instructors turn
 * up to one training. The row is locked, the count re-read under the lock, and
 * a CHECK constraint holds `confirmed_count <= needed_count` if a future caller
 * forgets the lock.
 */
```

- [ ] **3.4 — Koş** · **3.5 — Commit**

---

## Görev 4: Esaslı değişiklik ve yeniden onay

**Dosyalar:** `app/Listeners/ReconfirmAssignmentsOnEssentialChange.php` (yeni)

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

```php
it('sends a confirmed assignment back for reconfirmation when the date moves', function (): void {
    // egitmen-spec §6.10, K-11
    $training = Training::factory()->create(['starts_on' => '2026-09-03']);
    $assignment = confirmAssignment($training, instructorAbleToTeach($training));

    app(UpdateTrainingPlan::class)(planner(), $training, startsOn: '2026-09-10');

    expect($assignment->fresh()->status)->toBe('reconfirming');
});

it('keeps the calendar blocked while reconfirmation is pending', function (): void {
    // ⚠ Takvim bloğu SÜRER: eğitmen henüz hayır demedi.
    $training = Training::factory()->create(['starts_on' => '2026-09-03']);
    $instructor = instructorAbleToTeach($training);
    confirmAssignment($training, $instructor);

    app(UpdateTrainingPlan::class)(planner(), $training, startsOn: '2026-09-10');

    $other = Training::factory()->create(['starts_on' => '2026-09-10']);
    allowToTeach($instructor, $other);

    expect(app(EligibleInstructors::class)->for($other, planner()))->toBeEmpty();
});

it('alerts three sides when the instructor refuses the change', function (): void {
    // egitmen-spec K-11: satış dahil üç tarafa alert.
    Notification::fake();

    $training = Training::factory()->create();
    $assignment = confirmAssignment($training, instructorAbleToTeach($training));
    app(UpdateTrainingPlan::class)(planner(), $training, startsOn: '2026-09-10');

    app(DeclineReconfirmation::class)($assignment->fresh(), 'O tarihte müsait değilim');

    Notification::assertSentTimes(AssignmentReleased::class, 3);
});
```

- [ ] **4.2 — Yaz** · **4.3 — Koş** · **4.4 — Commit**

---

## Görev 5: Planlama maliyet kalemleri

**Dosyalar:** `database/migrations/2026_08_15_000300_create_training_cost_items.php` · `app/Actions/Office/AddCostItem.php` · `app/Actions/Office/RecordActualCost.php` · `app/Domain/Trainings/CostVariance.php` (yeni)

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

```php
it('refuses a variance with no reason', function (): void {
    // egitmen-spec §7.2-7.3, K-13
    $item = costItem(planned: 1000);

    expect(fn () => app(RecordActualCost::class)(planner(), $item, actual: 1200, reason: ''))
        ->toThrow(ValidationException::class);
});

it('never lets the planned amount change afterwards', function (): void {
    // ⛔ K-13: planlanan tutar geriye dönük HİÇBİR YOLDAN değişmez.
    $item = costItem(planned: 1000);

    expect(fn () => DB::table('training_cost_items')->where('id', $item->getKey())
        ->update(['planned_amount' => 900]))->toThrow(QueryException::class);
});

it('records a negative variance the same way as a positive one', function (): void {
    // ⚠ K-13: ± ikisi de kaydedilir — ucuza gelmek de bir sapmadır.
    $item = costItem(planned: 1000);

    app(RecordActualCost::class)(planner(), $item, actual: 800, reason: 'Otel indirim yaptı');

    expect($item->fresh()->variance_reason)->not->toBeNull();
});

it('takes the threshold over the whole training, not one item', function (): void {
    // egitmen-spec §7.5, K-15: taban kalem başına gerçekleşen varsa gerçekleşen,
    // yoksa planlanan — bunların TOPLAMI.
    $training = Training::factory()->create();
    costItem(planned: 400, training: $training);
    costItem(planned: 400, training: $training, actual: 900);

    expect(app(TrainingCostTotal::class)->for($training))->toBe(1300.0);
});
```

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

```php
Schema::create('training_cost_items', function (Blueprint $table): void {
    $table->bigIncrements('id');
    $table->unsignedBigInteger('training_id');
    $table->foreign('training_id')->references('id')->on('trainings')->cascadeOnDelete();

    // instructor_fee | travel | accommodation | venue | other — yönetilen liste
    $table->unsignedBigInteger('kind_option_value_id');
    $table->foreign('kind_option_value_id')->references('id')->on('option_list_values')->restrictOnDelete();

    $table->string('label');

    // ⛔ İkisi ayrı kolon: planlanan geriye dönük değişmez (K-13), gerçekleşen
    // birikir. Tek kolon olsaydı "ne planlamıştık" sorusu cevapsız kalırdı.
    $table->decimal('planned_amount', 12, 2)->nullable();
    $table->decimal('actual_amount', 12, 2)->nullable();
    $table->text('variance_reason')->nullable();

    // paid_by_company | paid_by_instructor (egitmen-spec §7.6)
    $table->string('paid_by', 20)->default('paid_by_company');

    $table->unsignedBigInteger('supplier_id')->nullable();
    $table->foreign('supplier_id')->references('id')->on('suppliers')->nullOnDelete();

    $table->unsignedBigInteger('instructor_id')->nullable();
    $table->foreign('instructor_id')->references('id')->on('instructors')->nullOnDelete();

    $table->timestampsTz(precision: 6);
    $table->index(['training_id', 'kind_option_value_id']);
});
```

Ve planlanan tutarı koruyan tetikleyici:

```sql
CREATE OR REPLACE FUNCTION app.deny_planned_amount_change() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.planned_amount IS DISTINCT FROM OLD.planned_amount THEN
    RAISE EXCEPTION 'planned amount cannot be changed: item %', OLD.id
      USING ERRCODE = '42501';
  END IF;
  RETURN NEW;
END $$;
```

- [ ] **5.3 — Yaz** · **5.4 — Koş** · **5.5 — Commit**

---

## Görev 6: Donmuş kanıt seti

**Dosyalar:** `database/migrations/2026_08_15_000400_create_training_evidence_documents.php` · `app/Actions/Office/FreezeInstructorEvidence.php` · `app/Policies/TrainingEvidencePolicy.php` (yeni)

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

```php
it('copies the files onto the training when the assignment is confirmed', function (): void {
    Storage::fake('local');

    $training = Training::factory()->create();
    $instructor = instructorAbleToTeach($training);
    $document = documentFor($instructor, DocumentType::factory()->create());

    confirmAssignment($training, $instructor);

    $evidence = TrainingEvidenceDocument::query()->where('training_id', $training->getKey())->first();

    expect($evidence)->not->toBeNull()
        ->and(Storage::disk('local')->exists($evidence->storage_path))->toBeTrue()
        ->and($evidence->storage_path)->not->toBe($document->versions()->latest('version')->first()->storage_path);
});

it('keeps the copy when the instructor side changes', function (): void {
    // ⚠ Sets'in gerekçesi: "yarın eğitmen evrağı silinmiş, referans evrak gitmiş
    // gibi bir sıkıntımız olmaz."
    Storage::fake('local');

    $training = Training::factory()->create();
    $instructor = instructorAbleToTeach($training);
    $document = documentFor($instructor, DocumentType::factory()->create(), critical: false);
    confirmAssignment($training, $instructor);

    $document->delete();

    expect(TrainingEvidenceDocument::query()->where('training_id', $training->getKey())->exists())->toBeTrue();
});

it('refuses the evidence set to somebody who may only see the training', function (): void {
    // ⛔ spec §9.6: eğitimi görebilmek kanıt setini görebilmek değildir.
    $training = Training::factory()->create();
    $viewer = userWithPermissions(['training.view.all']);

    expect($viewer->can('view', evidenceOn($training)))->toBeFalse();
});

it('writes an access row when the evidence is opened', function (): void {
    // AD-21, spec §9.6
    $training = Training::factory()->create();
    $reader = userWithPermissions(['training.view.all', 'document.view_special_category']);

    app(ReadTrainingEvidence::class)($reader, evidenceOn($training));

    expect(AuditAccess::query()->where('subject_type', TrainingEvidenceDocument::class)->exists())->toBeTrue();
});
```

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

Künye + dosya alanları: `training_id`, `instructor_id`, `instructor_requirement_id`, `document_type_id`, `title`, `serial`, `issued_on`, `expires_on`, `content_hash`, `byte_size`, `storage_disk`, `storage_path`, `key_version`, `copied_at`, `copied_by`.

⚠ **Saklama süresi kolonu yok, kural veriden gelir** (spec §9.5): taban `training_definitions.record_retention_years`. Bir kolon eklemek, aynı sayıyı iki yerde tutmak olurdu.

⛔ Kanıt seti silinemez — T1'in tetikleyici deseni.

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

Atama `confirmed` olduğunda tetiklenir (listener). Eğitmenin **o eğitimin şartlarını karşılayan** belgelerinin son versiyonlarını `SecureFileStore` ile kopyalar; künyeyi yazar.

- [ ] **6.4 — Koş** · **6.5 — Commit**

---

## Görev 7: Tedarikçiler

**Dosyalar:** `database/migrations/2026_08_15_000500_create_suppliers.php` · `app/Actions/Office/CreateSupplier.php` · `app/Queries/Office/SupplierPriceHistory.php` (yeni)

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

```php
it('answers what a hotel cost in a province last year', function (): void {
    // Çatı §11/§186: bölgesel fiyat tarihçesi.
    $izmit = provinceValue('Kocaeli');
    $hotel = Supplier::factory()->create(['city_option_value_id' => $izmit->getKey()]);

    $hotel->priceQuotes()->create([
        'service' => 'accommodation',
        'amount' => 2400,
        'quoted_on' => '2025-09-01',
    ]);

    $history = app(SupplierPriceHistory::class)->forProvince($izmit, 'accommodation');

    expect($history->first()['amount'])->toBe('2400.00');
});

it('keeps a supplier out of the picker once it is inactive', function (): void {
    Supplier::factory()->create(['is_active' => false]);

    expect(app(AssignableSuppliers::class)->all())->toBeEmpty();
});
```

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

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

    // hotel | agency | vehicle — yönetilen liste (AD-08)
    $table->unsignedBigInteger('kind_option_value_id');
    $table->foreign('kind_option_value_id')->references('id')->on('option_list_values')->restrictOnDelete();

    $table->unsignedBigInteger('city_option_value_id')->nullable();
    $table->foreign('city_option_value_id')->references('id')->on('option_list_values')->nullOnDelete();

    $table->string('contact_name')->nullable();
    $table->string('contact_phone', 32)->nullable();
    $table->string('contact_email')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestampsTz(precision: 6);

    $table->index(['kind_option_value_id', 'city_option_value_id']);
});

Schema::create('supplier_price_quotes', function (Blueprint $table): void {
    $table->bigIncrements('id');
    $table->unsignedBigInteger('supplier_id');
    $table->foreign('supplier_id')->references('id')->on('suppliers')->cascadeOnDelete();

    $table->string('service', 32);
    $table->decimal('amount', 12, 2);
    $table->date('quoted_on');
    $table->date('valid_until')->nullable();
    $table->text('note')->nullable();

    $table->timestampsTz(precision: 6);
    $table->index(['supplier_id', 'service', 'quoted_on']);
});
```

⚠ **Tedarikçi portalı yoktur** — tasarımın kendi notu *"tedarikçi portalı sonraki faz"*. Fiyatları planlamacı elle girer; çatı §186'nın *"MVP: manuel giriş"* notu budur.

- [ ] **7.3 — Yaz** · **7.4 — Koş** · **7.5 — Commit**

---

## Görev 8: Eğitim evrakları

**Dosyalar:** `app/Actions/Office/OpenTrainingDocumentTasks.php` · `app/Models/DocumentType.php` (değişen)

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

```php
it('opens a task for a missing training paper', function (): void {
    // Çatı §16 son paragrafı, AD-24: kapsam = eğitim.
    $type = DocumentType::factory()->create(['uploadable_by' => DocumentType::UPLOADER_OPERATION]);
    $type->scopeRows()->create(['scope' => DocumentType::SCOPE_TRAINING]);

    $training = Training::factory()->create();

    app(OpenTrainingDocumentTasks::class)($training);

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

⚠ `DocumentType::SCOPE_TRAINING` sabiti eklenecek; mevcut kapsam değerleri **Türkçe** (`SCOPE_INSTRUCTOR = 'egitmen'`), yenisi de öyle olur — tutarlılık, tercih değil.

- [ ] **8.2 — Yaz** · **8.3 — Koş** · **8.4 — Commit**

---

## Görev 9: Ekranlar

**Dosyalar:** `resources/js/pages/Office/Trainings/Show.vue` (genişleyen) · `resources/js/pages/Office/Calendar/Index.vue` · `resources/js/pages/Office/Suppliers/Index.vue` · `resources/js/organisms/InstructorCandidateList.vue` · `CostItemTable.vue` (yeni)

| Ekran | Tasarım | İçerik |
|---|---|---|
| Eğitim detayı — eğitmen bölümü | `22` | Aday listesi, ihtiyaç adedi, mod seçimi, bekleyen/kesinleşmiş ayrımı |
| Eğitim detayı — maliyet bölümü | `21`'in maliyet modalı | Kalem tablosu: planlanan · gerçekleşen · ödeme sorumlusu · sapma gerekçesi |
| Takvim | `23` | Eğitimler tarih ekseninde; statü rozeti, il, kontenjan, eğitmen durumu |
| Tedarikçiler | `23`'ün tedarikçi bölümü | Liste + ekleme drawer'ı + fiyat tarihçesi |

⛔ Kilitler nedenini söyler: *"Bu eğitmen belgesi eksik olduğu için listede yok"* değil — şartı taşımayan eğitmen **hiç görünmez** (K-1). Görünmeyeni açıklamak, görünmemesi gereken bilgiyi vermektir.

- [ ] **9.1 — Testler** · **9.2 — Yaz** · **9.3 — Koş** · **9.4 — Commit**

---

## Görev 10: Kapanış

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

```bash
php artisan route:list | grep -E "egitmen|takvim|tedarikci"
grep -rn "OpenInstructorRequest\|AcceptInstructorOffer\|SelectInstructor\|FreezeInstructorEvidence\|AddCostItem\|CreateSupplier" app/Http/ app/Listeners/ app/Console/
php artisan schedule:list | grep Expire
```

- [ ] **10.2 — Grup koşuları:** `planlama`, `egitmen`, `omurga`, `mevzuat`

- [ ] **10.3 — Proje haritasını güncelle** — §4.3 ve §4.6: eğitmen ataması, maliyet ve tedarikçi satırları ✓ olur.

- [ ] **10.4 — Ticket kapanışı** — TeamCity yeşil.

---

## Kabul kriterleri

| # | Kriter | Kaynak |
|---|---|---|
| T2-1 | Eğitmenin şartları verebileceği eğitimlerden türer, ayrıca işaretlenmez | spec P-12 |
| T2-2 | Bir şart kabul edilen belge türlerinden herhangi biriyle karşılanır | spec P-13 |
| T2-3 | Belge süresi dolunca şart düşer ve eğitmen aday listesinden çıkar | spec §9.1 |
| T2-4 | Şartı eksik eğitmen aday listesinde görünmez | egitmen K-1 |
| T2-5 | Sertifikası dolmuş / iptal edilmiş / iki kuruluş sınırını aşmış eğitmen listede yok | egitmen K-1 |
| T2-6 | Planlamacı ayarına göre yalnız havuzunu veya tümünü görür | egitmen K-2 |
| T2-7 | `first_accepted` modunda eşzamanlı iki kabulden yalnız biri geçer — veritabanı seviyesinde | egitmen K-3 |
| T2-8 | Çakışmanın tek kaynağı kesinleşmiş atamalardır; bekleyen teklif çakışma üretmez | egitmen K-4, spec P-22 |
| T2-9 | `selection` modunda kontenjanı seçim kapatır; seçilmeyen `not_selected` olur ve bildirim alır | egitmen K-5 |
| T2-10 | Uygunluk hem kabulde hem seçimde değerlendirilir; seçimde bağlayıcıdır | egitmen K-6 |
| T2-11 | Süresi dolan teklif `expired` olur; süre her teklifin kendi gönderim anından işler | egitmen K-7 |
| T2-12 | Tüm adaylar reddederse istek `escalated` olur ve alert üretir | egitmen K-8 |
| T2-13 | İhtiyaç karşılanınca açık teklifler `withdrawn` olur | egitmen K-9 |
| T2-14 | Tarih/yer değişince atama `reconfirming` olur, takvim bloğu sürer, ret hâlinde üç tarafa alert | egitmen K-11 |
| T2-15 | Sıfırdan farklı sapma gerekçesiz kaydedilemez; planlanan tutar geriye dönük değişmez | egitmen K-13 |
| T2-16 | Eşik toplam maliyete bakar: kalem başına gerçekleşen varsa gerçekleşen, yoksa planlanan | egitmen K-15 |
| T2-17 | Atama kesinleştiğinde eğitmenin belgelerinin **dosyaları** eğitime kopyalanır | spec P-14 |
| T2-18 | Eğitmen tarafındaki değişiklik kopyayı etkilemez | spec §9.4 |
| T2-19 | Kanıt setine erişim ayrı izin ister ve loglanır | spec P-41 |
| T2-20 | Kanıt setinin saklama tabanı eğitim tanımından okunur | spec P-42 |
| T2-21 | Tedarikçi fiyatları il bazında sorgulanabilir | spec P-23 |
| T2-22 | Eğitimin zorunlu evrakı eksikse görev düşer | spec P-29 |
| T2-23 | Kesinleşmiş atama hard delete edilemez | egitmen K-25 |

---

## Kapanış adresi verilen

| Ne | Nerede |
|---|---|
| Masraf onayı, eğitmen carisi, ödeme kaydı | Mali tur — `egitmen-spec` §7.6-7.8 |
| Eğitmen paneli (kabul/ret ekranı, saha, masraf girişi) | Eğitmen turu — tasarım `40`, `41` |
| Planlama yazışması (tedarikçi e-postaları, inbound) | AD-20 uygulaması, yazışma turu |
| Satılabilirlik göstergesi | `egitmen-spec` §6.11, satış turu |

---

## Revizyon geçmişi

| Sürüm | Tarih | Değişiklik |
|---|---|---|
| v1.0 | 29.07.2026 | İlk sürüm. 10 görev, 23 kabul kriteri. Spec §8, §9, §12 ve `egitmen-spec` §3, §4, §6, §7. |
