# Sprint 3C — Reports (F1 + F2) Implementation Plan

> **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:** Реализовать 3 недостающих провайдера отчётов (managers/sources/billing summary) и endpoint скачивания готового файла отчёта по signed URL (24 ч).

**Architecture:** F1 — 3 новых класса `ReportDataProvider` (managers/sources/billing), зарегистрированы в `ReportGeneratorRegistry`; формат-слой (csv/xlsx/json/pdf) уже готов и переиспользуется без изменений. F2 — новый route `GET /api/reports/jobs/{id}/file` под `signed`-middleware (не под `auth:sanctum` — подпись URL = capability-token), метод `ReportJobController@download` отдаёт файл потоком; `toResource()` отдаёт `download_url` (24 ч); frontend проводит `download_url` через api→mapper→`ReportJobsList` и вешает на кнопку «Скачать».

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16 (партиционированная `deals`, RLS); Vue 3 / Vuetify 3 / Vitest 4 / TypeScript.

---

## Контекст для исполнителя (прочитать до начала)

**Аудит-источник:** `docs/superpowers/specs/2026-05-15-portal-audit-design.md` §F — F1 (Reports Provider'ы, 3 из 4) и F2 (Reports Скачать). Sprint 3C идёт после закрытых 3A/3B.

**Существующая архитектура отчётов (НЕ менять, только расширять):**

- `app/app/Models/ReportJob.php` — модель. `TYPES = ['deals_export','managers_summary','sources_summary','billing_summary']` уже объявлены все 4. `FORMATS = ['csv','xlsx','json','pdf']`. Статусы pending→processing→done|failed.
- `app/app/Services/Reports/Providers/ReportDataProvider.php` — интерфейс: `headers(): array`, `rows(ReportJob $job): array`, `slug(): string`.
- `app/app/Services/Reports/Providers/DealsExportProvider.php` — единственный готовый провайдер, **эталон стиля** (RLS-обёртка `DB::transaction` + `SET LOCAL app.current_tenant_id`).
- `app/app/Services/Reports/ReportGeneratorRegistry.php` — резолвер: `provider(type)` match, `formatter(format)` match, `isSupported(type, format)`.
- `app/app/Jobs/GenerateReportJob.php` — оркестратор: provider→formatter→`Storage::disk('local')->put('reports/{tenant_id}/{job_id}.{ext}', $content)`.
- `app/app/Http/Controllers/Api/ReportJobController.php` — CRUD + retry/cancel/delete; `toResource()` сериализует job.
- Formatter-классы `csv/xlsx/json` — готовы; `PdfStubFormatter` намеренно кидает RuntimeException (PDF → failed-job, Post-MVP).
- Routes: `app/routes/web.php` строки 62-72, группа `Route::middleware(['auth:sanctum','tenant'])->prefix('/api/reports/jobs')`.

**RLS:** `report_jobs`, `deals`, `lead_charges`, `balance_transactions` — все ENABLE ROW LEVEL SECURITY (policy `tenant_isolation` по `current_setting('app.current_tenant_id')`). На dev/test БД работает под `postgres` superuser (RLS `ENABLE`, не `FORCE` для `report_jobs` → superuser обходит политику), поэтому в коде ОБЯЗАТЕЛЕН явный `where('tenant_id', ...)` как defense-in-depth + `SET LOCAL` для prod-роли `crm_app_user`. Этот паттерн виден во всех методах `ReportJobController` и в `DealsExportProvider`.

**Партиции `deals`:** партиционирована по `received_at`, партиции существуют на май–октябрь 2026. В тестах вставлять сделки с `received_at` = `Carbon::now()` (текущий месяц → партиция есть).

**«Won»-статус:** конверсия считается по `deals.status = 'paid'` — так же, как в свежем `DashboardController` (Sprint 3B / J3). Не вводить новых определений.

**Конвенции тестов:** существующие Pest-файлы используют `uses(DatabaseTransactions::class)`, `Storage::fake('local')`, top-level helper-функции с УНИКАЛЬНЫМИ именами (`makeJob`, `makeReportJob` уже заняты — новые helper'ы называть иначе, иначе PHP redeclare-fatal при прогоне всего suite).

---

## File Structure

**Создаются:**

- `app/app/Services/Reports/Providers/ManagersSummaryProvider.php` — агрегат сделок по менеджерам.
- `app/app/Services/Reports/Providers/SourcesSummaryProvider.php` — агрегат сделок по `utm_source`.
- `app/app/Services/Reports/Providers/BillingSummaryProvider.php` — агрегат `balance_transactions` по типу операции.
- `app/tests/Feature/Reports/ManagersSummaryProviderTest.php`
- `app/tests/Feature/Reports/SourcesSummaryProviderTest.php`
- `app/tests/Feature/Reports/BillingSummaryProviderTest.php`
- `app/tests/Feature/Reports/ReportDownloadTest.php`

**Модифицируются:**

- `app/app/Services/Reports/ReportGeneratorRegistry.php` — +3 провайдера в конструктор, +3 ветки `provider()`, упрощение `isSupported()`.
- `app/app/Http/Controllers/Api/ReportJobController.php` — +метод `download()`, `toResource()` +`download_url`.
- `app/routes/web.php` — +route `reports.download` под `signed`.
- `app/tests/Feature/Reports/ReportJobControllerTest.php` — переписать тест `managers_summary не реализован`, +2 интеграционных теста (sources/billing).
- `app/resources/js/api/reports.ts` — `ApiReportJob` +`download_url`.
- `app/resources/js/composables/mockReports.ts` — `ReportJob` +`downloadUrl`.
- `app/resources/js/composables/reportsMapper.ts` — `mapApiReportJob` +`downloadUrl`.
- `app/resources/js/components/reports/ReportJobsList.vue` — кнопка «Скачать» → `:href`.
- `app/tests/Frontend/reportsMapper.spec.ts`, `reports-api.spec.ts`, `ReportsView.spec.ts` — +`download_url` в fixtures, +тесты.

---

### Task 1: ManagersSummaryProvider (F1, провайдер 1/3)

**Files:**

- Create: `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`
- Create: `app/tests/Feature/Reports/ManagersSummaryProviderTest.php`
- Modify: `app/app/Services/Reports/ReportGeneratorRegistry.php`
- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php` (строки 339-351 — переписать тест)

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

Создать `app/tests/Feature/Reports/ManagersSummaryProviderTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Reports\Providers\ManagersSummaryProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
});

/** Вставляет сделку напрямую (deals партиционирована, фабрики нет). */
function seedManagerDeal(int $tenantId, int $projectId, array $overrides = []): void
{
    DB::table('deals')->insert(array_merge([
        'tenant_id' => $tenantId,
        'project_id' => $projectId,
        'phone' => '+7999'.random_int(1000000, 9999999),
        'status' => 'new',
        'received_at' => Carbon::now()->startOfMonth()->addDays(10),
        'created_at' => Carbon::now(),
        'updated_at' => Carbon::now(),
    ], $overrides));
}

/** ReportJob без сохранения — провайдер читает только tenant_id + parameters. */
function managersJob(int $tenantId): ReportJob
{
    return new ReportJob([
        'tenant_id' => $tenantId,
        'type' => 'managers_summary',
        'parameters' => [
            'format' => 'csv',
            'date_from' => Carbon::now()->startOfMonth()->toDateString(),
            'date_to' => Carbon::now()->endOfMonth()->toDateString(),
        ],
    ]);
}

test('headers: 4 колонки', function () {
    expect((new ManagersSummaryProvider)->headers())
        ->toBe(['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)']);
});

test('slug = managers', function () {
    expect((new ManagersSummaryProvider)->slug())->toBe('managers');
});

test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
    $manager = User::factory()->create([
        'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
    ]);
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);

    $rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));

    expect($rows)->toHaveCount(1);
    expect($rows[0])->toBe(['Иван Петров', 3, 2, 66.7]);
});

test('сделки без менеджера → строка «Не назначен»', function () {
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => null, 'status' => 'new']);

    $rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));

    expect($rows)->toHaveCount(1);
    expect($rows[0][0])->toBe('Не назначен');
});

test('исключает soft-deleted и тестовые сделки', function () {
    $manager = User::factory()->create(['tenant_id' => $this->tenant->id]);
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id]);
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'deleted_at' => Carbon::now()]);
    seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'is_test' => true]);

    $rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));

    expect($rows)->toHaveCount(1);
    expect($rows[0][1])->toBe(1);
});

test('изолирует по tenant_id', function () {
    $other = Tenant::factory()->create();
    $otherProject = Project::factory()->create(['tenant_id' => $other->id]);
    seedManagerDeal($other->id, $otherProject->id);

    $rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));

    expect($rows)->toBe([]);
});
```

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

Run: `cd app && php artisan test tests/Feature/Reports/ManagersSummaryProviderTest.php`
Expected: FAIL — `Class "App\Services\Reports\Providers\ManagersSummaryProvider" not found`.

- [ ] **Step 3: Создать провайдер**

Создать `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports\Providers;

use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * managers_summary — агрегат сделок по менеджерам за период (audit F1).
 *
 * Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
 * в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
 * в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
 *
 * parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
 * (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
 * SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
 */
class ManagersSummaryProvider implements ReportDataProvider
{
    public function headers(): array
    {
        return ['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
    }

    public function rows(ReportJob $job): array
    {
        $params = $job->parameters ?? [];
        $dateFrom = Carbon::parse($params['date_from'])->startOfDay();
        $dateTo = Carbon::parse($params['date_to'])->endOfDay();

        return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
            DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);

            $rows = DB::table('deals')
                ->leftJoin('users', 'deals.manager_id', '=', 'users.id')
                ->where('deals.tenant_id', $job->tenant_id)
                ->whereNull('deals.deleted_at')
                ->where('deals.is_test', false)
                ->whereBetween('deals.received_at', [$dateFrom, $dateTo])
                ->groupBy('deals.manager_id', 'users.first_name', 'users.last_name', 'users.email')
                ->orderByRaw('COUNT(*) DESC')
                ->orderBy('deals.manager_id')
                ->selectRaw(
                    "deals.manager_id,
                     users.first_name, users.last_name, users.email,
                     COUNT(*) AS total,
                     COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
                )
                ->get();

            return $rows->map(function ($row): array {
                $name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
                if ($name === '') {
                    $name = (string) ($row->email ?? '');
                }
                if ($name === '') {
                    $name = 'Не назначен';
                }
                $total = (int) $row->total;
                $paid = (int) $row->paid;
                $conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;

                return [$name, $total, $paid, $conversion];
            })->all();
        });
    }

    public function slug(): string
    {
        return 'managers';
    }
}
```

- [ ] **Step 4: Зарегистрировать провайдер в ReportGeneratorRegistry**

В `app/app/Services/Reports/ReportGeneratorRegistry.php`:

Добавить `use` после строки 13 (`use App\Services\Reports\Providers\DealsExportProvider;`):

```php
use App\Services\Reports\Providers\ManagersSummaryProvider;
```

В конструкторе добавить параметр после `DealsExportProvider $dealsExport`:

```php
    public function __construct(
        private readonly DealsExportProvider $dealsExport,
        private readonly ManagersSummaryProvider $managersSummary,
        private readonly CsvFormatter $csv,
        private readonly XlsxFormatter $xlsx,
        private readonly JsonFormatter $json,
        private readonly PdfStubFormatter $pdf,
    ) {}
```

В `provider()` добавить ветку перед `default`:

```php
    public function provider(string $type): ReportDataProvider
    {
        return match ($type) {
            'deals_export' => $this->dealsExport,
            'managers_summary' => $this->managersSummary,
            default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"),
        };
    }
```

В `isSupported()` заменить строку `$supportedTypes = ['deals_export'];` на:

```php
        $supportedTypes = ['deals_export', 'managers_summary'];
```

- [ ] **Step 5: Запустить тест провайдера — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/ManagersSummaryProviderTest.php`
Expected: PASS — 6 passed.

- [ ] **Step 6: Переписать устаревший интеграционный тест в ReportJobControllerTest**

В `app/tests/Feature/Reports/ReportJobControllerTest.php` ПОЛНОСТЬЮ заменить тест `POST /api/reports/jobs (sync queue): managers_summary не реализован → failed` (строки 339-351) на:

```php
test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', function () {
    config()->set('queue.default', 'sync');

    $now = Carbon::now()->startOfMonth()->addDays(10);
    $project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
    $manager = User::factory()->create([
        'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
    ]);
    DB::table('deals')->insert([
        'tenant_id' => $this->tenant->id,
        'project_id' => $project->id,
        'manager_id' => $manager->id,
        'phone' => '+79990001122',
        'status' => 'paid',
        'received_at' => $now,
        'created_at' => Carbon::now(),
        'updated_at' => Carbon::now(),
    ]);

    $response = $this->postJson('/api/reports/jobs', [
        'type' => 'managers_summary', 'format' => 'csv',
        'parameters' => [
            'date_from' => $now->copy()->startOfMonth()->toDateString(),
            'date_to' => $now->copy()->endOfMonth()->toDateString(),
        ],
    ]);

    $response->assertStatus(201);
    $job = ReportJob::find($response->json('job.id'));
    expect($job->status)->toBe('done');
    expect($job->file_path)->toEndWith('.csv');

    $content = Storage::disk('local')->get($job->file_path);
    expect($content)->toContain('Менеджер');
    expect($content)->toContain('Иван Петров');
});
```

- [ ] **Step 7: Запустить ReportJobControllerTest — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/ReportJobControllerTest.php`
Expected: PASS — все тесты зелёные (включая переписанный managers_summary).

- [ ] **Step 8: Pint + commit**

```bash
cd app && composer pint
git add app/app/Services/Reports/Providers/ManagersSummaryProvider.php app/app/Services/Reports/ReportGeneratorRegistry.php app/tests/Feature/Reports/ManagersSummaryProviderTest.php app/tests/Feature/Reports/ReportJobControllerTest.php
git commit -m "feat(reports): ManagersSummaryProvider — агрегат сделок по менеджерам (F1)"
```

---

### Task 2: SourcesSummaryProvider (F1, провайдер 2/3)

**Files:**

- Create: `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`
- Create: `app/tests/Feature/Reports/SourcesSummaryProviderTest.php`
- Modify: `app/app/Services/Reports/ReportGeneratorRegistry.php`
- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php` (добавить тест)

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

Создать `app/tests/Feature/Reports/SourcesSummaryProviderTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Services\Reports\Providers\SourcesSummaryProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
});

function seedSourceDeal(int $tenantId, int $projectId, array $overrides = []): void
{
    DB::table('deals')->insert(array_merge([
        'tenant_id' => $tenantId,
        'project_id' => $projectId,
        'phone' => '+7999'.random_int(1000000, 9999999),
        'status' => 'new',
        'received_at' => Carbon::now()->startOfMonth()->addDays(10),
        'created_at' => Carbon::now(),
        'updated_at' => Carbon::now(),
    ], $overrides));
}

function sourcesJob(int $tenantId): ReportJob
{
    return new ReportJob([
        'tenant_id' => $tenantId,
        'type' => 'sources_summary',
        'parameters' => [
            'format' => 'csv',
            'date_from' => Carbon::now()->startOfMonth()->toDateString(),
            'date_to' => Carbon::now()->endOfMonth()->toDateString(),
        ],
    ]);
}

test('headers: 4 колонки', function () {
    expect((new SourcesSummaryProvider)->headers())
        ->toBe(['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)']);
});

test('slug = sources', function () {
    expect((new SourcesSummaryProvider)->slug())->toBe('sources');
});

test('агрегирует сделки по utm_source', function () {
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);

    $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));

    expect($rows)->toHaveCount(2);
    // ORDER BY COUNT(*) DESC → yandex (2) первый.
    expect($rows[0])->toBe(['yandex', 2, 1, 50.0]);
    expect($rows[1])->toBe(['vk', 1, 1, 100.0]);
});

test('сделки без utm_source → «Прямые / без метки»', function () {
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => null]);

    $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));

    expect($rows)->toHaveCount(1);
    expect($rows[0][0])->toBe('Прямые / без метки');
});

test('исключает soft-deleted и тестовые сделки', function () {
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex']);
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'deleted_at' => Carbon::now()]);
    seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'is_test' => true]);

    $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));

    expect($rows)->toHaveCount(1);
    expect($rows[0][1])->toBe(1);
});

test('изолирует по tenant_id', function () {
    $other = Tenant::factory()->create();
    $otherProject = Project::factory()->create(['tenant_id' => $other->id]);
    seedSourceDeal($other->id, $otherProject->id, ['utm_source' => 'yandex']);

    $rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));

    expect($rows)->toBe([]);
});
```

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

Run: `cd app && php artisan test tests/Feature/Reports/SourcesSummaryProviderTest.php`
Expected: FAIL — `Class "App\Services\Reports\Providers\SourcesSummaryProvider" not found`.

- [ ] **Step 3: Создать провайдер**

Создать `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports\Providers;

use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
 *
 * Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
 * строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
 * paid / total * 100, округление до 0.1.
 *
 * parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
 * сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
 */
class SourcesSummaryProvider implements ReportDataProvider
{
    public function headers(): array
    {
        return ['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
    }

    public function rows(ReportJob $job): array
    {
        $params = $job->parameters ?? [];
        $dateFrom = Carbon::parse($params['date_from'])->startOfDay();
        $dateTo = Carbon::parse($params['date_to'])->endOfDay();

        return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
            DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);

            $rows = DB::table('deals')
                ->where('tenant_id', $job->tenant_id)
                ->whereNull('deleted_at')
                ->where('is_test', false)
                ->whereBetween('received_at', [$dateFrom, $dateTo])
                ->groupBy('utm_source')
                ->orderByRaw('COUNT(*) DESC')
                ->orderBy('utm_source')
                ->selectRaw(
                    "utm_source,
                     COUNT(*) AS total,
                     COUNT(*) FILTER (WHERE status = 'paid') AS paid"
                )
                ->get();

            return $rows->map(function ($row): array {
                $source = $row->utm_source !== null && trim((string) $row->utm_source) !== ''
                    ? (string) $row->utm_source
                    : 'Прямые / без метки';
                $total = (int) $row->total;
                $paid = (int) $row->paid;
                $conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;

                return [$source, $total, $paid, $conversion];
            })->all();
        });
    }

    public function slug(): string
    {
        return 'sources';
    }
}
```

- [ ] **Step 4: Зарегистрировать провайдер в ReportGeneratorRegistry**

В `app/app/Services/Reports/ReportGeneratorRegistry.php`:

Добавить `use` рядом с `ManagersSummaryProvider`:

```php
use App\Services\Reports\Providers\SourcesSummaryProvider;
```

В конструкторе добавить параметр после `ManagersSummaryProvider $managersSummary`:

```php
        private readonly SourcesSummaryProvider $sourcesSummary,
```

В `provider()` добавить ветку:

```php
            'sources_summary' => $this->sourcesSummary,
```

В `isSupported()` заменить строку `$supportedTypes` на:

```php
        $supportedTypes = ['deals_export', 'managers_summary', 'sources_summary'];
```

- [ ] **Step 5: Запустить тест провайдера — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/SourcesSummaryProviderTest.php`
Expected: PASS — 6 passed.

- [ ] **Step 6: Добавить интеграционный тест в ReportJobControllerTest**

В конец `app/tests/Feature/Reports/ReportJobControllerTest.php` добавить:

```php
test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', function () {
    config()->set('queue.default', 'sync');

    $now = Carbon::now()->startOfMonth()->addDays(10);
    $project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
    DB::table('deals')->insert([
        'tenant_id' => $this->tenant->id,
        'project_id' => $project->id,
        'phone' => '+79990002233',
        'status' => 'paid',
        'utm_source' => 'yandex',
        'received_at' => $now,
        'created_at' => Carbon::now(),
        'updated_at' => Carbon::now(),
    ]);

    $response = $this->postJson('/api/reports/jobs', [
        'type' => 'sources_summary', 'format' => 'csv',
        'parameters' => [
            'date_from' => $now->copy()->startOfMonth()->toDateString(),
            'date_to' => $now->copy()->endOfMonth()->toDateString(),
        ],
    ]);

    $response->assertStatus(201);
    $job = ReportJob::find($response->json('job.id'));
    expect($job->status)->toBe('done');
    expect($job->file_path)->toEndWith('.csv');

    $content = Storage::disk('local')->get($job->file_path);
    expect($content)->toContain('Источник');
    expect($content)->toContain('yandex');
});
```

- [ ] **Step 7: Запустить ReportJobControllerTest — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/ReportJobControllerTest.php`
Expected: PASS.

- [ ] **Step 8: Pint + commit**

```bash
cd app && composer pint
git add app/app/Services/Reports/Providers/SourcesSummaryProvider.php app/app/Services/Reports/ReportGeneratorRegistry.php app/tests/Feature/Reports/SourcesSummaryProviderTest.php app/tests/Feature/Reports/ReportJobControllerTest.php
git commit -m "feat(reports): SourcesSummaryProvider — агрегат сделок по utm_source (F1)"
```

---

### Task 3: BillingSummaryProvider (F1, провайдер 3/3)

**Files:**

- Create: `app/app/Services/Reports/Providers/BillingSummaryProvider.php`
- Create: `app/tests/Feature/Reports/BillingSummaryProviderTest.php`
- Modify: `app/app/Services/Reports/ReportGeneratorRegistry.php`
- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php` (добавить тест)

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

Создать `app/tests/Feature/Reports/BillingSummaryProviderTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use App\Models\ReportJob;
use App\Models\Tenant;
use App\Services\Reports\Providers\BillingSummaryProvider;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
});

function seedBillingTx(int $tenantId, string $type, float $amount, ?Carbon $at = null): void
{
    BalanceTransaction::create([
        'tenant_id' => $tenantId,
        'type' => $type,
        'amount_rub' => $amount,
        'amount_leads' => 0,
        'description' => 'test',
        'created_at' => $at ?? Carbon::now()->startOfMonth()->addDays(10),
    ]);
}

function billingJob(int $tenantId): ReportJob
{
    return new ReportJob([
        'tenant_id' => $tenantId,
        'type' => 'billing_summary',
        'parameters' => [
            'format' => 'csv',
            'date_from' => Carbon::now()->startOfMonth()->toDateString(),
            'date_to' => Carbon::now()->endOfMonth()->toDateString(),
        ],
    ]);
}

test('headers: 3 колонки', function () {
    expect((new BillingSummaryProvider)->headers())
        ->toBe(['Тип операции', 'Количество', 'Сумма (₽)']);
});

test('slug = billing', function () {
    expect((new BillingSummaryProvider)->slug())->toBe('billing');
});

test('агрегирует balance_transactions по типу: count + сумма', function () {
    seedBillingTx($this->tenant->id, 'topup', 5000);
    seedBillingTx($this->tenant->id, 'topup', 3000);
    seedBillingTx($this->tenant->id, 'lead_charge', -250);

    $rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));

    // ORDER BY type → 'lead_charge' < 'topup'.
    expect($rows)->toHaveCount(2);
    expect($rows[0])->toBe(['Списание за лиды', 1, '-250.00']);
    expect($rows[1])->toBe(['Пополнение', 2, '8000.00']);
});

test('исключает транзакции вне периода', function () {
    seedBillingTx($this->tenant->id, 'topup', 5000);
    seedBillingTx($this->tenant->id, 'topup', 1000, Carbon::now()->subMonths(3));

    $rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));

    expect($rows)->toHaveCount(1);
    expect($rows[0])->toBe(['Пополнение', 1, '5000.00']);
});

test('изолирует по tenant_id', function () {
    $other = Tenant::factory()->create();
    seedBillingTx($other->id, 'topup', 9999);

    $rows = (new BillingSummaryProvider)->rows(billingJob($this->tenant->id));

    expect($rows)->toBe([]);
});
```

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

Run: `cd app && php artisan test tests/Feature/Reports/BillingSummaryProviderTest.php`
Expected: FAIL — `Class "App\Services\Reports\Providers\BillingSummaryProvider" not found`.

- [ ] **Step 3: Создать провайдер**

Создать `app/app/Services/Reports/Providers/BillingSummaryProvider.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports\Providers;

use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * billing_summary — агрегат balance_transactions по типу операции (audit F1).
 *
 * Группировка по balance_transactions.type; count + SUM(amount_rub). Тип
 * операции переводится в человекочитаемую метку. parameters: date_from,
 * date_to (Y-m-d) — фильтр по created_at.
 *
 * RLS-обёртка SET LOCAL app.current_tenant_id (balance_transactions имеет RLS
 * tenant_isolation) + явный where('tenant_id') — паттерн BillingController.
 */
class BillingSummaryProvider implements ReportDataProvider
{
    /** Канон-типы balance_transactions.type → RU-метка (schema §7.6 CHECK). */
    private const TYPE_LABELS = [
        'trial_bonus' => 'Стартовый бонус',
        'topup' => 'Пополнение',
        'lead_charge' => 'Списание за лиды',
        'refund' => 'Возврат',
        'manual_adjustment' => 'Ручная корректировка',
        'historical_import' => 'Импорт истории',
        'chargeback_writedown' => 'Chargeback — списание в долг',
        'chargeback_repayment' => 'Chargeback — погашение долга',
    ];

    public function headers(): array
    {
        return ['Тип операции', 'Количество', 'Сумма (₽)'];
    }

    public function rows(ReportJob $job): array
    {
        $params = $job->parameters ?? [];
        $dateFrom = Carbon::parse($params['date_from'])->startOfDay();
        $dateTo = Carbon::parse($params['date_to'])->endOfDay();

        return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
            DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);

            $rows = DB::table('balance_transactions')
                ->where('tenant_id', $job->tenant_id)
                ->whereBetween('created_at', [$dateFrom, $dateTo])
                ->groupBy('type')
                ->orderBy('type')
                ->selectRaw('type, COUNT(*) AS cnt, COALESCE(SUM(amount_rub), 0) AS sum_rub')
                ->get();

            return $rows->map(function ($row): array {
                $label = self::TYPE_LABELS[$row->type] ?? (string) $row->type;

                return [$label, (int) $row->cnt, (string) $row->sum_rub];
            })->all();
        });
    }

    public function slug(): string
    {
        return 'billing';
    }
}
```

- [ ] **Step 4: Зарегистрировать провайдер + упростить isSupported()**

В `app/app/Services/Reports/ReportGeneratorRegistry.php`:

Добавить `use`:

```php
use App\Services\Reports\Providers\BillingSummaryProvider;
```

В конструкторе добавить параметр после `SourcesSummaryProvider $sourcesSummary`:

```php
        private readonly BillingSummaryProvider $billingSummary,
```

В `provider()` добавить ветку:

```php
            'billing_summary' => $this->billingSummary,
```

Заменить весь метод `isSupported()` (теперь реализованы все 4 типа — whitelist `$supportedTypes` избыточен) на:

```php
    public function isSupported(string $type, string $format): bool
    {
        // Все 4 типа ReportJob::TYPES реализованы (F1, 2026-05-16).
        // PDF валидируется, но PdfStubFormatter кидает RuntimeException →
        // GenerateReportJob ловит → failed-job (intended, Post-MVP).
        return in_array($type, ReportJob::TYPES, true)
            && in_array($format, ReportJob::FORMATS, true);
    }
```

- [ ] **Step 5: Запустить тест провайдера — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/BillingSummaryProviderTest.php`
Expected: PASS — 5 passed.

- [ ] **Step 6: Добавить интеграционный тест в ReportJobControllerTest**

В конец `app/tests/Feature/Reports/ReportJobControllerTest.php` добавить (после `use` уже импортирован `BalanceTransaction`? — нет; добавить `use App\Models\BalanceTransaction;` в шапку файла рядом с прочими `use`):

```php
test('POST /api/reports/jobs (sync queue): billing_summary → done с CSV', function () {
    config()->set('queue.default', 'sync');

    BalanceTransaction::create([
        'tenant_id' => $this->tenant->id,
        'type' => 'topup',
        'amount_rub' => 5000,
        'amount_leads' => 0,
        'description' => 'test topup',
        'created_at' => Carbon::now()->startOfMonth()->addDays(5),
    ]);

    $response = $this->postJson('/api/reports/jobs', [
        'type' => 'billing_summary', 'format' => 'csv',
        'parameters' => [
            'date_from' => Carbon::now()->startOfMonth()->toDateString(),
            'date_to' => Carbon::now()->endOfMonth()->toDateString(),
        ],
    ]);

    $response->assertStatus(201);
    $job = ReportJob::find($response->json('job.id'));
    expect($job->status)->toBe('done');
    expect($job->file_path)->toEndWith('.csv');

    $content = Storage::disk('local')->get($job->file_path);
    expect($content)->toContain('Тип операции');
    expect($content)->toContain('Пополнение');
});
```

- [ ] **Step 7: Запустить ReportJobControllerTest — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/ReportJobControllerTest.php`
Expected: PASS.

- [ ] **Step 8: Pint + commit**

```bash
cd app && composer pint
git add app/app/Services/Reports/Providers/BillingSummaryProvider.php app/app/Services/Reports/ReportGeneratorRegistry.php app/tests/Feature/Reports/BillingSummaryProviderTest.php app/tests/Feature/Reports/ReportJobControllerTest.php
git commit -m "feat(reports): BillingSummaryProvider + isSupported всех 4 типов (F1 закрыт)"
```

---

### Task 4: Download endpoint + signed URL (F2 backend)

**Files:**

- Modify: `app/app/Http/Controllers/Api/ReportJobController.php` — +метод `download()`, `toResource()` +`download_url`
- Modify: `app/routes/web.php` — +route `reports.download`
- Create: `app/tests/Feature/Reports/ReportDownloadTest.php`

**Дизайн F2:** route `GET /api/reports/jobs/{id}/file` — НЕ под `auth:sanctum`, а под `signed`-middleware. Подпись URL (24 ч) = capability-token: signed URL генерируется только в `toResource()` при листинге/просмотре отчётов СВОЕГО тенанта, подделать подпись для чужого `id` нельзя. Параметр `tenant` в подписи нужен для RLS-контекста (`download` работает без авторизованного `user()`); подпись покрывает все query-параметры, `tenant` подделать нельзя.

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

Создать `app/tests/Feature/Reports/ReportDownloadTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\ReportJob;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    Storage::fake('local');
});

function doneReportJob(int $tenantId, int $userId, array $overrides = []): ReportJob
{
    return ReportJob::create(array_merge([
        'tenant_id' => $tenantId,
        'user_id' => $userId,
        'type' => 'deals_export',
        'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
        'status' => ReportJob::STATUS_DONE,
        'file_path' => null,
        'file_size' => null,
        'finished_at' => Carbon::now(),
        'expires_at' => Carbon::now()->addDays(30),
    ], $overrides));
}

function signedDownloadUrl(ReportJob $job, ?Carbon $expiry = null): string
{
    return URL::temporarySignedRoute(
        'reports.download',
        $expiry ?? Carbon::now()->addHours(24),
        ['id' => $job->id, 'tenant' => $job->tenant_id],
    );
}

test('download: success → 200 + attachment файла', function () {
    $path = "reports/{$this->tenant->id}/1.csv";
    Storage::disk('local')->put($path, "col\r\nval\r\n");
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);

    $response = $this->get(signedDownloadUrl($job));

    $response->assertOk();
    $response->assertDownload("report-{$job->id}.csv");
});

test('download: невалидная подпись → 403', function () {
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/x/1.csv']);

    $this->get("/api/reports/jobs/{$job->id}/file?tenant={$this->tenant->id}&expires=9999999999&signature=deadbeef")
        ->assertStatus(403);
});

test('download: просроченная подпись → 403', function () {
    $path = "reports/{$this->tenant->id}/2.csv";
    Storage::disk('local')->put($path, 'data');
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);

    $this->get(signedDownloadUrl($job, Carbon::now()->subHour()))->assertStatus(403);
});

test('download: file_path=NULL (истёк) → 410', function () {
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => null]);

    $this->get(signedDownloadUrl($job))->assertStatus(410);
});

test('download: файл отсутствует на диске → 404', function () {
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/missing/9.csv']);

    $this->get(signedDownloadUrl($job))->assertStatus(404);
});

test('download: несуществующий job → 404', function () {
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/x/1.csv']);
    $url = signedDownloadUrl($job);
    // Подменяем id на несуществующий, не ломая подпись: подпись станет
    // невалидной → 403. Для чистой проверки 404 строим подпись на чужой id.
    $ghost = new ReportJob(['id' => 999999, 'tenant_id' => $this->tenant->id]);
    $ghost->id = 999999;
    $this->get(signedDownloadUrl($ghost))->assertStatus(404);
});

test('toResource (GET /jobs/{id}): done-job содержит download_url', function () {
    $path = "reports/{$this->tenant->id}/3.csv";
    Storage::disk('local')->put($path, 'data');
    $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]);

    $response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");

    $response->assertOk();
    $url = $response->json('job.download_url');
    expect($url)->toContain("/api/reports/jobs/{$job->id}/file");
    expect($url)->toContain('signature=');
});

test('toResource: pending-job → download_url = null', function () {
    $job = ReportJob::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'type' => 'deals_export',
        'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
        'status' => ReportJob::STATUS_PENDING,
    ]);

    $response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}");

    expect($response->json('job.download_url'))->toBeNull();
});
```

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

Run: `cd app && php artisan test tests/Feature/Reports/ReportDownloadTest.php`
Expected: FAIL — route `reports.download` не существует (`Route [reports.download] not defined`).

- [ ] **Step 3: Добавить route в web.php**

В `app/routes/web.php` сразу ПОСЛЕ группы `/api/reports/jobs` (после строки 72 `});`) добавить:

```php
// F2 (audit): скачивание готового файла отчёта по signed URL (24 ч, OPEN-И-20).
// НЕ под auth:sanctum — подпись URL = capability-token (генерируется только
// в ReportJobController::toResource() для отчётов своего тенанта).
Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')
    ->where('id', '[0-9]+')
    ->name('reports.download')
    ->middleware('signed');
```

- [ ] **Step 4: Добавить метод download() + download_url в toResource()**

В `app/app/Http/Controllers/Api/ReportJobController.php`:

Добавить `use` в шапку (рядом с прочими `use Illuminate\...`):

```php
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
```

Добавить метод `download()` перед приватным `toResource()`:

```php
    /**
     * GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= — скачать
     * готовый файл отчёта (F2, OPEN-И-20).
     *
     * Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token.
     * `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а).
     * Подпись покрывает все query-параметры — `tenant`/`id` подделать нельзя.
     */
    public function download(Request $request, int $id): Response
    {
        $tenantId = (int) $request->query('tenant', '0');

        return DB::transaction(function () use ($id, $tenantId): Response {
            DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

            $job = ReportJob::query()
                ->where('id', $id)
                ->where('tenant_id', $tenantId)
                ->first();

            if ($job === null) {
                return response()->json(['message' => 'Отчёт не найден.'], 404);
            }

            if ($job->status !== ReportJob::STATUS_DONE || $job->file_path === null) {
                return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410);
            }

            if (! Storage::disk('local')->exists($job->file_path)) {
                return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404);
            }

            $extension = pathinfo($job->file_path, PATHINFO_EXTENSION);

            return Storage::disk('local')->download(
                $job->file_path,
                sprintf('report-%d.%s', $job->id, $extension)
            );
        });
    }

    /**
     * Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или
     * после истечения retention (file_path обнулён cron'ом reports:cleanup-expired).
     */
    private function downloadUrl(ReportJob $job): ?string
    {
        if ($job->status !== ReportJob::STATUS_DONE || $job->file_path === null) {
            return null;
        }

        return URL::temporarySignedRoute(
            'reports.download',
            Carbon::now()->addHours(24),
            ['id' => $job->id, 'tenant' => $job->tenant_id],
        );
    }
```

В методе `toResource()` добавить ключ `download_url` (например, после `'file_path' => $job->file_path,`):

```php
            'file_path' => $job->file_path,
            'download_url' => $this->downloadUrl($job),
```

- [ ] **Step 5: Запустить тест — зелёный**

Run: `cd app && php artisan test tests/Feature/Reports/ReportDownloadTest.php`
Expected: PASS — 8 passed.

- [ ] **Step 6: Прогнать весь Reports-каталог (регрессия toResource)**

Run: `cd app && php artisan test tests/Feature/Reports/`
Expected: PASS — добавление ключа `download_url` в ответ не ломает существующие тесты (они проверяют конкретные ключи через `expect()->json('...')`).

- [ ] **Step 7: Pint + Larastan + commit**

```bash
cd app && composer pint && composer stan
git add app/app/Http/Controllers/Api/ReportJobController.php app/routes/web.php app/tests/Feature/Reports/ReportDownloadTest.php
git commit -m "feat(reports): download endpoint + signed URL 24ч (F2 backend)"
```

Если Larastan даёт новые ошибки на `download()` (возвращаемый тип `Response`) — убедиться, что импортирован `Symfony\Component\HttpFoundation\Response` (базовый класс и `JsonResponse`, и `StreamedResponse`).

---

### Task 5: Провести download_url на frontend и подключить кнопку «Скачать» (F2 frontend)

**Files:**

- Modify: `app/resources/js/api/reports.ts`
- Modify: `app/resources/js/composables/mockReports.ts`
- Modify: `app/resources/js/composables/reportsMapper.ts`
- Modify: `app/resources/js/components/reports/ReportJobsList.vue`
- Modify: `app/tests/Frontend/reportsMapper.spec.ts`
- Modify: `app/tests/Frontend/reports-api.spec.ts`
- Modify: `app/tests/Frontend/ReportsView.spec.ts`

- [ ] **Step 1: Обновить fixtures + написать падающие тесты**

В `app/tests/Frontend/reportsMapper.spec.ts` — в функции `makeApi` добавить поле в объект (после `is_expired: false,`):

```js
        download_url: 'http://localhost/api/reports/jobs/1/file?tenant=1&signature=fake',
```

И добавить новый тест в `describe('reportsMapper', ...)`:

```js
    it('downloadUrl берётся из api.download_url', () => {
        const ui = mapApiReportJob(makeApi({ download_url: 'http://x/file?signature=abc' }));
        expect(ui.downloadUrl).toBe('http://x/file?signature=abc');
    });

    it('downloadUrl = null если api.download_url null', () => {
        expect(mapApiReportJob(makeApi({ download_url: null })).downloadUrl).toBeNull();
    });
```

В `app/tests/Frontend/reports-api.spec.ts` — в объект `FAKE_JOB` добавить (после `is_expired: false,`):

```js
    download_url: null,
```

В `app/tests/Frontend/ReportsView.spec.ts` — в функции `makeApiJob` добавить в объект (после `is_expired: false,`):

```js
        download_url: 'http://localhost/api/reports/jobs/1/file?tenant=1&signature=fake',
```

И добавить новый тест в `describe('ReportsView.vue (API integration)', ...)`:

```js
    it('done-job: кнопка «Скачать» имеет href из download_url', async () => {
        (reportsApi.listReportJobs as ReturnType<typeof vi.fn>).mockResolvedValue({
            jobs: [makeApiJob({ status: 'done', download_url: 'http://localhost/dl?signature=xyz' })],
            total: 1,
            limit: 50,
            offset: 0,
            counts: { pending: 0, processing: 0, done: 1, failed: 0 },
            quota: { active: 0, max_active: 3 },
        } satisfies ListReportJobsResponse);
        const wrapper = await mountView();
        const dl = wrapper.find('[aria-label="Скачать"]');
        expect(dl.exists()).toBe(true);
        expect(dl.attributes('href')).toBe('http://localhost/dl?signature=xyz');
    });
```

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

Run: `cd app && npm run test:vue -- reportsMapper reports-api ReportsView`
Expected: FAIL — TS-ошибки (`download_url` отсутствует в типе `ApiReportJob`, `downloadUrl` — в `ReportJob`) и упавший тест про href.

- [ ] **Step 3: Добавить download_url в ApiReportJob**

В `app/resources/js/api/reports.ts` в интерфейс `ApiReportJob` добавить поле (после `file_path: string | null;`):

```ts
    file_path: string | null;
    download_url: string | null;
```

- [ ] **Step 4: Добавить downloadUrl в UI-тип ReportJob**

В `app/resources/js/composables/mockReports.ts` в интерфейс `ReportJob` добавить поле (после `error: string | null;`):

```ts
    error: string | null;
    downloadUrl: string | null; // signed URL (24ч) скачивания готового файла; null для не-готовых
```

- [ ] **Step 5: Прокинуть downloadUrl в mapApiReportJob**

В `app/resources/js/composables/reportsMapper.ts` в объект, возвращаемый `mapApiReportJob`, добавить поле (после `error: api.error_message,`):

```ts
        error: api.error_message,
        downloadUrl: api.download_url,
```

- [ ] **Step 6: Подключить href на кнопку «Скачать»**

В `app/resources/js/components/reports/ReportJobsList.vue` заменить блок кнопки скачивания:

```vue
                    <v-btn
                        v-if="job.status === 'done'"
                        icon="mdi-download"
                        variant="text"
                        size="small"
                        aria-label="Скачать"
                        :data-testid="`download-${job.id}`"
                    />
```

на:

```vue
                    <v-btn
                        v-if="job.status === 'done' && job.downloadUrl"
                        :href="job.downloadUrl"
                        icon="mdi-download"
                        variant="text"
                        size="small"
                        aria-label="Скачать"
                        :data-testid="`download-${job.id}`"
                    />
```

(`v-btn` с `href` рендерится как `<a>`; ответ сервера отдаёт `Content-Disposition: attachment` → браузер скачивает файл без ухода со страницы.)

- [ ] **Step 7: Запустить тесты — зелёные**

Run: `cd app && npm run test:vue -- reportsMapper reports-api ReportsView`
Expected: PASS — включая новые тесты про `downloadUrl` и href.

- [ ] **Step 8: type-check + lint + commit**

```bash
cd app && npm run type-check && npm run lint:vue
git add app/resources/js/api/reports.ts app/resources/js/composables/mockReports.ts app/resources/js/composables/reportsMapper.ts app/resources/js/components/reports/ReportJobsList.vue app/tests/Frontend/reportsMapper.spec.ts app/tests/Frontend/reports-api.spec.ts app/tests/Frontend/ReportsView.spec.ts
git commit -m "feat(reports): кнопка «Скачать» → signed download URL (F2 frontend)"
```

---

### Финальная верификация (после всех задач)

- [ ] **Полный backend-прогон:**

Run: `cd app && php artisan test --parallel`
Expected: PASS — 0 failed (новые provider/download тесты + регрессия). Failure'ы выписать с file:line.

- [ ] **Полный frontend-прогон:**

Run: `cd app && npm run test:vue`
Expected: PASS — 0 failed.

- [ ] **Type-check + линтеры:**

Run: `cd app && npm run type-check && npm run lint:vue && composer pint -- --test && composer stan`
Expected: vue-tsc 0 ошибок, ESLint 0, Pint clean, Larastan 0 новых.

- [ ] **Pre-commit перед финалом не нужен — каждая задача коммитилась отдельно.** Финальный код-ревью всей реализации через subagent-driven-development final reviewer.

---

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

**1. Spec coverage:**

- F1 «Reports Provider'ы — managers/sources/billing summary» → Tasks 1, 2, 3. ✅ Все 3 недостающих провайдера + регистрация в Registry + интеграционные тесты.
- F2 «Reports Скачать-файл endpoint `/api/reports/jobs/{id}/file` + signed URL 24ч» → Task 4 (backend: route + `download()` + `download_url`) + Task 5 (frontend: проводка + кнопка). ✅
- F3 (bell deep-link) — закрыт в Sprint 3B, вне 3C. ✅ Не дублируется.

**2. Placeholder scan:** Код полный в каждом шаге; команды с ожидаемым выводом; тест-код приведён целиком. Нет «TODO»/«fill in». ✅

**3. Type consistency:**

- `ReportDataProvider` сигнатуры (`headers()`, `rows(ReportJob)`, `slug()`) — соблюдены во всех 3 провайдерах.
- `ReportGeneratorRegistry` конструктор расширяется инкрементально (Task 1→2→3), порядок параметров фиксирован: `dealsExport, managersSummary, sourcesSummary, billingSummary, csv, xlsx, json, pdf`.
- `download_url` (snake_case API) ↔ `downloadUrl` (camelCase UI) — конверсия в `mapApiReportJob`, совпадает в типах `ApiReportJob`/`ReportJob`.
- Route name `reports.download` — одинаков в `web.php`, `downloadUrl()` и тестах.

**Замечания:**

- Добавление required-поля `download_url` в `ApiReportJob` ломает TS-компиляцию 3 fixture-функций (`makeApi`, `FAKE_JOB`, `makeApiJob`) — Task 5 Step 1 явно их чинит.
- Существующий тест `managers_summary не реализован → failed` становится неверным после Task 1 — Task 1 Step 6 его переписывает.
