# Переделка миграции проектов — План 1: фундамент данных

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Заложить модель данных под per-субъект экспорт и cap-распределение: `supplier_projects.subject_code`, pivot `project_supplier_links` (замена 3 FK-слотов), `deals.subject_code`, сид `system_settings.supplier_export_mode`, бэкофилл существующих привязок.

**Architecture:** Инкрементальные миграции (raw SQL, idempotent guard через `to_regclass`/`Schema::hasColumn`/`IF NOT EXISTS`) + параллельная правка консолидированного `db/schema.sql` + запись в `db/CHANGELOG_schema.md`. Pivot — SaaS-level (без RLS, как `supplier_projects`): пишется sync-флоу, читается sharing-флоу через BYPASSRLS-роль `crm_supplier_worker`. Колонки `projects.supplier_b{1,2,3}_project_id` остаются (двойная запись) до зелёной регрессии — удаление в follow-up.

**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (NULLS NOT DISTINCT для unique-индекса пула «Вся РФ») / Pest 4 / squawk (линт миграций) / Eloquent belongsToMany.

**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.2, §5.

**Prereq:** выполняется в изолированном worktree (см. `superpowers:using-git-worktrees`) от свежего `origin/main`, либо на ветке `feat/project-migration-redesign`. После каждой миграции — `php artisan migrate:fresh` smoke не запускать на каждом шаге; полный прогон — Task 7.

---

## Task 1: `supplier_projects.subject_code` + составной unique-индекс

**Files:**

- Create: `app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php`
- Modify: `db/schema.sql:902-930` (блок `CREATE TABLE supplier_projects` + индекс)
- Modify: `db/CHANGELOG_schema.md` (новая запись сверху)
- Test: `app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Models\SupplierProject;
use Illuminate\Database\QueryException;

it('allows same (platform, unique_key) with different subject_code', function (): void {
    SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
        'subject_code' => 83, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    expect(SupplierProject::query()->where('unique_key', 'okna.ru')->count())->toBe(2);
});

it('rejects duplicate (platform, unique_key, subject_code) including NULL pool', function (): void {
    SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
        'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    expect(fn () => SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
        'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
    ]))->toThrow(QueryException::class);
});

it('rejects subject_code out of 1..89 range', function (): void {
    expect(fn () => SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
        'subject_code' => 90, 'current_limit' => 0, 'sync_status' => 'ok',
    ]))->toThrow(QueryException::class);
});
```

- [ ] **Step 2: Прогнать — убедиться, что падает**

Run: `php artisan test --filter=SupplierProjectSubjectCodeTest`
Expected: FAIL — `subject_code` ещё не fillable / колонки нет (массовое присвоение игнорит поле, unique старый, тесты падают на count/отсутствии исключения).

- [ ] **Step 3: Написать миграцию**

`app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php`:

```php
<?php

declare(strict_types=1);

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

/**
 * Per-субъект supplier_projects (эпик переделки миграции проектов, v8.26).
 *
 * +subject_code SMALLINT NULL (1..89 субъект РФ; NULL = пул «Вся РФ»).
 * Старый unique (platform, unique_key) → (platform, unique_key, subject_code)
 * NULLS NOT DISTINCT — пул «Вся РФ» уникален per (platform, unique_key).
 *
 * Guard: migrate:fresh грузит schema.sql v8.26 (delta уже там) до миграций.
 */
return new class extends Migration
{
    public function up(): void
    {
        if (! Schema::hasColumn('supplier_projects', 'subject_code')) {
            DB::statement('ALTER TABLE supplier_projects ADD COLUMN subject_code SMALLINT');
        }

        DB::statement(<<<'SQL'
            DO $$ BEGIN
              IF NOT EXISTS (
                SELECT 1 FROM pg_constraint WHERE conname = 'chk_supplier_projects_subject_code'
              ) THEN
                ALTER TABLE supplier_projects
                  ADD CONSTRAINT chk_supplier_projects_subject_code
                  CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
              END IF;
            END $$;
        SQL);
        DB::statement('ALTER TABLE supplier_projects VALIDATE CONSTRAINT chk_supplier_projects_subject_code');

        DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_unique_key_unique');
        DB::statement(
            'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_key_subject_unique '
            .'ON supplier_projects (platform, unique_key, subject_code) NULLS NOT DISTINCT'
        );

        DB::statement(
            "COMMENT ON COLUMN supplier_projects.subject_code IS "
            ."'Субъект РФ 1..89 (resources/js/constants/regions.ts). NULL = пул «Вся РФ». Эпик миграции проектов v8.26.'"
        );
    }

    public function down(): void
    {
        DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_key_subject_unique');
        DB::statement(
            'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_unique_key_unique '
            .'ON supplier_projects (platform, unique_key)'
        );
        DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT IF EXISTS chk_supplier_projects_subject_code');

        if (Schema::hasColumn('supplier_projects', 'subject_code')) {
            Schema::table('supplier_projects', fn ($t) => $t->dropColumn('subject_code'));
        }
    }
};
```

- [ ] **Step 4: Зеркалить в `db/schema.sql`**

В блоке `CREATE TABLE supplier_projects` ([schema.sql:902](../../../db/schema.sql#L902)) после строки `current_regions JSONB,` добавить:

```sql
    subject_code            SMALLINT,                                      -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26)
```

В список CHECK добавить:

```sql
,
    CONSTRAINT chk_supplier_projects_subject_code
        CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
```

Заменить индекс `supplier_projects_platform_unique_key_unique` ([schema.sql:929-930](../../../db/schema.sql#L929)) на:

```sql
CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique
    ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT;
```

- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (новая запись сверху, над v8.25)

```markdown
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)

`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
+1 CHECK `chk_supplier_projects_subject_code`. Unique-индекс
`supplier_projects_platform_unique_key_unique` (platform, unique_key) → заменён на
`supplier_projects_platform_key_subject_unique` (platform, unique_key, subject_code)
NULLS NOT DISTINCT (пул «Вся РФ» уникален per источник×платформа).
Эпик: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.2.
Миграция: 2026_05_20_100000_supplier_projects_subject_code.php (Schema::hasColumn +
pg_constraint guards). Индексы: −1 +1 (нет дельты count). RLS не затронут (SaaS-level).
```

- [ ] **Step 6: Сделать `subject_code` fillable в модели**

В `app/app/Models/SupplierProject.php` в `$fillable` ([SupplierProject.php:32](../../../app/app/Models/SupplierProject.php#L32)) добавить `'subject_code',`; в `casts()` ([:45](../../../app/app/Models/SupplierProject.php#L45)) добавить `'subject_code' => 'integer',`.

- [ ] **Step 7: Прогнать тест — зелёный**

Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=SupplierProjectSubjectCodeTest`
Expected: PASS (3 теста). Вывести полный вывод.

- [ ] **Step 8: squawk + pint + larastan на изменённое**

Run: `composer pint -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php; composer stan`
Expected: 0 ошибок (larastan может потребовать `php artisan ide-helper:models -W` для нового атрибута — выполнить при @property-ошибке).

- [ ] **Step 9: Коммит** (явный путь — shared-dir)

```bash
git add -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(supplier): supplier_projects.subject_code + per-subject unique index (v8.26)" -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
```

---

## Task 2: pivot-таблица `project_supplier_links` (замена 3 FK-слотов)

**Files:**

- Create: `app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`
- Modify: `db/schema.sql` (после блока ALTER TABLE projects FK ~[schema.sql:947](../../../db/schema.sql#L947))
- Modify: `db/CHANGELOG_schema.md`
- Test: `app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Support\Facades\DB;

it('creates pivot row linking project to supplier_project', function (): void {
    $project = Project::factory()->create();
    $sp = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'link.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    DB::table('project_supplier_links')->insert([
        'project_id' => $project->id,
        'supplier_project_id' => $sp->id,
        'platform' => 'B1',
        'subject_code' => 82,
    ]);

    expect(DB::table('project_supplier_links')
        ->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->exists())->toBeTrue();
});

it('cascades pivot deletion when supplier_project is deleted', function (): void {
    $project = Project::factory()->create();
    $sp = SupplierProject::query()->create([
        'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'casc.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B2', 'subject_code' => 82,
    ]);

    $sp->delete();

    expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->exists())->toBeFalse();
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=ProjectSupplierLinkSchemaTest`
Expected: FAIL — таблицы `project_supplier_links` нет.

- [ ] **Step 3: Написать миграцию**

`app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`:

```php
<?php

declare(strict_types=1);

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

/**
 * M:N pivot между projects (tenant) и supplier_projects (SaaS, shared) — v8.26.
 *
 * Заменяет 3 FK-слота projects.supplier_b{1,2,3}_project_id (которые не вмещают
 * per-субъект модель: N субъектов × до 3 платформ = до 3N связей).
 * SaaS-level (без RLS, как supplier_projects): пишется sync-флоу, читается
 * sharing-флоу через BYPASSRLS-роль crm_supplier_worker.
 */
return new class extends Migration
{
    public function up(): void
    {
        $exists = DB::selectOne("SELECT to_regclass('public.project_supplier_links') AS r");
        if ($exists !== null && $exists->r !== null) {
            return;
        }

        DB::unprepared(<<<'SQL'
            CREATE TABLE project_supplier_links (
                id BIGSERIAL PRIMARY KEY,
                project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
                supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
                platform VARCHAR(4) NOT NULL,
                subject_code SMALLINT,
                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                CONSTRAINT chk_psl_platform CHECK (platform IN ('B1', 'B2', 'B3')),
                CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
            );

            CREATE INDEX idx_psl_supplier_project ON project_supplier_links (supplier_project_id);
            CREATE INDEX idx_psl_project ON project_supplier_links (project_id);
        SQL);
    }

    public function down(): void
    {
        DB::statement('DROP TABLE IF EXISTS project_supplier_links');
    }
};
```

- [ ] **Step 4: Зеркалить в `db/schema.sql`** — после блока `ALTER TABLE projects ADD CONSTRAINT projects_supplier_b3_project_id_fk ...` ([schema.sql:947](../../../db/schema.sql#L947)) добавить:

```sql

-- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id).
CREATE TABLE project_supplier_links (
    id                      BIGSERIAL PRIMARY KEY,
    project_id              BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    supplier_project_id     BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
    platform                VARCHAR(4) NOT NULL,
    subject_code            SMALLINT,                                      -- субъект РФ 1..89; NULL = пул «Вся РФ»
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
    CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
);
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
CREATE INDEX idx_psl_project ON project_supplier_links(project_id);
```

- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (запись сверху)

```markdown
## v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)

+1 таблица SaaS-level `project_supplier_links` (project_id, supplier_project_id,
platform, subject_code, created_at): M:N замена 3 FK-слотов
projects.supplier_b{1,2,3}_project_id (per-субъект модель). +2 FK (оба ON DELETE
CASCADE), +1 CHECK chk_psl_platform, +1 UNIQUE uq_psl_project_supplier, +2 индекса.
Без RLS (как supplier_projects). Старые FK-колонки остаются (двойная запись) до
follow-up. Миграция: 2026_05_20_101000_create_project_supplier_links.php.
```

- [ ] **Step 6: Прогнать тест — зелёный**

Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=ProjectSupplierLinkSchemaTest`
Expected: PASS (2). Полный вывод.

- [ ] **Step 7: squawk-чек миграции**

Run: `composer pint -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`
Expected: OK. (squawk прогонится в pre-commit; CREATE TABLE без backfill — safe.)

- [ ] **Step 8: Коммит**

```bash
git add -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(supplier): project_supplier_links M:N pivot (v8.26)" -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php db/schema.sql db/CHANGELOG_schema.md
```

---

## Task 3: `deals.subject_code` (регион-тег поставщика)

**Files:**

- Create: `app/database/migrations/2026_05_20_102000_deals_subject_code.php`
- Modify: `db/schema.sql` (блок `CREATE TABLE deals` рядом с `region_code` [schema.sql:1612](../../../db/schema.sql#L1612))
- Modify: `db/CHANGELOG_schema.md`
- Test: `app/tests/Feature/Supplier/DealSubjectCodeTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Schema;

it('deals has nullable subject_code column', function (): void {
    expect(Schema::hasColumn('deals', 'subject_code'))->toBeTrue();
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=DealSubjectCodeTest`
Expected: FAIL — колонки нет.

- [ ] **Step 3: Написать миграцию**

`app/database/migrations/2026_05_20_102000_deals_subject_code.php`:

```php
<?php

declare(strict_types=1);

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

/**
 * deals.subject_code — субъект РФ из тега поставщика (raw_payload['tag']) — v8.26.
 *
 * Источник истины региона сделки = тег проекта у поставщика (надёжнее phone-prefix
 * для мобильных). Отдельно от deals.region_code (ISO-3166, phone-derived).
 * deals партиционирована — ADD COLUMN наследуется партициями.
 */
return new class extends Migration
{
    public function up(): void
    {
        if (! Schema::hasColumn('deals', 'subject_code')) {
            DB::statement('ALTER TABLE deals ADD COLUMN subject_code SMALLINT');
        }

        DB::statement(
            "COMMENT ON COLUMN deals.subject_code IS "
            ."'Субъект РФ 1..89 из тега поставщика (raw_payload[tag]). NULL = «Вся РФ»/неизвестно. v8.26.'"
        );
    }

    public function down(): void
    {
        if (Schema::hasColumn('deals', 'subject_code')) {
            Schema::table('deals', fn ($t) => $t->dropColumn('subject_code'));
        }
    }
};
```

- [ ] **Step 4: Зеркалить в `db/schema.sql`** — в блоке `CREATE TABLE deals` после строки `region_code VARCHAR(8),` ([schema.sql:1612](../../../db/schema.sql#L1612)) добавить:

```sql
    subject_code    SMALLINT,                              -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно
```

- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (сверху)

```markdown
## v8.26 (доп) — 2026-05-20 — deals.subject_code

`deals` +1 колонка `subject_code SMALLINT NULL` — субъект РФ из тега поставщика
(raw_payload[tag]); отдельно от region_code (ISO, phone-derived). Наследуется
12 партициями. Миграция: 2026_05_20_102000_deals_subject_code.php.
```

- [ ] **Step 6: Сделать `subject_code` fillable в `Deal`**

В `app/app/Models/Deal.php` добавить `'subject_code'` в `$fillable` и `'subject_code' => 'integer'` в `casts()`.

- [ ] **Step 7: Прогнать тест — зелёный**

Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=DealSubjectCodeTest`
Expected: PASS. Полный вывод.

- [ ] **Step 8: Коммит**

```bash
git add -- app/database/migrations/2026_05_20_102000_deals_subject_code.php app/app/Models/Deal.php app/tests/Feature/Supplier/DealSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(supplier): deals.subject_code from supplier tag (v8.26)" -- app/database/migrations/2026_05_20_102000_deals_subject_code.php app/app/Models/Deal.php app/tests/Feature/Supplier/DealSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
```

---

## Task 4: seed `system_settings.supplier_export_mode`

**Files:**

- Create: `app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php`
- Modify: `db/CHANGELOG_schema.md`
- Test: `app/tests/Feature/Supplier/SupplierExportModeSeedTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;

it('seeds supplier_export_mode = batch by default', function (): void {
    $row = DB::table('system_settings')->where('key', 'supplier_export_mode')->first();

    expect($row)->not->toBeNull()
        ->and($row->value)->toBe('batch')
        ->and($row->type)->toBe('string');
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=SupplierExportModeSeedTest`
Expected: FAIL — строки нет.

- [ ] **Step 3: Написать миграцию (idempotent insert)**

`app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php`:

```php
<?php

declare(strict_types=1);

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

/**
 * Глобальный тумблер режима экспорта проектов поставщику (v8.26).
 * 'batch' (default, прод-безопасно) | 'online'. Резолвится SupplierExportMode (План 3).
 */
return new class extends Migration
{
    public function up(): void
    {
        $exists = DB::table('system_settings')->where('key', 'supplier_export_mode')->exists();
        if ($exists) {
            return;
        }

        DB::table('system_settings')->insert([
            'key' => 'supplier_export_mode',
            'value' => 'batch',
            'type' => 'string',
            'description' => 'Режим экспорта проектов поставщику: batch (ночной 18:00) | online (сразу при правке).',
            'updated_at' => now(),
        ]);
    }

    public function down(): void
    {
        DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
    }
};
```

- [ ] **Step 4: Записать в `db/CHANGELOG_schema.md`** (сверху)

```markdown
## v8.26 (доп) — 2026-05-20 — seed system_settings.supplier_export_mode

Сид-строка `supplier_export_mode='batch'` (тумблер режима экспорта; online|batch).
Не структурное изменение. Миграция: 2026_05_20_103000_seed_supplier_export_mode.php.
```

- [ ] **Step 5: Прогнать тест — зелёный**

Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=SupplierExportModeSeedTest`
Expected: PASS. Полный вывод.

- [ ] **Step 6: Коммит**

```bash
git add -- app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php app/tests/Feature/Supplier/SupplierExportModeSeedTest.php db/CHANGELOG_schema.md
git commit -m "feat(supplier): seed supplier_export_mode toggle (v8.26)" -- app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php app/tests/Feature/Supplier/SupplierExportModeSeedTest.php db/CHANGELOG_schema.md
```

---

## Task 5: Eloquent-связи Project ↔ SupplierProject через pivot

**Files:**

- Modify: `app/app/Models/SupplierProject.php` (relation `projects()`)
- Modify: `app/app/Models/Project.php` (relation `supplierProjects()`)
- Test: `app/tests/Feature/Supplier/ProjectSupplierRelationTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;

it('links project to supplier projects via belongsToMany pivot', function (): void {
    $project = Project::factory()->create();
    $sp1 = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    $sp2 = SupplierProject::query()->create([
        'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
        'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);

    $project->supplierProjects()->attach([
        $sp1->id => ['platform' => 'B1', 'subject_code' => 82],
        $sp2->id => ['platform' => 'B2', 'subject_code' => 82],
    ]);

    expect($project->supplierProjects()->count())->toBe(2)
        ->and($sp1->projects()->pluck('projects.id')->all())->toContain($project->id)
        ->and($project->supplierProjects->first()->pivot->platform)->not->toBeNull();
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=ProjectSupplierRelationTest`
Expected: FAIL — методов `supplierProjects()` / `projects()` нет.

- [ ] **Step 3: Добавить связь в `SupplierProject`**

В `app/app/Models/SupplierProject.php` добавить импорт `use Illuminate\Database\Eloquent\Relations\BelongsToMany;` и метод:

```php
    /**
     * @return BelongsToMany<Project, $this>
     */
    public function projects(): BelongsToMany
    {
        return $this->belongsToMany(Project::class, 'project_supplier_links')
            ->withPivot(['platform', 'subject_code']);
    }
```

- [ ] **Step 4: Добавить связь в `Project`**

В `app/app/Models/Project.php` добавить импорт `use Illuminate\Database\Eloquent\Relations\BelongsToMany;` и метод:

```php
    /**
     * @return BelongsToMany<SupplierProject, $this>
     */
    public function supplierProjects(): BelongsToMany
    {
        return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
            ->withPivot(['platform', 'subject_code']);
    }
```

(Если `use App\Models\SupplierProject;` ещё не импортирован — добавить.)

- [ ] **Step 5: Прогнать тест — зелёный**

Run: `php artisan test --filter=ProjectSupplierRelationTest`
Expected: PASS. Полный вывод.

- [ ] **Step 6: pint + larastan**

Run: `composer pint -- app/app/Models/SupplierProject.php app/app/Models/Project.php; composer stan`
Expected: 0 ошибок (при @property/relation-ошибке — `php artisan ide-helper:models -W` для обеих моделей, закоммитить обновлённый helper-файл если меняется).

- [ ] **Step 7: Коммит**

```bash
git add -- app/app/Models/SupplierProject.php app/app/Models/Project.php app/tests/Feature/Supplier/ProjectSupplierRelationTest.php
git commit -m "feat(supplier): Project<->SupplierProject belongsToMany via pivot" -- app/app/Models/SupplierProject.php app/app/Models/Project.php app/tests/Feature/Supplier/ProjectSupplierRelationTest.php
```

---

## Task 6: Backfill pivot из существующих `supplier_b{1,2,3}_project_id`

**Files:**

- Create: `app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`
- Test: `app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Support\Facades\DB;

it('backfills pivot rows from legacy supplier_b{1,2,3}_project_id slots', function (): void {
    $sp1 = SupplierProject::query()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
        'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    $sp3 = SupplierProject::query()->create([
        'platform' => 'B3', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
        'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
    ]);
    $project = Project::factory()->create([
        'supplier_b1_project_id' => $sp1->id,
        'supplier_b3_project_id' => $sp3->id,
    ]);

    // Симулируем «до бэкофилла»: pivot пуст.
    DB::table('project_supplier_links')->where('project_id', $project->id)->delete();

    // Запуск логики бэкофилла повторно (миграция идемпотентна).
    require_once base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php');
    (include base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php'))->up();

    $rows = DB::table('project_supplier_links')->where('project_id', $project->id)->get();
    expect($rows)->toHaveCount(2)
        ->and($rows->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B3']);
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=BackfillProjectSupplierLinksTest`
Expected: FAIL — файла миграции нет.

- [ ] **Step 3: Написать миграцию бэкофилла**

`app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`:

```php
<?php

declare(strict_types=1);

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

/**
 * Бэкофилл pivot project_supplier_links из legacy supplier_b{1,2,3}_project_id (v8.26).
 *
 * Для каждого ненулевого слота → строка pivot (subject_code=NULL: legacy-записи без
 * субъекта). Идемпотентно — ON CONFLICT DO NOTHING по uq_psl_project_supplier.
 */
return new class extends Migration
{
    public function up(): void
    {
        foreach (['B1' => 'supplier_b1_project_id', 'B2' => 'supplier_b2_project_id', 'B3' => 'supplier_b3_project_id'] as $platform => $col) {
            DB::statement(
                'INSERT INTO project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at) '
                ."SELECT id, {$col}, ?, NULL, NOW() FROM projects WHERE {$col} IS NOT NULL "
                .'ON CONFLICT (project_id, supplier_project_id) DO NOTHING',
                [$platform]
            );
        }
    }

    public function down(): void
    {
        // Бэкофилл-данные не откатываем точечно (pivot живёт дальше); no-op.
    }
};
```

- [ ] **Step 4: Прогнать тест — зелёный**

Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=BackfillProjectSupplierLinksTest`
Expected: PASS. Полный вывод.

- [ ] **Step 5: pint**

Run: `composer pint -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`
Expected: OK.

- [ ] **Step 6: Коммит**

```bash
git add -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php
git commit -m "feat(supplier): backfill project_supplier_links from legacy FK slots" -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php
```

---

## Task 7: Регрессия фундамента

**Files:** нет (проверка).

- [ ] **Step 1: migrate:fresh на чистой БД**

Run: `php artisan migrate:fresh --env=testing`
Expected: все миграции прошли, 0 ошибок (schema.sql v8.26 + 5 новых инкрементальных миграций идемпотентны).

- [ ] **Step 2: Полный Supplier+Integration suite**

Run: `php artisan test --testsuite=Feature -- --filter='Supplier|Integration|Project'` (или `composer test -- --group=supplier` если группа есть; иначе `php artisan test app/tests/Feature/Supplier`)
Expected: GREEN, 0 регрессий относительно baseline. Выписать число passed/failed; любой failed — с file:line.

- [ ] **Step 3: pint + larastan на всём изменённом**

Run: `composer pint; composer stan`
Expected: 0 ошибок (larastan baseline без новых записей; при необходимости regen ide-helper).

- [ ] **Step 4 (verification-before-completion):** перед заявлением «План 1 готов» — invoke `superpowers:verification-before-completion`, привести фактический вывод `migrate:fresh` + теста, не «должно проходить».

---

## Self-review (выполнен при написании плана)

- **Покрытие spec §4.2:** subject_code (T1), pivot (T2), deals.subject_code (T3), system_settings seed (T4), модели/связи (T5); §5 миграция/бэкофилл (T6). Резолвер `SupplierExportMode::current()`, формула заказа, save-маппинг, админка, ЛК — **вне Плана 1** (Планы 2-4, см. roadmap в spec/комментарии).
- **Без плейсхолдеров:** весь код приведён; «if squawk flags» снят — CHECK сразу NOT VALID + VALIDATE (squawk-safe).
- **Согласованность типов:** `subject_code SMALLINT`/cast integer везде; pivot withPivot(['platform','subject_code']) — те же поля во всех тасках; имена индексов/констрейнтов уникальны.
- **Известное ограничение:** Task 6 тест дважды вызывает `up()` (idempotent ON CONFLICT) — проверяет повторный прогон; если в проекте тест-БД не несёт legacy FK-колонок (они ещё есть, §4.2 двойная запись) — ок.
