# Supplier Integration — Foundation (Plan 1/5)

> **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.
>
> **Verification is mandatory at every gate.** This plan was authored under the directive: "перепроверять на все возможные ошибки, в логике, в коде, в цикличностях, опечатки и тд". Do not skip verification gates.

**Goal:** Расширить existing schema и Eloquent-модели Лидерры под supplier-integration data model: 3 новых таблицы (`supplier_projects`, `pricing_tiers`, `lead_charges`, `supplier_sync_log`), расширение `projects` под signal_type/identifier/SMS-поля, обновление `db/schema.sql`, RLS на tenant-scoped таблицах, factories + Pest-тесты.

**Architecture:** Используем существующие паттерны проекта: SetTenantContext middleware + RLS, Eloquent-модели c HasFactory (+ SoftDeletes где нужно), Pest c DatabaseTransactions + явный `SET app.current_tenant_id`. Новые таблицы — два класса: tenant-scoped (с RLS) и SaaS-level (без RLS, REVOKE на crm_app_user). Миграции в `database/migrations/` отдельными файлами + sync с `db/schema.sql` (single source of truth) + запись в `db/CHANGELOG_schema.md`.

**Tech Stack:** Laravel 13.7, PostgreSQL 16, Pest 4, Larastan, squawk, pgFormatter (existing toolchain).

**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md) — раздел §2 (Модель данных), §7 (Биллинг), §9 (Схема БД).

**Verification philosophy:** TDD для каждой задачи (failing test first → green → commit). Verification gates после каждой группы задач. Финальный «Comprehensive Verification» в конце плана включает: Larastan, Pest, squawk, pgFormatter, cspell, markdownlint, миграция fresh, RLS-смок, dependency-cycle check, code-reviewer subagent.

---

## File Structure

### Files to create

```
database/migrations/
  2026_05_10_000001_extend_projects_for_supplier_integration.php
  2026_05_10_000002_create_supplier_projects_table.php
  2026_05_10_000003_create_pricing_tiers_table.php
  2026_05_10_000004_create_lead_charges_table.php
  2026_05_10_000005_create_supplier_sync_log_table.php

app/Models/
  SupplierProject.php
  PricingTier.php
  LeadCharge.php
  SupplierSyncLog.php

database/factories/
  SupplierProjectFactory.php
  PricingTierFactory.php
  LeadChargeFactory.php
  SupplierSyncLogFactory.php

tests/Unit/Models/
  SupplierProjectTest.php
  PricingTierTest.php
  LeadChargeTest.php
  SupplierSyncLogTest.php

tests/Feature/Integration/
  ProjectsSchemaExtensionTest.php
  LeadChargesRlsTest.php
  SupplierProjectsAccessTest.php
```

### Files to modify

```
app/Models/Project.php             # add casts, relationships, signal_type accessor
database/factories/ProjectFactory.php  # extend with new fields
db/schema.sql                       # mirror migration changes (source of truth)
db/CHANGELOG_schema.md              # entry v8.12 → v8.13
```

### Responsibilities (one purpose per file)

- **Migration files** — DDL для одной концепции (extend projects / create supplier_projects / etc.). Не смешивать.
- **Models** — только Eloquent-модель: relationships, casts, scopes. Никакой бизнес-логики.
- **Factories** — генерация фейковых данных для тестов. Минимум валидных значений, без edge cases.
- **Unit tests** — модель в изоляции: casts работают, relationships определены, scope-методы корректны.
- **Integration tests** — миграция применяется чисто, RLS изолирует tenant'ы, FK работают, индексы созданы.

---

## Pre-flight checks

- [ ] **Step P1:** Verify clean working tree

Run: `git status --short`
Expected: только untracked файлы (известные плановые), нет staged/modified в `app/`, `database/`, `db/`.

Если есть посторонние модификации — остановись и согласуй с пользователем.

- [ ] **Step P2:** Verify baseline tests pass

Run (в `c:\моя\проекты\портал crm\Документация\app\`): `composer test`
Expected: `Tests: 403 passed` (или текущая baseline-цифра).

Если падает что-то постороннее — остановись, не начинай новую работу на сломанном baseline.

- [ ] **Step P3:** Verify Larastan baseline clean

Run: `composer stan`
Expected: `[OK] No errors`

Если есть ошибки — остановись.

- [ ] **Step P4:** Snapshot текущей версии schema

Run: `head -5 db/schema.sql`
Запиши версию (например, "v8.11") — пригодится при обновлении CHANGELOG.

---

## Task 1: Migration `extend_projects_for_supplier_integration`

**Why:** Существующий `projects` table содержит часть нужных полей (`tag`, `daily_limit_target`, `region_mask`, `delivery_days_mask`, `is_active`). Не хватает: `signal_type`, `signal_identifier`, `sms_senders`, `sms_keyword`, `delivered_in_month`, FK на supplier_projects.

**Files:**

- Create: `database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php`
- Create: `tests/Feature/Integration/ProjectsSchemaExtensionTest.php`

- [ ] **Step 1.1: Write failing migration test**

Create `tests/Feature/Integration/ProjectsSchemaExtensionTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(\Tests\TestCase::class);

test('projects table has signal_type column with enum constraint', function () {
    expect(Schema::hasColumn('projects', 'signal_type'))->toBeTrue();

    $check = DB::selectOne(
        "SELECT pg_get_constraintdef(c.oid) AS def
         FROM pg_constraint c
         JOIN pg_class t ON c.conrelid = t.oid
         WHERE t.relname = 'projects' AND c.conname LIKE '%signal_type%'"
    );

    expect($check->def)->toContain("'site'", "'call'", "'sms'");
});

test('projects table has signal_identifier text column', function () {
    expect(Schema::hasColumn('projects', 'signal_identifier'))->toBeTrue();
});

test('projects table has sms_senders jsonb array column', function () {
    expect(Schema::hasColumn('projects', 'sms_senders'))->toBeTrue();

    $type = DB::selectOne(
        "SELECT data_type FROM information_schema.columns
         WHERE table_name = 'projects' AND column_name = 'sms_senders'"
    );

    expect($type->data_type)->toBe('jsonb');
});

test('projects table has sms_keyword nullable text column', function () {
    expect(Schema::hasColumn('projects', 'sms_keyword'))->toBeTrue();

    $col = DB::selectOne(
        "SELECT is_nullable FROM information_schema.columns
         WHERE table_name = 'projects' AND column_name = 'sms_keyword'"
    );

    expect($col->is_nullable)->toBe('YES');
});

test('projects table has delivered_in_month integer counter', function () {
    expect(Schema::hasColumn('projects', 'delivered_in_month'))->toBeTrue();
});

test('projects table has supplier_b1_project_id, b2, b3 nullable FK columns', function () {
    foreach (['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id'] as $col) {
        expect(Schema::hasColumn('projects', $col))->toBeTrue();
    }
});

test('signal_type sms requires sms_senders non-empty (CHECK constraint)', function () {
    $check = DB::selectOne(
        "SELECT pg_get_constraintdef(c.oid) AS def
         FROM pg_constraint c
         JOIN pg_class t ON c.conrelid = t.oid
         WHERE t.relname = 'projects' AND c.conname = 'projects_sms_senders_required_for_sms'"
    );

    expect($check)->not->toBeNull();
    expect($check->def)->toContain('signal_type');
});
```

- [ ] **Step 1.2: Run test — verify it fails**

Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ProjectsSchemaExtensionTest.php -v`
Expected: 7 failures (columns missing).

- [ ] **Step 1.3: Write the migration**

Create `database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php`:

```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;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('projects', function (Blueprint $table) {
            $table->string('signal_type', 16)->nullable()->after('type');
            $table->text('signal_identifier')->nullable()->after('signal_type');
            $table->jsonb('sms_senders')->nullable()->after('signal_identifier');
            $table->text('sms_keyword')->nullable()->after('sms_senders');
            $table->unsignedInteger('delivered_in_month')->default(0)->after('effective_daily_limit_today');
            $table->unsignedBigInteger('supplier_b1_project_id')->nullable()->after('delivered_in_month');
            $table->unsignedBigInteger('supplier_b2_project_id')->nullable()->after('supplier_b1_project_id');
            $table->unsignedBigInteger('supplier_b3_project_id')->nullable()->after('supplier_b2_project_id');
        });

        DB::statement("
            ALTER TABLE projects
            ADD CONSTRAINT projects_signal_type_check
            CHECK (signal_type IS NULL OR signal_type IN ('site','call','sms'))
        ");

        DB::statement("
            ALTER TABLE projects
            ADD CONSTRAINT projects_sms_senders_required_for_sms
            CHECK (
                signal_type <> 'sms'
                OR (sms_senders IS NOT NULL AND jsonb_typeof(sms_senders) = 'array' AND jsonb_array_length(sms_senders) > 0)
            )
        ");

        DB::statement("
            ALTER TABLE projects
            ADD CONSTRAINT projects_signal_identifier_required_for_site_call
            CHECK (
                signal_type NOT IN ('site','call')
                OR (signal_identifier IS NOT NULL AND length(trim(signal_identifier)) > 0)
            )
        ");

        Schema::table('projects', function (Blueprint $table) {
            $table->index(['tenant_id', 'signal_type', 'signal_identifier'], 'idx_projects_tenant_signal');
        });
    }

    public function down(): void
    {
        Schema::table('projects', function (Blueprint $table) {
            $table->dropIndex('idx_projects_tenant_signal');
        });

        DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_signal_identifier_required_for_site_call');
        DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_sms_senders_required_for_sms');
        DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_signal_type_check');

        Schema::table('projects', function (Blueprint $table) {
            $table->dropColumn([
                'supplier_b3_project_id',
                'supplier_b2_project_id',
                'supplier_b1_project_id',
                'delivered_in_month',
                'sms_keyword',
                'sms_senders',
                'signal_identifier',
                'signal_type',
            ]);
        });
    }
};
```

- [ ] **Step 1.4: Lint migration with squawk**

Run: `cd app && npx --yes squawk database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php` (или конкретная команда из существующего lefthook.yml — посмотри `lefthook.yml` jobs).

Expected: `0 issues`.

Если squawk ругается на ALTER TABLE без `IF NOT EXISTS` или на missing index — поправь.

- [ ] **Step 1.5: Apply migration**

Run: `cd app && php artisan migrate`
Expected: `Migrating: 2026_05_10_000001_extend_projects_for_supplier_integration ... DONE`

- [ ] **Step 1.6: Run tests — verify pass**

Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ProjectsSchemaExtensionTest.php -v`
Expected: 7 passed.

- [ ] **Step 1.7: Verify rollback works**

Run: `cd app && php artisan migrate:rollback --step=1`
Expected: rollback DONE; columns/constraints/index dropped.

Run: `cd app && php artisan migrate`
Expected: re-applied DONE.

- [ ] **Step 1.8: Sync `db/schema.sql`**

Open `db/schema.sql`, найди блок `CREATE TABLE projects (...)`, добавь новые колонки и CHECK-constraints в том же порядке что в миграции. Добавь индекс. Не забудь обновить версию в шапке (например, v8.11 → v8.12).

Run: `cd app && composer format:sql:check` (или альтернативная команда из существующих скриптов) — pgFormatter не должен ругаться.

- [ ] **Step 1.9: Update `db/CHANGELOG_schema.md`**

Добавь запись в начало:

```markdown
## v8.12 — 2026-05-10

- `projects` extended for supplier integration: +signal_type (enum site/call/sms), +signal_identifier (text), +sms_senders (jsonb array), +sms_keyword (nullable text), +delivered_in_month (uint), +supplier_b{1,2,3}_project_id (nullable FK placeholder). 3 CHECK constraints + 1 composite index.
- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1
```

- [ ] **Step 1.10: Commit**

Run:

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php \
        app/tests/Feature/Integration/ProjectsSchemaExtensionTest.php \
        db/schema.sql \
        db/CHANGELOG_schema.md
git commit -m "feat(db): extend projects for supplier integration (signal_type, identifier, sms_senders, sms_keyword, delivered_in_month, b1/b2/b3 FK placeholders)"
```

---

## Task 2: Migration `create_supplier_projects_table`

**Why:** SaaS-level агрегатная сущность — отражает что именно у поставщика создано/синхронизировано. Не tenant-scoped (несколько клиентов Лидерры разделяют один supplier_project — sharing model).

**Files:**

- Create: `database/migrations/2026_05_10_000002_create_supplier_projects_table.php`
- Create: `tests/Feature/Integration/SupplierProjectsAccessTest.php`

- [ ] **Step 2.1: Write failing structure test**

Create `tests/Feature/Integration/SupplierProjectsAccessTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(\Tests\TestCase::class);

test('supplier_projects table exists with required columns', function () {
    expect(Schema::hasTable('supplier_projects'))->toBeTrue();

    foreach ([
        'id', 'platform', 'signal_type', 'unique_key', 'supplier_external_id',
        'current_limit', 'current_workdays', 'current_regions',
        'sync_status', 'last_synced_at', 'inactive_since',
        'created_at', 'updated_at',
    ] as $col) {
        expect(Schema::hasColumn('supplier_projects', $col))
            ->toBeTrue("column {$col} missing");
    }
});

test('supplier_projects has unique constraint on (platform, unique_key)', function () {
    $idx = DB::selectOne(
        "SELECT indexdef FROM pg_indexes
         WHERE tablename = 'supplier_projects' AND indexname = 'supplier_projects_platform_unique_key_unique'"
    );
    expect($idx)->not->toBeNull();
    expect($idx->indexdef)->toContain('UNIQUE');
});

test('supplier_projects platform check constraint allows only B1, B2, B3', function () {
    $check = DB::selectOne(
        "SELECT pg_get_constraintdef(c.oid) AS def
         FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid
         WHERE t.relname = 'supplier_projects' AND c.conname = 'supplier_projects_platform_check'"
    );
    expect($check->def)->toContain("'B1'", "'B2'", "'B3'");
});

test('supplier_projects sync_status check constraint', function () {
    $check = DB::selectOne(
        "SELECT pg_get_constraintdef(c.oid) AS def
         FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid
         WHERE t.relname = 'supplier_projects' AND c.conname = 'supplier_projects_sync_status_check'"
    );
    expect($check->def)->toContain("'pending'", "'ok'", "'failed'");
});

test('supplier_projects has NO RLS policy (SaaS-level)', function () {
    $rls = DB::selectOne(
        "SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_projects'"
    );
    expect((bool) $rls->relrowsecurity)->toBeFalse();
});

test('supplier_projects has REVOKE ALL on crm_app_user', function () {
    $grants = DB::select(
        "SELECT privilege_type FROM information_schema.role_table_grants
         WHERE table_name = 'supplier_projects' AND grantee = 'crm_app_user'"
    );

    expect($grants)->toBeArray();
    expect($grants)->toBeEmpty('crm_app_user must have no direct privileges on supplier_projects');
});
```

- [ ] **Step 2.2: Run test — verify it fails**

Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/SupplierProjectsAccessTest.php -v`
Expected: 6 failures.

- [ ] **Step 2.3: Write migration**

Create `database/migrations/2026_05_10_000002_create_supplier_projects_table.php`:

```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;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('supplier_projects', function (Blueprint $table) {
            $table->id();
            $table->string('platform', 4);
            $table->string('signal_type', 16);
            $table->text('unique_key');
            $table->string('supplier_external_id', 64)->nullable();
            $table->unsignedInteger('current_limit')->default(0);
            $table->jsonb('current_workdays')->nullable();
            $table->jsonb('current_regions')->nullable();
            $table->string('sync_status', 16)->default('pending');
            $table->timestamp('last_synced_at')->nullable();
            $table->timestamp('inactive_since')->nullable();
            $table->timestamps();

            $table->unique(['platform', 'unique_key'], 'supplier_projects_platform_unique_key_unique');
            $table->index('sync_status');
            $table->index('inactive_since');
        });

        DB::statement("
            ALTER TABLE supplier_projects
            ADD CONSTRAINT supplier_projects_platform_check
            CHECK (platform IN ('B1','B2','B3'))
        ");

        DB::statement("
            ALTER TABLE supplier_projects
            ADD CONSTRAINT supplier_projects_signal_type_check
            CHECK (signal_type IN ('site','call','sms'))
        ");

        DB::statement("
            ALTER TABLE supplier_projects
            ADD CONSTRAINT supplier_projects_sync_status_check
            CHECK (sync_status IN ('pending','ok','failed'))
        ");

        DB::statement("
            ALTER TABLE supplier_projects
            ADD CONSTRAINT supplier_projects_b1_not_for_sms
            CHECK (NOT (platform = 'B1' AND signal_type = 'sms'))
        ");

        DB::statement('REVOKE ALL ON TABLE supplier_projects FROM crm_app_user');
    }

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

- [ ] **Step 2.4: Lint with squawk**

Run: `cd app && npx --yes squawk database/migrations/2026_05_10_000002_create_supplier_projects_table.php`
Expected: 0 issues. (Возможно warning на missing CONCURRENTLY index — для пустой таблицы при create это false-positive, можно add to .squawkrc.)

- [ ] **Step 2.5: Apply and run tests**

Run:

```bash
cd app
php artisan migrate
./vendor/bin/pest tests/Feature/Integration/SupplierProjectsAccessTest.php -v
```

Expected: migration done, 6 tests passed.

- [ ] **Step 2.6: Verify rollback**

Run: `cd app && php artisan migrate:rollback --step=1 && php artisan migrate`
Expected: оба шага DONE.

- [ ] **Step 2.7: Sync `db/schema.sql` + CHANGELOG**

Add `CREATE TABLE supplier_projects (...)` block в schema.sql после блока `projects`. Bump version in header to v8.13. Update CHANGELOG.

- [ ] **Step 2.8: Commit**

```bash
git add app/database/migrations/2026_05_10_000002_create_supplier_projects_table.php \
        app/tests/Feature/Integration/SupplierProjectsAccessTest.php \
        db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(db): create supplier_projects table (SaaS-level aggregate, B1/B2/B3 platforms)"
```

---

## Task 3: Migration `create_pricing_tiers_table`

**Why:** §7 спеки — 7-ступенчатый объёмный тариф, конфигурируемый админом Лидерры. SaaS-level (один на всю Лидерру; per-tenant override — out of scope для MVP).

**Files:**

- Create: `database/migrations/2026_05_10_000003_create_pricing_tiers_table.php`

- [ ] **Step 3.1: Write failing test (in `LeadChargesRlsTest.php` namespace, секция pricing_tiers)**

Дополни новый файл `tests/Feature/Integration/PricingTiersTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(\Tests\TestCase::class);

test('pricing_tiers table exists with required columns', function () {
    expect(Schema::hasTable('pricing_tiers'))->toBeTrue();

    foreach (['id', 'tier_no', 'leads_in_tier', 'price_per_lead_kopecks', 'is_active', 'effective_from', 'created_at', 'updated_at'] as $col) {
        expect(Schema::hasColumn('pricing_tiers', $col))->toBeTrue("column {$col} missing");
    }
});

test('pricing_tiers tier_no constrained to 1..7', function () {
    $check = DB::selectOne(
        "SELECT pg_get_constraintdef(c.oid) AS def
         FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid
         WHERE t.relname = 'pricing_tiers' AND c.conname = 'pricing_tiers_tier_no_check'"
    );
    expect($check->def)->toContain('1', '7');
});

test('pricing_tiers has unique on (tier_no, effective_from) for active rows', function () {
    $idx = DB::selectOne(
        "SELECT indexdef FROM pg_indexes
         WHERE tablename = 'pricing_tiers' AND indexname = 'pricing_tiers_tier_effective_unique'"
    );
    expect($idx)->not->toBeNull();
});

test('pricing_tiers price stored in kopecks (integer, not float)', function () {
    $col = DB::selectOne(
        "SELECT data_type FROM information_schema.columns
         WHERE table_name = 'pricing_tiers' AND column_name = 'price_per_lead_kopecks'"
    );
    expect($col->data_type)->toBe('integer');
});
```

- [ ] **Step 3.2: Run test — verify fail**

Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/PricingTiersTest.php -v`
Expected: 4 failures.

- [ ] **Step 3.3: Write migration**

Create `database/migrations/2026_05_10_000003_create_pricing_tiers_table.php`:

```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;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('pricing_tiers', function (Blueprint $table) {
            $table->id();
            $table->unsignedSmallInteger('tier_no');
            $table->unsignedInteger('leads_in_tier')->nullable();
            $table->unsignedInteger('price_per_lead_kopecks');
            $table->boolean('is_active')->default(true);
            $table->date('effective_from');
            $table->timestamps();

            $table->unique(['tier_no', 'effective_from'], 'pricing_tiers_tier_effective_unique');
            $table->index(['is_active', 'effective_from']);
        });

        DB::statement("
            ALTER TABLE pricing_tiers
            ADD CONSTRAINT pricing_tiers_tier_no_check
            CHECK (tier_no BETWEEN 1 AND 7)
        ");

        DB::statement('REVOKE ALL ON TABLE pricing_tiers FROM crm_app_user');
        DB::statement('GRANT SELECT ON TABLE pricing_tiers TO crm_app_user');
    }

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

> **Why kopecks (не decimal):** избегаем floating-point округлений в money-расчётах. Формат: integer kopecks (1 руб = 100 копеек). UI converts at the edge.

- [ ] **Step 3.4: Lint, apply, test**

Run:

```bash
cd app
npx --yes squawk database/migrations/2026_05_10_000003_create_pricing_tiers_table.php
php artisan migrate
./vendor/bin/pest tests/Feature/Integration/PricingTiersTest.php -v
```

Expected: 0 squawk issues, migration DONE, 4 tests passed.

- [ ] **Step 3.5: Sync schema.sql + CHANGELOG, commit**

```bash
git add app/database/migrations/2026_05_10_000003_create_pricing_tiers_table.php \
        app/tests/Feature/Integration/PricingTiersTest.php \
        db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(db): create pricing_tiers table (7-step volume billing, kopecks integer)"
```

---

## Task 4: Migration `create_lead_charges_table`

**Why:** §7.4 — ledger списаний за каждый доставленный лид. Tenant-scoped (RLS), append-only (insert) для аудита.

**Files:**

- Create: `database/migrations/2026_05_10_000004_create_lead_charges_table.php`
- Create: `tests/Feature/Integration/LeadChargesRlsTest.php`

- [ ] **Step 4.1: Write failing test**

Create `tests/Feature/Integration/LeadChargesRlsTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('lead_charges table exists with required columns', function () {
    expect(Schema::hasTable('lead_charges'))->toBeTrue();

    foreach ([
        'id', 'tenant_id', 'deal_id', 'deal_received_at',
        'tier_no', 'price_per_lead_kopecks', 'charged_at', 'created_at',
    ] as $col) {
        expect(Schema::hasColumn('lead_charges', $col))->toBeTrue("column {$col} missing");
    }
});

test('lead_charges has FK to deals (composite id, received_at)', function () {
    $fk = DB::selectOne(
        "SELECT pg_get_constraintdef(c.oid) AS def
         FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid
         WHERE t.relname = 'lead_charges' AND c.contype = 'f'
         LIMIT 1"
    );

    expect($fk)->not->toBeNull();
    expect($fk->def)->toContain('deals', 'received_at', 'id');
});

test('lead_charges enforces RLS on tenant_id', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();

    DB::transaction(function () use ($a) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$a->id]);

        DB::table('lead_charges')->insert([
            'tenant_id' => $a->id,
            'deal_id' => 1,
            'deal_received_at' => now(),
            'tier_no' => 1,
            'price_per_lead_kopecks' => 6000,
            'charged_at' => now(),
            'created_at' => now(),
        ]);
    });

    $countA = DB::transaction(function () use ($a) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$a->id]);
        return DB::table('lead_charges')->count();
    });

    $countB = DB::transaction(function () use ($b) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$b->id]);
        return DB::table('lead_charges')->count();
    });

    expect($countA)->toBe(1);
    expect($countB)->toBe(0, 'tenant B must not see tenant A charges (RLS leak)');
});
```

> **Note:** этот RLS-тест предполагает что в дальнейших задачах будет `LeadChargeFactory`. Пока INSERT через DB::table, чтобы не блокировать тест на отсутствующей фабрике.

- [ ] **Step 4.2: Run test — verify fail**

Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/LeadChargesRlsTest.php -v`
Expected: 3 failures.

- [ ] **Step 4.3: Write migration**

Create `database/migrations/2026_05_10_000004_create_lead_charges_table.php`:

```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;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('lead_charges', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('deal_id');
            $table->timestamp('deal_received_at');
            $table->unsignedSmallInteger('tier_no');
            $table->unsignedInteger('price_per_lead_kopecks');
            $table->timestamp('charged_at');
            $table->timestamp('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();

            $table->index(['tenant_id', 'charged_at']);
            $table->index(['deal_id', 'deal_received_at']);
        });

        DB::statement('
            ALTER TABLE lead_charges
            ADD CONSTRAINT lead_charges_deals_fk
            FOREIGN KEY (deal_id, deal_received_at)
            REFERENCES deals (id, received_at)
            DEFERRABLE INITIALLY DEFERRED
            ON DELETE CASCADE
        ');

        DB::statement('ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY');
        DB::statement('ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY');
        DB::statement("
            CREATE POLICY tenant_isolation ON lead_charges
            USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
            WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint)
        ");

        DB::statement('GRANT SELECT, INSERT ON TABLE lead_charges TO crm_app_user');
        DB::statement('GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO crm_app_user');
    }

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

- [ ] **Step 4.4: Lint, apply, test**

Run:

```bash
cd app
npx --yes squawk database/migrations/2026_05_10_000004_create_lead_charges_table.php
php artisan migrate
./vendor/bin/pest tests/Feature/Integration/LeadChargesRlsTest.php -v
```

Expected: squawk 0 issues, migration DONE, 3 tests passed.

- [ ] **Step 4.5: Sync schema + CHANGELOG, commit**

```bash
git add app/database/migrations/2026_05_10_000004_create_lead_charges_table.php \
        app/tests/Feature/Integration/LeadChargesRlsTest.php \
        db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(db): create lead_charges ledger (tenant-scoped RLS, FK to partitioned deals)"
```

---

## Task 5: Migration `create_supplier_sync_log_table`

**Why:** §4.3 — лог синхронизаций для аудита/отладки. SaaS-level, append-only.

**Files:**

- Create: `database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php`
- Create: `tests/Feature/Integration/SupplierSyncLogTest.php`

- [ ] **Step 5.1: Write failing test**

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Schema;

uses(\Tests\TestCase::class);

test('supplier_sync_log table exists with required columns', function () {
    expect(Schema::hasTable('supplier_sync_log'))->toBeTrue();

    foreach ([
        'id', 'supplier_project_id', 'action',
        'request_payload', 'response_body', 'http_status',
        'error_message', 'duration_ms', 'created_at',
    ] as $col) {
        expect(Schema::hasColumn('supplier_sync_log', $col))->toBeTrue("column {$col} missing");
    }
});
```

- [ ] **Step 5.2: Run failing test**

`cd app && ./vendor/bin/pest tests/Feature/Integration/SupplierSyncLogTest.php -v`
Expected: 1 failure.

- [ ] **Step 5.3: Write migration**

```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;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('supplier_sync_log', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('supplier_project_id')->nullable();
            $table->string('action', 32);
            $table->jsonb('request_payload')->nullable();
            $table->jsonb('response_body')->nullable();
            $table->unsignedSmallInteger('http_status')->nullable();
            $table->text('error_message')->nullable();
            $table->unsignedInteger('duration_ms')->nullable();
            $table->timestamp('created_at')->useCurrent();

            $table->foreign('supplier_project_id')
                ->references('id')->on('supplier_projects')
                ->nullOnDelete();

            $table->index('supplier_project_id');
            $table->index('action');
            $table->index('created_at');
        });

        DB::statement("
            ALTER TABLE supplier_sync_log
            ADD CONSTRAINT supplier_sync_log_action_check
            CHECK (action IN ('create','update','delete','disable','session_refresh'))
        ");

        DB::statement('REVOKE ALL ON TABLE supplier_sync_log FROM crm_app_user');
    }

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

- [ ] **Step 5.4: Apply, test, commit**

```bash
cd app
npx --yes squawk database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php
php artisan migrate
./vendor/bin/pest tests/Feature/Integration/SupplierSyncLogTest.php -v
cd ..
git add app/database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php \
        app/tests/Feature/Integration/SupplierSyncLogTest.php \
        db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(db): create supplier_sync_log audit table (SaaS-level, append-only)"
```

---

## ✓ Verification Gate A — DDL & migrations

После Tasks 1–5 — обязательная проверка перед моделями.

- [ ] **Step VA.1: Fresh migration smoke test**

Run:

```bash
cd app
php artisan migrate:fresh --seed
```

Expected: все миграции применяются с нуля без ошибок. Если что-то падает на fresh — есть ordering/dependency issue.

- [ ] **Step VA.2: Run all integration schema tests**

Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ -v`
Expected: все тесты passed.

- [ ] **Step VA.3: Run full Pest baseline**

Run: `cd app && composer test`
Expected: 403 + (новые добавленные) passed. Никаких регрессий в existing тестах.

- [ ] **Step VA.4: Run Larastan**

Run: `cd app && composer stan`
Expected: 0 errors. (Новых файлов мало, pure migrations — должно быть чисто.)

- [ ] **Step VA.5: Schema diff sanity-check**

Run: `git diff db/schema.sql | head -200`
Visually verify: все 5 изменений присутствуют, нет случайных правок не относящихся к Plan 1.

- [ ] **Step VA.6: pgFormatter dry-run**

Run: `cd app && composer format:sql:check`
Expected: schema.sql отформатирован.

- [ ] **Step VA.7: Cycle/dependency check**

Run: `composer dump-autoload -o`
Expected: 0 warnings о круговых зависимостях.

> **Не двигайся дальше**, пока все шаги VA.1–VA.7 не прошли. Если что-то красное — фикси прямо сейчас, не накапливай.

---

## Task 6: Eloquent model `SupplierProject`

**Files:**

- Create: `app/Models/SupplierProject.php`
- Create: `database/factories/SupplierProjectFactory.php`
- Create: `tests/Unit/Models/SupplierProjectTest.php`

- [ ] **Step 6.1: Write failing model unit test**

`tests/Unit/Models/SupplierProjectTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('SupplierProject can be created via factory', function () {
    $sp = SupplierProject::factory()->create();
    expect($sp->id)->toBeInt()->toBeGreaterThan(0);
    expect($sp->platform)->toBeIn(['B1', 'B2', 'B3']);
    expect($sp->signal_type)->toBeIn(['site', 'call', 'sms']);
});

test('SupplierProject casts current_workdays as array', function () {
    $sp = SupplierProject::factory()->create([
        'current_workdays' => [1, 2, 3, 4, 5],
    ]);

    expect($sp->fresh()->current_workdays)->toBe([1, 2, 3, 4, 5]);
});

test('SupplierProject casts current_regions as array', function () {
    $sp = SupplierProject::factory()->create([
        'current_regions' => ['77', '78'],
    ]);

    expect($sp->fresh()->current_regions)->toBe(['77', '78']);
});

test('SupplierProject scopeActive returns only active rows', function () {
    SupplierProject::factory()->create(['inactive_since' => null]);
    SupplierProject::factory()->create(['inactive_since' => now()->subDays(10)]);

    expect(SupplierProject::active()->count())->toBe(1);
});

test('SupplierProject scopeStaleSince returns rows inactive longer than N days', function () {
    SupplierProject::factory()->create(['inactive_since' => now()->subDays(200)]);
    SupplierProject::factory()->create(['inactive_since' => now()->subDays(100)]);
    SupplierProject::factory()->create(['inactive_since' => null]);

    expect(SupplierProject::staleSince(180)->count())->toBe(1);
});
```

- [ ] **Step 6.2: Run failing test**

`cd app && ./vendor/bin/pest tests/Unit/Models/SupplierProjectTest.php -v`
Expected: failures (class missing).

- [ ] **Step 6.3: Write model**

`app/Models/SupplierProject.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\SupplierProjectFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class SupplierProject extends Model
{
    use HasFactory;

    protected $table = 'supplier_projects';

    protected $fillable = [
        'platform',
        'signal_type',
        'unique_key',
        'supplier_external_id',
        'current_limit',
        'current_workdays',
        'current_regions',
        'sync_status',
        'last_synced_at',
        'inactive_since',
    ];

    protected $casts = [
        'current_workdays' => 'array',
        'current_regions' => 'array',
        'current_limit' => 'integer',
        'last_synced_at' => 'datetime',
        'inactive_since' => 'datetime',
    ];

    public function scopeActive(Builder $query): Builder
    {
        return $query->whereNull('inactive_since');
    }

    public function scopeStaleSince(Builder $query, int $days): Builder
    {
        return $query->whereNotNull('inactive_since')
            ->where('inactive_since', '<=', now()->subDays($days));
    }

    public function scopeForSignal(Builder $query, string $signalType, string $uniqueKey): Builder
    {
        return $query->where('signal_type', $signalType)->where('unique_key', $uniqueKey);
    }

    protected static function newFactory(): SupplierProjectFactory
    {
        return SupplierProjectFactory::new();
    }
}
```

- [ ] **Step 6.4: Write factory**

`database/factories/SupplierProjectFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\SupplierProject;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<SupplierProject>
 */
class SupplierProjectFactory extends Factory
{
    protected $model = SupplierProject::class;

    public function definition(): array
    {
        $platform = fake()->randomElement(['B1', 'B2', 'B3']);
        $signal = fake()->randomElement($platform === 'B1' ? ['site', 'call'] : ['site', 'call', 'sms']);

        return [
            'platform' => $platform,
            'signal_type' => $signal,
            'unique_key' => fake()->unique()->domainName(),
            'supplier_external_id' => (string) fake()->numberBetween(1_000_000, 99_999_999),
            'current_limit' => 0,
            'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
            'current_regions' => null,
            'sync_status' => 'pending',
            'last_synced_at' => null,
            'inactive_since' => null,
        ];
    }
}
```

- [ ] **Step 6.5: Run unit tests — verify pass**

`cd app && ./vendor/bin/pest tests/Unit/Models/SupplierProjectTest.php -v`
Expected: 5 passed.

- [ ] **Step 6.6: Pint + Larastan**

Run: `cd app && composer pint && composer stan`
Expected: code-style clean, stan 0 errors.

- [ ] **Step 6.7: Commit**

```bash
git add app/app/Models/SupplierProject.php \
        app/database/factories/SupplierProjectFactory.php \
        app/tests/Unit/Models/SupplierProjectTest.php
git commit -m "feat(models): add SupplierProject Eloquent model + factory + unit tests"
```

---

## Task 7: Eloquent model `PricingTier`

**Files:**

- Create: `app/Models/PricingTier.php`
- Create: `database/factories/PricingTierFactory.php`
- Create: `tests/Unit/Models/PricingTierTest.php`

- [ ] **Step 7.1: Write failing test**

```php
<?php

declare(strict_types=1);

use App\Models\PricingTier;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('PricingTier created via factory', function () {
    $t = PricingTier::factory()->create();
    expect($t->tier_no)->toBeInt()->toBeBetween(1, 7);
    expect($t->price_per_lead_kopecks)->toBeInt()->toBeGreaterThan(0);
});

test('priceRubles accessor converts kopecks to rubles', function () {
    $t = PricingTier::factory()->create(['price_per_lead_kopecks' => 6000]);
    expect($t->price_rubles)->toBe(60.0);
});

test('scopeActive returns only is_active=true with effective_from <= today', function () {
    PricingTier::factory()->create(['is_active' => true, 'effective_from' => now()->subDay(), 'tier_no' => 1]);
    PricingTier::factory()->create(['is_active' => false, 'effective_from' => now()->subDay(), 'tier_no' => 2]);
    PricingTier::factory()->create(['is_active' => true, 'effective_from' => now()->addDay(), 'tier_no' => 3]);

    expect(PricingTier::active()->count())->toBe(1);
});

test('current() returns today active tier set keyed by tier_no', function () {
    foreach (range(1, 7) as $n) {
        PricingTier::factory()->create([
            'tier_no' => $n,
            'is_active' => true,
            'effective_from' => now()->subDay(),
            'leads_in_tier' => $n === 7 ? null : 100,
            'price_per_lead_kopecks' => 7000 - ($n * 500),
        ]);
    }

    $current = PricingTier::current();
    expect($current)->toHaveCount(7);
    expect($current[1]->price_per_lead_kopecks)->toBe(6500);
});
```

- [ ] **Step 7.2: Run failing test**

`./vendor/bin/pest tests/Unit/Models/PricingTierTest.php -v`
Expected: failures.

- [ ] **Step 7.3: Write model**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\PricingTierFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class PricingTier extends Model
{
    use HasFactory;

    protected $table = 'pricing_tiers';

    protected $fillable = [
        'tier_no',
        'leads_in_tier',
        'price_per_lead_kopecks',
        'is_active',
        'effective_from',
    ];

    protected $casts = [
        'tier_no' => 'integer',
        'leads_in_tier' => 'integer',
        'price_per_lead_kopecks' => 'integer',
        'is_active' => 'boolean',
        'effective_from' => 'date',
    ];

    public function getPriceRublesAttribute(): float
    {
        return $this->price_per_lead_kopecks / 100;
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true)
            ->where('effective_from', '<=', now()->toDateString());
    }

    /**
     * Returns currently active tier-set keyed by tier_no (1..7).
     *
     * @return Collection<int, PricingTier>
     */
    public static function current(): Collection
    {
        return self::active()
            ->orderBy('tier_no')
            ->get()
            ->keyBy('tier_no');
    }

    protected static function newFactory(): PricingTierFactory
    {
        return PricingTierFactory::new();
    }
}
```

- [ ] **Step 7.4: Write factory**

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\PricingTier;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<PricingTier>
 */
class PricingTierFactory extends Factory
{
    protected $model = PricingTier::class;

    public function definition(): array
    {
        return [
            'tier_no' => fake()->numberBetween(1, 7),
            'leads_in_tier' => fake()->randomElement([300, 700, 1000, 2000, 5000, 10000, null]),
            'price_per_lead_kopecks' => fake()->numberBetween(2000, 7000),
            'is_active' => true,
            'effective_from' => now()->toDateString(),
        ];
    }
}
```

- [ ] **Step 7.5: Run tests — verify pass**

`./vendor/bin/pest tests/Unit/Models/PricingTierTest.php -v`
Expected: 4 passed.

- [ ] **Step 7.6: Pint + stan + commit**

```bash
cd app && composer pint && composer stan
cd ..
git add app/app/Models/PricingTier.php \
        app/database/factories/PricingTierFactory.php \
        app/tests/Unit/Models/PricingTierTest.php
git commit -m "feat(models): add PricingTier model with kopecks→rubles accessor + current() snapshot"
```

---

## Task 8: Eloquent model `LeadCharge`

**Files:**

- Create: `app/Models/LeadCharge.php`
- Create: `database/factories/LeadChargeFactory.php`
- Create: `tests/Unit/Models/LeadChargeTest.php`

- [ ] **Step 8.1: Write failing test**

```php
<?php

declare(strict_types=1);

use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('LeadCharge created via factory', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $charge = LeadCharge::factory()->create(['tenant_id' => $tenant->id]);

        expect($charge->tier_no)->toBeInt();
        expect($charge->price_per_lead_kopecks)->toBeInt();
    });
});

test('LeadCharge belongs to tenant', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $charge = LeadCharge::factory()->create(['tenant_id' => $tenant->id]);

        expect($charge->tenant)->toBeInstanceOf(Tenant::class);
        expect($charge->tenant->id)->toBe($tenant->id);
    });
});

test('LeadCharge belongs to deal via composite key', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $deal = Deal::factory()->create(['tenant_id' => $tenant->id]);
        $charge = LeadCharge::factory()->create([
            'tenant_id' => $tenant->id,
            'deal_id' => $deal->id,
            'deal_received_at' => $deal->received_at,
        ]);

        expect($charge->deal)->toBeInstanceOf(Deal::class);
        expect($charge->deal->id)->toBe($deal->id);
    });
});

test('priceRubles accessor', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $charge = LeadCharge::factory()->create([
            'tenant_id' => $tenant->id,
            'price_per_lead_kopecks' => 5500,
        ]);

        expect($charge->price_rubles)->toBe(55.0);
    });
});
```

- [ ] **Step 8.2: Run failing test**

Expected: failures.

- [ ] **Step 8.3: Write model**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\LeadChargeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LeadCharge extends Model
{
    use HasFactory;

    public $timestamps = false;

    protected $table = 'lead_charges';

    protected $fillable = [
        'tenant_id',
        'deal_id',
        'deal_received_at',
        'tier_no',
        'price_per_lead_kopecks',
        'charged_at',
        'created_at',
    ];

    protected $casts = [
        'tier_no' => 'integer',
        'price_per_lead_kopecks' => 'integer',
        'deal_received_at' => 'datetime',
        'charged_at' => 'datetime',
        'created_at' => 'datetime',
    ];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function deal(): BelongsTo
    {
        return $this->belongsTo(Deal::class, 'deal_id', 'id');
    }

    public function getPriceRublesAttribute(): float
    {
        return $this->price_per_lead_kopecks / 100;
    }

    protected static function newFactory(): LeadChargeFactory
    {
        return LeadChargeFactory::new();
    }
}
```

- [ ] **Step 8.4: Write factory**

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\LeadCharge;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<LeadCharge>
 */
class LeadChargeFactory extends Factory
{
    protected $model = LeadCharge::class;

    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'deal_id' => fake()->numberBetween(1, 99999),
            'deal_received_at' => now(),
            'tier_no' => fake()->numberBetween(1, 7),
            'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000),
            'charged_at' => now(),
            'created_at' => now(),
        ];
    }
}
```

> **Note:** factory НЕ создаёт реальный Deal из-за партиционированной таблицы; тесты, которым нужен реальный FK, явно создают `Deal::factory()` отдельно.

- [ ] **Step 8.5: Run tests, pint, stan, commit**

```bash
cd app && ./vendor/bin/pest tests/Unit/Models/LeadChargeTest.php -v
composer pint && composer stan
cd ..
git add app/app/Models/LeadCharge.php \
        app/database/factories/LeadChargeFactory.php \
        app/tests/Unit/Models/LeadChargeTest.php
git commit -m "feat(models): add LeadCharge ledger model + factory + relations to Tenant/Deal"
```

---

## Task 9: Eloquent model `SupplierSyncLog`

**Files:**

- Create: `app/Models/SupplierSyncLog.php`
- Create: `database/factories/SupplierSyncLogFactory.php`
- Create: `tests/Unit/Models/SupplierSyncLogTest.php`

- [ ] **Step 9.1: Write test**

```php
<?php

declare(strict_types=1);

use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('SupplierSyncLog created via factory', function () {
    $log = SupplierSyncLog::factory()->create();
    expect($log->action)->toBeIn(['create', 'update', 'delete', 'disable', 'session_refresh']);
});

test('SupplierSyncLog has nullable supplier_project relation', function () {
    $sp = SupplierProject::factory()->create();
    $log = SupplierSyncLog::factory()->create(['supplier_project_id' => $sp->id]);

    expect($log->supplierProject)->toBeInstanceOf(SupplierProject::class);
    expect($log->supplierProject->id)->toBe($sp->id);
});

test('SupplierSyncLog request_payload and response_body cast as array', function () {
    $log = SupplierSyncLog::factory()->create([
        'request_payload' => ['name' => 'X'],
        'response_body' => ['status' => 'OK'],
    ]);

    expect($log->fresh()->request_payload)->toBe(['name' => 'X']);
    expect($log->fresh()->response_body)->toBe(['status' => 'OK']);
});
```

- [ ] **Step 9.2: Run failing**

Expected: failures.

- [ ] **Step 9.3: Write model**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\SupplierSyncLogFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class SupplierSyncLog extends Model
{
    use HasFactory;

    public $timestamps = false;

    protected $table = 'supplier_sync_log';

    protected $fillable = [
        'supplier_project_id',
        'action',
        'request_payload',
        'response_body',
        'http_status',
        'error_message',
        'duration_ms',
        'created_at',
    ];

    protected $casts = [
        'request_payload' => 'array',
        'response_body' => 'array',
        'http_status' => 'integer',
        'duration_ms' => 'integer',
        'created_at' => 'datetime',
    ];

    public function supplierProject(): BelongsTo
    {
        return $this->belongsTo(SupplierProject::class);
    }

    protected static function newFactory(): SupplierSyncLogFactory
    {
        return SupplierSyncLogFactory::new();
    }
}
```

- [ ] **Step 9.4: Write factory**

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\SupplierSyncLog;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<SupplierSyncLog>
 */
class SupplierSyncLogFactory extends Factory
{
    protected $model = SupplierSyncLog::class;

    public function definition(): array
    {
        return [
            'supplier_project_id' => null,
            'action' => fake()->randomElement(['create', 'update', 'delete', 'disable', 'session_refresh']),
            'request_payload' => ['stub' => true],
            'response_body' => null,
            'http_status' => 200,
            'error_message' => null,
            'duration_ms' => fake()->numberBetween(50, 5000),
            'created_at' => now(),
        ];
    }
}
```

- [ ] **Step 9.5: Run tests + pint + stan + commit**

```bash
cd app && ./vendor/bin/pest tests/Unit/Models/SupplierSyncLogTest.php -v
composer pint && composer stan
cd ..
git add app/app/Models/SupplierSyncLog.php \
        app/database/factories/SupplierSyncLogFactory.php \
        app/tests/Unit/Models/SupplierSyncLogTest.php
git commit -m "feat(models): add SupplierSyncLog model + factory (audit trail для AJAX-sync)"
```

---

## Task 10: Extend `Project` model

**Why:** Существующая `Project` сейчас без полей signal_*, sms_*, delivered_in_month, supplier_b{1,2,3}_project_id. Нужно добавить fillable + casts + relationships + accessors.

**Files:**

- Modify: `app/Models/Project.php`
- Modify: `database/factories/ProjectFactory.php`
- Create: `tests/Unit/Models/ProjectExtensionsTest.php`

- [ ] **Step 10.1: Write failing test**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('Project has signal_type, signal_identifier in fillable', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $p = Project::factory()->create([
            'tenant_id' => $tenant->id,
            'signal_type' => 'site',
            'signal_identifier' => 'example.com',
        ]);

        expect($p->signal_type)->toBe('site');
        expect($p->signal_identifier)->toBe('example.com');
    });
});

test('Project casts sms_senders as array', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $p = Project::factory()->create([
            'tenant_id' => $tenant->id,
            'signal_type' => 'sms',
            'sms_senders' => ['TINKOFF', 'SBERBANK'],
            'sms_keyword' => 'ипотека',
        ]);

        expect($p->fresh()->sms_senders)->toBe(['TINKOFF', 'SBERBANK']);
        expect($p->fresh()->sms_keyword)->toBe('ипотека');
    });
});

test('Project has supplierB1/B2/B3 relations', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        $sp = SupplierProject::factory()->create(['platform' => 'B1']);
        $p = Project::factory()->create([
            'tenant_id' => $tenant->id,
            'supplier_b1_project_id' => $sp->id,
        ]);

        expect($p->supplierB1)->toBeInstanceOf(SupplierProject::class);
        expect($p->supplierB1->id)->toBe($sp->id);
    });
});

test('Project scopeActiveOnDay returns projects with today in delivery_days_mask', function () {
    $tenant = Tenant::factory()->create();

    DB::transaction(function () use ($tenant) {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);

        // delivery_days_mask: bit 0..6 для Пн..Вс
        // Проект на все дни:
        Project::factory()->create([
            'tenant_id' => $tenant->id,
            'is_active' => true,
            'delivery_days_mask' => 0b1111111,
        ]);
        // Только выходные:
        Project::factory()->create([
            'tenant_id' => $tenant->id,
            'is_active' => true,
            'delivery_days_mask' => 0b1100000,
        ]);

        $todayDow = (int) now()->dayOfWeekIso; // 1..7

        $count = Project::activeOnDay($todayDow)->count();
        expect($count)->toBeGreaterThanOrEqual(1);
    });
});
```

- [ ] **Step 10.2: Run failing test**

Expected: failures (методы/relations отсутствуют).

- [ ] **Step 10.3: Modify Project model**

Открой `app/Models/Project.php` и добавь:

В `$fillable` (если используется):

```php
'signal_type',
'signal_identifier',
'sms_senders',
'sms_keyword',
'delivered_in_month',
'supplier_b1_project_id',
'supplier_b2_project_id',
'supplier_b3_project_id',
```

В `$casts`:

```php
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
```

Добавь методы:

```php
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

public function supplierB1(): BelongsTo
{
    return $this->belongsTo(SupplierProject::class, 'supplier_b1_project_id');
}

public function supplierB2(): BelongsTo
{
    return $this->belongsTo(SupplierProject::class, 'supplier_b2_project_id');
}

public function supplierB3(): BelongsTo
{
    return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id');
}

public function scopeActiveOnDay(Builder $query, int $isoDayOfWeek): Builder
{
    $bit = 1 << ($isoDayOfWeek - 1);
    return $query->where('is_active', true)
        ->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]);
}

public function scopeForSignal(Builder $query, string $signalType, string $identifier): Builder
{
    return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
}
```

Добавь imports `use App\Models\SupplierProject;` если ещё нет.

- [ ] **Step 10.4: Update `ProjectFactory`**

Добавь в `definition()`:

```php
'signal_type' => null,
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'delivered_in_month' => 0,
'supplier_b1_project_id' => null,
'supplier_b2_project_id' => null,
'supplier_b3_project_id' => null,
```

Добавь state-метод:

```php
public function asSiteSignal(string $domain): self
{
    return $this->state([
        'signal_type' => 'site',
        'signal_identifier' => $domain,
    ]);
}

public function asCallSignal(string $phone): self
{
    return $this->state([
        'signal_type' => 'call',
        'signal_identifier' => $phone,
    ]);
}

public function asSmsSignal(array $senders, ?string $keyword = null): self
{
    return $this->state([
        'signal_type' => 'sms',
        'signal_identifier' => null,
        'sms_senders' => $senders,
        'sms_keyword' => $keyword,
    ]);
}
```

- [ ] **Step 10.5: Run tests — pass**

`./vendor/bin/pest tests/Unit/Models/ProjectExtensionsTest.php -v`
Expected: 4 passed.

- [ ] **Step 10.6: Pint, stan, full Pest baseline**

```bash
cd app
composer pint
composer stan
composer test
```

Expected: всё зелёное, 403+новые passed (никаких регрессий в existing тестах после правки `Project`).

- [ ] **Step 10.7: Commit**

```bash
git add app/app/Models/Project.php \
        app/database/factories/ProjectFactory.php \
        app/tests/Unit/Models/ProjectExtensionsTest.php
git commit -m "feat(models): extend Project with signal_type, sms_senders, supplier_b1/b2/b3 relations + scopes"
```

---

## ✓ Verification Gate B — Models & Factories

- [ ] **Step VB.1: Run Unit/Models tests**

`cd app && ./vendor/bin/pest tests/Unit/Models/ -v`
Expected: все passed.

- [ ] **Step VB.2: Run full baseline**

`cd app && composer test`
Expected: 403 baseline + новые passed, 0 регрессий.

- [ ] **Step VB.3: Larastan**

`cd app && composer stan`
Expected: 0 errors. Если появились ошибки на новых моделях — типы PHPDoc'ов поправь в моделях/фабриках.

- [ ] **Step VB.4: Pint**

`cd app && composer pint -- --test`
Expected: clean.

- [ ] **Step VB.5: Cross-model relation sanity-check**

Mini-script (можно добавить как отдельный test или прогнать в `php artisan tinker`):

```php
$tenant = \App\Models\Tenant::factory()->create();
DB::transaction(function () use ($tenant) {
    DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]);
    $sp = \App\Models\SupplierProject::factory()->create(['platform' => 'B1']);
    $p = \App\Models\Project::factory()->create([
        'tenant_id' => $tenant->id,
        'signal_type' => 'site',
        'signal_identifier' => 'a.com',
        'supplier_b1_project_id' => $sp->id,
    ]);
    dump($p->supplierB1->platform); // 'B1'
});
```

Ожидание: 'B1' в выводе.

---

## Task 11: SupplierProject ↔ Project linkage helper service

**Why:** Логика «найти существующий supplier_project или создать стаб» нужна в нескольких местах (Project create, supplier sync). Чтобы избежать циклической зависимости и дублирования — выносим в Service.

**Files:**

- Create: `app/Services/SupplierProjects/SupplierProjectResolver.php`
- Create: `tests/Unit/Services/SupplierProjectResolverTest.php`

- [ ] **Step 11.1: Write failing test**

```php
<?php

declare(strict_types=1);

use App\Models\SupplierProject;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(\Tests\TestCase::class, DatabaseTransactions::class);

test('resolveOrStub returns existing supplier_project for matching (platform, unique_key)', function () {
    $existing = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'example.com',
    ]);

    $resolver = new SupplierProjectResolver();
    $resolved = $resolver->resolveOrStub('B1', 'site', 'example.com');

    expect($resolved->id)->toBe($existing->id);
});

test('resolveOrStub creates pending stub when no existing project', function () {
    $resolver = new SupplierProjectResolver();
    $resolved = $resolver->resolveOrStub('B2', 'call', '79991234567');

    expect($resolved->exists)->toBeTrue();
    expect($resolved->platform)->toBe('B2');
    expect($resolved->signal_type)->toBe('call');
    expect($resolved->unique_key)->toBe('79991234567');
    expect($resolved->sync_status)->toBe('pending');
});

test('resolveOrStub returns same row on second call (no duplicates)', function () {
    $resolver = new SupplierProjectResolver();
    $first = $resolver->resolveOrStub('B3', 'sms', 'TINKOFF');
    $second = $resolver->resolveOrStub('B3', 'sms', 'TINKOFF');

    expect($first->id)->toBe($second->id);
    expect(SupplierProject::where('unique_key', 'TINKOFF')->count())->toBe(1);
});

test('resolveOrStub throws DomainException for B1+sms (forbidden combo)', function () {
    $resolver = new SupplierProjectResolver();
    expect(fn () => $resolver->resolveOrStub('B1', 'sms', 'TINKOFF'))
        ->toThrow(DomainException::class);
});

test('resolveOrStub throws InvalidArgumentException for invalid platform', function () {
    $resolver = new SupplierProjectResolver();
    expect(fn () => $resolver->resolveOrStub('B9', 'site', 'a.com'))
        ->toThrow(InvalidArgumentException::class);
});
```

- [ ] **Step 11.2: Run failing test**

Expected: failures.

- [ ] **Step 11.3: Write service**

```php
<?php

declare(strict_types=1);

namespace App\Services\SupplierProjects;

use App\Models\SupplierProject;
use DomainException;
use InvalidArgumentException;

class SupplierProjectResolver
{
    private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3'];
    private const ALLOWED_SIGNAL_TYPES = ['site', 'call', 'sms'];

    public function resolveOrStub(string $platform, string $signalType, string $uniqueKey): SupplierProject
    {
        if (!in_array($platform, self::ALLOWED_PLATFORMS, true)) {
            throw new InvalidArgumentException("Unknown platform: {$platform}");
        }

        if (!in_array($signalType, self::ALLOWED_SIGNAL_TYPES, true)) {
            throw new InvalidArgumentException("Unknown signal_type: {$signalType}");
        }

        if ($platform === 'B1' && $signalType === 'sms') {
            throw new DomainException('B1 platform does not support SMS signals (supplier limitation)');
        }

        return SupplierProject::firstOrCreate(
            ['platform' => $platform, 'unique_key' => $uniqueKey],
            [
                'signal_type' => $signalType,
                'sync_status' => 'pending',
                'current_limit' => 0,
                'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
            ]
        );
    }
}
```

- [ ] **Step 11.4: Run tests, pint, stan**

`./vendor/bin/pest tests/Unit/Services/SupplierProjectResolverTest.php -v`
Expected: 5 passed.

`composer pint && composer stan`

- [ ] **Step 11.5: Commit**

```bash
git add app/app/Services/SupplierProjects/SupplierProjectResolver.php \
        app/tests/Unit/Services/SupplierProjectResolverTest.php
git commit -m "feat(services): add SupplierProjectResolver (resolveOrStub with B1+SMS guard)"
```

---

## Task 12: Validation rules — Signal validators

**Why:** §3.1 спеки — формат валидации. Выносим в отдельные Rule-классы для переиспользования (FormRequest на следующем плане + service-level вызов).

**Files:**

- Create: `app/Rules/SignalIdentifier/DomainIdentifier.php`
- Create: `app/Rules/SignalIdentifier/PhoneIdentifier.php`
- Create: `app/Rules/SignalIdentifier/SmsSenderRule.php`
- Create: `tests/Unit/Rules/SignalValidatorsTest.php`

- [ ] **Step 12.1: Write failing test**

```php
<?php

declare(strict_types=1);

use App\Rules\SignalIdentifier\DomainIdentifier;
use App\Rules\SignalIdentifier\PhoneIdentifier;
use App\Rules\SignalIdentifier\SmsSenderRule;
use Illuminate\Support\Facades\Validator;

dataset('domains_valid', [
    ['example.com'],
    ['vashinvestor.ru'],
    ['sub.example.co.uk'],
]);

dataset('domains_invalid', [
    [''],
    ['EXAMPLE.COM'], // upper case
    ['no-tld'],
    ['has spaces.com'],
    ['http://example.com'], // protocol
    ['example.com/path'], // path
]);

test('DomainIdentifier accepts valid', function (string $value) {
    $v = Validator::make(['x' => $value], ['x' => [new DomainIdentifier()]]);
    expect($v->fails())->toBeFalse("rejected valid {$value}");
})->with('domains_valid');

test('DomainIdentifier rejects invalid', function (string $value) {
    $v = Validator::make(['x' => $value], ['x' => [new DomainIdentifier()]]);
    expect($v->fails())->toBeTrue("accepted invalid {$value}");
})->with('domains_invalid');

dataset('phones_valid', [
    ['79991234567'],
    ['74955551212'],
]);

dataset('phones_invalid', [
    ['89991234567'], // not 7-prefix
    ['7999123456'],  // 10 digits
    ['799912345678'], // 12 digits
    ['+79991234567'], // plus
    ['7 999 123 45 67'], // spaces
]);

test('PhoneIdentifier accepts valid', function (string $v) {
    $val = Validator::make(['x' => $v], ['x' => [new PhoneIdentifier()]]);
    expect($val->fails())->toBeFalse();
})->with('phones_valid');

test('PhoneIdentifier rejects invalid', function (string $v) {
    $val = Validator::make(['x' => $v], ['x' => [new PhoneIdentifier()]]);
    expect($val->fails())->toBeTrue();
})->with('phones_invalid');

test('SmsSenderRule accepts alpha and short numeric', function () {
    foreach (['TINKOFF', 'SBER', '900', '1234', 'BANK-1'] as $v) {
        $r = Validator::make(['x' => $v], ['x' => [new SmsSenderRule()]]);
        expect($r->fails())->toBeFalse("rejected valid {$v}");
    }
});

test('SmsSenderRule rejects 11-digit phone (supplier blocks it)', function () {
    foreach (['79991234567', '12345678901'] as $v) {
        $r = Validator::make(['x' => $v], ['x' => [new SmsSenderRule()]]);
        expect($r->fails())->toBeTrue("accepted invalid {$v}");
    }
});

test('SmsSenderRule rejects too long', function () {
    $longString = str_repeat('A', 31);
    $r = Validator::make(['x' => $longString], ['x' => [new SmsSenderRule()]]);
    expect($r->fails())->toBeTrue();
});
```

- [ ] **Step 12.2: Run failing**

Expected: failures.

- [ ] **Step 12.3: Write rules**

`app/Rules/SignalIdentifier/DomainIdentifier.php`:

```php
<?php

declare(strict_types=1);

namespace App\Rules\SignalIdentifier;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class DomainIdentifier implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || $value === '') {
            $fail('Source domain is required.');
            return;
        }

        if (!preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/', $value)) {
            $fail('Source domain must be lowercase, only letters/digits/hyphens, with a TLD (no protocol/path).');
        }
    }
}
```

`app/Rules/SignalIdentifier/PhoneIdentifier.php`:

```php
<?php

declare(strict_types=1);

namespace App\Rules\SignalIdentifier;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class PhoneIdentifier implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || !preg_match('/^7\d{10}$/', $value)) {
            $fail('Phone number must be 11 digits and start with 7 (no spaces, no plus sign).');
        }
    }
}
```

`app/Rules/SignalIdentifier/SmsSenderRule.php`:

```php
<?php

declare(strict_types=1);

namespace App\Rules\SignalIdentifier;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class SmsSenderRule implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || $value === '' || strlen($value) > 30) {
            $fail('SMS sender must be 1–30 characters.');
            return;
        }

        if (!preg_match('/^[A-Za-z0-9_-]+$/', $value)) {
            $fail('SMS sender must contain only letters, digits, underscore or hyphen.');
            return;
        }

        // Reject 11-digit phone numbers (supplier blocks them)
        if (preg_match('/^\d{11}$/', $value)) {
            $fail('SMS sender cannot be an 11-digit phone number (supplier rejection).');
        }
    }
}
```

- [ ] **Step 12.4: Run tests + pint + stan**

```bash
cd app
./vendor/bin/pest tests/Unit/Rules/SignalValidatorsTest.php -v
composer pint && composer stan
```

Expected: все тесты passed, 0 stan errors.

- [ ] **Step 12.5: Commit**

```bash
git add app/app/Rules/SignalIdentifier/DomainIdentifier.php \
        app/app/Rules/SignalIdentifier/PhoneIdentifier.php \
        app/app/Rules/SignalIdentifier/SmsSenderRule.php \
        app/tests/Unit/Rules/SignalValidatorsTest.php
git commit -m "feat(rules): add Signal validators (Domain, Phone, SmsSender) with comprehensive datasets"
```

---

## ✓ Comprehensive Verification Gate (END OF PLAN 1)

> Это финальный gate. Без зелёного прохода всех 14 шагов — план НЕ закрыт.

- [ ] **Step CV.1: Fresh migration from scratch**

```bash
cd app
php artisan migrate:fresh
```

Expected: все миграции с нуля прошли.

- [ ] **Step CV.2: Full Pest baseline**

`cd app && composer test`
Expected: ВСЕ существующие 403 + добавленные тесты passed. **0 регрессий.**

- [ ] **Step CV.3: Larastan full**

`cd app && composer stan`
Expected: 0 errors.

- [ ] **Step CV.4: Pint full project**

`cd app && composer pint -- --test`
Expected: clean.

- [ ] **Step CV.5: squawk на все новые миграции**

```bash
cd app
for m in database/migrations/2026_05_10_*.php; do
  npx --yes squawk "$m" || echo "❌ $m"
done
```

Expected: 0 issues per migration.

- [ ] **Step CV.6: pgFormatter dry-run**

`cd app && composer format:sql:check`
Expected: clean.

- [ ] **Step CV.7: cspell на все .md изменения**

`npm run spell` (в корне репозитория)
Expected: 0 unknown words. Если есть — добавить в `cspell-words.txt` (новые валидные термины), либо исправить опечатки.

- [ ] **Step CV.8: markdownlint**

`npm run lint:md`
Expected: 0 errors.

- [ ] **Step CV.9: Cycle check**

`cd app && composer dump-autoload -o`
Expected: 0 warnings (особенно про circular references между models/services).

- [ ] **Step CV.10: Schema diff manual review**

`git diff db/schema.sql`
Verify (visual):

- 5 новых тиблов/расширений присутствуют (3 CREATE TABLE + 1 ALTER TABLE projects + не забыли ничего ещё)
- Все CHECK-constraints читаемы, без опечаток имён колонок
- RLS включён ровно на `lead_charges`
- REVOKE для SaaS-таблиц
- Версия в шапке схемы bumped (v8.11 → v8.13)

- [ ] **Step CV.11: Relations sanity in tinker**

```bash
cd app && php artisan tinker
```

В тинкере:

```php
use App\Models\{Tenant, Project, SupplierProject, LeadCharge, PricingTier, SupplierSyncLog};
$t = Tenant::factory()->create();
DB::transaction(function () use ($t) {
    DB::statement('SET LOCAL app.current_tenant_id = ?', [$t->id]);
    $sp = SupplierProject::factory()->create(['platform' => 'B1']);
    $p = Project::factory()->create(['tenant_id' => $t->id, 'supplier_b1_project_id' => $sp->id]);
    echo $p->supplierB1->platform . "\n";  // B1
    echo $p->signal_type ?? 'null' . "\n"; // null (default)
    $pt = PricingTier::factory()->create();
    echo $pt->price_rubles . "\n";          // float
    $log = SupplierSyncLog::factory()->create(['supplier_project_id' => $sp->id]);
    echo $log->supplierProject->id === $sp->id ? "log->sp OK\n" : "FAIL\n";
});
```

Expected: все строки выводятся корректно.

- [ ] **Step CV.12: Code review subagent**

Dispatch subagent:

```
Agent({
  description: "Code review supplier foundation",
  subagent_type: "general-purpose",
  prompt: "Code-review changes in commits since `<base SHA>` on branch <branch> in c:\моя\проекты\портал crm\Документация\.

  Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md (sections §2, §7)
  Plan: docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md

  Verify:
  1. Migrations — наличие всех CHECK constraints из спеки (B1+SMS forbidden, sms_senders required if signal_type=sms, etc.)
  2. Models — фабрики не создают forbidden combos (B1+SMS), relationships определены, casts соответствуют DDL
  3. Validators — datasets покрывают всё что в §3.1 спеки (домен/номер/sms-sender)
  4. RLS — lead_charges ENABLE+FORCE+POLICY+GRANT, supplier-side таблицы REVOKE
  5. SQL safety — squawk-relevant: нет dangerous DDL без CONCURRENTLY, нет default values for added columns в больших таблицах (projects может быть large)
  6. Опечатки — особенно в SQL identifiers/CHECK constraint names

  Report: что найдено, чего не хватает, и какие потенциальные баги. Под 400 слов."
})
```

После review — поправить найденное, повторить gate.

- [ ] **Step CV.13: Update Tooling reference + project state memory**

Если в этом плане появились новые npm/composer пакеты — обновить `docs/Tooling_v8_3.md`. Если изменились квирки окружения — добавить в `memory/feedback_environment.md`.

В этом плане новых пакетов нет — пропускай шаг.

- [ ] **Step CV.14: Final commit (CHANGELOG + plan checkbox closure)**

Если все шаги CV.1–CV.13 зелёные:

```bash
cd "c:/моя/проекты/портал crm/Документация"
# Mark plan as complete in its header (Status field) если есть
git add docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md
git commit -m "docs(plans): close Plan 1 — supplier integration foundation (schema + models + validators)"
git push origin main  # или branch если в worktree
```

---

## Definition of Done (Plan 1)

- ✅ 5 миграций применяются на чистую БД, откатываются обратно
- ✅ 4 новые модели с factories, accessors, scopes
- ✅ Project model расширен, существующие тесты не сломаны
- ✅ 3 валидатора (Domain/Phone/SmsSender) с dataset-тестами на edge cases
- ✅ Сервис `SupplierProjectResolver` с B1+SMS guard
- ✅ RLS на `lead_charges` работает (cross-tenant isolation тест)
- ✅ db/schema.sql + CHANGELOG_schema.md синхронизированы
- ✅ 0 регрессий: 403 baseline + новые passed
- ✅ Larastan / Pint / squawk / pgFormatter / cspell / markdownlint — все чистые
- ✅ Code review subagent прошёл без блокеров

После закрытия Plan 1 — переходим к Plan 2 (Webhook + Sharing Routing).

---

## Notes & quirks

- **kopecks vs decimal:** все money-поля integer kopecks. Float — только в accessors на чтение.
- **Composite FK:** `lead_charges.deal_id, deal_received_at` → `deals(id, received_at)` через DEFERRABLE INITIALLY DEFERRED — потому что при INSERT deal+charge в одной транзакции composite FK иначе не пройдёт.
- **Existing Project поля:** `tag`, `daily_limit_target`, `region_mask`, `delivery_days_mask`, `is_active`, `effective_daily_limit_today` уже есть. Не дублируем — переиспользуем. `region_mask` — это битовая маска (uint64), не jsonb. Соответствие "regions из спеки" обеспечит mapper в Plan 2.
- **delivery_days_mask:** битовая маска bit 0 = понедельник, bit 6 = воскресенье (ISO day-of-week минус 1). `scopeActiveOnDay($iso)` использует `1 << ($iso - 1)`.
- **Существующий webhook payload:** в Plan 2 Будем парсить `B1_vashinvestor.ru` → platform=B1, identifier=vashinvestor.ru. Адаптация существующего `ProcessWebhookJob`.
- **`SetTenantContext` middleware:** применяется автоматически на роутах под `tenant` alias. Тесты с RLS — внутри `DB::transaction()` с явным `SET LOCAL`.

---

## Self-review checklist (executed by author at write-time)

- [x] **Spec coverage:** §2.1 (Project fields) — Task 1+10. §2.2 (supplier_projects) — Task 2+6. §7 (billing) — Task 3+8 (pricing_tiers + lead_charges). §3.1 (validators) — Task 12. §4.3 (sync_log) — Task 5+9. ✓
- [x] **Placeholder scan:** нет TBD/TODO/«implement later» в коде, везде указаны точные пути и команды. ✓
- [x] **Type consistency:** `signal_type` enum 'site'/'call'/'sms' одинаков в DDL CHECK + ProjectFactory + Validators + Resolver. `platform` enum 'B1'/'B2'/'B3' одинаков везде. `tier_no` integer 1..7 одинаков. ✓
