# Sprint 6 Phase A: Reports Backend 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:** Extend Reports backend with 3 new providers (managers\_summary, sources\_summary, billing\_summary), S3 abstraction via `reports` disk, and file download endpoint.

**Architecture:** Three new `ReportDataProvider` implementations follow the `DealsExportProvider` pattern (`DB::transaction` + `SET LOCAL` + join queries). `ReportGeneratorRegistry` gains a `SUPPORTED_TYPES` private constant and 3 new injected providers. S3 abstraction adds a `reports` disk to `config/filesystems.php` driven by `REPORT_DISK` env var; every `Storage::disk('local')` in the Reports subsystem is replaced with `Storage::disk('reports')`. The download endpoint streams a stored file via `response()->streamDownload()`.

**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16 (deals/users/projects/suppliers/supplier\_lead\_costs/balance\_transactions tables), `Illuminate\Support\Facades\Storage`

> **Note on column names:** The design spec SQL used `d.assigned_user_id` and `bt.transaction_type`/`bt.balance_after_rub` — actual schema columns are `deals.manager_id`, `balance_transactions.type`, and `balance_transactions.balance_rub_after`. This plan uses the real names.
>
> **Note on test dates:** `deals` and `supplier_lead_costs` are partitioned; earliest partition is `2026-05-01`. All test data uses May 2026 dates.

---

## File Map

**New files:**

- `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`
- `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`
- `app/app/Services/Reports/Providers/BillingSummaryProvider.php`
- `app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php`
- `app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php`
- `app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php`
- `app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php`

**Modified files:**

- `app/app/Services/Reports/ReportGeneratorRegistry.php` — add 3 providers + `SUPPORTED_TYPES` const
- `app/config/filesystems.php` — add `reports` disk
- `.env.example` — add `REPORT_DISK=local`
- `app/app/Jobs/GenerateReportJob.php:82` — `disk('local')` → `disk('reports')`
- `app/app/Http/Controllers/Api/ReportJobController.php` — `disk('local')` → `disk('reports')` in `destroy()`; add `download()` method; add `StreamedResponse` import
- `app/app/Console/Commands/ReportsCleanupExpired.php:71` — `disk('local')` → `disk('reports')`
- `app/tests/Feature/Reports/ReportJobControllerTest.php` — `fake('local')` → `fake('reports')`
- `app/tests/Feature/Reports/ReportLifecycleTest.php` — `fake('local')` → `fake('reports')`; `disk('local')` → `disk('reports')` in test bodies
- `app/routes/web.php` — add `GET /{id}/file` route inside reports group

---

## Task 1: ManagersSummaryProvider

**Files:**

- Create: `app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php`
- Create: `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

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\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->provider = app(ManagersSummaryProvider::class);
    $this->job = ReportJob::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'type' => 'managers_summary',
        'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'],
        'status' => ReportJob::STATUS_PENDING,
    ]);
});

test('slug() == managers_summary', function () {
    expect($this->provider->slug())->toBe('managers_summary');
});

test('headers() returns 7 columns', function () {
    $headers = $this->provider->headers();
    expect($headers)->toHaveCount(7);
    expect($headers[0])->toBe('Менеджер');
    expect($headers[6])->toBe('Средняя стоимость ₽');
});

test('rows() returns empty array for period with no deals', function () {
    $rows = $this->provider->rows($this->job);
    expect($rows)->toBe([]);
});

test('rows() returns manager stats with conversion and revenue', function () {
    $manager = User::factory()->create(['tenant_id' => $this->tenant->id]);

    $projectId = DB::table('projects')->insertGetId([
        'tenant_id' => $this->tenant->id,
        'name' => 'Test Project',
    ]);

    $supplierId = DB::table('suppliers')->insertGetId([
        'code' => 'test-mgr-s1',
        'name' => 'Test Supplier',
        'accepts_types' => '{websites}',
        'cost_rub' => 100,
    ]);

    $d1ReceivedAt = '2026-05-10 10:00:00';
    $d2ReceivedAt = '2026-05-15 10:00:00';
    $d3ReceivedAt = '2026-05-20 10:00:00';

    $deal1 = DB::table('deals')->insertGetId([
        'tenant_id' => $this->tenant->id,
        'project_id' => $projectId,
        'phone' => '+70001234501',
        'manager_id' => $manager->id,
        'status' => 'accepted',
        'received_at' => $d1ReceivedAt,
    ]);
    $deal2 = DB::table('deals')->insertGetId([
        'tenant_id' => $this->tenant->id,
        'project_id' => $projectId,
        'phone' => '+70001234502',
        'manager_id' => $manager->id,
        'status' => 'accepted',
        'received_at' => $d2ReceivedAt,
    ]);
    $deal3 = DB::table('deals')->insertGetId([
        'tenant_id' => $this->tenant->id,
        'project_id' => $projectId,
        'phone' => '+70001234503',
        'manager_id' => $manager->id,
        'status' => 'rejected',
        'received_at' => $d3ReceivedAt,
    ]);

    DB::table('supplier_lead_costs')->insert([
        ['deal_id' => $deal1, 'received_at' => $d1ReceivedAt, 'supplier_id' => $supplierId, 'cost_rub' => 1000],
        ['deal_id' => $deal2, 'received_at' => $d2ReceivedAt, 'supplier_id' => $supplierId, 'cost_rub' => 2000],
    ]);

    DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenant->id);

    $rows = $this->provider->rows($this->job);

    expect($rows)->toHaveCount(1);
    $row = $rows[0];
    expect($row[1])->toBe(3);           // total
    expect($row[2])->toBe(2);           // accepted
    expect($row[3])->toBe(1);           // rejected
    expect((float) $row[4])->toBe(66.7); // conversion %
    expect((float) $row[5])->toBe(3000.0); // revenue_rub
});
```

Save to `app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php`.

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

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php --no-coverage
```

Expected: FAIL — `Class "App\Services\Reports\Providers\ManagersSummaryProvider" not found`

- [ ] **Step 3: Write ManagersSummaryProvider**

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports\Providers;

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

class ManagersSummaryProvider implements ReportDataProvider
{
    public function slug(): string
    {
        return 'managers_summary';
    }

    public function headers(): array
    {
        return [
            'Менеджер',
            'Всего заявок',
            'Принято',
            'Отклонено',
            'Конверсия %',
            'Выручка ₽',
            'Средняя стоимость ₽',
        ];
    }

    public function rows(ReportJob $job): array
    {
        $params = $job->parameters ?? [];
        $dateFrom = $params['date_from'];
        $dateTo = $params['date_to'] . ' 23:59:59';

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

            return DB::table('deals as d')
                ->leftJoin('users as u', 'u.id', '=', 'd.manager_id')
                ->leftJoin('supplier_lead_costs as slc', 'slc.deal_id', '=', 'd.id')
                ->where('d.tenant_id', $job->tenant_id)
                ->whereNull('d.deleted_at')
                ->whereBetween('d.received_at', [$dateFrom, $dateTo])
                ->groupBy('u.id', 'u.first_name', 'u.last_name', 'u.email')
                ->orderByDesc(DB::raw('COUNT(d.id)'))
                ->select([
                    DB::raw("COALESCE(u.first_name || ' ' || u.last_name, u.email) AS manager"),
                    DB::raw('COUNT(d.id) AS total'),
                    DB::raw("COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted"),
                    DB::raw("COUNT(d.id) FILTER (WHERE d.status = 'rejected') AS rejected"),
                    DB::raw("ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion"),
                    DB::raw('COALESCE(SUM(slc.cost_rub), 0) AS revenue_rub'),
                    DB::raw('ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub'),
                ])
                ->get()
                ->map(fn ($row): array => [
                    $row->manager,
                    (int) $row->total,
                    (int) $row->accepted,
                    (int) $row->rejected,
                    (float) $row->conversion,
                    (float) $row->revenue_rub,
                    $row->avg_cost_rub !== null ? (float) $row->avg_cost_rub : null,
                ])
                ->all();
        });
    }
}
```

Save to `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`.

- [ ] **Step 4: Run test to verify it passes**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php --no-coverage
```

Expected: PASS (4 tests)

- [ ] **Step 5: Commit**

```bash
cd app && ./vendor/bin/pint app/Services/Reports/Providers/ManagersSummaryProvider.php
git add app/app/Services/Reports/Providers/ManagersSummaryProvider.php app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php
git commit -m "feat(reports): ManagersSummaryProvider — managers summary report"
```

---

## Task 2: SourcesSummaryProvider

**Files:**

- Create: `app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php`
- Create: `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

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

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->provider = app(SourcesSummaryProvider::class);
    $this->job = ReportJob::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'type' => 'sources_summary',
        'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'],
        'status' => ReportJob::STATUS_PENDING,
    ]);
});

test('slug() == sources_summary', function () {
    expect($this->provider->slug())->toBe('sources_summary');
});

test('headers() returns 6 columns', function () {
    $headers = $this->provider->headers();
    expect($headers)->toHaveCount(6);
    expect($headers[0])->toBe('Источник');
    expect($headers[5])->toBe('Среднее ₽');
});

test('rows() returns empty array for period with no deals', function () {
    expect($this->provider->rows($this->job))->toBe([]);
});

test('rows() aggregates deals by source (project) with conversion', function () {
    $projectId = DB::table('projects')->insertGetId([
        'tenant_id' => $this->tenant->id,
        'name' => 'Source Alpha',
    ]);

    $supplierId = DB::table('suppliers')->insertGetId([
        'code' => 'test-src-s1',
        'name' => 'Test Supplier',
        'accepts_types' => '{websites}',
        'cost_rub' => 100,
    ]);

    $d1ReceivedAt = '2026-05-10 10:00:00';
    $d2ReceivedAt = '2026-05-15 10:00:00';

    $deal1 = DB::table('deals')->insertGetId([
        'tenant_id' => $this->tenant->id,
        'project_id' => $projectId,
        'phone' => '+70001234601',
        'status' => 'accepted',
        'received_at' => $d1ReceivedAt,
    ]);
    DB::table('deals')->insert([
        'tenant_id' => $this->tenant->id,
        'project_id' => $projectId,
        'phone' => '+70001234602',
        'status' => 'rejected',
        'received_at' => $d2ReceivedAt,
    ]);

    DB::table('supplier_lead_costs')->insert([
        'deal_id' => $deal1, 'received_at' => $d1ReceivedAt, 'supplier_id' => $supplierId, 'cost_rub' => 500,
    ]);

    DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenant->id);

    $rows = $this->provider->rows($this->job);

    expect($rows)->toHaveCount(1);
    $row = $rows[0];
    expect($row[0])->toBe('Source Alpha');
    expect($row[1])->toBe(2);             // total
    expect($row[2])->toBe(1);             // accepted
    expect((float) $row[3])->toBe(50.0);  // conversion %
    expect((float) $row[4])->toBe(500.0); // total_rub
});
```

Save to `app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php`.

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

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php --no-coverage
```

Expected: FAIL — `Class "App\Services\Reports\Providers\SourcesSummaryProvider" not found`

- [ ] **Step 3: Write SourcesSummaryProvider**

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports\Providers;

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

class SourcesSummaryProvider implements ReportDataProvider
{
    public function slug(): string
    {
        return 'sources_summary';
    }

    public function headers(): array
    {
        return [
            'Источник',
            'Всего заявок',
            'Принято',
            'Конверсия %',
            'Итого ₽',
            'Среднее ₽',
        ];
    }

    public function rows(ReportJob $job): array
    {
        $params = $job->parameters ?? [];
        $dateFrom = $params['date_from'];
        $dateTo = $params['date_to'] . ' 23:59:59';

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

            return DB::table('deals as d')
                ->leftJoin('projects as p', 'p.id', '=', 'd.project_id')
                ->leftJoin('supplier_lead_costs as slc', 'slc.deal_id', '=', 'd.id')
                ->where('d.tenant_id', $job->tenant_id)
                ->whereNull('d.deleted_at')
                ->whereBetween('d.received_at', [$dateFrom, $dateTo])
                ->groupBy('p.id', 'p.name')
                ->orderByDesc(DB::raw('COUNT(d.id)'))
                ->select([
                    DB::raw('p.name AS source'),
                    DB::raw('COUNT(d.id) AS total'),
                    DB::raw("COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted"),
                    DB::raw("ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion"),
                    DB::raw('COALESCE(SUM(slc.cost_rub), 0) AS total_rub'),
                    DB::raw('ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub'),
                ])
                ->get()
                ->map(fn ($row): array => [
                    $row->source,
                    (int) $row->total,
                    (int) $row->accepted,
                    (float) $row->conversion,
                    (float) $row->total_rub,
                    $row->avg_cost_rub !== null ? (float) $row->avg_cost_rub : null,
                ])
                ->all();
        });
    }
}
```

Save to `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`.

- [ ] **Step 4: Run test to verify it passes**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php --no-coverage
```

Expected: PASS (4 tests)

- [ ] **Step 5: Commit**

```bash
cd app && ./vendor/bin/pint app/Services/Reports/Providers/SourcesSummaryProvider.php
git add app/app/Services/Reports/Providers/SourcesSummaryProvider.php app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php
git commit -m "feat(reports): SourcesSummaryProvider — deals by source report"
```

---

## Task 3: BillingSummaryProvider

**Files:**

- Create: `app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php`
- Create: `app/app/Services/Reports/Providers/BillingSummaryProvider.php`

> Note: `balance_transactions` schema columns are `type` (not `transaction_type`) and `balance_rub_after` (not `balance_after_rub`). The headers stay human-friendly.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

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

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->provider = app(BillingSummaryProvider::class);
    $this->job = ReportJob::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'type' => 'billing_summary',
        'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'],
        'status' => ReportJob::STATUS_PENDING,
    ]);
});

test('slug() == billing_summary', function () {
    expect($this->provider->slug())->toBe('billing_summary');
});

test('headers() returns 5 columns', function () {
    $headers = $this->provider->headers();
    expect($headers)->toHaveCount(5);
    expect($headers[0])->toBe('Дата');
    expect($headers[4])->toBe('Описание');
});

test('rows() returns empty array for period with no transactions', function () {
    expect($this->provider->rows($this->job))->toBe([]);
});

test('rows() returns billing transactions in chronological order', function () {
    DB::table('balance_transactions')->insert([
        [
            'tenant_id' => $this->tenant->id,
            'type' => 'topup',
            'amount_rub' => 5000,
            'balance_rub_after' => 5000,
            'description' => 'Пополнение счёта',
            'created_at' => '2026-05-05 12:00:00',
        ],
        [
            'tenant_id' => $this->tenant->id,
            'type' => 'lead_charge',
            'amount_rub' => -200,
            'balance_rub_after' => 4800,
            'description' => 'Списание за лид',
            'created_at' => '2026-05-10 09:00:00',
        ],
    ]);

    // Another tenant's transaction — must be excluded
    $other = Tenant::factory()->create();
    DB::table('balance_transactions')->insert([
        'tenant_id' => $other->id,
        'type' => 'topup',
        'amount_rub' => 1000,
        'balance_rub_after' => 1000,
        'description' => 'Other tenant',
        'created_at' => '2026-05-07 10:00:00',
    ]);

    DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenant->id);

    $rows = $this->provider->rows($this->job);

    expect($rows)->toHaveCount(2);
    expect($rows[0][1])->toBe('topup');
    expect((float) $rows[0][2])->toBe(5000.0);
    expect($rows[1][1])->toBe('lead_charge');
    expect((float) $rows[1][2])->toBe(-200.0);
});
```

Save to `app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php`.

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

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/Providers/BillingSummaryProviderTest.php --no-coverage
```

Expected: FAIL — `Class "App\Services\Reports\Providers\BillingSummaryProvider" not found`

- [ ] **Step 3: Write BillingSummaryProvider**

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports\Providers;

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

class BillingSummaryProvider implements ReportDataProvider
{
    public function slug(): string
    {
        return 'billing_summary';
    }

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

    public function rows(ReportJob $job): array
    {
        $params = $job->parameters ?? [];
        $dateFrom = $params['date_from'];
        $dateTo = $params['date_to'] . ' 23:59:59';

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

            return DB::table('balance_transactions as bt')
                ->where('bt.tenant_id', $job->tenant_id)
                ->whereBetween('bt.created_at', [$dateFrom, $dateTo])
                ->orderBy('bt.created_at')
                ->select([
                    DB::raw('bt.created_at::date AS date'),
                    'bt.type',
                    'bt.amount_rub',
                    'bt.balance_rub_after',
                    'bt.description',
                ])
                ->get()
                ->map(fn ($row): array => [
                    $row->date,
                    $row->type,
                    (float) $row->amount_rub,
                    $row->balance_rub_after !== null ? (float) $row->balance_rub_after : null,
                    $row->description,
                ])
                ->all();
        });
    }
}
```

Save to `app/app/Services/Reports/Providers/BillingSummaryProvider.php`.

- [ ] **Step 4: Run test to verify it passes**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/Providers/BillingSummaryProviderTest.php --no-coverage
```

Expected: PASS (4 tests)

- [ ] **Step 5: Commit**

```bash
cd app && ./vendor/bin/pint app/Services/Reports/Providers/BillingSummaryProvider.php
git add app/app/Services/Reports/Providers/BillingSummaryProvider.php app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php
git commit -m "feat(reports): BillingSummaryProvider — billing transactions report"
```

---

## Task 4: Extend ReportGeneratorRegistry

**Files:**

- Create: `app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php`
- Modify: `app/app/Services/Reports/ReportGeneratorRegistry.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

use App\Services\Reports\Providers\BillingSummaryProvider;
use App\Services\Reports\Providers\DealsExportProvider;
use App\Services\Reports\Providers\ManagersSummaryProvider;
use App\Services\Reports\Providers\SourcesSummaryProvider;
use App\Services\Reports\ReportGeneratorRegistry;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

test('provider() resolves deals_export', function () {
    expect(app(ReportGeneratorRegistry::class)->provider('deals_export'))
        ->toBeInstanceOf(DealsExportProvider::class);
});

test('provider() resolves managers_summary', function () {
    expect(app(ReportGeneratorRegistry::class)->provider('managers_summary'))
        ->toBeInstanceOf(ManagersSummaryProvider::class);
});

test('provider() resolves sources_summary', function () {
    expect(app(ReportGeneratorRegistry::class)->provider('sources_summary'))
        ->toBeInstanceOf(SourcesSummaryProvider::class);
});

test('provider() resolves billing_summary', function () {
    expect(app(ReportGeneratorRegistry::class)->provider('billing_summary'))
        ->toBeInstanceOf(BillingSummaryProvider::class);
});

test('provider() throws for unknown type', function () {
    expect(fn () => app(ReportGeneratorRegistry::class)->provider('unknown'))
        ->toThrow(InvalidArgumentException::class);
});

test('isSupported() true for all 4 types with csv', function () {
    $registry = app(ReportGeneratorRegistry::class);
    expect($registry->isSupported('deals_export', 'csv'))->toBeTrue();
    expect($registry->isSupported('managers_summary', 'csv'))->toBeTrue();
    expect($registry->isSupported('sources_summary', 'csv'))->toBeTrue();
    expect($registry->isSupported('billing_summary', 'csv'))->toBeTrue();
});

test('isSupported() false for unknown type', function () {
    expect(app(ReportGeneratorRegistry::class)->isSupported('unknown_type', 'csv'))->toBeFalse();
});
```

Save to `app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php`.

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

```bash
cd app && php vendor/bin/pest tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php --no-coverage
```

Expected: FAIL — `provider('managers_summary')` throws `InvalidArgumentException`

- [ ] **Step 3: Replace ReportGeneratorRegistry.php**

Replace the full content of `app/app/Services/Reports/ReportGeneratorRegistry.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Reports;

use App\Models\ReportJob;
use App\Services\Reports\Formatters\CsvFormatter;
use App\Services\Reports\Formatters\JsonFormatter;
use App\Services\Reports\Formatters\PdfStubFormatter;
use App\Services\Reports\Formatters\ReportFormatter;
use App\Services\Reports\Formatters\XlsxFormatter;
use App\Services\Reports\Providers\BillingSummaryProvider;
use App\Services\Reports\Providers\DealsExportProvider;
use App\Services\Reports\Providers\ManagersSummaryProvider;
use App\Services\Reports\Providers\ReportDataProvider;
use App\Services\Reports\Providers\SourcesSummaryProvider;
use InvalidArgumentException;

class ReportGeneratorRegistry
{
    private const SUPPORTED_TYPES = [
        'deals_export',
        'managers_summary',
        'sources_summary',
        'billing_summary',
    ];

    public function __construct(
        private readonly DealsExportProvider $dealsExport,
        private readonly ManagersSummaryProvider $managersSummary,
        private readonly SourcesSummaryProvider $sourcesSummary,
        private readonly BillingSummaryProvider $billingSummary,
        private readonly CsvFormatter $csv,
        private readonly XlsxFormatter $xlsx,
        private readonly JsonFormatter $json,
        private readonly PdfStubFormatter $pdf,
    ) {}

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

    public function formatter(string $format): ReportFormatter
    {
        return match ($format) {
            'csv' => $this->csv,
            'xlsx' => $this->xlsx,
            'json' => $this->json,
            'pdf' => $this->pdf,
            default => throw new InvalidArgumentException("Формат не поддерживается: {$format}"),
        };
    }

    public function isSupported(string $type, string $format): bool
    {
        if (! in_array($type, ReportJob::TYPES, true) || ! in_array($format, ReportJob::FORMATS, true)) {
            return false;
        }

        if (! in_array($type, self::SUPPORTED_TYPES, true)) {
            return false;
        }

        // PDF — stub: validates, но генерация даёт failed-job (intended).
        return true;
    }
}
```

- [ ] **Step 4: Run registry tests**

```bash
cd app && php vendor/bin/pest tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php --no-coverage
```

Expected: PASS (7 tests)

- [ ] **Step 5: Run full Reports suite (regression)**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/ tests/Unit/Services/Reports/ --no-coverage
```

Expected: All pass — no regressions in existing lifecycle/controller tests.

- [ ] **Step 6: Commit**

```bash
cd app && ./vendor/bin/pint app/Services/Reports/ReportGeneratorRegistry.php
git add app/app/Services/Reports/ReportGeneratorRegistry.php app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php
git commit -m "feat(reports): extend ReportGeneratorRegistry — 4 providers, SUPPORTED_TYPES const"
```

---

## Task 5: S3 Abstraction (storage disk swap)

**Files:**

- Modify: `app/config/filesystems.php`
- Modify: `.env.example`
- Modify: `app/app/Jobs/GenerateReportJob.php`
- Modify: `app/app/Http/Controllers/Api/ReportJobController.php`
- Modify: `app/app/Console/Commands/ReportsCleanupExpired.php`
- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php`
- Modify: `app/tests/Feature/Reports/ReportLifecycleTest.php`

No new tests — existing tests will break after the disk rename and pass after the fix.

- [ ] **Step 1: Baseline — confirm all Reports tests green before touching anything**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/ --no-coverage
```

Expected: All pass. Note the test count.

- [ ] **Step 2: Add `reports` disk to config/filesystems.php**

In `app/config/filesystems.php`, inside the `'disks' => [` array, add the following entry after the `'public'` disk block (before `'s3'`):

```php
'reports' => [
    'driver' => env('REPORT_DISK', 'local'),
    // driver=local: files go to storage/app/private/ (same root as 'local' disk)
    // driver=s3: picks up AWS_BUCKET + AWS_* env vars automatically
],
```

- [ ] **Step 3: Add REPORT\_DISK to .env.example**

Find the line `FILESYSTEM_DISK=local` in `.env.example` and add immediately after it:

```
REPORT_DISK=local
```

- [ ] **Step 4: Replace disk('local') in GenerateReportJob**

In `app/app/Jobs/GenerateReportJob.php` at line 82, change:

```php
Storage::disk('local')->put($relativePath, $content);
```

to:

```php
Storage::disk('reports')->put($relativePath, $content);
```

- [ ] **Step 5: Replace disk('local') in ReportJobController::destroy()**

In `app/app/Http/Controllers/Api/ReportJobController.php`, find inside the `destroy()` method:

```php
Storage::disk('local')->delete($job->file_path);
```

Change to:

```php
Storage::disk('reports')->delete($job->file_path);
```

- [ ] **Step 6: Replace disk('local') in ReportsCleanupExpired**

In `app/app/Console/Commands/ReportsCleanupExpired.php` at line 71:

```php
Storage::disk('local')->delete($job->file_path);
```

Change to:

```php
Storage::disk('reports')->delete($job->file_path);
```

- [ ] **Step 7: Fix Storage::fake in test files**

**ReportJobControllerTest.php** — in `beforeEach()`, change:

```php
Storage::fake('local');
```

to:

```php
Storage::fake('reports');
```

**ReportLifecycleTest.php** — in `beforeEach()`, change:

```php
Storage::fake('local');
```

to:

```php
Storage::fake('reports');
```

**ReportLifecycleTest.php** — in test bodies, change all `Storage::disk('local')` to `Storage::disk('reports')`. There are 5 occurrences: lines 154, 160, 193, 195, 206, 207. Do a find-replace in the file:

- `Storage::disk('local')->put(` → `Storage::disk('reports')->put(`
- `Storage::disk('local')->assertMissing(` → `Storage::disk('reports')->assertMissing(`
- `Storage::disk('local')->assertExists(` → `Storage::disk('reports')->assertExists(`

- [ ] **Step 8: Run Reports suite — confirm all green**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/ --no-coverage
```

Expected: Same count as Step 1, all pass.

- [ ] **Step 9: Run Larastan**

```bash
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M
```

Expected: 0 errors above baseline.

- [ ] **Step 10: Commit**

```bash
cd app && ./vendor/bin/pint app/Jobs/GenerateReportJob.php app/Http/Controllers/Api/ReportJobController.php app/Console/Commands/ReportsCleanupExpired.php
git add app/config/filesystems.php .env.example app/app/Jobs/GenerateReportJob.php app/app/Http/Controllers/Api/ReportJobController.php app/app/Console/Commands/ReportsCleanupExpired.php app/tests/Feature/Reports/ReportJobControllerTest.php app/tests/Feature/Reports/ReportLifecycleTest.php
git commit -m "feat(reports): S3 abstraction — reports disk, REPORT_DISK env, replace disk('local')"
```

---

## Task 6: File Download Endpoint

**Files:**

- Modify: `app/routes/web.php`
- Modify: `app/app/Http/Controllers/Api/ReportJobController.php`
- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php`

- [ ] **Step 1: Write the failing tests**

Append these tests to the end of `app/tests/Feature/Reports/ReportJobControllerTest.php`:

```php
// ------ download ------

test('GET /jobs/{id}/file: 401 без auth', function () {
    auth()->logout();
    $this->getJson('/api/reports/jobs/1/file')->assertStatus(401);
});

test('GET /jobs/{id}/file: 404 unknown job', function () {
    $this->getJson('/api/reports/jobs/999999/file')->assertStatus(404);
});

test('GET /jobs/{id}/file: 404 чужой job', function () {
    $other = Tenant::factory()->create();
    $otherUser = User::factory()->create(['tenant_id' => $other->id]);
    $job = makeReportJob($other->id, $otherUser->id, ['status' => ReportJob::STATUS_DONE]);
    $this->getJson("/api/reports/jobs/{$job->id}/file")->assertStatus(404);
});

test('GET /jobs/{id}/file: 422 если status != done', function () {
    $job = makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_PENDING]);
    $response = $this->getJson("/api/reports/jobs/{$job->id}/file");
    $response->assertStatus(422);
    expect($response->json('message'))->toBe('Report not ready');
});

test('GET /jobs/{id}/file: 410 если file_path null (expired)', function () {
    $job = makeReportJob($this->tenant->id, $this->user->id, [
        'status' => ReportJob::STATUS_DONE,
        'file_path' => null,
    ]);
    $response = $this->getJson("/api/reports/jobs/{$job->id}/file");
    $response->assertStatus(410);
    expect($response->json('message'))->toBe('Report file expired');
});

test('GET /jobs/{id}/file: 410 если файл не существует на диске', function () {
    $job = makeReportJob($this->tenant->id, $this->user->id, [
        'status' => ReportJob::STATUS_DONE,
        'file_path' => 'reports/99/99.csv',
    ]);
    // Storage::fake('reports') — файл не создан
    $this->getJson("/api/reports/jobs/{$job->id}/file")->assertStatus(410);
});

test('GET /jobs/{id}/file: 200 CSV — Content-Disposition и Content-Type', function () {
    Storage::disk('reports')->put('reports/1/42.csv', "col1,col2\nval1,val2");
    $job = makeReportJob($this->tenant->id, $this->user->id, [
        'status' => ReportJob::STATUS_DONE,
        'file_path' => 'reports/1/42.csv',
        'file_size' => 19,
        'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'],
    ]);

    $response = $this->get("/api/reports/jobs/{$job->id}/file");

    $response->assertStatus(200);
    expect($response->headers->get('Content-Type'))->toContain('text/csv');
    expect($response->headers->get('Content-Disposition'))->toContain("report-{$job->id}.csv");
});

test('GET /jobs/{id}/file: 200 XLSX — правильный Content-Type', function () {
    Storage::disk('reports')->put('reports/1/43.xlsx', 'fake-xlsx-bytes');
    $job = makeReportJob($this->tenant->id, $this->user->id, [
        'status' => ReportJob::STATUS_DONE,
        'file_path' => 'reports/1/43.xlsx',
        'parameters' => ['format' => 'xlsx', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'],
    ]);

    $response = $this->get("/api/reports/jobs/{$job->id}/file");

    $response->assertStatus(200);
    expect($response->headers->get('Content-Type'))->toContain('spreadsheetml');
});
```

- [ ] **Step 2: Run new tests to confirm they fail**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/ReportJobControllerTest.php --filter="file" --no-coverage
```

Expected: FAIL — 404 or 405 (route not registered yet)

- [ ] **Step 3: Add download route to web.php**

In `app/routes/web.php`, inside the `Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')` group, add the new route **before** the `DELETE /{id}` line:

```php
Route::get('/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')->where('id', '[0-9]+');
```

The reports group becomes:

```php
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')->group(function () {
    Route::get('/', 'App\Http\Controllers\Api\ReportJobController@index');
    Route::post('/', 'App\Http\Controllers\Api\ReportJobController@store');
    Route::get('/{id}', 'App\Http\Controllers\Api\ReportJobController@show')->where('id', '[0-9]+');
    Route::post('/{id}/retry', 'App\Http\Controllers\Api\ReportJobController@retry')->where('id', '[0-9]+');
    Route::post('/{id}/cancel', 'App\Http\Controllers\Api\ReportJobController@cancel')->where('id', '[0-9]+');
    Route::get('/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')->where('id', '[0-9]+');
    Route::delete('/{id}', 'App\Http\Controllers\Api\ReportJobController@destroy')->where('id', '[0-9]+');
});
```

- [ ] **Step 4: Add download() method and StreamedResponse import to ReportJobController**

Add `use Symfony\Component\HttpFoundation\StreamedResponse;` to the imports section of `app/app/Http/Controllers/Api/ReportJobController.php` (after the existing `use` statements).

Add the following method after the `show()` method and before `store()`:

```php
/**
 * GET /api/reports/jobs/{id}/file — скачать готовый файл отчёта.
 */
public function download(Request $request, int $id): StreamedResponse|JsonResponse
{
    /** @var User $user */
    $user = $request->user();

    $job = DB::transaction(function () use ($user, $id): ?ReportJob {
        DB::statement('SET LOCAL app.current_tenant_id = ' . (int) $user->tenant_id);

        return ReportJob::query()
            ->where('id', $id)
            ->where('tenant_id', $user->tenant_id)
            ->first();
    });

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

    if ($job->status !== ReportJob::STATUS_DONE) {
        return response()->json(['message' => 'Report not ready'], 422);
    }

    if ($job->file_path === null || ! Storage::disk('reports')->exists($job->file_path)) {
        return response()->json(['message' => 'Report file expired'], 410);
    }

    $format = (string) ($job->parameters['format'] ?? 'csv');
    $mimeType = match ($format) {
        'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        'json' => 'application/json',
        default => 'text/csv; charset=utf-8',
    };
    $contentLength = $job->file_size ?? Storage::disk('reports')->size($job->file_path);

    return response()->streamDownload(function () use ($job): void {
        $stream = Storage::disk('reports')->readStream($job->file_path);
        fpassthru($stream);
        fclose($stream);
    }, "report-{$job->id}.{$format}", [
        'Content-Type' => $mimeType,
        'Content-Length' => (string) $contentLength,
    ]);
}
```

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

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/ReportJobControllerTest.php --filter="file" --no-coverage
```

Expected: PASS (8 tests)

- [ ] **Step 6: Run full Reports suite**

```bash
cd app && php vendor/bin/pest tests/Feature/Reports/ tests/Feature/Reports/Providers/ tests/Unit/Services/Reports/ --no-coverage
```

Expected: All pass (no regressions).

- [ ] **Step 7: Run full Pest suite**

```bash
cd app && php vendor/bin/pest --no-coverage
```

Expected: All pass. Count should be 403 (previous) + new tests from Tasks 1–6.

- [ ] **Step 8: Run Larastan**

```bash
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M
```

Expected: 0 errors above baseline.

- [ ] **Step 9: Commit**

```bash
cd app && ./vendor/bin/pint app/Http/Controllers/Api/ReportJobController.php
git add app/routes/web.php app/app/Http/Controllers/Api/ReportJobController.php app/tests/Feature/Reports/ReportJobControllerTest.php
git commit -m "feat(reports): GET /api/reports/jobs/{id}/file — streaming download endpoint"
```

---

## Self-Review

**Spec coverage:**

- ✅ `ManagersSummaryProvider` — slug, 7 headers, DB query on deals/users/supplier\_lead\_costs (Task 1)
- ✅ `SourcesSummaryProvider` — slug, 6 headers, DB query on deals/projects/supplier\_lead\_costs (Task 2)
- ✅ `BillingSummaryProvider` — slug, 5 headers, DB query on balance\_transactions (Task 3)
- ✅ `ReportGeneratorRegistry` — 4 providers, `SUPPORTED_TYPES` const, `isSupported()` updated (Task 4)
- ✅ `reports` disk in `config/filesystems.php` with `REPORT_DISK` env var (Task 5)
- ✅ All `Storage::disk('local')` in Reports subsystem replaced with `Storage::disk('reports')` (Task 5)
- ✅ `.env.example` updated (Task 5)
- ✅ Both test files' `Storage::fake('local')` updated to `Storage::fake('reports')` (Task 5)
- ✅ `GET /api/reports/jobs/{id}/file` — 401/404/422/410/200 cases (Task 6)
- ✅ `Content-Disposition` and `Content-Type` headers set correctly (Task 6)

**Placeholder scan:** None. All steps contain complete code.

**Spec vs actual schema corrections applied:**

- `deals.manager_id` (not `assigned_user_id`)
- `balance_transactions.type` (not `transaction_type`)
- `balance_transactions.balance_rub_after` (not `balance_after_rub`)
- Test dates use May 2026 (partitions start `2026-05-01`)
- `supplier_lead_costs` insert includes `received_at` (partition key, must match deal's `received_at`)
