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:
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 говорит «переводим разом»).
|
||||
Reference in New Issue
Block a user