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>
31 KiB
Переделка миграции проектов — План 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):
- POST
/admin/visit/rt-project-saveс payload, гдеsrcrt=srcbl=srcmt=true, уникальныйname(валидный домен, lowercase — recon §находка 1),tag="Москва",type=hosts,regions=[],limit=9. - Запомнить
idиз ответа (последний из 3 — recon §находка 2). listProjects()→ найти ВСЕ строки сname == <наш>Иtag == "Москва"→ собрать ихid.- Вывести: сколько проектов создалось (ожидаем 3), их id + платформы (поле
src). - 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() (новый):
- Загрузить активные Лидерра-projects (
is_active=true,archived_at IS NULL) черезpgsql_supplier. - Развернуть каждый в группы
(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).
- subjects =
- Для каждой группы:
- 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).
- 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 + Коммит
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_codeint|null — совпадает с Планом 1. - Риск (R-SAVE): если T1 smoke покажет, что 3 id из listProjects не вытащить надёжно — переключиться на fallback б (3 раздельных save, каждый отдаёт id) в T4; алгоритм T5 при этом не меняется (он зовёт
saveProjectMultiFlag, чья внутренность инкапсулирует а/б).