Files
portal/docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md
T
Дмитрий 34458df474 docs(plan): Plan 3 (экспорт + заказ) — переделка миграции проектов
8 TDD-задач: R-SAVE live smoke (гейт), SupplierExportMode тумблер, формула заказа
max(наиб,ceil(Σ/3)) + убран split, saveProjectMultiFlag R5/R6/R7 (захват 3 id),
SyncSupplierProjectsJob группировка источник×субъект + pivot, онлайн mode-aware
sync + grouping-хелперы, крон 18:00, регрессия. Третий из 4 планов. +cspell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:24 +03:00

31 KiB
Raw Blame History

Переделка миграции проектов — План 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 §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
// 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: Удалить временный скрипт
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

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

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 + Коммит
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

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:

  • Добавить статический метод:
    /**
     * Заказ у поставщика на (источник × субъект): 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). allocate() и unionInts() — переписываются/используются в Task 5 (sync rewrite); на этом шаге оставить allocate() как есть, но заменить внутри вызов distributeForPlatform(...) на computeOrder($eligibleProjects->pluck('daily_limit')->all()) для лимита (временная совместимость до Task 5; платформенный split исчезает — лимит одинаков для всех платформ). Конкретно строку $platformLimit = self::distributeForPlatform(...) (:59) заменить на:
        $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 + Коммит
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

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 путей):

        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):

  • '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

    /**
     * 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 + Коммит
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() + syncOnesyncGroup

Реализовать алгоритм выше. Сохранить: connection pgsql_supplier, time-budget cutoff, все catch-блоки (Auth/Transient/Client/Window/Tier), SupplierSyncLog. Заменить: FK-итерацию supplier_projects → итерацию по группам активных проектов; SupplierQuotaAllocator::allocatecomputeOrder + ручная сборка 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 + Коммит
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: Написать падающий тест

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); 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 + Коммит
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). Если теста на 18:00 нет — добавить assert по образцу существующего. R2 = no-op кроме закрепления.

  • Step 2: Коммит (если тест добавлялся)
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, чья внутренность инкапсулирует а/б).