# Sprint 4 — Историческая миграция лидов (§6 CSV-импорт) 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:** Тенант загружает CSV-выгрузку лидов из crm.bp-gr.ru, видит прогресс, маппит неизвестные статусы и получает email с результатом — исторические сделки переносятся без списания баланса.

**Architecture:** Загруженный CSV сохраняется на диск, `ImportLeadsJob` (queued) парсит его через `CsvLeadsParser`, `HistoricalImportService` выполняет идемпотентный advisory-lock upsert в `deals` (та же логика, что `ProcessWebhookJob`), создавая недостающие месячные партиции под исторические даты. Прогресс и счётчики пишутся в `import_log`; неизвестные статусы воронки — в `import_unknown_statuses` для ручного маппинга через wizard. Баланс не списывается — фиксируется одна транзакция типа `historical_import`.

**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (RLS + партиционирование) / Pest 4 — backend; Vue 3 + Vuetify 3 + Vitest 4 — frontend.

---

## Scope

Покрывает аудит-находки **H1, H2, H3, H4, H5, H6, H8, H9** (миграция **лидов**, спецификация — ТЗ §6).

**Вынесено из плана:**

- **H7 (bulk-импорт проектов)** — формат «18-колоночного CSV проектов» не специфицирован в ТЗ §6. §6.2 описывает только CSV **лидов** (9 колонок); 17-колоночная таблица в `Analiz_originala_v8_3.md:1318` — это stats-экран `/admin/visit/rt-stat`, не импортируемый формат. H7 требует отдельного format-спека → отдельный план «Sprint 4B».
- **H8 «кнопка в ProjectsView»** — относится к импорту **проектов** (H7), а не лидов. UI-вход для лидов в этом плане — отдельный маршрут `/import` + пункт сайдбара (Task 12).

## Стартовое состояние

- Ветка от `origin/main` (Sprint 3F, `447ef59`). `import_log` **уже существует** в `db/schema.sql` (раздел 6.7) с RLS-политикой — нужен только `ALTER` (+5 колонок). `import_unknown_statuses` — новая таблица. Тип транзакции `historical_import` **уже** в `CHECK` на `balance_transactions`.
- **Зависимость от Sprint 3E:** ветка `feat/sprint3e-settings-tabs` (реструктуризация вкладок `SettingsView`) не влита. Этот план НЕ трогает `SettingsView` — UI-вход через отдельный маршрут `/import` (Task 12), коллизии нет.
- **Конвенция миграций:** `migrate:fresh` сначала грузит `db/schema.sql`, затем гоняет миграции. Поэтому каждая инкрементальная миграция guard'ится `Schema::hasTable`/`hasColumn` (см. `2026_05_11_140000_add_archived_at_to_projects.php`).
- **Quirk 25:** любой новый Pest-тест-файл триггерит ложные срабатывания Larastan (`TestCall::$prop`/`method.notFound`) → после написания тестов нужна регенерация `app/phpstan-baseline.neon` (3 шага: закомментировать `- phpstan-baseline.neon` в `phpstan.neon` → `vendor/bin/phpstan analyse --generate-baseline` → раскомментировать). Делается одним заходом в конце соответствующего Task'а.

---

## File Structure

**Backend (`app/`):**

- `database/migrations/2026_05_16_120000_sprint4_historical_import_schema.php` — H1+H2 schema delta.
- `app/Models/ImportLog.php`, `app/Models/ImportUnknownStatus.php` — Eloquent-модели.
- `database/factories/ImportLogFactory.php`, `database/factories/ImportUnknownStatusFactory.php`.
- `app/Services/MonthlyPartitionManager.php` — создание месячных партиций (extract из `PartitionsCreateMonths`).
- `app/Services/Import/StatusRuToSlugMapper.php` — чистый маппер статусов (ТЗ §6.4).
- `app/Services/Import/CsvLeadsParser.php` + `app/Services/Import/ParsedLeadRow.php` — парсинг и валидация CSV.
- `app/Services/Import/HistoricalImportService.php` — оркестрация upsert'а.
- `app/Jobs/ImportLeadsJob.php` — queued-обработчик.
- `app/Mail/ImportCompletedNotification.php` + `resources/views/mail/import-completed.blade.php`.
- `app/Http/Controllers/Api/ImportController.php` + `app/Http/Requests/StoreImportRequest.php` + `app/Http/Requests/ResolveUnknownStatusesRequest.php`.
- `routes/web.php` — 5 маршрутов в группе `auth:sanctum`+`tenant`.

**Frontend (`app/resources/js/`):**

- `api/imports.ts` — API-обёртки.
- `views/ImportView.vue` — экран импорта.
- `components/import/UnknownStatusesDialog.vue` — wizard маппинга.
- `router/index.ts` — маршрут `/import`.
- `components/AppSidebar.vue` — пункт навигации.

**Docs / schema:**

- `db/schema.sql` — раздел `import_unknown_statuses` + 5 колонок `import_log` + RLS + GRANTs; bump версии.
- `db/CHANGELOG_schema.md` — запись о delta.
- `db/02_grants.sql` — GRANTs для `import_unknown_statuses`.
- `docs/Как_перенести_данные_из_crm-bp-gr.md` — H9 мини-инструкция.

---

## Task 1: Schema delta — `import_unknown_statuses` + enrichment `import_log` (H1+H2)

**Files:**

- Create: `app/database/migrations/2026_05_16_120000_sprint4_historical_import_schema.php`
- Modify: `db/schema.sql` (раздел 6.7 рядом с `import_log`; RLS-секция; GRANTs-секция; заголовок-версия на строке 5)
- Modify: `db/02_grants.sql`
- Modify: `db/CHANGELOG_schema.md`
- Test: `app/tests/Feature/Import/ImportSchemaTest.php`

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

`app/tests/Feature/Import/ImportSchemaTest.php`:

```php
<?php

declare(strict_types=1);

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

test('import_log имеет 5 новых колонок enrichment', function (): void {
    foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
        expect(Schema::hasColumn('import_log', $column))->toBeTrue("import_log.$column отсутствует");
    }
});

test('import_unknown_statuses существует с RLS', function (): void {
    expect(Schema::hasTable('import_unknown_statuses'))->toBeTrue();

    $rls = DB::selectOne(
        "SELECT relrowsecurity FROM pg_class WHERE relname = 'import_unknown_statuses'"
    );
    expect($rls->relrowsecurity)->toBeTrue('RLS не включён на import_unknown_statuses');

    $policy = DB::selectOne(
        "SELECT 1 AS ok FROM pg_policies WHERE tablename = 'import_unknown_statuses' AND policyname = 'tenant_isolation'"
    );
    expect($policy)->not->toBeNull('Политика tenant_isolation отсутствует');
});

test('import_unknown_statuses имеет UNIQUE (tenant_id, status_ru)', function (): void {
    $unique = DB::selectOne(
        "SELECT 1 AS ok FROM pg_constraint
         WHERE conrelid = 'import_unknown_statuses'::regclass AND contype = 'u'"
    );
    expect($unique)->not->toBeNull('UNIQUE-ограничение отсутствует');
});
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportSchemaTest.php`
Expected: FAIL — колонок/таблицы нет.

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

`app/database/migrations/2026_05_16_120000_sprint4_historical_import_schema.php`:

```php
<?php

declare(strict_types=1);

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

/**
 * Sprint 4 (H1+H2) — историческая миграция лидов §6.
 *
 * H1: новая таблица import_unknown_statuses (tenant-level resolved mappings).
 * H2: enrichment import_log — +5 колонок.
 *
 * Guard'ы: migrate:fresh грузит schema.sql v8.20+ (где delta уже есть) до миграций,
 * поэтому каждый кусок применяется только при отсутствии.
 */
return new class extends Migration
{
    public function up(): void
    {
        foreach ([
            'entity_type' => "ALTER TABLE import_log ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'leads' CHECK (entity_type IN ('leads','projects'))",
            'source_system' => "ALTER TABLE import_log ADD COLUMN source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru'",
            'mapping_config' => 'ALTER TABLE import_log ADD COLUMN mapping_config JSONB',
            'unknown_statuses_count' => 'ALTER TABLE import_log ADD COLUMN unknown_statuses_count INT NOT NULL DEFAULT 0',
            'dry_run' => 'ALTER TABLE import_log ADD COLUMN dry_run BOOLEAN NOT NULL DEFAULT FALSE',
        ] as $column => $ddl) {
            if (! Schema::hasColumn('import_log', $column)) {
                DB::statement($ddl);
            }
        }

        if (! Schema::hasTable('import_unknown_statuses')) {
            DB::statement(<<<'SQL'
                CREATE TABLE import_unknown_statuses (
                    id              BIGSERIAL PRIMARY KEY,
                    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
                    import_log_id   BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
                    status_ru       VARCHAR(100) NOT NULL,
                    occurrences     INT NOT NULL DEFAULT 0,
                    mapped_to_slug  VARCHAR(50) REFERENCES lead_statuses(slug),
                    resolved_at     TIMESTAMPTZ,
                    resolved_by     BIGINT REFERENCES users(id),
                    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                    updated_at      TIMESTAMPTZ,
                    UNIQUE (tenant_id, status_ru)
                )
                SQL);

            DB::statement(
                'CREATE INDEX idx_import_unknown_statuses_unresolved
                 ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL'
            );

            DB::statement('ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY');
            DB::statement(
                "CREATE POLICY tenant_isolation ON import_unknown_statuses
                 USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"
            );
        }
    }

    public function down(): void
    {
        // down() не симметричен: на проекте rollback применяется только после
        // migrate:fresh (см. add_archived_at_to_projects). Для отката v8.20 —
        // отдельный schema-bump, не эта миграция.
        DB::statement('DROP TABLE IF EXISTS import_unknown_statuses');

        foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
            if (Schema::hasColumn('import_log', $column)) {
                Schema::table('import_log', fn ($table) => $table->dropColumn($column));
            }
        }
    }
};
```

- [ ] **Step 4: Обновить `db/schema.sql`**

В `db/schema.sql` в определении `CREATE TABLE import_log` (раздел 6.7, ~строка 1478) добавить 5 колонок перед закрывающей `)`:

```sql
    entity_type     VARCHAR(20) NOT NULL DEFAULT 'leads'
                    CHECK (entity_type IN ('leads','projects')),
    source_system   VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru',
    mapping_config  JSONB,
    unknown_statuses_count INT NOT NULL DEFAULT 0,
    dry_run         BOOLEAN NOT NULL DEFAULT FALSE,
```

Сразу после блока `import_log` добавить новую таблицу:

```sql
-- -----------------------------------------------------------------------------
-- import_unknown_statuses — tenant-level маппинг неизвестных статусов CSV (раздел 6.4)
-- Sprint 4 (H1): русский статус из CSV, не найденный в STATUS_RU_TO_SLUG, пишется сюда.
-- Wizard (§6.6) проставляет mapped_to_slug; повторный импорт применяет маппинг.
-- -----------------------------------------------------------------------------
CREATE TABLE import_unknown_statuses (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    import_log_id   BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
    status_ru       VARCHAR(100) NOT NULL,
    occurrences     INT NOT NULL DEFAULT 0,
    mapped_to_slug  VARCHAR(50) REFERENCES lead_statuses(slug),
    resolved_at     TIMESTAMPTZ,
    resolved_by     BIGINT REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ,
    UNIQUE (tenant_id, status_ru)
);

CREATE INDEX idx_import_unknown_statuses_unresolved
    ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL;
```

В RLS-секцию (рядом со строкой `ALTER TABLE import_log ENABLE ROW LEVEL SECURITY`, ~2705) добавить:

```sql
ALTER TABLE import_unknown_statuses  ENABLE ROW LEVEL SECURITY;
```

Рядом со строкой `CREATE POLICY tenant_isolation ON import_log` (~2745):

```sql
CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
```

В GRANTs-секции `db/schema.sql` найти строки `GRANT ... ON import_log ...` и сразу под ними добавить зеркальные для новой таблицы:

```sql
GRANT SELECT, INSERT, UPDATE, DELETE ON import_unknown_statuses TO crm_app_user, crm_app_admin;
GRANT SELECT ON import_unknown_statuses TO crm_readonly;
GRANT USAGE, SELECT ON SEQUENCE import_unknown_statuses_id_seq TO crm_app_user, crm_app_admin;
```

Заголовок `db/schema.sql` строка 5 — прочитать текущую базовую версию (на момент написания плана `v8.19`), bump minor +0.01 (`v8.19` → `v8.20`). Если там уже `v8.20`+ — bump до следующей. Дописать в строку 5 краткое: `Sprint 4: import_unknown_statuses + import_log enrichment (+5 колонок)`.

- [ ] **Step 5: Обновить `db/02_grants.sql` и `db/CHANGELOG_schema.md`**

В `db/02_grants.sql` добавить те же 3 GRANT-строки из Step 4 (зеркально блоку `import_log`).

В `db/CHANGELOG_schema.md` добавить запись сверху списка:

```markdown
## v8.20 — 2026-05-16 — Sprint 4 (историческая миграция лидов §6)

- **+1 таблица** `import_unknown_statuses` (tenant-level маппинг неизвестных статусов CSV; RLS `tenant_isolation`; UNIQUE `(tenant_id, status_ru)`; partial index `idx_import_unknown_statuses_unresolved`).
- **+5 колонок** в `import_log`: `entity_type`, `source_system`, `mapping_config`, `unknown_statuses_count`, `dry_run`.
- GRANTs для `import_unknown_statuses`: SELECT/INSERT/UPDATE/DELETE — `crm_app_user`/`crm_app_admin`; SELECT — `crm_readonly`.
- Миграция: `2026_05_16_120000_sprint4_historical_import_schema.php` (guard'ы `hasTable`/`hasColumn`).
```

(Если базовая версия в Step 4 оказалась иной — синхронизировать номер заголовка.)

- [ ] **Step 6: Применить миграцию и проверить `migrate:fresh`**

Run: `cd app && php artisan migrate`
Затем: `cd app && php artisan migrate:fresh --seed`
Expected: обе команды — exit 0, без `duplicate column`/`already exists`.

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportSchemaTest.php`
Expected: PASS 3/3.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/database/migrations/2026_05_16_120000_sprint4_historical_import_schema.php app/tests/Feature/Import/ImportSchemaTest.php db/schema.sql db/02_grants.sql db/CHANGELOG_schema.md
git commit -m "feat(import): H1+H2 — схема import_unknown_statuses + enrichment import_log"
```

---

## Task 2: Eloquent-модели `ImportLog` и `ImportUnknownStatus`

**Files:**

- Create: `app/app/Models/ImportLog.php`
- Create: `app/app/Models/ImportUnknownStatus.php`
- Create: `app/database/factories/ImportLogFactory.php`
- Create: `app/database/factories/ImportUnknownStatusFactory.php`
- Test: `app/tests/Feature/Import/ImportModelsTest.php`

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

`app/tests/Feature/Import/ImportModelsTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;

beforeEach(function (): void {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->for($this->tenant)->create();
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
});

test('ImportLog создаётся с дефолтами и кастует mapping_config/dry_run', function (): void {
    $log = ImportLog::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'filename' => 'leads.csv',
        'file_path' => 'imports/1/uuid.csv',
        'mapping_config' => ['status' => ['Новые' => 'new']],
        'dry_run' => true,
    ]);

    expect($log->status)->toBe('pending')
        ->and($log->entity_type)->toBe('leads')
        ->and($log->dry_run)->toBeTrue()
        ->and($log->mapping_config)->toBe(['status' => ['Новые' => 'new']]);
});

test('ImportUnknownStatus хранит маппинг и фильтруется scope unresolved', function (): void {
    ImportUnknownStatus::create([
        'tenant_id' => $this->tenant->id,
        'status_ru' => 'Архив',
        'occurrences' => 3,
    ]);
    ImportUnknownStatus::create([
        'tenant_id' => $this->tenant->id,
        'status_ru' => 'Спам',
        'occurrences' => 1,
        'mapped_to_slug' => 'closed',
        'resolved_at' => now(),
    ]);

    expect(ImportUnknownStatus::unresolved()->count())->toBe(1)
        ->and(ImportUnknownStatus::unresolved()->first()->status_ru)->toBe('Архив');
});
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportModelsTest.php`
Expected: FAIL — классов `ImportLog`/`ImportUnknownStatus` нет.

- [ ] **Step 3: Создать модель `ImportLog`**

`app/app/Models/ImportLog.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

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

/**
 * Журнал CSV-импорта (schema §6.7, Sprint 4).
 *
 * Tenant-aware модель с RLS: tenant_isolation по current_setting('app.current_tenant_id').
 * Sprint 4 enrichment: entity_type / source_system / mapping_config / unknown_statuses_count / dry_run.
 *
 * @mixin IdeHelperImportLog
 */
class ImportLog extends Model
{
    /** @use HasFactory<ImportLogFactory> */
    use HasFactory;

    public const UPDATED_AT = null;

    public const CREATED_AT = null;

    protected $table = 'import_log';

    protected $fillable = [
        'tenant_id',
        'user_id',
        'filename',
        'file_path',
        'rows_total',
        'rows_added',
        'rows_updated',
        'rows_skipped',
        'status',
        'error_message',
        'started_at',
        'finished_at',
        'entity_type',
        'source_system',
        'mapping_config',
        'unknown_statuses_count',
        'dry_run',
    ];

    protected function casts(): array
    {
        return [
            'tenant_id' => 'integer',
            'user_id' => 'integer',
            'rows_total' => 'integer',
            'rows_added' => 'integer',
            'rows_updated' => 'integer',
            'rows_skipped' => 'integer',
            'unknown_statuses_count' => 'integer',
            'dry_run' => 'boolean',
            'mapping_config' => 'array',
            'started_at' => 'datetime',
            'finished_at' => 'datetime',
        ];
    }

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

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

> NB: таблица `import_log` имеет только `started_at`/`finished_at` (нет `created_at`/`updated_at`) — поэтому `CREATED_AT`/`UPDATED_AT = null`, иначе Eloquent попытается писать несуществующие колонки.

- [ ] **Step 4: Создать модель `ImportUnknownStatus`**

`app/app/Models/ImportUnknownStatus.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\ImportUnknownStatusFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Неизвестный статус воронки из CSV-импорта (schema §6.4, Sprint 4 H1).
 *
 * Tenant-aware модель с RLS. UNIQUE (tenant_id, status_ru): повторный импорт
 * инкрементит occurrences и переиспользует ранее проставленный mapped_to_slug.
 *
 * @mixin IdeHelperImportUnknownStatus
 */
class ImportUnknownStatus extends Model
{
    /** @use HasFactory<ImportUnknownStatusFactory> */
    use HasFactory;

    protected $fillable = [
        'tenant_id',
        'import_log_id',
        'status_ru',
        'occurrences',
        'mapped_to_slug',
        'resolved_at',
        'resolved_by',
    ];

    protected function casts(): array
    {
        return [
            'tenant_id' => 'integer',
            'import_log_id' => 'integer',
            'occurrences' => 'integer',
            'resolved_by' => 'integer',
            'resolved_at' => 'datetime',
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
        ];
    }

    /**
     * Незамапленные статусы (mapped_to_slug IS NULL) — вход для wizard'а §6.6.
     *
     * @param  Builder<ImportUnknownStatus>  $query
     * @return Builder<ImportUnknownStatus>
     */
    public function scopeUnresolved(Builder $query): Builder
    {
        return $query->whereNull('mapped_to_slug');
    }

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

- [ ] **Step 5: Создать фабрики**

`app/database/factories/ImportLogFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<ImportLog> */
class ImportLogFactory extends Factory
{
    protected $model = ImportLog::class;

    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'user_id' => User::factory(),
            'filename' => 'leads-export.csv',
            'file_path' => 'imports/1/'.$this->faker->uuid().'.csv',
            'status' => 'pending',
            'entity_type' => 'leads',
            'source_system' => 'crm.bp-gr.ru',
            'dry_run' => false,
        ];
    }
}
```

`app/database/factories/ImportUnknownStatusFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<ImportUnknownStatus> */
class ImportUnknownStatusFactory extends Factory
{
    protected $model = ImportUnknownStatus::class;

    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'status_ru' => $this->faker->unique()->word(),
            'occurrences' => $this->faker->numberBetween(1, 20),
            'mapped_to_slug' => null,
        ];
    }
}
```

- [ ] **Step 6: Прогнать тест — убедиться, что проходит**

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportModelsTest.php`
Expected: PASS 2/2.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Models/ImportLog.php app/app/Models/ImportUnknownStatus.php app/database/factories/ImportLogFactory.php app/database/factories/ImportUnknownStatusFactory.php app/tests/Feature/Import/ImportModelsTest.php
git commit -m "feat(import): Eloquent-модели ImportLog + ImportUnknownStatus"
```

---

## Task 3: Сервис `MonthlyPartitionManager` (extract + рефактор команды)

Исторические `received_at` лидов выходят за пределы существующих партиций `deals` (только май–окт 2026). Нужен сервис создания месячных партиций под произвольный диапазон дат. Логика DDL уже есть в `PartitionsCreateMonths` — выносим в переиспользуемый сервис (DRY), команда рефакторится на него.

**Files:**

- Create: `app/app/Services/MonthlyPartitionManager.php`
- Modify: `app/app/Console/Commands/PartitionsCreateMonths.php`
- Test: `app/tests/Feature/Import/MonthlyPartitionManagerTest.php`

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

`app/tests/Feature/Import/MonthlyPartitionManagerTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\MonthlyPartitionManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

function partitionExists(string $name): bool
{
    return DB::selectOne(
        "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
        [$name],
    ) !== null;
}

test('ensureRange создаёт месячные партиции deals под диапазон', function (): void {
    $manager = app(MonthlyPartitionManager::class);

    $created = $manager->ensureRange(
        'deals',
        Carbon::parse('2024-02-15'),
        Carbon::parse('2024-04-03'),
    );

    expect($created)->toBeGreaterThanOrEqual(3)
        ->and(partitionExists('deals_2024_02'))->toBeTrue()
        ->and(partitionExists('deals_2024_03'))->toBeTrue()
        ->and(partitionExists('deals_2024_04'))->toBeTrue();
});

test('ensureRange идемпотентна — повторный вызов не падает', function (): void {
    $manager = app(MonthlyPartitionManager::class);

    $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
    $secondRun = $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));

    expect($secondRun)->toBe(0); // всё уже существует
});

test('ensureRange отвергает неизвестную таблицу', function (): void {
    app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now());
})->throws(InvalidArgumentException::class);
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/MonthlyPartitionManagerTest.php`
Expected: FAIL — класса `MonthlyPartitionManager` нет.

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

`app/app/Services/MonthlyPartitionManager.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services;

use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;

/**
 * Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
 *
 * Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
 * из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
 *
 * Используется:
 *   - cron `partitions:create-months` — N месяцев вперёд;
 *   - HistoricalImportService — под исторический диапазон дат CSV.
 */
class MonthlyPartitionManager
{
    /** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
    public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];

    /**
     * Гарантирует наличие месячных партиций таблицы для всех месяцев,
     * пересекающих [$from, $to] включительно.
     *
     * @return int Сколько партиций реально создано (0 — все уже были).
     */
    public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
    {
        if (! in_array($table, self::PARTITIONED_TABLES, true)) {
            throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
        }

        $month = $from->copy()->startOfMonth();
        $last = $to->copy()->startOfMonth();
        $created = 0;

        while ($month->lessThanOrEqualTo($last)) {
            $created += $this->ensureMonth($table, $month) ? 1 : 0;
            $month->addMonth();
        }

        return $created;
    }

    /**
     * Создаёт одну месячную партицию. Возвращает true, если партиция создана,
     * false — если уже существовала.
     */
    public function ensureMonth(string $table, CarbonInterface $monthStart): bool
    {
        if (! in_array($table, self::PARTITIONED_TABLES, true)) {
            throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
        }

        $start = $monthStart->copy()->startOfMonth();
        $end = $start->copy()->addMonth();
        $partition = sprintf('%s_%s', $table, $start->format('Y_m'));

        $exists = DB::selectOne(
            "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
            [$partition],
        );

        if ($exists !== null) {
            return false;
        }

        DB::statement(sprintf(
            "CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
            $partition,
            $table,
            $start->format('Y-m-d'),
            $end->format('Y-m-d'),
        ));

        return true;
    }
}
```

- [ ] **Step 4: Рефакторить `PartitionsCreateMonths` на сервис**

Заменить тело `handle()` в `app/app/Console/Commands/PartitionsCreateMonths.php` так, чтобы цикл использовал `MonthlyPartitionManager::ensureMonth()`. Метод `partitionExists()` и константу `PARTITIONED_TABLES` из команды удалить (теперь в сервисе). Новое `handle()`:

```php
public function handle(MonthlyPartitionManager $manager): int
{
    $ahead = max(1, (int) $this->option('ahead'));
    $now = Carbon::now()->startOfMonth();

    $created = 0;
    $skipped = 0;

    for ($i = 0; $i <= $ahead; $i++) {
        $monthStart = $now->copy()->addMonths($i);

        foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
            $partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));

            if ($manager->ensureMonth($table, $monthStart)) {
                $created++;
                $this->info("  create <fg=green>{$partitionName}</>");
            } else {
                $skipped++;
                $this->line("  skip   <fg=gray>{$partitionName}</> (already exists)");
            }
        }
    }

    $this->newLine();
    $this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}).");

    return self::SUCCESS;
}
```

Добавить `use App\Services\MonthlyPartitionManager;`, удалить теперь неиспользуемый `use ... DB;` если он больше нигде не нужен в файле.

- [ ] **Step 5: Прогнать тесты — сервис + регрессия команды**

Run: `cd app && php vendor/bin/pest tests/Feature/Import/MonthlyPartitionManagerTest.php tests/Feature/PartitionsCreateMonthsTest.php`
Expected: PASS — новый тест 3/3 + существующий `PartitionsCreateMonthsTest` зелёный (поведение команды не изменилось).

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Services/MonthlyPartitionManager.php app/app/Console/Commands/PartitionsCreateMonths.php app/tests/Feature/Import/MonthlyPartitionManagerTest.php
git commit -m "feat(import): сервис MonthlyPartitionManager + рефактор partitions:create-months"
```

---

## Task 4: Сервис `StatusRuToSlugMapper` (ТЗ §6.4)

Чистый сервис: маппит русское название статуса воронки в slug по фиксированной таблице ТЗ §6.4. Tenant-специфичные переопределения (из `import_unknown_statuses`) НЕ здесь — их накладывает `HistoricalImportService` (Task 6).

**Files:**

- Create: `app/app/Services/Import/StatusRuToSlugMapper.php`
- Test: `app/tests/Unit/Import/StatusRuToSlugMapperTest.php`

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

`app/tests/Unit/Import/StatusRuToSlugMapperTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Import\StatusRuToSlugMapper;

test('маппит все 14 канонических статусов §6.4', function (): void {
    $mapper = new StatusRuToSlugMapper;

    expect($mapper->toSlug('Новые'))->toBe('new')
        ->and($mapper->toSlug('Оплачено'))->toBe('paid')
        ->and($mapper->toSlug('Конечный недозвон'))->toBe('final_missed')
        ->and($mapper->map())->toHaveCount(14);
});

test('тримит пробелы вокруг значения', function (): void {
    expect((new StatusRuToSlugMapper)->toSlug('  Переговоры  '))->toBe('negotiations');
});

test('возвращает null для неизвестного статуса', function (): void {
    expect((new StatusRuToSlugMapper)->toSlug('Архив'))->toBeNull()
        ->and((new StatusRuToSlugMapper)->toSlug(''))->toBeNull();
});
```

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

Run: `cd app && php vendor/bin/pest tests/Unit/Import/StatusRuToSlugMapperTest.php`
Expected: FAIL — класса нет.

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

`app/app/Services/Import/StatusRuToSlugMapper.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Import;

/**
 * Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
 *
 * Чистый сервис без зависимостей. Tenant-специфичные переопределения
 * неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
 */
class StatusRuToSlugMapper
{
    /** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
    private const STATUS_RU_TO_SLUG = [
        'Новые' => 'new',
        'Просмотрено' => 'viewed',
        'Проработан' => 'worked',
        'База' => 'base',
        'Недозвон' => 'missed',
        'Переговоры' => 'negotiations',
        'Ожидаем оплаты' => 'waiting_payment',
        'Партнерка' => 'partnership',
        'Оплачено' => 'paid',
        'Закрыто и не реализовано' => 'closed',
        'Тест драйв' => 'test_drive',
        'Горячий' => 'hot',
        'На замену' => 'replacement',
        'Конечный недозвон' => 'final_missed',
    ];

    /**
     * Возвращает slug или null, если статус не входит в каноническую таблицу.
     */
    public function toSlug(string $statusRu): ?string
    {
        return self::STATUS_RU_TO_SLUG[trim($statusRu)] ?? null;
    }

    /**
     * Полная каноническая таблица — для UI wizard'а (показать варианты).
     *
     * @return array<string, string>
     */
    public function map(): array
    {
        return self::STATUS_RU_TO_SLUG;
    }
}
```

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

Run: `cd app && php vendor/bin/pest tests/Unit/Import/StatusRuToSlugMapperTest.php`
Expected: PASS 3/3.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Services/Import/StatusRuToSlugMapper.php app/tests/Unit/Import/StatusRuToSlugMapperTest.php
git commit -m "feat(import): сервис StatusRuToSlugMapper (ТЗ §6.4)"
```

---

## Task 5: Сервис `CsvLeadsParser` + DTO `ParsedLeadRow`

Парсит CSV-выгрузку лидов crm.bp-gr.ru (ТЗ §6.2): срезает BOM, разбирает строки, валидирует. Возвращает валидные строки как DTO + список ошибок (строка/сообщение). Невалидные строки в БД не попадают.

**Files:**

- Create: `app/app/Services/Import/ParsedLeadRow.php`
- Create: `app/app/Services/Import/CsvParseResult.php`
- Create: `app/app/Services/Import/CsvLeadsParser.php`
- Test: `app/tests/Unit/Import/CsvLeadsParserTest.php`

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

`app/tests/Unit/Import/CsvLeadsParserTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Import\CsvLeadsParser;

function csvLeads(string $body, bool $withBom = true): string
{
    $header = 'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';

    return ($withBom ? "\xEF\xBB\xBF" : '').$header."\n".$body;
}

test('парсит валидную строку в ParsedLeadRow', function (): void {
    $result = (new CsvLeadsParser)->parse(csvLeads(
        '1001,B1_Окна,окна,79161234567,2024/03/15 10:30:00,,Тёплый клиент,Переговоры,Иван'
    ));

    expect($result->rows)->toHaveCount(1)
        ->and($result->errors)->toBeEmpty();

    $row = $result->rows[0];
    expect($row->sourceCrmId)->toBe(1001)
        ->and($row->projectName)->toBe('Окна')          // префикс B1_ срезан
        ->and($row->projectTag)->toBe('окна')
        ->and($row->phone)->toBe('79161234567')
        ->and($row->statusRu)->toBe('Переговоры')
        ->and($row->contactName)->toBe('Иван')
        ->and($row->reminderAt)->toBeNull()
        ->and($row->receivedAt->format('Y-m-d H:i:s'))->toBe('2024-03-15 10:30:00');
});

test('срезает BOM и не считает заголовок строкой данных', function (): void {
    $result = (new CsvLeadsParser)->parse(csvLeads(
        '1,Проект,тег,79990001122,2024/01/01 00:00:00,,,Новые,'
    ));

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

test('парсит напоминание когда оно непустое', function (): void {
    $result = (new CsvLeadsParser)->parse(csvLeads(
        '2,П,т,79990001122,2024/01/01 09:00:00,2024/01/05 12:00:00,,Новые,'
    ));

    expect($result->rows[0]->reminderAt?->format('Y-m-d H:i:s'))->toBe('2024-01-05 12:00:00');
});

test('собирает ошибки невалидного телефона и даты, не роняя парсинг', function (): void {
    $result = (new CsvLeadsParser)->parse(csvLeads(
        "3,П,т,8916123,2024/01/01 00:00:00,,,Новые,\n".
        "4,П,т,79990001122,НЕ-ДАТА,,,Новые,\n".
        '5,П,т,79990001133,2024/01/02 00:00:00,,,Новые,'
    ));

    expect($result->rows)->toHaveCount(1)              // только строка 5 валидна
        ->and($result->rows[0]->sourceCrmId)->toBe(5)
        ->and($result->errors)->toHaveCount(2);

    expect($result->errors[0]['line'])->toBe(2);        // 1-я data-строка (после header)
});

test('обрабатывает кавычки и запятые внутри поля', function (): void {
    $result = (new CsvLeadsParser)->parse(csvLeads(
        '6,П,т,79990001122,2024/01/01 00:00:00,,"Комментарий, с запятой",Новые,Пётр'
    ));

    expect($result->rows[0]->comment)->toBe('Комментарий, с запятой');
});
```

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

Run: `cd app && php vendor/bin/pest tests/Unit/Import/CsvLeadsParserTest.php`
Expected: FAIL — классов нет.

- [ ] **Step 3: Создать DTO `ParsedLeadRow`**

`app/app/Services/Import/ParsedLeadRow.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Import;

use Carbon\CarbonImmutable;

/**
 * Одна валидная строка CSV-импорта лидов (ТЗ §6.3).
 */
final readonly class ParsedLeadRow
{
    public function __construct(
        public int $sourceCrmId,
        public string $projectName,
        public ?string $projectTag,
        public string $phone,
        public CarbonImmutable $receivedAt,
        public ?CarbonImmutable $reminderAt,
        public ?string $comment,
        public string $statusRu,
        public ?string $contactName,
    ) {}
}
```

- [ ] **Step 4: Создать DTO `CsvParseResult`**

`app/app/Services/Import/CsvParseResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Import;

/**
 * Результат парсинга CSV: валидные строки + ошибки по номеру строки.
 */
final readonly class CsvParseResult
{
    /**
     * @param  array<int, ParsedLeadRow>  $rows
     * @param  array<int, array{line: int, message: string}>  $errors
     */
    public function __construct(
        public array $rows,
        public array $errors,
    ) {}
}
```

- [ ] **Step 5: Создать `CsvLeadsParser`**

`app/app/Services/Import/CsvLeadsParser.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Import;

use Carbon\CarbonImmutable;
use Throwable;

/**
 * Парсер CSV-выгрузки лидов из crm.bp-gr.ru (ТЗ §6.2/§6.3).
 *
 * Формат: UTF-8 с BOM, разделитель — запятая, дата `Y/m/d H:i:s`,
 * телефон `7XXXXXXXXXX`. Заголовок:
 *   id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
 *
 * Невалидные строки не роняют парсинг — собираются в errors[].
 * Файл целиком загружается в память (MVP: ожидаемый объём — единицы тысяч строк).
 */
class CsvLeadsParser
{
    private const EXPECTED_COLUMNS = 9;

    private const DATE_FORMAT = 'Y/m/d H:i:s';

    public function parse(string $content): CsvParseResult
    {
        // Срезаем UTF-8 BOM.
        if (str_starts_with($content, "\xEF\xBB\xBF")) {
            $content = substr($content, 3);
        }

        $lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: [];
        $rows = [];
        $errors = [];

        // Строка 0 — заголовок, пропускаем. dataLine — 1-based номер строки данных.
        foreach (array_slice($lines, 1) as $index => $rawLine) {
            $dataLine = $index + 1;

            if (trim($rawLine) === '') {
                continue;
            }

            $cells = str_getcsv($rawLine);

            if (count($cells) < self::EXPECTED_COLUMNS) {
                $errors[] = ['line' => $dataLine, 'message' => 'Ожидалось 9 колонок, получено '.count($cells)];

                continue;
            }

            $parsed = $this->parseRow($cells, $dataLine, $errors);
            if ($parsed !== null) {
                $rows[] = $parsed;
            }
        }

        return new CsvParseResult($rows, $errors);
    }

    /**
     * @param  array<int, string>  $cells
     * @param  array<int, array{line: int, message: string}>  $errors
     */
    private function parseRow(array $cells, int $dataLine, array &$errors): ?ParsedLeadRow
    {
        [$id, $project, $tag, $phone, $createdAt, $reminder, $comment, $status, $name] = $cells;

        $phone = trim($phone);
        if (preg_match('/^7\d{10}$/', $phone) !== 1) {
            $errors[] = ['line' => $dataLine, 'message' => "Невалидный телефон: '{$phone}'"];

            return null;
        }

        $receivedAt = $this->parseDate($createdAt);
        if ($receivedAt === null) {
            $errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Создано': '{$createdAt}'"];

            return null;
        }

        $reminderAt = trim($reminder) === '' ? null : $this->parseDate($reminder);
        if (trim($reminder) !== '' && $reminderAt === null) {
            $errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Напоминание': '{$reminder}'"];

            return null;
        }

        $status = trim($status);
        if ($status === '') {
            $errors[] = ['line' => $dataLine, 'message' => 'Пустое поле «Состояние»'];

            return null;
        }

        // Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
        $projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
        if ($projectName === '') {
            $errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];

            return null;
        }

        return new ParsedLeadRow(
            sourceCrmId: (int) trim($id),
            projectName: $projectName,
            projectTag: trim($tag) === '' ? null : trim($tag),
            phone: $phone,
            receivedAt: $receivedAt,
            reminderAt: $reminderAt,
            comment: trim($comment) === '' ? null : trim($comment),
            statusRu: $status,
            contactName: trim($name) === '' ? null : trim($name),
        );
    }

    private function parseDate(string $value): ?CarbonImmutable
    {
        try {
            $date = CarbonImmutable::createFromFormat(self::DATE_FORMAT, trim($value));
        } catch (Throwable) {
            return null;
        }

        // createFromFormat возвращает false при несовпадении формата.
        return $date instanceof CarbonImmutable ? $date : null;
    }
}
```

- [ ] **Step 6: Прогнать — убедиться, что проходит**

Run: `cd app && php vendor/bin/pest tests/Unit/Import/CsvLeadsParserTest.php`
Expected: PASS 5/5.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Services/Import/ParsedLeadRow.php app/app/Services/Import/CsvParseResult.php app/app/Services/Import/CsvLeadsParser.php app/tests/Unit/Import/CsvLeadsParserTest.php
git commit -m "feat(import): CsvLeadsParser + DTO ParsedLeadRow/CsvParseResult"
```

---

## Task 6: Сервис `HistoricalImportService` — идемпотентный upsert

Ядро миграции. На входе — список `ParsedLeadRow` + `ImportLog`. Создаёт партиции под исторический диапазон, для каждой строки делает advisory-lock upsert в `deals` (логика `ProcessWebhookJob::upsertDeal`), создаёт `reminders` для непустого «Напоминание», пишет неизвестные статусы в `import_unknown_statuses`. **Баланс не списывается** — одна транзакция `historical_import` фиксируется отдельно (ТЗ §6.5).

**Архитектурные решения:**

- ТЗ §6.3 маппит «Напоминание» на `deals.reminder_at` — **этой колонки больше нет** (удалена в schema v8.3, заменена таблицей `reminders`). Поэтому непустое «Напоминание» создаёт строку `reminders` (`text` = «Импортировано из crm.bp-gr.ru», `remind_at` = дата, `created_by` = импортирующий пользователь).
- Идемпотентность — через `webhook_dedup_keys` по `(tenant_id, source_crm_id)`, та же advisory-lock логика, что в `ProcessWebhookJob`. Повторный импорт: ТЗ §6.5 стадия 3a → UPDATE `status/contact_name/comment` (в отличие от webhook'а, где `status` не перезаписывается — для исторической миграции перезапись осознанная, ТЗ §6.5).
- Каждая строка — в собственной транзакции (`SET LOCAL` + advisory lock + upsert + reminder). Ошибка одной строки не роняет остальные. Партиции создаются один раз заранее.
- `dry_run`: маппинг + валидация + детект неизвестных статусов, без создания партиций/сделок/напоминаний. Возвращает проекцию счётчиков.

**Files:**

- Create: `app/app/Services/Import/ImportResult.php`
- Create: `app/app/Services/Import/HistoricalImportService.php`
- Test: `app/tests/Feature/Import/HistoricalImportServiceTest.php`

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

`app/tests/Feature/Import/HistoricalImportServiceTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Reminder;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Support\Facades\DB;

beforeEach(function (): void {
    $this->tenant = Tenant::factory()->create(['balance_leads' => 5]);
    $this->user = User::factory()->for($this->tenant)->create();
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    $this->service = app(HistoricalImportService::class);
});

function importLog(Tenant $tenant, User $user, bool $dryRun = false): ImportLog
{
    return ImportLog::create([
        'tenant_id' => $tenant->id,
        'user_id' => $user->id,
        'filename' => 'leads.csv',
        'file_path' => 'imports/x.csv',
        'dry_run' => $dryRun,
    ]);
}

function parseFixture(string $body): array
{
    $header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';

    return (new CsvLeadsParser)->parse($header."\n".$body)->rows;
}

test('импортирует исторические лиды, создавая партиции под старые даты', function (): void {
    $rows = parseFixture(
        '5001,Окна,окна,79161112233,2023/07/10 10:00:00,,Комментарий,Переговоры,Иван'
    );

    $result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);

    expect($result->added)->toBe(1)
        ->and($result->updated)->toBe(0);

    $deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
    expect($deal->status)->toBe('negotiations')
        ->and($deal->phone)->toBe('79161112233')
        ->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
});

test('баланс лидов не списывается, фиксируется транзакция historical_import', function (): void {
    $rows = parseFixture(
        '5002,Окна,окна,79161112234,2023/07/11 10:00:00,,,Новые,'
    );

    $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);

    expect($this->tenant->fresh()->balance_leads)->toBe(5); // не изменился

    $tx = DB::table('balance_transactions')
        ->where('tenant_id', $this->tenant->id)
        ->where('type', 'historical_import')
        ->first();
    expect($tx)->not->toBeNull()
        ->and((int) $tx->amount_leads)->toBe(0);
});

test('повторный импорт того же файла не создаёт дублей (idempotent UPDATE)', function (): void {
    $rows = parseFixture(
        '5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Старый,Новые,'
    );
    $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);

    $rows2 = parseFixture(
        '5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Обновлённый,Оплачено,Пётр'
    );
    $result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows2);

    expect($result->added)->toBe(0)
        ->and($result->updated)->toBe(1)
        ->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);

    $deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
    expect($deal->status)->toBe('paid')               // §6.5 стадия 3a: status перезаписан
        ->and($deal->contact_name)->toBe('Пётр')
        ->and($deal->comment)->toBe('Обновлённый');
});

test('непустое «Напоминание» создаёт строку reminders', function (): void {
    $rows = parseFixture(
        '5004,Окна,окна,79161112236,2023/09/01 10:00:00,2023/09/05 14:00:00,,Новые,'
    );
    $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);

    $deal = Deal::query()->where('source_crm_id', 5004)->firstOrFail();
    $reminder = Reminder::query()->where('deal_id', $deal->id)->firstOrFail();
    expect($reminder->remind_at->format('Y-m-d H:i'))->toBe('2023-09-05 14:00')
        ->and($reminder->created_by)->toBe($this->user->id);
});

test('неизвестный статус → сделка new + запись в import_unknown_statuses', function (): void {
    $log = importLog($this->tenant, $this->user);
    $rows = parseFixture(
        "5005,Окна,окна,79161112237,2023/10/01 10:00:00,,,Архив,\n".
        '5006,Окна,окна,79161112238,2023/10/02 10:00:00,,,Архив,'
    );
    $result = $this->service->import($this->tenant->id, $this->user->id, $log, $rows);

    expect($result->unknownStatuses)->toBe(['Архив' => 2])
        ->and(Deal::query()->where('source_crm_id', 5005)->firstOrFail()->status)->toBe('new');

    $unknown = ImportUnknownStatus::query()->where('status_ru', 'Архив')->firstOrFail();
    expect($unknown->occurrences)->toBe(2)
        ->and($unknown->mapped_to_slug)->toBeNull();
});

test('resolved-маппинг tenant-а применяется к ранее неизвестному статусу', function (): void {
    ImportUnknownStatus::create([
        'tenant_id' => $this->tenant->id,
        'status_ru' => 'Архив',
        'occurrences' => 1,
        'mapped_to_slug' => 'closed',
        'resolved_at' => now(),
    ]);
    $rows = parseFixture(
        '5007,Окна,окна,79161112239,2023/11/01 10:00:00,,,Архив,'
    );
    $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);

    expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
});

test('dry_run не пишет сделки, но считает проекцию', function (): void {
    $rows = parseFixture(
        '5008,Окна,окна,79161112240,2023/12/01 10:00:00,,,Новые,'
    );
    $result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user, dryRun: true), $rows);

    expect($result->added)->toBe(1)
        ->and(Deal::query()->where('source_crm_id', 5008)->exists())->toBeFalse();
});
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/HistoricalImportServiceTest.php`
Expected: FAIL — классов нет.

- [ ] **Step 3: Создать DTO `ImportResult`**

`app/app/Services/Import/ImportResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Import;

/**
 * Итог импорта одного файла.
 */
final readonly class ImportResult
{
    /**
     * @param  array<string, int>  $unknownStatuses  статус_ru => количество вхождений
     * @param  array<int, array{line: int, message: string}>  $errors
     */
    public function __construct(
        public int $added,
        public int $updated,
        public int $skipped,
        public array $unknownStatuses,
        public array $errors,
    ) {}
}
```

- [ ] **Step 4: Создать `HistoricalImportService`**

`app/app/Services/Import/HistoricalImportService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Import;

use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Project;
use App\Models\Reminder;
use App\Services\MonthlyPartitionManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Оркестрация исторической миграции лидов из CSV crm.bp-gr.ru (ТЗ §6).
 *
 * Идемпотентность — через webhook_dedup_keys (та же advisory-lock логика, что
 * ProcessWebhookJob). Баланс НЕ списывается: исторические данные не являются
 * новыми лидами (ТЗ §6.5) — фиксируется одна транзакция типа historical_import.
 */
class HistoricalImportService
{
    public function __construct(
        private readonly MonthlyPartitionManager $partitions,
        private readonly StatusRuToSlugMapper $statusMapper,
    ) {}

    /**
     * @param  array<int, ParsedLeadRow>  $rows
     */
    public function import(int $tenantId, int $userId, ImportLog $log, array $rows, bool $dryRun = false): ImportResult
    {
        $dryRun = $dryRun || $log->dry_run;

        if ($rows === []) {
            return new ImportResult(0, 0, 0, [], []);
        }

        // Партиции deals под исторический диапазон дат CSV (один раз заранее).
        if (! $dryRun) {
            $dates = array_map(fn (ParsedLeadRow $r) => $r->receivedAt, $rows);
            $this->partitions->ensureRange(
                'deals',
                min($dates),
                max($dates),
            );
        }

        // Tenant-резолвленные переопределения неизвестных статусов.
        $overrides = $this->loadStatusOverrides($tenantId);

        $added = 0;
        $updated = 0;
        $skipped = 0;
        $unknown = [];
        $errors = [];

        foreach ($rows as $row) {
            $slug = $this->resolveStatus($row->statusRu, $overrides, $unknown);

            if ($dryRun) {
                $added++; // проекция: для dry-run не различаем add/update

                continue;
            }

            try {
                $wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
                $wasCreated ? $added++ : $updated++;
            } catch (Throwable $e) {
                $skipped++;
                $errors[] = ['line' => $row->sourceCrmId, 'message' => $e->getMessage()];
                Log::warning('import.row_failed', ['source_crm_id' => $row->sourceCrmId, 'error' => $e->getMessage()]);
            }
        }

        if (! $dryRun) {
            $this->persistUnknownStatuses($tenantId, $userId, $log->id, $unknown);
            $this->recordHistoricalTransaction($tenantId, $added + $updated);
        }

        return new ImportResult($added, $updated, $skipped, $unknown, $errors);
    }

    /**
     * @return array<string, string> status_ru => slug (только resolved)
     */
    private function loadStatusOverrides(int $tenantId): array
    {
        return DB::transaction(function () use ($tenantId): array {
            DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

            return ImportUnknownStatus::query()
                ->whereNotNull('mapped_to_slug')
                ->pluck('mapped_to_slug', 'status_ru')
                ->all();
        });
    }

    /**
     * Маппит статус: каноническая таблица §6.4 → tenant-override → fallback 'new'.
     * Неизвестный статус инкрементит счётчик в $unknown по ссылке.
     *
     * @param  array<string, string>  $overrides
     * @param  array<string, int>  $unknown
     */
    private function resolveStatus(string $statusRu, array $overrides, array &$unknown): string
    {
        $slug = $this->statusMapper->toSlug($statusRu);
        if ($slug !== null) {
            return $slug;
        }

        $key = trim($statusRu);
        if (isset($overrides[$key])) {
            return $overrides[$key];
        }

        $unknown[$key] = ($unknown[$key] ?? 0) + 1;

        return 'new';
    }

    /**
     * Идемпотентный upsert одной строки в собственной транзакции.
     * Возвращает true — создана новая сделка, false — обновлена существующая.
     */
    private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
    {
        return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
            DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

            $project = Project::firstOrCreate(
                ['tenant_id' => $tenantId, 'name' => $row->projectName],
                ['tag' => $row->projectTag, 'type' => 'import'],
            );

            // advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5).
            $lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
            DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);

            $existing = DB::selectOne(
                'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
                [$tenantId, $row->sourceCrmId],
            );

            if ($existing !== null) {
                $deal = Deal::query()
                    ->where('id', $existing->deal_id)
                    ->where('received_at', $existing->deal_received_at)
                    ->firstOrFail();
                // §6.5 стадия 3a: для исторической миграции status перезаписывается.
                $deal->update([
                    'status' => $slug,
                    'contact_name' => $row->contactName,
                    'comment' => $row->comment,
                ]);
                $this->syncReminder($tenantId, $userId, $deal, $row);

                return false;
            }

            $deal = Deal::create([
                'tenant_id' => $tenantId,
                'source_crm_id' => $row->sourceCrmId,
                'project_id' => $project->id,
                'phone' => $row->phone,
                'status' => $slug,
                'contact_name' => $row->contactName,
                'comment' => $row->comment,
                'received_at' => $row->receivedAt,
            ]);

            DB::table('webhook_dedup_keys')->insert([
                'tenant_id' => $tenantId,
                'source_crm_id' => $row->sourceCrmId,
                'deal_id' => $deal->id,
                'deal_received_at' => $deal->received_at,
                'created_at' => now(),
            ]);

            $this->syncReminder($tenantId, $userId, $deal, $row);

            return true;
        });
    }

    /**
     * Создаёт reminders-строку для непустого «Напоминание» (ТЗ §6.3 — поле
     * deals.reminder_at удалено в v8.3, заменено таблицей reminders).
     * Идемпотентно: не дублирует напоминание при повторном импорте.
     */
    private function syncReminder(int $tenantId, int $userId, Deal $deal, ParsedLeadRow $row): void
    {
        if ($row->reminderAt === null) {
            return;
        }

        $exists = Reminder::query()
            ->where('deal_id', $deal->id)
            ->where('remind_at', $row->reminderAt)
            ->exists();

        if ($exists) {
            return;
        }

        Reminder::create([
            'tenant_id' => $tenantId,
            'deal_id' => $deal->id,
            'text' => 'Импортировано из crm.bp-gr.ru',
            'remind_at' => $row->reminderAt,
            'created_by' => $userId,
        ]);
    }

    /**
     * upsert import_unknown_statuses: инкремент occurrences, маппинг не трогаем.
     *
     * @param  array<string, int>  $unknown
     */
    private function persistUnknownStatuses(int $tenantId, int $userId, int $importLogId, array $unknown): void
    {
        if ($unknown === []) {
            return;
        }

        DB::transaction(function () use ($tenantId, $importLogId, $unknown): void {
            DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

            foreach ($unknown as $statusRu => $count) {
                $existing = ImportUnknownStatus::query()->where('status_ru', $statusRu)->first();

                if ($existing !== null) {
                    $existing->increment('occurrences', $count);

                    continue;
                }

                ImportUnknownStatus::create([
                    'tenant_id' => $tenantId,
                    'import_log_id' => $importLogId,
                    'status_ru' => $statusRu,
                    'occurrences' => $count,
                ]);
            }
        });
    }

    /**
     * Одна информационная транзакция historical_import (баланс не меняется, ТЗ §6.5).
     */
    private function recordHistoricalTransaction(int $tenantId, int $count): void
    {
        if ($count === 0) {
            return;
        }

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

            DB::table('balance_transactions')->insert([
                'tenant_id' => $tenantId,
                'type' => 'historical_import',
                'amount_rub' => 0,
                'amount_leads' => 0,
                'description' => "Импортировано {$count} исторических сделок (баланс не списан)",
                'created_at' => now(),
            ]);
        });
    }
}
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/HistoricalImportServiceTest.php`
Expected: PASS 8/8.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Services/Import/ImportResult.php app/app/Services/Import/HistoricalImportService.php app/tests/Feature/Import/HistoricalImportServiceTest.php
git commit -m "feat(import): HistoricalImportService — идемпотентный upsert лидов"
```

---

## Task 7: `ImportLeadsJob` — queued-обработчик

Queued-job: читает CSV с диска, парсит, прогоняет через `HistoricalImportService`, обновляет `import_log` (статусы pending→processing→done/failed, счётчики), отправляет email.

**Files:**

- Create: `app/app/Jobs/ImportLeadsJob.php`
- Test: `app/tests/Feature/Import/ImportLeadsJobTest.php`

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

`app/tests/Feature/Import/ImportLeadsJobTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\ImportLeadsJob;
use App\Mail\ImportCompletedNotification;
use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;

beforeEach(function (): void {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->for($this->tenant)->create();
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    Mail::fake();
});

function storedCsv(int $tenantId, string $body): string
{
    $header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
    $path = "imports/{$tenantId}/test.csv";
    Storage::disk('local')->put($path, $header."\n".$body);

    return $path;
}

test('job импортирует лиды и переводит import_log в done', function (): void {
    $path = storedCsv($this->tenant->id,
        '7001,Окна,окна,79161112233,2023/05/10 10:00:00,,,Новые,Иван'
    );
    $log = ImportLog::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'filename' => 'leads.csv',
        'file_path' => $path,
        'status' => 'pending',
    ]);

    (new ImportLeadsJob($log->id, $this->tenant->id))->handle(app(\App\Services\Import\HistoricalImportService::class), app(\App\Services\Import\CsvLeadsParser::class));

    $log->refresh();
    expect($log->status)->toBe('done')
        ->and($log->rows_added)->toBe(1)
        ->and($log->finished_at)->not->toBeNull()
        ->and(Deal::query()->where('source_crm_id', 7001)->exists())->toBeTrue();

    Mail::assertSent(ImportCompletedNotification::class);
});

test('job переводит import_log в failed при отсутствии файла', function (): void {
    $log = ImportLog::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'filename' => 'leads.csv',
        'file_path' => 'imports/missing.csv',
        'status' => 'pending',
    ]);

    (new ImportLeadsJob($log->id, $this->tenant->id))->handle(app(\App\Services\Import\HistoricalImportService::class), app(\App\Services\Import\CsvLeadsParser::class));

    expect($log->refresh()->status)->toBe('failed')
        ->and($log->error_message)->not->toBeNull();
});

test('job пишет unknown_statuses_count и rows_skipped', function (): void {
    $path = storedCsv($this->tenant->id,
        "7002,Окна,окна,79161112234,2023/05/11 10:00:00,,,Архив,\n".
        '7003,Окна,окна,BADPHONE,2023/05/12 10:00:00,,,Новые,'
    );
    $log = ImportLog::create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'filename' => 'leads.csv',
        'file_path' => $path,
        'status' => 'pending',
    ]);

    (new ImportLeadsJob($log->id, $this->tenant->id))->handle(app(\App\Services\Import\HistoricalImportService::class), app(\App\Services\Import\CsvLeadsParser::class));

    $log->refresh();
    expect($log->status)->toBe('done')
        ->and($log->unknown_statuses_count)->toBe(1)   // «Архив»
        ->and($log->rows_skipped)->toBe(1);            // битый телефон
});
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportLeadsJobTest.php`
Expected: FAIL — класса `ImportLeadsJob` нет.

- [ ] **Step 3: Создать `ImportLeadsJob`**

`app/app/Jobs/ImportLeadsJob.php`:

```php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Mail\ImportCompletedNotification;
use App\Models\ImportLog;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Throwable;

/**
 * Асинхронная обработка CSV-импорта исторических лидов (ТЗ §6.6).
 *
 * Жизненный цикл import_log: pending → processing → done | failed.
 * RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
 * вне middleware-контекста — паритет с ProcessWebhookJob).
 */
class ImportLeadsJob implements ShouldQueue
{
    use FoundationQueueable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 1;

    public int $timeout = 600;

    public function __construct(
        public int $importLogId,
        public int $tenantId,
    ) {}

    public function handle(HistoricalImportService $service, CsvLeadsParser $parser): void
    {
        $log = $this->loadLog();
        if ($log === null) {
            Log::error('import.log_not_found', ['import_log_id' => $this->importLogId]);

            return;
        }

        $this->updateLog($log->id, ['status' => 'processing', 'started_at' => now()]);

        try {
            if (! Storage::disk('local')->exists($log->file_path)) {
                throw new \RuntimeException("Файл импорта не найден: {$log->file_path}");
            }

            $content = (string) Storage::disk('local')->get($log->file_path);
            $parsed = $parser->parse($content);

            $result = $service->import($this->tenantId, $log->user_id, $log, $parsed->rows, $log->dry_run);

            $this->updateLog($log->id, [
                'status' => 'done',
                'rows_total' => count($parsed->rows) + count($parsed->errors),
                'rows_added' => $result->added,
                'rows_updated' => $result->updated,
                'rows_skipped' => count($parsed->errors) + $result->skipped,
                'unknown_statuses_count' => count($result->unknownStatuses),
                'finished_at' => now(),
            ]);

            $this->notify($log->user_id, 'done');
        } catch (Throwable $e) {
            Log::error('import.job_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
            $this->updateLog($log->id, [
                'status' => 'failed',
                'error_message' => $e->getMessage(),
                'finished_at' => now(),
            ]);
            $this->notify($log->user_id, 'failed');
        }
    }

    private function loadLog(): ?ImportLog
    {
        return DB::transaction(function (): ?ImportLog {
            DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);

            return ImportLog::query()->find($this->importLogId);
        });
    }

    /**
     * @param  array<string, mixed>  $attributes
     */
    private function updateLog(int $logId, array $attributes): void
    {
        DB::transaction(function () use ($logId, $attributes): void {
            DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
            ImportLog::query()->whereKey($logId)->update($attributes);
        });
    }

    private function notify(int $userId, string $outcome): void
    {
        $log = $this->loadLog();
        $user = DB::transaction(function () use ($userId): ?User {
            DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);

            return User::query()->find($userId);
        });

        if ($log === null || $user === null || $user->email === null) {
            return;
        }

        try {
            Mail::to($user->email)->send(new ImportCompletedNotification($log, $outcome));
        } catch (Throwable $e) {
            // Отказ почтового канала не должен валить успешный импорт.
            Log::warning('import.mail_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
        }
    }
}
```

> NB: `tries = 1` — импорт идемпотентен на уровне строк, но повторный прогон всего job'а после частичного фейла усложняет счётчики; на MVP — без авто-ретрая, при `failed` пользователь перезапускает вручную.

- [ ] **Step 4: Прогнать тест (зависит от Task 8 Mailable)**

`ImportLeadsJob` ссылается на `ImportCompletedNotification` — тест Step 1 пройдёт только после Task 8. Допускается реализовать Task 8 до прогона. Временный прогон без Mailable:

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportLeadsJobTest.php`
Expected: FAIL с `Class "App\Mail\ImportCompletedNotification" not found` — это ожидаемо, закрывается Task 8.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Jobs/ImportLeadsJob.php app/tests/Feature/Import/ImportLeadsJobTest.php
git commit -m "feat(import): ImportLeadsJob — queued-обработчик CSV-импорта"
```

---

## Task 8: Mailable `ImportCompletedNotification`

Email пользователю по завершении импорта (ТЗ §6.6 — «Email пользователю + уведомление в интерфейсе»).

**Files:**

- Create: `app/app/Mail/ImportCompletedNotification.php`
- Create: `app/resources/views/mail/import-completed.blade.php`
- Test: `app/tests/Feature/Import/ImportCompletedNotificationTest.php`

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

`app/tests/Feature/Import/ImportCompletedNotificationTest.php`:

```php
<?php

declare(strict_types=1);

use App\Mail\ImportCompletedNotification;
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;

beforeEach(function (): void {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->for($this->tenant)->create();
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
});

test('письмо об успешном импорте содержит счётчики', function (): void {
    $log = ImportLog::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'status' => 'done',
        'rows_added' => 120,
        'rows_updated' => 8,
        'rows_skipped' => 2,
    ]);

    $rendered = (new ImportCompletedNotification($log, 'done'))->render();

    expect($rendered)->toContain('120')
        ->and($rendered)->toContain('Импорт завершён');
});

test('письмо о неуспешном импорте сообщает об ошибке', function (): void {
    $log = ImportLog::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'status' => 'failed',
        'error_message' => 'Файл повреждён',
    ]);

    $mailable = new ImportCompletedNotification($log, 'failed');
    $rendered = $mailable->render();

    expect($rendered)->toContain('Файл повреждён')
        ->and($mailable->envelope()->subject)->toContain('не удался');
});
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportCompletedNotificationTest.php`
Expected: FAIL — класса нет.

- [ ] **Step 3: Создать Mailable**

`app/app/Mail/ImportCompletedNotification.php`:

```php
<?php

declare(strict_types=1);

namespace App\Mail;

use App\Models\ImportLog;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/**
 * Уведомление о завершении CSV-импорта исторических лидов (ТЗ §6.6).
 */
class ImportCompletedNotification extends Mailable
{
    use Queueable;
    use SerializesModels;

    /**
     * @param  string  $outcome  'done' | 'failed'
     */
    public function __construct(
        public ImportLog $log,
        public string $outcome,
    ) {}

    public function envelope(): Envelope
    {
        $subject = $this->outcome === 'done'
            ? 'Импорт данных завершён — Лидерра'
            : 'Импорт данных не удался — Лидерра';

        return new Envelope(subject: $subject);
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'mail.import-completed',
            with: [
                'log' => $this->log,
                'outcome' => $this->outcome,
            ],
        );
    }
}
```

- [ ] **Step 4: Создать blade-шаблон**

`app/resources/views/mail/import-completed.blade.php`:

```blade
<x-mail::message>
@if ($outcome === 'done')
# Импорт завершён

Импорт файла **{{ $log->filename }}** успешно завершён.

| Показатель | Значение |
|:-----------|---------:|
| Добавлено сделок | {{ $log->rows_added }} |
| Обновлено сделок | {{ $log->rows_updated }} |
| Пропущено строк | {{ $log->rows_skipped }} |
| Неизвестных статусов | {{ $log->unknown_statuses_count }} |

@if ($log->unknown_statuses_count > 0)
Обнаружены неизвестные статусы воронки — замапьте их вручную на экране «Импорт данных».
@endif
@else
# Импорт не удался

Импорт файла **{{ $log->filename }}** завершился ошибкой:

> {{ $log->error_message }}

Проверьте формат файла и повторите загрузку на экране «Импорт данных».
@endif

<x-mail::button :url="config('app.url').'/import'">
Открыть «Импорт данных»
</x-mail::button>

С уважением,<br>
Лидерра
</x-mail::message>
```

> NB: компоненты `<x-mail::message>` / `<x-mail::button>` — стандартные Laravel mail-компоненты. Если в проекте не опубликованы — выполнить `php artisan vendor:publish --tag=laravel-mail` (проверить наличие `resources/views/vendor/mail/`; другие Mailable проекта используют те же компоненты — публикация уже могла быть сделана).

- [ ] **Step 5: Прогнать тесты — Mailable + job из Task 7**

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportCompletedNotificationTest.php tests/Feature/Import/ImportLeadsJobTest.php`
Expected: PASS — Mailable 2/2 + `ImportLeadsJobTest` 3/3 (теперь зелёный — Mailable существует).

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Mail/ImportCompletedNotification.php app/resources/views/mail/import-completed.blade.php app/tests/Feature/Import/ImportCompletedNotificationTest.php
git commit -m "feat(import): Mailable ImportCompletedNotification"
```

---

## Task 9: `ImportController` + маршруты + FormRequests

REST-эндпойнты импорта под `auth:sanctum`+`tenant` (паритет с J1 Sprint 3F).

**Files:**

- Create: `app/app/Http/Requests/StoreImportRequest.php`
- Create: `app/app/Http/Requests/ResolveUnknownStatusesRequest.php`
- Create: `app/app/Http/Controllers/Api/ImportController.php`
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/Import/ImportControllerTest.php`

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

`app/tests/Feature/Import/ImportControllerTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\ImportLeadsJob;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;

beforeEach(function (): void {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->for($this->tenant)->create();
    $this->actingAs($this->user);
});

test('POST /api/imports принимает CSV, создаёт import_log, диспатчит job', function (): void {
    Queue::fake();
    Storage::fake('local');

    $csv = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя'."\n".
        '9001,Окна,окна,79161112233,2023/05/10 10:00:00,,,Новые,';

    $response = $this->postJson('/api/imports', [
        'file' => UploadedFile::fake()->createWithContent('leads.csv', $csv),
    ]);

    $response->assertStatus(201)
        ->assertJsonPath('data.status', 'pending')
        ->assertJsonPath('data.filename', 'leads.csv');

    Queue::assertPushed(ImportLeadsJob::class);
    expect(ImportLog::query()->count())->toBe(1);
});

test('POST /api/imports отвергает не-CSV файл', function (): void {
    Storage::fake('local');

    $response = $this->postJson('/api/imports', [
        'file' => UploadedFile::fake()->create('image.png', 10, 'image/png'),
    ]);

    $response->assertStatus(422)->assertJsonValidationErrorFor('file');
});

test('POST /api/imports требует авторизации', function (): void {
    app('auth')->forgetGuards();

    $this->postJson('/api/imports', [])->assertStatus(401);
});

test('GET /api/imports возвращает только import_log своего тенанта', function (): void {
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    ImportLog::factory()->count(2)->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
    ]);

    $this->getJson('/api/imports')
        ->assertStatus(200)
        ->assertJsonCount(2, 'data');
});

test('GET /api/imports/{id} отдаёт прогресс', function (): void {
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    $log = ImportLog::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'status' => 'processing',
        'rows_added' => 10,
    ]);

    $this->getJson("/api/imports/{$log->id}")
        ->assertStatus(200)
        ->assertJsonPath('data.status', 'processing')
        ->assertJsonPath('data.rows_added', 10);
});

test('GET /api/imports/unknown-statuses возвращает незамапленные статусы', function (): void {
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    ImportUnknownStatus::create([
        'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 3,
    ]);
    ImportUnknownStatus::create([
        'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
        'mapped_to_slug' => 'closed', 'resolved_at' => now(),
    ]);

    $this->getJson('/api/imports/unknown-statuses')
        ->assertStatus(200)
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.status_ru', 'Архив');
});

test('POST /api/imports/unknown-statuses/resolve проставляет маппинг', function (): void {
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    $unknown = ImportUnknownStatus::create([
        'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 3,
    ]);

    $this->postJson('/api/imports/unknown-statuses/resolve', [
        'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
    ])->assertStatus(200);

    expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
        ->and($unknown->resolved_at)->not->toBeNull();
});

test('resolve отвергает несуществующий slug', function (): void {
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);

    $this->postJson('/api/imports/unknown-statuses/resolve', [
        'mappings' => [['status_ru' => 'Архив', 'slug' => 'нет-такого']],
    ])->assertStatus(422);
});
```

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportControllerTest.php`
Expected: FAIL — контроллера/маршрутов нет (404/500).

- [ ] **Step 3: Создать `StoreImportRequest`**

`app/app/Http/Requests/StoreImportRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/**
 * Валидация загрузки CSV-файла импорта (ТЗ §6.2).
 */
class StoreImportRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            // mimes csv,txt — экспорт crm.bp-gr.ru отдаётся как text/csv или text/plain.
            'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
            'dry_run' => ['sometimes', 'boolean'],
        ];
    }
}
```

- [ ] **Step 4: Создать `ResolveUnknownStatusesRequest`**

`app/app/Http/Requests/ResolveUnknownStatusesRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

/**
 * Валидация ручного маппинга неизвестных статусов воронки (§6.4 wizard).
 */
class ResolveUnknownStatusesRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'mappings' => ['required', 'array', 'min:1'],
            'mappings.*.status_ru' => ['required', 'string', 'max:100'],
            'mappings.*.slug' => ['required', 'string', Rule::exists('lead_statuses', 'slug')],
        ];
    }
}
```

- [ ] **Step 5: Создать `ImportController`**

`app/app/Http/Controllers/Api/ImportController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\ResolveUnknownStatusesRequest;
use App\Http\Requests\StoreImportRequest;
use App\Jobs\ImportLeadsJob;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * CSV-импорт исторических лидов из crm.bp-gr.ru (ТЗ §6).
 *
 * Все маршруты — под auth:sanctum + tenant (RLS-контекст задан middleware).
 * tenant_id берётся из авторизованного пользователя, не из запроса.
 */
class ImportController extends Controller
{
    /**
     * POST /api/imports — загрузка CSV, создание import_log, dispatch job'а.
     */
    public function store(StoreImportRequest $request): JsonResponse
    {
        $tenantId = (int) $request->user()->tenant_id;
        $file = $request->file('file');
        $storedName = Str::uuid()->toString().'.csv';
        $path = $file->storeAs("imports/{$tenantId}", $storedName, 'local');

        $log = ImportLog::create([
            'tenant_id' => $tenantId,
            'user_id' => $request->user()->id,
            'filename' => $file->getClientOriginalName(),
            'file_path' => $path,
            'status' => 'pending',
            'entity_type' => 'leads',
            'source_system' => 'crm.bp-gr.ru',
            'dry_run' => $request->boolean('dry_run'),
        ]);

        ImportLeadsJob::dispatch($log->id, $tenantId);

        return response()->json(['data' => $this->toResource($log)], 201);
    }

    /**
     * GET /api/imports — история импортов тенанта (RLS отфильтрует по tenant).
     */
    public function index(): JsonResponse
    {
        $logs = ImportLog::query()
            ->orderByDesc('id')
            ->limit(50)
            ->get()
            ->map(fn (ImportLog $log) => $this->toResource($log));

        return response()->json(['data' => $logs]);
    }

    /**
     * GET /api/imports/{importLog} — прогресс одного импорта (для polling'а).
     */
    public function show(ImportLog $importLog): JsonResponse
    {
        return response()->json(['data' => $this->toResource($importLog)]);
    }

    /**
     * GET /api/imports/unknown-statuses — незамапленные статусы (вход wizard'а §6.6).
     */
    public function unknownStatuses(): JsonResponse
    {
        $rows = ImportUnknownStatus::query()
            ->unresolved()
            ->orderByDesc('occurrences')
            ->get()
            ->map(fn (ImportUnknownStatus $s) => [
                'id' => $s->id,
                'status_ru' => $s->status_ru,
                'occurrences' => $s->occurrences,
            ]);

        return response()->json(['data' => $rows]);
    }

    /**
     * POST /api/imports/unknown-statuses/resolve — ручной маппинг статусов.
     */
    public function resolveUnknownStatuses(ResolveUnknownStatusesRequest $request): JsonResponse
    {
        $tenantId = (int) $request->user()->tenant_id;
        $userId = (int) $request->user()->id;

        DB::transaction(function () use ($request, $tenantId, $userId): void {
            DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

            foreach ($request->validated()['mappings'] as $mapping) {
                ImportUnknownStatus::query()
                    ->where('status_ru', $mapping['status_ru'])
                    ->update([
                        'mapped_to_slug' => $mapping['slug'],
                        'resolved_at' => now(),
                        'resolved_by' => $userId,
                    ]);
            }
        });

        return response()->json(['data' => ['resolved' => count($request->validated()['mappings'])]]);
    }

    /**
     * @return array<string, mixed>
     */
    private function toResource(ImportLog $log): array
    {
        return [
            'id' => $log->id,
            'filename' => $log->filename,
            'status' => $log->status,
            'rows_total' => $log->rows_total,
            'rows_added' => $log->rows_added,
            'rows_updated' => $log->rows_updated,
            'rows_skipped' => $log->rows_skipped,
            'unknown_statuses_count' => $log->unknown_statuses_count,
            'dry_run' => $log->dry_run,
            'error_message' => $log->error_message,
            'started_at' => $log->started_at?->toIso8601String(),
            'finished_at' => $log->finished_at?->toIso8601String(),
        ];
    }
}
```

> NB: route-model-binding `{importLog}` резолвится внутри tenant-транзакции middleware `tenant` → RLS автоматически отсекает чужие `import_log` (вернёт 404). Параметр `request` в `index()`/`unknownStatuses()` не нужен — tenant-контекст уже в RLS.

- [ ] **Step 6: Зарегистрировать маршруты в `routes/web.php`**

Найти группу `Route::middleware(['auth:sanctum', 'tenant'])->group(function () { ... })` (создана в Sprint 3F J1 для `/api/deals*`). Внутри неё добавить:

```php
// Sprint 4 — CSV-импорт исторических лидов (ТЗ §6).
Route::get('/api/imports/unknown-statuses', [\App\Http\Controllers\Api\ImportController::class, 'unknownStatuses']);
Route::post('/api/imports/unknown-statuses/resolve', [\App\Http\Controllers\Api\ImportController::class, 'resolveUnknownStatuses']);
Route::get('/api/imports', [\App\Http\Controllers\Api\ImportController::class, 'index']);
Route::post('/api/imports', [\App\Http\Controllers\Api\ImportController::class, 'store']);
Route::get('/api/imports/{importLog}', [\App\Http\Controllers\Api\ImportController::class, 'show']);
```

> ВАЖНО: маршрут `/api/imports/unknown-statuses` объявить **до** `/api/imports/{importLog}` — иначе `{importLog}` перехватит `unknown-statuses` как id. Если в файле используется единый стиль импортов классов сверху — добавить `use App\Http\Controllers\Api\ImportController;` и сократить ссылки.

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

Run: `cd app && php vendor/bin/pest tests/Feature/Import/ImportControllerTest.php`
Expected: PASS 8/8.

- [ ] **Step 8: Регенерация Larastan baseline + полный backend-прогон (quirk 25)**

Новые Pest-файлы (`tests/Feature/Import/*`, `tests/Unit/Import/*`) дают ложные `TestCall` ошибки Larastan. Регенерировать baseline:

1. В `app/phpstan.neon` закомментировать строку `- phpstan-baseline.neon`.
2. Run: `cd app && php vendor/bin/phpstan analyse --generate-baseline`
3. Раскомментировать строку обратно.

Затем полный прогон логического блока (бэкенд Sprint 4 готов):

Run: `cd app && php vendor/bin/pest tests/Feature/Import tests/Unit/Import`
Run: `cd app && composer pint && composer stan`
Expected: Pest — все Import-тесты зелёные; Pint — clean; Larastan — 0 errors.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/app/Http/Controllers/Api/ImportController.php app/app/Http/Requests/StoreImportRequest.php app/app/Http/Requests/ResolveUnknownStatusesRequest.php app/routes/web.php app/tests/Feature/Import/ImportControllerTest.php app/phpstan-baseline.neon
git commit -m "feat(import): ImportController + маршруты /api/imports"
```

---

## Task 10: Frontend — `api/imports.ts` + `ImportView.vue`

Экран импорта: загрузка файла, прогресс (polling), таблица результата, история импортов.

**Files:**

- Create: `app/resources/js/api/imports.ts`
- Create: `app/resources/js/views/ImportView.vue`
- Test: `app/resources/js/views/__tests__/ImportView.spec.ts`

> Пре-проверка перед написанием: открыть существующий `app/resources/js/api/dashboard.ts` — повторить его стиль (axios-инстанс, экспорт функций, типы). Открыть соседний `*.spec.ts` (напр. `views/__tests__/DashboardView.spec.ts`) — повторить расположение и стиль тестов (mount, мок axios). Если каталог `__tests__` называется иначе — положить spec по фактической конвенции.

- [ ] **Step 1: Создать `api/imports.ts`**

`app/resources/js/api/imports.ts`:

```ts
import axios from 'axios';

export interface ImportLogResource {
    id: number;
    filename: string;
    status: 'pending' | 'processing' | 'done' | 'failed';
    rows_total: number;
    rows_added: number;
    rows_updated: number;
    rows_skipped: number;
    unknown_statuses_count: number;
    dry_run: boolean;
    error_message: string | null;
    started_at: string | null;
    finished_at: string | null;
}

export interface UnknownStatus {
    id: number;
    status_ru: string;
    occurrences: number;
}

export interface StatusMapping {
    status_ru: string;
    slug: string;
}

/** POST /api/imports — загрузить CSV. */
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
    const form = new FormData();
    form.append('file', file);
    if (dryRun) {
        form.append('dry_run', '1');
    }
    const { data } = await axios.post<{ data: ImportLogResource }>('/api/imports', form);
    return data.data;
}

/** GET /api/imports — история импортов. */
export async function listImports(): Promise<ImportLogResource[]> {
    const { data } = await axios.get<{ data: ImportLogResource[] }>('/api/imports');
    return data.data;
}

/** GET /api/imports/{id} — прогресс одного импорта. */
export async function getImport(id: number): Promise<ImportLogResource> {
    const { data } = await axios.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
    return data.data;
}

/** GET /api/imports/unknown-statuses — незамапленные статусы. */
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
    const { data } = await axios.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
    return data.data;
}

/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
    await axios.post('/api/imports/unknown-statuses/resolve', { mappings });
}
```

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

`app/resources/js/views/__tests__/ImportView.spec.ts`:

```ts
import { mount, flushPromises } from '@vue/test-utils';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import ImportView from '../ImportView.vue';
import * as importsApi from '../../api/imports';

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

function mountView() {
    return mount(ImportView, {
        global: {
            plugins: [vuetify],
            stubs: { UnknownStatusesDialog: true },
        },
    });
}

describe('ImportView', () => {
    beforeEach(() => {
        vi.restoreAllMocks();
        vi.spyOn(importsApi, 'listImports').mockResolvedValue([]);
        vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([]);
    });

    it('грузит историю импортов при монтировании', async () => {
        const spy = vi.spyOn(importsApi, 'listImports').mockResolvedValue([
            {
                id: 1, filename: 'leads.csv', status: 'done', rows_total: 5,
                rows_added: 5, rows_updated: 0, rows_skipped: 0,
                unknown_statuses_count: 0, dry_run: false,
                error_message: null, started_at: null, finished_at: null,
            },
        ]);
        const wrapper = mountView();
        await flushPromises();

        expect(spy).toHaveBeenCalled();
        expect(wrapper.text()).toContain('leads.csv');
    });

    it('кнопка загрузки заблокирована без выбранного файла', async () => {
        const wrapper = mountView();
        await flushPromises();

        const uploadBtn = wrapper.find('[data-test="upload-btn"]');
        expect(uploadBtn.attributes('disabled')).toBeDefined();
    });

    it('показывает баннер о неизвестных статусах', async () => {
        vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([
            { id: 1, status_ru: 'Архив', occurrences: 3 },
        ]);
        const wrapper = mountView();
        await flushPromises();

        expect(wrapper.find('[data-test="unknown-banner"]').exists()).toBe(true);
    });
});
```

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

Run: `cd app && npm run test:vue -- ImportView`
Expected: FAIL — `ImportView.vue` нет.

- [ ] **Step 4: Создать `ImportView.vue`**

`app/resources/js/views/ImportView.vue`:

```vue
<script setup lang="ts">
/**
 * Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
 *
 * Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
 * Неизвестные статусы маппятся через UnknownStatusesDialog.
 */
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
    getImport,
    getUnknownStatuses,
    listImports,
    uploadImport,
    type ImportLogResource,
    type UnknownStatus,
} from '../api/imports';
import UnknownStatusesDialog from '../components/import/UnknownStatusesDialog.vue';

const file = ref<File | null>(null);
const dryRun = ref(false);
const uploading = ref(false);
const errorMessage = ref<string | null>(null);
const history = ref<ImportLogResource[]>([]);
const activeImport = ref<ImportLogResource | null>(null);
const unknownStatuses = ref<UnknownStatus[]>([]);
const wizardOpen = ref(false);

let pollTimer: ReturnType<typeof setInterval> | null = null;

const canUpload = computed(() => file.value !== null && !uploading.value);
const isProcessing = computed(
    () => activeImport.value?.status === 'pending' || activeImport.value?.status === 'processing',
);

async function refreshHistory(): Promise<void> {
    try {
        history.value = await listImports();
    } catch {
        // история — не критично, тихо игнорируем
    }
}

async function refreshUnknown(): Promise<void> {
    try {
        unknownStatuses.value = await getUnknownStatuses();
    } catch {
        unknownStatuses.value = [];
    }
}

function stopPolling(): void {
    if (pollTimer !== null) {
        clearInterval(pollTimer);
        pollTimer = null;
    }
}

function startPolling(id: number): void {
    stopPolling();
    pollTimer = setInterval(async () => {
        try {
            activeImport.value = await getImport(id);
            if (!isProcessing.value) {
                stopPolling();
                await refreshHistory();
                await refreshUnknown();
            }
        } catch {
            stopPolling();
        }
    }, 2000);
}

async function submit(): Promise<void> {
    if (file.value === null) {
        return;
    }
    uploading.value = true;
    errorMessage.value = null;
    try {
        activeImport.value = await uploadImport(file.value, dryRun.value);
        startPolling(activeImport.value.id);
        file.value = null;
    } catch (e: unknown) {
        errorMessage.value = 'Не удалось загрузить файл. Проверьте формат (CSV, до 10 МБ).';
    } finally {
        uploading.value = false;
    }
}

async function onWizardResolved(): Promise<void> {
    wizardOpen.value = false;
    await refreshUnknown();
}

onMounted(async () => {
    await refreshHistory();
    await refreshUnknown();
});

onUnmounted(stopPolling);
</script>

<template>
    <v-container fluid class="import-view pa-6">
        <header class="page-head mb-4">
            <h1 class="text-h4 mb-2">Импорт данных</h1>
            <p class="text-body-2 text-medium-emphasis ma-0">
                Перенос исторических лидов из crm.bp-gr.ru. Формат — CSV-выгрузка (UTF-8).
            </p>
        </header>

        <v-alert
            v-if="unknownStatuses.length > 0"
            data-test="unknown-banner"
            type="warning"
            variant="tonal"
            class="mb-4"
        >
            Найдено {{ unknownStatuses.length }} неизвестных статусов воронки — замапьте вручную.
            <template #append>
                <v-btn size="small" variant="flat" @click="wizardOpen = true">Замапить</v-btn>
            </template>
        </v-alert>

        <v-card variant="outlined" class="pa-6 mb-6">
            <v-file-input
                v-model="file"
                label="CSV-файл выгрузки лидов"
                accept=".csv,text/csv"
                prepend-icon="mdi-database-import-outline"
                variant="outlined"
                density="comfortable"
                :disabled="uploading"
            />
            <v-checkbox
                v-model="dryRun"
                label="Пробный прогон (проверить файл без записи сделок)"
                density="compact"
                hide-details
            />
            <v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
                {{ errorMessage }}
            </v-alert>
            <div class="mt-4">
                <v-btn
                    data-test="upload-btn"
                    color="primary"
                    :loading="uploading"
                    :disabled="!canUpload"
                    @click="submit"
                >
                    Загрузить
                </v-btn>
            </div>
        </v-card>

        <v-card v-if="activeImport" variant="outlined" class="pa-6 mb-6">
            <h2 class="text-h6 mb-3">Текущий импорт — {{ activeImport.filename }}</h2>
            <v-progress-linear
                v-if="isProcessing"
                indeterminate
                color="primary"
                class="mb-3"
            />
            <div data-test="active-status" class="text-body-2">
                Статус: <strong>{{ activeImport.status }}</strong>
            </div>
            <v-table v-if="!isProcessing" density="compact" class="mt-3">
                <tbody>
                    <tr><td>Добавлено</td><td>{{ activeImport.rows_added }}</td></tr>
                    <tr><td>Обновлено</td><td>{{ activeImport.rows_updated }}</td></tr>
                    <tr><td>Пропущено</td><td>{{ activeImport.rows_skipped }}</td></tr>
                    <tr><td>Неизвестных статусов</td><td>{{ activeImport.unknown_statuses_count }}</td></tr>
                </tbody>
            </v-table>
            <v-alert
                v-if="activeImport.status === 'failed'"
                type="error"
                variant="tonal"
                class="mt-3"
            >
                {{ activeImport.error_message }}
            </v-alert>
        </v-card>

        <v-card variant="outlined" class="pa-6">
            <h2 class="text-h6 mb-3">История импортов</h2>
            <v-table v-if="history.length > 0" density="compact">
                <thead>
                    <tr>
                        <th>Файл</th><th>Статус</th><th>Добавлено</th><th>Обновлено</th><th>Пропущено</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="row in history" :key="row.id">
                        <td>{{ row.filename }}</td>
                        <td>{{ row.status }}</td>
                        <td>{{ row.rows_added }}</td>
                        <td>{{ row.rows_updated }}</td>
                        <td>{{ row.rows_skipped }}</td>
                    </tr>
                </tbody>
            </v-table>
            <p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
        </v-card>

        <UnknownStatusesDialog
            v-model="wizardOpen"
            :statuses="unknownStatuses"
            @resolved="onWizardResolved"
        />
    </v-container>
</template>

<style scoped>
.import-view {
    max-width: 1100px;
}
</style>
```

- [ ] **Step 5: Прогнать тест (зависит от Task 11 — `UnknownStatusesDialog`)**

`ImportView` импортирует `UnknownStatusesDialog`. Тест монтирует со `stubs: { UnknownStatusesDialog: true }`, но импорт компонента должен резолвиться — реализовать Task 11 до прогона. Временный прогон до Task 11 даст ошибку резолва импорта.

Run: `cd app && npm run test:vue -- ImportView`
Expected (после Task 11): PASS 3/3.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/resources/js/api/imports.ts app/resources/js/views/ImportView.vue app/resources/js/views/__tests__/ImportView.spec.ts
git commit -m "feat(import): ImportView + api/imports.ts"
```

---

## Task 11: `UnknownStatusesDialog` — wizard маппинга статусов (H6)

Диалог: список незамапленных статусов, для каждого — `v-select` из 14 канонических slug'ов; «Сохранить» → POST resolve.

**Files:**

- Create: `app/resources/js/components/import/UnknownStatusesDialog.vue`
- Test: `app/resources/js/components/import/__tests__/UnknownStatusesDialog.spec.ts`

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

`app/resources/js/components/import/__tests__/UnknownStatusesDialog.spec.ts`:

```ts
import { mount, flushPromises } from '@vue/test-utils';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import UnknownStatusesDialog from '../UnknownStatusesDialog.vue';
import * as importsApi from '../../../api/imports';

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

function mountDialog() {
    return mount(UnknownStatusesDialog, {
        props: {
            modelValue: true,
            statuses: [
                { id: 1, status_ru: 'Архив', occurrences: 3 },
                { id: 2, status_ru: 'Спам', occurrences: 1 },
            ],
        },
        global: { plugins: [vuetify] },
        attachTo: document.body,
    });
}

describe('UnknownStatusesDialog', () => {
    beforeEach(() => {
        vi.restoreAllMocks();
        vi.spyOn(importsApi, 'resolveUnknownStatuses').mockResolvedValue();
    });

    it('рендерит строку на каждый неизвестный статус', () => {
        const wrapper = mountDialog();
        expect(document.body.textContent).toContain('Архив');
        expect(document.body.textContent).toContain('Спам');
        wrapper.unmount();
    });

    it('кнопка сохранения заблокирована пока не выбраны все маппинги', async () => {
        const wrapper = mountDialog();
        await flushPromises();
        const save = document.querySelector('[data-test="save-mappings"]') as HTMLButtonElement;
        expect(save.disabled).toBe(true);
        wrapper.unmount();
    });

    it('сохраняет маппинги и эмитит resolved', async () => {
        const spy = vi.spyOn(importsApi, 'resolveUnknownStatuses').mockResolvedValue();
        const wrapper = mountDialog();
        // выбираем slug для обоих статусов через программную установку
        wrapper.vm.selection['Архив'] = 'closed';
        wrapper.vm.selection['Спам'] = 'closed';
        await flushPromises();
        await wrapper.vm.save();
        await flushPromises();

        expect(spy).toHaveBeenCalledWith([
            { status_ru: 'Архив', slug: 'closed' },
            { status_ru: 'Спам', slug: 'closed' },
        ]);
        expect(wrapper.emitted('resolved')).toBeTruthy();
        wrapper.unmount();
    });
});
```

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

Run: `cd app && npm run test:vue -- UnknownStatusesDialog`
Expected: FAIL — компонента нет.

- [ ] **Step 3: Создать `UnknownStatusesDialog.vue`**

`app/resources/js/components/import/UnknownStatusesDialog.vue`:

```vue
<script setup lang="ts">
/**
 * Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
 *
 * Для каждого незамапленного русского статуса пользователь выбирает один из
 * 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
 */
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';

const props = defineProps<{
    modelValue: boolean;
    statuses: UnknownStatus[];
}>();

const emit = defineEmits<{
    'update:modelValue': [value: boolean];
    resolved: [];
}>();

/** 14 канонических статусов воронки (ТЗ §6.4). */
const STATUS_OPTIONS: { value: string; title: string }[] = [
    { value: 'new', title: 'Новые' },
    { value: 'viewed', title: 'Просмотрено' },
    { value: 'worked', title: 'Проработан' },
    { value: 'base', title: 'База' },
    { value: 'missed', title: 'Недозвон' },
    { value: 'negotiations', title: 'Переговоры' },
    { value: 'waiting_payment', title: 'Ожидаем оплаты' },
    { value: 'partnership', title: 'Партнерка' },
    { value: 'paid', title: 'Оплачено' },
    { value: 'closed', title: 'Закрыто и не реализовано' },
    { value: 'test_drive', title: 'Тест драйв' },
    { value: 'hot', title: 'Горячий' },
    { value: 'replacement', title: 'На замену' },
    { value: 'final_missed', title: 'Конечный недозвон' },
];

const selection = reactive<Record<string, string | null>>({});
const saving = ref(false);
const error = ref<string | null>(null);

const dialogOpen = computed({
    get: () => props.modelValue,
    set: (v: boolean) => emit('update:modelValue', v),
});

const allMapped = computed(
    () => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
);

async function save(): Promise<void> {
    if (!allMapped.value) {
        return;
    }
    saving.value = true;
    error.value = null;
    try {
        const mappings: StatusMapping[] = props.statuses.map((s) => ({
            status_ru: s.status_ru,
            slug: selection[s.status_ru] as string,
        }));
        await resolveUnknownStatuses(mappings);
        emit('resolved');
    } catch {
        error.value = 'Не удалось сохранить маппинг. Повторите попытку.';
    } finally {
        saving.value = false;
    }
}

defineExpose({ selection, save });
</script>

<template>
    <v-dialog v-model="dialogOpen" max-width="640">
        <v-card>
            <v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
            <v-card-text>
                <p class="text-body-2 text-medium-emphasis mb-4">
                    Эти статусы из CSV не входят в стандартную воронку. Выберите
                    соответствие — повторный импорт применит маппинг автоматически.
                </p>
                <div
                    v-for="status in statuses"
                    :key="status.id"
                    class="d-flex align-center ga-3 mb-3"
                >
                    <div class="flex-grow-1">
                        <strong>{{ status.status_ru }}</strong>
                        <span class="text-caption text-medium-emphasis ml-2">
                            ({{ status.occurrences }} шт.)
                        </span>
                    </div>
                    <v-select
                        v-model="selection[status.status_ru]"
                        :items="STATUS_OPTIONS"
                        label="Статус воронки"
                        density="compact"
                        variant="outlined"
                        hide-details
                        style="max-width: 280px"
                    />
                </div>
                <v-alert v-if="error" type="error" variant="tonal" class="mt-2">
                    {{ error }}
                </v-alert>
            </v-card-text>
            <v-card-actions>
                <v-spacer />
                <v-btn variant="text" @click="dialogOpen = false">Отмена</v-btn>
                <v-btn
                    data-test="save-mappings"
                    color="primary"
                    variant="flat"
                    :loading="saving"
                    :disabled="!allMapped"
                    @click="save"
                >
                    Сохранить
                </v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>
</template>
```

- [ ] **Step 4: Прогнать тесты — диалог + ImportView из Task 10**

Run: `cd app && npm run test:vue -- UnknownStatusesDialog ImportView`
Expected: PASS — `UnknownStatusesDialog` 3/3 + `ImportView` 3/3 (теперь импорт резолвится).

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/resources/js/components/import/UnknownStatusesDialog.vue app/resources/js/components/import/__tests__/UnknownStatusesDialog.spec.ts
git commit -m "feat(import): UnknownStatusesDialog — wizard маппинга статусов"
```

---

## Task 12: UI-вход — маршрут `/import` + сайдбар (H8) + инструкция (H9)

**Files:**

- Modify: `app/resources/js/router/index.ts`
- Modify: `app/resources/js/components/AppSidebar.vue`
- Create: `docs/Как_перенести_данные_из_crm-bp-gr.md`
- Test: `app/resources/js/router/__tests__/router.spec.ts` (или фактический файл роутер-тестов — пре-проверить)

> **Decision (H8):** UI-вход для импорта **лидов** — отдельный маршрут `/import` + пункт сайдбара, НЕ вкладка `SettingsView`. Причина: (1) избегаем коллизии с невлитым Sprint 3E (реструктуризация вкладок Settings); (2) импорт — самостоятельный flow, не «настройка». «Кнопка в ProjectsView» из аудит-спека относится к импорту **проектов** (H7) — вне scope этого плана.

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

Пре-проверка: открыть `app/resources/js/router/index.ts` — найти стиль объявления маршрутов (`meta: { layout: 'app' }`, lazy-import). Открыть существующий роутер-тест, повторить стиль. Добавить кейс:

```ts
it('маршрут /import зарегистрирован с layout app', () => {
    const route = router.resolve('/import');
    expect(route.name).toBe('import');
    expect(route.meta.layout).toBe('app');
});
```

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

Run: `cd app && npm run test:vue -- router`
Expected: FAIL — маршрута нет.

- [ ] **Step 3: Добавить маршрут в `router/index.ts`**

В массив `routes` добавить (повторив стиль соседних app-маршрутов — lazy-import + `meta.layout`):

```ts
{
    path: '/import',
    name: 'import',
    component: () => import('../views/ImportView.vue'),
    meta: { layout: 'app' },
},
```

> Если соседние маршруты используют `meta.devIndex` — добавить следующий по счёту индекс. Если есть `meta.requiresAuth` или аналог — добавить по конвенции файла.

- [ ] **Step 4: Добавить пункт в `AppSidebar.vue`**

Пре-проверка: открыть `app/resources/js/components/AppSidebar.vue` — найти структуру навигации (массив `navItems`/группы «Работа» и т.п.). Добавить пункт в подходящую группу (рядом с «Сделки»/«Проекты»), повторив форму существующих элементов:

```ts
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
```

> Точные ключи (`title`/`label`, `icon`/`countKey`, `to`/`route`) — взять из соседних элементов файла, не выдумывать. Если навигация сгруппирована — поместить в группу «Работа» (или ближайшую по смыслу).

- [ ] **Step 5: Создать инструкцию H9**

`docs/Как_перенести_данные_из_crm-bp-gr.md`:

```markdown
# Как перенести данные из crm.bp-gr.ru

> Опциональный модуль. Нужен, если вы раньше вели лиды в crm.bp-gr.ru и хотите
> перенести историю в Лидерру. Новые лиды приходят через webhook автоматически —
> импорт нужен только для исторических данных.

## Шаг 1. Выгрузите CSV из crm.bp-gr.ru

Откройте в crm.bp-gr.ru:

```

https://crm.bp-gr.ru/admin/visit/index-visit-archive?ext=csv

```

Сохранится файл с колонками:
`id, Проект, Тег проекта, Телефон, Создано, Напоминание, Комментарий, Состояние, Имя`.

Файл должен быть в кодировке UTF-8, разделитель — запятая.

## Шаг 2. Загрузите файл в Лидерре

1. Откройте раздел **«Импорт данных»** в боковом меню.
2. Выберите CSV-файл.
3. (Опционально) включите **«Пробный прогон»** — проверка файла без записи сделок.
4. Нажмите **«Загрузить»**.

## Шаг 3. Дождитесь завершения

Импорт идёт в фоне. На экране виден прогресс; по завершении придёт письмо
со сводкой: сколько сделок добавлено, обновлено, пропущено.

Повторная загрузка того же файла безопасна — дубли не создаются (сделки
сопоставляются по `id` из crm.bp-gr.ru). Баланс лидов при импорте **не списывается**.

## Шаг 4. Замапьте неизвестные статусы (если появятся)

Если в файле встретятся статусы воронки, которых нет в Лидерре, появится баннер
**«Найдено N неизвестных статусов»**. Нажмите **«Замапить»**, выберите для каждого
соответствие из стандартной воронки и сохраните. Затем загрузите файл ещё раз —
сделки с этими статусами обновятся автоматически.

## Возможные ошибки

| Симптом | Причина | Решение |
|---|---|---|
| Строка пропущена | Невалидный телефон (не `7XXXXXXXXXX`) или дата | Исправьте строку в CSV |
| Импорт «failed» | Файл повреждён / не CSV | Перевыгрузите файл из crm.bp-gr.ru |
| Статус сделки стал «Новые» | Неизвестный статус, ещё не замаплен | Замапьте статус и повторите импорт |
```

- [ ] **Step 6: Прогнать тест маршрута + полный Vitest-блок**

Run: `cd app && npm run test:vue -- router ImportView UnknownStatusesDialog`
Expected: PASS — router-кейс зелёный + Import-тесты зелёные.

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

```bash
cd "c:/моя/проекты/портал crm/Документация"
git add app/resources/js/router/index.ts app/resources/js/components/AppSidebar.vue "docs/Как_перенести_данные_из_crm-bp-gr.md" app/resources/js/router/__tests__/router.spec.ts
git commit -m "feat(import): маршрут /import + сайдбар + инструкция H9"
```

---

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

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

```bash
cd app
php artisan migrate:fresh --seed                       # схема v8.20 применяется чисто
php vendor/bin/pest --parallel                          # весь backend
npm run test:vue                                        # весь frontend
composer pint && composer stan                          # стиль + статанализ
npx vue-tsc --noEmit                                    # типы Vue
```

Expected: Pest — 0 failed (новые Import-тесты + 0 регрессий); Vitest — 0 failed; Pint — clean; Larastan — 0; vue-tsc — 0.

- [ ] **Browser smoke** (Playwright MCP, dev-сервер запущен): открыть `/import`, проверить рендер, отсутствие console-ошибок.

- [ ] **Реестр открытых вопросов** — НЕ трогать. H1–H9 — аудит-находки, не реестровые `Б-/CTO-/DO-/Ю-/Диз-/OPEN-`. Если по ходу всплывут реестровые вопросы — оставить открытыми (нет «закрываем» от заказчика).

---

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

**1. Покрытие spec'а (аудит §3 Sprint 4 — H1–H9):**

| H | Покрыто | Где |
|---|---|---|
| H1 — `import_unknown_statuses` + RLS | ✅ | Task 1 |
| H2 — enrichment `import_log` (+5 колонок) | ✅ | Task 1 |
| H3 — `CsvLeadsParser` + `StatusRuToSlugMapper` + `HistoricalImportService` | ✅ | Tasks 4, 5, 6 |
| H4 — `ImportLeadsJob` + `ImportController` + route + email | ✅ | Tasks 7, 8, 9 |
| H5 — Frontend ImportView (dropzone + progress + результат) | ✅ | Task 10 |
| H6 — Unknown statuses wizard | ✅ | Task 11 |
| H7 — Bulk-импорт проектов | ⏭️ **вынесено** | см. «Scope» — формат не специфицирован в ТЗ §6 → отдельный план Sprint 4B |
| H8 — UI-вход | ✅ (решение: `/import` + сайдбар) | Task 12 |
| H9 — Мини-инструкция | ✅ | Task 12 |

**2. Placeholder-скан:** в коде задач нет «TODO»/«добавить обработку ошибок»/«аналогично Task N». Пре-проверки (`api/dashboard.ts`, `AppSidebar.vue`, `router/index.ts`) — это инструкции свериться с фактической конвенцией перед написанием, не placeholder'ы кода.

**3. Согласованность типов:**

- `ImportLog`/`ImportUnknownStatus` (Task 2) — `fillable`/`casts` совпадают с колонками миграции Task 1 (`entity_type`, `source_system`, `mapping_config`, `unknown_statuses_count`, `dry_run`).
- `ImportResult` (Task 6) — поля `added/updated/skipped/unknownStatuses/errors` используются в `ImportLeadsJob` (Task 7) консистентно.
- `MonthlyPartitionManager::ensureRange/ensureMonth` (Task 3) — сигнатуры совпадают с вызовами в `HistoricalImportService` (Task 6) и `PartitionsCreateMonths` (Task 3 Step 4).
- `ImportLogResource`/`UnknownStatus`/`StatusMapping` (TS, Task 10) — поля совпадают с `ImportController::toResource()`/`unknownStatuses()` JSON (Task 9).
- 14 slug'ов в `UnknownStatusesDialog.STATUS_OPTIONS` (Task 11) = `STATUS_RU_TO_SLUG` в `StatusRuToSlugMapper` (Task 4) = ТЗ §6.4.

**4. Ключевые архитектурные решения зафиксированы:**

- ТЗ §6.3 «Напоминание → `deals.reminder_at`» — колонка удалена в v8.3; план маршрутизирует на таблицу `reminders` (Task 6 `syncReminder`).
- Исторические даты вне партиций `deals` — `MonthlyPartitionManager` создаёт партиции под диапазон CSV (Task 3 + Task 6).
- Идемпотентность — `webhook_dedup_keys` + advisory lock (паритет с `ProcessWebhookJob`).
- Баланс не списывается — одна транзакция `historical_import` (Task 6 `recordHistoricalTransaction`).
- Quirk 25 — регенерация Larastan baseline учтена (Task 9 Step 8).
- Коллизия со Sprint 3E (`SettingsView`) исключена — UI-вход через `/import`, Settings не трогается.

---

## Execution Handoff

План сохранён: `docs/superpowers/plans/2026-05-16-sprint4-historical-import.md`.

**Зависимость:** план ветвится от `origin/main` (`447ef59`, Sprint 3F). Со Sprint 3E коллизии нет (Settings не трогается). H7 (импорт проектов) — отдельный план Sprint 4B (нужен format-спек 18-колоночного CSV).
