# Резервный CSV-канал импорта лидов (Путь 2) — план реализации

> **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:** Резервный CSV-канал, который при обрыве webhook подбирает пропущенные лиды из отчёта поставщика «Запрос номеров» и сигнализирует о падении основного канала.

**Architecture:** Каждые 30 минут (Laravel scheduler) `CsvReconcileJob` через `SupplierPortalClient` заказывает у поставщика отчёт «Запрос номеров» (async: заказать → дождаться → скачать), `SupplierCsvParser` парсит CSV `Name;Tag;Phone`, job убирает дубли по ключу `(phone, project)`, недостающие лиды создаёт в `supplier_leads` (`vid=NULL`) и запускает `RouteSupplierLeadJob`. drift > 5% → алерт. Админ-страница показывает здоровье канала и кнопку ручной сверки.

**Tech Stack:** Laravel 13, PHP 8.3, PostgreSQL 16, Redis (Cache::lock), Pest 4, Vue 3 + Vuetify 3, Vitest.

**Spec:** [docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md](../specs/2026-05-18-supplier-csv-reconcile-channel-design.md)

---

## Карта файлов

| Файл | Ответственность | Задача |
|---|---|---|
| `app/database/migrations/2026_05_18_140000_supplier_leads_vid_nullable.php` | Миграция `vid → nullable` | T1 |
| `db/schema.sql` | SoT-схема — `vid` без `NOT NULL` | T1 |
| `db/CHANGELOG_schema.md` | Запись об изменении схемы | T1 |
| `app/app/Services/Supplier/SupplierCsvParser.php` | Парсинг CSV `Name;Tag;Phone` (rework) | T2 |
| `app/app/Services/Supplier/SupplierPortalClient.php` | +3 метода async-флоу отчёта; −`downloadLeadsCsv` | T3 |
| `app/app/Jobs/Supplier/CsvReconcileJob.php` | Сверка: дедуп `(phone,project)`, recovery (rework) | T4 |
| `app/routes/console.php` | Scheduler `everyThirtyMinutes` вместо `hourly` | T5 |
| `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` | API здоровья канала + ручной запуск | T6 |
| `app/routes/web.php` | Роуты `/api/admin/supplier-integration*` + SPA-роут | T6, T7 |
| `app/resources/js/views/admin/AdminSupplierIntegrationView.vue` | Экран «Интеграция с поставщиком» | T7 |
| `app/resources/js/router/index.ts` | Роут `/admin/supplier-integration` | T7 |
| `app/resources/js/components/AppSidebar.vue` | Пункт меню | T7 |

Тесты: `tests/Feature/Supplier/SupplierLeadsVidNullableTest.php` (T1), `tests/Feature/Supplier/SupplierCsvParserTest.php` (T2), `tests/Feature/Supplier/SupplierPortalClientReportTest.php` (T3), `tests/Feature/Supplier/CsvReconcileJobTest.php` (rework, T4), `tests/Feature/Admin/AdminSupplierIntegrationTest.php` (T6), `tests/Frontend/AdminSupplierIntegrationView.spec.ts` (T7).

---

## Task 1: Миграция `supplier_leads.vid` → nullable

CSV «Запрос номеров» не содержит `vid`. CSV-recovered лиды получают `vid=NULL`. UNIQUE-индекс `idx_supplier_leads_vid_unique` остаётся — в PostgreSQL `NULL ≠ NULL`, множественные NULL не конфликтуют.

**Files:**

- Create: `app/database/migrations/2026_05_18_140000_supplier_leads_vid_nullable.php`
- Modify: `db/schema.sql` (строка определения `vid` в `CREATE TABLE supplier_leads`)
- Modify: `db/CHANGELOG_schema.md`
- Test: `app/tests/Feature/Supplier/SupplierLeadsVidNullableTest.php`

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

Создать `app/tests/Feature/Supplier/SupplierLeadsVidNullableTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\SupplierLead;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;

uses(DatabaseTransactions::class, SharesSupplierPdo::class);

it('allows supplier_leads with vid=NULL (CSV-recovered leads)', function (): void {
    $lead = SupplierLead::create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'phone' => '79991234567',
        'vid' => null,
        'raw_payload' => ['project' => 'B1_a.com', 'tag' => 't', 'phone' => '79991234567'],
        'received_at' => now(),
        'source' => 'csv_recovery',
        'recovered_from_csv_at' => now(),
    ]);

    expect($lead->id)->toBeGreaterThan(0);
    expect($lead->fresh()->vid)->toBeNull();
});

it('allows multiple supplier_leads with vid=NULL under the UNIQUE index', function (): void {
    foreach (['79990000001', '79990000002', '79990000003'] as $phone) {
        SupplierLead::create([
            'supplier_project_id' => null,
            'platform' => 'B1',
            'phone' => $phone,
            'vid' => null,
            'raw_payload' => ['project' => 'B1_a.com', 'phone' => $phone],
            'received_at' => now(),
            'source' => 'csv_recovery',
            'recovered_from_csv_at' => now(),
        ]);
    }

    expect(SupplierLead::whereNull('vid')->count())->toBeGreaterThanOrEqual(3);
});

it('still accepts a real numeric vid for webhook leads', function (): void {
    $lead = SupplierLead::create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'phone' => '79991234567',
        'vid' => 432176649,
        'raw_payload' => ['vid' => 432176649, 'project' => 'B1_a.com'],
        'received_at' => now(),
        'source' => 'webhook',
    ]);

    expect($lead->fresh()->vid)->toBe(432176649);
});
```

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

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/SupplierLeadsVidNullableTest.php`
Expected: FAIL — первый тест падает с `SQLSTATE[23502] ... null value in column "vid" violates not-null constraint`.

- [ ] **Step 3: Создать миграцию**

Создать `app/database/migrations/2026_05_18_140000_supplier_leads_vid_nullable.php`:

```php
<?php

declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        // Guard: после migrate:fresh schema.sql загружается первой (load_initial_schema).
        // Если schema.sql уже отдаёт vid как nullable — миграция no-op (idempotent).
        $isNullable = DB::selectOne(
            "SELECT is_nullable FROM information_schema.columns
             WHERE table_name = 'supplier_leads' AND column_name = 'vid'"
        );
        if ($isNullable !== null && $isNullable->is_nullable === 'YES') {
            return;
        }

        DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL');
    }

    public function down(): void
    {
        // Внимание: down() не симметричен после migrate:fresh со свежей schema.sql.
        // Не использовать как откат schema-bump — нужна отдельная schema-правка.
        DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid SET NOT NULL');
    }
};
```

- [ ] **Step 4: Поправить `db/schema.sql`**

Найти строку определения `vid` в `CREATE TABLE ... supplier_leads`:

Run: `grep -n "vid" db/schema.sql`

В блоке `CREATE TABLE` для `supplier_leads` строка вида `vid bigint NOT NULL,` (или `vid bigint NOT NULL`) — убрать `NOT NULL`, оставив `vid bigint,`. Комментарий рядом (если есть) — дополнить: `-- nullable: NULL у CSV-recovered лидов (Путь 2)`.

Обновить версию схемы в шапке `db/schema.sql` (строка `-- Schema vX.YY` / заголовок) — инкремент minor-версии (прочитать текущую в `db/schema.sql` строки 1–10).

- [ ] **Step 5: Запись в `db/CHANGELOG_schema.md`**

Добавить запись сверху (после заголовка), формат — как у предыдущих записей файла:

```markdown
## vX.YY — 2026-05-18 — supplier_leads.vid → nullable

`ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL`. Резервный CSV-канал
(Путь 2): отчёт поставщика «Запрос номеров» не содержит vid → CSV-recovered лиды
имеют vid=NULL. UNIQUE-индекс idx_supplier_leads_vid_unique сохранён (в PostgreSQL
NULL ≠ NULL — множественные NULL не конфликтуют). Миграция:
2026_05_18_140000_supplier_leads_vid_nullable.php. RLS не затронут.
```

(`vX.YY` — та же версия, что выставлена в `db/schema.sql` на шаге 4.)

- [ ] **Step 6: Перезалить схему и запустить тест**

Run: `cd app && C:/tools/php83/php.exe artisan migrate:fresh 2>&1 | tail -5 && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/SupplierLeadsVidNullableTest.php`
Expected: PASS — 3 теста зелёные.

- [ ] **Step 7: Smoke + регрессия supplier-тестов**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Http/Webhook/SupplierWebhookTest.php`
Expected: PASS — webhook-путь с реальным vid не сломан. (Файл `CsvReconcileJobTest.php` на этом этапе ещё старый — если он падает из-за миграции, это ожидаемо; он будет переписан в T4. Зафиксировать его падения отдельно, не как регрессию T1.)

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

```bash
git add app/database/migrations/2026_05_18_140000_supplier_leads_vid_nullable.php db/schema.sql db/CHANGELOG_schema.md app/tests/Feature/Supplier/SupplierLeadsVidNullableTest.php
git commit -m "feat(supplier): supplier_leads.vid → nullable для CSV-recovered лидов"
```

---

## Task 2: `SupplierCsvParser` — переписать под CSV `Name;Tag;Phone`

Текущий парсер ждёт 6 колонок `vid;project;tag;phone;phones;time`. Реальный отчёт «Запрос номеров» — 3 колонки `Name;Tag;Phone`, без vid и времени.

**Files:**

- Modify: `app/app/Services/Supplier/SupplierCsvParser.php` (переписать полностью)
- Test: `app/tests/Feature/Supplier/SupplierCsvParserTest.php` (создать)

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

Создать `app/tests/Feature/Supplier/SupplierCsvParserTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierCsvParser;

function rowsOf(iterable $gen): array
{
    $out = [];
    foreach ($gen as $row) {
        $out[] = $row;
    }

    return $out;
}

it('parses 3-column Name;Tag;Phone CSV', function (): void {
    $csv = "Name;Tag;Phone\nB1_a.com;tagA;79991234567\nB2_79990001122;tagB;79993334455\n";
    $rows = rowsOf((new SupplierCsvParser)->parse($csv));

    expect($rows)->toHaveCount(2);
    expect($rows[0])->toBe(['project' => 'B1_a.com', 'tag' => 'tagA', 'phone' => '79991234567']);
    expect($rows[1])->toBe(['project' => 'B2_79990001122', 'tag' => 'tagB', 'phone' => '79993334455']);
});

it('strips UTF-8 BOM and normalizes CRLF', function (): void {
    $csv = "\xEF\xBB\xBFName;Tag;Phone\r\nB1_a.com;t;79991234567\r\n";
    $rows = rowsOf((new SupplierCsvParser)->parse($csv));

    expect($rows)->toHaveCount(1);
    expect($rows[0]['project'])->toBe('B1_a.com');
});

it('skips malformed rows with fewer than 3 columns', function (): void {
    $csv = "Name;Tag;Phone\nB1_a.com;t;79991234567\nbroken;row\nB2_b.com;t2;79990000000\n";
    $rows = rowsOf((new SupplierCsvParser)->parse($csv));

    expect($rows)->toHaveCount(2);
    expect($rows[1]['project'])->toBe('B2_b.com');
});

it('returns nothing for empty CSV', function (): void {
    expect(rowsOf((new SupplierCsvParser)->parse('')))->toBe([]);
});

it('returns nothing for header-only CSV', function (): void {
    expect(rowsOf((new SupplierCsvParser)->parse("Name;Tag;Phone\n")))->toBe([]);
});
```

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

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/SupplierCsvParserTest.php`
Expected: FAIL — текущий парсер возвращает ключи `vid/project/phone/time` и ждёт 6 колонок; первый тест падает на сравнении массива.

- [ ] **Step 3: Переписать `SupplierCsvParser`**

Заменить содержимое `app/app/Services/Supplier/SupplierCsvParser.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use Illuminate\Support\Facades\Log;

/**
 * Streaming-парсер CSV-отчёта «Запрос номеров» supplier-портала crm.bp-gr.ru.
 *
 * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.1
 * Столбцы: Name;Tag;Phone — 3 колонки. vid и время в этом отчёте отсутствуют.
 *
 * Возвращает Generator. BOM + CRLF поддерживаются. Malformed rows skip + log.
 */
final class SupplierCsvParser
{
    private const EXPECTED_COLUMNS = 3;

    /**
     * @return iterable<int, array{project: string, tag: string, phone: string}>
     */
    public function parse(string $rawCsv): iterable
    {
        if ($rawCsv === '') {
            return;
        }

        // Убираем UTF-8 BOM (EF BB BF)
        if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) {
            $rawCsv = substr($rawCsv, 3);
        }

        // Нормализуем CRLF → LF
        $rawCsv = str_replace("\r\n", "\n", $rawCsv);

        $lines = explode("\n", $rawCsv);
        $headerSkipped = false;
        $lineNo = 0;

        foreach ($lines as $line) {
            $lineNo++;
            if ($line === '') {
                continue;
            }
            if (! $headerSkipped) {
                $headerSkipped = true;

                continue;
            }

            $cols = str_getcsv($line, separator: ';');
            if (count($cols) < self::EXPECTED_COLUMNS) {
                Log::warning('supplier_csv_parser.malformed_row', [
                    'line_no' => $lineNo,
                    'columns_found' => count($cols),
                    'expected' => self::EXPECTED_COLUMNS,
                    'sample' => substr($line, 0, 100),
                ]);

                continue;
            }

            yield [
                'project' => (string) $cols[0],
                'tag' => (string) $cols[1],
                'phone' => (string) $cols[2],
            ];
        }
    }
}
```

- [ ] **Step 4: Запустить тест — убедиться, что проходит**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/SupplierCsvParserTest.php`
Expected: PASS — 5 тестов зелёные.

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

```bash
git add app/app/Services/Supplier/SupplierCsvParser.php app/tests/Feature/Supplier/SupplierCsvParserTest.php
git commit -m "feat(supplier): SupplierCsvParser под отчёт «Запрос номеров» (Name;Tag;Phone)"
```

---

## Task 3: `SupplierPortalClient` — async-флоу заказа отчёта

Добавить заказ отчёта «Запрос номеров» (async: заказать → дождаться «Обработан» → скачать). Удалить устаревший синхронный `downloadLeadsCsv`.

> **Discovery-шаг обязателен (Step 1):** точные имена endpoint'ов, параметры заказа отчёта и значения статуса — placeholder в spec §4.3. Их нужно установить на реальном портале до написания кода. Код в Step 3 — на основе разведки 18.05 (`/admin/report/index`, `/admin/report/getfile?id=N`); скорректировать по факту.

**Files:**

- Modify: `app/app/Services/Supplier/SupplierPortalClient.php`
- Test: `app/tests/Feature/Supplier/SupplierPortalClientReportTest.php` (создать)

- [ ] **Step 1: Discovery — реальные endpoint'ы отчёта**

Залогиниться на портал поставщика (`app/playwright/refresh-session.js` обновляет сессию), вручную в браузере заказать отчёт «Запрос номеров»: зафиксировать (а) URL+метод+параметры запроса генерации, (б) как узнаётся `report_id`, (в) URL+способ опроса статуса и текст статуса «Обработан», (г) URL скачивания файла. Записать факты в комментарий к этой задаче. Если они расходятся с предположениями Step 3 — скорректировать код Step 3 перед написанием тестов.

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

Создать `app/tests/Feature/Supplier/SupplierPortalClientReportTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function (): void {
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
    Cache::store('redis')->put('supplier:session', ['phpsessid' => 'test', 'csrf' => 'test'], now()->addHour());
});

it('requestNumbersReport posts a generation request and returns report id', function (): void {
    Http::fake([
        'crm.bp-gr.ru/admin/report/generate*' => Http::response(['id' => 508346], 200),
    ]);

    $client = app(SupplierPortalClient::class);
    $id = $client->requestNumbersReport(Carbon::parse('2026-05-17'), Carbon::parse('2026-05-18'));

    expect($id)->toBe(508346);
});

it('waitReportReady polls until status is processed', function (): void {
    Http::fakeSequence('crm.bp-gr.ru/admin/report/status*')
        ->push(['status' => 'building'], 200)
        ->push(['status' => 'processed'], 200);

    $client = app(SupplierPortalClient::class);
    $client->waitReportReady(508346);

    expect(true)->toBeTrue(); // не бросил исключение — отчёт дождался
});

it('downloadReport returns raw CSV body', function (): void {
    Http::fake([
        'crm.bp-gr.ru/admin/report/getfile*' => Http::response("Name;Tag;Phone\nB1_a.com;t;79991234567\n", 200),
    ]);

    $client = app(SupplierPortalClient::class);
    $csv = $client->downloadReport(508346);

    expect($csv)->toContain('Name;Tag;Phone');
});
```

- [ ] **Step 3: Запустить тест — убедиться, что падает**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/SupplierPortalClientReportTest.php`
Expected: FAIL — `Method App\Services\Supplier\SupplierPortalClient::requestNumbersReport does not exist`.

- [ ] **Step 4: Добавить 3 метода в `SupplierPortalClient`**

В `app/app/Services/Supplier/SupplierPortalClient.php` **удалить** метод `downloadLeadsCsv` (строки 123–142) и **добавить** вместо него (перед `private function request`):

```php
    /**
     * Заказывает у поставщика отчёт «Запрос номеров» за диапазон [from, to].
     * Возвращает report_id для последующего waitReportReady / downloadReport.
     *
     * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.3
     * NB: endpoint/параметры уточнены discovery-шагом T3 Step 1.
     */
    public function requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int
    {
        $response = $this->request('POST', '/admin/report/generate', [
            'type' => 'numbers',
            'from' => $from->format('Y-m-d'),
            'to' => $to->format('Y-m-d'),
        ]);

        return (int) ($response->json('id') ?? 0);
    }

    /**
     * Опрашивает статус отчёта до значения «Обработан». На таймаут — SupplierTransientException.
     */
    public function waitReportReady(int $reportId): void
    {
        $maxAttempts = 20;
        $delaySeconds = 3;

        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $response = $this->request('GET', '/admin/report/status', ['id' => $reportId]);
            $status = (string) ($response->json('status') ?? '');

            if ($status === 'processed') {
                return;
            }

            if ($attempt < $maxAttempts) {
                sleep($delaySeconds);
            }
        }

        throw new SupplierTransientException(
            "Report {$reportId} not ready after {$maxAttempts} polls"
        );
    }

    /**
     * Скачивает готовый отчёт как raw CSV-body (UTF-8 + BOM, CRLF).
     * Парсинг — снаружи через SupplierCsvParser.
     */
    public function downloadReport(int $reportId): string
    {
        $response = $this->request('GET', '/admin/report/getfile', ['id' => $reportId]);

        return $response->body();
    }
```

`use App\Exceptions\Supplier\SupplierTransientException;` уже импортирован (строка 9). `CarbonInterface` уже импортирован (строка 13).

- [ ] **Step 5: Запустить тест — убедиться, что проходит**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/SupplierPortalClientReportTest.php`
Expected: PASS — 3 теста зелёные.

> Если тест `waitReportReady` идёт долго из-за `sleep()` — это ожидаемо (2 итерации, 1× sleep 3с). Допустимо. Если мешает — в Step 4 значение `$delaySeconds` оставить как есть (продакшн-поведение важнее скорости теста, 3с приемлемо).

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

```bash
git add app/app/Services/Supplier/SupplierPortalClient.php app/tests/Feature/Supplier/SupplierPortalClientReportTest.php
git commit -m "feat(supplier): SupplierPortalClient — async-флоу заказа отчёта «Запрос номеров»"
```

---

## Task 4: `CsvReconcileJob` — переписать под дедуп `(phone, project)`

Текущий job дедуплицирует по `vid` через `array_diff_key` и зовёт синхронный `downloadLeadsCsv` — оба невозможны на реальном CSV. Переписать: async-флоу заказа отчёта, дедуп по `(phone, project)`, окно 2 календарных дня.

**Files:**

- Modify: `app/app/Jobs/Supplier/CsvReconcileJob.php` (переписать `handle` + `extractPlatform`)
- Test: `app/tests/Feature/Supplier/CsvReconcileJobTest.php` (переписать полностью)

- [ ] **Step 1: Переписать тест-файл**

Заменить содержимое `app/tests/Feature/Supplier/CsvReconcileJobTest.php`:

```php
<?php

declare(strict_types=1);

use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;

uses(DatabaseTransactions::class, SharesSupplierPdo::class);

function putSupplierSession(): void
{
    Cache::store('redis')->put(
        'supplier:session',
        ['phpsessid' => 'test', 'csrf' => 'test'],
        now()->addHour(),
    );
}

beforeEach(function (): void {
    Mail::fake();
    Bus::fake([RouteSupplierLeadJob::class]);
    app()->bind(RefreshSupplierSessionJob::class, fn () => new class
    {
        public function handle(): void
        {
            putSupplierSession();
        }
    });
    Cache::store('redis')->forget('supplier:csv_reconcile');
    putSupplierSession();
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
    config(['services.supplier.alert_email' => 'ops@liderra.ru']);
});

afterEach(function (): void {
    Cache::store('redis')->forget('supplier:csv_reconcile');
});

/**
 * 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
 *
 * @param  array<int, array{project: string, phone: string}>  $rows
 */
function csvBody(array $rows): string
{
    $out = "Name;Tag;Phone\n";
    foreach ($rows as $r) {
        $out .= "{$r['project']};tag;{$r['phone']}\n";
    }

    return $out;
}

/**
 * Мокает весь async-флоу отчёта: generate → status(processed) → getfile(csv).
 */
function fakeReportFlow(string $csv): void
{
    Http::fake([
        'crm.bp-gr.ru/admin/report/generate*' => Http::response(['id' => 700001], 200),
        'crm.bp-gr.ru/admin/report/status*' => Http::response(['status' => 'processed'], 200),
        'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
    ]);
}

function runCsvReconcile(): void
{
    app(CsvReconcileJob::class)->handle(
        app(SupplierPortalClient::class),
        app(SupplierCsvParser::class),
        app(Mailer::class),
    );
}

it('no missing leads — status=ok, no recovery, no alert', function (): void {
    // Все CSV-строки уже есть в supplier_leads (phone+project совпадают).
    for ($i = 0; $i < 10; $i++) {
        SupplierLead::create([
            'supplier_project_id' => null,
            'platform' => 'B1',
            'phone' => "7999000000{$i}",
            'vid' => 800000 + $i,
            'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
            'received_at' => now()->subHour(),
            'source' => 'webhook',
        ]);
    }

    $rows = [];
    for ($i = 0; $i < 10; $i++) {
        $rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
    }
    fakeReportFlow(csvBody($rows));

    runCsvReconcile();

    $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
    expect($log->status)->toBe('ok');
    expect((int) $log->total_csv_rows)->toBe(10);
    expect((int) $log->matched_count)->toBe(10);
    expect((int) $log->recovered_count)->toBe(0);

    Mail::assertNothingSent();
    Bus::assertNothingDispatched();
});

it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
    // 9 из 10 уже есть; 1 строка CSV отсутствует → missing.
    for ($i = 0; $i < 9; $i++) {
        SupplierLead::create([
            'supplier_project_id' => null,
            'platform' => 'B1',
            'phone' => "7999111000{$i}",
            'vid' => 810000 + $i,
            'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
            'received_at' => now()->subHour(),
            'source' => 'webhook',
        ]);
    }

    $rows = [];
    for ($i = 0; $i < 10; $i++) {
        $rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
    }
    fakeReportFlow(csvBody($rows));

    runCsvReconcile();

    $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
    expect($log->status)->toBe('drift_alert');
    expect((float) $log->drift_ratio)->toBeGreaterThan(0.05);
    expect((int) $log->recovered_count)->toBe(1);

    // missing-лид создан с vid=NULL, source=csv_recovery.
    $recovered = SupplierLead::where('source', 'csv_recovery')->first();
    expect($recovered)->not->toBeNull();
    expect($recovered->vid)->toBeNull();
    expect($recovered->recovered_from_csv_at)->not->toBeNull();

    Mail::assertSent(CsvDriftAlertMail::class, 1);
    Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
});

it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
    for ($i = 0; $i < 99; $i++) {
        SupplierLead::create([
            'supplier_project_id' => null,
            'platform' => 'B1',
            'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
            'vid' => 820000 + $i,
            'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
            'received_at' => now()->subHour(),
            'source' => 'webhook',
        ]);
    }

    $rows = [];
    for ($i = 0; $i < 100; $i++) {
        $rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
    }
    fakeReportFlow(csvBody($rows));

    runCsvReconcile();

    $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
    expect($log->status)->toBe('ok');
    expect((int) $log->recovered_count)->toBe(1);
    Mail::assertNothingSent();
});

it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
    // Webhook принял phone X на проект B1_a.com. CSV содержит тот же phone на B2_b.com.
    SupplierLead::create([
        'supplier_project_id' => null,
        'platform' => 'B1',
        'phone' => '79995550000',
        'vid' => 830000,
        'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
        'received_at' => now()->subHour(),
        'source' => 'webhook',
    ]);

    fakeReportFlow(csvBody([
        ['project' => 'B1_a.com', 'phone' => '79995550000'],   // совпадает — matched
        ['project' => 'B2_b.com', 'phone' => '79995550000'],   // тот же phone, др. проект — missing
    ]));

    runCsvReconcile();

    $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
    expect((int) $log->matched_count)->toBe(1);
    expect((int) $log->recovered_count)->toBe(1);
});

it('empty CSV — status=ok, drift=0', function (): void {
    fakeReportFlow("Name;Tag;Phone\n");

    runCsvReconcile();

    $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
    expect($log->status)->toBe('ok');
    expect((int) $log->total_csv_rows)->toBe(0);
});

it('overlap lock held — job skips, no log row', function (): void {
    $countBefore = DB::table('supplier_csv_reconcile_log')->count();

    $lock = Cache::store('redis')->lock('supplier:csv_reconcile', 600);
    $lock->get();

    try {
        runCsvReconcile();
    } finally {
        $lock->release();
    }

    expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
});

it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);

    expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);

    $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
    expect($log->status)->toBe('failed');
    expect($log->error_message)->toContain('500');
});
```

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

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/CsvReconcileJobTest.php`
Expected: FAIL — текущий `handle` зовёт `downloadLeadsCsv` (удалён в T3) и дедуплицирует по vid.

- [ ] **Step 3: Переписать `CsvReconcileJob`**

Заменить содержимое `app/app/Jobs/Supplier/CsvReconcileJob.php`:

```php
<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Резервный CSV-канал (Путь 2): сверка отчёта поставщика «Запрос номеров»
 * с принятыми webhook-лидами; recovery пропущенного + drift-детект.
 *
 * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md
 *
 * Алгоритм:
 *   1. Cache::lock — overlap-защита.
 *   2. INSERT supplier_csv_reconcile_log (status='running').
 *   3. Заказать отчёт «Запрос номеров» за окно (2 кал. дня) → дождаться → скачать.
 *   4. Parse CSV (Name;Tag;Phone).
 *   5. Дедуп по (phone, project): SELECT existing supplier_leads за окно.
 *   6. Diff = missing → INSERT supplier_leads (vid=NULL, source='csv_recovery') + RouteJob.
 *   7. UPDATE log + drift; drift > 5% → CsvDriftAlertMail.
 *   8. На exception — status='failed', throw (cron повторит через 30 мин).
 */
final class CsvReconcileJob implements ShouldQueue
{
    use FoundationQueueable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 1;

    public int $timeout = 300;

    private const DB_CONNECTION = 'pgsql_supplier';

    private const DRIFT_THRESHOLD = 0.05;

    private const WINDOW_DAYS = 2;

    private const LOCK_NAME = 'supplier:csv_reconcile';

    private const LOCK_TTL_SECONDS = 600;

    public function handle(
        SupplierPortalClient $portal,
        SupplierCsvParser $parser,
        Mailer $mailer,
    ): void {
        /** @var LockProvider $lockStore */
        $lockStore = Cache::store('redis');
        $lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
        if (! $lock->get()) {
            Log::info('csv_reconcile.skipped_overlap');

            return;
        }

        // Окно: начало (сегодня − (WINDOW_DAYS−1) дней) 00:00 .. сейчас.
        $windowEnd = Carbon::now();
        $windowStart = Carbon::today()->subDays(self::WINDOW_DAYS - 1);

        $logId = DB::connection(self::DB_CONNECTION)
            ->table('supplier_csv_reconcile_log')
            ->insertGetId([
                'started_at' => now(),
                'window_start' => $windowStart,
                'window_end' => $windowEnd,
                'status' => 'running',
                'created_at' => now(),
            ]);

        try {
            $reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
            $portal->waitReportReady($reportId);
            $csv = $portal->downloadReport($reportId);

            // CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
            /** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
            $csvByKey = [];
            foreach ($parser->parse($csv) as $row) {
                $csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
            }
            $totalCsvRows = count($csvByKey);

            // Существующие лиды за окно → set ключей phone|project.
            $existingKeys = [];
            DB::connection(self::DB_CONNECTION)
                ->table('supplier_leads')
                ->where('received_at', '>=', $windowStart)
                ->select('phone', 'raw_payload')
                ->orderBy('id')
                ->chunk(500, function ($leads) use (&$existingKeys): void {
                    foreach ($leads as $lead) {
                        $payload = is_string($lead->raw_payload)
                            ? json_decode($lead->raw_payload, true)
                            : (array) $lead->raw_payload;
                        $project = (string) ($payload['project'] ?? '');
                        $existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
                    }
                });

            $missing = array_diff_key($csvByKey, $existingKeys);

            $recoveredCount = 0;
            foreach ($missing as $row) {
                $platform = $this->extractPlatform((string) $row['project']);
                if ($platform === null) {
                    Log::warning('csv_reconcile.unparseable_project_skipped', [
                        'project' => $row['project'],
                    ]);

                    continue;
                }

                try {
                    $lead = SupplierLead::create([
                        'vid' => null,
                        'platform' => $platform,
                        'phone' => (string) $row['phone'],
                        'raw_payload' => $row,
                        'received_at' => now(),
                        'recovered_from_csv_at' => now(),
                        'source' => 'csv_recovery',
                        'supplier_project_id' => null,
                    ]);
                    RouteSupplierLeadJob::dispatch($lead->id);
                    $recoveredCount++;
                } catch (QueryException $e) {
                    Log::warning('csv_reconcile.lead_insert_failed', [
                        'phone' => $row['phone'],
                        'project' => $row['project'],
                        'error' => $e->getMessage(),
                    ]);
                }
            }

            $matchedCount = $totalCsvRows - count($missing);
            $driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
            $status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';

            $update = [
                'finished_at' => now(),
                'total_csv_rows' => $totalCsvRows,
                'matched_count' => $matchedCount,
                'recovered_count' => $recoveredCount,
                'drift_ratio' => $driftRatio,
                'status' => $status,
            ];

            if ($status === 'drift_alert') {
                $mailer->to((string) config('services.supplier.alert_email'))
                    ->send(new CsvDriftAlertMail(
                        reconcileLogId: $logId,
                        totalCsvRows: $totalCsvRows,
                        missingCount: count($missing),
                        recoveredCount: $recoveredCount,
                        driftRatio: $driftRatio,
                        windowStart: $windowStart,
                        windowEnd: $windowEnd,
                    ));
                $update['alert_email_sent_at'] = now();
            }

            DB::connection(self::DB_CONNECTION)
                ->table('supplier_csv_reconcile_log')
                ->where('id', $logId)
                ->update($update);

        } catch (Throwable $e) {
            DB::connection(self::DB_CONNECTION)
                ->table('supplier_csv_reconcile_log')
                ->where('id', $logId)
                ->update([
                    'finished_at' => now(),
                    'status' => 'failed',
                    'error_message' => substr($e->getMessage(), 0, 1000),
                ]);
            throw $e;
        } finally {
            $lock->release();
        }
    }

    /**
     * Ключ дедупа: нормализованный phone + project.
     */
    private function dedupKey(string $phone, string $project): string
    {
        return trim($phone).'|'.trim($project);
    }

    /**
     * Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
     * Возвращает null если не парсится — caller пропустит строку с warning.
     */
    private function extractPlatform(string $project): ?string
    {
        if (preg_match('/^(B[123])_/', $project, $m) === 1) {
            return $m[1];
        }

        return null;
    }
}
```

- [ ] **Step 4: Запустить тест — убедиться, что проходит**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/CsvReconcileJobTest.php`
Expected: PASS — 7 тестов зелёные.

- [ ] **Step 5: Регрессия supplier-блока**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier`
Expected: PASS — без регрессий в соседних supplier-тестах.

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

```bash
git add app/app/Jobs/Supplier/CsvReconcileJob.php app/tests/Feature/Supplier/CsvReconcileJobTest.php
git commit -m "feat(supplier): CsvReconcileJob — дедуп (phone,project) + async-флоу отчёта"
```

---

## Task 5: Scheduler — `everyThirtyMinutes`

`CsvReconcileJob` сейчас в расписании `->hourly()`. Заказчик подтвердил частоту 30 минут.

**Files:**

- Modify: `app/routes/console.php:56-58`
- Test: `app/tests/Feature/Supplier/CsvReconcileScheduleTest.php` (создать)

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

Создать `app/tests/Feature/Supplier/CsvReconcileScheduleTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Console\Scheduling\Schedule;

it('CsvReconcileJob is scheduled every 30 minutes', function (): void {
    /** @var Schedule $schedule */
    $schedule = app(Schedule::class);

    $csvEvent = collect($schedule->events())->first(function ($event): bool {
        $repr = (string) ($event->description ?? '');
        if (property_exists($event, 'job')) {
            $repr .= ' '.((string) $event->job);
        }

        return str_contains($repr, 'CsvReconcileJob');
    });

    expect($csvEvent)->not->toBeNull();
    // Laravel everyThirtyMinutes() → cron-выражение '*/30 * * * *'.
    expect($csvEvent->expression)->toBe('*/30 * * * *');
});
```

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

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/CsvReconcileScheduleTest.php`
Expected: FAIL — текущее выражение `0 * * * *` (hourly), не `*/30 * * * *`.

- [ ] **Step 3: Изменить расписание**

В `app/routes/console.php` заменить строки 56–58:

```php
// Plan 4 Task 8: hourly CSV reconciliation (резерв-канал приёма лидов).
// Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
Schedule::job(new CsvReconcileJob)->hourly();
```

на:

```php
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes();
```

- [ ] **Step 4: Запустить тест — убедиться, что проходит**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier/CsvReconcileScheduleTest.php`
Expected: PASS.

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

```bash
git add app/routes/console.php app/tests/Feature/Supplier/CsvReconcileScheduleTest.php
git commit -m "feat(supplier): CsvReconcileJob — расписание каждые 30 минут"
```

---

## Task 6: API «Интеграция с поставщиком» — здоровье канала + ручной запуск

Backend для админ-страницы: GET — здоровье канала + история сверок; POST — ручной запуск `CsvReconcileJob`.

**Files:**

- Create: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php`
- Modify: `app/routes/web.php` (в группе `Route::middleware('saas-admin')`)
- Test: `app/tests/Feature/Admin/AdminSupplierIntegrationTest.php`

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

Создать `app/tests/Feature/Admin/AdminSupplierIntegrationTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\CsvReconcileJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;

uses(DatabaseTransactions::class, SharesSupplierPdo::class);

it('GET /api/admin/supplier-integration returns channel health + history', function (): void {
    DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
        'started_at' => now()->subMinutes(10),
        'finished_at' => now()->subMinutes(9),
        'window_start' => now()->subDay(),
        'window_end' => now(),
        'total_csv_rows' => 100,
        'matched_count' => 98,
        'recovered_count' => 2,
        'drift_ratio' => 0.02,
        'status' => 'ok',
        'created_at' => now()->subMinutes(10),
    ]);

    $response = $this->getJson('/api/admin/supplier-integration');

    $response->assertOk();
    $response->assertJsonStructure([
        'health' => ['last_run_at', 'last_status', 'drift_ratio', 'webhook_state'],
        'history' => [['started_at', 'status', 'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio']],
    ]);
    expect($response->json('health.last_status'))->toBe('ok');
    expect($response->json('health.webhook_state'))->toBe('live');
});

it('webhook_state is "down" when last run had drift_alert', function (): void {
    DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
        'started_at' => now()->subMinutes(5),
        'finished_at' => now()->subMinutes(4),
        'window_start' => now()->subDay(),
        'window_end' => now(),
        'total_csv_rows' => 100,
        'matched_count' => 80,
        'recovered_count' => 20,
        'drift_ratio' => 0.20,
        'status' => 'drift_alert',
        'created_at' => now()->subMinutes(5),
    ]);

    $response = $this->getJson('/api/admin/supplier-integration');

    expect($response->json('health.webhook_state'))->toBe('down');
});

it('POST /api/admin/supplier-integration/reconcile dispatches CsvReconcileJob', function (): void {
    Bus::fake([CsvReconcileJob::class]);

    $response = $this->postJson('/api/admin/supplier-integration/reconcile');

    $response->assertOk();
    $response->assertJson(['dispatched' => true]);
    Bus::assertDispatched(CsvReconcileJob::class, 1);
});
```

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

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Admin/AdminSupplierIntegrationTest.php`
Expected: FAIL — роут `/api/admin/supplier-integration` не существует (404).

- [ ] **Step 3: Создать контроллер**

Создать `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Jobs\Supplier\CsvReconcileJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;

/**
 * SaaS-admin → Интеграция с поставщиком: здоровье резервного CSV-канала (Путь 2).
 *
 * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.4
 */
final class AdminSupplierIntegrationController extends Controller
{
    private const HISTORY_LIMIT = 20;

    public function index(): JsonResponse
    {
        $rows = DB::connection('pgsql_supplier')
            ->table('supplier_csv_reconcile_log')
            ->orderByDesc('id')
            ->limit(self::HISTORY_LIMIT)
            ->get();

        $last = $rows->first();

        $webhookState = ($last !== null && $last->status === 'drift_alert') ? 'down' : 'live';

        return response()->json([
            'health' => [
                'last_run_at' => $last->finished_at ?? $last->started_at ?? null,
                'last_status' => $last->status ?? null,
                'drift_ratio' => $last !== null ? (float) $last->drift_ratio : null,
                'webhook_state' => $webhookState,
            ],
            'history' => $rows->map(fn ($r): array => [
                'started_at' => $r->started_at,
                'finished_at' => $r->finished_at,
                'window_start' => $r->window_start,
                'window_end' => $r->window_end,
                'status' => $r->status,
                'total_csv_rows' => (int) $r->total_csv_rows,
                'matched_count' => (int) $r->matched_count,
                'recovered_count' => (int) $r->recovered_count,
                'drift_ratio' => (float) $r->drift_ratio,
            ])->all(),
        ]);
    }

    public function reconcile(): JsonResponse
    {
        CsvReconcileJob::dispatch();

        return response()->json(['dispatched' => true]);
    }
}
```

- [ ] **Step 4: Зарегистрировать роуты**

В `app/routes/web.php` внутри группы `Route::middleware('saas-admin')->group(function () {` (рядом с другими `/api/admin/*` роутами, напр. после блока `AdminSuppliersController`) добавить:

```php
    // Резервный CSV-канал (Путь 2): здоровье канала + ручной запуск сверки.
    Route::get('/api/admin/supplier-integration', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@index');
    Route::post('/api/admin/supplier-integration/reconcile', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@reconcile');
```

- [ ] **Step 5: Запустить тест — убедиться, что проходит**

Run: `cd app && C:/tools/php83/php.exe artisan test tests/Feature/Admin/AdminSupplierIntegrationTest.php`
Expected: PASS — 3 теста зелёные.

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

```bash
git add app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes/web.php app/tests/Feature/Admin/AdminSupplierIntegrationTest.php
git commit -m "feat(supplier): API «Интеграция с поставщиком» — здоровье CSV-канала + ручная сверка"
```

---

## Task 7: UI — экран «Интеграция с поставщиком»

Vue-страница в админ-части: здоровье канала, кнопка «Сверить сейчас», история сверок.

**Files:**

- Create: `app/resources/js/views/admin/AdminSupplierIntegrationView.vue`
- Modify: `app/resources/js/router/index.ts` (роут `/admin/supplier-integration`)
- Modify: `app/resources/js/components/AppSidebar.vue` (пункт меню)
- Modify: `app/routes/web.php` (SPA-роут `Route::view`)
- Test: `app/tests/Frontend/AdminSupplierIntegrationView.spec.ts`

> **Паттерн:** следовать существующему `app/resources/js/views/admin/AdminSupplierPricesView.vue` — структура layout, axios-вызовы, Vuetify-компоненты. Прочитать его перед написанием.

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

Создать `app/tests/Frontend/AdminSupplierIntegrationView.spec.ts`:

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';

vi.mock('axios');

const vuetify = createVuetify({ components, directives });

const healthPayload = {
    health: { last_run_at: '2026-05-18T12:00:00Z', last_status: 'ok', drift_ratio: 0.02, webhook_state: 'live' },
    history: [
        {
            started_at: '2026-05-18T12:00:00Z', finished_at: '2026-05-18T12:01:00Z',
            window_start: '2026-05-17T00:00:00Z', window_end: '2026-05-18T12:00:00Z',
            status: 'ok', total_csv_rows: 100, matched_count: 98, recovered_count: 2, drift_ratio: 0.02,
        },
    ],
};

function mountView() {
    return mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
}

beforeEach(() => {
    vi.clearAllMocks();
    (axios.get as ReturnType<typeof vi.fn>).mockResolvedValue({ data: healthPayload });
    (axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { dispatched: true } });
});

describe('AdminSupplierIntegrationView', () => {
    it('loads channel health on mount', async () => {
        const wrapper = mountView();
        await new Promise((r) => setTimeout(r, 0));
        expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration');
        expect(wrapper.text()).toContain('live');
    });

    it('renders reconcile history rows', async () => {
        const wrapper = mountView();
        await new Promise((r) => setTimeout(r, 0));
        await wrapper.vm.$nextTick();
        expect(wrapper.text()).toContain('100');
    });

    it('triggers manual reconcile on button click', async () => {
        const wrapper = mountView();
        await new Promise((r) => setTimeout(r, 0));
        await wrapper.find('[data-test="reconcile-now"]').trigger('click');
        expect(axios.post).toHaveBeenCalledWith('/api/admin/supplier-integration/reconcile');
    });
});
```

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

Run: `cd app && npm run test:vue -- tests/Frontend/AdminSupplierIntegrationView.spec.ts`
Expected: FAIL — файл `AdminSupplierIntegrationView.vue` не существует.

- [ ] **Step 3: Создать Vue-компонент**

Создать `app/resources/js/views/admin/AdminSupplierIntegrationView.vue`:

```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';

interface ReconcileRow {
    started_at: string;
    finished_at: string | null;
    window_start: string;
    window_end: string;
    status: string;
    total_csv_rows: number;
    matched_count: number;
    recovered_count: number;
    drift_ratio: number;
}

interface Health {
    last_run_at: string | null;
    last_status: string | null;
    drift_ratio: number | null;
    webhook_state: string;
}

const health = ref<Health | null>(null);
const history = ref<ReconcileRow[]>([]);
const loading = ref(false);
const reconciling = ref(false);

async function load(): Promise<void> {
    loading.value = true;
    try {
        const { data } = await axios.get('/api/admin/supplier-integration');
        health.value = data.health;
        history.value = data.history;
    } finally {
        loading.value = false;
    }
}

async function reconcileNow(): Promise<void> {
    reconciling.value = true;
    try {
        await axios.post('/api/admin/supplier-integration/reconcile');
        // Сверка асинхронная — даём ей время и перезагружаем здоровье.
        setTimeout(() => void load(), 4000);
    } finally {
        reconciling.value = false;
    }
}

function statusColor(status: string | null): string {
    if (status === 'ok') return 'success';
    if (status === 'drift_alert') return 'warning';
    if (status === 'failed') return 'error';
    return 'grey';
}

onMounted(() => void load());
</script>

<template>
    <div class="pa-6">
        <h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>

        <v-card class="mb-4">
            <v-card-title>Здоровье резервного канала</v-card-title>
            <v-card-text v-if="health">
                <div class="mb-2">
                    Webhook:
                    <v-chip :color="health.webhook_state === 'live' ? 'success' : 'error'" size="small">
                        {{ health.webhook_state }}
                    </v-chip>
                </div>
                <div class="mb-2">
                    Последняя сверка:
                    <v-chip :color="statusColor(health.last_status)" size="small">
                        {{ health.last_status ?? '—' }}
                    </v-chip>
                    <span class="ml-2">{{ health.last_run_at ?? '—' }}</span>
                </div>
                <div class="mb-4">
                    Расхождение (drift):
                    {{ health.drift_ratio !== null ? (health.drift_ratio * 100).toFixed(2) + ' %' : '—' }}
                </div>
                <v-btn
                    data-test="reconcile-now"
                    color="primary"
                    :loading="reconciling"
                    @click="reconcileNow"
                >
                    Сверить сейчас
                </v-btn>
            </v-card-text>
        </v-card>

        <v-card>
            <v-card-title>История сверок</v-card-title>
            <v-table>
                <thead>
                    <tr>
                        <th>Начало</th>
                        <th>Статус</th>
                        <th>Строк CSV</th>
                        <th>Совпало</th>
                        <th>Подобрано</th>
                        <th>Drift</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="(row, i) in history" :key="i">
                        <td>{{ row.started_at }}</td>
                        <td>
                            <v-chip :color="statusColor(row.status)" size="x-small">{{ row.status }}</v-chip>
                        </td>
                        <td>{{ row.total_csv_rows }}</td>
                        <td>{{ row.matched_count }}</td>
                        <td>{{ row.recovered_count }}</td>
                        <td>{{ (row.drift_ratio * 100).toFixed(2) }} %</td>
                    </tr>
                </tbody>
            </v-table>
        </v-card>
    </div>
</template>
```

- [ ] **Step 4: Зарегистрировать роут в router**

В `app/resources/js/router/index.ts` добавить роут (рядом с другими `/admin/*` роутами, следовать существующему паттерну lazy-import + `meta.layout`):

```ts
    {
        path: '/admin/supplier-integration',
        name: 'admin-supplier-integration',
        component: () => import('../views/admin/AdminSupplierIntegrationView.vue'),
        meta: { layout: 'app' },
    },
```

- [ ] **Step 5: Добавить пункт в боковое меню**

В `app/resources/js/components/AppSidebar.vue` добавить пункт навигации в админ-секцию (следовать формату существующих `nav`-элементов файла — title, иконка Lucide, `to: '/admin/supplier-integration'`). Точный формат — по соседнему админ-пункту в том же файле.

- [ ] **Step 6: Добавить SPA-роут в `web.php`**

В `app/routes/web.php` рядом с другими `Route::view(...)` SPA-роутами добавить:

```php
Route::view('/admin/supplier-integration', 'welcome');
```

- [ ] **Step 7: Запустить тест — убедиться, что проходит**

Run: `cd app && npm run test:vue -- tests/Frontend/AdminSupplierIntegrationView.spec.ts`
Expected: PASS — 3 теста зелёные.

- [ ] **Step 8: Сборка фронтенда**

Run: `cd app && npm run build`
Expected: сборка без ошибок (PROD-режим — портал отдаёт `public/build/`).

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

```bash
git add app/resources/js/views/admin/AdminSupplierIntegrationView.vue app/resources/js/router/index.ts app/resources/js/components/AppSidebar.vue app/routes/web.php app/tests/Frontend/AdminSupplierIntegrationView.spec.ts
git commit -m "feat(supplier): UI-экран «Интеграция с поставщиком» — здоровье CSV-канала"
```

---

## Финальная проверка эпика

- [ ] **Полная регрессия**

```bash
cd app && C:/tools/php83/php.exe artisan test tests/Feature/Supplier tests/Feature/Admin tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Http/Webhook/SupplierWebhookTest.php
cd app && npm run test:vue
```

Expected: все зелёные, 0 регрессий.

- [ ] **Pre-commit перед каждым коммитом** уже прогоняет pint + larastan + gitleaks. Если Larastan baseline переполнен новыми тестами — обновить `app/phpstan-baseline.neon` (счётчики `actingAs` / `getJson` / `tenant` в новых тест-файлах), отдельным коммитом или в составе задачи.

- [ ] **Push** — `git push origin <ветка>:main` (паттерн проекта); pre-push прогоняет gitleaks-full-history + lychee.

## Замечания

- **Endpoint'ы отчёта (T3)** — placeholder до discovery-шага T3 Step 1. Это единственная зона неопределённости; код T3 написан на лучшем предположении и корректируется по факту портала.
- **Письмо drift-alert** доставляется только после настройки почты (Б-1); `CsvDriftAlertMail` формируется всегда, тест проверяет `Mail::assertSent` (fake).
- **Плашка + колокольчик** при drift > 5% — spec §7 упоминает их; в этом плане drift фиксируется в `supplier_csv_reconcile_log` + письмо, а UI-индикатор «webhook down» (T6/T7) выполняет роль плашки на админ-странице. Отдельный in-app `notification` (колокольчик) — НЕ в этом плане (требует привязки к tenant-уровню `in_app_notifications`, а CSV-канал SaaS-level); если нужен — отдельная задача.
