From ce48d911d6612cdc8393a5ed10db5af89b7b8fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 22 Jul 2026 16:39:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=20=D1=842=20=D1=8D=D1=82=D0=B0=D0=BF=20B):=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B5=D0=B7=D0=B4=20=3D=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=81=D0=B5=204=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20(loaded),=20=D0=B1=D0=B5=D0=B7=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 2 Этап B, Task B1 (+ план этапа B). AdAudienceIntake::ingest больше не запускает прогрев: грузит фирму во все 4 канала (yandex/vk/mts/sms) строками firm_channels status='loaded' (firstOrCreate — не понижает уже греющийся канал). ch_* на заезде не пишутся (источник членства — строки firm_channels, resolveChannels удалён). Повторный заезд обновляет только снимок-поля и НЕ сбрасывает warmup_started_at/ready_at/stopped_at/stop_reason (заезд ≠ рестарт прогрева). Фирма без warming-строк ни в одну заливку не идёт ⇒ автостарта нет по построению. Запуск прогрева — кнопкой «Греть» на портале (этап C). Тесты intake мигрированы на новую семантику. Bump baseline (+6 postJson-ложняков). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Services/Sales/AdAudienceIntake.php | 74 ++++------- app/phpstan-baseline.neon | 2 +- .../Feature/Sales/AdAudienceIntakeTest.php | 118 +++++++++++++++--- ...2-progrev-razdelnye-kanaly-faza2-etap-B.md | 83 ++++++++++++ 4 files changed, 209 insertions(+), 68 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-22-progrev-razdelnye-kanaly-faza2-etap-B.md diff --git a/app/app/Services/Sales/AdAudienceIntake.php b/app/app/Services/Sales/AdAudienceIntake.php index a52b2f78..5ff54016 100644 --- a/app/app/Services/Sales/AdAudienceIntake.php +++ b/app/app/Services/Sales/AdAudienceIntake.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services\Sales; use App\Models\SalesAdAudienceFirm; +use App\Models\SalesAdAudienceFirmChannel; use App\Models\SalesAdAudiencePhone; use App\Models\SalesAdAudienceState; use Illuminate\Support\Facades\DB; @@ -12,8 +13,17 @@ use Illuminate\Support\Facades\DB; /** * Приём фирм в прогрев рекламой из «Поиска клиентов». * - * Фирма опознаётся по ИНН. Повторная отправка не плодит дубль — она начинает - * прогрев заново (начальник осознанно вернул фирму в рекламу). + * Фирма опознаётся по ИНН — повторная отправка не плодит дубль. + * + * Фаза 2 этап B: заезд БОЛЬШЕ НЕ запускает прогрев. Он только ГРУЗИТ фирму во + * все 4 канала (yandex/vk/mts/sms) в состоянии «загружена, выключена» + * (firm_channels.status='loaded'). Сам прогрев запускается отдельно кнопкой + * «Греть» на портале. Поэтому повторный заезд НЕ сбрасывает сроки фирмы + * (warmup_started_at/ready_at/stopped_at/stop_reason) и НЕ понижает уже + * греющийся канал обратно в loaded (firstOrCreate не трогает существующую строку). + * + * Источник членства фирмы в площадке — строки firm_channels, а не булевы ch_* + * на фирме: ch_* на заезде больше не пишем. * * Спека: docs/superpowers/specs/2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika-design.md */ @@ -38,6 +48,9 @@ class AdAudienceIntake ? SalesAdAudienceFirm::where('firm_inn', $raw['firm_inn'])->first() : null; + // Только снимок-поля. Сроки прогрева (warmup_started_at/ready_at/ + // stopped_at/stop_reason) и ch_* здесь НЕ трогаем — заезд грузит, + // а не перезапускает прогрев (Фаза 2 этап B). $attributes = [ 'firm_name' => $raw['firm_name'], 'firm_inn' => $raw['firm_inn'] ?? null, @@ -49,16 +62,10 @@ class AdAudienceIntake 'rating_label' => $raw['rating_label'] ?? null, 'payload' => $raw['payload'] ?? [], 'contacts' => $raw['contacts'] ?? [], - ...$this->resolveChannels($raw), ]; if ($existing) { - $existing->update($attributes + [ - 'warmup_started_at' => now(), - 'ready_at' => null, - 'stopped_at' => null, - 'stop_reason' => null, - ]); + $existing->update($attributes); $firm = $existing; $renewed++; } else { @@ -99,48 +106,19 @@ class AdAudienceIntake $row->save(); $phones++; } + + // Грузим фирму во все 4 канала как «загружена, выключена». firstOrCreate + // не трогает уже существующую строку: если канал ранее запустили в + // прогрев (status='warming'), повторный заезд его в loaded не понижает. + foreach (SalesAdAudienceFirmChannel::CHANNELS as $channel) { + $firm->firmChannels()->firstOrCreate( + ['channel' => $channel], + ['status' => 'loaded'], + ); + } } }); return ['added' => $added, 'renewed' => $renewed, 'phones' => $phones]; } - - /** - * Площадки прогрева поддерживают два формата контракта разом. - * - * Новый — три булевых поля ch_yandex/ch_vk/ch_mts (Python-служба ещё не - * переехала на них). Старый — строка channels ('yandex'|'vk'|'both'), - * которую раскладываем на те же три поля. Если не пришло ни то, ни - * другое — фирма отправлена в прогрев осознанно, разумное умолчание — - * основная площадка (ch_yandex=true). - * - * @param array $raw - * @return array{ch_yandex: bool, ch_vk: bool, ch_mts: bool} - */ - private function resolveChannels(array $raw): array - { - $hasNewFormat = array_key_exists('ch_yandex', $raw) - || array_key_exists('ch_vk', $raw) - || array_key_exists('ch_mts', $raw); - - if ($hasNewFormat) { - return [ - 'ch_yandex' => (bool) ($raw['ch_yandex'] ?? false), - 'ch_vk' => (bool) ($raw['ch_vk'] ?? false), - 'ch_mts' => (bool) ($raw['ch_mts'] ?? false), - ]; - } - - if (is_string($raw['channels'] ?? null)) { - $legacy = $raw['channels']; - - return [ - 'ch_yandex' => in_array($legacy, ['yandex', 'both'], true), - 'ch_vk' => in_array($legacy, ['vk', 'both'], true), - 'ch_mts' => false, - ]; - } - - return ['ch_yandex' => true, 'ch_vk' => false, 'ch_mts' => false]; - } } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 1ffc2183..fef8af8b 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3777,7 +3777,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound - count: 19 + count: 25 path: tests/Feature/Sales/AdAudienceIntakeTest.php - diff --git a/app/tests/Feature/Sales/AdAudienceIntakeTest.php b/app/tests/Feature/Sales/AdAudienceIntakeTest.php index 1885fe22..924f0018 100644 --- a/app/tests/Feature/Sales/AdAudienceIntakeTest.php +++ b/app/tests/Feature/Sales/AdAudienceIntakeTest.php @@ -2,22 +2,33 @@ declare(strict_types=1); +use App\Jobs\SyncAdAudienceJob; use App\Models\SalesAdAudienceFirm; use App\Models\SalesAdAudiencePhone; +use App\Models\SalesAdAudiencePlatform; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use Tests\Concerns\SharesSupplierPdo; /** * Приём фирм в прогрев рекламой сервис-каналом «Поиск → Портал» (контракт v2). * - * Контракт сменился: приходит фирма со снимком и списком номеров, а не голый - * номер (v1). Фирма опознаётся по ИНН — повторная отправка не плодит дубль, - * а начинает прогрев заново (начальник осознанно вернул фирму в рекламу). + * Фаза 2 этап B: заезд БОЛЬШЕ НЕ запускает прогрев. Он только ГРУЗИТ фирму во + * все 4 канала (yandex/vk/mts/sms) в состоянии «загружена, выключена» + * (firm_channels.status='loaded'). Сам прогрев запускается отдельно кнопкой + * «Греть» на портале (этап C). Поэтому: + * • заезд новой фирмы создаёт 4 строки firm_channels со status='loaded' + * (ни одной warming — фирма в заливки/сегменты пока не идёт); + * • повторный заезд НЕ рестартует прогрев (не сбрасывает warmup_started_at/ + * ready_at/stopped_at/stop_reason) и НЕ понижает уже греющийся канал в loaded; + * • поля channels/ch_* в payload на членство больше не влияют — источник + * членства это строки firm_channels, а не булевы ch_* на фирме. * * Токен канала — config('sales.integration_token') (middleware SalesIntegrationToken). * В тестовом окружении env пуст, поэтому задаём его здесь, как в SalesIntegrationApiTest. * - * SharesSupplierPdo обязателен: обе модели прибиты к соединению pgsql_supplier + * SharesSupplierPdo обязателен: модели прибиты к соединению pgsql_supplier * (отдельный PDO), и без шаринга DatabaseTransactions откатывал бы только * соединение по умолчанию — записи текли бы из теста в тест. */ @@ -57,7 +68,80 @@ it('заводит фирму на прогрев вместе с её номе ->and($firm->warmup_started_at)->not->toBeNull(); }); -it('повторная отправка не плодит дубль, а начинает прогрев заново', function () { +it('заезд грузит фирму во все 4 канала как loaded, прогрев не стартует', function () { + // Смысл этапа B: заезд = загрузка, не старт. Четыре строки firm_channels + // (yandex/vk/mts/sms) заводятся сразу, но ВСЕ в loaded — ни одной warming. + $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token']) + ->assertOk() + ->assertJson(['added' => 1]); + + $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); + + expect($firm->firmChannels()->count())->toBe(4) + ->and($firm->firmChannels()->orderBy('channel')->pluck('channel')->all()) + ->toBe(['mts', 'sms', 'vk', 'yandex']) + ->and($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4) + ->and($firm->firmChannels()->where('status', 'warming')->count())->toBe(0); +}); + +it('загруженная (не греется) фирма не попадает в состав Яндекс-заливки', function () { + // Фирма заехала (loaded), но прогрев не запускали — значит в ночной сегмент + // Яндекса она входить НЕ должна: греется только warming. + $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), ['X-Sales-Token' => 'test-token'])->assertOk(); + + SalesAdAudiencePlatform::forCode('yandex')->update(['enabled' => true, 'external_id' => 777]); + config(['services.yandex_audience.token' => 't']); + Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]); + + SyncAdAudienceJob::dispatchSync(); + + Http::assertSent(function (Request $r) { + $file = collect($r->data())->firstWhere('name', 'file'); + $csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body(); + + return ! str_contains($csv, '79990000001'); + }); +}); + +it('повторный заезд не понижает уже греющийся канал и не сбивает его срок', function () { + // Начальник нажал «Греть» по Яндексу (loaded → warming). Повторный заезд той + // же фирмы из поиска НЕ должен погасить прогрев обратно в loaded или сбить срок. + $headers = ['X-Sales-Token' => 'test-token']; + $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); + + $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); + $startedAt = now()->subDays(4); + $firm->firmChannels()->where('channel', 'yandex')->update([ + 'status' => 'warming', 'warming_started_at' => $startedAt, + ]); + + $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); + + $row = $firm->firmChannels()->where('channel', 'yandex')->firstOrFail(); + + expect($row->status)->toBe('warming') + ->and($row->warming_started_at->timestamp)->toBe($startedAt->timestamp); +}); + +it('повторный заезд не сбрасывает warmup_started_at фирмы на сегодня', function () { + // Заезд ≠ рестарт прогрева: срок фирмы, выставленный ранее, остаётся как был. + $headers = ['X-Sales-Token' => 'test-token']; + $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); + + SalesAdAudienceFirm::where('firm_inn', '7700000001') + ->update(['warmup_started_at' => now()->subDays(3)]); + + $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); + + $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); + + expect($firm->warmup_started_at->isToday())->toBeFalse() + ->and($firm->warmup_started_at->lessThan(now()->subDays(2)))->toBeTrue(); +}); + +it('повторная отправка не плодит дубль и не рестартует прогрев', function () { + // Фаза 2 этап B: повторный заезд НЕ трогает warmup_started_at/stopped_at — + // заезд грузит, а не перезапускает прогрев (раньше сбрасывал, теперь нет). $headers = ['X-Sales-Token' => 'test-token']; $this->postJson('/api/sales/integration/ad-audience', warmupPayload(), $headers)->assertOk(); @@ -72,8 +156,8 @@ it('повторная отправка не плодит дубль, а нач $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); expect(SalesAdAudienceFirm::count())->toBe(1) - ->and($firm->stopped_at)->toBeNull() - ->and($firm->warmup_started_at->isToday())->toBeTrue(); + ->and($firm->stopped_at)->not->toBeNull() + ->and($firm->warmup_started_at->isToday())->toBeFalse(); }); it('добавляет новый номер к уже заведённой фирме', function () { @@ -105,7 +189,9 @@ it('фирму без единого номера в прогрев не бер )->assertStatus(422); }); -it('старый формат channels=both включает обе галочки ch_yandex и ch_vk', function () { +it('старый формат channels=both больше не задаёт членство — грузятся все 4 канала loaded', function () { + // Фаза 2: channels/ch_* в payload на членство не влияют. Заезд грузит ВСЕ 4 + // канала в loaded вне зависимости от подсказки channels. $this->postJson( '/api/sales/integration/ad-audience', warmupPayload(['channels' => 'both']), @@ -114,12 +200,10 @@ it('старый формат channels=both включает обе галочк $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); - expect($firm->ch_yandex)->toBeTrue() - ->and($firm->ch_vk)->toBeTrue() - ->and($firm->ch_mts)->toBeFalse(); + expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4); }); -it('без указания площадки греет в Яндексе (ch_yandex=true по умолчанию)', function () { +it('без указания площадки заезд всё равно грузит все 4 канала loaded', function () { $payload = warmupPayload(); unset($payload['firms'][0]['channels']); @@ -128,12 +212,10 @@ it('без указания площадки греет в Яндексе (ch_ya $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); - expect($firm->ch_yandex)->toBeTrue() - ->and($firm->ch_vk)->toBeFalse() - ->and($firm->ch_mts)->toBeFalse(); + expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4); }); -it('новый формат ch_yandex/ch_vk/ch_mts проставляет площадки явно', function () { +it('ch_yandex/ch_vk/ch_mts в payload больше не влияют на членство — все 4 канала loaded', function () { $payload = warmupPayload(); unset($payload['firms'][0]['channels']); $payload['firms'][0]['ch_yandex'] = false; @@ -145,9 +227,7 @@ it('новый формат ch_yandex/ch_vk/ch_mts проставляет пло $firm = SalesAdAudienceFirm::where('firm_inn', '7700000001')->firstOrFail(); - expect($firm->ch_yandex)->toBeFalse() - ->and($firm->ch_vk)->toBeTrue() - ->and($firm->ch_mts)->toBeTrue(); + expect($firm->firmChannels()->where('status', 'loaded')->count())->toBe(4); }); it('отвергает выдуманную площадку', function () { diff --git a/docs/superpowers/plans/2026-07-22-progrev-razdelnye-kanaly-faza2-etap-B.md b/docs/superpowers/plans/2026-07-22-progrev-razdelnye-kanaly-faza2-etap-B.md new file mode 100644 index 00000000..df6c3a1d --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-progrev-razdelnye-kanaly-faza2-etap-B.md @@ -0,0 +1,83 @@ +# Фаза 2, Этап B — Заезд = только загрузка (finder + intake) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: проектная обёртка `subagent-driven-development` (git-safety per Pravila §15.1), задача-за-задачей. Шаги — чекбоксы. + +**Goal:** Кнопка «В прогрев» в finder (обе страницы) грузит отмеченные фирмы **сразу во все 4 канала** в состоянии **«загружена, выключена»** (`firm_channels.status='loaded'`) — фирма появляется в списке каждого канала, но **не греется**. Автостарт прогрева на заезде **убирается**. Запуск — только кнопкой «Греть» на портале (этап C). + +**Спека:** §3.7, §1 (решения 1). Предшественник — Этап A (модель firm_channels + движок/заливки/тоггл на строках, уже в ветке). + +**Ветка:** `feat/progrev-razdelnye-kanaly-faza2` (продолжаем, поверх этапа A). + +**Затрагивает:** портал (`AdAudienceIntake`, контракт `ingestAdAudience`) + **finder** (Python, `моя/sales-finder`, деплой scp — вне git-конвейера портала; свой pytest). Выкат finder+портал — согласованно (этап-вопрос выката, не плана). + +--- + +## Решения по дизайну (инженерные, приняты исполнителем — не переписывать) + +1. **`firm_channels` на заезде:** для КАЖДОГО из 4 каналов (`yandex/vk/mts/sms`) — upsert строки `status='loaded'` по `(firm_id, channel)`. Если строка уже `warming` — **НЕ понижать** в loaded (повторный заезд не гасит греющееся). `mode/flat_days/warming_started_at` для loaded — NULL. `warmed_times` не трогаем (остаётся как было / 0 для новой). +2. **`firm.warmup_started_at`:** остаётся (колонка NOT NULL, нужна для `days_in_ads` показа). Новой фирме = `now()`. При **повторном** заезде — **НЕ сбрасывать** (заезд ≠ рестарт прогрева; часы прогрева канала теперь на `firm_channels.warming_started_at`, их заводит «Греть»). Поля `ready_at/stopped_at/stop_reason` при повторном заезде тоже **не сбрасывать** (раньше сбрасывались, т.к. заезд = рестарт; теперь заезд ничего не запускает). +3. **Номера-снимок:** создаются/обновляются как сейчас (upsert по phone, снимок фирмы). Состояние номера роли не играет для заливок — заливки берут фирмы по `firm_channels(warming)`, а у загруженной их нет ⇒ **автостарта нет по построению**. `state` номера НЕ форсим в active на заезде (пусть остаётся как есть/пересчёт поправит); достаточно того, что фирма без warming-строк ни в один сегмент не уйдёт. +4. **`ch_*` на заезде:** intake **перестаёт** писать `ch_yandex/ch_vk/ch_mts` (источник — строки firm_channels). Колонки не удаляем (откат). `resolveChannels()` больше не участвует в заезде (оставить метод — вдруг нужен откату, но `ingest` его не зовёт; либо удалить вызов, метод пометить deprecated). Показ-бейджи по ch_* — переедут на строки в этапе C. +5. **Контракт `ingestAdAudience`:** добавляем необязательное поле-намерение. Т.к. finder и портал катятся согласованно (переводим разом, §3.7) — новый заезд = «загрузить во все 4, выключено». Поле `start` (boolean, default для нового контракта — false=load). Старые поля `channels`/`ch_*` — оставить в валидации `nullable` (обратная совместимость приёма от Python-службы скрапинга, которая переезжает не мгновенно), но `ingest` их для членства **не использует** (грузит все 4 loaded). Т.е. семантика заезда: всегда «все 4 loaded», вне зависимости от channels/ch_* в payload. +6. **Finder:** обе кнопки (`/view/{rid}/warmup`, `/bulk/warmup`) — выбор каналов **убрать** (всегда все 4), автозапуск убрать. `portal.send_ad_audience` шлёт контракт без channels-выбора (или с `start:false`). Тексты кнопок/подтверждений — «Загрузить в прогрев» (не «Прогреть»), т.к. запуск теперь на портале. + +--- + +## Карта файлов + +**Портал (изменить):** +- `app/app/Services/Sales/AdAudienceIntake.php` — `ingest`: строки firm_channels loaded для 4 каналов, без автостарта, повторный заезд не гасит warming/не сбрасывает clock. +- `app/app/Http/Controllers/Api/Sales/SalesIntegrationController.php` — контракт (`start` nullable boolean; channels/ch_* остаются nullable для совместимости, но заезд грузит все 4). +- Тесты: `app/tests/Feature/Sales/*` — intake-тесты (AdAudienceIntake / AdAudienceIngest / SalesIntegration ad-audience). Найти прогоном. + +**Finder (изменить, `моя/sales-finder`):** +- `web/app.py` (`/view/{rid}/warmup`), `web/bulk.py` (`/bulk/warmup`), `web/ui.py` (кнопки/formaction каналов — убрать выбор), `salesfinder/portal.py` (`send_ad_audience`), `salesfinder/bulkops.py` (`split_for_warmup` если завязан на channels). +- Тесты finder: `tests/test_adaudience.py`, `tests/test_ad_payload_contract.py`, `tests/test_bulk.py`, `tests/test_bulkops.py`. Прогон `cd моя/sales-finder && python -m pytest`. + +--- + +## Task B1: Intake — заезд грузит 4 канала в loaded, без автостарта (портал) + +**Files:** Modify `AdAudienceIntake.php` · Test: intake-тест (найти: `grep -rl "AdAudienceIntake\|integration/ad-audience" app/tests`) + +- [ ] **Step 1: Прочитать** текущий `ingest` + существующие intake-тесты (что ассертят про warmup_started_at/phones state/ch_*). +- [ ] **Step 2: Падающие тесты:** + - Заезд новой фирмы (payload с phones) → создаёт 4 строки `firm_channels` со `status='loaded'` (yandex/vk/mts/sms), фирма создана, номера-снимок есть, но **ни одной `warming`-строки** ⇒ фирма НЕ попадёт в Яндекс-sync (проверить `SyncAdAudienceJob::dispatchSync()` → номера нет в сегменте; enabled=true+token). + - Повторный заезд фирмы, у которой уже есть `firm_channels(yandex, warming)` → **строка остаётся warming** (не понижена в loaded), `warming_started_at` не обнулён. + - Повторный заезд НЕ сбрасывает `firm.warmup_started_at` (был subDays(3) → остался subDays(3)). +- [ ] **Step 3: FAIL.** +- [ ] **Step 4: Реализация.** `ingest`: после upsert фирмы+номеров — для каждого из `SalesAdAudienceFirmChannel::CHANNELS` (4 канала) `firm->firmChannels()->firstOrCreate(['channel'=>$ch], ['status'=>'loaded'])` (firstOrCreate НЕ трогает существующую warming-строку). Убрать `resolveChannels()` из атрибутов фирмы (ch_* не пишем на заезде). Новой фирме `warmup_started_at=now()`; повторной — **не** переустанавливать warmup_started_at/ready_at/stopped_at/stop_reason. Номера — как есть (снимок). +- [ ] **Step 5: GREEN + Larastan + миграция существующих intake-тестов** (те, что ждали ch_*/автостарт — переписать под loaded-семантику; смысл сохранить). Прогон `--filter "AdAudience|Sales|Integration"`. +- [ ] **Step 6: коммит** (эскейп у владельца). + +## Task B2: Контракт `ingestAdAudience` — поле `start` + all-4-loaded семантика (портал) + +**Files:** Modify `SalesIntegrationController.php` · Test: integration ad-audience тест + +- [ ] **Step 1: Падающий тест:** POST `/api/sales/integration/ad-audience` с `X-Sales-Token` и payload без channels → 200, создаёт 4 loaded-строки. Со `start:false` — то же. Старый payload с `channels:'yandex'` — по-прежнему 200 (совместимость приёма), но грузит все 4 loaded (channels для членства игнорируется). +- [ ] **Step 2: FAIL.** +- [ ] **Step 3: Реализация.** Добавить `firms.*.start => ['nullable','boolean']` в валидацию. channels/ch_* оставить nullable (совместимость), но не влияют на членство. (Логика загрузки — в intake B1.) +- [ ] **Step 4: GREEN + Larastan + коммит.** + +## Task B3: Finder — обе кнопки грузят во все 4, без выбора канала и автозапуска (Python) + +**Files:** Modify `web/app.py`, `web/bulk.py`, `web/ui.py`, `salesfinder/portal.py`, `salesfinder/bulkops.py` · Tests: `tests/test_adaudience.py`, `test_ad_payload_contract.py`, `test_bulk.py`, `test_bulkops.py` + +- [ ] **Step 1: Прочитать** текущие эндпоинты + `send_ad_audience` + контракт-тест `test_ad_payload_contract.py` (что ждёт портал). +- [ ] **Step 2: Падающие тесты (pytest):** `/view/{rid}/warmup` и `/bulk/warmup` формируют payload без выбора каналов (все 4 / `start:false`), не шлют channels-строку/список как «включить». Контракт-тест — payload соответствует новому приёму портала. +- [ ] **Step 3: FAIL.** +- [ ] **Step 4: Реализация.** Убрать параметр `channels` из выбора кнопок (или игнорировать), тексты «Загрузить в прогрев». `send_ad_audience` — контракт с `start:false` (или просто без channels-членства). `split_for_warmup` — если завязан на chans, упростить (все 4 / без разбивки по каналам). +- [ ] **Step 5: GREEN (pytest) + коммит.** ⚠️ finder — вне git-конвейера портала, но код лежит в `моя/sales-finder` (gitignored). Коммит finder-изменений — **отдельно обсудить с владельцем** (finder не в git портала; возможно, только на диске + scp при выкате). НЕ добавлять `моя/sales-finder` в git портала без явного разрешения. + +## Task B4: Регресс этапа B + отчёт + +- [ ] Портал: `--filter "AdAudience|Vk|Sales|Sms|Integration"` + `composer stan`. Finder: `python -m pytest`. +- [ ] Self-review: заезд не стартует прогрев (нет warming-строк), повторный заезд не гасит warming; поведение «загрузить» соответствует §3.7. +- [ ] Отчёт владельцу человеческим языком. + +--- + +## Открытые вопросы к владельцу (surface при исполнении, не гадать) + +- **Коммит finder-кода:** finder gitignored в репо портала, деплой scp. Коммитить ли его изменения в git портала (или держать только на диске + отдельный бэкап)? — спросить перед Task B3 коммитом. +- **Совместимость Python-службы скрапинга:** если какой-то внешний вызов ещё шлёт старый контракт с автозапуском — теперь он тоже станет load-only. Подтвердить, что это ок (§3.7 говорит «переводим разом»).