feat(прогрев ф2 этап B): заезд = только загрузка во все 4 канала (loaded), без автостарта

Фаза 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-22 16:39:45 +03:00
parent e15a4d8a69
commit ce48d911d6
4 changed files with 209 additions and 68 deletions
+26 -48
View File
@@ -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<string, mixed> $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];
}
}
+1 -1
View File
@@ -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
-
@@ -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 () {
@@ -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 говорит «переводим разом»).