# Sprint 6 «Post-MVP backend» — Design Spec

**Версия:** 1.0  
**Дата:** 10.05.2026  
**Автор:** Claude Code (skill: superpowers:brainstorming)  
**Заказчик:** Дмитрий  
**Статус:** approved  
**Базовый HEAD:** `e5848bd` (Sprint 5 Pre-prod tooling закрыт)  
**Источники:** codebase exploration (Reports/Notifications/AdminTenantDetail), roadmap Sprint 6, memory `project_state.md`

---

## 1. Цель

Закрыть Post-MVP backend backlog, не зависящий от Б-1: расширить Reports (3 новых провайдера + файловый download + S3-абстракция), реализовать оставшиеся Notification интеграции (new_device_login + deep-link bell + ЮKassa scaffold) и урезанный AdminTenantDetail (contact_phone + role).

**Definition of done:**

- 3 новых типа отчётов генерируются и скачиваются через API
- `GET /api/reports/jobs/{id}/file` отдаёт файл с правильным Content-Disposition
- `REPORT_DISK` переменная позволяет переключиться на S3 без кода
- При логине с нового устройства пользователь получает email
- Клик по колокольчику с deal_id навигирует на нужный раздел
- `POST /api/webhooks/yukassa` принимает платёж и обновляет invoice
- `tenants.contact_phone` и `users.role` в схеме, API и Vue
- Регрессия зелёная: Pest / Vitest / Larastan / vue-tsc / ESLint

---

## 2. Non-goals

- `inn`, `legal_address` для тенантов — ждут Б-1
- Реальная интеграция ЮKassa (боевые ключи, тестовые платежи) — ждёт Б-1 / DO-2
- Push-уведомления (FCM / APNS) — Post-MVP
- PDF-формат отчётов — Post-MVP (`PdfStubFormatter` остаётся)
- Новые модули CRM (аналитика второго порядка и т.п.)

---

## 3. Phase A: Reports backend доделки

### 3.1 Три новых провайдера

Все провайдеры реализуют `App\Services\Reports\Providers\ReportDataProvider`:

```php
interface ReportDataProvider {
    public function headers(): array;
    public function rows(ReportJob $job): array;
    public function slug(): string;
}
```

Фильтры берутся из `$job->parameters` (JSONB): `date_from`, `date_to` обязательные (уже валидируются в `ReportJobController::store()`).

#### ManagersSummaryProvider

- **Slug:** `managers_summary`
- **Файл:** `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`
- **Колонки:** Менеджер | Всего заявок | Принято | Отклонено | Конверсия % | Выручка ₽ | Средняя стоимость ₽
- **Источник данных:**

  ```sql
  SELECT
    COALESCE(u.first_name || ' ' || u.last_name, u.email) AS manager,
    COUNT(d.id) AS total,
    COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted,
    COUNT(d.id) FILTER (WHERE d.status = 'rejected') AS rejected,
    ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion,
    COALESCE(SUM(slc.cost_rub), 0) AS revenue_rub,
    ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub
  FROM deals d
  LEFT JOIN users u ON u.id = d.assigned_user_id
  LEFT JOIN supplier_lead_costs slc ON slc.deal_id = d.id
  WHERE d.received_at BETWEEN :date_from AND :date_to
    AND d.deleted_at IS NULL
  GROUP BY u.id, u.first_name, u.last_name, u.email
  ORDER BY total DESC
  ```

- **Тест-кейсы:** headers() возвращает 7 колонок; rows() с данными считает конверсию; rows() на пустой период → пустой массив.

#### SourcesSummaryProvider

- **Slug:** `sources_summary`
- **Файл:** `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`
- **Колонки:** Источник | Всего заявок | Принято | Конверсия % | Итого ₽ | Среднее ₽
- **Источник данных:**

  ```sql
  SELECT
    p.name AS source,
    COUNT(d.id) AS total,
    COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted,
    ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion,
    COALESCE(SUM(slc.cost_rub), 0) AS total_rub,
    ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub
  FROM deals d
  LEFT JOIN projects p ON p.id = d.project_id
  LEFT JOIN supplier_lead_costs slc ON slc.deal_id = d.id
  WHERE d.received_at BETWEEN :date_from AND :date_to
    AND d.deleted_at IS NULL
  GROUP BY p.id, p.name
  ORDER BY total DESC
  ```

#### BillingSummaryProvider

- **Slug:** `billing_summary`
- **Файл:** `app/app/Services/Reports/Providers/BillingSummaryProvider.php`
- **Колонки:** Дата | Тип операции | Сумма ₽ | Баланс после ₽ | Описание
- **Источник данных:**

  ```sql
  SELECT
    bt.created_at::date AS date,
    bt.transaction_type,
    bt.amount_rub,
    bt.balance_after_rub,
    bt.description
  FROM balance_transactions bt
  WHERE bt.created_at BETWEEN :date_from AND :date_to
  ORDER BY bt.created_at ASC
  ```

  (RLS обеспечивает изоляцию tenant'а через `SET LOCAL app.current_tenant_id`)
- **Параметры:** только `date_from`/`date_to`, без `project_id`/`manager_id`

### 3.2 ReportGeneratorRegistry — расширение

**Файл:** `app/app/Services/Reports/ReportGeneratorRegistry.php`

Обновить `isSupported()` — добавить 3 новых типа в `SUPPORTED_TYPES` константу (или аналог):

```php
private const SUPPORTED_TYPES = [
    'deals_export',
    'managers_summary',
    'sources_summary',
    'billing_summary',
];
```

Зарегистрировать 3 новых провайдера в `provider()` switch/match — по аналогии с `DealsExportProvider`.

### 3.3 File download endpoint

**Route:** `GET /api/reports/jobs/{id}/file` (под `auth:sanctum` + `tenant` middleware)  
**Controller:** `ReportJobController::download(ReportJob $job)`  
**Файл:** `app/app/Http/Controllers/Api/ReportJobController.php` (добавить метод)

Логика:

1. Проверить принадлежность job текущему tenant (уже делает Route Model Binding + RLS, дополнительно явная проверка `$job->tenant_id === auth()->user()->tenant_id`)
2. Если `$job->status !== 'done'` → 422 с `{"message": "Report not ready"}`
3. Если `$job->file_path === null` → 410 Gone (`{"message": "Report file expired"}`)
4. Если файл физически не существует → 410 Gone
5. Определить MIME по формату из `$job->parameters['format']`:
   - csv → `text/csv; charset=utf-8`
   - xlsx → `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
   - json → `application/json`
6. `StreamedResponse` через `Storage::disk(config('filesystems.reports_disk'))->readStream($job->file_path)`
7. Заголовки: `Content-Disposition: attachment; filename="report-{$job->id}.{ext}"`, `Content-Type`, `Content-Length`

### 3.4 S3-абстракция

**Файл:** `config/filesystems.php`  
Добавить в секцию `disks`:

```php
'reports' => [
    'driver' => env('REPORT_DISK', 'local'),
    // При driver=s3 подхватит стандартные AWS_* env vars
    // При driver=local — файлы в storage/app/
],
```

**Файл:** `app/app/Jobs/GenerateReportJob.php`  
Заменить `Storage::disk('local')` на `Storage::disk('reports')`.

**Файл:** `app/app/Http/Controllers/Api/ReportJobController.php` (download метод)  
Использовать `Storage::disk('reports')`.

**Файл:** `.env.example`  
Добавить `REPORT_DISK=local`.

---

## 4. Phase B: Notifications

### 4.1 new_device_login

#### Миграция: `user_known_devices`

```sql
CREATE TABLE user_known_devices (
    id           BIGSERIAL PRIMARY KEY,
    user_id      BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    fingerprint  VARCHAR(64) NOT NULL,
    first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_user_known_devices UNIQUE (user_id, fingerprint)
);
CREATE INDEX idx_user_known_devices_user_id ON user_known_devices(user_id);
```

**Миграция-файл:** `app/database/migrations/XXXX_XX_XX_create_user_known_devices_table.php`

#### Логика определения нового устройства

**Файл:** `app/app/Http/Controllers/Auth/AuthenticatedSessionController.php` (метод `store()`)

После успешной аутентификации (`Auth::attempt(...)` прошёл):

```php
$fingerprint = hash('sha256', $request->ip() . '|' . $request->userAgent());
$isNew = ! UserKnownDevice::where('user_id', $user->id)
    ->where('fingerprint', $fingerprint)
    ->exists();

UserKnownDevice::firstOrCreate(
    ['user_id' => $user->id, 'fingerprint' => $fingerprint],
    ['first_seen_at' => now()]
);

if ($isNew) {
    app(NotificationService::class)->notifyNewDeviceLogin(
        $user,
        $request->ip(),
        $request->userAgent()
    );
}
```

#### NotificationService::notifyNewDeviceLogin()

**Файл:** `app/app/Services/NotificationService.php` (добавить метод)

```php
public function notifyNewDeviceLogin(User $user, string $ip, string $userAgent): void
{
    if (!($user->notification_preferences['new_device_login']['email'] ?? true)) {
        return;
    }
    try {
        Mail::to($user->email)->send(new NewDeviceLoginMail($user, $ip, $userAgent));
    } catch (Throwable $e) {
        Log::error('new_device_login mail failed', ['user' => $user->id, 'error' => $e->getMessage()]);
    }
}
```

#### Mailable + Blade

**Файлы:**

- `app/app/Mail/NewDeviceLoginMail.php`
- `app/resources/views/emails/new-device-login.blade.php`

`NewDeviceLoginMail` получает `$user`, `$ip`, `$userAgent`. Blade-шаблон показывает: IP-адрес, браузер/ОС (из userAgent, без парсинга — raw строка), время, ссылку на смену пароля (`/settings#security`). Стиль — аналогичен существующим шаблонам проекта.

#### Модель UserKnownDevice

**Файл:** `app/app/Models/UserKnownDevice.php`

```php
class UserKnownDevice extends Model {
    public $timestamps = false;
    protected $fillable = ['user_id', 'fingerprint', 'first_seen_at'];
    protected $casts = ['first_seen_at' => 'datetime'];
}
```

### 4.2 Deep-link bell (Vue frontend)

**Файл:** `app/resources/js/components/AppTopbar.vue` (или субкомпонент BellNotifications)

При клике на уведомление в списке:

```typescript
function handleNotificationClick(notification: InAppNotification) {
  markAsRead(notification.id) // существующий PATCH endpoint

  if (notification.deal_id) {
    router.push({ name: 'deals', query: { open: String(notification.deal_id) } })
  } else if (notification.event === 'reminder') {
    router.push({ name: 'reminders' })
  } else if (['low_balance', 'zero_balance', 'topup_success', 'invoice_paid'].includes(notification.event)) {
    router.push({ name: 'billing' })
  }
  // new_device_login, marketing, new_lead без deal_id — только markAsRead
}
```

`DealsView` — нужно добавить логику `query.open`: при монтировании если `route.query.open` задан → открыть DealDetailDrawer для этого deal_id. Это часть коммита #6.

**Тип InAppNotification** (обновить в `api/notifications.ts` если не хватает полей):

```typescript
interface InAppNotification {
  id: number
  event: string
  title: string
  body: string
  deal_id: number | null
  payload: Record<string, unknown>
  read_at: string | null
  created_at: string
}
```

### 4.3 ЮKassa webhook scaffold

#### Route

**Файл:** `app/routes/api.php`

```php
Route::post('/webhooks/yukassa', [WebhookYukassaController::class, 'handle'])
    ->name('webhooks.yukassa');
// БЕЗ auth:sanctum и tenant middleware — внешний webhook
```

#### WebhookYukassaController

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

```php
class WebhookYukassaController extends Controller
{
    public function handle(Request $request, NotificationService $notifications): JsonResponse
    {
        // 1. Валидация подписи
        $secret = config('services.yukassa.secret');
        $body = $request->getContent();
        $signature = hash('sha256', $body . $secret);
        if (!hash_equals($signature, $request->header('HTTP_SHOP_ID', ''))) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }
        // NOTE: точный формат подписи ЮKassa уточнить по docs.yukassa.ru при активации.
        // SHA-256(body . secret) — scaffold-реализация; заголовок HTTP_SHOP_ID предположительный.

        // 2. Парсинг тела
        $data = json_decode($body, true);
        $event = $data['event'] ?? null;
        $invoiceId = $data['object']['metadata']['invoice_id'] ?? null;

        if (!$invoiceId) {
            return response()->json(['error' => 'Missing invoice_id'], 422);
        }

        $invoice = SaasInvoice::find($invoiceId);
        if (!$invoice) {
            return response()->json(['error' => 'Invoice not found'], 404);
        }

        // 3. Обновление статуса
        if ($event === 'payment.succeeded') {
            $invoice->update(['status' => 'paid', 'paid_at' => now()]);
            $tenant = Tenant::find($invoice->tenant_id);
            $notifications->notifyInvoicePaid($tenant, $invoice);
        } elseif ($event === 'payment.canceled') {
            $invoice->update(['status' => 'canceled']);
        }

        return response()->json(['status' => 'ok']);
    }
}
```

#### Конфиг

**Файл:** `config/services.php` — добавить:

```php
'yukassa' => [
    'secret' => env('YUKASSA_SECRET_KEY', ''),
],
```

**Файл:** `.env.example` — добавить `YUKASSA_SECRET_KEY=`.

---

## 5. Phase C: AdminTenantDetail (reduced)

### 5.1 Миграция 1: tenants.contact_phone

**Файл:** `app/database/migrations/XXXX_add_contact_phone_to_tenants.php`

```php
Schema::table('tenants', function (Blueprint $table) {
    $table->string('contact_phone', 20)->nullable()->after('contact_email');
});
```

Обновить `app/Models/Tenant.php` — добавить `contact_phone` в `$fillable`.

Обновить `AdminTenantsController::show()` — включить `contact_phone` в tenant base секцию ответа.

**Vue:** Обновить `TenantDetailHeader.vue` — показать `contact_phone` рядом с `contact_email` (через `v-if`).

### 5.2 Миграция 2: users.role

**Файл:** `app/database/migrations/XXXX_add_role_to_users.php`

```php
Schema::table('users', function (Blueprint $table) {
    $table->string('role', 20)->default('manager')->after('last_name');
});
```

Допустимые значения: `manager`, `owner`, `admin`. Валидация в UserController при обновлении профиля — `in:manager,owner,admin`.

Обновить `AdminTenantsController::show()` users секцию — заменить захардкоженный `'manager'` на `$user->role`.

Обновить `app/Models/User.php` — добавить `role` в `$fillable`.

**Vue:** Обновить `TenantDetailTabs.vue` (users tab) — брать `role` из API вместо hardcode.

---

## 6. Тестовое покрытие

| Phase | Тесты Pest | Тесты Vitest |
|-------|-----------|-------------|
| A1: 3 провайдера | +9 (3×3: headers, rows with data, rows empty) | — |
| A2: registry | +3 (isSupported для каждого нового типа) | — |
| A3: file download | +4 (200, 422 not ready, 410 expired, 403 wrong tenant) | — |
| B1: new_device_login | +3 (новое устройство → уведомление; повтор → нет; preference off → нет) | — |
| B2: deep-link bell | — | +3 (deal_id → router.push deals; reminder → reminders; billing event → billing) |
| B3: ЮKassa webhook | +4 (payment.succeeded, payment.canceled, bad signature, unknown invoice) | — |
| C: AdminTenantDetail | +2 (contact_phone в ответе; role из БД а не хардкод) | +1 (role отображается в TenantDetailTabs) |
| **Итого** | **+25 Pest** | **+4 Vitest** |

---

## 7. Порядок коммитов

| # | Содержание | Phase |
|---|------------|-------|
| 1 | feat(reports): ManagersSummaryProvider + SourcesSummaryProvider + BillingSummaryProvider + registry update | A |
| 2 | feat(reports): GET /api/reports/jobs/{id}/file download endpoint | A |
| 3 | feat(reports): REPORT_DISK env var — S3-ready storage abstraction | A |
| 4 | feat(auth): user_known_devices table + new_device_login notification | B |
| 5 | feat(notifications): NewDeviceLoginMail + blade template | B |
| 6 | feat(frontend): deep-link bell navigation on notification click | B |
| 7 | feat(webhooks): POST /api/webhooks/yukassa scaffold + notifyInvoicePaid trigger | B |
| 8 | feat(tenants): add contact_phone column + API + Vue | C |
| 9 | feat(users): add role column + remove hardcoded 'manager' + Vue | C |

---

## 8. Архитектурные решения (recap)

| Решение | Выбор | Обоснование |
|---------|-------|-------------|
| S3-абстракция | `REPORT_DISK` env var → Laravel Storage disk | YAGNI; Storage facade уже абстрагирует |
| new_device_login хранилище | Таблица `user_known_devices` | Чисто; позволит «доверенные устройства» в будущем |
| AdminTenantDetail scope | contact_phone + role только; inn/legal_address → Б-1 | Не заблокировано; immediate value |
| ЮKassa scope | Scaffold только (endpoint + HMAC + model update) | Боевые ключи ждут Б-1 |
