# Phase 3: DIRECT platform for non-B prefix projects

> **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:** Webhook на проекты без `B[123]_` префикса (`client.carmoney.ru`, `cashmotor.ru`, числовые) принимается, проходит routing, создаёт Deal под новой платформой `DIRECT`. Закрывает оставшиеся ~67 потерь сутки.

**Architecture:** Расширить `platform` enum в `supplier_projects` и `project_supplier_links` до `(B1, B2, B3, DIRECT)` через миграцию. Снять regex в webhook controller. `parsePlatform`/`parseProjectField`/`extractPlatform` возвращают `'DIRECT'` для не-B. `SupplierProjectResolver` принимает DIRECT. `LeadRouter` для DIRECT использует **прямой матч signal_identifier** (потому что DIRECT-supplier_projects ещё не привязаны к Лидерра-проектам через `project_supplier_links`). `LedgerService.resolveSupplierId` — fallback для DIRECT.

**Tech Stack:** Laravel 13 / PostgreSQL 16 / Pest 4 / PHP 8.3

**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 3
**Предусловие:** Phase 2 deployed и наблюдаем clean ≥2 часов.
**Ветка:** `feat/supplier-webhook-fixes` (продолжение)
**Риск:** ВЫСОКИЙ — миграция БД + 5 файлов кода + бизнес-семантика биллинга

---

## Открытые вопросы

- **OQ-2.** `chk_supplier_projects_b1_not_for_sms` constraint — мешает ли DIRECT? **Ответ:** не мешает — это `CHECK (NOT (platform='B1' AND signal_type='sms'))`. DIRECT+SMS пропускается.
- **OQ-3.** Биллинг для DIRECT-платформы — какой Supplier (`suppliers.code`) использовать? **Ответ:** добавим `supplier code='direct'` в seed; в [LedgerService.resolveSupplierId](../../../app/app/Services/Billing/LedgerService.php#L127) добавим case `if platform=='DIRECT' return Supplier::where('code', 'direct')`.
- **OQ-4.** Как DIRECT-supplier_project привязывается к Лидерра-проекту, если `project_supplier_links` для DIRECT supplier_projects ещё нет? **Ответ:** добавляем fallback в `LeadRouter::matchEligibleProjects` для DIRECT supplier_projects — матчинг по `signal_type + signal_identifier` напрямую с `projects.signal_type + projects.signal_identifier`, без обязательного `project_supplier_links`.

---

## File Structure

**Создать:**
- `database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php` — расширение CHECK constraints
- `database/migrations/2026_05_25_120100_seed_direct_supplier.php` — seed строки `suppliers.code='direct'` (cost_rub из существующего шаблона)
- `app/tests/Feature/Supplier/DirectPlatformTest.php` — end-to-end тесты для DIRECT flow

**Изменить:**
- `app/app/Http/Controllers/Api/SupplierWebhookController.php`:
  - line 86: снять `regex:/^B[123]_.+$/'`
  - lines 183-188: `parsePlatform` возвращает `'DIRECT'` для не-B
- `app/app/Jobs/RouteSupplierLeadJob.php`:
  - lines 172-200: `parseProjectField` добавить DIRECT branch
- `app/app/Jobs/Supplier/CsvReconcileJob.php`:
  - lines 237-244: `extractPlatform` возвращает 'DIRECT' (а не `null`) для парсящихся как domain/call/sms строк; `null` оставить только для реального мусора (numeric-only без структуры)
- `app/app/Services/SupplierProjects/SupplierProjectResolver.php`:
  - line 24: `ALLOWED_PLATFORMS = ['B1','B2','B3','DIRECT']`
- `app/app/Services/LeadRouter.php`:
  - lines 50-71: для DIRECT — расширить eligibility SQL с fallback на signal_type+identifier
- `app/app/Services/Billing/LedgerService.php`:
  - lines 127-148: `resolveSupplierId` — добавить case `platform='DIRECT'`
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`:
  - line 95: переписать тест — теперь `invalid_no_b_prefix` → 202 (принимается, platform=DIRECT)
- `db/schema.sql` — отразить новый constraint
- `db/CHANGELOG_schema.md` — запись v8.X

**Не трогать:**
- `LeadDistributor` — cap=3 работает на Collection, platform-agnostic
- `supplier_lead_deliveries` — уже Phase 2 покрывает идемпотентность

---

## Task 1: Read all touched files + verify b1-not-for-sms constraint

**Files:**
- Read: `db/schema.sql` § supplier_projects + project_supplier_links
- Read: `app/database/migrations/` для последней supplier_projects-related migration

- [ ] **Step 1: Find current CHECK constraints**

```bash
grep -n 'chk_supplier_projects_platform\|chk_psl_platform\|chk_supplier_projects_b1' \
  "c:/моя/проекты/портал crm/Документация/db/schema.sql"
```

Зафиксировать exact text constraints для миграции (DROP + ADD).

- [ ] **Step 2: Find last migration touching supplier_projects.platform**

```bash
ls "c:/моя/проекты/портал crm/Документация/app/database/migrations/" | grep -i supplier_project
```

Документировать в комментарии новой миграции.

- [ ] **Step 3: Verify b1-not-for-sms doesn't conflict with DIRECT**

`chk_supplier_projects_b1_not_for_sms` — это `CHECK (NOT (platform='B1' AND signal_type='sms'))`. DIRECT+SMS — не B1, так что пропускается. Не нужно трогать.

---

## Task 2: Migration — extend platform CHECK to include DIRECT

**Files:**
- Create: `app/database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php`

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

```php
<?php

declare(strict_types=1);

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

/**
 * Phase 3 supplier webhook reliability — расширяет platform enum в
 * supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
 *
 * DIRECT — это «прямая» платформа поставщика без B-префикса в имени
 * проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
 * До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись.
 *
 * Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
 *
 * NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
 * DIRECT+SMS этим constraint'ом не блокируется.
 */
return new class extends Migration
{
    public function up(): void
    {
        DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
        DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");

        DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
        DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
    }

    public function down(): void
    {
        // Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
        // иначе constraint провалится при ADD. Это ответственность того, кто
        // запускает migrate:rollback. На prod — отдельный cleanup SQL до отката.
        DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
        DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");

        DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
        DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
    }
};
```

- [ ] **Step 2: Test migration locally**

```
cd app && php artisan migrate --pretend
```

Expected: видим что DROP/ADD CONSTRAINT statements корректны, без ошибок.

```
cd app && php artisan migrate
```

Expected: migration applied. Проверка:
```
cd app && php artisan tinker --execute='echo DB::selectOne("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conname=\"chk_supplier_projects_platform\"")->def;'
```

Должно содержать `'DIRECT'`.

- [ ] **Step 3: Commit migration**

```bash
git add app/database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php
git commit -m "feat(db): extend supplier_projects.platform CHECK to include DIRECT

Adds DIRECT value to chk_supplier_projects_platform and chk_psl_platform
constraints. DIRECT represents supplier projects without B[123]_ prefix
(e.g. client.carmoney.ru, cashmotor.ru, numeric phone IDs) — currently
67 leads/day lost to 302 redirects from webhook validation.

Schema-only change; no code yet uses DIRECT — code changes follow in
subsequent commits. Migration is forward-compatible: old code continues
to work with B1/B2/B3 rows."
```

---

## Task 3: Seed Supplier row with code='direct'

**Files:**
- Create: `app/database/migrations/2026_05_25_120100_seed_direct_supplier.php`

- [ ] **Step 1: Inspect existing suppliers rows**

```
cd app && php artisan tinker --execute='print_r(DB::table("suppliers")->get()->toArray());'
```

Найти существующий `cost_rub` для одной из B-платформ. Использовать тот же (DIRECT — same supplier, разная платформа).

- [ ] **Step 2: Write seed migration**

```php
<?php

declare(strict_types=1);

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

/**
 * Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
 * fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
 * different routing).
 */
return new class extends Migration
{
    public function up(): void
    {
        $b1 = DB::table('suppliers')->where('code', 'b1')->first();
        if ($b1 === null) {
            // Если B1 нет — significant prod drift, не должно произойти.
            return;
        }

        DB::table('suppliers')->updateOrInsert(
            ['code' => 'direct'],
            [
                'name' => 'BP-GR Direct',
                'cost_rub' => $b1->cost_rub,
                'created_at' => now(),
                'updated_at' => now(),
            ]
        );
    }

    public function down(): void
    {
        DB::table('suppliers')->where('code', 'direct')->delete();
    }
};
```

- [ ] **Step 3: Run migration**

```
cd app && php artisan migrate
```

- [ ] **Step 4: Verify**

```
cd app && php artisan tinker --execute='echo DB::table("suppliers")->where("code","direct")->first()->name;'
```

Expected: `BP-GR Direct`.

- [ ] **Step 5: Commit**

```bash
git add app/database/migrations/2026_05_25_120100_seed_direct_supplier.php
git commit -m "feat(db): seed suppliers.code='direct' for DIRECT platform billing"
```

---

## Task 4: Failing test — DirectPlatformTest end-to-end

**Files:**
- Create: `app/tests/Feature/Supplier/DirectPlatformTest.php`

- [ ] **Step 1: Write end-to-end test**

```php
<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

beforeEach(function () {
    SystemSetting::query()
        ->where('key', 'supplier_webhook_secret')
        ->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
    SystemSetting::query()
        ->where('key', 'supplier_ip_allowlist')
        ->update(['value' => '[]']);

    $this->tenant = Tenant::factory()->create([
        'balance_rub' => '1000.00',
        'delivered_in_month' => 0,
    ]);
    $this->project = Project::factory()->create([
        'tenant_id' => $this->tenant->id,
        'signal_type' => 'site',
        'signal_identifier' => 'client.carmoney.ru',
        'is_active' => true,
        'daily_limit_target' => 100,
        'delivered_today' => 0,
    ]);
});

it('webhook with non-B-prefix project is accepted (202) and platform=DIRECT', function () {
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 9999001,
        'project' => 'client.carmoney.ru',
        'phone' => '79991234567',
        'time' => time(),
    ]);
    $response->assertStatus(202);
    expect(SupplierLead::where('vid', 9999001)->exists())->toBeTrue();
    expect(SupplierLead::where('vid', 9999001)->first()->platform)->toBe('DIRECT');
});

it('SupplierProjectResolver creates DIRECT supplier_project for non-B project', function () {
    $resolver = app(\App\Services\SupplierProjects\SupplierProjectResolver::class);
    $sp = $resolver->resolveOrStub('DIRECT', 'site', 'client.carmoney.ru');
    expect($sp->platform)->toBe('DIRECT');
    expect($sp->unique_key)->toBe('client.carmoney.ru');
    expect($sp->signal_type)->toBe('site');
});

it('RouteSupplierLeadJob delivers DIRECT lead to matching Liderra project via signal_identifier fallback', function () {
    $lead = SupplierLead::create([
        'platform' => 'DIRECT',
        'phone' => '79991234567',
        'vid' => 9999002,
        'raw_payload' => ['project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time()],
        'received_at' => now(),
        'source' => 'webhook',
    ]);
    (new RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
        app(\App\Services\LeadDistributor::class),
        app(\App\Services\RegionTagResolver::class),
    );

    $deal = Deal::where('tenant_id', $this->tenant->id)->where('phone', '79991234567')->first();
    expect($deal)->not->toBeNull();
    expect($deal->project_id)->toBe($this->project->id);
    expect($deal->source_crm_id)->toBe(9999002);
});

it('numeric-only project (e.g. 79135191264) accepted as DIRECT', function () {
    // Поставщик иногда шлёт project=телефонный номер (callback-проекты).
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 9999003,
        'project' => '79135191264',
        'phone' => '79991234567',
        'time' => time(),
    ]);
    $response->assertStatus(202);
});

it('existing B1/B2/B3 webhooks still work (regression)', function () {
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 9999004,
        'project' => 'B1_krk-finance.ru',
        'phone' => '79991234567',
        'time' => time(),
    ]);
    $response->assertStatus(202);
    expect(SupplierLead::where('vid', 9999004)->first()->platform)->toBe('B1');
});
```

- [ ] **Step 2: Run tests, expect FAIL on most**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php
```

Expected: тесты #1, #2, #3, #4 FAIL (regex rejects non-B, resolver throws, job throws). Тест #5 PASS (B1 already works).

- [ ] **Step 3: Commit failing tests**

```bash
git add app/tests/Feature/Supplier/DirectPlatformTest.php
git commit -m "test(supplier): end-to-end DIRECT platform tests (failing)"
```

---

## Task 5: Implement — webhook controller accepts non-B + parsePlatform returns DIRECT

**Files:**
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php`

- [ ] **Step 1: Remove regex constraint on project field (line 86)**

```php
'project' => ['required', 'string', 'max:255'], // снят regex /^B[123]_.+$/
```

- [ ] **Step 2: Update parsePlatform (lines 183-188) to return 'DIRECT' for non-B**

```php
private function parsePlatform(string $project): string
{
    if (preg_match('/^(B[123])_/', $project, $m) === 1) {
        return $m[1];
    }
    return 'DIRECT';
}
```

- [ ] **Step 3: Run tests — DirectPlatformTest #1 should now PASS**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='accepted (202) and platform=DIRECT'
```

Expected: PASS. Также:
```
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter='rejects invalid project format'
```

Тест ('rejects invalid project format ... with 422') теперь будет **FAIL** — потому что мы изменили поведение. Это ожидаемое — переписываем тест в следующем step.

- [ ] **Step 4: Rewrite the obsolete test in SupplierWebhookTest.php line 95**

Перепиcать:
```php
it('accepts project without B[123]_ prefix as platform=DIRECT (Phase 3)', function () {
    Bus::fake();
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 1, 'project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time(),
    ]);
    $response->assertStatus(202);
});
```

- [ ] **Step 5: Run full SupplierWebhookTest + DirectPlatformTest**

```
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php tests/Feature/Supplier/DirectPlatformTest.php
```

Expected: тесты #1 в DirectPlatformTest PASS, остальные новые — пока FAIL (resolver/job не готовы).

- [ ] **Step 6: Commit**

```bash
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
git commit -m "feat(supplier-webhook): accept non-B-prefix projects as platform=DIRECT

Drops regex /^B[123]_.+\$/ from project field validation; parsePlatform()
returns 'DIRECT' for projects without B-prefix. SupplierLead created
with platform='DIRECT' for these. Rewrites obsolete test that asserted
invalid_format → 422 — now invalid_format → 202 with platform=DIRECT."
```

---

## Task 6: Implement — SupplierProjectResolver accepts DIRECT

**Files:**
- Modify: `app/app/Services/SupplierProjects/SupplierProjectResolver.php`

- [ ] **Step 1: Extend ALLOWED_PLATFORMS**

```php
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
```

- [ ] **Step 2: Run DirectPlatformTest #2**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='creates DIRECT supplier_project'
```

Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add app/app/Services/SupplierProjects/SupplierProjectResolver.php
git commit -m "feat(supplier): SupplierProjectResolver accepts platform=DIRECT"
```

---

## Task 7: Implement — RouteSupplierLeadJob.parseProjectField + LeadRouter fallback for DIRECT

**Files:**
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:172-200`
- Modify: `app/app/Services/LeadRouter.php:45-76`

- [ ] **Step 1: parseProjectField — добавить DIRECT branch**

В RouteSupplierLeadJob, `parseProjectField` (lines 172-200), заменить начало с:

```php
private function parseProjectField(string $project): array
{
    if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
        $platform = $m[1];
        $rest = $m[2];
    } else {
        // Phase 3: проекты без B-префикса попадают в DIRECT.
        // Весь project считается identifier-частью; signal_type определяется
        // тем же regex'ом, что для $rest у B-префиксных.
        $platform = 'DIRECT';
        $rest = $project;
    }

    // далее существующий код — определение signal_type/identifier на $rest
    // (call / site / sms по regex'ам), без изменений
    $domainRe = '/(?<![a-z0-9.\-])([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,})/i';
    // ... existing logic ...
}
```

- [ ] **Step 2: LeadRouter — добавить DIRECT fallback**

В LeadRouter::matchEligibleProjects, расширить SQL: для DIRECT supplier_projects использовать fallback по signal_type+signal_identifier matchу с Лидерра-проектами (если нет project_supplier_links для DIRECT).

```php
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
{
    $todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);

    // Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
    // match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
    // ещё не настроены (это автоматический матчинг по сигналу). Для B1/B2/B3
    // продолжаем использовать explicit psl-link.
    if ($supplierProject->platform === 'DIRECT') {
        $sql = <<<'SQL'
            SELECT DISTINCT ON (projects.tenant_id) projects.*
            FROM projects
            WHERE projects.signal_type = ?
              AND LOWER(projects.signal_identifier) = LOWER(?)
              AND projects.is_active = true
              AND (projects.delivery_days_mask & ?) <> 0
              AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
              AND EXISTS (
                  SELECT 1 FROM tenants
                  WHERE tenants.id = projects.tenant_id
                    AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
              )
            ORDER BY
                projects.tenant_id,
                (COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
                projects.created_at,
                projects.id
        SQL;
        $rows = DB::connection('pgsql_supplier')->select(
            $sql,
            [$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
        );

        return Project::hydrate($rows)->values();
    }

    // Existing B1/B2/B3 path — explicit psl link
    $sql = <<<'SQL'
        SELECT DISTINCT ON (projects.tenant_id) projects.*
        FROM projects
        WHERE EXISTS (
            SELECT 1 FROM project_supplier_links psl
            WHERE psl.project_id = projects.id
              AND psl.supplier_project_id = ?
        )
        AND projects.is_active = true
        AND (projects.delivery_days_mask & ?) <> 0
        AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
        AND EXISTS (
            SELECT 1 FROM tenants
            WHERE tenants.id = projects.tenant_id
              AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
        )
        ORDER BY
            projects.tenant_id,
            (COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
            projects.created_at,
            projects.id
    SQL;
    $rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);

    return Project::hydrate($rows)->values();
}
```

- [ ] **Step 3: Run DirectPlatformTest #3 — end-to-end DIRECT routing**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='delivers DIRECT lead'
```

Expected: PASS. Deal создан, project_id matched.

- [ ] **Step 4: Run full supplier regression**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/ tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Http/Webhook/
```

Expected: все тесты PASS. Особенно регрессия B1/B2/B3 — proxy через `else` branch.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/RouteSupplierLeadJob.php app/app/Services/LeadRouter.php
git commit -m "feat(supplier): RouteSupplierLeadJob + LeadRouter handle DIRECT platform

parseProjectField() returns ('DIRECT', signal_type, identifier) when project
has no B-prefix; identifier-detection (call/site/sms regex) runs on full
project string. LeadRouter::matchEligibleProjects has a DIRECT fast-path
that matches Liderra projects by (signal_type, signal_identifier) directly
without requiring project_supplier_links pivot — because DIRECT
supplier_projects are auto-created on first webhook and don't have manual
psl links.

B1/B2/B3 path unchanged (psl-based)."
```

---

## Task 8: Implement — LedgerService.resolveSupplierId fallback for DIRECT + CsvReconcileJob extractPlatform

**Files:**
- Modify: `app/app/Services/Billing/LedgerService.php:127-148`
- Modify: `app/app/Jobs/Supplier/CsvReconcileJob.php:237-244`

- [ ] **Step 1: Extend LedgerService.resolveSupplierId**

```php
private function resolveSupplierId(SupplierLead $lead): ?int
{
    if ($lead->supplier_project_id !== null) {
        $sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
        if ($sp !== null) {
            if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
                $supplier = Supplier::where('code', strtolower($sp->platform))->first();
                if ($supplier !== null) {
                    return (int) $supplier->id;
                }
            }
            if ($sp->platform === 'DIRECT') {
                $supplier = Supplier::where('code', 'direct')->first();
                return $supplier?->id;
            }
        }
    }
    // Fallback: parse platform from raw_payload['project']
    $project = trim((string) ($lead->raw_payload['project'] ?? ''));
    if (preg_match('/^(B[123])_/', $project, $m) === 1) {
        $code = strtolower($m[1]);
        $supplier = Supplier::where('code', $code)->first();
        return $supplier?->id;
    }
    // Phase 3: project без B-префикса — DIRECT
    if ($project !== '') {
        $supplier = Supplier::where('code', 'direct')->first();
        return $supplier?->id;
    }
    return null;
}
```

- [ ] **Step 2: Update CsvReconcileJob.extractPlatform**

Сейчас extractPlatform возвращает null для не-B → строка увеличивает `unparseable_count` (правильный для МУСОРА типа phone/URL в поле project, но НЕ для DIRECT-проектов как `client.carmoney.ru`). Различение:

```php
private function extractPlatform(string $project): ?string
{
    if (preg_match('/^(B[123])_/', $project, $m) === 1) {
        return $m[1];
    }
    // Phase 3: пытаемся распарсить как DIRECT (валидный domain/call/sms identifier).
    // Только если строка содержит хотя бы одну букву или dot (= вероятно
    // domain/название), а не чистый-числовой (= скорее всего телефон в роли проекта).
    if (preg_match('/[a-zA-Zа-яА-Я.]/u', $project) === 1) {
        return 'DIRECT';
    }
    // Чисто цифры или мусор — оставляем как unparseable (как было).
    return null;
}
```

NB: чисто-числовые проекты ('79135191264') у поставщика — это **callback-проекты**, они валидны и должны быть DIRECT. Уточняем regex:

```php
private function extractPlatform(string $project): ?string
{
    if (preg_match('/^(B[123])_/', $project, $m) === 1) {
        return $m[1];
    }
    // Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
    // unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
    $trimmed = trim($project);
    if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
        return 'DIRECT';
    }
    return null;
}
```

- [ ] **Step 3: Run regression — CsvReconcileJobTest + RouteSupplierLeadJobBillingTest**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Supplier/DirectPlatformTest.php
```

Expected: все PASS.

- [ ] **Step 4: Commit**

```bash
git add app/app/Services/Billing/LedgerService.php app/app/Jobs/Supplier/CsvReconcileJob.php
git commit -m "feat(supplier): LedgerService + CsvReconcileJob recognise DIRECT platform

LedgerService::resolveSupplierId returns suppliers.code='direct' row for
DIRECT-platform supplier_projects (and for parsed-from-payload non-B
projects). CsvReconcileJob::extractPlatform now classifies most non-empty,
non-junk project strings as DIRECT (instead of dumping them into
unparseable_count) — this allows CSV recovery to also create DIRECT
supplier_leads, mirroring the webhook path."
```

---

## Task 9: Sync db/schema.sql + CHANGELOG_schema.md

**Files:**
- Modify: `db/schema.sql` — поправить constraint definitions
- Modify: `db/CHANGELOG_schema.md`

- [ ] **Step 1: Update db/schema.sql constraint definitions**

В двух местах `chk_supplier_projects_platform` и `chk_psl_platform` — заменить `IN ('B1','B2','B3')` на `IN ('B1','B2','B3','DIRECT')`.

- [ ] **Step 2: Add CHANGELOG_schema.md entry**

```markdown
## v8.X — 2026-05-25 — DIRECT platform support

- Extended `chk_supplier_projects_platform` to include `'DIRECT'`
- Extended `chk_psl_platform` to include `'DIRECT'`
- Seeded `suppliers.code='direct'` row (BP-GR Direct, cost_rub = same as B1)
- Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md
```

- [ ] **Step 3: Commit**

```bash
git add db/schema.sql db/CHANGELOG_schema.md
git commit -m "docs(schema): sync DIRECT platform CHECK constraints to db/schema.sql"
```

---

## Task 10: Regression + prod-readiness

**Files:**
- None

- [ ] **Step 1: /regression full**

```
/regression full
```

Expected: GREEN. Pest --parallel 700+ tests pass.

- [ ] **Step 2: Larastan**

```
cd app && composer stan
```

Expected: 0 errors над baseline.

- [ ] **Step 3: Manual webhook smoke на dev**

(если dev-сервер работает)
```bash
cd app && php artisan serve --port=8000 &
sleep 2
curl -X POST http://localhost:8000/api/webhook/supplier/<dev-secret> \
  -H 'Content-Type: application/json' \
  -d '{"vid":99999,"project":"client.carmoney.ru","phone":"79991234567","time":'$(date +%s)'}'
pkill -f 'artisan serve' || true
```

Expected: `{"status":"accepted","supplier_lead_id":...}` 202.

---

## Task 11: Deploy to liderra.ru

**Files:**
- None

- [ ] **Step 1: prod-deploy-validator agent**

```
subagent_type: prod-deploy-validator
prompt: проверь готовность liderra.ru к Phase 3 деплою. Меняется: миграция БД (2 CHECK constraints), seed (suppliers.code='direct'), 5 PHP-файлов (SupplierWebhookController/RouteSupplierLeadJob/CsvReconcileJob/SupplierProjectResolver/LeadRouter/LedgerService), сменён тест.

Особое внимание:
1. Миграция ALTER CONSTRAINT не блокирует таблицу долго (DROP+ADD на 2 таблицах в одной транзакции).
2. После миграции — обязательный queue:restart (RouteSupplierLeadJob memory-cached в воркерах).
3. redeploy.sh должен сначала migrate потом optimize — проверь порядок.

Phase 1 + Phase 2 уже стоят ≥2h. 8 pre-flight + GO/NO-GO.
```

- [ ] **Step 2: Merge feature branch → main**

```bash
git checkout main
git merge --ff-only feat/supplier-webhook-fixes
git push origin main
```

- [ ] **Step 3: redeploy.sh**

```bash
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -80"
```

Expected: migration ran successfully, queue:restart fired, deploy complete.

- [ ] **Step 4: Prod smoke — webhook with non-B project**

```bash
ssh liderra 'curl -sk -X POST \
  -H "Content-Type: application/json" \
  -d "{\"vid\":99999001,\"project\":\"client.carmoney.ru\",\"phone\":\"79991234567\",\"time\":'$(date +%s)'}" \
  https://liderra.ru/api/webhook/supplier/8c1c07ddb0768763661b357198e0625832f74ad0915d91b1'
```

Expected: `{"status":"accepted","supplier_lead_id":...}` или `{"status":"already_processed",...}` если повтор. Status 202 / 200.

- [ ] **Step 5: Check supplier_projects has new DIRECT row**

```bash
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT id, platform, signal_type, unique_key, created_at FROM supplier_projects WHERE platform='DIRECT' ORDER BY id DESC LIMIT 5\""
```

Expected: видим только что созданную (или существующую) DIRECT-row с unique_key='client.carmoney.ru' (test smoke).

- [ ] **Step 6: Wait 6 hours, observe**

Через 6 часов:
```bash
ssh liderra "sudo grep '/api/webhook/supplier' /var/log/nginx/access.log | grep '$(date +%d/%b)' | awk '{print \$9}' | sort | uniq -c"
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT platform, COUNT(*) FROM supplier_leads WHERE received_at > NOW() - interval '6 hours' GROUP BY platform\""
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT COUNT(*) FILTER (WHERE source_crm_id IS NULL) AS no_crm_id, COUNT(*) FILTER (WHERE source_crm_id IS NOT NULL) AS with_crm_id, COUNT(*) AS total FROM deals WHERE tenant_id=2 AND created_at > NOW() - interval '6 hours'\""
```

Expected:
- nginx: 0 × 302 на webhook (все принимаются)
- supplier_leads: видим записи с platform='DIRECT' (~ 67/24 = 2-3 в час)
- deals: 0 unmerged duplicates (Phase 2 покрывает)

- [ ] **Step 7: Update ПИЛОТ.md + memory**

```bash
# Update ПИЛОТ.md, memory entries
git add ПИЛОТ.md
git commit -m "docs(пилот): Phase 3 supplier DIRECT platform deployed, $X DIRECT leads in 6h"
git push origin main
```

---

## Done criteria для Phase 3

- [ ] Все тесты в DirectPlatformTest.php + регрессия supplier/* + webhook/* PASS
- [ ] /regression full GREEN
- [ ] Larastan baseline clean
- [ ] migration up/down работают на dev
- [ ] Прод-smoke: webhook `project: "client.carmoney.ru"` → 202
- [ ] 6 часов наблюдения: webhook 302 ушли в 0, новые DIRECT leads принимаются, нет дублей

---

## Откат

Сложнее остальных — есть миграция БД.

```bash
# 1. Cleanup: убрать DIRECT-rows если они появились на проде
ssh liderra "sudo -u postgres psql -d liderra -c \"DELETE FROM project_supplier_links WHERE platform='DIRECT'; DELETE FROM supplier_projects WHERE platform='DIRECT'\""

# 2. Migration down
ssh liderra "cd /var/www/liderra/app && sudo -u www-data php artisan migrate:rollback --step=2"

# 3. Revert code
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD~N..HEAD && sudo -u www-data ./redeploy.sh"
```

Лиды с platform=DIRECT, уже превратившиеся в deals, остаются (deal.project_id указывает на валидный Лидерра-проект); supplier_lead.platform='B1' fallback не применится для уже сохранённых, но и не нужен — они уже обработаны.

Если откат нужен экстренно — можно ограничиться **revert кода без migration:rollback**: миграция оставляет DIRECT в enum, старый код просто никогда не создаст такую row. БД не сломается.
