# Переделка миграции проектов — План 3: канал экспорта + формула заказа

> **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:** Переписать экспорт проектов поставщику: режимы online/batch (глобальный тумблер), per-субъект save (R7, tag=субъект), один multi-flag save B1+B2+B3 с захватом 3 external_id (R5), портал делит лимит сам (R6, наш split убран), формула заказа `max(наиб, ceil(Σ/3))`, запись связей в pivot.

**Architecture:** `SupplierExportMode` (резолвер тумблера `system_settings`). `SupplierQuotaAllocator` → pure `computeOrder()` (max(наиб, ceil(Σ/3))) + union workdays, без platform-split. `SupplierPortalClient::saveProjectMultiFlag()` — один save с 3 флагами → дочитывает `listProjects()` → возвращает `[platform => external_id]` (R-SAVE вариант а). `SyncSupplierProjectsJob` переписан с FK-итерации на **группировку (источник × субъект)** из активных Лидерра-проектов; создаёт/обновляет 3 supplier_projects (по платформе) на группу + пишет pivot. `SyncSupplierProjectJob` (онлайн) при mode=online шлёт полные параметры, иначе — каркас (как сейчас). `ProjectService` диспатчит онлайн-sync mode-aware.

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Playwright bridge (R-SAVE live smoke).

**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.1, §4.3, §4.5 (часть заказа), §8 R-SAVE.

**Prereq:** Планы 1+2 исполнены (pivot, subject_code, RegionTagResolver, RussianRegions, LeadRouter на pivot). Жив supplier-портал + сессия (`refresh-session.js`) для Task 1 smoke.

---

## Task 1: R-SAVE live smoke — захват 3 external_id из multi-flag save (ГЕЙТ)

> Гейт всего плана. До зелёного smoke прод-путь R5 не переписывать. Решение заказчика — **вариант а** (дочитать listProjects); fallback **б** (3 раздельных save) только если а провалится.

**Files:**

- Create: `app/storage/_smoke_multiflag_save.php` (временный tinker-скрипт; удалить после)
- Create: `docs/discovery/2026-05-20-rsave-multiflag-finding.md` (фиксация результата)

- [ ] **Step 1: Написать smoke-скрипт**

`app/storage/_smoke_multiflag_save.php` — через живой `SupplierPortalClient` (Tier-1 AJAX):

1. POST `/admin/visit/rt-project-save` с payload, где `srcrt=srcbl=srcmt=true`, уникальный `name` (валидный домен, lowercase — recon §находка 1), `tag="Москва"`, `type=hosts`, `regions=[]`, `limit=9`.
2. Запомнить `id` из ответа (последний из 3 — recon §находка 2).
3. `listProjects()` → найти ВСЕ строки с `name == <наш>` И `tag == "Москва"` → собрать их `id`.
4. Вывести: сколько проектов создалось (ожидаем 3), их id + платформы (поле `src`).
5. Cleanup: `deleteProject()` по каждому из найденных id.

```php
<?php
// app/storage/_smoke_multiflag_save.php — запуск: php artisan tinker --execute="require 'storage/_smoke_multiflag_save.php';"
use App\Services\Supplier\SupplierPortalClient;

$client = app(SupplierPortalClient::class);
$name = 'rsave-smoke-'.date('His').'.example';
$tag = 'Москва';

// 1. multi-flag save (срабатываем напрямую через request — saveProject() пока single-flag).
$resp = app('supplier.smoke.multiflag')($name, $tag); // helper ниже ИЛИ inline request
echo "save response id (last): ".$resp."\n";

// 3. дочитать список
$all = $client->listProjects();
$mine = array_values(array_filter($all, fn ($p) => ($p['name'] ?? '') === $name && ($p['tag'] ?? '') === $tag));
echo "found projects by name+tag: ".count($mine)."\n";
foreach ($mine as $p) {
    echo "  id={$p['id']} src=".json_encode($p['src'] ?? null)."\n";
}

// 5. cleanup
foreach ($mine as $p) {
    $client->deleteProject((int) $p['id']);
    echo "deleted {$p['id']}\n";
}
```

(`src`/поле платформы в строке listProjects — уточнить по факту вывода; recon §listProjects описывает конверт `{projects:[{id,tag,src,name,type,...}]}`.)

- [ ] **Step 2: Прогнать smoke против живого портала**

Run: `cd app; php artisan tinker --execute="require base_path('storage/_smoke_multiflag_save.php');"`
Expected: вывод `found projects by name+tag: 3` + 3 разные платформы + 3 успешных delete. Привести ПОЛНЫЙ вывод.

- [ ] **Step 3: Зафиксировать находку**

Создать `docs/discovery/2026-05-20-rsave-multiflag-finding.md`: результат (3 ли проекта; как платформа кодируется в `src`; по каким полям матчить). **Решение:** вариант а подтверждён ИЛИ откат на б (3 раздельных save). Этот файл — вход для Task 4.

- [ ] **Step 4: Удалить временный скрипт**

```bash
rm app/storage/_smoke_multiflag_save.php
git add -- docs/discovery/2026-05-20-rsave-multiflag-finding.md
git commit -m "docs(supplier): R-SAVE multi-flag save live smoke finding" -- docs/discovery/2026-05-20-rsave-multiflag-finding.md
```

---

## Task 2: `SupplierExportMode` — резолвер глобального тумблера

**Files:**

- Create: `app/app/Services/Supplier/SupplierExportMode.php`
- Test: `app/tests/Feature/Supplier/SupplierExportModeTest.php`

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

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierExportMode;
use Illuminate\Support\Facades\DB;

it('reads mode from system_settings, defaults batch', function (): void {
    DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
    expect(SupplierExportMode::current())->toBe('online')
        ->and(SupplierExportMode::isOnline())->toBeTrue();

    DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
    expect(SupplierExportMode::current())->toBe('batch')
        ->and(SupplierExportMode::isOnline())->toBeFalse();
});

it('falls back to batch when setting missing', function (): void {
    DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
    expect(SupplierExportMode::current())->toBe('batch');
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=SupplierExportModeTest`
Expected: FAIL — класса нет.

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

`app/app/Services/Supplier/SupplierExportMode.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use Illuminate\Support\Facades\DB;

/**
 * Глобальный режим экспорта проектов поставщику (system_settings).
 * 'online' — sync сразу при create/edit с полными параметрами;
 * 'batch'  — каркас сразу + полные параметры ночным SyncSupplierProjectsJob (18:00).
 * Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
 */
final class SupplierExportMode
{
    public const ONLINE = 'online';

    public const BATCH = 'batch';

    public static function current(): string
    {
        $value = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');

        return $value === self::ONLINE ? self::ONLINE : self::BATCH;
    }

    public static function isOnline(): bool
    {
        return self::current() === self::ONLINE;
    }
}
```

- [ ] **Step 4: Прогнать тест — зелёный**

Run: `php artisan test --filter=SupplierExportModeTest`
Expected: PASS (2). Полный вывод.

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

```bash
composer pint -- app/app/Services/Supplier/SupplierExportMode.php; composer stan
git add -- app/app/Services/Supplier/SupplierExportMode.php app/tests/Feature/Supplier/SupplierExportModeTest.php
git commit -m "feat(supplier): SupplierExportMode toggle resolver (online|batch)" -- app/app/Services/Supplier/SupplierExportMode.php app/tests/Feature/Supplier/SupplierExportModeTest.php
```

---

## Task 3: `SupplierQuotaAllocator` — формула заказа max(наиб, ceil(Σ/3))

**Files:**

- Modify: `app/app/Services/Supplier/SupplierQuotaAllocator.php` (новый `computeOrder()`; удалить `distributeForPlatform`)
- Test: `app/tests/Unit/Services/SupplierQuotaAllocatorTest.php` (переписать под формулу заказа)

- [ ] **Step 1: Написать падающий тест (verified-примеры из brief)**

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierQuotaAllocator;

it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (array $limits, int $expected): void {
    expect(SupplierQuotaAllocator::computeOrder($limits))->toBe($expected);
})->with([
    'brief 1' => [[5, 5, 10, 20], 20],
    'brief 2' => [array_merge(array_fill(0, 15, 5), [10]), 29], // 15×5 + 10 → Σ85, наиб10, ceil(85/3)=29
    'brief 3' => [[15, 15, 15], 15],
    'brief 4' => [[15, 15, 15, 30], 30],
    'brief 5' => [[10, 10, 10, 10], 14],
    'single'  => [[7], 7],
    'empty'   => [[], 0],
]);
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=SupplierQuotaAllocatorTest`
Expected: FAIL — метода `computeOrder` нет (старый `allocate`/`distributeForPlatform` ещё на месте).

- [ ] **Step 3: Добавить `computeOrder`, удалить `distributeForPlatform`**

В `app/app/Services/Supplier/SupplierQuotaAllocator.php`:

- Добавить статический метод:

```php
    /**
     * Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
     *   ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
     *   наиб      — крупнейший клиент должен иметь шанс добрать.
     * Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
     *
     * @param  array<int, int>  $dailyLimits  лимиты eligible-сегодня клиентов группы
     */
    public static function computeOrder(array $dailyLimits): int
    {
        if ($dailyLimits === []) {
            return 0;
        }

        $sum = array_sum($dailyLimits);
        $max = max($dailyLimits);

        return max($max, (int) ceil($sum / 3));
    }
```

- Удалить метод `distributeForPlatform()` ([SupplierQuotaAllocator.php:73-95](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L73)). `allocate()` и `unionInts()` — переписываются/используются в Task 5 (sync rewrite); на этом шаге **оставить `allocate()` как есть**, но заменить внутри вызов `distributeForPlatform(...)` на `computeOrder($eligibleProjects->pluck('daily_limit')->all())` для лимита (временная совместимость до Task 5; платформенный split исчезает — лимит одинаков для всех платформ). Конкретно строку `$platformLimit = self::distributeForPlatform(...)` ([:59](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L59)) заменить на:

```php
        $platformLimit = self::computeOrder(
            $eligibleProjects->pluck('daily_limit')->map(fn ($v) => (int) $v)->all()
        );
```

- [ ] **Step 4: Прогнать тест — зелёный**

Run: `php artisan test --filter=SupplierQuotaAllocatorTest`
Expected: PASS (7 датасетов). Полный вывод.

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

```bash
composer pint -- app/app/Services/Supplier/SupplierQuotaAllocator.php; composer stan
git add -- app/app/Services/Supplier/SupplierQuotaAllocator.php app/tests/Unit/Services/SupplierQuotaAllocatorTest.php
git commit -m "feat(supplier): order formula max(max, ceil(sum/3)), drop platform split" -- app/app/Services/Supplier/SupplierQuotaAllocator.php app/tests/Unit/Services/SupplierQuotaAllocatorTest.php
```

---

## Task 4: `SupplierPortalClient::saveProjectMultiFlag` — R5/R6/R7 save (вариант а)

> Точная реализация захвата id зависит от Task 1 finding. Ниже — вариант а (дочитать listProjects). Если smoke показал иное (б) — реализовать раздельные save по платформам.

**Files:**

- Modify: `app/app/Services/Supplier/SupplierPortalClient.php` (новый `saveProjectMultiFlag()`; `toPayload` — tag из dto, multi-flag по набору платформ)
- Modify: `app/app/Services/Supplier/Dto/SupplierProjectDto.php` (+поля `tag`, `platforms`)
- Test: `app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php` (HTTP::fake)

- [ ] **Step 1: Написать падающий тест (HTTP::fake save + listProjects)**

```php
<?php

declare(strict_types=1);

use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Support\Facades\Http;

it('multi-flag save returns external_id per platform via listProjects', function (): void {
    Http::fake([
        '*/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'id' => '300'], 200),
        '*/admin/visit/rt-projects-load*' => Http::response(['projects' => [
            ['id' => '100', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
            ['id' => '200', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
            ['id' => '300', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
            ['id' => '999', 'name' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
        ]], 200),
    ]);

    $dto = new SupplierProjectDto(
        platform: 'B1', signalType: 'site', uniqueKey: 'okna.ru', limit: 9,
        workdays: [1,2,3,4,5,6,7], regions: [82], regionsReverse: false, status: 'active',
        tag: 'Москва', platforms: ['B1','B2','B3'],
    );

    $ids = app(SupplierPortalClient::class)->saveProjectMultiFlag($dto);

    expect($ids)->toBe(['B1' => 100, 'B2' => 200, 'B3' => 300]);
});
```

(Маппинг `src`-кода портала → платформа — по Task 1 finding: `rt→B1, bl→B2, mt→B3` ориентировочно из `srcrt/srcbl/srcmt`. Уточнить по выводу smoke.)

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=SupplierPortalClientMultiFlagTest`
Expected: FAIL — метода нет, DTO без `tag`/`platforms`.

- [ ] **Step 3: Расширить DTO**

В `app/app/Services/Supplier/Dto/SupplierProjectDto.php` добавить в конструктор два параметра (с дефолтами для обратной совместимости single-flag путей):

```php
        public string $tag = '_lidpotok',
        /** @var array<int, string> */
        public array $platforms = [],
```

(`platforms` пустой → single `[platform]`. `tag` дефолт `_lidpotok` — старые пути не ломаются.)

- [ ] **Step 4: `toPayload` — tag из dto + multi-flag**

В `toPayload` ([SupplierPortalClient.php:413](../../../app/app/Services/Supplier/SupplierPortalClient.php#L413)):

- `'tag' => $dto->tag,` (вместо хардкода `'_lidpotok'`).
- Набор активных платформ: `$platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];` → `'srcrt' => in_array('B1', $platforms, true), 'srcbl' => in_array('B2', $platforms, true), 'srcmt' => in_array('B3', $platforms, true),`.
- `'regions' => $dto->regions` уже есть — теперь несёт `[subject_code]` или `[]`.

- [ ] **Step 5: Добавить `saveProjectMultiFlag`**

```php
    /**
     * R5: один save с флагами всех dto->platforms → портал создаёт N rt-проектов,
     * портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего →
     * дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
     *
     * @return array<string, int> [platform => external_id]
     */
    public function saveProjectMultiFlag(SupplierProjectDto $dto): array
    {
        $response = $this->request(
            'POST', '/admin/visit/rt-project-save',
            $this->toPayload($dto, externalId: 0), asJson: true,
        );
        $this->assertStatusOk($response, '/admin/visit/rt-project-save');

        $srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
        $out = [];
        foreach ($this->listProjects() as $p) {
            if (($p['name'] ?? null) !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
                continue;
            }
            $platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
            if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
                $out[$platform] = (int) $p['id'];
            }
        }

        return $out;
    }
```

- [ ] **Step 6: Прогнать тест — зелёный**

Run: `php artisan test --filter=SupplierPortalClientMultiFlagTest`
Expected: PASS. Полный вывод. (Старые `SupplierPortalClient` тесты — single-flag/`_lidpotok` дефолт — не затронуты; прогнать `--filter=SupplierPortalClient` целиком.)

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

```bash
composer pint -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php; composer stan
git add -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php
git commit -m "feat(supplier): saveProjectMultiFlag R5 + tag/platforms DTO (R6/R7)" -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php
```

---

## Task 5: `SyncSupplierProjectsJob` — группировка (источник × субъект) + pivot

> Ядро рерайта. Переход от FK-итерации к группировке активных Лидерра-проектов по (источник × субъект); на группу — один multi-flag save + 3 supplier_projects (по платформе, subject_code) + pivot-связи.

**Files:**

- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`
- Modify: `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`

**Алгоритм `handle()` (новый):**

1. Загрузить активные Лидерра-projects (`is_active=true`, `archived_at IS NULL`) через `pgsql_supplier`.
2. Развернуть каждый в группы `(signal_type, identifier, subject_code)`:
   - subjects = `project.regions` (коды 1..89); если пусто → одна группа с `subject_code = null` (пул «Вся РФ»).
   - identifier = `signal_identifier` (site/call) / sender(+keyword) (sms) — как `buildUniqueKey` в `SyncSupplierProjectJob`.
   - platforms = `resolvePlatforms()` (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
3. Для каждой группы:
   - eligible-сегодня (workday-маска на завтра, как сейчас targetDate=tomorrow) проекты группы.
   - `order = SupplierQuotaAllocator::computeOrder($eligibleLimits)`; `workdays = union`; `tag = subject ? RussianRegions::CODE_TO_NAME[subject] : 'РФ'`; `regions = subject ? [subject] : []`.
   - найти существующие supplier_projects группы (по `unique_key=identifier`, `subject_code`); если ни одного — создать через `saveProjectMultiFlag` (→ 3 id), upsert 3 supplier_projects (platform, signal_type, unique_key, subject_code, supplier_external_id, current_limit=order, current_regions); если есть — `updateProject` каждого external_id (R6: один лимит).
   - синхронизировать pivot: для каждого contributing Лидерра-проекта × каждого supplier_project группы — upsert строки `project_supplier_links` (idempotent ON CONFLICT).
4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — **сохранить как есть**.

- [ ] **Step 1: Переписать тест `SyncSupplierProjectsJobTest`** под группировку

Ключевые кейсы (HTTP::fake портала; адаптировать фабрики существующего файла):

- **per-subject:** проект `regions=[82,83]`, site → 2 группы (Москва, СПб) → 2 multi-flag save → 6 supplier_projects (2 субъекта × 3 платформы) с верными `subject_code`/`tag`; pivot — 6 связей на проект.
- **вся РФ пул:** проект `regions=[]` → 1 группа `subject_code=null`, `tag='РФ'`, `regions=[]` → 3 supplier_projects.
- **order:** 2 проекта на один (источник×субъект) лимиты [10,20] → save с `limit=20` (computeOrder).
- **sms:** sms+keyword → платформы B2+B3 (2 supplier_projects на субъект).
- **idempotent:** повторный прогон без изменений → updateProject (не дубль supplier_projects/pivot).

(Привести полный код хотя бы первого и order-кейса по образцу существующих тестов файла + `Http::fake` save/list как в Task 4.)

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=SyncSupplierProjectsJobTest`
Expected: FAIL — старая FK-итерация не группирует по субъекту, не пишет pivot.

- [ ] **Step 3: Переписать `handle()` + `syncOne` → `syncGroup`**

Реализовать алгоритм выше. Сохранить: connection `pgsql_supplier`, time-budget cutoff, все catch-блоки (Auth/Transient/Client/Window/Tier), `SupplierSyncLog`. Заменить: FK-итерацию supplier_projects → итерацию по группам активных проектов; `SupplierQuotaAllocator::allocate` → `computeOrder` + ручная сборка DTO (tag/regions/platforms) + `saveProjectMultiFlag` на create; pivot upsert через `DB::table('project_supplier_links')->...->upsert(...)` или `INSERT ... ON CONFLICT DO NOTHING`. Хелперы `buildUniqueKey`/`resolvePlatforms` — переиспользовать (вынести из `SyncSupplierProjectJob` в общий трейт/сервис `SupplierProjectGrouping` чтобы DRY; см. Task 6).

**NB:** точная сигнатура save-капчи — по Task 1 finding (вариант а реализован в Task 4 `saveProjectMultiFlag`). Update-путь существующих проектов остаётся `updateProject(externalId, dto)` на каждый из 3 (R6: одинаковый лимит).

- [ ] **Step 4: Прогнать тест — зелёный**

Run: `php artisan test --filter=SyncSupplierProjectsJobTest`
Expected: PASS все кейсы. Полный вывод; failed — file:line.

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

```bash
composer pint -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php; composer stan
git add -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
git commit -m "feat(supplier): SyncSupplierProjectsJob per-subject grouping + pivot + order" -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
```

---

## Task 6: `SyncSupplierProjectJob` (онлайн) — mode-aware полные параметры

**Files:**

- Create: `app/app/Services/Supplier/SupplierProjectGrouping.php` (DRY-хелперы buildUniqueKey/resolvePlatforms/subjectsOf)
- Modify: `app/app/Jobs/SyncSupplierProjectJob.php` (online: full params; batch: каркас как сейчас)
- Test: `app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php`

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

```php
it('online mode creates per-subject supplier_projects with full params + pivot', function (): void {
    DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
    Http::fake([/* save OK + listProjects 3 ids per subject как в Task 4 */]);

    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'okna.ru',
        'is_active' => true, 'daily_limit_target' => 12, 'regions' => [82], 'delivery_days_mask' => 127,
    ]);

    (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));

    expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3)
        ->and(SupplierProject::where('unique_key', 'okna.ru')->where('subject_code', 82)->count())->toBe(3);
});

it('batch mode keeps каркас (limit 0, no subject save)', function (): void {
    DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
    // ... ожидание: каркас как сейчас (current_limit=0), ночной job дольёт.
});
```

- [ ] **Step 2: Прогнать — падает**

Run: `php artisan test --filter=SyncSupplierProjectJobTest`
Expected: FAIL — джоб не mode-aware, не per-subject.

- [ ] **Step 3: Вынести общие хелперы в `SupplierProjectGrouping`**

`app/app/Services/Supplier/SupplierProjectGrouping.php` — статические `buildUniqueKey(Project,$platform)`, `resolvePlatforms(Project): array`, `subjectsOf(Project): array` (`regions ?: [null]`). Перенести логику из `SyncSupplierProjectJob::buildUniqueKey/resolvePlatforms` ([SyncSupplierProjectJob.php:138-172](../../../app/app/Jobs/SyncSupplierProjectJob.php#L138)); Task 5 sync-job тоже их использует (DRY).

- [ ] **Step 4: Сделать `SyncSupplierProjectJob::handle()` mode-aware**

`online` → для каждой (subject × platform-set) группы проекта: `saveProjectMultiFlag` (full params: limit=daily_limit_target, regions=[subject]/[], tag) → upsert supplier_projects + pivot. `batch` → текущий каркас-путь (limit 0, regions []), ночной `SyncSupplierProjectsJob` дольёт. Эскалация в ярус-3 при сбое — сохранить (FailoverProjectChannel).

- [ ] **Step 5: Прогнать тест — зелёный**

Run: `php artisan test --filter=SyncSupplierProjectJobTest`
Expected: PASS. Полный вывод.

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

```bash
composer pint -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php; composer stan
git add -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
git commit -m "feat(supplier): online-mode full-param per-subject sync + grouping helpers" -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
```

---

## Task 7: закрепить крон 18:00 тестом (R2 — уже выполнен)

**Files:**

- Modify/verify: `app/tests/Feature/Schedule/SupplierScheduleTest.php`

- [ ] **Step 1: Убедиться, что тест требует 18:00**

`SupplierScheduleTest.php:15` уже: «SyncSupplierProjectsJob is scheduled at 18:00 MSK». Прогнать:
Run: `php artisan test --filter=SupplierScheduleTest`
Expected: PASS (крон уже `dailyAt('18:00')`, [console.php:52](../../../app/routes/console.php#L52)). Если теста на 18:00 нет — добавить assert по образцу существующего. R2 = no-op кроме закрепления.

- [ ] **Step 2: Коммит (если тест добавлялся)**

```bash
git add -- app/tests/Feature/Schedule/SupplierScheduleTest.php
git commit -m "test(supplier): lock SyncSupplierProjectsJob cron at 18:00 MSK (R2)" -- app/tests/Feature/Schedule/SupplierScheduleTest.php
```

---

## Task 8: Регрессия экспорта

**Files:** нет (проверка).

- [ ] **Step 1: Полный Supplier + Jobs + Schedule suite**

Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Jobs app/tests/Feature/Schedule app/tests/Unit/Services`
Expected: GREEN. passed/failed; failed — file:line.

- [ ] **Step 2: pint + larastan**

Run: `composer pint; composer stan`
Expected: 0 ошибок.

- [ ] **Step 3 (verification-before-completion):** перед «План 3 готов» — invoke `superpowers:verification-before-completion`; фактический вывод тестов.

---

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

- **Покрытие spec §4.1/§4.3/§4.5/§8:** режимы online/batch (T2 + T6 mode-aware + T7 крон); формула заказа (T3); R5 multi-flag + захват id (T1 smoke + T4); R6 (split убран — T3); R7 tag=субъект/regions=[subject]/[] (T4 toPayload + T5/T6 группировка); per-субъект + pivot (T5/T6).
- **Без плейсхолдеров:** концертный код для T2/T3/T4. T1 (smoke) и T5 (sync rewrite) несут явную зависимость от живого контракта портала — это не TODO, а гейт: T1 фиксирует контракт, T5 реализует поверх него. `src→platform` маппинг помечен «уточнить по выводу smoke» (реальная неизвестность портала, закрывается T1).
- **Согласованность типов:** `computeOrder(array<int>): int`; `saveProjectMultiFlag(DTO): array<string,int>`; DTO +`tag:string`/`platforms:array` с дефолтами (single-flag пути целы); `SupplierExportMode::current(): string`. `subject_code` int|null — совпадает с Планом 1.
- **Риск (R-SAVE):** если T1 smoke покажет, что 3 id из listProjects не вытащить надёжно — переключиться на fallback б (3 раздельных save, каждый отдаёт id) в T4; алгоритм T5 при этом не меняется (он зовёт `saveProjectMultiFlag`, чья внутренность инкапсулирует а/б).
