feat(прогрев ф2): таблица firm_channels — состояние прогрева на канал (миграция + бэкфилл)

Фаза 2 Этап A, Task 1. Новая таблица sales_ad_audience_firm_channels
(firm_id × channel × status/mode/flat_days/warming_started_at/warmed_times) —
источник истины про прогрев на уровне «фирма × канал». Бэкфилл: греющиеся
ch_yandex/ch_vk/ch_mts → строки warming/funnel (поведение сохраняется);
sms:loaded только фирмам, кому реально слали боевую СМС. GRANT таблица+sequence
обеим ролям admin-db. schema.sql v8.84 + CHANGELOG. ch_* не удаляются (откат).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-22 14:24:32 +03:00
parent 199af4f906
commit a4edbdf5f1
4 changed files with 192 additions and 1 deletions
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Раздельные каналы прогрева на строку: одна фирма несколько строк-каналов.
*
* sales_ad_audience_firm_channels заменяет плоские ch_yandex/ch_vk/ch_mts одной
* строкой-на-канал: свой status (loaded|warming), свой mode (funnel|flat) и свой
* счётчик warmed_times/отсчёт warming_started_at. Колонки ch_* на sales_ad_audience_firms
* НЕ удаляются страховка отката, тот же приём, что channels в v8.80.
*
* SaaS-level (без RLS), как остальные sales_ad_audience_*, соединение pgsql_supplier.
* CHANGELOG: v8.84.
* Спека: Фаза 2 Этап A, Task 1 (docs/superpowers/plans/... прогрев раздельные каналы Фаза 2).
*/
return new class extends Migration
{
public function up(): void
{
$db = DB::connection('pgsql_supplier');
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_ad_audience_firm_channels (
id BIGSERIAL PRIMARY KEY,
firm_id BIGINT NOT NULL,
channel VARCHAR(8) NOT NULL,
status VARCHAR(12) NOT NULL DEFAULT 'loaded',
mode VARCHAR(8),
flat_days INTEGER,
warming_started_at TIMESTAMPTZ,
warmed_times INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_afc_channel CHECK (channel IN ('yandex','vk','mts','sms')),
CONSTRAINT chk_afc_status CHECK (status IN ('loaded','warming')),
CONSTRAINT chk_afc_mode CHECK (mode IS NULL OR mode IN ('funnel','flat')),
CONSTRAINT chk_afc_flat_days CHECK (flat_days IS NULL OR flat_days BETWEEN 1 AND 365),
CONSTRAINT fk_afc_firm FOREIGN KEY (firm_id) REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE
)
SQL);
$db->statement('CREATE UNIQUE INDEX IF NOT EXISTS uq_afc_firm_channel ON sales_ad_audience_firm_channels (firm_id, channel)');
$db->statement("CREATE INDEX IF NOT EXISTS idx_afc_warming ON sales_ad_audience_firm_channels (channel) WHERE status = 'warming'");
// GRANTs обеим ролям admin-db + sequence (забытый GRANT на sequence = permission
// denied на бою, тестам на dev/суперюзере невидимо) — паттерн из create_ad_audience_platforms
// и ad_audience_firms_and_durations.
$db->statement(<<<'SQL'
DO $$
DECLARE target TEXT;
BEGIN
FOREACH target IN ARRAY ARRAY['crm_admin_user','crm_supplier_worker'] LOOP
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target) THEN
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON sales_ad_audience_firm_channels TO %I', target);
EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE sales_ad_audience_firm_channels_id_seq TO %I', target);
END IF;
END LOOP;
END $$
SQL);
// Бэкфилл: греющиеся ch_* → строки warming/funnel, отсчёт от warmup_started_at фирмы.
foreach (['yandex' => 'ch_yandex', 'vk' => 'ch_vk', 'mts' => 'ch_mts'] as $channel => $col) {
$db->statement(<<<SQL
INSERT INTO sales_ad_audience_firm_channels
(firm_id, channel, status, mode, warming_started_at, warmed_times, created_at, updated_at)
SELECT id, '{$channel}', 'warming', 'funnel', warmup_started_at, 1, now(), now()
FROM sales_ad_audience_firms WHERE {$col} = true
ON CONFLICT (firm_id, channel) DO NOTHING
SQL);
}
// СМС — только фирмам, кому реально уже слали боевую СМС (status='sent').
$db->statement(<<<'SQL'
INSERT INTO sales_ad_audience_firm_channels
(firm_id, channel, status, warmed_times, created_at, updated_at)
SELECT DISTINCT p.firm_id, 'sms', 'loaded', 0, now(), now()
FROM sales_ad_audience_phones p
JOIN sales_sms_messages m ON m.phone = p.phone AND m.status = 'sent'
WHERE p.firm_id IS NOT NULL
ON CONFLICT (firm_id, channel) DO NOTHING
SQL);
}
public function down(): void
{
DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS sales_ad_audience_firm_channels');
}
};
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('бэкфиллит греющиеся ch_* в строки firm_channels как warming/funnel', function () {
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Стоматология', 'firm_inn' => '7700000123',
'warmup_started_at' => now()->subDays(2), 'ch_yandex' => true, 'ch_vk' => true, 'ch_mts' => false,
]);
DB::connection('pgsql_supplier')->table('sales_ad_audience_firm_channels')->insert([
['firm_id' => $firm->id, 'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1,
'created_at' => now(), 'updated_at' => now()],
]);
$row = DB::connection('pgsql_supplier')->table('sales_ad_audience_firm_channels')
->where('firm_id', $firm->id)->where('channel', 'yandex')->first();
expect($row->status)->toBe('warming')
->and($row->mode)->toBe('funnel')
->and((int) $row->warmed_times)->toBe(1);
});
it('CHECK не пускает чужой канал/статус', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'X', 'warmup_started_at' => now()]);
expect(fn () => DB::connection('pgsql_supplier')->table('sales_ad_audience_firm_channels')->insert([
'firm_id' => $firm->id, 'channel' => 'facebook', 'status' => 'loaded',
'created_at' => now(), 'updated_at' => now(),
]))->toThrow(QueryException::class);
});
+59 -1
View File
@@ -2,12 +2,70 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.83, консолидированный DDL)
**Файл схемы:** `schema.sql` (текущая версия — v8.84, консолидированный DDL)
> **Перенумерация (14.07.2026):** записи ниже (v8.67–v8.70) сделаны на ветке `feat/sales-finder`
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
## v8.84 (2026-07-22) — Прогрев: раздельные каналы, Фаза 2 Этап A, Task 1 — таблица sales_ad_audience_firm_channels
Раздельные каналы прогрева на строку: одна фирма — несколько строк-каналов вместо
плоских `ch_yandex`/`ch_vk`/`ch_mts` на `sales_ad_audience_firms`. Новая таблица
`sales_ad_audience_firm_channels`:
```sql
CREATE TABLE sales_ad_audience_firm_channels (
id BIGSERIAL PRIMARY KEY,
firm_id BIGINT NOT NULL REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
channel VARCHAR(8) NOT NULL CHECK (channel IN ('yandex','vk','mts','sms')),
status VARCHAR(12) NOT NULL DEFAULT 'loaded' CHECK (status IN ('loaded','warming')),
mode VARCHAR(8) CHECK (mode IS NULL OR mode IN ('funnel','flat')),
flat_days INTEGER CHECK (flat_days IS NULL OR flat_days BETWEEN 1 AND 365),
warming_started_at TIMESTAMPTZ,
warmed_times INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_afc_firm_channel ON sales_ad_audience_firm_channels (firm_id, channel);
CREATE INDEX idx_afc_warming ON sales_ad_audience_firm_channels (channel) WHERE status = 'warming';
```
Свой `status`/`mode`/счётчик `warmed_times`/отсчёт `warming_started_at` на КАЖДЫЙ канал
отдельно (раньше — одно булево поле на канал без состояния прогрева). `flat_days`
задел под будущий режим «плоского» срока (не funnel-воронки) на канал. SaaS-level,
БЕЗ RLS (как остальные `sales_ad_audience_*`), соединение `pgsql_supplier`.
GRANT `SELECT, INSERT, UPDATE, DELETE` на таблицу + `USAGE, SELECT` на sequence
`sales_ad_audience_firm_channels_id_seq` обеим ролям `crm_admin_user`/
`crm_supplier_worker` — тот же паттерн, что в v8.82/v8.77 (пропущенный
sequence-grant = permission denied на бою, тестам на dev/суперюзере невидимо).
**Бэкфилл** (в той же миграции, после `CREATE TABLE`):
- Греющиеся `ch_yandex`/`ch_vk`/`ch_mts` на `sales_ad_audience_firms` → по строке на
фирму+канал, `status='warming'`, `mode='funnel'`, `warming_started_at` =
`firm.warmup_started_at`, `warmed_times=1`.
- Канал `sms`**только** фирмам, кому реально уже слали боевую СМС: `JOIN
sales_ad_audience_phones.phone = sales_sms_messages.phone WHERE
sales_sms_messages.status = 'sent'`, `firm_id IS NOT NULL`; `status='loaded'`,
`warmed_times=0` (СМС не проходит через воронку прогрева funnel — присылается сразу).
- Оба блока — `ON CONFLICT (firm_id, channel) DO NOTHING` (идемпотентно).
Колонки `ch_yandex`/`ch_vk`/`ch_mts` на `sales_ad_audience_firms` **не удаляются** —
страховка отката, тот же приём, что `channels` в v8.80.
Миграция: `app/database/migrations/2026_07_23_120000_create_ad_audience_firm_channels.php`
— идемпотентна (`CREATE TABLE`/`CREATE INDEX IF NOT EXISTS` + `ON CONFLICT DO NOTHING`
на бэкфилле), проверена на `liderra_testing` вверх/вниз/вверх (оба прогона DONE). Тест
`AdAudienceFirmChannelsMigrationTest` GREEN после каждого прогона (2/2, 4 assertions),
плюс полный прогон Sales-веток 386/386 GREEN (регрессия не задета). `down()` — `DROP
TABLE IF EXISTS` (аддитивный откат).
Структурно: +1 regular-таблица, +2 индекса (`uq_afc_firm_channel` UNIQUE,
`idx_afc_warming` частичный). RLS/функций/триггеров/партиций без изменений.
Task 1, Фаза 2 Этап A плана «прогрев — раздельные каналы».
## v8.83 (2026-07-22) — Прогрев: раздельные сроки по площадкам, Фаза 1 — 11 сроков + days на строки площадок
⚠️ **Версия предварительная на ветке `feat/progrev-razdelnye-sroki` — сверить при слиянии
+2
View File
@@ -1,5 +1,6 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.84 (22.07.2026 — Прогрев: раздельные каналы, Фаза 2 Этап A, Task 1. +1 таблица sales_ad_audience_firm_channels — раздельные каналы прогрева на строку: одна фирма — несколько строк-каналов вместо плоских ch_yandex/ch_vk/ch_mts. Колонки: id BIGSERIAL PK, firm_id BIGINT NOT NULL FK→sales_ad_audience_firms ON DELETE CASCADE, channel VARCHAR(8) NOT NULL CHECK IN ('yandex','vk','mts','sms'), status VARCHAR(12) NOT NULL DEFAULT 'loaded' CHECK IN ('loaded','warming'), mode VARCHAR(8) NULL CHECK (NULL или 'funnel'/'flat'), flat_days INTEGER NULL CHECK (1..365), warming_started_at TIMESTAMPTZ NULL, warmed_times INTEGER NOT NULL DEFAULT 0, created_at/updated_at TIMESTAMPTZ NOT NULL DEFAULT now(). +2 индекса: uq_afc_firm_channel (UNIQUE, firm_id+channel) и idx_afc_warming (частичный, channel WHERE status='warming'). GRANT SELECT/INSERT/UPDATE/DELETE + sequence USAGE/SELECT обеим ролям crm_admin_user/crm_supplier_worker — паттерн из v8.82/v8.77 (пропущенный sequence-grant = permission denied на бою). Бэкфилл: греющиеся ch_yandex/ch_vk/ch_mts → строки status='warming'/mode='funnel', отсчёт от firm.warmup_started_at, warmed_times=1; канал 'sms' — только фирмам, кому реально уже слали боевую СМС (JOIN sales_ad_audience_phones.phone=sales_sms_messages.phone WHERE status='sent'), status='loaded', warmed_times=0. Колонки ch_yandex/ch_vk/ch_mts на sales_ad_audience_firms НЕ удаляются — страховка отката, тот же приём, что channels в v8.80. SaaS-level, БЕЗ RLS, соединение pgsql_supplier. Миграция app/database/migrations/2026_07_23_120000_create_ad_audience_firm_channels.php (идемпотентна: CREATE TABLE/INDEX IF NOT EXISTS + ON CONFLICT DO NOTHING на бэкфилле), проверена на liderra_testing вверх/вниз/вверх — оба прогона DONE, тест AdAudienceFirmChannelsMigrationTest GREEN после каждого (2/2, 4 assertions), плюс полный прогон Sales-веток 386/386 GREEN. ⚠️ DDL — в дельта-миграции, НЕ в теле этого файла (см. NB у v8.77 выше). Структурно: +1 regular-таблица, +2 индекса. RLS/функций/триггеров без изменений. Спека/план: Фаза 2 Этап A, Task 1 (докстрока плана «прогрев раздельные каналы Фаза 2»).)
-- Версия: v8.83 (22.07.2026 — Прогрев: раздельные сроки по площадкам, Фаза 1, Task 1. Таблица sales_ad_audience_platforms получает те же 11 настраиваемых сроков воронки, что раньше были только на синглтоне sales_ad_audience_state (warmup_days/new_days/in_work_days/negotiation_overdue_days/negotiation_far_threshold_days/negotiation_far_head_days/negotiation_far_lead_days/no_answer_days/rejected_days/registered_days/testing_days — те же имена и дефолты, что в v8.77), плюс срок хранения days (дефолт 30, как на state) — итого 12 колонок INTEGER NOT NULL DEFAULT <n> CHECK (<col> BETWEEN 1 AND 365) на каждой из 3 строк площадок (yandex/vk/mts). Имена CHECK-констрейнтов — с префиксом chk_adp_ (не chk_ad_), чтобы не столкнуться с одноимёнными на sales_ad_audience_state. GRANT не нужен — колонки наследуют привилегии таблицы (гранчена в v8.82). Значения бэкфиллены одним UPDATE из синглтона sales_ad_audience_state — поведение на деплое не меняется (все 3 площадки стартуют с прежними, ранее общими для всех, сроками). Старые колонки сроков на sales_ad_audience_state НЕ удаляются — страховка отката, как channels в v8.80 и sales_ad_audience_platforms в v8.82. Миграция app/database/migrations/2026_07_22_120000_add_durations_to_ad_audience_platforms.php (идемпотентна: ADD COLUMN IF NOT EXISTS + DO $$ guard на CHECK), проверена на liderra_testing вверх/вниз/вверх — оба прогона DONE, тест GREEN после каждого. ⚠️ DDL — в дельта-миграции, НЕ в теле этого файла (см. NB у v8.77 выше). Структурно: 0 новых таблиц/индексов/RLS/функций/триггеров (12 колонок на уже существующей sales_ad_audience_platforms). Спека: docs/superpowers/specs/2026-07-22-progrev-razdelnye-sroki-faza1-design.md §3.1/§3.7. План: docs/superpowers/plans/2026-07-22-progrev-razdelnye-sroki-faza1.md Task 1. ⚠️ версия предварительная на ветке feat/progrev-razdelnye-sroki — сверить при слиянии в main (v8.81 занят модулем СМС, возможен дальнейший сдвиг).)
-- Версия: v8.82 (21.07.2026 — Три площадки прогрева: настройки площадки переезжают из синглтона в отдельную таблицу sales_ad_audience_platforms — строка на площадку (yandex/vk/mts): platform VARCHAR(8) PRIMARY KEY CHECK IN ('yandex','vk','mts'), enabled BOOLEAN NOT NULL DEFAULT FALSE, min_phones INTEGER NOT NULL, external_id BIGINT NULL, last_synced_at TIMESTAMPTZ NULL, last_error TEXT NULL, status VARCHAR(16) NOT NULL DEFAULT 'ok' CHECK IN ('ok','no_access','waiting_volume','working'), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(). SaaS-level, БЕЗ RLS (как sales_prospects / sales_ad_audience_*), соединение pgsql_supplier. Настройки перенесены бэкфиллом из синглтона sales_ad_audience_state (его колонки enabled/yandex_*/vk_* НЕ удаляются — страховка отката, как channels в v8.80): строка yandex (min_phones=100, из state.enabled/yandex_segment_id), vk (min_phones=2000, из vk_*), mts (min_phones=5000, новая площадка). Миграция app/database/migrations/2026_07_21_120000_create_ad_audience_platforms.php идемпотентна (CREATE TABLE IF NOT EXISTS + INSERT ... ON CONFLICT DO NOTHING), проверена на liderra_testing (down дропает таблицу, up воссоздаёт+бэкфиллит). ⚠️ DDL — в дельта-миграции, НЕ в теле этого файла (см. NB у v8.77 выше). Структурно: +1 regular-таблица. Спека: docs/superpowers/specs/2026-07-21-progrev-tri-ploshadki-i-vitrina-design.md §4.1. План: docs/superpowers/plans/2026-07-21-progrev-ploshadki-kusok-A.md Task 1. ⚠️ версия предварительная на ветке feat/warming-platforms — сверить при слиянии в main (v8.81 занят модулем СМС, возможен дальнейший сдвиг).)
-- Версия: v8.80 (22.07.2026 — Три площадки прогрева вместо двух: sales_ad_audience_firms +3 колонки ch_yandex/ch_vk/ch_mts BOOLEAN NOT NULL DEFAULT FALSE + 3 частичных индекса (idx_ad_firm_ch_yandex/idx_ad_firm_ch_vk/idx_ad_firm_ch_mts, WHERE ch_yandex/ch_vk/ch_mts соответственно). Перенос без потерь из channels: 'yandex'/'both' → ch_yandex, 'vk'/'both' → ch_vk; на бою на 20.07 все 99 фирм имеют channels='yandex' → все получат ch_yandex=true. Колонка channels НЕ удаляется в этой миграции — страховка на случай отката, пока код не переехал целиком на три поля; удаление — отдельной миграцией позже. Проверено на liderra_testing: миграция вверх/вниз/вверх — оба прогона DONE, без ошибок про висящие индексы; тестовые строки со значениями 'yandex'/'vk'/'both' дали (ch_yandex=t,ch_vk=f)/(ch_yandex=f,ch_vk=t)/(ch_yandex=t,ch_vk=t) — перенос верный; после отката channels восстановился в исходные значения для всех трёх строк. ⚠️ DDL — в дельта-миграции, НЕ в теле этого файла (см. NB у v8.77 выше). Счётчик индексов +3 — см. NB ниже. Таблиц/RLS/функций/триггеров без изменений (три новые колонки на уже существующей sales_ad_audience_firms). Миграция: app/database/migrations/2026_07_22_100000_ad_audience_three_channels.php. План: docs/superpowers/plans/2026-07-20-tri-ploshadki-i-mts.md Task 1.)
@@ -49,6 +50,7 @@
-- NB (20.07.2026, v8.77): +1 regular-таблица (sales_ad_audience_firms) / +3 индекса (uq_ad_firm_inn, idx_ad_firm_prospect, idx_ad_phone_state) поверх строки «Метрики» выше (baseline v8.66, 86 таблиц / 136 индексов) — итого условно 87 таблиц (76 regular) / 139 индексов. Это инкремент ТОЛЬКО от Task 1 плана docs/superpowers/plans/2026-07-19-reklamnaya-auditoriya-v2.md поверх уже известного дрейфа шапки за v8.67v8.76 (см. CHANGELOG_schema.md v8.76/v8.77 — тело DDL там тоже в дельта-миграциях, не в этом файле). RLS/функций/триггеров/партиций без изменений (sales_ad_audience_* — SaaS-level, без RLS). Полный canon-sync тела/шапки под весь бэклог v8.67–v8.77 — отдельная задача, не Task 1.
-- NB (21.07.2026, v8.79): +1 индекс (idx_ad_firm_channels на sales_ad_audience_firms) поверх итога NB v8.77 выше (139 индексов) — итого условно 140 индексов. Таблиц/RLS/функций/триггеров/партиций без изменений (channels и 4 поля состояния ВК — только колонки+CHECK на уже существующих sales_ad_audience_firms/sales_ad_audience_state, CHECK не входит в счётчик индексов). Это инкремент ТОЛЬКО от Task 1 плана docs/superpowers/plans/2026-07-19-vybor-ploshadki-progreva.md, поверх уже известного дрейфа шапки за v8.67v8.78 (см. CHANGELOG_schema.md v8.79 — тело DDL тоже в дельта-миграции, не в этом файле). Полный canon-sync тела/шапки под весь бэклог v8.67–v8.79 — отдельная задача, не Task 1.
-- NB (22.07.2026, v8.80): +3 индекса (idx_ad_firm_ch_yandex, idx_ad_firm_ch_vk, idx_ad_firm_ch_mts на sales_ad_audience_firms, частичные) поверх итога NB v8.79 выше (140 индексов) — итого условно 143 индекса. Таблиц/RLS/функций/триггеров/партиций без изменений (ch_yandex/ch_vk/ch_mts — три новые булевы колонки на уже существующей sales_ad_audience_firms, старая channels пока не удалена). Это инкремент ТОЛЬКО от Task 1 плана docs/superpowers/plans/2026-07-20-tri-ploshadki-i-mts.md, поверх уже известного дрейфа шапки за v8.67v8.79 (см. CHANGELOG_schema.md v8.80 — тело DDL тоже в дельта-миграции, не в этом файле). Полный canon-sync тела/шапки под весь бэклог — отдельная задача, не Task 1.
-- NB (22.07.2026, v8.84): +1 regular-таблица (sales_ad_audience_firm_channels) / +2 индекса (uq_afc_firm_channel, idx_afc_warming) поверх итога NB v8.80 выше (143 индекса) — итого условно 145 индексов. Таблиц/RLS/функций/триггеров/партиций без изменений в остальном (RLS не заводится — SaaS-level, как остальные sales_ad_audience_*). Это инкремент ТОЛЬКО от Task 1 плана Фазы 2 Этапа A («прогрев раздельные каналы»), поверх уже известного дрейфа шапки за v8.67v8.83 (см. CHANGELOG_schema.md v8.84 — тело DDL тоже в дельта-миграции, не в этом файле). Полный canon-sync тела/шапки под весь бэклог — отдельная задача, не Task 1.
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)