# Supplier Projects Import (lkomega → info@lkomega.ru) 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:** Одноразовая artisan-команда, которая усыновляет активные проекты поставщика crm.bp-gr.ru (аккаунт lkomega) как проекты Лидерры под тенантом info@lkomega.ru — по правилам Лидерры (B1/B2/B3 → один проект, лимит = сумма площадок), без записи на портал.

**Architecture:** Чистый сервис `SupplierProjectImporter` (читает `listProjects`, группирует, реверс-маппит регионы, строит план) + статические pure-хелперы `SupplierImportMapper` + artisan-команда (dry-run по умолчанию, `--commit` пишет в БД через `pgsql_supplier` BYPASSRLS, портал не дёргается). Зеркалит create-ветку `SyncSupplierProjectsJob`, но external_id берёт из `listProjects` вместо `saveProjectMultiFlag`.

**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16 (RLS + pgsql_supplier BYPASSRLS connection), Eloquent.

**Spec:** `docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md`

**Рабочая директория:** Laravel-приложение в `app/` (composer/artisan/pest запускаются из `app/`). Пути ниже — относительно `app/` если начинаются с `app/Services` и т.п.; путь спеки — от корня репо.

---

## File Structure

- Create: `app/app/Services/Supplier/Import/SupplierImportMapper.php` — pure static хелперы (src→platform, type→signal_type, парсинг ГИБДД-регионов, workdays→mask, парсинг sms-content).
- Create: `app/app/Services/Supplier/Import/SupplierProjectImporter.php` — `buildPlan(int $tenantId): array` (read-only) + `commit(array $plan, int $tenantId): array` (запись).
- Create: `app/app/Console/Commands/ImportSupplierProjectsCommand.php` — artisan `supplier:import-projects`.
- Modify: `app/app/Support/SupplierRegions.php` — добавить `mapFromSupplier()` (инверсия `LIDERRA_TO_SUPPLIER`).
- Test: `app/tests/Unit/Supplier/SupplierImportMapperTest.php`
- Test: `app/tests/Unit/Supplier/SupplierRegionsTest.php` (существует — добавить кейсы `mapFromSupplier`)
- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`
- Test: `app/tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php`

**Plan data shape** (массив, как в `SyncSupplierProjectsJob`):

```
planned item = [
  'signal_type'        => 'site'|'call'|'sms',
  'signal_identifier'  => string|null,   // content для site/call; null для sms
  'sms_senders'        => list<string>,  // для sms (иначе [])
  'sms_keyword'        => string|null,   // для sms B2 (иначе null)
  'name'               => string,
  'tag'                => string,        // кампания с портала
  'regions'            => list<int>,     // Лидерра-коды (union, reverse), [] = вся РФ
  'delivery_days_mask' => int,           // union по площадкам
  'daily_limit_target' => int,           // СУММА lim активных площадок
  'platforms'          => list<array{platform:string, external_id:int, lim:int}>,
]
skipped item = ['reason' => string, 'label' => string]  // label — маскируется при печати
```

---

## Task 1: SupplierRegions::mapFromSupplier (инверсия ГИБДД→Лидерра)

**Files:**

- Modify: `app/app/Support/SupplierRegions.php`
- Test: `app/tests/Unit/Supplier/SupplierRegionsTest.php`

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

Добавить в конец `app/tests/Unit/Supplier/SupplierRegionsTest.php` (файл уже использует `uses(TestCase::class)` — см. существующую шапку; если нет, добавить `uses(Tests\TestCase::class);` после `use`-блока, иначе `Log` facade упадёт):

```php
test('mapFromSupplier inverts LIDERRA_TO_SUPPLIER bijection', function (): void {
    // ГИБДД 24 → Лидерра 29 (Красноярский); ГИБДД 77 → Лидерра 82 (Москва)
    expect(\App\Support\SupplierRegions::mapFromSupplier([24]))->toBe([29]);
    expect(\App\Support\SupplierRegions::mapFromSupplier([77]))->toBe([82]);
});

test('mapFromSupplier maps multiple codes, sorted ascending, deduped', function (): void {
    // ГИБДД 77→82 (Москва), 78→83 (СПб), 24→29 (Красноярский)
    expect(\App\Support\SupplierRegions::mapFromSupplier([78, 24, 77, 24]))->toBe([29, 82, 83]);
});

test('mapFromSupplier drops unknown supplier codes', function (): void {
    // 999 нет в карте → отброшен; 24 → 29
    expect(\App\Support\SupplierRegions::mapFromSupplier([999, 24]))->toBe([29]);
});

test('mapFromSupplier returns [] for empty input', function (): void {
    expect(\App\Support\SupplierRegions::mapFromSupplier([]))->toBe([]);
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierRegionsTest.php --filter=mapFromSupplier`
Expected: FAIL — `Call to undefined method App\Support\SupplierRegions::mapFromSupplier()`

- [ ] **Step 3: Write minimal implementation**

Добавить метод в `app/app/Support/SupplierRegions.php` после `mapToSupplier()` (перед закрывающей `}` класса):

```php
    /**
     * Инверсия {@see mapToSupplier}: коды поставщика (ГИБДД) → Лидерра-коды
     * (конституционный порядок). Неизвестные коды поставщика отбрасываются
     * с warning'ом. Результат — уникальные Лидерра-коды по возрастанию.
     *
     * @param  list<int>|array<int|string, int|string>  $supplierCodes
     * @return list<int>
     */
    public static function mapFromSupplier(array $supplierCodes): array
    {
        /** @var array<int, int> $supplierToLiderra */
        $supplierToLiderra = array_flip(self::LIDERRA_TO_SUPPLIER);

        $out = [];
        $dropped = [];

        foreach ($supplierCodes as $code) {
            $code = (int) $code;
            if (isset($supplierToLiderra[$code])) {
                $out[$supplierToLiderra[$code]] = true;
            } else {
                $dropped[] = $code;
            }
        }

        if ($dropped !== []) {
            Log::warning('supplier.regions.unmapped_reverse', [
                'supplier_codes' => $dropped,
                'note' => 'supplier code has no Liderra equivalent — dropped on import',
            ]);
        }

        $codes = array_keys($out);
        sort($codes);

        return $codes;
    }
```

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

Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierRegionsTest.php --filter=mapFromSupplier`
Expected: PASS (4 tests)

- [ ] **Step 5: Commit**

```bash
git add app/app/Support/SupplierRegions.php app/tests/Unit/Supplier/SupplierRegionsTest.php
git commit -m "feat(supplier-import): SupplierRegions::mapFromSupplier — обратная карта ГИБДД→Лидерра"
```

---

## Task 2: SupplierImportMapper — pure хелперы

**Files:**

- Create: `app/app/Services/Supplier/Import/SupplierImportMapper.php`
- Test: `app/tests/Unit/Supplier/SupplierImportMapperTest.php`

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

Create `app/tests/Unit/Supplier/SupplierImportMapperTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\Import\SupplierImportMapper;

uses(Tests\TestCase::class);

test('platformFromSrc maps rt/bl/mt to B1/B2/B3, others null', function (): void {
    expect(SupplierImportMapper::platformFromSrc('rt'))->toBe('B1');
    expect(SupplierImportMapper::platformFromSrc('bl'))->toBe('B2');
    expect(SupplierImportMapper::platformFromSrc('mt'))->toBe('B3');
    expect(SupplierImportMapper::platformFromSrc('dop2'))->toBeNull();
    expect(SupplierImportMapper::platformFromSrc(''))->toBeNull();
});

test('signalTypeFromType maps calls/hosts/sms', function (): void {
    expect(SupplierImportMapper::signalTypeFromType('calls'))->toBe('call');
    expect(SupplierImportMapper::signalTypeFromType('hosts'))->toBe('site');
    expect(SupplierImportMapper::signalTypeFromType('sms'))->toBe('sms');
    expect(SupplierImportMapper::signalTypeFromType('unknown'))->toBeNull();
});

test('parseGibddRegions splits comma/space string of codes; empty → []', function (): void {
    expect(SupplierImportMapper::parseGibddRegions('24'))->toBe([24]);
    expect(SupplierImportMapper::parseGibddRegions('24,77'))->toBe([24, 77]);
    expect(SupplierImportMapper::parseGibddRegions('24, 77 78'))->toBe([24, 77, 78]);
    expect(SupplierImportMapper::parseGibddRegions(''))->toBe([]);
    expect(SupplierImportMapper::parseGibddRegions(null))->toBe([]);
});

test('workdaysToMask converts string day list to bitmask (bit0=Mon)', function (): void {
    // Пн-Пт = [1,2,3,4,5] → биты 0..4 = 1+2+4+8+16 = 31
    expect(SupplierImportMapper::workdaysToMask(['1', '2', '3', '4', '5']))->toBe(31);
    // Все 7 → 127
    expect(SupplierImportMapper::workdaysToMask(['1', '2', '3', '4', '5', '6', '7']))->toBe(127);
    // Пусто → 127 (по умолчанию все дни — портал-проект без явных дней работает всегда)
    expect(SupplierImportMapper::workdaysToMask([]))->toBe(127);
});

test('parseSmsContent splits sender+keyword; sender-only when no plus', function (): void {
    expect(SupplierImportMapper::parseSmsContent('79001234567+KVARTIRA'))
        ->toBe(['sender' => '79001234567', 'keyword' => 'KVARTIRA']);
    expect(SupplierImportMapper::parseSmsContent('79001234567'))
        ->toBe(['sender' => '79001234567', 'keyword' => null]);
    expect(SupplierImportMapper::parseSmsContent(''))
        ->toBe(['sender' => '', 'keyword' => null]);
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierImportMapperTest.php`
Expected: FAIL — class `SupplierImportMapper` not found.

- [ ] **Step 3: Write minimal implementation**

Create `app/app/Services/Supplier/Import/SupplierImportMapper.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Import;

/**
 * Pure-хелперы перевода полей строки rt-проекта поставщика → поля Лидерры.
 * Без побочных эффектов и зависимостей — только статические функции.
 *
 * Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md §4
 */
final class SupplierImportMapper
{
    private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];

    private const TYPE_TO_SIGNAL = ['calls' => 'call', 'hosts' => 'site', 'sms' => 'sms'];

    public static function platformFromSrc(string $src): ?string
    {
        return self::SRC_TO_PLATFORM[$src] ?? null;
    }

    public static function signalTypeFromType(string $type): ?string
    {
        return self::TYPE_TO_SIGNAL[$type] ?? null;
    }

    /**
     * Строку ГИБДД-кодов («24», «24,77», «24, 77 78») → list<int>.
     * Пусто/null → [].
     *
     * @return list<int>
     */
    public static function parseGibddRegions(?string $regions): array
    {
        if ($regions === null) {
            return [];
        }
        $parts = preg_split('/[,\s]+/', trim($regions), -1, PREG_SPLIT_NO_EMPTY);
        if ($parts === false || $parts === []) {
            return [];
        }

        return array_values(array_map(static fn (string $p): int => (int) $p, $parts));
    }

    /**
     * Список дней-строк ["1".."7"] (1=Пн..7=Вс ISO) → битовая маска (bit0=Пн).
     * Пусто → 127 (все дни).
     *
     * @param  list<int|string>  $workdays
     */
    public static function workdaysToMask(array $workdays): int
    {
        if ($workdays === []) {
            return 127;
        }
        $mask = 0;
        foreach ($workdays as $d) {
            $day = (int) $d;
            if ($day >= 1 && $day <= 7) {
                $mask |= (1 << ($day - 1));
            }
        }

        return $mask === 0 ? 127 : $mask;
    }

    /**
     * sms-content: «sender+keyword» → ['sender'=>…, 'keyword'=>…];
     * «sender» (без плюса) → ['sender'=>…, 'keyword'=>null].
     *
     * @return array{sender: string, keyword: string|null}
     */
    public static function parseSmsContent(string $content): array
    {
        $plus = strpos($content, '+');
        if ($plus === false) {
            return ['sender' => $content, 'keyword' => null];
        }

        return [
            'sender' => substr($content, 0, $plus),
            'keyword' => substr($content, $plus + 1),
        ];
    }
}
```

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

Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierImportMapperTest.php`
Expected: PASS (5 tests)

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Supplier/Import/SupplierImportMapper.php app/tests/Unit/Supplier/SupplierImportMapperTest.php
git commit -m "feat(supplier-import): SupplierImportMapper pure-хелперы (src/type/regions/workdays/sms)"
```

---

## Task 3: SupplierProjectImporter::buildPlan — site/call группировка + лимит-сумма

**Files:**

- Create: `app/app/Services/Supplier/Import/SupplierProjectImporter.php`
- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`

Группировка site/call: ключ `(signal_type, content)`; платформа из `src`; лимит Лидерры = Σ `lim` активных площадок; регионы = union reverse-mapped; workdays = union; tag = первый непустой `tag` группы; идемпотентность по существующему Project — в Task 5.

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

Create `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Services\Supplier\Import\SupplierProjectImporter;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;

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

/**
 * Хелпер: importer с замоканным клиентом, отдающим заданные строки listProjects.
 *
 * @param  list<array<string, mixed>>  $rows
 */
function importerWithRows(array $rows): SupplierProjectImporter
{
    $client = Mockery::mock(SupplierPortalClient::class);
    $client->shouldReceive('listProjects')->andReturn($rows);

    return new SupplierProjectImporter($client);
}

test('buildPlan groups B1/B2/B3 call rows into one planned project, limit = sum', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
        ['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
        ['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(1);
    $p = $plan['planned'][0];
    expect($p['signal_type'])->toBe('call');
    expect($p['signal_identifier'])->toBe('79991112233');
    expect($p['daily_limit_target'])->toBe(18);              // 6+6+6
    expect($p['delivery_days_mask'])->toBe(31);              // Пн-Пт
    expect($p['tag'])->toBe('Каранга');
    expect($p['regions'])->toBe([]);                          // вся РФ
    expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
    expect(collect($p['platforms'])->firstWhere('platform', 'B1')['external_id'])->toBe(4001);
});

test('buildPlan skips inactive rows (status=false)', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79995550000', 'tag' => 'X', 'lim' => '5', 'status' => false, 'regions' => '', 'workdays' => []],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(0);
});

test('buildPlan skips dop2 (unsupported source) and reports it', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '6001', 'src' => 'dop2', 'type' => 'calls', 'content' => '79996660000', 'tag' => 'X', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(0);
    expect(collect($plan['skipped'])->pluck('reason'))->toContain('unsupported_source');
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php`
Expected: FAIL — class `SupplierProjectImporter` not found.

- [ ] **Step 3: Write minimal implementation**

Create `app/app/Services/Supplier/Import/SupplierProjectImporter.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier\Import;

use App\Services\Supplier\SupplierPortalClient;
use App\Support\SupplierRegions;

/**
 * Усыновление активных проектов поставщика (аккаунт lkomega) как проектов
 * Лидерры. Читает listProjects (read-only), группирует площадки B1/B2/B3 в один
 * проект, реверс-маппит регионы, считает лимит как сумму площадок.
 *
 * buildPlan() — без побочных эффектов записи (кроме чтения существующих Project
 * для идемпотентности). commit() — пишет (см. отдельную задачу плана).
 *
 * Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md
 */
class SupplierProjectImporter
{
    public function __construct(
        private readonly SupplierPortalClient $client,
    ) {}

    /**
     * @return array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>}
     */
    public function buildPlan(int $tenantId): array
    {
        $rows = $this->client->listProjects();

        /** @var list<array{reason: string, label: string}> $skipped */
        $skipped = [];

        // group key (signal_type|identifier) => accumulator
        /** @var array<string, array<string, mixed>> $groups */
        $groups = [];

        foreach ($rows as $row) {
            if (($row['status'] ?? false) !== true) {
                continue; // только включённые
            }

            $platform = SupplierImportMapper::platformFromSrc((string) ($row['src'] ?? ''));
            if ($platform === null) {
                $skipped[] = ['reason' => 'unsupported_source', 'label' => (string) ($row['name'] ?? $row['content'] ?? '?')];

                continue;
            }

            $signalType = SupplierImportMapper::signalTypeFromType((string) ($row['type'] ?? ''));
            if ($signalType === null) {
                $skipped[] = ['reason' => 'unsupported_type', 'label' => (string) ($row['name'] ?? '?')];

                continue;
            }

            // sms обрабатывается в отдельной задаче; site/call здесь
            if ($signalType === 'sms') {
                continue;
            }

            $identifier = (string) ($row['content'] ?? '');
            $key = $signalType.'|'.$identifier;

            if (! isset($groups[$key])) {
                $groups[$key] = [
                    'signal_type' => $signalType,
                    'signal_identifier' => $identifier,
                    'sms_senders' => [],
                    'sms_keyword' => null,
                    'tag' => '',
                    'regions' => [],
                    'has_all_russia' => false,
                    'workdays_mask' => 0,
                    'daily_limit_target' => 0,
                    'platforms' => [],
                ];
            }

            $this->accumulateRow($groups[$key], $row, $platform);
        }

        $planned = [];
        foreach ($groups as $g) {
            unset($g['has_all_russia']);
            $g['delivery_days_mask'] = $g['workdays_mask'] === 0 ? 127 : $g['workdays_mask'];
            unset($g['workdays_mask']);
            if ($g['tag'] === '') {
                $g['tag'] = 'РФ';
            }
            $g['name'] = $this->deriveName($g);
            $planned[] = $g;
        }

        return ['planned' => array_values($planned), 'skipped' => $skipped];
    }

    /**
     * @param  array<string, mixed>  $group
     * @param  array<string, mixed>  $row
     */
    private function accumulateRow(array &$group, array $row, string $platform): void
    {
        $lim = (int) ($row['lim'] ?? 0);
        $group['daily_limit_target'] += $lim;
        $group['platforms'][] = [
            'platform' => $platform,
            'external_id' => (int) ($row['id'] ?? 0),
            'lim' => $lim,
        ];

        // tag: первый непустой и не «РФ» (кампания заказчика)
        $rowTag = trim((string) ($row['tag'] ?? ''));
        if ($group['tag'] === '' && $rowTag !== '' && $rowTag !== 'РФ') {
            $group['tag'] = $rowTag;
        }

        // workdays union
        $group['workdays_mask'] |= SupplierImportMapper::workdaysToMask((array) ($row['workdays'] ?? []));

        // regions union (reverse-mapped); пустой regions у любой площадки → вся РФ
        if (! $group['has_all_russia']) {
            $gibdd = SupplierImportMapper::parseGibddRegions(
                is_string($row['regions'] ?? null) ? $row['regions'] : ''
            );
            if ($gibdd === []) {
                $group['has_all_russia'] = true;
                $group['regions'] = [];
            } else {
                $liderra = SupplierRegions::mapFromSupplier($gibdd);
                $group['regions'] = array_values(array_unique(array_merge($group['regions'], $liderra)));
                sort($group['regions']);
            }
        }
    }

    /**
     * @param  array<string, mixed>  $group
     */
    private function deriveName(array $group): string
    {
        $tag = (string) $group['tag'];
        $base = ($tag !== '' && $tag !== 'РФ')
            ? $tag
            : (string) ($group['signal_identifier'] ?? 'проект');

        return mb_substr($base, 0, 255);
    }
}
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php`
Expected: PASS (3 tests)

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Supplier/Import/SupplierProjectImporter.php app/tests/Feature/Supplier/SupplierProjectImporterTest.php
git commit -m "feat(supplier-import): buildPlan — site/call группировка B1/B2/B3, лимит=сумма"
```

---

## Task 4: buildPlan — регионы (reverse + union + вся РФ) и regions_reverse skip

**Files:**

- Modify: `app/app/Services/Supplier/Import/SupplierProjectImporter.php`
- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`

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

Добавить в `SupplierProjectImporterTest.php`:

```php
test('buildPlan reverse-maps regions and unions across platforms', function (): void {
    $tenant = Tenant::factory()->create();

    // ГИБДД 24 (B1) + 77 (B2) → Лидерра 29 + 82, union; B3 тоже 24
    $plan = importerWithRows([
        ['id' => '7001', 'src' => 'rt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
        ['id' => '7002', 'src' => 'bl', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '77', 'workdays' => [], 'regions_reverse' => false],
        ['id' => '7003', 'src' => 'mt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
    ])->buildPlan($tenant->id);

    expect($plan['planned'][0]['regions'])->toBe([29, 82]);
});

test('buildPlan treats any empty-regions platform as all-Russia', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '7101', 'src' => 'rt', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
        ['id' => '7102', 'src' => 'bl', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '', 'workdays' => [], 'regions_reverse' => false],
    ])->buildPlan($tenant->id);

    expect($plan['planned'][0]['regions'])->toBe([]);
});

test('buildPlan skips group when any active row has regions_reverse=true', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '7201', 'src' => 'rt', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
        ['id' => '7202', 'src' => 'bl', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(0);
    expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php --filter="reverse-maps|all-Russia|regions_reverse"`
Expected: первый тест (reverse-maps) уже PASS (логика была в Task 3); тест all-Russia PASS; тест `regions_reverse` FAIL (группа не пропускается, попадает в planned).

- [ ] **Step 3: Write minimal implementation**

В `buildPlan()`, в цикле по `$rows`, после блока с `$signalType === 'sms'` (т.е. для site/call) добавить проверку exclude ПЕРЕД накоплением. Заменить строку `$this->accumulateRow($groups[$key], $row, $platform);` на:

```php
            if (($row['regions_reverse'] ?? false) === true) {
                $skipped[] = ['reason' => 'regions_exclude', 'label' => $identifier];
                // пометить группу к удалению, чтобы частично накопленные площадки не остались
                $groups[$key]['__excluded'] = true;
            }

            $this->accumulateRow($groups[$key], $row, $platform);
```

И в сборке `$planned` (цикл `foreach ($groups as $g)`) в начало тела добавить:

```php
            if (($g['__excluded'] ?? false) === true) {
                continue;
            }
            unset($g['__excluded']);
```

(оставить существующие `unset($g['has_all_russia'])` и т.д. после этого).

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php`
Expected: PASS (все, включая 3 новых)

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Supplier/Import/SupplierProjectImporter.php app/tests/Feature/Supplier/SupplierProjectImporterTest.php
git commit -m "feat(supplier-import): buildPlan — обратные регионы/union/вся РФ + skip regions_reverse"
```

---

## Task 5: buildPlan — sms группировка по sender (B2/B3)

**Files:**

- Modify: `app/app/Services/Supplier/Import/SupplierProjectImporter.php`
- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`

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

Добавить в `SupplierProjectImporterTest.php`:

```php
test('buildPlan groups sms by sender: B2 (sender+keyword) and B3 (sender)', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '8001', 'src' => 'bl', 'type' => 'sms', 'content' => '79001234567+KVARTIRA', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
        ['id' => '8002', 'src' => 'mt', 'type' => 'sms', 'content' => '79001234567', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(1);
    $p = $plan['planned'][0];
    expect($p['signal_type'])->toBe('sms');
    expect($p['signal_identifier'])->toBeNull();
    expect($p['sms_senders'])->toBe(['79001234567']);
    expect($p['sms_keyword'])->toBe('KVARTIRA');
    expect($p['daily_limit_target'])->toBe(8);              // 4+4
    expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
});

test('buildPlan handles sms B3-only (no keyword)', function (): void {
    $tenant = Tenant::factory()->create();

    $plan = importerWithRows([
        ['id' => '8101', 'src' => 'mt', 'type' => 'sms', 'content' => '79009998877', 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(1);
    expect($plan['planned'][0]['sms_senders'])->toBe(['79009998877']);
    expect($plan['planned'][0]['sms_keyword'])->toBeNull();
    expect($plan['planned'][0]['platforms'][0]['platform'])->toBe('B3');
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php --filter=sms`
Expected: FAIL — sms rows сейчас `continue`-ятся (planned пуст).

- [ ] **Step 3: Write minimal implementation**

В `buildPlan()` заменить блок `if ($signalType === 'sms') { continue; }` на вызов sms-аккумулятора:

```php
            if ($signalType === 'sms') {
                $parsed = SupplierImportMapper::parseSmsContent((string) ($row['content'] ?? ''));
                $sender = $parsed['sender'];
                if ($sender === '') {
                    $skipped[] = ['reason' => 'sms_unparseable', 'label' => (string) ($row['name'] ?? '?')];

                    continue;
                }
                $key = 'sms|'.$sender;
                if (! isset($groups[$key])) {
                    $groups[$key] = [
                        'signal_type' => 'sms',
                        'signal_identifier' => null,
                        'sms_senders' => [$sender],
                        'sms_keyword' => null,
                        'tag' => '',
                        'regions' => [],
                        'has_all_russia' => false,
                        'workdays_mask' => 0,
                        'daily_limit_target' => 0,
                        'platforms' => [],
                    ];
                }
                if ($parsed['keyword'] !== null && $parsed['keyword'] !== '' && $groups[$key]['sms_keyword'] === null) {
                    $groups[$key]['sms_keyword'] = $parsed['keyword'];
                }
                if (($row['regions_reverse'] ?? false) === true) {
                    $skipped[] = ['reason' => 'regions_exclude', 'label' => $sender];
                    $groups[$key]['__excluded'] = true;
                }
                $this->accumulateRow($groups[$key], $row, $platform);

                continue;
            }
```

(Существующий site/call-блок ниже не трогается.)

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php`
Expected: PASS (все)

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Supplier/Import/SupplierProjectImporter.php app/tests/Feature/Supplier/SupplierProjectImporterTest.php
git commit -m "feat(supplier-import): buildPlan — sms-группировка по sender (B2/B3)"
```

---

## Task 6: buildPlan — идемпотентность (существующий Project → skipped)

**Files:**

- Modify: `app/app/Services/Supplier/Import/SupplierProjectImporter.php`
- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`

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

Добавить в `SupplierProjectImporterTest.php` (вверху добавить `use App\Models\Project;`):

```php
test('buildPlan skips a group whose Project already exists for the tenant', function (): void {
    $tenant = Tenant::factory()->create();

    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'signal_type' => 'call',
        'signal_identifier' => '79993332211',
    ]);

    $plan = importerWithRows([
        ['id' => '9001', 'src' => 'rt', 'type' => 'calls', 'content' => '79993332211', 'tag' => 'X', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => []],
    ])->buildPlan($tenant->id);

    expect($plan['planned'])->toHaveCount(0);
    expect(collect($plan['skipped'])->pluck('reason'))->toContain('already_exists');
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php --filter=already_exists`
Expected: FAIL — группа попадает в planned (идемпотентность не реализована).

- [ ] **Step 3: Write minimal implementation**

В `buildPlan()`, в финальном цикле сборки `$planned`, после `unset($g['__excluded'])`-блока и ПЕРЕД `$planned[] = $g;`, добавить проверку существования + сборку `name`/`tag`/`mask` сначала. Перенести `$planned[] = $g;` под проверку:

```php
            // (после расчёта name/tag/delivery_days_mask):
            if ($this->projectExists($tenantId, $g)) {
                $skipped[] = ['reason' => 'already_exists', 'label' => $this->groupLabel($g)];

                continue;
            }
            $planned[] = $g;
```

Добавить методы в класс:

```php
    /**
     * @param  array<string, mixed>  $group
     */
    private function projectExists(int $tenantId, array $group): bool
    {
        $query = \App\Models\Project::on('pgsql_supplier')
            ->where('tenant_id', $tenantId)
            ->where('signal_type', $group['signal_type']);

        if ($group['signal_type'] === 'sms') {
            $sender = $group['sms_senders'][0] ?? '';
            $keyword = $group['sms_keyword'];

            return $query
                ->whereJsonContains('sms_senders', $sender)
                ->where(fn ($q) => $keyword === null ? $q->whereNull('sms_keyword') : $q->where('sms_keyword', $keyword))
                ->exists();
        }

        return $query->where('signal_identifier', $group['signal_identifier'])->exists();
    }

    /**
     * @param  array<string, mixed>  $group
     */
    private function groupLabel(array $group): string
    {
        return $group['signal_type'] === 'sms'
            ? (string) ($group['sms_senders'][0] ?? '?')
            : (string) ($group['signal_identifier'] ?? '?');
    }
```

NB: чтение `Project::on('pgsql_supplier')` (BYPASSRLS) обязательно — команда без tenant-контекста, дефолтное RLS-подключение упало бы (паттерн supplier-джобов).

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php`
Expected: PASS (все)

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Supplier/Import/SupplierProjectImporter.php app/tests/Feature/Supplier/SupplierProjectImporterTest.php
git commit -m "feat(supplier-import): buildPlan идемпотентность — существующий Project пропускается"
```

---

## Task 7: SupplierProjectImporter::commit — запись Project + supplier_projects + pivot (портал не трогаем)

**Files:**

- Modify: `app/app/Services/Supplier/Import/SupplierProjectImporter.php`
- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`

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

Добавить в `SupplierProjectImporterTest.php` (вверху добавить `use App\Models\SupplierProject;` и `use Illuminate\Support\Facades\DB;` и `use Illuminate\Support\Facades\Http;`):

```php
test('commit creates Project + supplier_projects (external_id from portal) + pivot, no portal write', function (): void {
    Http::fake(); // ловушка: НИ один HTTP не должен уйти на портал
    $tenant = Tenant::factory()->create();

    $importer = importerWithRows([
        ['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
        ['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
        ['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
    ]);

    $plan = $importer->buildPlan($tenant->id);
    $result = $importer->commit($plan, $tenant->id);

    expect($result['created_projects'])->toBe(1);

    $project = \App\Models\Project::on('pgsql_supplier')
        ->where('tenant_id', $tenant->id)
        ->where('signal_identifier', '79991112233')
        ->first();
    expect($project)->not->toBeNull();
    expect($project->daily_limit_target)->toBe(18);
    expect($project->is_active)->toBeTrue();
    expect($project->regions)->toBe([29]);                  // ГИБДД 24 → Лидерра 29
    expect($project->delivery_days_mask)->toBe(31);

    $sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79991112233')->get();
    expect($sps)->toHaveCount(3);
    expect($sps->pluck('supplier_external_id')->sort()->values()->all())->toBe(['4001', '4002', '4003']);
    expect($sps->pluck('sync_status')->unique()->all())->toBe(['ok']);
    expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(6);

    $pivot = DB::connection('pgsql_supplier')->table('project_supplier_links')
        ->where('project_id', $project->id)->count();
    expect($pivot)->toBe(3);

    Http::assertNothingSent(); // портал не дёргался
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php --filter="commit creates"`
Expected: FAIL — `Call to undefined method ...::commit()`.

- [ ] **Step 3: Write minimal implementation**

Добавить в класс `SupplierProjectImporter` (вверху файла — `use` для моделей и фасадов):

```php
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\SupplierProjectGrouping;
use Illuminate\Support\Facades\DB;
```

Метод:

```php
    private const DB_CONNECTION = 'pgsql_supplier';

    /**
     * Пишет план в БД: Project + supplier_projects (external_id с портала) + pivot.
     * НЕ обращается к порталу. Каждый проект — в своей транзакции (частичный сбой
     * не откатывает уже импортированные).
     *
     * @param  array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>}  $plan
     * @return array{created_projects: int, created_supplier_projects: int, created_links: int}
     */
    public function commit(array $plan, int $tenantId): array
    {
        $createdProjects = 0;
        $createdSps = 0;
        $createdLinks = 0;

        foreach ($plan['planned'] as $item) {
            DB::connection(self::DB_CONNECTION)->transaction(function () use ($item, $tenantId, &$createdProjects, &$createdSps, &$createdLinks): void {
                /** @var Project $project */
                $project = Project::on(self::DB_CONNECTION)->create([
                    'tenant_id' => $tenantId,
                    'name' => $item['name'],
                    'tag' => $item['tag'],
                    'is_active' => true,
                    'signal_type' => $item['signal_type'],
                    'signal_identifier' => $item['signal_identifier'],
                    'sms_senders' => $item['sms_senders'] !== [] ? $item['sms_senders'] : null,
                    'sms_keyword' => $item['sms_keyword'],
                    'regions' => $item['regions'],
                    'region_mode' => 'include',
                    'delivery_days_mask' => $item['delivery_days_mask'],
                    'daily_limit_target' => $item['daily_limit_target'],
                ]);
                $createdProjects++;

                foreach ($item['platforms'] as $pl) {
                    $platform = (string) $pl['platform'];
                    $uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);

                    /** @var SupplierProject $sp */
                    $sp = SupplierProject::on(self::DB_CONNECTION)->firstOrCreate(
                        ['platform' => $platform, 'unique_key' => $uniqueKey, 'subject_code' => null],
                        [
                            'signal_type' => $item['signal_type'],
                            'supplier_external_id' => (string) $pl['external_id'],
                            'current_limit' => (int) $pl['lim'],
                            'current_workdays' => $this->maskToList((int) $item['delivery_days_mask']),
                            'current_regions' => $item['regions'],
                            'sync_status' => 'ok',
                            'last_synced_at' => now(),
                        ],
                    );
                    if ($sp->wasRecentlyCreated) {
                        $createdSps++;
                        SupplierSyncLog::on(self::DB_CONNECTION)->create([
                            'supplier_project_id' => $sp->id,
                            'action' => 'create',
                            'http_status' => 200,
                            'created_at' => now(),
                        ]);
                    }

                    $inserted = DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
                        'project_id' => $project->id,
                        'supplier_project_id' => $sp->id,
                        'platform' => $platform,
                        'subject_code' => null,
                    ]);
                    $createdLinks += $inserted;
                }
            });
        }

        return [
            'created_projects' => $createdProjects,
            'created_supplier_projects' => $createdSps,
            'created_links' => $createdLinks,
        ];
    }

    /**
     * Маска дней (bit0=Пн) → list<int> [1..7].
     *
     * @return list<int>
     */
    private function maskToList(int $mask): array
    {
        $out = [];
        for ($i = 0; $i < 7; $i++) {
            if (($mask & (1 << $i)) !== 0) {
                $out[] = $i + 1;
            }
        }

        return $out;
    }
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php`
Expected: PASS (все)

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Supplier/Import/SupplierProjectImporter.php app/tests/Feature/Supplier/SupplierProjectImporterTest.php
git commit -m "feat(supplier-import): commit — Project+supplier_projects+pivot из external_id, без записи на портал"
```

---

## Task 8: commit — реюз существующего supplier_project (без дубля)

**Files:**

- Test: `app/tests/Feature/Supplier/SupplierProjectImporterTest.php`

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

Добавить:

```php
test('commit reuses an existing supplier_project row instead of duplicating', function (): void {
    Http::fake();
    $tenant = Tenant::factory()->create();

    // supplier_project уже есть (например, создан webhook resolveOrStub ранее)
    SupplierProject::on('pgsql_supplier')->forceCreate([
        'platform' => 'B1',
        'signal_type' => 'call',
        'unique_key' => '79994445566',
        'subject_code' => null,
        'supplier_external_id' => 'EXIST1',
        'current_limit' => 6,
        'current_workdays' => [1, 2, 3, 4, 5],
        'current_regions' => [],
        'sync_status' => 'ok',
        'last_synced_at' => now(),
    ]);

    $importer = importerWithRows([
        ['id' => '4500', 'src' => 'rt', 'type' => 'calls', 'content' => '79994445566', 'tag' => 'Y', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
    ]);
    $plan = $importer->buildPlan($tenant->id);
    $importer->commit($plan, $tenant->id);

    // по-прежнему ровно 1 supplier_project с этим ключом+платформой (реюз, не дубль)
    expect(SupplierProject::on('pgsql_supplier')
        ->where('unique_key', '79994445566')->where('platform', 'B1')->count())->toBe(1);

    // pivot привязал существующую строку к новому проекту
    $project = \App\Models\Project::on('pgsql_supplier')->where('signal_identifier', '79994445566')->first();
    $sp = SupplierProject::on('pgsql_supplier')->where('unique_key', '79994445566')->first();
    expect(DB::connection('pgsql_supplier')->table('project_supplier_links')
        ->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->count())->toBe(1);
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierProjectImporterTest.php --filter="reuses an existing"`
Expected: PASS (логика `firstOrCreate` по `(platform, unique_key, subject_code)` уже реюзит). Если FAIL — проверить, что `firstOrCreate`-атрибуты совпадают с unique-индексом `(platform, unique_key, subject_code NULLS NOT DISTINCT)`.

- [ ] **Step 3: (если Step 2 PASS — пропустить implementation)**

Если тест прошёл — реализация Task 7 уже корректна; шаг implementation не нужен.

- [ ] **Step 4: Commit**

```bash
git add app/tests/Feature/Supplier/SupplierProjectImporterTest.php
git commit -m "test(supplier-import): commit реюзит существующий supplier_project, не дублирует"
```

---

## Task 9: ImportSupplierProjectsCommand — artisan (dry-run по умолчанию, --commit)

**Files:**

- Create: `app/app/Console/Commands/ImportSupplierProjectsCommand.php`
- Test: `app/tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php`

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

Create `app/tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;

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

beforeEach(function (): void {
    $this->tenant = Tenant::factory()->create();
    User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'info@lkomega.ru']);

    $client = Mockery::mock(SupplierPortalClient::class);
    $client->shouldReceive('listProjects')->andReturn([
        ['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
        ['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
        ['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
    ]);
    $this->app->instance(SupplierPortalClient::class, $client);
});

test('dry-run prints plan and writes nothing', function (): void {
    Http::fake();

    $this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru'])
        ->assertExitCode(0);

    expect(Project::on('pgsql_supplier')->where('tenant_id', $this->tenant->id)->count())->toBe(0);
    Http::assertNothingSent();
});

test('--commit writes projects', function (): void {
    Http::fake();

    $this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru', '--commit' => true])
        ->assertExitCode(0);

    expect(Project::on('pgsql_supplier')
        ->where('tenant_id', $this->tenant->id)
        ->where('signal_identifier', '79991112233')->count())->toBe(1);
    Http::assertNothingSent();
});

test('unknown tenant email → non-zero exit, no write', function (): void {
    $this->artisan('supplier:import-projects', ['--tenant' => 'nobody@nowhere.ru', '--commit' => true])
        ->assertExitCode(1);
});
```

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php`
Expected: FAIL — команда `supplier:import-projects` не существует.

- [ ] **Step 3: Write minimal implementation**

Create `app/app/Console/Commands/ImportSupplierProjectsCommand.php`:

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\User;
use App\Services\Supplier\Import\SupplierProjectImporter;
use Illuminate\Console\Command;

/**
 * Разовый импорт активных проектов поставщика (аккаунт lkomega) как проектов
 * Лидерры под тенантом владельца. По умолчанию dry-run (печатает план, ничего
 * не пишет). С --commit пишет в БД через pgsql_supplier (BYPASSRLS), портал НЕ
 * трогает. Идемпотентна.
 *
 * Plan: docs/superpowers/plans/2026-05-22-supplier-projects-import-lkomega.md
 */
class ImportSupplierProjectsCommand extends Command
{
    protected $signature = 'supplier:import-projects
        {--tenant= : email пользователя тенанта (напр. info@lkomega.ru)}
        {--commit : выполнить запись (без флага — только dry-run)}';

    protected $description = 'Усыновить активные проекты поставщика как проекты Лидерры под тенантом (dry-run по умолчанию)';

    public function handle(SupplierProjectImporter $importer): int
    {
        $email = (string) $this->option('tenant');
        if ($email === '') {
            $this->error('Укажите --tenant=<email>');

            return self::FAILURE;
        }

        $tenantId = User::on('pgsql_supplier')->where('email', $email)->value('tenant_id');
        if ($tenantId === null) {
            $this->error("Тенант для email '{$email}' не найден.");

            return self::FAILURE;
        }

        $plan = $importer->buildPlan((int) $tenantId);

        $this->info(sprintf('Тенант %s (id=%d). К созданию: %d проектов. Пропущено строк/групп: %d.',
            $email, $tenantId, count($plan['planned']), count($plan['skipped'])));

        $this->table(
            ['Тип', 'Идентификатор', 'Тег', 'Регионы', 'Лимит', 'Площадки (external_id)'],
            array_map(fn (array $p): array => [
                $p['signal_type'],
                $this->mask($p['signal_identifier'] ?? ($p['sms_senders'][0] ?? '')),
                mb_substr((string) $p['tag'], 0, 30),
                $p['regions'] === [] ? 'вся РФ' : implode(',', $p['regions']),
                (string) $p['daily_limit_target'],
                collect($p['platforms'])->map(fn (array $pl): string => $pl['platform'].':'.$pl['external_id'])->implode(' '),
            ], $plan['planned']),
        );

        if ($plan['skipped'] !== []) {
            $this->warn('Пропуски:');
            foreach ($plan['skipped'] as $s) {
                $this->line(sprintf('  - [%s] %s', $s['reason'], $this->mask($s['label'])));
            }
        }

        if (! $this->option('commit')) {
            $this->comment('DRY-RUN: ничего не записано. Повторите с --commit для реальной записи.');

            return self::SUCCESS;
        }

        $result = $importer->commit($plan, (int) $tenantId);
        $this->info(sprintf('Создано: проектов=%d, supplier_projects=%d, связок=%d.',
            $result['created_projects'], $result['created_supplier_projects'], $result['created_links']));

        return self::SUCCESS;
    }

    /** Маскирует цифровые хвосты (телефоны) для вывода (152-ФЗ). */
    private function mask(string $value): string
    {
        return (string) preg_replace('/\d{4,}/', fn (array $m): string => substr($m[0], 0, 2).str_repeat('*', max(0, strlen($m[0]) - 4)).substr($m[0], -2), $value);
    }
}
```

NB: Laravel 13 авто-регистрирует команды из `app/Console/Commands/` — отдельной регистрации не нужно.

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

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php`
Expected: PASS (3 теста)

- [ ] **Step 5: Commit**

```bash
git add app/app/Console/Commands/ImportSupplierProjectsCommand.php app/tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php
git commit -m "feat(supplier-import): artisan supplier:import-projects (dry-run / --commit, маскирование ПДн)"
```

---

## Task 10: Регрессия + Pint + Larastan + деплой-чеклист

**Files:**

- Create: `docs/deploy/supplier-import-lkomega-runbook.md`

- [ ] **Step 1: Прогнать целевую регрессию**

Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierImportMapperTest.php tests/Unit/Supplier/SupplierRegionsTest.php tests/Feature/Supplier/SupplierProjectImporterTest.php tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php`
Expected: все PASS, 0 failed. Выписать фактические числа.

- [ ] **Step 2: Прогнать широкий supplier-suite (нет регрессий)**

Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier tests/Unit/Supplier`
Expected: 0 новых падений против baseline.

- [ ] **Step 3: Pint + Larastan**

Run: `cd app && composer pint && composer stan`
Expected: Pint clean; Larastan 0 ошибок выше baseline. Если новые baseline-записи нужны (Pest TestCall pattern / Mockery) — `composer stan -- --generate-baseline=phpstan-baseline.neon`, затем `composer stan` (квирк #25), и закоммитить baseline.

- [ ] **Step 4: Написать runbook деплоя/прогона**

Create `docs/deploy/supplier-import-lkomega-runbook.md` с содержанием:

```markdown
# Runbook: импорт проектов lkomega → info@lkomega.ru

Разовая операция на боевом liderra.ru (111.88.246.137).

## Деплой команды
1. scp 4 файла на сервер в /var/www/liderra/app:
   - app/Support/SupplierRegions.php
   - app/Services/Supplier/Import/SupplierImportMapper.php
   - app/Services/Supplier/Import/SupplierProjectImporter.php
   - app/Console/Commands/ImportSupplierProjectsCommand.php
   (бэкап заменяемого SupplierRegions.php: cp *.bak)
2. `php artisan optimize:clear` (сброс кэша команд/конфига).

## Dry-run (показать план)
`php artisan supplier:import-projects --tenant=info@lkomega.ru`
→ показать вывод заказчику (число проектов, лимиты, регионы, пропуски). Получить «ок».

## Реальный прогон
`php artisan supplier:import-projects --tenant=info@lkomega.ru --commit`

## Пост-проверка
- `php artisan tinker --execute="echo App\Models\Project::on('pgsql_supplier')->where('tenant_id', <ID>)->count();"`
- Выборочно сверить 2-3 проекта (лимит = сумме площадок, регионы корректны).
- Подтвердить, что на портале crm.bp-gr.ru НЕ появилось новых проектов (команда его не дёргает).

## Откат (если нужно)
Импортированные проекты под тенантом — soft-archive через ЛК или
`Project::on('pgsql_supplier')->where(...)->update(['is_active'=>false,'archived_at'=>now()])`;
supplier_projects/pivot можно оставить (они указывают на реальные портальные проекты).
```

- [ ] **Step 5: Commit**

```bash
git add docs/deploy/supplier-import-lkomega-runbook.md app/phpstan-baseline.neon
git commit -m "docs(supplier-import): runbook деплоя/прогона + larastan baseline"
```

(Если `phpstan-baseline.neon` не менялся — убрать из `git add`.)

---

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

**Spec coverage:**

- §4 маппинг (src/type/regions/workdays/sms) → Task 1+2. ✅
- §4 группировка B1/B2/B3 + лимит-сумма → Task 3. ✅
- §4 регионы reverse/union/вся РФ + regions_reverse skip → Task 4. ✅
- §4 sms по sender → Task 5. ✅
- §5 идемпотентность Project → Task 6; supplier_project реюз → Task 8. ✅
- §7 запись Project+supplier_projects+pivot, без портала → Task 7. ✅
- §6 команда dry-run/--commit + маскирование → Task 9. ✅
- §8 безопасность (dry-run default, транзакция, портал не трогаем) → Task 7+9. ✅
- §10 выполнение/деплой → Task 10. ✅
- Охват «все активные (status + lim>0)»: status-фильтр в buildPlan; lim>0 — фактически у всех (recon), отдельного фильтра не требует. ✅

**Type consistency:** plan-item shape единый во всех задачах; `commit` читает те же ключи, что пишет `buildPlan`; `platforms[].external_id` int везде; `SupplierProjectGrouping::buildUniqueKey($project, $platform)` существует (origin/main). ✅

**Placeholder scan:** 0 TBD/«добавить обработку ошибок» — весь код приведён. ✅

**Открытый момент планирования (из спеки §11):** группировка идёт по `(signal_type, identifier)` без тега (как ночной джоб) — реализовано в Task 3/5; тег = первый непустой не-«РФ» из группы. Финальное число проектов покажет dry-run на реальных данных (ожидаемо ~128, recon с тегом дал 128; без тега возможно меньше при коллизиях тег↔идентификатор).
