# Supplier Integration — Webhook + Sharing Routing (Plan 2/5)

> **✅ Plan completed (10.05.2026 поздняя ночь).** Все Tasks 1–9 implementations + CV.1–CV.14 verification gates + Task 11 memory updates выполнены в `main` (`fb55bfd..c1ae195`, 16 commits). Чекбоксы ниже намеренно не tick'нуты — финальный статус задокументирован в `memory/project_supplier_integration.md` и `memory/project_state.md`. Plan 2.5 hotfix (commits `1ba1df8` fix #3 idempotency + `c1ae195` fix #2 concurrency) закрыл 2 из 3 BLOCKER findings CV.11 audit; BLOCKER #6 (RLS на `failed_webhook_jobs` INSERT NULL tenant) + 2 operational WARN (`__SET_ON_DEPLOY__` placeholder secret + пустой IP allowlist) + 8 minor WARN + 5 NIT → Plan 3 backlog. Pest 549/547 (+2 от baseline), Larastan + Pint clean.
>
> **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.** Do not skip verification gates. This plan was authored under directive: "перепроверять на все возможные ошибки, в логике, в коде, в цикличностях, опечатки и тд".

**Goal:** Принимать единый stream лидов от поставщика crm.bp-gr.ru на платформенный URL и распределять каждый лид по N tenant-проектам Лидерры (sharing-model: 1 лид → N независимых deal-копий).

**Architecture:** Новый платформенный endpoint `POST /api/webhook/supplier/{secret}` (защита: IP allowlist + system-wide secret token). Контроллер парсит payload, INSERT'ит raw в `supplier_leads`, dispatch'ит `RouteSupplierLeadJob`. Job находит `SupplierProject` через scope `forSignal(platform, signal_type, unique_key)`, определяет регион номера через `PhonePrefixService`, через `LeadRouter` подбирает eligible Лидерра-проекты (FK `supplier_b{1,2,3}_project_id` + active + workdays + region + delivered_today < daily_limit + balance > 0) и для каждого создаёт независимую `deal`-копию с инкрементом счётчиков. Старый per-tenant webhook (`/api/webhook/{token}`) остаётся нетронутым — отдельный legacy-канал, не пересекается с новым.

**Tech Stack:** Laravel 13.7, PostgreSQL 16 (RLS + advisory lock), 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) — §5 (Приём лидов), §6 (Routing).

**Verification philosophy:** TDD для каждой задачи (failing test first → green → commit). Verification gates после каждой задачи. Финальный «Comprehensive Verification Gate» (CV) в конце плана: Larastan + Pest + squawk + pgFormatter + cspell + markdownlint + migrate:fresh + code-reviewer subagent.

**Plan 1 learnings применяются с самого старта** (см. [memory/project_supplier_integration.md](../../../../../../../Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_supplier_integration.md) §«Ключевые learnings»):

- DDL **только в schema.sql** (single source of truth) + `load_initial_schema` миграция; **НЕ создавать incremental Laravel migrations** (Plan 1 учил: их нужно консолидировать → лишний цикл).
- Convention CHECK-имён: `chk_<table>_<purpose>` (НЕ Laravel default).
- Money — kopecks integer. Здесь не money — но pattern помним.
- Pest model-тесты в `tests/Feature/Models/` (не `tests/Unit/Models/`) — Pest auto-binds `\Tests\TestCase` только в Feature/Browser.
- `SET LOCAL app.current_tenant_id` — string interpolation, **не** `?` placeholder (PG limit).
- `testing_rls_user` нужен для проверки RLS (postgres BYPASSRLS обходит политику).
- `chk_<>_required` для NOT NULL conditions через CHECK при partial NOT NULL.

---

## File Structure

### Files to create

```
app/app/Models/
  SupplierLead.php

app/database/factories/
  SupplierLeadFactory.php

app/app/Services/
  PhonePrefixService.php
  LeadRouter.php

app/app/Http/Controllers/Api/
  SupplierWebhookController.php

app/app/Jobs/
  RouteSupplierLeadJob.php

app/app/Console/Commands/
  ResetDeliveredTodayCommand.php

app/tests/Feature/Models/
  SupplierLeadTest.php

app/tests/Unit/Services/
  PhonePrefixServiceTest.php

app/tests/Feature/Services/
  LeadRouterTest.php

app/tests/Feature/Http/Webhook/
  SupplierWebhookTest.php

app/tests/Feature/Jobs/
  RouteSupplierLeadJobTest.php

app/tests/Feature/Console/
  ResetDeliveredTodayCommandTest.php

app/tests/Feature/Integration/
  SupplierLeadFlowTest.php
```

### Files to modify

```
db/schema.sql                        v8.17 → v8.18
db/CHANGELOG_schema.md               +entry v8.18
app/routes/web.php                   +1 route (line ~127, рядом с legacy)
app/bootstrap/app.php                +schedule callback (если используется ->withSchedule()) ИЛИ
app/routes/console.php               +schedule (Laravel 11+ default)
```

### Files NOT to modify

```
app/app/Http/Controllers/Api/WebhookReceiveController.php  # legacy per-tenant; trogether
app/app/Jobs/ProcessWebhookJob.php                          # legacy per-tenant; together
```

Эти два файла — legacy per-tenant flow. Они остаются нетронутыми. Новый supplier-flow живёт параллельно. Решение о deprecation — после Plan 5.

### File responsibilities

| Файл | Ответственность |
|---|---|
| `db/schema.sql` v8.18 | Создать `supplier_leads` + расширить `projects.delivered_today` + 2 строки в `system_settings` (secret + IP allowlist) |
| `SupplierLead` | Eloquent-модель для raw-payload (SaaS-level, без RLS — supplier_lead появляется до routing) |
| `PhonePrefixService` | phone (E.164 / 11 цифр) → `region_code` (ISO 3166-2:RU) + federal district bit (1..128 для region_mask) |
| `LeadRouter` | Чистая функция: `(SupplierProject, phone, region_bit) → Collection<Project>` (eligible) |
| `SupplierWebhookController` | HTTP layer: secret check + IP allowlist + payload validation + INSERT supplier_leads + dispatch |
| `RouteSupplierLeadJob` | Background: парсит payload, ищет supplier_project, вызывает LeadRouter, создаёт deal-копии, апдейтит счётчики, шлёт уведомления |
| `ResetDeliveredTodayCommand` | Cron 00:00 МСК: `UPDATE projects SET delivered_today = 0` |

---

## Task 1: Schema — supplier_leads + delivered_today + system_settings rows

**Files:**

- Modify: `db/schema.sql` (bump v8.17 → v8.18; +1 раздел; +1 колонка; +2 seed-строки)
- Modify: `db/CHANGELOG_schema.md` (+1 entry v8.18)

**Контекст:** Новая SaaS-level таблица `supplier_leads` хранит raw-payload входящих webhook'ов от поставщика **до** routing'а. Не tenant-scoped (на момент INSERT'а tenant ещё не определён — routing в job'е). Колонка `projects.delivered_today` хранит дневной счётчик для проверки квоты в §6 спека. 2 строки в `system_settings` хранят platform-wide secret и IP-allowlist для defense-in-depth.

- [ ] **Step 1: Failing test — supplier_leads table existence + columns**

Файл: `app/tests/Feature/Integration/SchemaV8_18Test.php`

```php
<?php

declare(strict_types=1);

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

it('supplier_leads table exists with required columns', function (): void {
    expect(Schema::hasTable('supplier_leads'))->toBeTrue();
    expect(Schema::hasColumns('supplier_leads', [
        'id', 'supplier_project_id', 'platform', 'raw_payload', 'vid',
        'phone', 'received_at', 'source', 'processed_at', 'deals_created_count',
    ]))->toBeTrue();
});

it('supplier_leads has FK to supplier_projects with ON DELETE SET NULL', function (): void {
    $row = DB::selectOne(<<<'SQL'
        SELECT confdeltype
        FROM pg_constraint c
        JOIN pg_class t ON t.oid = c.conrelid
        WHERE t.relname = 'supplier_leads' AND c.contype = 'f'
        SQL);
    expect($row)->not->toBeNull();
    expect($row->confdeltype)->toBe('n'); // n = SET NULL
});

it('supplier_leads.source has CHECK enum', function (): void {
    DB::statement("INSERT INTO supplier_leads (platform, raw_payload, vid, phone, received_at, source) VALUES ('B1', '{}', 1, '79991234567', NOW(), 'webhook')");
    expect(fn () => DB::statement("INSERT INTO supplier_leads (platform, raw_payload, vid, phone, received_at, source) VALUES ('B1', '{}', 2, '79991234567', NOW(), 'invalid_source')"))
        ->toThrow(\Illuminate\Database\QueryException::class);
});

it('projects.delivered_today exists with default 0', function (): void {
    expect(Schema::hasColumn('projects', 'delivered_today'))->toBeTrue();
    $defaults = DB::selectOne("SELECT column_default FROM information_schema.columns WHERE table_name = 'projects' AND column_name = 'delivered_today'");
    expect($defaults->column_default)->toContain('0');
});

it('system_settings seed rows exist for supplier_webhook_secret + supplier_ip_allowlist', function (): void {
    $secret = DB::selectOne("SELECT value FROM system_settings WHERE key = 'supplier_webhook_secret'");
    expect($secret)->not->toBeNull();
    expect((string) $secret->value)->toMatch('/^[A-Za-z0-9_\-]{32,}$/');

    $allowlist = DB::selectOne("SELECT value FROM system_settings WHERE key = 'supplier_ip_allowlist'");
    expect($allowlist)->not->toBeNull();
    expect(json_decode($allowlist->value, true))->toBeArray();
});
```

- [ ] **Step 2: Run test to verify it fails**

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Integration/SchemaV8_18Test.php --filter='supplier_leads table exists'
```

Expected: FAIL with `Failed asserting that false is true` (table not yet created).

- [ ] **Step 3: Edit `db/schema.sql` — bump version + add table + column + seed**

Шапка файла: header comment v8.17 → **v8.18**.

```diff
--- header
-- Версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: ...)
+-- Версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
+-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK + CHECK + resolver guard)
```

Добавить **в конце раздела 5 (после `failed_webhook_jobs` / `rejected_deals_log`, перед разделом 7 биллинг)** новый блок:

```sql
-- -----------------------------------------------------------------------------
-- supplier_leads — SaaS-level raw-payload входящих webhook'ов (v8.18, Plan 2/5)
-- -----------------------------------------------------------------------------
-- Контекст: см. spec §5.1. Поставщик POST'ит на /api/webhook/supplier/{secret}
-- (общий URL, не per-tenant). На момент INSERT'а tenant ещё не определён —
-- routing происходит в RouteSupplierLeadJob через supplier_project + LeadRouter.
-- Поэтому таблица НЕ tenant-scoped, RLS НЕ применяется (как supplier_projects).
--
-- supplier_project_id заполняется job'ом ПОСЛЕ парсинга `project` из payload
-- (`B1_vashinvestor.ru` → platform=B1, signal_identifier=vashinvestor.ru).
-- ON DELETE SET NULL: при удалении supplier_project не каскадим — сохраняем
-- raw для аудита.
--
-- source: 'webhook' (основной канал §5.1) | 'csv_recovery' (резерв §5.2 —
-- Plan 4 CSV reconciliation). На Plan 2 поддерживаем только 'webhook';
-- enum extensibility — для Plan 4.
--
-- deals_created_count: сколько deal-копий было создано после routing'а
-- (sharing-model: 0..N). Заполняется job'ом в конце обработки. NULL до processed.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_leads (
    id                      BIGSERIAL PRIMARY KEY,
    supplier_project_id     BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
    platform                VARCHAR(4) NOT NULL,                          -- B1 / B2 / B3 (parsed from project prefix)
    raw_payload             JSONB NOT NULL,                                -- весь payload как есть
    vid                     BIGINT NOT NULL,                               -- supplier-side lead id (idempotency key in source CRM)
    phone                   VARCHAR(20) NOT NULL,
    received_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    source                  VARCHAR(16) NOT NULL DEFAULT 'webhook',
    processed_at            TIMESTAMPTZ,
    deals_created_count     INTEGER,                                       -- NULL до обработки; 0..N после
    error                   TEXT,                                          -- сообщение если job упал

    CONSTRAINT chk_supplier_leads_platform
        CHECK (platform IN ('B1','B2','B3')),
    CONSTRAINT chk_supplier_leads_source
        CHECK (source IN ('webhook','csv_recovery')),
    CONSTRAINT chk_supplier_leads_deals_count_nonneg
        CHECK (deals_created_count IS NULL OR deals_created_count >= 0)
);

CREATE INDEX idx_supplier_leads_received_at ON supplier_leads(received_at DESC);
CREATE INDEX idx_supplier_leads_supplier_project ON supplier_leads(supplier_project_id) WHERE supplier_project_id IS NOT NULL;
-- Idempotency lookup: при повторной доставке того же vid от поставщика — пропускаем.
CREATE UNIQUE INDEX idx_supplier_leads_vid_unique ON supplier_leads(vid);

-- v8.18 (Plan 2/5): defense-in-depth — REVOKE ALL FROM crm_app_user.
-- SaaS-level таблица. tenant-приложение читать не должно (raw-payload — наша зона).
-- Conditional wrapper см. в supplier_projects (dev=postgres superuser без crm_app_user).
--
-- REVOKE ALL ON supplier_leads FROM crm_app_user;
```

В разделе 4 (`projects` таблица), **после** `delivered_in_month` добавить:

```sql
    -- РАСШИРЕНИЕ v8.18 (Plan 2/5): дневной счётчик доставленных лидов.
    -- Сбрасывается cron'ом ResetDeliveredTodayCommand в 00:00 МСК.
    -- Используется в LeadRouter для проверки квоты (delivered_today < effective_daily_limit).
    delivered_today                 INTEGER NOT NULL DEFAULT 0
                                    CHECK (delivered_today >= 0),
```

Найти `delivered_in_month` в DDL projects (строка ~785) и добавить **новую** колонку `delivered_today` сразу после `delivered_in_month`. Так же добавить в одну CHECK constraints не нужно — inline CHECK уже есть.

В блоке `INSERT INTO system_settings` (раздел 12, строка ~2498) **в конце списка** добавить **2 новых seed-строки**:

```sql
    ('supplier_webhook_secret',  -- key
     '__SET_ON_DEPLOY__',        -- value (placeholder; admin генерит при первом деплое)
     'string',                   -- type
     'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
    ('supplier_ip_allowlist',
     '[]',                       -- JSON array, пустой массив = пропускать всех (dev-режим)
     'json',
     'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
```

⚠️ Внимание: проверить точный синтаксис существующих seed-строк в schema.sql:2498 — формат может быть `(key, value, type, description)` OR `(key, type, description, value)`. Адаптировать под реальный.

- [ ] **Step 4: Add CHANGELOG entry**

Файл `db/CHANGELOG_schema.md` — append:

````markdown
## v8.18 от 10.05.2026 (Plan 2/5 Task 1)

**Цель:** Подготовка слоя данных для supplier-webhook + sharing routing (spec §5–§6).

**Изменения:**
- ✅ Новая таблица `supplier_leads` (SaaS-level, без RLS) — raw-payload входящих webhook'ов от поставщика, FK на `supplier_projects(id) ON DELETE SET NULL`, 3 CHECK (platform enum / source enum / deals_count nonneg), 3 индекса (idx_received_at DESC + idx_supplier_project partial + UNIQUE на vid для idempotency).
- ✅ `projects.delivered_today INTEGER NOT NULL DEFAULT 0 CHECK (>=0)` — дневной счётчик для проверки квоты, сбрасывается cron'ом в 00:00 МСК.
- ✅ 2 строки в `system_settings`:
  - `supplier_webhook_secret` (string, placeholder `__SET_ON_DEPLOY__`) — platform-wide секрет в URL.
  - `supplier_ip_allowlist` (json, default `[]`) — IP/CIDR поставщика.

**Метрики:** 60 → **61 базовых таблиц**, +3 индекса (111 → **114**), 39 RLS (без изменений — supplier_leads SaaS-level).

**REVOKE:** `supplier_leads` defense-in-depth (закомментирован, conditional wrapper аналогично `supplier_projects`).
````

- [ ] **Step 5: Apply schema fresh + verify**

Run:

```powershell
cd app
.\artisan migrate:fresh --env=testing
.\vendor\bin\pest tests/Feature/Integration/SchemaV8_18Test.php
```

Expected: PASS (5 assertions).

- [ ] **Step 6: pgFormatter check + lefthook squawk dry-run**

Run:

```powershell
cd "c:\моя\проекты\портал crm\Документация"
npm run format:sql:check
.\bin\squawk.exe db/schema.sql
```

Expected: оба чисто (нет diff'а в pgFormatter, 0 issues в squawk).

- [ ] **Step 7: Commit**

```bash
git add db/schema.sql db/CHANGELOG_schema.md app/tests/Feature/Integration/SchemaV8_18Test.php
git commit -m "$(cat <<'EOF'
feat(db): supplier_leads + projects.delivered_today + 2 system_settings (v8.18)

Plan 2/5 Task 1 — слой данных для supplier-webhook flow.

- supplier_leads (SaaS-level, без RLS) — raw payload incoming webhook'ов
- projects.delivered_today — дневной счётчик для проверки daily quota
- system_settings: supplier_webhook_secret + supplier_ip_allowlist

Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5-§6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: SupplierLead model + factory + tests

**Files:**

- Create: `app/app/Models/SupplierLead.php`
- Create: `app/database/factories/SupplierLeadFactory.php`
- Create: `app/tests/Feature/Models/SupplierLeadTest.php`

**Контекст:** Eloquent-модель для `supplier_leads`. SaaS-level (без RLS). Связь `belongsTo(SupplierProject)`. `raw_payload` cast в array. `received_at` / `processed_at` — datetime cast. Используется в `RouteSupplierLeadJob` (Task 5).

- [ ] **Step 1: Failing test**

Файл `app/tests/Feature/Models/SupplierLeadTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\SupplierLead;
use App\Models\SupplierProject;

it('factory creates a valid SupplierLead', function (): void {
    $supplierProject = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'vashinvestor.ru',
    ]);

    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => $supplierProject->id,
        'platform' => 'B1',
    ]);

    expect($lead->id)->toBeInt()->toBeGreaterThan(0);
    expect($lead->platform)->toBe('B1');
    expect($lead->raw_payload)->toBeArray();
    expect($lead->received_at)->toBeInstanceOf(\Illuminate\Support\Carbon::class);
});

it('belongsTo supplier_project', function (): void {
    $supplierProject = SupplierProject::factory()->create();
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => $supplierProject->id,
    ]);

    expect($lead->supplierProject->id)->toBe($supplierProject->id);
});

it('casts raw_payload to array and *_at to Carbon', function (): void {
    $lead = SupplierLead::factory()->create([
        'raw_payload' => ['vid' => 12345, 'project' => 'B1_test.ru'],
        'processed_at' => now(),
    ]);

    expect($lead->raw_payload)->toBe(['vid' => 12345, 'project' => 'B1_test.ru']);
    expect($lead->processed_at)->toBeInstanceOf(\Illuminate\Support\Carbon::class);
});

it('factory respects deals_created_count nullable default', function (): void {
    $lead = SupplierLead::factory()->create();
    expect($lead->deals_created_count)->toBeNull();
    expect($lead->processed_at)->toBeNull();
});
```

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Models/SupplierLeadTest.php
```

Expected: FAIL (Class not found).

- [ ] **Step 2: Implement model**

Файл `app/app/Models/SupplierLead.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

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

/**
 * Raw-payload входящих webhook'ов от поставщика crm.bp-gr.ru.
 *
 * SaaS-level таблица (без RLS) — на момент INSERT'а tenant ещё не определён;
 * routing к Лидерра-проектам происходит в RouteSupplierLeadJob через
 * supplier_project_id + LeadRouter.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.1
 *
 * @mixin IdeHelperSupplierLead
 */
class SupplierLead extends Model
{
    /** @use HasFactory<SupplierLeadFactory> */
    use HasFactory;

    protected $table = 'supplier_leads';

    public $timestamps = false;

    protected $fillable = [
        'supplier_project_id',
        'platform',
        'raw_payload',
        'vid',
        'phone',
        'received_at',
        'source',
        'processed_at',
        'deals_created_count',
        'error',
    ];

    protected function casts(): array
    {
        return [
            'raw_payload' => 'array',
            'received_at' => 'datetime',
            'processed_at' => 'datetime',
            'vid' => 'integer',
            'deals_created_count' => 'integer',
        ];
    }

    /** @return BelongsTo<SupplierProject, $this> */
    public function supplierProject(): BelongsTo
    {
        return $this->belongsTo(SupplierProject::class);
    }

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

- [ ] **Step 3: Implement factory**

Файл `app/database/factories/SupplierLeadFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

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

/**
 * @extends Factory<SupplierLead>
 */
class SupplierLeadFactory extends Factory
{
    protected $model = SupplierLead::class;

    /** @return array<string,mixed> */
    public function definition(): array
    {
        $platform = $this->faker->randomElement(['B1', 'B2', 'B3']);
        $vid = $this->faker->unique()->numberBetween(100_000_000, 999_999_999);
        $phone = '7'.$this->faker->numerify('##########'); // 11 digits, leading 7

        return [
            'supplier_project_id' => null,
            'platform' => $platform,
            'raw_payload' => [
                'vid' => $vid,
                'project' => $platform.'_test.example.com',
                'tag' => 'test-tag',
                'phone' => $phone,
                'phones' => [$phone],
                'time' => now()->getTimestamp(),
            ],
            'vid' => $vid,
            'phone' => $phone,
            'received_at' => now(),
            'source' => 'webhook',
            'processed_at' => null,
            'deals_created_count' => null,
            'error' => null,
        ];
    }
}
```

- [ ] **Step 4: Update IDE helper**

Run:

```powershell
cd app
.\artisan ide-helper:models -W -M -N
```

Expected: regenerates `_ide_helper_models.php` with SupplierLead. **Без аргумента** (Plan 1 learning #14).

- [ ] **Step 5: Run tests**

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Models/SupplierLeadTest.php
```

Expected: PASS (4 tests).

- [ ] **Step 6: Larastan check**

Run:

```powershell
cd app; .\vendor\bin\phpstan analyse
```

Expected: 0 errors (Plan 1 baseline сохранён).

- [ ] **Step 7: Commit**

```bash
git add app/app/Models/SupplierLead.php app/database/factories/SupplierLeadFactory.php app/tests/Feature/Models/SupplierLeadTest.php app/_ide_helper_models.php
git commit -m "$(cat <<'EOF'
feat(models): SupplierLead model + factory (raw-payload incoming webhooks)

SaaS-level модель для supplier_leads (Plan 2/5 Task 2).
belongsTo(SupplierProject) + array cast на raw_payload + datetime *_at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: PhonePrefixService (MVP — мобильные коды + федеральные округа)

**Files:**

- Create: `app/app/Services/PhonePrefixService.php`
- Create: `app/tests/Unit/Services/PhonePrefixServiceTest.php`

**Контекст:** Spec §6 step 2: «Определяем регион номера по коду телефона (справочник DEF-кодов)». Полный справочник Минсвязи — 40K+ строк, импорт отложен на Plan 5 / future. **MVP-стратегия:** мобильные операторы (9XX) → возвращаем `region_bit = 0xFF` (все 8 округов — лид принимается везде, фактический регион определяется позже на стороне клиента UI). Кодов городов (495/812 и т.п.) — мапим на конкретный округ. Возвращаемый bit складывается с `projects.region_mask` через `&`.

Архитектура: чистая stateless-функция, без БД. Тесты — Unit (без Pest TestCase / DB).

- [ ] **Step 1: Failing test**

Файл `app/tests/Unit/Services/PhonePrefixServiceTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\PhonePrefixService;

it('mobile prefix 9XX returns all-districts mask', function (): void {
    $service = new PhonePrefixService();

    expect($service->resolveDistrictBit('79991234567'))->toBe(0xFF); // все 8 округов
});

it('Moscow code 495 returns Central district bit (1)', function (): void {
    $service = new PhonePrefixService();

    expect($service->resolveDistrictBit('74951234567'))->toBe(1);
});

it('Saint-Petersburg code 812 returns Northwest district bit (2)', function (): void {
    $service = new PhonePrefixService();

    expect($service->resolveDistrictBit('78121234567'))->toBe(2);
});

it('Yekaterinburg code 343 returns Ural district bit (32)', function (): void {
    $service = new PhonePrefixService();

    expect($service->resolveDistrictBit('73431234567'))->toBe(32);
});

it('unknown code returns all-districts mask (defensive default)', function (): void {
    $service = new PhonePrefixService();

    expect($service->resolveDistrictBit('70001234567'))->toBe(0xFF);
});

it('rejects invalid phone formats', function (): void {
    $service = new PhonePrefixService();

    expect(fn () => $service->resolveDistrictBit('123'))->toThrow(\InvalidArgumentException::class);
    expect(fn () => $service->resolveDistrictBit('+79991234567'))->toThrow(\InvalidArgumentException::class);
    expect(fn () => $service->resolveDistrictBit('89991234567'))->toThrow(\InvalidArgumentException::class);
});

it('matchesProject — region_mode include + matching bit', function (): void {
    $service = new PhonePrefixService();

    expect($service->phoneMatchesRegions('74951234567', regionMask: 1, regionMode: 'include'))->toBeTrue();
    expect($service->phoneMatchesRegions('74951234567', regionMask: 2, regionMode: 'include'))->toBeFalse();
});

it('matchesProject — region_mode exclude inverts logic', function (): void {
    $service = new PhonePrefixService();

    expect($service->phoneMatchesRegions('74951234567', regionMask: 1, regionMode: 'exclude'))->toBeFalse();
    expect($service->phoneMatchesRegions('74951234567', regionMask: 2, regionMode: 'exclude'))->toBeTrue();
});

it('mobile (all-districts) always passes include any-bit', function (): void {
    $service = new PhonePrefixService();

    expect($service->phoneMatchesRegions('79991234567', regionMask: 1, regionMode: 'include'))->toBeTrue();
    expect($service->phoneMatchesRegions('79991234567', regionMask: 255, regionMode: 'include'))->toBeTrue();
});
```

Run:

```powershell
cd app; .\vendor\bin\pest tests/Unit/Services/PhonePrefixServiceTest.php
```

Expected: FAIL (class not found).

- [ ] **Step 2: Implement service**

Файл `app/app/Services/PhonePrefixService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use InvalidArgumentException;

/**
 * Резолв phone → federal district bit для region_mask на projects.
 *
 * Битовое представление 8 ФО (синхронно с projects.region_mask):
 *   1   = Центральный
 *   2   = Северо-Западный
 *   4   = Южный
 *   8   = Северо-Кавказский
 *   16  = Приволжский
 *   32  = Уральский
 *   64  = Сибирский
 *   128 = Дальневосточный
 *   255 = все 8 округов (мобильные / неизвестный код)
 *
 * MVP-стратегия (Plan 2/5):
 *   - Мобильные (9XX) → 255 (мобильник может быть в любом округе по факту;
 *     передаём all-bits, чтобы любой include-фильтр прошёл).
 *   - Известные коды городов — мапим на ФО.
 *   - Неизвестный → 255 (defensive — лучше пропустить, чем потерять).
 *
 * Полный справочник Минсвязи DEF-codes (40K+ строк) — отложен на Plan 5 / future.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 step 2
 */
class PhonePrefixService
{
    private const DISTRICT_CENTRAL       = 1;
    private const DISTRICT_NORTHWEST     = 2;
    private const DISTRICT_SOUTH         = 4;
    private const DISTRICT_NORTH_CAUCASUS = 8;
    private const DISTRICT_VOLGA         = 16;
    private const DISTRICT_URAL          = 32;
    private const DISTRICT_SIBERIA       = 64;
    private const DISTRICT_FAR_EAST      = 128;
    private const ALL_DISTRICTS          = 0xFF;

    /**
     * 3-значный код города (после '7') → district bit.
     * Источник: общеизвестные ABC-коды Минсвязи (MVP-выборка).
     *
     * @var array<string, int>
     */
    private const CITY_CODE_MAP = [
        // Центральный
        '495' => self::DISTRICT_CENTRAL, '498' => self::DISTRICT_CENTRAL,
        '499' => self::DISTRICT_CENTRAL, '496' => self::DISTRICT_CENTRAL,
        '4922' => self::DISTRICT_CENTRAL, // Владимир (4-digit)
        '484' => self::DISTRICT_CENTRAL,  // Калуга
        // Северо-Западный
        '812' => self::DISTRICT_NORTHWEST,
        '813' => self::DISTRICT_NORTHWEST,
        '814' => self::DISTRICT_NORTHWEST,
        '815' => self::DISTRICT_NORTHWEST, // Мурманск
        '816' => self::DISTRICT_NORTHWEST, // Новгород
        '818' => self::DISTRICT_NORTHWEST, // Архангельск
        // Южный
        '861' => self::DISTRICT_SOUTH,     // Краснодар
        '862' => self::DISTRICT_SOUTH,     // Сочи
        '863' => self::DISTRICT_SOUTH,     // Ростов
        '865' => self::DISTRICT_SOUTH,     // Ставрополь (фактически СКФО, но MVP-приближение)
        // Северо-Кавказский
        '871' => self::DISTRICT_NORTH_CAUCASUS, // Грозный
        '872' => self::DISTRICT_NORTH_CAUCASUS,
        '873' => self::DISTRICT_NORTH_CAUCASUS,
        // Приволжский
        '831' => self::DISTRICT_VOLGA,     // Нижний Новгород
        '843' => self::DISTRICT_VOLGA,     // Казань
        '846' => self::DISTRICT_VOLGA,     // Самара
        '347' => self::DISTRICT_VOLGA,     // Уфа
        '342' => self::DISTRICT_VOLGA,     // Пермь
        // Уральский
        '343' => self::DISTRICT_URAL,      // Екатеринбург
        '345' => self::DISTRICT_URAL,      // Тюмень
        '351' => self::DISTRICT_URAL,      // Челябинск
        // Сибирский
        '383' => self::DISTRICT_SIBERIA,   // Новосибирск
        '391' => self::DISTRICT_SIBERIA,   // Красноярск
        '381' => self::DISTRICT_SIBERIA,   // Омск
        '382' => self::DISTRICT_SIBERIA,   // Томск
        // Дальневосточный
        '423' => self::DISTRICT_FAR_EAST,  // Владивосток
        '421' => self::DISTRICT_FAR_EAST,  // Хабаровск
        '411' => self::DISTRICT_FAR_EAST,  // Якутск
    ];

    /**
     * Возвращает битовое значение федерального округа для phone (E.164 11 цифр, начинается с 7).
     *
     * @throws InvalidArgumentException если phone не соответствует формату.
     */
    public function resolveDistrictBit(string $phone): int
    {
        if (! preg_match('/^7\d{10}$/', $phone)) {
            throw new InvalidArgumentException(
                "Phone must be 11 digits starting with 7 (got: {$phone})"
            );
        }

        $codeArea = substr($phone, 1, 3); // 3-digit ABC-code (after leading 7)

        // Mobile prefix (9XX) → all districts (мобильник может быть везде)
        if ($codeArea[0] === '9') {
            return self::ALL_DISTRICTS;
        }

        // Try 4-digit prefix (для редких регионов типа 4922 Владимир)
        $code4 = substr($phone, 1, 4);
        if (isset(self::CITY_CODE_MAP[$code4])) {
            return self::CITY_CODE_MAP[$code4];
        }

        return self::CITY_CODE_MAP[$codeArea] ?? self::ALL_DISTRICTS;
    }

    /**
     * Проверяет соответствие phone настройкам региона проекта.
     *
     * @param  int     $regionMask  битмаска projects.region_mask (0..255).
     * @param  string  $regionMode  'include' | 'exclude'.
     */
    public function phoneMatchesRegions(string $phone, int $regionMask, string $regionMode): bool
    {
        $phoneBits = $this->resolveDistrictBit($phone);
        $intersect = $phoneBits & $regionMask;

        return match ($regionMode) {
            'include' => $intersect !== 0,
            'exclude' => $intersect === 0,
            default => throw new InvalidArgumentException("Unknown region_mode: {$regionMode}"),
        };
    }
}
```

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

Run:

```powershell
cd app; .\vendor\bin\pest tests/Unit/Services/PhonePrefixServiceTest.php
```

Expected: PASS (9 tests).

- [ ] **Step 4: Commit**

```bash
git add app/app/Services/PhonePrefixService.php app/tests/Unit/Services/PhonePrefixServiceTest.php
git commit -m "$(cat <<'EOF'
feat(services): PhonePrefixService — phone → federal district bit (MVP)

Маппинг мобильных + 30+ ABC-кодов городов на 8 ФО RF (synchronized
с projects.region_mask битами). Мобильные → all-districts (255).
Полный Минсвязи справочник отложен на Plan 5+.

Spec: §6 step 2 routing geo-filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: LeadRouter service — eligible Liderra projects

**Files:**

- Create: `app/app/Services/LeadRouter.php`
- Create: `app/tests/Feature/Services/LeadRouterTest.php`

**Контекст:** Чистая функция: дано (`SupplierProject`, phone) → возвращает `Collection<Project>` где каждый проект:

- ссылается на этот supplier_project через `supplier_b{1,2,3}_project_id` (зависит от supplier_project.platform);
- `is_active = true`;
- сегодняшний день в `delivery_days_mask`;
- регион phone попадает в `region_mask` × `region_mode`;
- `delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)`;
- `tenant.balance_leads > 0`.

Сортировка: `created_at ASC` (старые проекты первыми — детерминированный порядок, spec §6 step 4).

**Не учитывает:** дедупликацию по phone (Биз-19) — это в Job (после создания deal). Job сначала ищет master через `DuplicateDetector`, и если master найден — помечает копию `duplicate_of_id` (без charge — это уже Plan 4 territory).

⚠️ **RLS-quirk:** LeadRouter обращается к проектам **через несколько tenant'ов** (sharing). Нужно либо (а) обходить RLS через `pg_set_role` на elevated role, либо (б) выполнять запрос вне tenant-context'а (без `SET LOCAL app.current_tenant_id`). На dev (postgres BYPASSRLS) запрос пройдёт. На prod — нужен elevated role. **Для MVP** — выполняем запрос без `SET LOCAL` (нет tenant-context), и полагаемся на: dev=BYPASSRLS, prod=elevated role при выполнении job'а (см. Plan 5+ DevOps). Документируем это в docblock.

- [ ] **Step 1: Failing test**

Файл `app/tests/Feature/Services/LeadRouterTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Support\Facades\DB;

beforeEach(function (): void {
    // Tenant context off — sharing routing идёт без RLS
    DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});

it('returns matching active projects for B1 site supplier_project (sharing across tenants)', function (): void {
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'vashinvestor.ru',
    ]);

    $tenant1 = Tenant::factory()->create(['balance_leads' => 100]);
    $tenant2 = Tenant::factory()->create(['balance_leads' => 100]);

    $project1 = Project::factory()->create([
        'tenant_id' => $tenant1->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => 'vashinvestor.ru',
        'is_active' => true,
        'daily_limit_target' => 10,
        'delivered_today' => 0,
        'delivery_days_mask' => 127, // все 7 дней
        'region_mask' => 255,        // все 8 округов
        'region_mode' => 'include',
    ]);

    $project2 = Project::factory()->create([
        'tenant_id' => $tenant2->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'signal_identifier' => 'vashinvestor.ru',
        'is_active' => true,
        'daily_limit_target' => 10,
        'delivered_today' => 0,
        'delivery_days_mask' => 127,
        'region_mask' => 255,
        'region_mode' => 'include',
    ]);

    $router = app(LeadRouter::class);
    $matched = $router->matchEligibleProjects($supplier, '79991234567');

    expect($matched)->toHaveCount(2);
    expect($matched->pluck('id')->all())->toEqualCanonicalizing([$project1->id, $project2->id]);
});

it('skips paused project (is_active=false)', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => false, // ← paused
    ]);

    $router = app(LeadRouter::class);
    expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});

it('skips project where today is not in delivery_days_mask', function (): void {
    // current день недели (ISO 1=Mon..7=Sun); mask без сегодняшнего бита
    $todayBit = 1 << (now()->isoWeekday() - 1);
    $maskWithoutToday = 127 & ~$todayBit;

    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
        'delivery_days_mask' => $maskWithoutToday,
    ]);

    $router = app(LeadRouter::class);
    expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});

it('skips project where delivered_today >= effective_daily_limit_today', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
        'effective_daily_limit_today' => 5,
        'delivered_today' => 5,
    ]);

    $router = app(LeadRouter::class);
    expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});

it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
        'effective_daily_limit_today' => null,
        'daily_limit_target' => 10,
        'delivered_today' => 5,
    ]);

    $router = app(LeadRouter::class);
    expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
});

it('skips project where region_mode=include and region_mask does not include phone district', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
        'region_mask' => 1, // только Центральный округ
        'region_mode' => 'include',
    ]);

    $router = app(LeadRouter::class);
    // 78121234567 = СПб (Северо-Западный, бит 2)
    expect($router->matchEligibleProjects($supplier, '78121234567'))->toHaveCount(0);
});

it('skips project where tenant.balance_leads <= 0', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 0]); // ← пуст

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
    ]);

    $router = app(LeadRouter::class);
    expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
});

it('routes through correct FK based on platform (B2 → supplier_b2_project_id)', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => null,
        'supplier_b2_project_id' => $supplier->id, // ← через B2
        'supplier_b3_project_id' => null,
        'signal_type' => 'site',
        'is_active' => true,
    ]);

    $router = app(LeadRouter::class);
    expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
});

it('orders results by created_at ASC (deterministic, spec §6 step 4)', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);

    $projectsCreated = collect();
    for ($i = 0; $i < 3; $i++) {
        $tenant = Tenant::factory()->create(['balance_leads' => 100]);
        $projectsCreated->push(
            Project::factory()->create([
                'tenant_id' => $tenant->id,
                'supplier_b1_project_id' => $supplier->id,
                'signal_type' => 'site',
                'is_active' => true,
                'created_at' => now()->subDays(3 - $i),
            ])
        );
    }

    $router = app(LeadRouter::class);
    $matched = $router->matchEligibleProjects($supplier, '79991234567');

    expect($matched->pluck('id')->all())->toBe($projectsCreated->pluck('id')->all());
});
```

⚠️ Перед запуском убедиться, что `testing_rls_user` создан (см. Plan 1 learning #10) и в test-bootstrap либо `DatabaseTransactions` rollback'ит, либо явно `DB::table('projects')->truncate()`.

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Services/LeadRouterTest.php
```

Expected: FAIL (class not found).

- [ ] **Step 2: Implement service**

Файл `app/app/Services/LeadRouter.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Project;
use App\Models\SupplierProject;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

/**
 * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
 *
 * Алгоритм (spec §6 step 3):
 *   1. Выбираем все Project, у которых FK по platform supplier_project'а заполнен:
 *      B1 → supplier_b1_project_id, B2 → supplier_b2_project_id, B3 → supplier_b3_project_id.
 *   2. Фильтр: is_active=true.
 *   3. Фильтр workdays: (delivery_days_mask & today_bit) <> 0, today_bit = 1 << (ISO_DOW - 1).
 *   4. Фильтр delivered_today: < COALESCE(effective_daily_limit_today, daily_limit_target).
 *   5. Фильтр region: PhonePrefixService::phoneMatchesRegions().
 *   6. Фильтр balance: tenants.balance_leads > 0 (через JOIN).
 *   7. Сортировка: created_at ASC (детерминированно — старые первыми).
 *
 * RLS-quirk: запрос работает поверх N tenant'ов одновременно. Не использует
 * SET LOCAL app.current_tenant_id (ему нечего передать — sharing). Полагается на
 * dev=postgres BYPASSRLS / prod=elevated role при выполнении из job'а.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6
 */
class LeadRouter
{
    public function __construct(
        private readonly PhonePrefixService $phonePrefix,
    ) {}

    /**
     * @return Collection<int, Project>
     */
    public function matchEligibleProjects(SupplierProject $supplierProject, string $phone): Collection
    {
        $fkColumn = match ($supplierProject->platform) {
            'B1' => 'supplier_b1_project_id',
            'B2' => 'supplier_b2_project_id',
            'B3' => 'supplier_b3_project_id',
        };

        $todayBit = 1 << (Carbon::now()->isoWeekday() - 1);

        /** @var Collection<int, Project> $candidates */
        $candidates = Project::query()
            ->where($fkColumn, $supplierProject->id)
            ->where('is_active', true)
            ->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
            ->whereRaw(
                'delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)'
            )
            ->whereExists(function ($q) {
                $q->selectRaw('1')
                    ->from('tenants')
                    ->whereColumn('tenants.id', 'projects.tenant_id')
                    ->where('tenants.balance_leads', '>', 0);
            })
            ->orderBy('created_at')
            ->orderBy('id') // tiebreaker
            ->get();

        // Region-фильтр в PHP: PostgreSQL bit-арифметика на JSONB неудобна,
        // да и MVP-PhonePrefixService — статический мап без БД.
        return $candidates->filter(
            fn (Project $p): bool => $this->phonePrefix->phoneMatchesRegions(
                $phone,
                (int) $p->region_mask,
                (string) $p->region_mode,
            )
        )->values();
    }
}
```

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

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Services/LeadRouterTest.php
```

Expected: PASS (9 tests).

- [ ] **Step 4: Larastan check**

Run:

```powershell
cd app; .\vendor\bin\phpstan analyse
```

Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php
git commit -m "$(cat <<'EOF'
feat(services): LeadRouter — eligible Liderra projects matcher

Sharing-model routing (spec §6): для входящего лида возвращает Collection<Project>
учитывая platform FK + active + workdays + region (PhonePrefixService) +
delivered_today < daily_limit + tenant.balance_leads > 0. Сортировка created_at ASC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: RouteSupplierLeadJob

**Files:**

- Create: `app/app/Jobs/RouteSupplierLeadJob.php`
- Create: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`

**Контекст:** Background-job для обработки `supplier_leads`. Шаги:

1. Загружает `SupplierLead` по id.
2. Парсит `raw_payload['project']` → (platform, identifier).
3. Резолвит `SupplierProject` через `forSignal` (если не нашёлся — лид orphan, помечает error и завершается).
4. Вызывает `LeadRouter::matchEligibleProjects(supplier_project, phone)`.
5. Для каждого matched project:
   - Wraps в `DB::transaction` с `SET LOCAL app.current_tenant_id = $project->tenant_id`.
   - `Tenant::lockForUpdate()`.
   - Создаёт deal-копию (`Deal::create`) с `source_crm_id = vid`.
   - Инкрементирует `tenant.balance_leads`-- (existing pattern).
   - Инкрементирует `project.delivered_today` + `project.delivered_in_month`.
   - Создаёт `BalanceTransaction` + `ActivityLog` + (TBD Plan 4) `LeadCharge`.
   - Вызывает `NotificationService::notifyNewLead`.
6. Обновляет `SupplierLead` (`processed_at`, `deals_created_count`).
7. На failed() — записывает в `failed_webhook_jobs` (как ProcessWebhookJob).

⚠️ **Идемпотентность по vid:** UNIQUE INDEX на `supplier_leads.vid` (Task 1) гарантирует, что повторный webhook с тем же vid провалится на INSERT. Job не должен **дополнительно** проверять vid — это уже сделано на уровне controller'а (UNIQUE violation → 200 OK игнор-ответ).

⚠️ **Дубль по phone (Биз-19) внутри tenant'а:** для каждого создаваемого deal'а вызвать `DuplicateDetector::findMaster` ПОСЛЕ создания (как в ProcessWebhookJob). Если master найден ≠ self → пометить `duplicate_of_id`, **НЕ списывать** balance, **НЕ инкрементировать** счётчики, **НЕ нотифицировать**. Charge / increment / notify — только для не-дублей.

- [ ] **Step 1: Failing test**

Файл `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`:

```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\Tenant;
use Illuminate\Support\Facades\DB;

it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'vashinvestor.ru',
    ]);

    $tenants = collect();
    $projects = collect();
    for ($i = 0; $i < 3; $i++) {
        $t = Tenant::factory()->create(['balance_leads' => 100]);
        $tenants->push($t);
        $projects->push(Project::factory()->create([
            'tenant_id' => $t->id,
            'supplier_b1_project_id' => $supplier->id,
            'signal_type' => 'site',
            'signal_identifier' => 'vashinvestor.ru',
            'is_active' => true,
            'delivered_today' => 0,
            'delivered_in_month' => 0,
        ]));
    }

    $vid = 432176649;
    $lead = SupplierLead::factory()->create([
        'platform' => 'B1',
        'vid' => $vid,
        'phone' => '79991234567',
        'raw_payload' => [
            'vid' => $vid,
            'project' => 'B1_vashinvestor.ru',
            'tag' => 'tag',
            'phone' => '79991234567',
            'phones' => ['79991234567'],
            'time' => now()->getTimestamp(),
        ],
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    $lead->refresh();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(3);
    expect($lead->supplier_project_id)->toBe($supplier->id);

    foreach ($projects as $p) {
        $p->refresh();
        expect($p->delivered_today)->toBe(1);
        expect($p->delivered_in_month)->toBe(1);

        // По одной deal-копии на каждый tenant
        DB::statement("SET LOCAL app.current_tenant_id = '{$p->tenant_id}'");
        $deals = Deal::query()
            ->where('tenant_id', $p->tenant_id)
            ->where('source_crm_id', $vid)
            ->get();
        expect($deals)->toHaveCount(1);
    }
});

it('decrements balance_leads for each tenant by 1', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
    ]);

    $lead = SupplierLead::factory()->create([
        'platform' => 'B1',
        'phone' => '79991234567',
        'raw_payload' => ['vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time()],
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    expect($tenant->fresh()->balance_leads)->toBe(99);
});

it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void {
    $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'signal_type' => 'site',
        'is_active' => true,
        'delivered_today' => 0,
    ]);

    // Master deal на тот же phone в окне 24ч
    DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
    $master = Deal::create([
        'tenant_id' => $tenant->id,
        'source_crm_id' => 999,
        'project_id' => $project->id,
        'phone' => '79991234567',
        'phones' => ['79991234567'],
        'status' => 'new',
        'received_at' => now()->subHours(2),
    ]);

    $lead = SupplierLead::factory()->create([
        'platform' => 'B1',
        'vid' => 1000,
        'phone' => '79991234567',
        'raw_payload' => ['vid' => 1000, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time()],
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    expect($tenant->fresh()->balance_leads)->toBe(100); // НЕ списан (дубль)
    expect($project->fresh()->delivered_today)->toBe(0); // НЕ инкремент

    $duplicate = Deal::where('source_crm_id', 1000)->first();
    expect($duplicate->duplicate_of_id)->toBe($master->id);
});

it('records error if supplier_project cannot be resolved (B1+SMS)', function (): void {
    $lead = SupplierLead::factory()->create([
        'platform' => 'B1',
        'raw_payload' => ['vid' => 1, 'project' => 'B1_TINKOFF', 'phone' => '79991234567', 'time' => time()],
    ]);
    // B1 + parsed signal_type=sms → throws DomainException

    expect(fn () => (new RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    ))->toThrow(\DomainException::class);

    // ... but лид сохранён, ошибка пишется через failed-callback (см. Step 2)
});
```

⚠️ Третий тест (B1+SMS) сложен — `parseProjectField` должен возвращать signal_type. Реализация Step 2 ниже.

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Jobs/RouteSupplierLeadJobTest.php
```

Expected: FAIL (class not found).

- [ ] **Step 2: Implement job**

Файл `app/app/Jobs/RouteSupplierLeadJob.php`:

```php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\ActivityLog;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\FailedWebhookJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DuplicateDetector;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Routing входящего supplier_lead к eligible Лидерра-проектам (sharing-model).
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5–§6.
 *
 * Алгоритм:
 *   1. Загрузить SupplierLead по id.
 *   2. Распарсить raw_payload.project → (platform, signal_type, identifier).
 *   3. Резолв SupplierProject (resolveOrStub если не существует).
 *   4. LeadRouter::matchEligibleProjects → Collection<Project>.
 *   5. Для каждого Project — атомарно:
 *      - SET LOCAL app.current_tenant_id;
 *      - lock tenant;
 *      - создать Deal с source_crm_id=vid;
 *      - DuplicateDetector::findMaster → если найден master, помечаем duplicate_of_id (без charge/inc/notify);
 *      - иначе: balance--, delivered_today++, delivered_in_month++,
 *               BalanceTransaction, ActivityLog, NotificationService::notifyNewLead.
 *   6. Обновить SupplierLead.processed_at + deals_created_count.
 *
 * NB: Биллинг (LeadCharge с tier-snapshot) реализуется в Plan 4 — здесь только
 * existing balance_leads decrement (как в ProcessWebhookJob).
 */
class RouteSupplierLeadJob implements ShouldQueue
{
    use FoundationQueueable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 3;
    public int $backoff = 60;
    public int $timeout = 60;

    public function __construct(public int $supplierLeadId) {}

    public function handle(
        LeadRouter $router,
        SupplierProjectResolver $resolver,
        DuplicateDetector $duplicateDetector,
        NotificationService $notifier,
    ): void {
        $lead = SupplierLead::findOrFail($this->supplierLeadId);

        [$platform, $signalType, $identifier] = $this->parseProjectField($lead->raw_payload['project'] ?? '');

        $supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);

        $lead->update(['supplier_project_id' => $supplier->id]);

        $matched = $router->matchEligibleProjects($supplier, $lead->phone);

        $createdCount = 0;
        foreach ($matched as $project) {
            if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier)) {
                $createdCount++;
            }
        }

        $lead->update([
            'processed_at' => now(),
            'deals_created_count' => $createdCount,
        ]);
    }

    /**
     * Парсит payload-строку `B1_vashinvestor.ru` или `B2_TINKOFF` (SMS) или `B1_79991234567` (call).
     *
     * @return array{0: string, 1: string, 2: string}  [platform, signal_type, identifier]
     */
    private function parseProjectField(string $project): array
    {
        if (! preg_match('/^(B[123])_(.+)$/', $project, $m)) {
            throw new RuntimeException("Cannot parse project field: {$project}");
        }
        $platform = $m[1];
        $rest = $m[2];

        // Эвристика signal_type:
        //   '7' + 10 digits → call
        //   только домен → site
        //   иначе → sms (sender или sender+keyword)
        if (preg_match('/^7\d{10}$/', $rest)) {
            $signalType = 'call';
        } elseif (preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i', $rest)) {
            $signalType = 'site';
        } else {
            $signalType = 'sms';
        }

        return [$platform, $signalType, $rest];
    }

    /**
     * Создаёт deal-копию для одного Project, обрабатывает дубль.
     *
     * @return bool  true если создана не-дубль deal (charge применился);
     *               false если дубль (без charge).
     */
    private function createDealCopyForProject(
        SupplierLead $lead,
        Project $project,
        DuplicateDetector $duplicateDetector,
        NotificationService $notifier,
    ): bool {
        return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier): bool {
            DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");

            $tenant = Tenant::query()
                ->whereKey($project->tenant_id)
                ->lockForUpdate()
                ->firstOrFail();

            $receivedAt = isset($lead->raw_payload['time'])
                ? Carbon::createFromTimestamp((int) $lead->raw_payload['time'])
                : $lead->received_at;

            $deal = Deal::create([
                'tenant_id' => $tenant->id,
                'source_crm_id' => $lead->vid,
                'project_id' => $project->id,
                'phone' => $lead->phone,
                'phones' => $lead->raw_payload['phones'] ?? [$lead->phone],
                'status' => 'new',
                'received_at' => $receivedAt,
            ]);

            $master = $duplicateDetector->findMaster(
                tenantId: $tenant->id,
                phone: $lead->phone,
                now: $receivedAt,
            );

            if ($master !== null && $master->id !== $deal->id) {
                $deal->update(['duplicate_of_id' => $master->id]);
                ActivityLog::create([
                    'tenant_id' => $tenant->id,
                    'user_id' => null,
                    'deal_id' => $deal->id,
                    'event' => ActivityLog::EVENT_DEAL_CREATED,
                    'context' => [
                        'source' => 'supplier_webhook',
                        'duplicate_of' => $master->id,
                        'supplier_lead_id' => $lead->id,
                    ],
                    'created_at' => now(),
                ]);
                return false;
            }

            // Не-дубль: charge + increment + activity + notify
            $tenant->decrement('balance_leads');
            $tenant->refresh();

            $project->increment('delivered_today');
            $project->increment('delivered_in_month');

            BalanceTransaction::create([
                'tenant_id' => $tenant->id,
                'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
                'amount_leads' => -1,
                'balance_leads_after' => (int) $tenant->balance_leads,
                'related_type' => Deal::class,
                'related_id' => $deal->id,
                'created_at' => now(),
            ]);

            ActivityLog::create([
                'tenant_id' => $tenant->id,
                'user_id' => null,
                'deal_id' => $deal->id,
                'event' => ActivityLog::EVENT_DEAL_CREATED,
                'context' => [
                    'source' => 'supplier_webhook',
                    'supplier_lead_id' => $lead->id,
                ],
                'created_at' => now(),
            ]);

            $deal->setRelation('project', $project);
            $notifier->notifyNewLead($tenant, $deal);

            return true;
        });
    }

    public function failed(Throwable $e): void
    {
        DB::table('failed_webhook_jobs')->insert([
            'tenant_id' => null, // multi-tenant routing — не привязан к одному
            'webhook_log_id' => null,
            'raw_payload' => json_encode([
                'supplier_lead_id' => $this->supplierLeadId,
            ], JSON_UNESCAPED_UNICODE),
            'exception' => $e->getMessage(),
            'retry_count' => $this->tries,
            'failed_at' => now(),
        ]);

        SupplierLead::query()
            ->whereKey($this->supplierLeadId)
            ->update(['error' => $e->getMessage()]);

        Log::error('supplier_lead.routing_failed_permanently', [
            'supplier_lead_id' => $this->supplierLeadId,
            'exception' => $e->getMessage(),
        ]);
    }
}
```

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

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Jobs/RouteSupplierLeadJobTest.php
```

Expected: PASS (4 tests).

- [ ] **Step 4: Larastan check**

Run:

```powershell
cd app; .\vendor\bin\phpstan analyse
```

Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
git commit -m "$(cat <<'EOF'
feat(jobs): RouteSupplierLeadJob — sharing-model deal-copies + charge per tenant

Распределяет supplier_lead по eligible Liderra-проектам через LeadRouter.
Для каждого: транзакция с SET LOCAL app.current_tenant_id, lockForUpdate,
DuplicateDetector check, balance_leads--, delivered_today/month++,
BalanceTransaction, ActivityLog, NotificationService::notifyNewLead.

Spec §5-§6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: SupplierWebhookController

**Files:**

- Create: `app/app/Http/Controllers/Api/SupplierWebhookController.php`
- Create: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`

**Контекст:** HTTP-слой. Шаги:

1. Сравнивает `{secret}` URL-segment с `system_settings.supplier_webhook_secret` через `hash_equals`. Несовпадение → **404** (не палим существование endpoint'а, как HMAC в legacy).
2. Если `system_settings.supplier_ip_allowlist` непустой массив — проверяет `request.ip()` против него (поддержка CIDR через `IpUtils::checkIp`). Несовпадение → **404**.
3. Валидирует payload: `vid:int>0`, `project:string max:255`, `phone:string regex 7XXXXXXXXXX`, `time:int>0`. Опциональные: `tag`, `phones[]`.
4. INSERT в `supplier_leads` (raw_payload + vid + phone + platform parsed). UNIQUE на vid: при дубле возвращаем 200 OK без INSERT (idempotency).
5. Dispatch `RouteSupplierLeadJob`.
6. Возврат 202 с `supplier_lead_id`.

⚠️ Для Plan 2 secret = `system_settings.supplier_webhook_secret`. Default placeholder `__SET_ON_DEPLOY__` отвергается hash_equals (не равен валидному secret) — так что dev-тесты сами SEED'ят валидный secret.

- [ ] **Step 1: Failing test**

Файл `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;

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

it('returns 404 for invalid secret', function (): void {
    $response = $this->postJson('/api/webhook/supplier/wrong-secret', [
        'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
    ]);
    $response->assertStatus(404);
});

it('returns 404 if IP not in allowlist (when allowlist non-empty)', function (): void {
    SystemSetting::query()->where('key', 'supplier_ip_allowlist')
        ->update(['value' => '["1.2.3.4", "10.0.0.0/24"]']);

    $response = $this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8'])
        ->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
            'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
        ]);
    $response->assertStatus(404);
});

it('passes IP allowlist when IP matches CIDR', function (): void {
    SystemSetting::query()->where('key', 'supplier_ip_allowlist')
        ->update(['value' => '["10.0.0.0/24"]']);
    Bus::fake();

    $response = $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.50'])
        ->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
            'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
        ]);
    $response->assertStatus(202);
});

it('inserts supplier_lead row + dispatches RouteSupplierLeadJob', function (): void {
    Bus::fake();

    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 432176649,
        'project' => 'B1_vashinvestor.ru',
        'tag' => 'Ваш инвестор',
        'phone' => '79991234567',
        'phones' => ['79991234567'],
        'time' => 1703781939,
    ]);

    $response->assertStatus(202);
    expect(SupplierLead::where('vid', 432176649)->exists())->toBeTrue();
    Bus::assertDispatched(\App\Jobs\RouteSupplierLeadJob::class);
});

it('returns 200 OK on duplicate vid (idempotency)', function (): void {
    SupplierLead::factory()->create(['vid' => 12345]);
    Bus::fake();

    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 12345,
        'project' => 'B1_test.ru',
        'phone' => '79991234567',
        'time' => time(),
    ]);

    $response->assertStatus(200);
    expect(SupplierLead::where('vid', 12345)->count())->toBe(1);
    Bus::assertNotDispatched(\App\Jobs\RouteSupplierLeadJob::class);
});

it('rejects invalid payload (missing vid) with 422', function (): void {
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
    ]);
    $response->assertStatus(422)->assertJsonValidationErrors('vid');
});

it('rejects invalid phone format (not 7XXXXXXXXXX) with 422', function (): void {
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '89991234567', 'time' => time(),
    ]);
    $response->assertStatus(422);
});

it('rejects invalid project format (no B[123]_ prefix) with 422', function (): void {
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 1, 'project' => 'invalid_format', 'phone' => '79991234567', 'time' => time(),
    ]);
    $response->assertStatus(422);
});
```

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Http/Webhook/SupplierWebhookTest.php
```

Expected: FAIL (route not found).

- [ ] **Step 2: Implement controller**

Файл `app/app/Http/Controllers/Api/SupplierWebhookController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SystemSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\IpUtils;

/**
 * Платформенный endpoint для входящего stream'а лидов от crm.bp-gr.ru.
 *
 * URL: POST /api/webhook/supplier/{secret}
 *
 * Защита (defense-in-depth, spec §5.1):
 *   1. {secret} в URL — platform-wide токен (≥32 chars), хранится в
 *      system_settings.supplier_webhook_secret. hash_equals для сравнения.
 *   2. IP allowlist (system_settings.supplier_ip_allowlist) — JSON array IP/CIDR.
 *      Пустой массив = пропускать всех (DEV mode); на prod заполнить.
 *
 * Несовпадение secret OR IP → 404 (не палим существование endpoint'а).
 *
 * Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
 * 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
 *
 * Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
 * на WebhookReceiveController — не пересекается.
 */
class SupplierWebhookController extends Controller
{
    public function receive(Request $request, string $secret): JsonResponse
    {
        if (! $this->verifySecret($secret)) {
            return response()->json(['message' => 'Not found.'], 404);
        }

        if (! $this->verifyIpAllowlist($request->ip())) {
            return response()->json(['message' => 'Not found.'], 404);
        }

        $validated = $request->validate([
            'vid' => 'required|integer|min:1',
            'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
            'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
            'time' => 'required|integer|min:1',
            'tag' => 'nullable|string|max:255',
            'phones' => 'nullable|array',
            'phones.*' => 'string|regex:/^7\d{10}$/',
        ]);

        // Idempotency: vid UNIQUE.
        $existing = SupplierLead::query()->where('vid', $validated['vid'])->first();
        if ($existing !== null) {
            return response()->json([
                'status' => 'already_processed',
                'supplier_lead_id' => $existing->id,
            ], 200);
        }

        $platform = $this->parsePlatform($validated['project']);

        $lead = SupplierLead::create([
            'platform' => $platform,
            'raw_payload' => $validated,
            'vid' => $validated['vid'],
            'phone' => $validated['phone'],
            'received_at' => now(),
            'source' => 'webhook',
        ]);

        RouteSupplierLeadJob::dispatch($lead->id);

        return response()->json([
            'status' => 'accepted',
            'supplier_lead_id' => $lead->id,
        ], 202);
    }

    private function verifySecret(string $providedSecret): bool
    {
        $row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
        if ($row === null) {
            return false;
        }
        $expected = (string) $row->value;
        // Placeholder __SET_ON_DEPLOY__ всегда отвергаем (не валидный secret).
        if ($expected === '__SET_ON_DEPLOY__' || strlen($expected) < 32) {
            return false;
        }

        return hash_equals($expected, $providedSecret);
    }

    private function verifyIpAllowlist(string $clientIp): bool
    {
        $row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
        if ($row === null) {
            return true; // нет настройки = пропускаем (dev)
        }
        $list = json_decode((string) $row->value, true) ?: [];
        if ($list === []) {
            return true; // пустой массив = пропускаем (dev)
        }

        return IpUtils::checkIp($clientIp, $list);
    }

    private function parsePlatform(string $project): string
    {
        preg_match('/^(B[123])_/', $project, $m);

        return $m[1] ?? 'B1';
    }
}
```

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

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Http/Webhook/SupplierWebhookTest.php
```

Expected: после Task 7 (route) — PASS (8 tests). На этом этапе — все 404 (no route), кроме того теста, что регистрировал маршрут. Возможен скип до Task 7 — но запустить можно.

- [ ] **Step 4: Larastan**

Run:

```powershell
cd app; .\vendor\bin\phpstan analyse
```

Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
git commit -m "$(cat <<'EOF'
feat(http): SupplierWebhookController — platform-wide /api/webhook/supplier/{secret}

Defense-in-depth: secret (≥32 chars system_setting) + IP allowlist (CIDR).
Несовпадение → 404. UNIQUE vid → 200 OK на дубль (idempotency).

Spec §5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Register route + run all webhook tests

**Files:**

- Modify: `app/routes/web.php` (line ~127)

- [ ] **Step 1: Edit `app/routes/web.php`**

После строки `Route::post('/api/webhook/{token}', ...)` (legacy line ~126), **до** SPA-routes (line 137), добавить:

```php
// Supplier-integration webhook (Plan 2/5, spec §5.1).
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
// (system_settings.supplier_ip_allowlist). Не пересекается с legacy /api/webhook/{token}.
Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive')
    ->where('secret', '[A-Za-z0-9_\-]+');
```

- [ ] **Step 2: Run webhook tests**

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Http/Webhook/
```

Expected: PASS (8 tests из SupplierWebhookTest). Если есть legacy webhook tests в той же директории — их тоже PASS (regression check).

- [ ] **Step 3: Run full Pest**

Run:

```powershell
cd app; .\vendor\bin\pest
```

Expected: ≥510/508 (Plan 1 baseline 500/498 + новые ~10–14 тестов из Plan 2 Tasks 1–6).

- [ ] **Step 4: Commit**

```bash
git add app/routes/web.php
git commit -m "$(cat <<'EOF'
feat(routes): register POST /api/webhook/supplier/{secret}

Spec §5.1 supplier-webhook endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: End-to-end integration test

**Files:**

- Create: `app/tests/Feature/Integration/SupplierLeadFlowTest.php`

**Контекст:** Полный сценарий «webhook → routing → N deals» в одном тесте без `Bus::fake()`. Проверяет, что HTTP+job совместно дают ожидаемый эффект на БД.

- [ ] **Step 1: Write integration test**

Файл `app/tests/Feature/Integration/SupplierLeadFlowTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;

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

it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function (): void {
    // Setup: 1 supplier_project на сайт vashinvestor.ru / B1
    $supplier = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'vashinvestor.ru',
    ]);

    // 3 активных tenant'а с проектом, привязанным к этому supplier_project
    $tenants = collect();
    $projects = collect();
    for ($i = 0; $i < 3; $i++) {
        $t = Tenant::factory()->create(['balance_leads' => 100]);
        $tenants->push($t);
        $projects->push(Project::factory()->create([
            'tenant_id' => $t->id,
            'supplier_b1_project_id' => $supplier->id,
            'signal_type' => 'site',
            'signal_identifier' => 'vashinvestor.ru',
            'is_active' => true,
            'delivered_today' => 0,
            'delivered_in_month' => 0,
            'delivery_days_mask' => 127,
            'region_mask' => 255,
            'region_mode' => 'include',
            'daily_limit_target' => 10,
            'effective_daily_limit_today' => null,
        ]));
    }

    // 4-й tenant с paused проектом — НЕ должен получить лид
    $pausedTenant = Tenant::factory()->create(['balance_leads' => 100]);
    Project::factory()->create([
        'tenant_id' => $pausedTenant->id,
        'supplier_b1_project_id' => $supplier->id,
        'is_active' => false, // ← paused
    ]);

    // Act: webhook
    $vid = 432176649;
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => $vid,
        'project' => 'B1_vashinvestor.ru',
        'tag' => 'Ваш инвестор',
        'phone' => '79991234567',
        'phones' => ['79991234567'],
        'time' => 1703781939,
    ]);

    $response->assertStatus(202);

    // Assert: 3 deals created (по одной на активный tenant), paused — 0
    foreach ($projects as $i => $project) {
        $tenant = $tenants[$i];
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");

        $deals = Deal::query()->where('source_crm_id', $vid)->get();
        expect($deals)->toHaveCount(1, "tenant {$tenant->id} expected 1 deal copy");

        $deal = $deals->first();
        expect($deal->phone)->toBe('79991234567');
        expect($deal->project_id)->toBe($project->id);
        expect($deal->duplicate_of_id)->toBeNull(); // не дубль (нет master)

        expect($tenant->fresh()->balance_leads)->toBe(99); // 100-1
        expect($project->fresh()->delivered_today)->toBe(1);
        expect($project->fresh()->delivered_in_month)->toBe(1);
    }

    // Paused tenant — 0 deals
    DB::statement("SET LOCAL app.current_tenant_id = '{$pausedTenant->id}'");
    expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
});

it('end-to-end: lead orphan (no supplier_project / no tenants) — 0 deals, lead stored', function (): void {
    $vid = 999_888_777;

    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => $vid,
        'project' => 'B1_orphan.ru',
        'phone' => '79991234567',
        'time' => time(),
    ]);

    $response->assertStatus(202);

    $lead = \App\Models\SupplierLead::where('vid', $vid)->firstOrFail();
    expect($lead->processed_at)->not->toBeNull();
    expect($lead->deals_created_count)->toBe(0);
    expect($lead->supplier_project_id)->not->toBeNull(); // resolveOrStub создаёт stub
});
```

- [ ] **Step 2: Run E2E test**

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Integration/SupplierLeadFlowTest.php
```

Expected: PASS (2 tests). NB: тесты гоняют job синхронно через Laravel sync queue в test-env (default).

- [ ] **Step 3: Commit**

```bash
git add app/tests/Feature/Integration/SupplierLeadFlowTest.php
git commit -m "$(cat <<'EOF'
test(integration): supplier webhook → N deals end-to-end (sharing-model)

Полный сценарий: 1 webhook на B1_vashinvestor.ru → 3 deal-копии у 3 tenant'ов
+ счётчики + balance. Paused tenant пропущен. Orphan supplier_project — stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: ResetDeliveredTodayCommand + cron schedule

**Files:**

- Create: `app/app/Console/Commands/ResetDeliveredTodayCommand.php`
- Create: `app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php`
- Modify: `app/bootstrap/app.php` OR `app/routes/console.php` (зависит от существующего convention)

**Контекст:** Spec §6.1 — cron в 00:00 МСК сбрасывает `delivered_today=0`. Простой UPDATE.

- [ ] **Step 1: Failing test**

Файл `app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php`:

```php
<?php

declare(strict_types=1);

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

it('resets delivered_today to 0 across all tenants', function (): void {
    DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");

    $t1 = Tenant::factory()->create();
    $t2 = Tenant::factory()->create();

    Project::factory()->create(['tenant_id' => $t1->id, 'delivered_today' => 5]);
    Project::factory()->create(['tenant_id' => $t1->id, 'delivered_today' => 3]);
    Project::factory()->create(['tenant_id' => $t2->id, 'delivered_today' => 10]);

    $this->artisan('projects:reset-delivered-today')->assertSuccessful();

    $allZero = DB::selectOne('SELECT COUNT(*) FILTER (WHERE delivered_today = 0) AS c, COUNT(*) AS total FROM projects');
    expect($allZero->c)->toBe($allZero->total);
});

it('does not touch delivered_in_month', function (): void {
    DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
    $t = Tenant::factory()->create();
    Project::factory()->create([
        'tenant_id' => $t->id,
        'delivered_today' => 5,
        'delivered_in_month' => 50,
    ]);

    $this->artisan('projects:reset-delivered-today')->assertSuccessful();

    $row = DB::selectOne('SELECT delivered_today, delivered_in_month FROM projects LIMIT 1');
    expect($row->delivered_today)->toBe(0);
    expect($row->delivered_in_month)->toBe(50);
});
```

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Console/ResetDeliveredTodayCommandTest.php
```

Expected: FAIL (command not found).

- [ ] **Step 2: Implement command**

Файл `app/app/Console/Commands/ResetDeliveredTodayCommand.php`:

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

/**
 * Сброс projects.delivered_today в 0 для всех tenant'ов.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1.
 * Расписание: каждый день в 00:00 МСК (timezone Europe/Moscow).
 *
 * NB: tenant-scoped запрос без RLS работает на elevated роли (postgres dev,
 * service-account на prod) — UPDATE проходит на все tenant'ы одновременно.
 */
class ResetDeliveredTodayCommand extends Command
{
    protected $signature = 'projects:reset-delivered-today';

    protected $description = 'Сброс projects.delivered_today=0 (00:00 МСК cron, spec §6.1)';

    public function handle(): int
    {
        $affected = DB::update('UPDATE projects SET delivered_today = 0 WHERE delivered_today <> 0');

        $this->info("Reset delivered_today on {$affected} project(s).");

        return self::SUCCESS;
    }
}
```

- [ ] **Step 3: Register schedule**

⚠️ **Сначала проверить convention.** Laravel 11+ default — schedule в `bootstrap/app.php` через `->withSchedule(function (Schedule $schedule) { ... })`. Если в `app/bootstrap/app.php` уже есть `withSchedule` — добавить туда. Иначе — в `app/routes/console.php` через facade `Schedule::command(...)`.

Run:

```powershell
cd app; grep -n "withSchedule\|Schedule::" bootstrap/app.php routes/console.php
```

Если в `bootstrap/app.php` — паттерн:

```php
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule): void {
    $schedule->command('projects:reset-delivered-today')
        ->dailyAt('00:00')
        ->timezone('Europe/Moscow')
        ->withoutOverlapping();
})
```

Если в `routes/console.php` — паттерн:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('projects:reset-delivered-today')
    ->dailyAt('00:00')
    ->timezone('Europe/Moscow')
    ->withoutOverlapping();
```

- [ ] **Step 4: Verify command shows in schedule:list**

Run:

```powershell
cd app; .\artisan schedule:list
```

Expected: видим `projects:reset-delivered-today` в расписании daily 00:00 (Europe/Moscow).

- [ ] **Step 5: Run tests**

Run:

```powershell
cd app; .\vendor\bin\pest tests/Feature/Console/ResetDeliveredTodayCommandTest.php
```

Expected: PASS (2 tests).

- [ ] **Step 6: Commit**

```bash
git add app/app/Console/Commands/ResetDeliveredTodayCommand.php app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php app/bootstrap/app.php app/routes/console.php
git commit -m "$(cat <<'EOF'
feat(commands): projects:reset-delivered-today (00:00 МСК cron)

Spec §6.1: ежедневный сброс projects.delivered_today=0.
delivered_in_month НЕ трогаем (это месячный счётчик, Plan 4 cron).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: Comprehensive Verification Gate (CV)

**Files:** none (verification commands).

Запустить все проверки последовательно. Все должны быть green.

- [ ] **CV.1: pgFormatter — schema.sql**

Run:

```powershell
cd "c:\моя\проекты\портал crm\Документация"; npm run format:sql:check
```

Expected: 0 diff.

- [ ] **CV.2: squawk — schema.sql**

Run:

```powershell
cd "c:\моя\проекты\портал crm\Документация"; .\bin\squawk.exe db/schema.sql
```

Expected: 0 issues.

- [ ] **CV.3: cspell + markdownlint — все .md изменения**

Run:

```powershell
cd "c:\моя\проекты\портал crm\Документация"; npm run lint:md; npm run spell
```

Expected: 0 issues. Если cspell ловит technical terms — добавить в `app/cspell-words.txt` (project convention).

- [ ] **CV.4: lychee — links**

Run:

```powershell
cd "c:\моя\проекты\портал crm\Документация"; npm run links
```

Expected: 0 broken links.

- [ ] **CV.5: Larastan — PHP static analysis**

Run:

```powershell
cd app; .\vendor\bin\phpstan analyse
```

Expected: 0 errors (Plan 1 baseline сохранён).

- [ ] **CV.6: Pint — PHP formatting**

Run:

```powershell
cd app; .\vendor\bin\pint --test
```

Expected: 0 changes.

- [ ] **CV.7: Pest full suite**

Run:

```powershell
cd app; .\vendor\bin\pest --parallel=4
```

Expected: 510+ passed (Plan 1 baseline 500/498 + Plan 2 ~14 новых: 5 schema + 4 SupplierLead + 9 PhonePrefix + 9 LeadRouter + 4 RouteJob + 8 controller + 2 E2E + 2 reset cmd ≈ 43 новых, → ~543/541).

⚠️ Если падают transient connection failures (Plan 1 learning #16) на параллели — retry; не блокер.

- [ ] **CV.8: migrate:fresh — testing DB**

Run:

```powershell
cd app
$env:DB_DATABASE = 'liderra_testing'
.\artisan migrate:fresh --env=testing
```

Expected: успешно (0 ошибок). Schema v8.18 разворачивается за <2 сек.

- [ ] **CV.9: ide-helper:models — re-generate**

Run:

```powershell
cd app; .\artisan ide-helper:models -W -M -N
```

Expected: `_ide_helper_models.php` обновлён, все модели включая `SupplierLead`.

- [ ] **CV.10: gitleaks — secrets scan**

Run:

```powershell
cd "c:\моя\проекты\портал crm\Документация"; .\bin\gitleaks.exe detect --no-git
```

Expected: 0 leaks. (Тестовый secret `test-secret-32chars-aaaaaaaaaaaaaa` — допустим в test-files; если gitleaks ругается — добавить в allowlist.)

- [ ] **CV.11: Code-reviewer subagent (parallel)**

Запустить через `Agent` tool с двумя subagent'ами:

```
Subagent 1 (general-purpose):
  Audit Plan 2 implementation (commits since 001d781) for:
  - Logic errors / off-by-one / typos in DDL и PHP
  - Race conditions в RouteSupplierLeadJob (lockForUpdate sufficient?)
  - RLS bypass concerns (LeadRouter без SET LOCAL — корректно для sharing?)
  - SQL injection / XSS surfaces в SupplierWebhookController
  - Idempotency edge cases (UNIQUE vid, retry behavior)
  Report: BLOCKER/WARN/NIT (как Plan 1).

Subagent 2 (Explore):
  Cross-check spec §5–§6 vs implementation. Перечислить конкретные строки spec'а
  и какой файл/функция их реализует. Найти gaps.
```

Expected: либо clean, либо findings → закрыть как Plan 1 closure (BLOCKER → fix перед merge, WARN → judge case-by-case, NIT → defer to Plan 3 NOTES).

- [ ] **CV.12: route:list verification**

Run:

```powershell
cd app; .\artisan route:list --path=webhook
```

Expected: 2 webhook routes:

- POST `/api/webhook/{token}` (legacy)
- POST `/api/webhook/supplier/{secret}` (new)

- [ ] **CV.13: schedule:list verification**

Run:

```powershell
cd app; .\artisan schedule:list
```

Expected: видим `projects:reset-delivered-today` daily 00:00 Europe/Moscow.

- [ ] **CV.14: dependency cycle check**

Run:

```powershell
cd app; .\vendor\bin\phpstan analyse --error-format=table | Select-String -Pattern "circular"
```

Expected: 0 циклов.

---

## Task 11: Update memory + final commit + push

- [ ] **Step 1: Update `memory/project_supplier_integration.md`**

Изменить статус Plan 2: `pending` → `✅ DONE`. Добавить раздел «Plan 2 итоги» с метриками (commits, tests added, schema bump). Если по результатам code-review subagent'а в CV.11 нашлись WARN/NIT для Plan 3 NOTES — записать в `## Что в плане 3 ... использовать` секцию.

- [ ] **Step 2: Update `memory/project_state.md`**

Schema v8.17 → **v8.18**. Pest count → **543/541**. HEAD origin/main → новый SHA после FF-merge. Plan 2 fully merged.

- [ ] **Step 3: Run lefthook pre-commit + pre-push (через actual commit)**

```bash
git add memory/*.md
git commit -m "$(cat <<'EOF'
chore(memory): close Plan 2/5 (Webhook + Sharing Routing)

Schema v8.18, Pest 543/541, +43 новых теста, 11 commits.
HEAD origin/main = <SHA>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 4: Push to remote**

⚠️ Если работа велась в feature-branch — FF-merge в main как Plan 1. Если в main — push сразу.

```bash
git push origin main
```

Expected: pre-push lefthook (gitleaks-full-history + lychee) PASS, push successful.

- [ ] **Step 5: Verify GitHub state**

Run:

```bash
git ls-remote origin HEAD
```

Expected: SHA совпадает с локальным `git rev-parse HEAD`.

---

## Self-review checklist

Spec coverage (галки только если соответствующая Task реализует):

- [x] §5.1 webhook receive (IP allowlist + secret + payload parse) — Task 6
- [x] §5.1 INSERT supplier_leads + RouteLead dispatch — Task 1, 5, 6
- [x] §5.1 secret защита (≥32 chars + hash_equals) — Task 6
- [x] §5.1 IP allowlist (CIDR через IpUtils) — Task 6
- [x] §5.1 невалид → 404 (не палим endpoint) — Task 6
- [x] §5.2 CSV reconciliation — **DEFERRED** в Plan 4 (нужны Playwright/HTTP-клиент infrastructure из Plan 3)
- [x] §6 step 1 SupplierProject lookup — Task 5 (resolveOrStub)
- [x] §6 step 2 регион определяем по DEF-кодам — Task 3 (PhonePrefixService MVP)
- [x] §6 step 3 фильтры (active/workdays/quota/region) — Task 4 (LeadRouter)
- [x] §6 step 4 сортировка created_at ASC — Task 4
- [x] §6 step 5 deal copy + counter inc + balance dec + notify — Task 5
- [x] §6 step 6 sharing N deals — Task 5
- [x] §6 step 7 пропускаем превышенных — Task 4
- [x] §6.1 cron 00:00 reset delivered_today — Task 9
- [ ] §6 step 5 «Списываем по текущей ступени pricing_tiers» — **DEFERRED в Plan 4** (Plan 2 использует existing balance_leads decrement)
- [x] Биз-19 дедуп по phone (24ч окно) — Task 5

Placeholder scan: TODO/TBD/«implement later» — нет (только в spec §10 Open Questions, исторические).

Type consistency: `SupplierLead`, `SupplierProject::scopeForSignal`, `LeadRouter::matchEligibleProjects`, `RouteSupplierLeadJob::handle(LeadRouter, ...)` — все типы согласованы.

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-10-supplier-webhook-routing-plan.md`.

**Two execution options:**

1. **Subagent-Driven (recommended)** — fresh subagent per task + 2-stage review между.
2. **Inline Execution** — гонит таски в текущей сессии через `superpowers:executing-plans`.

**Which approach?**
