Files
portal/db/CHANGELOG_schema.md
T

311 KiB
Raw Blame History

CHANGELOG schema.sql — Лидерра

Назначение: консолидированный журнал изменений 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.85, консолидированный DDL)

v9.10 — 28.07.2026 — лента сообщений по рекламной кампании: ad_campaign_messages

Новая таблица — окно передачи между Яндексом и клиентом по отказам модерации.

CREATE TABLE ad_campaign_messages (
    id          bigserial PRIMARY KEY,
    tenant_id   bigint NOT NULL REFERENCES tenants (id) ON DELETE CASCADE,
    campaign_id bigint NOT NULL REFERENCES ad_campaigns (id) ON DELETE CASCADE,
    banner_id   bigint NULL REFERENCES ad_campaign_banners (id) ON DELETE SET NULL,
    author      varchar(16) NOT NULL,   -- yandex | client | system
    body        text NOT NULL,
    file_path   varchar(512) NULL,
    file_name   varchar(255) NULL,
    file_size   integer NULL,
    file_mime   varchar(128) NULL,
    created_at  timestamp NULL,
    updated_at  timestamp NULL
);
CREATE INDEX ad_campaign_messages_tenant_id_campaign_id_id_index
    ON ad_campaign_messages (tenant_id, campaign_id, id);

Зачем. Пояснение модератора Яндекса портал исправно сохранял и не показывал никому: экран отчёта по кампании читает старую таблицу ad_campaign_ads, которую поток «за показы» не заполняет вообще. Клиент видел красный ярлык «Отклонено» без единого слова объяснения. Лента — одно место, через которое проходят и слова Яндекса, и ответ клиента, и приложенные им документы.

Почему bodytext, а не varchar(255). Модератор перечисляет претензии списком. Именно предел колонки moderation_reason на 255 знаков уже ронял обход модерации целиком (починено 28.07.2026): длинное пояснение не влезало, падал весь джоб, и вместе с ним — вердикты по чужим кампаниям и возврат денег. Повторять эту мину нельзя. Обрезанное по-прежнему живёт в ad_campaign_banners.moderation_reason — оно для ярлыка.

RLS. ENABLE + FORCE ROW LEVEL SECURITY, политика tenant_isolation по app.current_tenant_id — побуквенно как у ad_creative_jobs и ad_campaign_banners.

Гранты. SELECT, INSERT двум ролям — crm_app_user (клиентский портал) и crm_supplier_worker (под ней бежит SyncCampaignModerationJob через соединение pgsql_supplier, он и кладёт в ленту пояснения Яндекса). UPDATE и DELETE не выданы никому осознанно: лента только пополняется, сообщения не правятся и не стираются. Плюс USAGE, SELECT на нумератор ad_campaign_messages_id_seq обеим ролям — без него INSERT падает на бою с «permission denied for sequence», а на dev дырка невидима (там суперпользователь). Все гранты — внутри DO $$ с гардом на существование роли.

crm_admin_user гранта не получает: админского экрана по ленте пока нет. Когда появится список «ждёт разбора» (кусок 3 замысла), понадобится догоняющая миграция с GRANT SELECT — по образцу 2026_07_27_100100_grant_admin_ad_campaign_banners.php. 🪤 Без неё админский экран увидит ноль строк молча, без единой ошибки.

🔴 После выката на бой перезапустить db/03_service_bypass_policies.sql. На боевом кластере crm_supplier_worker и crm_admin_user не BYPASSRLS — кросс-тенантный доступ им даёт именно этот файл. Он таблично-агностичен и новую таблицу подхватит сам, но без повторного прогона джоб модерации увидит в ленте ноль строк, а журнал будет зелёный.

Миграция: app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php. Замысел: docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md.

v9.09 — 27.07.2026 — один баннер на слот кампании: uq_ad_campaign_banner_slot

Уникальный индекс на ad_campaign_banners:

CREATE UNIQUE INDEX uq_ad_campaign_banner_slot
    ON ad_campaign_banners (tenant_id, campaign_id, width, height);

Зачем. Загрузка картинки в слот идёт через updateOrCreate (AdvertisingCampaignController::uploadBanner()), а он без уникального индекса не атомарен: два одновременных нажатия «загрузить» на один и тот же размер (двойной клик, повтор с телефона, медленная сеть) оба проходят проверку «такой строки ещё нет» и создают две строки одного слота.

Последствие тихое и дорогое. Список слотов клиенту собирается через keyBy по размеру и одну строку молча теряет — в портале виден один баннер. А CampaignLauncher идёт по всем строкам набора и создаёт два одинаковых объявления, которые крутятся за деньги клиента. Раньше на таблице был только обычный индекс (tenant_id, campaign_id) — от дублей он не защищает.

Форма ключа. tenant_id в ключе избыточен логически (он однозначно определяется кампанией), но все запросы к таблице идут с явным tenant_id поверх RLS, и индекс той же формы работает заодно как рабочий индекс выборки.

Миграция строгая, дубли не чистит. Если бы в таблице уже лежали дубли, миграция упала бы громко — это осознанно: молча удалять картинки клиента нельзя. На боевом сервере таблицы ad_campaign_banners ещё нет вовсе (проверено 27.07.2026), так что чистить нечего.

RLS/гранты. Не затронуты: индекс — не объект, которому выдают права. Политика tenant_isolation и гранты из миграции 2026_07_26_100200 действуют без изменений. db/03_service_bypass_policies.sql из-за этой миграции перезапускать не нужно — новых таблиц нет.

Миграция: app/database/migrations/2026_07_27_130000_add_unique_slot_to_ad_campaign_banners.php. DDL — в дельта-миграции.

v9.08 — 27.07.2026 — «в работе не больше одного задания робота»: uq_creative_job_single_taken

Частичный уникальный индекс на ad_creative_jobs:

CREATE UNIQUE INDEX uq_creative_job_single_taken
    ON ad_creative_jobs ((status))
 WHERE status = 'taken';

Гарантия на уровне базы: заданий в статусе taken в любой момент не больше одного.

Зачем. На этом инварианте держится всё опознание креативов. Номера креативов портал добывает слепками creatives.get по аккаунту целиком — «до» и «после» загрузки, — а робот номера не читает вовсе. Два задания в работе одновременно перемешивают слепки между кампаниями; размеры блоков у всех клиентов одинаковые (IAB: 300×250, 728×90, …), поэтому итог — не громкий отказ, а тихая привязка чужого номера: картинка одного клиента уезжает в объявление другого.

Кода для этого не хватало. В CreativeJobService::takeNext() стояла проверка AdCreativeJob::where('status', 'taken')->exists(), но она не блокирует строку. Две одновременные транзакции обе видят «в работе никого»; первая берёт задание #1 и коммитит, вторая упирается в замок строки #1, после коммита PostgreSQL (READ COMMITTED) перепроверяет условие status = 'queued', строка #1 уже не подходит — и вторая забирает задание #2. Оба оказываются taken. lockForUpdate() защищал от выдачи одного задания дважды, но не от выдачи двух заданий сразу.

Индекс глобальный, без tenant_id — намеренно. Рекламный кабинет Яндекса один на всех клиентов, и робот-грузчик один; инвариант обязан быть общим для всей базы, иначе слепки перемешаются именно между тенантами.

Почему это не мешает RLS и не стреляет ошибкой уникальности в штатной работе. В taken строку переводит только служебный канал робота (/api/creative-robot/*, посредник admin-db, роль crm_admin_user с разрешающей политикой srv_bypass — то есть он и так видит все тенанты). Клиентский портал под crm_app_user только создаёт задание в статусе queued и в taken его не переводит. Плюс в takeNext() первой строкой транзакции стоит pg_advisory_xact_lock(hashtext('ad_creative_jobs_take')) — штатный путь не упирается в нарушение индекса, а спокойно отвечает роботу «работы нет».

RLS/гранты. Не затронуты: индекс — не объект, которому выдают права. Политика tenant_isolation и табличные гранты из v9.06 действуют без изменений. db/03_service_bypass_policies.sql из-за этой миграции перезапускать не нужно — новых таблиц нет. 🔴 Но при выкате ветки его перезапустить обязательно из-за самой таблицы ad_creative_jobs (v9.06), иначе служебные роли увидят ноль.

Миграция: app/database/migrations/2026_07_27_120100_add_single_taken_guard_to_ad_creative_jobs.php. DDL — в дельта-миграции.

v9.07 — 27.07.2026 — дата последнего дня показа кампании: ad_campaigns.shows_until

Новая колонка ad_campaigns.shows_until (date, nullable; ->after('run_days') в миграции — на PostgreSQL это no-op, физически колонка встаёт в конец таблицы) — последний день показа кампании, ровно тот, что уходит в Директ параметром EndDate при запуске (CampaignLauncher::resolvePeriod). Пишется вместе с yandex_campaign_id, то есть в тот момент, когда эту дату начинает держать Яндекс; возобновляемый запуск переиспользует уже записанную дату, чтобы портал и Директ считали срок одинаково.

Зачем. Единственным переходом кампании в completed было «смета откручена» (delivered >= paid_impressions). Задачи, закрывающей кампанию по истечении срока показа, в расписании не было вообще. После EndDate показы в Директе прекращаются, delivered замирает, смета не добирается — статус оставался running навсегда, а заморозка денег клиента — ACTIVE навсегда. Для медийной кампании по списку телефонов (аудитория ограничена списком, частота показов — настройкой) недокрут сметы это типовой исход, а не редкий случай.

Теперь CampaignImpressionCharger закрывает такую кампанию тем же выходом, что и «смета откручена»: у выхода №1 появилось второе условие, новых мест вызова AdWalletService::release() не добавилось — их по-прежнему ровно четыре.

RLS/гранты. Не затронуты: колонка добавлена в существующую таблицу, политика tenant_isolation и табличные гранты продолжают действовать без изменений. Новых объектов, которым нужны права, не появилось.

Миграция: app/database/migrations/2026_07_27_120000_add_shows_until_to_ad_campaigns.php. DDL — в дельта-миграции.

v9.06 — 27.07.2026 — очередь заданий робота-грузчика креативов: таблица ad_creative_jobs

Новая таблица ad_creative_jobs — очередь заданий роботу-грузчику: отнести готовые баннеры кампании в веб-кабинет Яндекс.Директа через браузер (API Директа картиночный креатив создать не может). Одно задание = один набор баннеров одной кампании. Робот работает строго по одному заданию за раз — иначе слепки creatives.get «до/после» перемешаются, и опознать, какой номер креатива принадлежит какой кампании, станет невозможно.

Состав. id, tenant_id (FK tenants, cascade), campaign_id (FK ad_campaigns, cascade), status (string(16), default queued; жизненный цикл queued → taken → done | failed), attempts (unsignedSmallInteger, default 0), snapshot_before (jsonb, nullable — слепок номеров картиночных креативов аккаунта ДО загрузки: номер → [ширина, высота]), failure_reason (string(1024), nullable), taken_at/finished_at (nullable timestamps), timestamps(). Индексы: (tenant_id, campaign_id) и status.

RLS. ENABLE ROW LEVEL SECURITY + FORCE ROW LEVEL SECURITY + политика tenant_isolation (tenant_id = current_setting('app.current_tenant_id')), тот же паттерн, что у ad_campaign_banners (26.07.2026).

Гранты. crm_app_user (клиентский портал) — SELECT, INSERT, UPDATE: он ставит задание при запуске кампании (задача 10). crm_admin_user — только SELECT, UPDATE: под этой ролью работает служебный канал робота-грузчика, потому что маршруты /api/creative-robot/* идут через посредник admin-db (App\Http\Middleware\UseAdminConnection), подменяющий активное подключение на pgsql_admin. Тот же порядок, что у канала «Поиск → Портал» (routes/web.php, группа api/sales/integration). INSERT роли crm_admin_user сознательно не выдан: робот заданий не создаёт, он только берёт очередное (queued → taken) и отчитывается о результате (taken → done | failed) — least privilege, лишнего доступа не даём. crm_supplier_worker прав на таблицу не получает вовсе: рекламные джобы с этой очередью не работают.

⚠️ Роль под каналом — это не деталь оформления, а условие работоспособности: первая редакция миграции выдала права crm_supplier_worker, хотя канал ходит под crm_admin_user. На тестах такая ошибка невидима (там суперпользователь postgres, права не проверяются), а на бою канал робота получил бы permission denied for table ad_creative_jobs. Тот же класс ошибки, что v9.05.

Нумератор. Отдельным грантом — GRANT USAGE, SELECT ON SEQUENCE public.ad_creative_jobs_id_seq TO crm_app_user, только ему, потому что INSERT есть только у него. Первичный ключ — bigserial ($table->id()), то есть рядом с таблицей живёт отдельный объект-нумератор со своими правами: GRANT INSERT ON <таблицу> НЕ разрешает взять следующий номер, без USAGE на нумераторе INSERT падает на бою с permission denied for sequence. Ровно этот класс дырки уже ловили и разбирали в v9.05 (семь нумераторов рекламных таблиц) — на dev/тестах она невидима, потому что там ходит суперпользователь postgres, которому права не проверяются. Гарды — на роль (pg_roles) и на существование самого нумератора (pg_class.relkind = 'S' в схеме public), тот же паттерн, что в 2026_07_27_100200_grant_ad_sequences.php.

down()Schema::dropIfExists('ad_creative_jobs'): вместе с таблицей уходят и политика, и гранты на таблицу/нумератор, отдельный REVOKE не нужен.

🔴 После выката на бой перезапустить db/03_service_bypass_policies.sql — на боевом кластере служебные роли (crm_supplier_worker, crm_admin_user) не BYPASSRLS, и без повторного прогона этого файла они молча увидят в новой RLS-таблице ноль строк (тихий ноль без ошибки), пока файл не перезапущен. schema.sql не тронут — по тому же паттерну, что v8.99–v9.05.

Миграция app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php, модель app/app/Models/AdCreativeJob.php, тест app/tests/Feature/Advertising/AdCreativeJobModelTest.php.

v9.05 — 27.07.2026 — нумераторы рекламных таблиц: GRANT USAGE, SELECT роли клиентского портала

GRANT USAGE, SELECT ON SEQUENCE <таблица>_id_seq TO crm_app_user на семь нумераторов: ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_phones_id_seq, ad_campaign_banners_id_seq, ad_wallets_id_seq, ad_wallet_transactions_id_seq, ad_wallet_holds_id_seq.

Зачем. У всех этих таблиц первичный ключ — bigserial ($table->id()), то есть рядом с таблицей живёт отдельный объект-нумератор <таблица>_id_seq. В PostgreSQL это самостоятельный объект со своими правами: GRANT INSERT ON <таблица> разрешает вставку, но НЕ разрешает взять следующий номер — без USAGE на нумераторе INSERT падает с permission denied for sequence. Табличные GRANT'ы авторы миграций рекламного модуля выдавали руками (create_ad_campaigns:39, create_ad_campaign_banners:31, grant_supplier_worker_advertising), а GRANT на нумераторы не выдал никто.

Кому. Нумератор нужен только тому, кто делает INSERT. По фактическому состоянию прав (information_schema.role_table_grants на liderra_testing) INSERT на рекламные таблицы есть ровно у одной роли — crm_app_user (клиентский портал). У crm_supplier_worker и crm_admin_user только SELECT/UPDATE, им нумератор не нужен — лишнего не раздаём (least privilege). Джобы Директа перечисляют кампании через pgsql_supplier, но само списание (вставка в ad_wallet_transactions) идёт основным соединением под crm_app_user — см. ChargeCampaignSpendJob::handle(). ad_settings_id_seq в список НЕ входит: таблица однострочная, INSERT в неё не выдан никому.

Почему не поймали раньше. На разработке и в тестах приложение подключается суперпользователем (postgres), которому права не проверяются вовсе — дырка невидима при полностью зелёных тестах и вылезает только на бою, где ходят настоящие ограниченные роли. Тот же класс ошибки уже описан в v8.82/v8.84: «пропущенный sequence-grant = permission denied на бою, тестам на dev/суперюзере невидимо».

🔴 Корневая причина — шире одной миграции. В db/02_grants.sql права раздаются как GRANT ... ON ALL TABLES/SEQUENCES IN SCHEMA public (разово, только по объектам, существующим на момент запуска) плюс ALTER DEFAULT PRIVILEGES IN SCHEMA public ...без FOR ROLE crm_migrator. Из-за отсутствия FOR ROLE дефолтные привилегии привязаны к тому, кто запускает файл, а не к crm_migrator, под которым идут миграции: всё, созданное миграциями после последнего запуска 02_grants.sql, дефолтных прав не наследует, и гранты приходится выдавать вручную в каждой миграции. Системное лечение — дописать FOR ROLE crm_migrator в db/02_grants.sql; это базовый файл прав, правится только по согласованию с владельцем — вынесено как отдельный вопрос, здесь только страховка по факту.

Аддитивно и безопасно к повторному прогону: колонок, таблиц и RLS-политик миграция не трогает, GRANT идемпотентен — если право уже выдано (например, на бою 02_grants.sql гонялся позже создания таблиц), повторная выдача ничего не меняет, безвредный no-op. Гарды: на существование роли (pg_roles) и на существование самого нумератора (pg_class.relkind = 'S') — миграция может прогоняться на окружении, где часть таблиц ещё/уже не создана. down() симметричен — REVOKE USAGE, SELECT ON SEQUENCE ... FROM crm_app_user под теми же гардами.

Миграция app/database/migrations/2026_07_27_100200_grant_ad_sequences.php, прогнана на liderra_testing (migraterollbackmigrate → повторный up(), все прогоны DONE). Проверка фактом: has_sequence_privilege('crm_app_user', '<seq>', 'USAGE'/'SELECT') — по всем семи нумераторам до миграции false/false, после true/true; ad_settings_id_seq и роли crm_supplier_worker/crm_admin_user остались false (лишнего не выдано). Функциональная проверка: под SET ROLE crm_app_user вызов nextval('ad_campaigns_id_seq') теперь проходит, а контрольный nextval('ad_settings_id_seq') по-прежнему даёт «нет доступа к последовательности» — то есть проверялось именно то право, которого не хватало. schema.sql не тронут — по тому же паттерну, что v8.99v9.04.

⚠️ Прогон на liderra_testing не является доказательством состояния боевой базы: там 02_grants.sql вообще не гонялся (у crm_app_user нет прав даже на deals и deals_id_seq). Миграция нужна именно как страховка «вслепую» — она аддитивна, и если на бою права уже есть, прогон будет безвредным no-op.

v9.04 — 27.07.2026 — админке выданы права на баннеры кампаний

GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_admin_user. Таблица создана 26.07.2026 — позже точечных грантов админке (v9.01), поэтому привилегий на ней у роли не было ни одной. Как только админ-экран начнёт читать статус модерации по баннерам (moderation_status, moderation_reason из v9.03), он молча увидел бы пустоту — «тихий ноль» при зелёных тестах.

UPDATE выдан намеренно, а не «за компанию»: оператор должен уметь вписать номер креатива (yandex_creative_id) руками, когда робот-грузчик креативов не справился. Тот же аварийный ручной путь, ради которого в v9.01 админке дали точечный GRANT UPDATE (yandex_creative_id) ON ad_campaigns, — но scope здесь сознательно шире: точечный (колоночный) грант разрешает писать одну названную колонку, а табличный GRANT UPDATE ON ad_campaign_banners технически позволяет оператору переписать любую колонку строки, включая path, tenant_id и campaign_id. Выбран табличный, потому что колоночный грант ломает обычную работу с моделью: Eloquent-save() всегда тащит updated_at, а колоночный грант её не разрешает — приходится обновлять сырым query-builder'ом. Эта ловушка уже задокументирована в докблоке метода setCampaignCreative (app/app/Http/Controllers/Api/AdminAdvertisingController.php:166184): «обновляем строго через query-builder (не Eloquent save()), иначе Eloquent попытается тронуть updated_at/другие колонки и упадёт по гранту».

Аддитивно: колонок не добавлено, RLS-политика tenant_isolation не меняется, права других ролей (crm_app_user, crm_supplier_worker) не тронуты. GRANT под гардом на pg_roles — на несуществующей роли GRANT роняет миграцию; на liderra и liderra_testing роль есть, гард нужен для чужого/чистого окружения. down() симметричен — REVOKE SELECT, UPDATE под тем же гардом.

🔴 После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql: на боевом кластере crm_admin_user не BYPASSRLS, табличный GRANT даёт право на таблицу, а кросс-тенантные строки открывает политика srv_bypass. Без перезапуска админ-экран увидит ноль строк — тот же «тихий ноль», про который предупреждает v9.03. Проверка после выката: SELECT policyname FROM pg_policies WHERE tablename='ad_campaign_banners' — ожидаются две строки, tenant_isolation и srv_bypass.

Миграция app/database/migrations/2026_07_27_100100_grant_admin_ad_campaign_banners.php, прогнана на liderra_testing; проверка фактом — has_table_privilege('crm_admin_user','ad_campaign_banners',…) до миграции false/false, после true/true. schema.sql не тронут — по тому же паттерну, что v8.99v9.03.

⚠️ Точность формулировки выше: прогон на liderra_testing подтверждает, что миграция делает то, что заявлено (после неё право появляется), но не подтверждает состояние боевой базы. На liderra_testing базовые гранты из db/02_grants.sql не раздавались вообще ни одной роли (даже у crm_migrator has_table_privilege на deals = false), поэтому «false до миграции» здесь не отличает «таблица создана позже раздачи прав» от «права на этом окружении никогда не раздавались». Каково состояние прав на бою — этим прогоном не установлено.

v9.03 — 27.07.2026 — номера креативов переезжают с кампании на баннер

ad_campaign_banners + yandex_creative_id, yandex_ad_id, moderation_status (default draft), moderation_reason. GRANT SELECT, UPDATE для crm_supplier_worker (джоб модерации).

Причина: конструктор креативов Яндекса закрыт 01.06.2026, адаптивного креатива не существует — медийная кампания состоит из объявления на каждый размер блока со своим креативом (findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md). Поле ad_campaigns.yandex_creative_id остаётся как аварийный ручной путь и будет снято отдельно.

Аддитивно, RLS tenant_isolation не меняется. Модель App\Models\AdCampaignBanner: четыре константы модерации (MOD_DRAFT/MOD_MODERATION/MOD_ACCEPTED/MOD_REJECTED), новые поля в $fillable, yandex_creative_id/yandex_ad_id в casts()integer. Миграция app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php, прогнана на liderra_testing. Тест app/tests/Feature/Advertising/BannerYandexIdsTest.php. schema.sql не тронут — по тому же паттерну, что v8.99–v9.02.

🔴 После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql. Это отменяет обратное указание записи v8.97, где для ad_campaign_banners перезапуск был признан ненужным: тогда таблица была чисто клиентской, теперь по ней ходит служебная роль crm_supplier_worker (джоб модерации), а на боевом кластере эта роль не BYPASSRLS. Без перезапуска джоб молча увидит ноль строк. Проверка после выката: SELECT policyname FROM pg_policies WHERE tablename='ad_campaign_banners' — ожидаются две строки, tenant_isolation и srv_bypass.

⚠️ Отсутствие привилегий crm_admin_user на ad_campaign_banners — закрыто в v9.04.

v9.02 (2026-07-26) — Реклама «за показы», Часть 4 — адрес сайта клиента и id медийного объявления

ad_campaigns — добавлены две колонки:

  • landing_url (VARCHAR(1024) nullable) — адрес сайта клиента, куда ведёт баннер по клику; вписывает клиент в мастере кампании, уходит в Директ как Href медийного объявления CpmBannerAdBuilderAd.
  • yandex_ad_id (BIGINT UNSIGNED nullable) — id единственного медийного объявления в Директе (в модели показов объявление одно — адаптивный креатив, — поэтому id храним на кампании, для модерации/паузы).

Аддитивно, RLS tenant_isolation не меняется. Отдельный GRANT не нужен: crm_app_user (клиент, пишет landing_url в мастере) и crm_supplier_worker (джоб запуска, пишет yandex_ad_id) уже имеют табличный INSERT/UPDATE ON ad_campaigns — так же пишутся соседние yandex_* поля. Модель App\Models\AdCampaign: landing_url (string) и yandex_ad_id (integer) в $fillable+casts(). Миграция app/database/migrations/2026_07_26_101500_add_landing_url_and_yandex_ad_id_to_ad_campaigns.php, прогнана на liderra_testing. schema.sql не тронут — по тому же паттерну, что v8.99/v9.00/v9.01.

v9.01 (2026-07-26) — Реклама «за показы», Часть 4 — номер адаптивного креатива Яндекса на кампании

ad_campaigns — добавлена колонка yandex_creative_id (BIGINT UNSIGNED nullable) — номер (CreativeId) адаптивного креатива в Яндекс.Директе; один креатив покрывает все размеры баннера, поэтому номер хранится на кампании, а не на ad_campaign_banners. Пока не заполняется никем — позже впишет оператор (админ-экран) или робот-креативщик. Аддитивно, RLS tenant_isolation не меняется. GRANT: crm_app_user (клиент) и crm_supplier_worker (робот-джобы) уже имеют табличный UPDATE ON ad_campaigns (покрывает новую колонку). Для оператора добавлен точечный GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user (least privilege: у admin на таблице был только SELECT) — с гардом на существование роли (dev/test = postgres superuser). Модель App\Models\AdCampaign: yandex_creative_id в $fillable и casts()integer. Миграция app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php, прогнана на liderra_testing. Тест app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php. schema.sql не тронут — по тому же паттерну, что и соседние миграции v8.99/v9.00.

v9.00 (2026-07-26) — Реклама «за показы»: два режима сбора аудитории + клиентская цена + наша наценка

ad_campaigns — добавлено пять колонок: mode (VARCHAR(10) NOT NULL DEFAULT 'auto', 'auto'| 'manual' — режим сбора аудитории), snapshot_from/snapshot_to (DATE nullable, ручной режим: период сделок для среза), run_days (SMALLINT nullable, ручной режим: срок показа в днях), client_cpm_rub (DECIMAL(8,2) nullable — клиент-редактируемая цена за 1000 показов конкретной кампании; NULL → берётся дефолт ad_settings.client_cpm_rub). Аддитивно, RLS tenant_isolation не меняется; доп. GRANT не нужен — табличный GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO crm_app_user (v8.67, миграция create_ad_campaigns) уже покрывает новые колонки. Миграция app/database/migrations/2026_07_26_100500_add_mode_price_to_ad_campaigns.php, прогнана на liderra_testing.

ad_settings — добавлена колонка ad_margin_percent (DECIMAL(5,2) NOT NULL DEFAULT 40.00) — наша наценка на модель «за показы» (в Директ уходит меньше, чем платит клиент). Глобальная однострочная таблица без RLS; доп. GRANT не нужен по тому же паттерну, что и client_cpm_rub (v8.96/2026_07_26_100000) — там явных per-column GRANT'ов не было, колонка покрывается табличным GRANT SELECT ON ad_settings TO crm_app_user / GRANT SELECT, UPDATE ON ad_settings TO crm_admin_user. Отдельно от уже существующего markup_percent (30.00, наценка в старой кликовой модели) — оставлен как есть, не трогаем. Миграция app/database/migrations/2026_07_26_100600_add_margin_to_ad_settings.php, прогнана на liderra_testing.

Модель App\Models\AdCampaign: новые поля в $fillable+casts (snapshot_from/snapshot_todate:Y-m-d, run_daysinteger, client_cpm_rubdecimal:2, modestring, дефолт auto в $attributes), константы MODE_AUTO/MODE_MANUAL, метод effectiveCpm() (своя цена кампании либо дефолт ad_settings.client_cpm_rub). yandex_cost_rub остаётся в $hidden (наценка клиенту не видна) — не тронуто. Тест app/tests/Feature/Advertising/CampaignModeModelTest.php.

v8.99 (2026-07-26) — Реклама «за показы», Часть 3b-3 — частичное включение баннера в показ

ad_campaign_banners — добавлена колонка included (BOOLEAN NOT NULL DEFAULT true) — «баннер идёт в показ». Клиент грузит свой файл на каждый размер отдельно (переезд с автогенерации из одной картинки); included позволяет временно исключить конкретный размер из набора без удаления строки/файла. RLS tenant_isolation не меняется (та же таблица, аддитивная колонка). GRANT UPDATE ON ad_campaign_banners TO crm_app_user — ранее у клиента были только SELECT, INSERT, DELETE (v8.97); переключение/замена баннера требует UPDATE. Миграция app/database/migrations/2026_07_26_100400_add_included_to_ad_campaign_banners.php, прогнана на liderra_testing.

Перенумерация (14.07.2026): записи ниже (v8.67–v8.70) сделаны на ветке feat/sales-finder параллельно с боевым main. Их прежние номера (v8.59–v8.62) столкнулись с боевыми (автоподбор), поэтому при сведении они перенумерованы. Содержание не менялось.

v8.98 (2026-07-26) — Реклама «за показы», Часть 3b-2 — утверждение набора баннеров

ad_campaigns — добавлена колонка banners_approved_at (TIMESTAMP nullable) — момент, когда клиент утвердил сгенерированный набор баннеров. Новая загрузка картинки сбрасывает флаг в NULL (нужно утверждать заново). RLS tenant_isolation и GRANT не меняются (та же таблица, аддитивно). Миграция app/database/migrations/2026_07_26_100300_add_banners_approved_at_to_ad_campaigns.php.

Endpoint'ы (tenant-scoped, AdvertisingCampaignController): POST campaigns/{id}/banner-source (1 картинка → CampaignBannerService::generate → 15 баннеров + сброс утверждения), GET campaigns/{id}/banners (список превью), GET campaigns/{id}/banners/{bannerId}/preview (стрим ПРИВАТНОГО файла с диска local, не публичный URL), POST campaigns/{id}/banners/approve (ставит banners_approved_at; пустой набор → 422). Чужой тенант → 404.

v8.97 (2026-07-26) — Реклама «за показы», Часть 3b-1 — набор баннеров кампании

Новая таблица ad_campaign_banners — сгенерированные из ОДНОЙ картинки клиента баннеры точных размеров блоков Яндекса (медийная). Колонки: id, tenant_id (FK tenants, cascade), campaign_id (FK ad_campaigns, cascade), width/height (SMALLINT unsigned), path (файл на приватном диске local = storage/app/private), bytes (INT unsigned), timestamps; индекс (tenant_id, campaign_id).

RLS: ENABLE+FORCE ROW LEVEL SECURITY, политика tenant_isolation (tenant_id = current_setting('app.current_tenant_id')) — как у ad_campaigns. GRANT SELECT, INSERT, DELETE только crm_app_user (клиент). Таблица клиентская — служебные роли (crm_admin_user/crm_supplier_worker) её не читают, поэтому srv_bypass перезапускать НЕ нужно (нет кросс-тенантного доступа служебных ролей). Миграция app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php, прогнана на liderra_testing.

Наполняет App\Services\Advertising\CampaignBannerService::generate() — прогон исходной картинки по BannerSizes через BannerGenerator (Часть 3a), файлы на диск local, строки в БД; перегенерация заменяет прежний набор. schema.sql (снимок) — регенерировать при завершении фичи.

⚠️ Предупреждение для Части 4 (джоб загрузки баннеров в Яндекс, вердикт rls-reviewer): Директ- джобы бегут под pgsql_supplier (crm_supplier_worker, на кластере НЕ BYPASSRLS — ср. хотфикс v8.94 для ad_campaigns). Когда появится джоб, читающий ad_campaign_banners под этой ролью, ему понадобится GRANT SELECT ON ad_campaign_banners TO crm_supplier_worker + перезапуск db/03_service_bypass_policies.sql (srv_bypass), иначе «тихий ноль». Здесь (клиентская генерация) это не нужно.

v8.96 (2026-07-26) — Реклама «за показы», Часть 1 — денежная модель кампании (CPM)

Перевод рекламного модуля с «за клики» на «за показы» (медийная кампания). Часть 1 из 6 (spec docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md).

Таблица ad_settings — добавлена колонка client_cpm_rub (DECIMAL(8,2), DEFAULT '120.00') — плоская клиентская цена за 1000 показов (наценка клиенту не показывается; см. spec §3). Строка единственная, backfill'ится в той же миграции. RLS нет (глобальная таблица, как было); GRANT наследуется от таблицы (SELECT crm_app_user, SELECT,UPDATE crm_admin_user). Миграция app/database/migrations/2026_07_26_100000_add_client_cpm_to_ad_settings.php.

Таблица ad_campaigns — добавлены поля модели «за показы»: frequency (SMALLINT unsigned, частота показов на человека), frequency_period_days (SMALLINT unsigned, период частоты; NULL = весь срок), estimated_impressions / paid_impressions (BIGINT unsigned), delivered_impressions (BIGINT unsigned, DEFAULT 0), budget_rub (DECIMAL(14,2), клиентская сумма сметы), yandex_cost_rub (DECIMAL(14,2), DEFAULT '0.00', факт расход Яндекса — только для админ-маржи), charged_client_rub (DECIMAL(14,2), DEFAULT '0.00', списано клиенту по факту). Клик-наследие weekly_budget_rubnullable (DROP NOT NULL). Клик-поля click_bid_rub / daily_budget_rub оставлены (уже nullable) — удаление отдельной уборкой после переезда всего кода. RLS tenant_isolation и GRANT не меняются (та же таблица). Миграция app/database/migrations/2026_07_26_100100_add_impression_fields_to_ad_campaigns.php.

Обе миграции прогнаны на liderra_testing (12/12 рекламных тестов зелёные, вкл. регресс кошелька). Расчёт денег — новый чистый сервис App\Services\Advertising\AdImpressionPricing (bcmath scale 2, округление клиентской суммы вверх до копейки). schema.sql (консолидированный снимок) — регенерировать при завершении фичи; здесь фиксируются миграции как источник.

⚠️ Конфиденциальность маржи (требование для Частей 5/6, вердикт rls-reviewer): RLS ограничивает строки, НЕ столбцы. crm_app_user имеет table-level SELECT на ad_campaigns, поэтому клиент технически может прочитать yandex_cost_rub (и вывести наценку) на СВОИХ строках. База это не запретит — запрет ОБЯЗАН стоять в приложении: клиентские сериализаторы/выдача НИКОГДА не отдают yandex_cost_rub (и margin-производные charged_client_rub/факт-расход) в клиентский ответ. Нужен явный allowlist столбцов для клиентской роли + тест «yandex_cost_rub не встречается в клиентском JSON». srv_bypass перезапускать НЕ нужно (таблица уже под RLS, изменения аддитивные).

v8.95 (2026-07-25) — Рекламный кошелёк, Часть A — оплата картой ЮKassa зачисляет рекламный кошелёк

Таблица saas_transactions дополнена NOT NULL колонкой credit_target (VARCHAR(16), DEFAULT 'leads') — тот же машинный дискриминатор, что уже есть у saas_invoices (v8.90), только для онлайн-платежей картой. Значения: 'leads' (умолчание, старое поведение без изменений) | 'advertising'. Миграция app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php, прогнана на liderra_testing — DONE. ADD COLUMN IF NOT EXISTS ... DEFAULT squawk-safe (короткий lock, без backfill отдельным UPDATE).

Назначение: закрывает TODO(Б-1) в PaymentWebhookController — карта ЮKassa теперь умеет зачислять и рекламный кошелёк (ad_wallets), а не только баланс за лиды (tenants.balance_rub), наравне со счёт-фактурой (InvoicePaymentService, v8.90).

OnlineTopupService::start() получил новый последний параметр string $creditTarget = 'leads' (defaulted — существующие вызовы не затронуты), пишет его в saas_transactions.credit_target и ветвит текст чека/описания. BillingController::topup() принимает опциональный credit_target (sometimes|in:leads,advertising, умолчание 'leads') — в ветке шлюза (флаг ВКЛ) передаёт его в OnlineTopupService::start(); в ветке-заглушке (флаг ВЫКЛ) 'advertising' зачисляет AdWalletService напрямую, 'leads' — прежний код BillingTopupService без изменений.

PaymentSettlementService::settle() ветвится по (string) ($tx->credit_target ?? 'leads') ПОСЛЕ атомарного claim pending→success (идемпотентность не тронута): 'leads' (умолчание) — путь БЕЗ ИЗМЕНЕНИЙ, зачисление через BillingTopupService (ledger balance_transactions), balance_rub_after/ balance_transaction_id проставляются как раньше; 'advertising' — зачисление через AdWalletService::topup() (ledger ad_wallet_transactions), balance_rub_after/balance_transaction_id НЕ проставляются (эти поля про ledger лидов, у рекламного кошелька свой ledger). Конструктор инжектит AdWalletService третьим аргументом.

Тест AdvertisingCardTopupTest (RED→GREEN, TDD, зеркалит AdWalletInvoiceTopupTest и PaymentWebhookTest) — 4 теста (settle advertising зачисляет кошелёк и не трогает баланс за лиды; settle leads — регрессия, зачисляет баланс, кошелёк не создаётся; идемпотентность повторного settle advertising; POST /api/billing/topup с credit_target=advertising при флаге ВКЛ создаёт pending-транзакцию рекламного кошелька). Полный tests/Feature/Billing + tests/Feature/ Advertising + tests/Unit/Advertising — 250/250, 726 assertions, все зелёные (регрессий нет). composer stan — 0 новых ошибок из затронутых файлов (только предсуществующие в SetTenantContext.php и др., не относящиеся к этой задаче).

Структурно: +1 колонка (credit_target, NOT NULL DEFAULT) на saas_transactions. Таблиц/индексов/функций/триггеров без изменений.

v8.94 (2026-07-25) — GRANT crm_supplier_worker на ad_campaigns/ad_campaign_ads — доступ ночных джобов Директа

rls-reviewer подтвердил дыру: три ночных джоба Директа (Charge/SyncAudience/ SyncModeration) перечисляют и обновляют ad_campaigns/ad_campaign_ads через соединение pgsql_supplier (роль crm_supplier_worker, BYPASSRLS). BYPASSRLS снимает RLS-политику, но НЕ заменяет табличную привилегию — без GRANT на проде было бы permission denied for table ad_campaigns/ ad_campaign_ads (тот же класс сбоя, что sales_prospects 19.07):

GRANT SELECT, UPDATE ON ad_campaigns TO crm_supplier_worker;
GRANT SELECT, UPDATE ON ad_campaign_ads TO crm_supplier_worker;

SELECT — чтение всеми тремя джобами; UPDATE — запись moderation_status через SyncCampaignModerationJob. Миграция app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php (down() — симметричный REVOKE), гард на существование роли (на dev/тест роли нет), прогнана на liderra_testing — DONE.

Той же задачей ChargeCampaignSpendJob переведён на чтение ad_settings (наценка) по ДЕФОЛТНОМУ соединению вместо pgsql_supplier — таблица глобальная, без RLS, crm_app_user уже имеет SELECT из миграции 2026_07_24_100300; отдельный supplier-грант не нужен.

Структурно: 0 новых таблиц/колонок/индексов, +2 GRANT-выражения. Функций/триггеров без изменений.

v8.93 (2026-07-25) — Яндекс-канал, Часть B1, Task 5 — GRANT crm_admin_user на ad_* + модели Eloquent

Закрывает совет B-adv-1 из RLS-ревью Части A: будущему админ-экрану расход/маржа нужен read-доступ к рекламным таблицам через crm_admin_user (BYPASSRLS), роль-канон — crm_admin_user (НЕ crm_app_admin, Р43). Никаких новых таблиц/колонок — только GRANT SELECT поверх уже существующих:

GRANT SELECT ON ad_wallets, ad_wallet_transactions, ad_wallet_holds, ad_settings TO crm_admin_user;
GRANT SELECT ON ad_campaigns, ad_campaign_ads, ad_campaign_phones TO crm_admin_user;

Миграция app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php (down() — симметричный REVOKE), прогнана на liderra_testing — DONE. ad_wallets/ad_wallet_transactions/ad_wallet_holds (Часть A) и ad_campaigns/ad_campaign_ads/ad_campaign_phones (Task 3–4 этой части) получают SELECT; ad_settings уже имел GRANT SELECT, UPDATE TO crm_admin_user в своей миграции (2026_07_24_100300) — повторный GRANT SELECT идемпотентен, не ошибка.

Той же задачей — три модели Eloquent: App\Models\AdCampaign (константы статусов STATUS_DRAFTSTATUS_STOPPED_NO_FUNDS, PHP-side default status='draft' через $attributes — паттерн SalesProspect/ImportLog, т.к. Eloquent не подтягивает DB-default обратно без refresh(); relations ads()/phones()/tenant()), App\Models\AdCampaignAd (константы модерации MOD_DRAFT|MOD_MODERATION|MOD_ACCEPTED|MOD_REJECTED, relations campaign()/tenant()), App\Models\AdCampaignPhone (relations campaign()/tenant()). Все — declare(strict_types=1), casts() метод (не $casts-свойство), мирроят AdWallet/AdWalletTransaction Части A.

Тест AdCampaignModelTest (RED→GREEN, TDD) — создаёт кампанию + одно объявление, проверяет status === STATUS_DRAFT, ads()->count() === 1, weekly_budget_rub === '2500.00'. 1/1, 3 assertions, зелёный. Полный tests/Feature/Advertising — 20/20, зелёные (регрессий нет). composer stan — 0 новых ошибок (только предсуществующие SetTenantContext.php и SmscSmsProviderTest.php:52).

Структурно: 0 новых таблиц/колонок/индексов, +2 GRANT-выражения (множественные таблицы в одном statement). Функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 5. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §4.

v8.92 (2026-07-25) — Яндекс-канал, Часть B1, Task 4 — таблицы ad_campaign_ads и ad_campaign_phones (RLS)

Две новые tenant-scoped дочерние таблицы ad_campaigns (объявления кампании и свой загруженный список телефонов клиента):

CREATE TABLE ad_campaign_ads (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE,
    campaign_id         BIGINT NOT NULL REFERENCES ad_campaigns ON DELETE CASCADE,
    title               VARCHAR(56)  NOT NULL,
    title2              VARCHAR(45),                       -- 30 + 15 узких символов
    text                VARCHAR(96)  NOT NULL,              -- 81 + 15 узких символов
    href                VARCHAR(1024) NOT NULL,
    image_normal_hash   VARCHAR(255),
    image_wide_hash     VARCHAR(255),
    yandex_ad_id        BIGINT,
    moderation_status   VARCHAR(16)  NOT NULL DEFAULT 'draft', -- draft|MODERATION|ACCEPTED|REJECTED
    moderation_reason   VARCHAR(255),
    created_at          TIMESTAMP NOT NULL,
    updated_at          TIMESTAMP NOT NULL
);
CREATE INDEX ON ad_campaign_ads (tenant_id, campaign_id);

CREATE TABLE ad_campaign_phones (
    id           BIGSERIAL PRIMARY KEY,
    tenant_id    BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE,
    campaign_id  BIGINT NOT NULL REFERENCES ad_campaigns ON DELETE CASCADE,
    phone        VARCHAR(11) NOT NULL,                     -- 79XXXXXXXXX
    expires_at   TIMESTAMP,                                -- период, на который клиент закинул номер (Р17)
    created_at   TIMESTAMP NOT NULL,
    updated_at   TIMESTAMP NOT NULL
);
CREATE INDEX ON ad_campaign_phones (tenant_id, campaign_id);
CREATE UNIQUE INDEX ON ad_campaign_phones (tenant_id, campaign_id, phone);

Миграции app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php и app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php, обе прогнаны на liderra_testing — DONE.

RLS: та же идиома, что и у ad_campaigns/ad_walletsENABLE+FORCE ROW LEVEL SECURITY, политика tenant_isolation USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) на обеих таблицах. GRANT: ad_campaign_adscrm_app_user SELECT, INSERT, UPDATE (объявления не удаляются, только меняют moderation_status); ad_campaign_phonescrm_app_user SELECT, INSERT, UPDATE, DELETE (клиент вправе удалить свой загруженный номер). GRANT crm_admin_user на обе — отдельной миграцией в Task 5 плана (ещё не сделано).

Тест AdCampaignChildTablesMigrationTest (RED→GREEN, TDD) — вставка одной строки объявления и одной строки телефона для тенанта+кампании через DB::table(...), проверка счётчиков и обратного чтения полей. 1/1, 11 assertions, зелёный. Полный tests/Feature/Advertising — 19/19, зелёные (регрессий нет). composer stan — 0 новых ошибок (только предсуществующие SetTenantContext.php и SmscSmsProviderTest.php:52).

Структурно: +2 таблицы (ad_campaign_ads, ad_campaign_phones), +2 RLS-политики (tenant_isolation), +2 обычных индекса (tenant_id, campaign_id на каждой) + 1 UNIQUE-индекс (tenant_id, campaign_id, phone на ad_campaign_phones). Функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 4. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §4.

v8.91 (2026-07-25) — Яндекс-канал, Часть B1, Task 3 — таблица ad_campaigns (кампании клиента, RLS)

Новая tenant-scoped таблица ad_campaigns (кампании клиента в Яндекс.Директе, поверх рекламного кошелька Части A):

CREATE TABLE ad_campaigns (
    id                          BIGSERIAL PRIMARY KEY,
    tenant_id                   BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE,
    channel                     VARCHAR(16)  NOT NULL DEFAULT 'yandex',
    name                        VARCHAR(255) NOT NULL,
    status                      VARCHAR(24)  NOT NULL DEFAULT 'draft', -- draft|pending_moderation|running|paused|rejected|stopped_no_funds
    audience_days               SMALLINT NOT NULL DEFAULT 10,          -- скользящее окно сделок
    use_uploaded_list           BOOLEAN NOT NULL DEFAULT FALSE,
    weekly_budget_rub           NUMERIC(14,2) NOT NULL,                -- клиентские ₽
    daily_budget_rub            NUMERIC(14,2),                        -- soft-cap (у ручной стратегии Директа поля нет)
    click_bid_rub               NUMERIC(14,2),
    yandex_segment_id           BIGINT,                                -- external_id Аудиторий
    yandex_retargeting_list_id  BIGINT,
    yandex_campaign_id          BIGINT,
    yandex_ad_group_id          BIGINT,
    moderation_reason           VARCHAR(255),
    launched_at                 TIMESTAMP,
    created_at                  TIMESTAMP NOT NULL,
    updated_at                  TIMESTAMP NOT NULL
);
CREATE INDEX ON ad_campaigns (tenant_id, status);

Миграция app/database/migrations/2026_07_25_100000_create_ad_campaigns.php, прогнана на liderra_testing — DONE.

RLS: та же идиома, что и у ad_wallets (Часть A) — ENABLE+FORCE ROW LEVEL SECURITY, политика tenant_isolation USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint). GRANT: crm_app_user — SELECT, INSERT, UPDATE (клиент создаёт/правит свои кампании; DELETE не даётся — кампании не удаляются, только меняют статус). Роль-канон — crm_app_user/crm_admin_user (Р43); GRANT crm_admin_user — отдельной миграцией в Task 5 плана (ещё не сделано).

Тест AdCampaignMigrationTest (RED→GREEN, TDD) — вставка строки кампании через DB::table('ad_campaigns') для тенанта, чтение полей обратно (channel, status, budgets, yandex_* id). 1/1, 11 assertions, зелёный.

Структурно: +1 таблица (ad_campaigns), +1 RLS-политика (tenant_isolation), +1 индекс (tenant_id, status). Функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 3. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §4.

v8.90 (2026-07-24) — Рекламный кошелёк, Часть A, Task 10 (финал) — оплата по счёту в рекламный кошелёк

Таблица saas_invoices дополнена NOT NULL колонкой credit_target (VARCHAR(16), DEFAULT 'leads') — машинный дискриминатор маршрутизации зачисления, НЕ путать со свободнотекстовым payment_purpose (банковское «назначение платежа»). Значения: 'leads' (умолчание, старое поведение без изменений) | 'advertising'. Миграция app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php, прогнана на liderra_testing — DONE. ADD COLUMN IF NOT EXISTS ... DEFAULT squawk-safe (короткий lock, без backfill отдельным UPDATE).

Назначение: клиент может выставить и оплатить банковским переводом счёт на пополнение рекламного кошелька (ad_wallets), а не только баланса за лиды (tenants.balance_rub), как раньше.

InvoiceService::create() получил новый последний параметр string $creditTarget = 'leads' (defaulted — существующие вызовы не затронуты). InvoiceController::store() принимает опциональный credit_target (sometimes|in:leads,advertising, умолчание 'leads').

InvoicePaymentService::markPaid() ветвится по $invoice->credit_target: 'leads' (умолчание) — путь БЕЗ ИЗМЕНЕНИЙ, зачисление через BillingTopupService (ledger balance_transactions), balance_rub_after/ balance_transaction_id на SaasTransaction проставляются как раньше; 'advertising' — зачисление через AdWalletService::topup() (ledger ad_wallet_transactions), balance_rub_after/balance_transaction_id НЕ проставляются (эти поля про ledger лидов, у рекламного кошелька свой). Акт (ActService::createForInvoice) создаётся в обеих ветках без изменений текста/НДС — формулировка акта для рекламных услуг оставлена как есть (TODO(В7) в коде, ждёт решения бухгалтера). Письмо InvoicePaidNotification после COMMIT — без изменений для обеих веток.

Вне охвата (задокументировано TODO(Б-1) в PaymentWebhookController): онлайн-оплата картой ЮKassa рекламного кошелька — go-live онлайн-оплаты ещё не завершён (Б-1), логика webhook не тронута.

Тест AdWalletInvoiceTopupTest (RED→GREEN, TDD, зеркалит InvoiceMarkPaidTest) — 2 новых теста (advertising зачисляет кошелёк и не трогает баланс за лиды; leads — регрессия, зачисляет баланс, кошелёк не создаётся). Полный tests/Feature/Advertising — 17/17, полный tests/Feature/Billing — 173/173, tests/Feature/Sales/SalesInvoiceTest — 7/7, все зелёные (регрессий нет). composer stan — 0 новых ошибок (только предсуществующие SetTenantContext.php и SmscSmsProviderTest.php:52).

Структурно: +1 колонка (credit_target, NOT NULL DEFAULT) на saas_invoices. Таблиц/индексов/функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md Task 10 (финал).

v8.89 (2026-07-24) — Рекламный кошелёк, Часть A, Task 7 — идемпотентный charge (списание по факту расхода)

Таблица ad_wallet_transactions дополнена nullable-колонкой external_key (строка) + UNIQUE-ограничением (tenant_id, external_key). Миграция app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php, прогнана на liderra_testing — DONE.

Назначение: идемпотентность списания за фактический расход рекламы (клики/показы по кампании) по внешнему ключу события (например, yandex:1:2026-07-24 — канал:кампания:дата). Postgres допускает множественные NULL в external_key под UNIQUE — старые строки topup/freeze/release (где external_key IS NULL) не конфликтуют между собой.

Новый метод AdWalletService::charge() (MONEY-код, только bcmath, scale 2): замок по кошельку (lockForUpdate) ДО проверки идемпотентности (как в SmsChargeService); повторный вызов с тем же external_key — no-op; при недоборе баланса до нуля не уходит в минус (balance_rub не опускается ниже 0.00 — жёсткая остановка по недобору обрабатывается отдельно, AdStopAll, Task 8). Пишет append-only транзакцию type=charge с отрицательной amount_rub и external_key.

Тест AdWalletChargeTest (RED→GREEN, TDD) + полный набор tests/Feature/Advertising + tests/Unit/Advertising — 8/8, 13 assertions, зелёные. composer stan — 0 новых ошибок (только предсуществующие SetTenantContext.php и SmscSmsProviderTest.php:52).

Структурно: +1 колонка (external_key, nullable) + 1 UNIQUE-индекс на ad_wallet_transactions. Таблиц/функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md Task 7. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §3.

v8.88 (2026-07-24) — Рекламный кошелёк, Часть A, Task 4 — глобальная настройка наценки ad_settings + AdMarkup

Наценка рекламного кошелька (клиент видит цену/бюджет с наценкой сверху яндексовой, Яндекс получает бюджет за вычетом наценки) — теперь редактируемый процент, а не хардкод. Новая таблица ad_settings:

CREATE TABLE ad_settings (
    id             BIGSERIAL PRIMARY KEY,
    markup_percent NUMERIC(5,2) NOT NULL DEFAULT '30.00', -- глобальная наценка рекламы
    created_at     TIMESTAMP    NOT NULL,
    updated_at     TIMESTAMP    NOT NULL
);

Намеренно ГЛОБАЛЬНАЯ singleton-таблица (одна строка на всю систему, засеяна markup_percent='30.00' миграцией) — без tenant_id и без RLS. Это не упущение: наценка — общесистемная политика ценообразования, не tenant-scoped данные, поэтому tenant_isolation-идиома здесь неприменима. GRANT: crm_app_user — только SELECT (клиентский код читает процент для расчёта цены); crm_admin_user — SELECT + UPDATE (редактирует только админ). Роль crm_app_admin, фигурирующая в некоторых старых условных GRANT-блоках schema.sql (guarded IF EXISTS ... rolname = 'crm_app_admin'), в 00_create_roles.sql не создаётся — это не активная роль кластера, поэтому здесь используется реальная админ-роль crm_admin_user (BYPASSRLS, см. § роли в 00_create_roles.sql).

Новый money-сервис App\Services\Advertising\AdMarkup — конвертация клиент↔Яндекс через bcmath (scale 2, TRUNCATE, не round-half-up): factor() = 1 + percent/100; clientFromYandex() = bcmul(yandex, factor, 2) (цена клиенту округляется ВНИЗ — никогда не переплатит по нашей вине); yandexFromClient() = bcdiv(client, factor, 2) (бюджет Яндексу округляется ВНИЗ — никогда не потратим больше клиентских денег). Пример при 30%: Яндекс 12.18 → клиент 15.83 (12.18×1.3=15.834, усечено); клиент 500.00 → Яндекс 384.61 (500/1.3=384.615…, усечено).

Client-level конфиг, БЕЗ RLS (осознанно, см. выше). Миграция app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php, прогнана на liderra_testing — DONE (строка markup_percent=30.00 подтверждена, GRANT crm_app_user=SELECT / crm_admin_user=SELECT,UPDATE подтверждены \dp). Тест AdMarkupTest GREEN (1/1, 2 assertions); полный набор tests/Unit/Advertising + tests/Feature/Advertising — 4/4, 7 assertions. composer stan — 0 новых ошибок (единственная — предсуществующая SmscSmsProviderTest.php:52).

Структурно: +1 таблица (глобальный конфиг, без RLS — намеренно, см. выше). Индексов/функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md Task 4. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §3.

v8.87 (2026-07-24) — Рекламный кошелёк, Часть A, Task 2 — таблицы ad_wallet_transactions и ad_wallet_holds

Продолжение Task 1 (ad_wallets, v8.86): append-only леджер операций кошелька и активные заморозки под кампании. Две новые таблицы:

CREATE TABLE ad_wallet_transactions (
    id                 BIGSERIAL PRIMARY KEY,
    tenant_id          BIGINT       NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    type               VARCHAR(32)  NOT NULL, -- topup|charge|freeze|release|refund|manual_adjustment
    amount_rub         NUMERIC(14,2) NOT NULL, -- + пополнение, − списание
    balance_rub_after  NUMERIC(14,2) NOT NULL,
    channel            VARCHAR(32),            -- yandex|sms|vk|telegram|ai_call
    related_type       VARCHAR(255),
    related_id         BIGINT,
    description        VARCHAR(255),
    created_at         TIMESTAMP    NOT NULL
);
CREATE INDEX ON ad_wallet_transactions (tenant_id, created_at);
CREATE INDEX ON ad_wallet_transactions (related_type, related_id);

CREATE TABLE ad_wallet_holds (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   BIGINT       NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    channel     VARCHAR(32)  NOT NULL,
    source_type VARCHAR(255) NOT NULL,
    source_id   BIGINT       NOT NULL,
    amount_rub  NUMERIC(14,2) NOT NULL,
    status      VARCHAR(16)  NOT NULL DEFAULT 'active', -- active|released
    created_at  TIMESTAMP    NOT NULL,
    updated_at  TIMESTAMP    NOT NULL,
    UNIQUE (tenant_id, channel, source_type, source_id)
);
CREATE INDEX ON ad_wallet_holds (tenant_id, status);

ad_wallet_transactionsappend-only леджер (без updated_at, никогда не UPDATE/DELETE): каждая операция кошелька — новая строка с balance_rub_after (остаток после операции), amount_rub со знаком (+/). ad_wallet_holds — активные заморозки под кампании/каналы, статус active→released при снятии заморозки; уникальность (tenant_id, channel, source_type, source_id) не даёт задвоить заморозку одного источника.

RLS — та же идиома tenant_isolation (как в ad_wallets v8.86 и autopodbor_sources): ENABLE + FORCE ROW LEVEL SECURITY, политика по tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint. GRANT клиентской роли crm_app_user: ad_wallet_transactions — SELECT/INSERT (без UPDATE/DELETE — леджер только дописывается); ad_wallet_holds — SELECT/INSERT/UPDATE (статус переключается).

Client-level (не supplier), RLS ВКЛЮЧЁН на обеих. Миграции app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php + 2026_07_24_100200_create_ad_wallet_holds.php, прогнаны на liderra_testing — DONE. Тест AdWalletTablesMigrationTest GREEN (1/1, 2 assertions); полный набор tests/Feature/Advertising/ — 2/2, 4 assertions. composer stan — 0 новых ошибок (единственная — предсуществующая SmscSmsProviderTest.php:52).

Структурно: +2 таблицы, +2 RLS-политики. Функций/триггеров без изменений. План: docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md Task 2. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §3.

v8.86 (2026-07-24) — Рекламный кошелёк, Часть A, Task 1 — таблица ad_wallets

Отдельный рекламный кошелёк тенанта (баланс + заморожено), намеренно не связан с tenants.balance_rub (баланс лидов) — деньги на рекламу и деньги на лиды не смешиваются. Новая таблица ad_wallets:

CREATE TABLE ad_wallets (
    id           BIGSERIAL PRIMARY KEY,
    tenant_id    BIGINT      NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
    balance_rub  NUMERIC(14,2) NOT NULL DEFAULT '0.00',
    frozen_rub   NUMERIC(14,2) NOT NULL DEFAULT '0.00',
    created_at   TIMESTAMP   NOT NULL,
    updated_at   TIMESTAMP   NOT NULL
);

Один кошелёк на тенанта (tenant_id UNIQUE). balance_rub — доступный остаток, frozen_rub — сумма активных заморозок под кампании (Task 3 плана добавит ad_wallet_holds); списание/пополнение/история — ad_wallet_transactions (Task 2).

RLS — идиома tenant_isolation (как в autopodbor_sources v8.5x): ENABLE + FORCE ROW LEVEL SECURITY, политика по tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint. GRANT SELECT/INSERT/UPDATE клиентской роли crm_app_user (кошелёк — клиентские данные тенанта, не поставщика — crm_supplier_worker не задействован).

Client-level (не supplier), RLS ВКЛЮЧЁН. Миграция app/database/migrations/2026_07_24_100000_create_ad_wallets.php, прогнана на liderra_testing — DONE. Тест AdWalletMigrationTest GREEN (1/1, 2 assertions).

Структурно: +1 таблица, +1 RLS-политика. Индексов/функций/триггеров без изменений (кроме implicit unique-индекса на tenant_id). План: docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md Task 1. Спека: docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md §3.

v8.85 (2026-07-23) — Витрина прогрева, кусок B (B1) — таблица sales_ad_audience_warming_episodes

Летопись эпизодов прогрева: одна строка на запуск прогрева фирмы на канале — источник значков витрины (идёт сейчас / грели раньше / ×N). Новая таблица sales_ad_audience_warming_episodes:

CREATE TABLE sales_ad_audience_warming_episodes (
    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')),
    started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    ended_at   TIMESTAMPTZ,          -- NULL = длящийся эпизод идёт сейчас; для СМС started_at = ended_at
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_awe_firm_channel ON sales_ad_audience_warming_episodes (firm_id, channel);
CREATE INDEX idx_awe_open ON sales_ad_audience_warming_episodes (firm_id, channel) WHERE ended_at IS NULL;

Длящиеся каналы (yandex/vk/mts): started_at при «Греть», ended_at при «Убрать» (ended_at IS NULL = идёт сейчас). СМС: мгновенный эпизод started_at = ended_at на каждую боевую отправку. Из летописи считаются значки: открытый эпизод = «идёт сейчас» (яркий), закрытый = «грели раньше» (серый), число эпизодов канала = «×N».

GRANT SELECT/INSERT/UPDATE/DELETE + sequence USAGE/SELECT обеим ролям crm_admin_user/crm_supplier_worker (паттерн v8.84 — пропущенный sequence-grant = permission denied на бою).

Бэкфилл без потерь: каждой греющейся строке sales_ad_audience_firm_channels (status='warming', yandex/vk/mts) → открытый эпизод от warming_started_at; каждой боевой СМС (sales_sms_messages.status IN ('sent','fake_sent'), sent_at NOT NULL) → мгновенный эпизод по её номеру (sales_sms_messages без created_attimestamps=false, время только в sent_at). Guard бэкфилла — по каждому источнику (NOT EXISTS эпизода канала-группы), не «таблица пуста»: первый INSERT наполнил бы её и второй всегда бы пропускался. Guard делает повторный прогон миграции безопасным (история без уникального ключа — задвоение недопустимо).

SaaS-level, БЕЗ RLS, соединение pgsql_supplier. Миграция app/database/migrations/2026_07_23_140000_create_ad_audience_warming_episodes.php (идемпотентна: CREATE TABLE/INDEX IF NOT EXISTS + guard-бэкфилл), прогнана на liderra_testing и dev — обе DONE. Тест WarmingEpisodeRecorderTest GREEN (6/6, 7 assertions), полный прогон Sales 417/417 GREEN, composer stan 0.

Структурно: +1 regular-таблица, +2 индекса. RLS/функций/триггеров без изменений. Спека: docs/superpowers/specs/2026-07-21-progrev-tri-ploshadki-i-vitrina-design.md §6. План: docs/superpowers/plans/2026-07-23-progrev-vitrina-kusok-B.md Task B1.

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:

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 — сверить при слиянии в main (v8.81 занят параллельным модулем «Прогрев СМС», возможен дальнейший сдвиг номера).

У каждого из трёх рекламных каналов (Яндекс/ВК/МТС) появляются свои независимые сроки воронки — таблица sales_ad_audience_platforms получает те же 11 колонок, что раньше были только на синглтоне sales_ad_audience_state (см. v8.77), плюс срок хранения days (был только на state, дефолт 30). Итого 12 новых колонок на каждой из 3 строк площадок (yandex/vk/mts):

warmup_days=3, new_days=3, in_work_days=14, negotiation_overdue_days=3,
negotiation_far_threshold_days=14, negotiation_far_head_days=3, negotiation_far_lead_days=3,
no_answer_days=7, rejected_days=3, registered_days=30, testing_days=30, days=30

Все INTEGER NOT NULL DEFAULT <n> с CHECK (<col> BETWEEN 1 AND 365). Имена CHECK-констрейнтов — с префиксом chk_adp_ (не chk_ad_, как на state), чтобы не столкнуться с одноимёнными констрейнтами на sales_ad_audience_state. GRANT не нужен — новые колонки наследуют привилегии уже гранченной таблицы (выдана в v8.82).

Значения бэкфиллены одним UPDATE ... FROM из синглтона 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, тест AdAudiencePlatformDurationsMigrationTest GREEN после каждого). down()DROP COLUMN IF EXISTS всех 12 добавленных колонок (аддитивный откат, констрейнты уходят вместе с колонками). ⚠️ DDL — в дельта-миграции, НЕ в теле schema.sql (тело/шапка отстают с v8.77, отдельный canon-sync закроет бэклог разом). Структурно: 0 новых таблиц/индексов/ RLS/функций/триггеров (12 колонок на уже существующей таблице).

Спека: 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.

v8.82 (2026-07-21) — Три площадки прогрева: таблица настроек sales_ad_audience_platforms

⚠️ Версия предварительная на ветке feat/warming-platforms — сверить при слиянии в main (v8.81 занят параллельным модулем «Прогрев СМС», возможен дальнейший сдвиг номера).

Настройки трёх площадок прогрева (Яндекс / ВК / МТС) переезжают из общего синглтона sales_ad_audience_state в отдельную таблицу sales_ad_audience_platforms — одна строка на площадку вместо расползающихся по синглтону колонок yandex_*/vk_*. Колонки: 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/state.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 — в дельта-миграции, НЕ в теле schema.sql (тело/шапка отстают с v8.77, отдельный canon-sync закроет бэклог разом). Структурно: +1 regular-таблица; RLS/функций/триггеров без изменений.

Спека: 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.

v8.81 (2026-07-23) — Модуль «Прогрев СМС»: четыре таблицы рассылки + оператор у номеров

Начальник отдела продаж отправляет СМС по отмеченным фирмам прогрева: свой текст, видимая цена до отправки, журнал после. Под это заводится четыре таблицы модуля и две колонки у уже существующей таблицы номеров прогрева.

Миграция: app/database/migrations/2026_07_23_100000_create_sales_sms_tables.php. План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md Task 1. Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md.

Добавлено — таблицы (все SaaS-level, без RLS, как остальные sales_*):

  • sales_sms_senders — имя отправителя, регистрируется у каждого провайдера отдельно и проходит модерацию оператора. status с CHECK (draft/pending/active/rejected) — пока не active, рассылка не стартует. Уникальный индекс uniq_sms_sender на (COALESCE(tenant_id, 0), name, provider_key).
  • sales_sms_campaigns — рассылка: текст, счётчики (planned/sent/failed/ skipped), оценочная и фактическая стоимость в копейках, ссылка на списание (balance_transaction_id). status с CHECK (draft/queued/sending/done/ failed/canceled). FK sender_id → sales_sms_senders(id).
  • sales_sms_messages — строка журнала на один номер. FK campaign_id → sales_sms_campaigns(id) ON DELETE CASCADE. Уникальный индекс uniq_sms_message_per_campaign на (campaign_id, phone)дубль номера внутри одной рассылки запрещён на уровне БД, а не только в коде. Рабочий индекс idx_sms_messages_campaign на (campaign_id, status).
  • sales_sms_optouts — стоп-лист. reason с CHECK (manual/reply_stop/ complaint/operator). Уникальный индекс uniq_sms_optout на (COALESCE(tenant_id, 0), phone).

Мультиклиентность: tenant_id BIGINT NULL заведён с первого дня в senders/campaigns/optouts как задел под клиентскую версию, NULL = «Лидерра сама». Внешнего ключа на tenants нет намеренноtenants живут на другом соединении (pgsql), JOIN между соединениями невозможен, разрешение в PHP.

Добавлено — колонки в sales_ad_audience_phones:

  • operator VARCHAR(30) NULL — оператор номера.
  • phone_type VARCHAR(12) NULL — тип номера (мобильный/городской).

Оба значения приходят из «Поиска клиентов» и уже оплачены ДаДате — второй раз за них не платим, поэтому храним рядом с номером.

GRANT'ы: выданы обеим ролям admin-db (crm_admin_user/crm_supplier_worker) внутри миграции, через DO $$ ... pg_roles-guard, как в create_sales_ad_audience_tables. SELECT, INSERT, UPDATE на senders/ campaigns/messages; SELECT, INSERT, DELETE на optouts (из стоп-листа номер можно убрать); USAGE, SELECT ON ALL SEQUENCES. Новые колонки sales_ad_audience_phones наследуют привилегии таблицы. Тестам гранты невидимы — тесты идут под ролью с полным доступом, забытый GRANT дал бы зелёные тесты и падение на правах на бою.

Проверено на liderra_testing: миграция вверх/вниз/вверх — все три прогона DONE, без ошибок про висящие FK и индексы (DROP TABLE ... CASCADE в обратном порядке зависимостей, колонки — DROP COLUMN IF EXISTS). Тест app/tests/Feature/Sales/SmsSchemaTest.php — 4 теста, 10 утверждений, зелёный до и после roundtrip; уникальность (campaign_id, phone) проверена реальным повторным INSERT (ожидается QueryException).

Структурно: +4 таблицы, +4 уникальных индекса, +1 рабочий индекс, +3 CHECK, +2 FK; sales_ad_audience_phones +2 колонки. RLS/функций/триггеров/партиций без изменений.

v8.80 (2026-07-22) — Три площадки прогрева вместо двух: Яндекс / ВК / МТС

Поле channels (yandex|vk|both) не растягивается на третью площадку МТС — сочетаний становится восемь. Заменяется тремя независимыми булевыми колонками ch_yandex / ch_vk / ch_mts у 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.

Добавлено — колонки в sales_ad_audience_firms:

  • ch_yandex BOOLEAN NOT NULL DEFAULT FALSE — греть в Яндексе.
  • ch_vk BOOLEAN NOT NULL DEFAULT FALSE — греть в ВК.
  • ch_mts BOOLEAN NOT NULL DEFAULT FALSE — отдавать в файл для МТС (у них нет программного доступа к рекламе в Telegram).
  • Частичные индексы idx_ad_firm_ch_yandex / idx_ad_firm_ch_vk / idx_ad_firm_ch_mts (каждый WHERE <колонка>) — джобы отбирают фирмы по своей площадке.

Перенос данных (без потерь): ch_yandex = channels IN ('yandex','both'), ch_vk = channels IN ('vk','both'). На бою на 20.07.2026 все 99 фирм на прогреве имели channels='yandex' — все 99 получают ch_yandex=true, ch_vk=false, ch_mts=false (для МТС источника данных в старом поле нет — начальник проставит галочку сам, когда решит).

Колонка channels не удаляется в этой миграции — остаётся страховкой на случай отката, пока код (скоупы модели, джобы, контроллер, экран) не переехал целиком на три поля. Удаление — отдельной миграцией позже, отдельным Task за рамками этого плана.

GRANT'ы: не нужны — таблица уже выдана crm_admin_user/crm_supplier_worker предыдущими миграциями, новые колонки наследуют привилегии таблицы.

Проверено на liderra_testing: миграция вверх/вниз/вверх — оба прогона DONE, без ошибок про висящие индексы. Перенос значений проверен вручную тремя временными строками (channels='yandex'/'vk'/'both'): после up() получили (ch_yandex=t,ch_vk=f) / (ch_yandex=f,ch_vk=t) / (ch_yandex=t,ch_vk=t) — совпадает с ожиданием. После down() channels восстановился в исходные 'yandex'/'vk'/'both' для всех трёх строк, три новые колонки удалены. Временные строки удалены из liderra_testing после проверки.

Структурно: sales_ad_audience_firms +3 колонки +3 индекса (частичных). Таблиц/RLS/функций/триггеров/партиций без изменений (SaaS-level, без RLS, как раньше).

v8.79 (2026-07-21) — Выбор площадки прогрева: Яндекс / ВК / обе

Начальник отдела продаж сам решает, на какой площадке греть каждую фирму. У фирмы появляется поле channels, ночная заливка в Яндекс начинает фильтровать состав по нему, заливка в ВК получает собственное поле состояния канала (три состояния: no_access / waiting_volume / working — до получения программного доступа от ВК и накопления минимума номеров ни одного обращения к API ВК не делается).

Миграция: app/database/migrations/2026_07_21_100000_ad_audience_channels.php. Спека: docs/superpowers/specs/2026-07-19-vybor-ploshadki-progreva-design.md. План: docs/superpowers/plans/2026-07-19-vybor-ploshadki-progreva.md Task 1.

Добавлено — колонки в sales_ad_audience_firms:

  • channels VARCHAR(8) NOT NULL DEFAULT 'yandex' — площадка прогрева: yandex / vk / both. Существующим фирмам ставится 'yandex' не как умолчание, а как факт: они уже лежат в яндексовом сегменте. CHECK chk_ad_firm_channels (channels IN ('yandex','vk','both')). Индекс idx_ad_firm_channels (channels) — джоб заливки отбирает фирмы по площадке.

Добавлено — колонки в sales_ad_audience_state:

  • vk_list_id BIGINT NULL — id списка аудитории в ВК.
  • vk_last_synced_at TIMESTAMPTZ NULL — метка последней успешной заливки в ВК.
  • vk_last_error TEXT NULL — последняя ошибка заливки в ВК.
  • vk_status VARCHAR(16) NOT NULL DEFAULT 'no_access' — статус канала ВК. CHECK chk_ad_vk_status (vk_status IN ('no_access','waiting_volume','working')).

GRANT'ы: не нужны — обе таблицы уже выданы crm_admin_user/crm_supplier_worker предыдущими миграциями, новые колонки наследуют привилегии таблицы.

Проверено на liderra_testing: миграция вверх/вниз/вверх — оба прогона DONE, без ошибок про висящие constraint'ы (все новые объекты простые ADD/DROP COLUMN IF EXISTS / IF NOT EXISTS-guard, без FK).

Структурно: sales_ad_audience_firms +1 колонка +1 индекс +1 CHECK; sales_ad_audience_state +4 колонки +1 CHECK. RLS/функций/триггеров/партиций без изменений (обе таблицы SaaS-level, без RLS).

v8.78 (2026-07-20) — Фирма на прогреве помнит увиденную стадию и когда та сменилась

При реализации Task 5 (ночной пересчёт состояний рекламы) выяснилось: ни sales_prospects.updated_at (двигается ЛЮБОЙ правкой карточки — менеджер поправил заметку, отсчёт рекламы ложно перезапускается бы), ни sales_ad_audience_firms.assigned_at (это момент назначения менеджера, не момент смены стадии) не годятся источником даты «сколько фирма сидит в текущей стадии». Нужна собственная память фирмы.

Миграция: app/database/migrations/2026_07_20_110000_add_stage_tracking_to_ad_audience_firms.php. Спека: docs/superpowers/specs/2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika-design.md, раздел «🔴 ВЕРСИЯ 2». План: docs/superpowers/plans/2026-07-19-reklamnaya-auditoriya-v2.md Task 5.

Добавлено — колонки в sales_ad_audience_firms:

  • stage_seen VARCHAR(16) NULL — стадия карточки sales_prospects, которую RecalcAdAudienceJob видел в последний ночной прогон.
  • stage_changed_at TIMESTAMPTZ NULL — когда увиденная стадия последний раз отличалась от предыдущей (джоб проставляет сам при расхождении).

GRANT'ы: не нужны — таблица уже выдана crm_admin_user/crm_supplier_worker миграцией 2026_07_20_100000, новые колонки наследуют привилегии таблицы.

Проверено на liderra_testing: миграция вверх/вниз/вверх — оба прогона DONE, без ошибок про висящие constraint'ы (обе колонки простые DROP COLUMN IF EXISTS, без FK/CHECK).

v8.77 (2026-07-20) — Реклама на кандидатов v2: прогрев ДО назначения менеджера

Порядок перевёрнут: начальник отдела продаж сначала отправляет найденную фирму в прогрев рекламой, и только когда клиент «созрел» — назначает ему менеджера. Дальше реклама сама следует за стадией карточки воронки и гаснет, когда клиент пополнил баланс.

Миграция: app/database/migrations/2026_07_20_100000_ad_audience_firms_and_durations.php. Спека: docs/superpowers/specs/2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika-design.md, раздел «🔴 ВЕРСИЯ 2». План: docs/superpowers/plans/2026-07-19-reklamnaya-auditoriya-v2.md Task 1.

Добавлено — новая таблица (SaaS-level, без RLS, как весь sales_*; тенантов тут нет):

  • sales_ad_audience_firms — фирма на прогреве: полный снимок из «Поиска клиентов». id BIGSERIAL PK, firm_inn/firm_name/legal_name/city/rubric/site/phone/ rating_label (справочные, nullable кроме firm_name), payload JSONB NOT NULL DEFAULT '{}', contacts JSONB NOT NULL DEFAULT '[]', prospect_id BIGINT (заполняется, когда начальник назначает менеджера — из снимка рождается карточка sales_prospects), warmup_started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ready_at/assigned_at/ stopped_at TIMESTAMPTZ NULL, stop_reason VARCHAR(32) NULL, created_at/updated_at. Уникальный индекс uq_ad_firm_inn (firm_inn) WHERE firm_inn IS NOT NULL — фирма заводится один раз, повторная отправка продлевает прогрев, не плодит дубль. Индекс idx_ad_firm_prospect (prospect_id) WHERE prospect_id IS NOT NULL.

Добавлено — колонки в существующих таблицах:

  • sales_ad_audience_phones: firm_id BIGINTsales_ad_audience_firms(id) ON DELETE CASCADE (fk_ad_phone_firm) — чей номер; state VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (active|paused|stopped) (chk_ad_phone_state) — что с рекламой сейчас; resume_at TIMESTAMPTZ NULL — когда включить снова (для paused, дальний созвон). Индекс idx_ad_phone_state (state) WHERE removed_at IS NULL — по нему ночной джоб заливки в Яндекс берёт активные номера.
  • sales_ad_audience_state: 11 новых INTEGER NOT NULL CHECK (BETWEEN 1 AND 365) вместо одного days — настраиваемые сроки по стадиям воронки: warmup_days (3), new_days (3), in_work_days (14), negotiation_overdue_days (3), negotiation_far_threshold_days (14), negotiation_far_head_days (3), negotiation_far_lead_days (3), no_answer_days (7), rejected_days (3), registered_days (30), testing_days (30). Старая колонка days не тронута (используется прежним контуром v1, пока он не выпилен следующими задачами).

GRANT'ы: SELECT, INSERT, UPDATE, DELETE ролям crm_admin_user и crm_supplier_worker на sales_ad_audience_firms (условно — только если роль существует в кластере), плюс USAGE, SELECT на последовательность sales_ad_audience_firms_id_seq.

Не изменено (намеренно):

  • GRANT'ы существующих колонок sales_ad_audience_phones/sales_ad_audience_state не трогаются — новые колонки наследуют привилегии таблицы.
  • Откат (down()) убирает только добавленное этой миграцией: FK/CHECK/3 колонки у phones, таблицу firms целиком, 11 колонок у state. Таблицы sales_ad_audience_phones и sales_ad_audience_state, заведённые миграцией v8.76, не трогаются и не создаются заново.
  • Проверено на liderra_testing: миграция вверх/вниз/вверх — оба прогона DONE, без ошибок про висящие constraint'ы.

⚠️ Известный дрейф шапки schema.sql: DDL таблиц sales_ad_audience_* (и v8.76, и эта v8.77) лежит только в дельта-миграциях, не свёрнут в тело файла — как ряд версий выше (например v8.39/v8.40). Полный canon-sync тела/шапки под актуальный список версий — отдельная задача, не Task 1.


v8.76 (2026-07-19) — Рекламная аудитория кандидатов (Яндекс.Аудитории)

Начальник отдела продаж отмечает фирмы в «Поиске клиентов», жмёт «Отправить в рекламу» — мобильные номера их директоров попадают в сегмент Яндекс.Аудиторий и автоматически выходят из него через заданное число дней.

Миграция: app/database/migrations/2026_07_19_100000_create_sales_ad_audience_tables.php. Спека: docs/superpowers/specs/2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika-design.md.

Добавлено — две таблицы (SaaS-level, без RLS, как весь sales_*; тенантов тут нет):

  • sales_ad_audience_phones — мобильные номера директоров, отправленные в рекламу: id BIGSERIAL PK, phone VARCHAR(11) NOT NULL UNIQUE, firm_inn/firm_name/city/rubric (справочные, nullable), added_at/expires_at TIMESTAMPTZ NOT NULL, synced_at/removed_at TIMESTAMPTZ NULL. Индекс idx_ad_audience_live (expires_at) WHERE removed_at IS NULL — для ночного джоба, который ищет просроченных и ещё не синхронизированных.
  • sales_ad_audience_state — единственная строка настроек (id BOOLEAN PK DEFAULT TRUE CHECK (id)): enabled BOOLEAN NOT NULL DEFAULT FALSE (рубильник), days INTEGER NOT NULL DEFAULT 30 CHECK (days BETWEEN 1 AND 365), yandex_segment_id BIGINT, last_synced_at, last_error TEXT, updated_at. Строка засеяна миграцией (INSERT ... ON CONFLICT DO NOTHING).
  • GRANT'ы: SELECT, INSERT, UPDATE ролям crm_admin_user и crm_supplier_worker (как sales_prospect_notes, но с UPDATE — номер продлевается/помечается синхронизированным, состояние обновляется вкл/выкл), плюс USAGE, SELECT на последовательности.

v8.75 (2026-07-18) — Журнал разговоров: заголовок записи

Результат разговора и его содержание ложились ДВУМЯ записями подряд — в ленте это читалось как два события, хотя разговор один. Теперь одна запись: сверху что решили («Договорились на созвон 20.07.2026 17:35»), снизу — что рассказали.

Миграция: app/database/migrations/2026_07_18_170000_add_title_to_sales_prospect_notes.php. Спека: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §20.2.

Добавлено:

  • sales_prospect_notes.title VARCHAR(500) NULL — что решили. Пусто у ручных заметок (менеджер просто написал) и у строк, созданных до этой правки.

Не изменено (намеренно):

  • Старые парные записи не сливаются задним числом — историю не переписываем.
  • GRANT'ы не трогаются: колонка наследует привилегии таблицы.

v8.74 (2026-07-18) — Воронка продаж: журнал разговоров по кандидату (append-only)

Раньше результат разговора перезаписывал одно поле карточки — прошлый разговор терялся. Менеджер при повторном звонке не помнил, о чём договаривались, а начальник не видел, как вели клиента. Теперь каждый разговор и каждый переезд по стадии остаются записью с датой и автором.

Миграция: app/database/migrations/2026_07_18_140000_create_sales_prospect_notes_table.php. Спека: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §19.

Добавлено — таблица sales_prospect_notes (SaaS-level, без RLS, как весь sales_*; доступ фильтруется в коде по владельцу карточки):

Колонка Тип Смысл
id BIGSERIAL PK
prospect_id BIGINT NOT NULLsales_prospects(id) ON DELETE CASCADE карточка
sales_user_id BIGINT NULLsales_users(id) автор; NULL — автозапись без человека
kind VARCHAR(16) NOT NULL DEFAULT 'note' CHECK (note|stage) руками / автозапись о стадии
body TEXT NOT NULL текст
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  • Индекс idx_prospect_note_card (prospect_id, created_at DESC) — лента карточки, свежие сверху.
  • GRANT'ы: SELECT, INSERT ролям crm_admin_user и crm_supplier_workerнамеренно без UPDATE/DELETE: журнал append-only, история не переписывается (тот же приём, что у sales_payouts). Плюс USAGE, SELECT на последовательности.
  • Перенос данных: непустые sales_prospects.notes одноразово скопированы в журнал как kind='note'; guard NOT EXISTS делает повторный прогон безопасным. Честная оговорка: дата перенесённой записи = updated_at карточки (приблизительная), автор = владелец карточки.

Не изменено (намеренно):

  • sales_prospects.notes — колонка НЕ удалена (в ней «Заметка» при заведении кандидата), но PATCH /prospects/{id} её больше НЕ принимает: раньше он молча затирал прошлое значение — ровно та потеря истории, ради которой журнал и появился. Единственный вход для текста разговора — POST /prospects/{id}/notes.

v8.73 (2026-07-18) — Воронка продаж: юрлицо отдельно от бренда + контактные лица

Замечание владельца по форме «Добавить кандидата»: телефонов у фирмы бывает много, нужны ФИО и должность человека, ИНН обязателен, а бренд («вывеска») и юрлицо — разные вещи, причём юрлицо надо подтягивать по ИНН через ДаData.

Миграция: app/database/migrations/2026_07_18_100000_add_contacts_and_legal_name_to_sales_prospects.php. Спека: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md §16.

Добавлено:

  • sales_prospects.legal_name VARCHAR(500) NULL — юрлицо (подтягивается по ИНН из ДаData, правится руками). firm_name остаётся брендом/вывеской — его показываем на карточке канбана.
  • sales_prospects.contacts JSONB NOT NULL DEFAULT '[]'::jsonb — список контактных лиц: [{name, position, phones: [...]}, ...]. Пустые люди и телефоны отсекаются на сервере.

Не изменено (намеренно):

  • phone — остаётся «главным телефоном» карточки (список, поиск, интеграция). При ручном заведении в него кладётся первый телефон первого контакта.
  • inn — колонка остаётся NULL-able: карточки из поиска и уже живущие строки могут быть без ИНН. Обязательность ИНН — правило ручного заведения (store()), не ограничение БД.
  • uq_prospect_user_inn — уникальный индекс с v8.71 не тронут; повтор теперь ловится проверкой ДО вставки и отдаёт понятный 422 вместо 500 от БД.

v8.72 (2026-07-16) — Воронка продаж: стадия «Взят в работу» + происхождение карточки

Две правки по запросу владельца: (1) видно, что менеджер карточку уже открывал — она сама уезжает в «Взят в работу», но её можно вернуть в «Новые» (открыл, отвлёкся, закрыл — чтобы не потерялась); (2) менеджер может заводить кандидатов сам, и в воронке видно, что дал начальник, а что менеджер придумал сам (начальник фильтрует по этому).

Миграция: app/database/migrations/2026_07_16_100000_add_inwork_stage_and_source_to_sales_prospects.php. План: docs/superpowers/plans/2026-07-16-sales-prospects-inwork-and-own-candidates.md.

Изменено:

  • sales_prospects.stage — CHECK пересобран, добавлена стадия in_work («Взят в работу», между new и negotiation): new|in_work|negotiation|registered|testing|topped_up|user|rejected|no_answer. Ставится автоматически при первом открытии карточки менеджером; возврат в new — вручную. Джоба авто-стадий (SalesProspectsAdvanceJob) её не трогает.

Добавлено:

  • sales_prospects.sourceVARCHAR(16) NOT NULL DEFAULT 'search', CHECK (search|manager). search — карточку отдал начальник из поиска (и все ранее созданные строки), manager — менеджер завёл сам. Индекс idx_prospect_source (source) под фильтр начальника.

v8.71 (2026-07-15) — Потенциальные клиенты отдела продаж: таблица sales_prospects

Этап 1 фичи «Потенциальные клиенты» — воронка-канбан отдела продаж. Начальник отдаёт фирмы из поиска менеджеру, карточки двигаются по стадиям.

Миграция: app/database/migrations/2026_07_15_100000_create_sales_prospects_table.php. Дизайн: docs/superpowers/specs/2026-07-15-sales-prospects-kanban-design.md.

Добавлено:

  • sales_prospects — SaaS-level (без RLS, как прочие sales_*; GRANT SELECT,INSERT,UPDATE ролям crm_admin_user + crm_supplier_worker). Карточка воронки. Поля: sales_user_id BIGINT (владелец, FK sales_users), assigned_by BIGINT (FK sales_users), stage VARCHAR(16) CHECK (new|negotiation|registered|testing|topped_up|user|rejected|no_answer, DEFAULT new), firm_name, city, phone, site, inn, rating_label, payload JSONB (снимок строки поиска), next_call_at TIMESTAMPTZ, reason TEXT, registered_email, linked_tenant_id BIGINT (FK tenants, для авто-стадий), notes TEXT, created_at/updated_at. Индексы: sales_user_id, stage, UNIQUE (sales_user_id, inn) WHERE inn IS NOT NULL (дедуп отдачи фирмы менеджеру).

v8.70 (2026-07-09) — Итоговая проверка заказа у поставщика: таблица supplier_order_checks

Начало фичи «итоговая проверка заказа у поставщика + сторож времени» (Задача 1 из 8). Таблица-история, в которую позже будет писать джоб проверки (сверка отправленных поставщику строк с фактически принятыми).

Миграция: app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php.

Добавлено:

  • supplier_order_checks — append-only история проверок. Поля: checked_at TIMESTAMPTZ, sync_run_id BIGINT (nullable, без FK), intended_rows/live_rows/mismatch_count INTEGER DEFAULT 0, status VARCHAR(32) (ok | mismatch | unable_to_verify), details JSONB, created_at TIMESTAMPTZ DEFAULT now(). Индексы: checked_at, status.

Счётчики: +1 таблица (SaaS-level, не партиционируется, без RLS/tenant_id — служебная история, не привязана к арендатору). ПДн не содержит (только тех. счётчики и статус проверки). Индексов +2 (checked_at, status). RLS-политик/функций/триггеров — без изменений. Фича на ветке feat/supplier-order-verification (НЕ на проде); schema.sql этой таблицы не содержит (Задача 1 из 8 — только миграция).

v8.69 (2026-07-03) — Портал продаж: пересмотр видов тарифа (daily_salary + topup_step)

Решение заказчика 03.07: состав тарифов менеджера сокращён с трёх видов до двух. Оставлены daily_salary (суточный оклад, ставка ₽/день = per-manager base_salary_rub) и topup_step (процент от пополнений с порогом + разовым бонусом). Убраны percent_oborot («Оклад + % от оборота») и fix_per_client («Фикс за клиента»).

Миграция: app/database/migrations/2026_07_03_120000_sales_tariffs_daily_salary_kinds.php.

Изменено:

  • CHECK sales_tariffs.kind: IN ('topup_step','percent_oborot','fix_per_client')IN ('daily_salary','topup_step'). Перед сменой — освобождены FK-ссылки уходящих видов (sales_users.current_tariff_id, sales_client_assignments.tariff_id → NULL) и удалены сами тарифы этих видов. Снимки привязок (tariff_kind/tariff_params) CHECK'ом не ограничены — не трогаются; для уходящих видов расчёт даёт 0 (default-ветка SalesEarningsService). DDL через pgsql_supplier.

Счётчики: структурно без изменений (1 CHECK переопределён). Таблиц/индексов/RLS/функций/ триггеров — без изменений. Фича на ветке feat/sales-portal-demo (НЕ на проде); schema.sql sales-таблиц не содержит.

v8.68 (2026-06-30) — Портал отдела продаж: таблица Sanctum-токенов

Продолжение фичи «Портал отдела продаж» (Task 0.3). Добавлена таблица personal_access_tokens (Sanctum) — раньше в проекте её не было, т.к. основной кабинет на SPA cookie-auth. Портал продаж (guard sales) использует Bearer-токены, поэтому таблица нужна.

Миграция: app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php.

Добавлено:

  • personal_access_tokens — стандартная Sanctum-таблица: tokenable_type/tokenable_id (morphs), name, token VARCHAR(64) UNIQUE, abilities, last_used_at, expires_at (index), created_at/updated_at. DDL через pgsql_supplier (на проде дефолтная роль без CREATE).
  • GRANTs для crm_admin_user (идемпотентный DO-блок): SELECT/INSERT/UPDATE/DELETE на personal_access_tokens + USAGE/SELECT на personal_access_tokens_id_seq. Вся зона /api/sales (включая логин и проверку токена) работает через admin-db (crm_admin_user), поэтому Sanctum читает/пишет токены под этой ролью.

Счётчики: +1 таблица (системная, без RLS); +1 unique-индекс (token), +1 индекс (expires_at). RLS-политик — без изменений.

v8.67 (2026-06-30) — Портал отдела продаж: 5 системных таблиц

Начало фичи «Портал отдела продаж» (Task 0.1). Пять таблиц SaaS-уровня (без RLS, без tenant_id) — фильтрация по владельцу происходит в коде приложения. Все операции проходят через соединение pgsql_admin (роль crm_admin_user).

Миграция: app/database/migrations/2026_07_01_100000_create_sales_portal_tables.php.

Добавлено:

  • sales_tariffs — каталог тарифных экземпляров. Поля: name, kind CHECK (topup_step | percent_oborot | fix_per_client), params JSONB, is_active BOOLEAN DEFAULT TRUE, id/created_at/updated_at.
  • sales_users — аккаунты менеджеров и руководителей отдела продаж. Поля: name, email UNIQUE, password, role CHECK (manager | head), is_active, base_salary_rub DECIMAL(12,2) (оклад), current_tariff_id → sales_tariffs, created_by → sales_users (самоссылочный FK).
  • sales_client_assignments — привязка «один менеджер на клиента» (UNIQUE tenant_id). Поля: sales_user_id → sales_users, tenant_id UNIQUE → tenants, tariff_id → sales_tariffs, tariff_kind, tariff_params JSONB — снимок тарифа на момент привязки. Индекс idx_sca_sales_user (sales_user_id).
  • sales_attachment_requests — заявки менеджера на привязку клиента. Поля: sales_user_id, login_input, tenant_id → tenants (nullable), status CHECK (pending | approved | rejected | not_found), comment, decided_by → sales_users, decided_at. Индекс idx_sar_status (status).
  • sales_payouts — append-only журнал выплат. Поля: sales_user_id, amount_rub DECIMAL(12,2) CHECK > 0, paid_on DATE, comment, created_by → sales_users. Индекс idx_payout_user (sales_user_id). Append-only гарантируется триггером trg_sales_payouts_no_mutate (BEFORE UPDATE OR DELETE) на функции sales_payouts_no_mutate() — RAISE EXCEPTION при любой попытке изменить/удалить строку.
  • GRANTs для crm_admin_user (идемпотентный DO-блок с проверкой pg_roles): SELECT/INSERT/UPDATE на четырёх таблицах; SELECT/INSERT на sales_payouts; USAGE/SELECT на всех последовательностях схемы public.

Счётчики: +5 таблиц (SaaS-level, без RLS); +3 явных индекса (idx_sca_sales_user, idx_sar_status, idx_payout_user); +1 функция (sales_payouts_no_mutate); +1 триггер (trg_sales_payouts_no_mutate, BEFORE UPDATE OR DELETE). RLS-политик — без изменений.

v8.66 (2026-07-14) — Сведение ветки чата/бота с боевым main перед выкатом

Перед боевым выкатом ветка worktree-jivo-bot-core (ИИ-бот в своём окошке чата, личные ответы, разборы для гостей, откат склейки конкурентов — v8.65) сведена с боевым main, куда 13–14.07 выкатили «Учёт посетителей» (v8.64).

Конфликтов по данным нет: обе стороны только ДОБАВЛЯЛИ таблицы, ничего не переписывая. В схеме теперь оба набора: knowledge_chunks + bot_dialogs (ИИ-бот; глобальные, без RLS) и site_visitors + site_events (учёт посетителей; системные, без RLS; site_events партиционирована помесячно по occurred_at), плюс autopodbor_merge_events.absorbed_sources.

Метрики после сведения: 86 базовых таблиц (75 regular + 11 partitioned) + 17 партиций / 136 индексов / 47 RLS-политик / 5 функций / 15 триггеров.

Миграции: 2026_07_02_100001/100002 (бот), 2026_07_13_100000 (посетители — уже на проде), 2026_07_14_090000 (absorbed_sources).

v8.65 (2026-07-14) — Откат склейки конкурентов: снимок источников/проектов у поглощённых

Разбор докладной 13.07.2026 показал: кнопка «Вернуть» (откат слияния конкурентов) возвращала только карточку поглощённой фирмы (имя/сайт/справочники/телефоны), но НЕ её источники (autopodbor_sources) и, если у источника был ПРОЕКТ (по нему идут заявки/деньги) — проект оставался приклеен к выжившей карточке и переименован под неё. Клиент видел пустую воскрешённую карточку, а его проект висел под именем чужой фирмы.

+autopodbor_merge_events.absorbed_sources JSONB, nullable — снимок источников каждого поглощённого конкурента (ключ верхнего уровня — id конкурента), снятый ДО переноса/переименования при слиянии (AutopodborCompetitorMerger::snapshotAbsorbedSources): id, study_run_id, signal_type, identifier, phone_kind, phone_type, provenance_url, provenance_label, dedup_key, created_project_id, box, where_found, office, confirmations, project_name_before (имя проекта ДО склейки). restoreMergeEvent по этому снимку либо перевешивает исходную строку источника обратно (обычный случай — она просто была перенесена), либо пересоздаёт её (если строка была удалена при разрешении коллизии dedup_key во время слияния), и откатывает имя проекта.

Nullable — обязательно: записи журнала слияний, созданные ДО этой миграции, снимка не имеют (его физически не сохраняли). Такие события restoreMergeEvent откатывает как раньше — только карточку, без источников/проектов — и это честно: ответ API помечается 'partial' => true с сообщением для фронта, а не притворяется полным откатом.

Миграция 2026_07_14_090000_add_absorbed_sources_to_autopodbor_merge_events идемпотентна (Schema::hasColumn). Структурно: autopodbor_merge_events +1 колонка (nullable) — счётчики таблиц/индексов/RLS/функций/триггеров/партиций без изменений. Полный прогон tests/Feature/Autopodbor/ + tests/Feature/Bot/ — 417/417. Ветка worktree-jivo-bot-core, НЕ на проде — решение о выкате отдельно за владельцем.

v8.64 (2026-07-10) — ИИ-бот техподдержки Jivo: knowledge_chunks + bot_dialogs

+knowledge_chunks (база знаний ИИ-бота, глобальная, без RLS — публичные статьи инструкции resources/help/*.md; generated column search_tsv russian + GIN-индекс idx_knowledge_chunks_search; заполняется командой help:rebuild-knowledge; спека docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md §3).

+bot_dialogs (журнал диалогов бота, глобальная, без RLS — ПДн клиентов не пишем; direction in/out, matched_chunks JSONB, latency_ms, escalated; индекс idx_bot_dialogs_chat (jivo_chat_id, created_at); спека §5). Счётчики: таблиц 82→84 (regular 72→74) / индексов 130→132. Миграции 2026_07_02_100001/100002. Ветка worktree-jivo-bot-core (перебазирована на канон main 10.07.2026), НЕ на проде.

v8.64 (2026-07-13) — Учёт посетителей: таблицы site_visitors + site_events

Worktree worktree-visitors-analytics (план docs/superpowers/plans/2026-07-13-visitors-analytics.md, спека docs/superpowers/specs/2026-07-13-visitors-analytics-design.md, Task 1). Свой счётчик посетителей — отличает робота от человека, показывает путь «лендинг → кнопка → регистрация → вход → экраны кабинета».

Свёрнуто в тело schema.sql (в самый конец, перед «КОНЕЦ schema.sql»):

  • +2 таблицы: site_visitors (гость сайта, PK id UUID, каналы/utm/referrer/устройство/гео/признаки is_datacenter/is_human, опциональные FK tenant_idtenants, user_idusers, оба ON DELETE SET NULL) и site_events (шаги пути гостя view/alive/cta_click/register_open/register_done/login_done/portal_screen, PARTITION BY RANGE (occurred_at), composite PK (id, occurred_at)).
  • +2 индекса на site_visitors (idx_site_visitors_first_seen, idx_site_visitors_channel) и +2 индекса на site_events (idx_site_events_visitor, idx_site_events_event).
  • +1 стартовая партиция site_events_y2026_m07 (текущий месяц на момент миграции; остальные — partitions:create-months / MonthlyPartitionManager, туда же добавлена запись site_eventsoccurred_at).
  • GRANT'ы: пишет crm_supplier_worker (SELECT/INSERT/UPDATE на site_visitors, SELECT/INSERT на site_events + USAGE/SELECT на site_events_id_seq); читают crm_admin_user/crm_readonly (SELECT). crm_app_user доступа не получает вовсе — клиентская роль эти таблицы не видит (как supplier_order_checks, системные SaaS-level таблицы без RLS/tenant_id).

Миграция 2026_07_13_100000_create_site_visitors_and_events (идемпотентна: CREATE TABLE IF NOT EXISTS, GRANT-блоки под IF EXISTS (SELECT 1 FROM pg_roles ...)). Тест схемы tests/Feature/SiteTrackingSchemaTest.php (3 теста: колонки site_visitors, site_events партиционирована, crm_app_user без доступа — skip на dev без роли). Маскирование ПДн в дампах — db/anon_masking_labels.sql (site_visitors.ip/user_agentMASKED WITH VALUE NULL).

Структурно: +2 regular/partitioned-таблицы (1 partitioned parent + 1 стартовая партиция), +4 индекса. RLS/функций/триггеров без изменений (таблицы намеренно без RLS — SaaS-system-level).

⏸ ХВОСТ (2026-07-08) — Автоподбор: RLS-политика межтенантного распорядителя шага 2

Ветка feat/autopodbor-step2-batch (worktree wt-autopodbor-batch, НЕ на проде). Ревью нашло риск: AutopodborStudyScheduler читает autopodbor_runs межтенантно через соединение pgsql_supplier (роль crm_supplier_worker), полагаясь на BYPASSRLS. На боевом Managed PG эта привилегия кастомным ролям не гарантирована → на сессии без tenant-контекста политика tenant_isolation вернёт 0 строк и распорядитель зависнет.

Применено миграцией (2026_07_08_130000_autopodbor_runs_supplier_read_policy), тело schema.sql — ОТЛОЖЕННЫЙ canon-sync:

  • +RLS policy autopodbor_runs_supplier_read ON autopodbor_runs FOR SELECT TO crm_supplier_worker USING (true) — явная permissive-политика межтенантного чтения очереди, работает независимо от атрибута BYPASSRLS роли. Причина: межтенантный распорядитель шага 2 (fair-queue). Запись по-прежнему идёт под tenant-контекстом — изоляция не ослабляется.

Структурно: autopodbor_runs +1 RLS-политика. Таблицы/колонки/индексы/функции/триггеры/партиции БЕЗ изменений. Миграция идемпотентна (DROP POLICY IF EXISTS + создание только если роль crm_supplier_worker существует — на dev/тесте её создаёт db/00_create_roles.sql, а не миграции, поэтому на тест-БД политика не создаётся и migrate не падает). Воркстри-фича, НЕ на проде.

⏸ ХВОСТ (2026-07-08) — Автоподбор: batch_id + индексы очереди «пакетный сбор источников» шага 2

Ветка feat/autopodbor-step2-batch (worktree wt-autopodbor-batch, НЕ на проде). Готовим пакетный сбор источников шага 2 — см. spec docs/superpowers/specs/2026-07-08-autopodbor-step2-batch-fair-queue-design.md. Прогоны одного пакета («отправили N сайтов разом») группируются общим batch_id, плюс индекс под честную очередь по status+kind. Статус canceled подготовлен на уровне соглашения (колонка status — свободный VARCHAR(16) без CHECK-ограничения в schema.sql, поэтому дополнительных ALTER не потребовалось).

Применено миграцией (в тест-БД liderra_testing_apk2batch), тело schema.sql — ОТЛОЖЕННЫЙ canon-sync:

  • autopodbor_runs +batch_id UUID NULL (после kind) — миграция 2026_07_08_120000_add_batch_id_to_autopodbor_runs.
  • +индекс autopodbor_runs_tenant_batch_idx (tenant_id, batch_id).
  • +индекс autopodbor_runs_status_kind_idx (status, kind) — под честную очередь пакетного сбора.

Структурно (для будущего canon-sync в тело): autopodbor_runs +1 колонка +2 индекса. Функций/ триггеров/партиций/RLS без изменений. Версия schema.sql остаётся v8.63 до отдельного canon-sync (этот файл — журнал изменений миграций, тело schema.sql синкает контроллер отдельно, см. §5 п.6/п.10 CLAUDE.md — прямые правки schema.sql только с записью сюда).

v8.63 (2026-07-07) — Автоподбор: колонка dismissed_actualize_keys (мягкое «Отказаться» на актуализации)

Ветка worktree-avtopodbor. Кнопка «Отказаться» на карточках «На актуализацию»: клиент отклоняет находку нового сайта/адреса — фирма поля остаётся, находка уходит в архив, а её новые ключи-опознавалки запоминаются у фирмы поля. ProposalClassifier вычитает отклонённые ключи из дельты → та же находка больше не всплывает («не засорять ленту»); реально другой новый сайт даёт новый ключ → снова покажется.

Свёрнуто в тело: autopodbor_competitors.dismissed_actualize_keys JSONB NOT NULL DEFAULT '[]'::jsonb (schema.sql ~л.1415). Миграция 2026_07_07_130000_add_dismissed_actualize_keys_to_autopodbor_competitors идемпотентна (Schema::hasColumn), migrate:fresh зелёный, ветка автоподбора 185/185.

Структурно: autopodbor_competitors +1 колонка. Счётчики таблиц/индексов/RLS/функций/триггеров/партиций БЕЗ изменений (nullable/default ADD COLUMN, RLS построчная не меняется). Колонка добавлена на прод миграцией 07.07 (ролью crm_migrator); в остальном фича воркстри-only.

v8.62 (2026-07-06) — Автоподбор: canon-sync денежной таблицы (пропущенный CHECK)

Ветка worktree-avtopodbor (НЕ на проде). При подготовке боевого выката автоподбора rls-reviewer нашёл пробел: миграция 2026_06_28_110100_extend_balance_transactions_type_for_autopodbor добавляет тип 'autopodbor_charge' в CHECK balance_transactions_type_check (списание за прогон автоподбора через AutopodborChargeService), но в тело schema.sql это значение не было свёрнуто — CHECK заканчивался на 'migration'. Это единственное изменение денежной таблицы balance_transactions в фиче автоподбора, и оно было недокументировано (§5 п.8).

Свёрнуто в тело: balance_transactions.type CHECK += 'autopodbor_charge' (schema.sql ~л.2730).

Структурно: меняется только множество допустимых значений CHECK — счётчики таблиц/индексов/RLS/функций/ триггеров/партиций БЕЗ изменений. balance_transactions партиционирована помесячно — на проде смена CHECK берёт кратковременный ACCESS EXCLUSIVE на родителя+партиции (гнать в тихое окно). Откат down() необратим после первого списания type='autopodbor_charge' (прецедент 'migration'). migrate:fresh на dev зелёный (миграция дельты переигрывает DROP+ADD поверх тела). Воркстри-фича avtopodbor, НЕ на проде.

v8.61 (2026-07-05) — Автоподбор: закрытие «хвоста» competitors/runs + журнал слияний

Ветка worktree-avtopodbor (НЕ на проде). Завершён отложенный canon-sync автоподбора: в тело schema.sql внесены ранее отложенные инкременты (в v8.60 явно помечены как «⏸ ХВОСТ») и добавлена новая таблица журнала слияний. Все ALTER-миграции автоподбора идемпотентны — migrate:fresh прошёл зелёным end-to-end (schema.sql создаёт таблицы/колонки → миграции guard-скипают), весь блок автоподбора 428/428, squawk 0 issues, rls-reviewer OK.

Свёрнуто в тело (catch-up отложенного хвоста):

  • autopodbor_competitors +box VARCHAR(16) NOT NULL DEFAULT 'proposal' +CHECK (proposal|field|archived) +индекс autopodbor_competitors_tenant_box_idx (tenant_id, box) (миграции 2026_06_29_120000 / 2026_07_01_100000 — это и был отложенный «ХВОСТ» из v8.60), +phones JSONB NOT NULL DEFAULT '[]' (2026_07_03_120000), +elements JSONB (2026_07_04_140000).
  • autopodbor_runs +progress JSONB +result JSONB (живой прогресс/итог прогона; 2026_07_05_120000 / 130000).

Новая таблица:

  • autopodbor_merge_events — журнал слияний конкурентов + снимок поглощённых карточек (absorbed JSONB) для истории и возврата («Вернуть карточку»). FK tenant_idtenants CASCADE, user_idusers SET NULL, survivor_idautopodbor_competitors SET NULL (история переживает удаление выжившей/сотрудника). Индекс (tenant_id, created_at), per-tenant RLS tenant_isolation (ENABLE+FORCE). Миграция 2026_07_05_140000.

Идемпотентность: elements/runs_progress/runs_result снабжены guard-ом Schema::hasColumn early-return; add_box/add_phones/add_phone_type/add_where_found — уже с hasColumn; create-миграции — to_regclass.

Счётчики (для autopodbor-блока): autopodbor_competitors +3 колонки/+1 индекс/+1 CHECK; autopodbor_runs +2 колонки; +1 regular-таблица autopodbor_merge_events (+1 индекс, +1 RLS-политика). Функций/триггеров/партиций без изменений. Гранты — бланкетные из db/02_grants.sql. NB: сквозная пересверка канон-счётчиков всей схемы — отдельный шаг (прецедент v8.54/v8.55), как и normative-sync нумерации при слиянии веток (коллизия версий с feat/sales-portal-demo).

v8.60 (2026-07-01) — Автоподбор: box += 'archived' (мягкое удаление + архив старого сайта)

Фича «сверка находок со состоянием клиента» (воркстри avtopodbor, НЕ на проде). Удаление конкурента из предложений/поля становится мягким: вместо стирания строка помечается box='archived' — это нужно для группы «ранее удалённые» при повторном подборе (снова нашли то, что клиент удалял). При актуализации (замена сайта) старый сайт-источник тоже уходит в архив. Поэтому ящик box получает третье значение archived у обеих таблиц автоподбора.

Миграция: app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor.php (идемпотентная: DROP CONSTRAINT IF EXISTS перед ADD CONSTRAINT).

Изменено:

  • autopodbor_sources.box — CHECK IN ('proposal','field')IN ('proposal','field','archived') (синкано в этот DDL, строка 1447).
  • autopodbor_competitors.box — тот же CHECK в БД расширен миграцией до archived; в DDL schema.sql колонка box этой таблицы по-прежнему отложенный хвост canon-sync (как отмечено в шапке с v8.59) — при будущем синке добавить archived в её CHECK.

RLS таблиц (tenant_isolation) — построчная, CHECK её не меняет. Значений/структуры прочих таблиц не трогаем.

v8.59 (2026-06-30) — Автоподбор: богатый провенанс источника + canon-sync инкрементов 29.06

Шаг 2 «Конкурентного поля»: один номер часто встречается в нескольких местах — в коде сайта и в карточках справочников (2ГИС/Яндекс) с РАЗНЫМИ адресами. Старый контракт хранил лишь одно provenance_url/label — список «где нашли» терялся. Добавлены три колонки в autopodbor_sources; фронт (SourceDto.where_found/confirmations/office) уже умеет их показывать кликабельным списком с подтверждениями.

Миграция: app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php (идемпотентная: ADD COLUMN под guard'ом information_schema.columns).

Добавлено (autopodbor_sources):

  • where_foundJSONB (nullable): список мест [{label,url},…]; число подтверждений = его длина. Кликабельные ссылки на сайт/карточки справочников.
  • officeVARCHAR(255) (nullable): подпись филиала/адрес точки из карточки справочника.
  • confirmationsSMALLINT NOT NULL DEFAULT 1: число подтверждений номера (сортировка «больше подтверждений — выше»).

Добавление безопасно: nullable / со значением по умолчанию — старые строки не переписываются. RLS таблицы (tenant_isolation) — построчная, новые колонки её не меняют (rls-reviewer review).

Canon-sync (ранее не синканные инкременты 29.06, внесены в тот же DDL):

  • phone_type VARCHAR(12) + CHECK (… IN ('city','mobile','tollfree')) — тип номера (DaData). Миграция 2026_06_29_120100.
  • box VARCHAR(16) NOT NULL DEFAULT 'proposal' + CHECK (… IN ('proposal','field'))
    • индекс autopodbor_sources_competitor_box_idx (competitor_id, box) — «два ящика» (§14.1). Миграция 2026_06_29_120000.

⏸ Известный хвост (отдельный canon-sync): колонка box у autopodbor_competitors (+ CHECK + индекс autopodbor_competitors_tenant_box_idx, та же миграция 2026_06_29_120000) в DDL schema.sql ещё не синкана. Точная пересверка сводных счётчиков (таблицы/индексы/RLS) отложена — прецедент v8.54/v8.55. Воркстри-фича avtopodbor, на проде НЕ разворачивалась.

v8.58 (2026-06-28) — Автоподбор конкурентов: 3 таблицы (autopodbor_runs/competitors/sources)

Фундамент фичи «Автоподбор конкурентов» (ИИ-агент находит клиенту конкурентов и их источники). Три tenant-isolated таблицы. RLS-политики сразу в харднинг-форме v8.57 (NULLIF(current_setting('app.current_tenant_id', true), '')::bigint).

Миграции: app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php, …_100100_create_autopodbor_competitors.php, …_100200_create_autopodbor_sources.php.

Добавлено:

  • autopodbor_runs — платный «прогон» агента: kind (search/study/resolve), status (queued/running/done/empty/failed), region_code, params (jsonb), competitor_id, price_rub_charged (decimal 12,2), balance_transaction_id, error_code, created_at/started_at/finished_at. FK tenant_id→tenants CASCADE. Индексы (tenant_id, status), (tenant_id, kind, status).
  • autopodbor_competitors — конкурент: search_run_id (FK→autopodbor_runs SET NULL), name, description, is_federal, relevance_pct, origin (auto/manual/resolve), site_url, directory_urls (jsonb), provenance (jsonb), dedup_key, study_run_id (FK→autopodbor_runs SET NULL), studied_at, created_at. UNIQUE autopodbor_competitor_dedup (tenant_id, search_run_id, dedup_key).
  • autopodbor_sources — источник конкурента: competitor_id (FK CASCADE), study_run_id (FK→autopodbor_runs CASCADE), signal_type (site/call), identifier (голова домена / 7xxxxxxxxxx), phone_kind (real/substitute/null), provenance_url, provenance_label, dedup_key, created_project_id (FK→projects SET NULL), created_at. UNIQUE autopodbor_source_dedup (competitor_id, dedup_key).
  • RLS: все три — ENABLE + FORCE ROW LEVEL SECURITY + политика tenant_isolation в форме USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint).

NB: UNIQUE с NULL в search_run_id (Postgres допускает несколько NULL) → дедуп «своих конкурентов» (search_run_id NULL) обеспечивается в коде (AutopodborDedup), не индексом. Счётчик RLS-политик: 44 → 47 (+3).


v8.57 (2026-06-26) — RLS GUC hardening: NULLIF во всех политиках tenant_isolation (инцидент входа)

Инцидент. После переезда на Yandex Managed PG (PgBouncer transaction pooling, порт 6432) вход в портал начал падать: 60 ошибок за день, все на таблице users. Причина — политики tenant_isolation вычисляли current_setting('app.current_tenant_id')::bigint. На пуло-соединении, обслуживающем auth-bootstrap (резолв users по email/id ДО tenant-контекста), GUC app.current_tenant_id либо пуст (''22P02 invalid input syntax for type bigint), либо не задан (→ 42704 unrecognized configuration parameter). На прямом self-managed подключении роль обходила RLS (BYPASSRLS), на управляемой базе BYPASSRLS-атрибута нет — обход только через srv_bypass для служебных ролей, а приложение ходит под изолированной crm_app_user.

Фикс. Все 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint:

  • флаг , true → нет 42704 (параметр не задан → NULL, не ошибка);
  • NULLIF(..., '') → нет 22P02 (пусто → NULL, не ошибка).

Пустой/незаданный GUC → tenant_id = NULL → 0 строк. Изоляция при ЗАДАННОМ tenant НЕ меняется (NULLIF возвращает само значение → выражение идентично прежнему).

5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку NULLIF(...) IS NULL OR ... — они читаются/пишутся на auth-роутах БЕЗ tenant-middleware (вход, регистрация-подтверждение, 2FA-восстановление, запись сессии), где GUC штатно пуст. Доступ там — по точному user_id/email/token, не перебором; при заданном tenant ветка IS NULL ложна → обычная изоляция. Решение DB-only (не код-фикс SET LOCAL в каждом эндпоинте) выбрано ради надёжности — ни один вызов не теряется. rls-reviewer: APPROVE-WITH-NITS (изоляция при заданном tenant не ослаблена; standing-инвариант — доступ к этим 5 при пустом GUC только exact-match — задокументирован).

Структурно БД не меняется — переписаны только USING/WITH CHECK 44 политик. Счётчики таблиц/индексов/RLS-политик(44)/функций/триггеров — без изменений.

Миграция: db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql (идемпотентна: DROP POLICY IF EXISTS + CREATE; обёрнута в BEGIN/COMMIT). Применена на боевой кластер c9q2cvtjpq3hgq6l0r96 (под членством crm_migrator): 44 safe / 0 unsafe, lead_charges FORCE RLS сохранён, изоляция проверена (deals empty=0 / tenant2=1013), вход endpoint = 422. Защита от повторного ввода небезопасного приведения — тест RlsGucHardeningGuardTest.

v8.56 (2026-06-26) — Путь А (Managed PG), шов C: пересчёт аудита без session_replication_role

Тело функции audit_block_mutation() доработано: при метке сессии app.audit_rebuild='on' И (текущая роль superuser — dev/test postgres; ЛИБО член crm_migrator — покрывает crm_supplier_worker, под которым идёт пересчёт на проде) мутация аудит-строки пропускается. Это заменяет SET session_replication_role='replica' (superuser-only, недоступно в Yandex Managed PostgreSQL) при пересборке hash-цепочки командой audit:rebuild-chain. Append-only гарантия сохранена: без метки любой UPDATE/DELETE аудита по-прежнему запрещён (ERRCODE check_violation). EXISTS-гард на crm_migrator не даёт функции падать на dev, где ролей crm_* нет.

CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS TRIGGER AS $$
BEGIN
    IF current_setting('app.audit_rebuild', true) = 'on' THEN
        IF (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN
            RETURN COALESCE(NEW, OLD);
        END IF;
        IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
            IF pg_has_role(current_user, 'crm_migrator', 'MEMBER') THEN
                RETURN COALESCE(NEW, OLD);
            END IF;
        END IF;
    END IF;
    RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
        USING ERRCODE = 'check_violation';
END;
$$ LANGUAGE plpgsql;

Сопутствующее (не схема): команда AuditRebuildChain переведена на SET LOCAL app.audit_rebuild='on' внутри транзакции pgsql_supplier (Odyssey-safe). Структурно БД НЕ меняется — только тело функции; счётчики без изменений (функций 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. TDD: тест tests/Feature/Audit/AuditRebuildChainTest.php (8/8 green). Шов B (политики srv_bypass вместо BYPASSRLS) — отдельный provision-скрипт db/03_service_bypass_policies.sql, применяется при настройке управляемого кластера (не Laravel-миграция: на dev ролей crm_* нет).

v8.55 (2026-06-25) — Эпик 5 отчёт заливки: +1 таблица supplier_sync_runs

Добавлена таблица-сводка supplier_sync_runs для экрана SaaS-admin «Интеграция с поставщиком». SyncSupplierProjectsJob по завершении вечерней заливки пишет одну строку: сколько групп всего, сколько синхронизировано, сколько ушло в ручную очередь / отложено / упало, и итоговый status (ok/partial/failed/aborted). SaaS-admin сверяет глазами, что заливка прошла ровно — от этого зависят заказанные у поставщика на завтра лиды.

CREATE TABLE supplier_sync_runs (
    id BIGSERIAL PRIMARY KEY, started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ,
    groups_total INT NOT NULL DEFAULT 0, synced_ok INT NOT NULL DEFAULT 0,
    manual_queued INT NOT NULL DEFAULT 0, deferred INT NOT NULL DEFAULT 0,
    failed INT NOT NULL DEFAULT 0,
    status VARCHAR(16) NOT NULL DEFAULT 'ok' CHECK (status IN ('ok','partial','failed','aborted')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_supplier_sync_runs_created ON supplier_sync_runs (created_at DESC);

SaaS-level (без RLS/tenant_id, как supplier_csv_reconcile_log) — пишет supplier-flow джоб под crm_supplier_worker (BYPASSRLS), читает SaaS-admin (контроллер AdminSupplierIntegrationController@syncRuns). Запись через finally — сводка пишется и при раннем abort (time-budget / mass-fail / auth). Миграция 2026_06_25_130000; явный GRANT SELECT/INSERT в миграции + blanket ON ALL TABLES в db/02_grants.sql. Счётчики: структурно +1 regular-таблица (база 78→79), +1 явный индекс (123→124). NB: сводные счётчики шапки несут известный дрейф рантайм-счётчика (ср. сверка 23.06) — точная пересверка отдельным canon-sync. RLS-политик/функций/триггеров — без изменений.

v8.54 (2026-06-25) — Эпик 4 online-defer: +1 таблица supplier_deferred_sync

Добавлена системная таблица-очередь supplier_deferred_sync для отложенных онлайн-правок. Онлайн-режим в окне 18:00→00:00 МСК больше не шлёт правки поставщику немедленно (иначе перезаписал бы уже зафиксированный слепок заказа 21:00) — проект кладётся в эту очередь, а FlushDeferredOnlineSyncJob в 00:05 МСК (вне окна) досылает их обычным путём.

CREATE TABLE supplier_deferred_sync (
    project_id BIGINT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

project_id — PK: естественный дедуп (ON CONFLICT DO NOTHING при повторных правках одного проекта в окне). Системная очередь без RLS/tenant_id (как supplier_manual_sync_queue) — доступ только из supplier-flow джоба под crm_supplier_worker (BYPASSRLS); явный GRANT SELECT/INSERT/DELETE — в миграции 2026_06_25_120000 (guarded CREATE TABLE IF NOT EXISTS). Прод дополнительно покрыт blanket-грантом ON ALL TABLES в db/02_grants.sql (как supplier_manual_sync_queue — отдельной строки для таблицы там нет). Счётчики: структурно +1 regular-таблица (база 77→78); явных CREATE INDEX +0 — у таблицы только PK-индекс (неявный). NB: сводные счётчики таблиц/индексов в шапке несут известный дрейф рантайм-счётчика (ср. сверка 23.06: RLS 42→44) — точная пересверка вынесена в отдельный canon-sync, здесь не делается. RLS-политик/функций/триггеров — без изменений.

v8.53 (2026-06-25) — canon-sync: audit_chain_hash() advisory-lock

Тело функции audit_chain_hash() в schema.sql приведено в соответствие с миграцией 2026_05_30_000001_add_advisory_lock_to_audit_chain_hash: добавлен per-partition pg_advisory_xact_lock(lock_key), где lock_key выводится из физического OID партиции (TG_RELID). Это сериализует конкурентные INSERT в одну партицию (разные партиции — разные ключи, не блокируют друг друга) и устраняет гонку, при которой воркеры читают один prev_hash до коммита и разветвляют hash-цепочку (Finding 1 мониторинга Stage-5 Day-1).

Дрейф канона (не структуры БД): миграция уже live — migrate:fresh даёт функцию с блокировкой (initial-load schema.sql → затем CREATE OR REPLACE миграции). Тело schema.sql её не содержало, поэтому канон отставал от реального состояния прод/тест-БД. Исправлено только текстовое тело канона + COMMENT; хеш-формула digest(COALESCE(prev_hash,''::bytea) || NEW::text::bytea,'sha256') — verbatim без изменений. Структурно БД не менялась. Счётчики таблиц/индексов/RLS-политик/функций/триггеров без изменений. Обнаружено при оздоровлении тест-стенда (тест AuditChainRaceConditionTest подтвердил блокировку живой в БД).

Сверка-чистка перед запуском (2026-06-23) — коррекция метрики RLS-политик 42→44

Сверка боевого liderra.ru перед накатом показала: число активных CREATE POLICY в теле schema.sql == 44 == число RLS-политик на проде (pg_policies, schema public). Шапка schema.sql («Метрики:») указывала 42 — исторический недоучёт в бегущем счётчике (политики tenant_requisites_tenant_isolation v8.43 и др. вносились в тело без правки сводной метрики). Структурно БД не изменена — поправлено только число в шапке (42→44) для соответствия реальности. Версия схемы не меняется (v8.52). Все три ранее «не найденные» политики подтверждены в теле: tenants_self_isolation (стр.713), project_routing_snapshots_tenant_isolation (стр.2189), tenant_requisites_tenant_isolation (стр.772); call_recordings.tenant_isolation — закомментирована (таблицы на проде нет). Дрейфа схемы прод↔канон НЕТ.

v8.52 (2026-06-22) — saas_transactions.balance_transaction_id (прослеживаемость онлайн-пополнения)

billing-yookassa: добавлена колонка saas_transactions.balance_transaction_id (BIGINT, nullable, без FKbalance_transactions партиционирована, внешний ключ к ней невозможен). Хранит id строки ledger, созданной при зачислении по платёжному webhook — жёсткая связка «оплата → запись в журнале» (provenance). Структурно: +1 колонка; счётчики таблиц / индексов / RLS-политик / функций / триггеров — без изменений. Миграция 2026_06_22_170000 (guarded ADD COLUMN IF NOT EXISTS). Ветка billing-yookassa, Task 7 (хвост provenance).

2026-06-22 — seed: флаги billing_yookassa_enabled и billing_receipt_enabled

seed: добавлены флаги billing_yookassa_enabled и billing_receipt_enabled в system_settings (дефолт false) — рубильник онлайн-оплаты ЮKassa. Таблицы / индексы / RLS-политики / функции / триггеры — без изменений. Правка только в seed-блоке INSERT INTO system_settings (ветка billing-yookassa, Task 2).

v8.51 (2026-06-22) — RLS hardening: tenants + created_at TZ

Защита-в-глубину на таблице tenants (реестр компаний-клиентов). Ключ — id (не tenant_id). Включена ROW LEVEL SECURITY + политика tenants_self_isolation (USING id = current_setting('app.current_tenant_id', true)::bigint, USING-only — как project_routing_snapshots): под crm_app_user видна только своя компания. Админка (crm_admin_user) и онбординг (соединение pgsql_supplier) идут под BYPASSRLS — их поведение не меняется (список всех компаний и создание новой компании работают как прежде). Закрывает латентный риск из аудита 09.05 (P0-02-сосед).

Заодно project_routing_snapshots.created_at: TIMESTAMPTIMESTAMPTZ (squawk prefer-timestamptz). Внимание (деплой): ALTER COLUMN ... TYPE на партиционированной таблице переписывает партиции под ACCESS EXCLUSIVE — катить в окно низкой нагрузки. Существующие наивные значения трактуются как UTC (USING created_at AT TIME ZONE 'UTC').

Изменено в schema.sql: +ALTER TABLE tenants ENABLE ROW LEVEL SECURITY + CREATE POLICY tenants_self_isolation (после индексов tenants); тип project_routing_snapshots.created_atTIMESTAMPTZ.

Счётчики: RLS-политик 41→42; таблиц / индексов / функций / триггеров — без изменений.

Верификация: метадата-тест tests/Feature/Database/TenantsRlsAndRoutingTzTest.php (relrowsecurity=1, политика существует, created_at=timestamptz) — GREEN на liderra_testing. Живая RLS-изоляция на dev/test не проверяется (соединение — superuser, BYPASSRLS); enforcement — на проде под реальными ролями + rls-reviewer. Миграция 2026_06_22_150000_enable_rls_tenants_and_routing_snapshots_tz. На прод не выкачено.

v8.50 (2026-06-22) — FN-2: корректирующая миграция DEFAULT notification_preferences

FN-2 (приёмка 22.06): миграция 2026_06_19_130000_drop_reminders_table (v8.45) дропнула таблицу reminders, но забыла убрать мёртвый ключ "reminder" из DEFAULT колонки users.notification_preferences. Тело schema.sql (§users, :795) v8.45 уже отражало чистый дефолт, а реальный DB-дефолт на dev/проде оставался с "reminder" — расхождение канон ↔ БД. Миграция 2026_06_22_120000 делает только ALTER TABLE users ALTER COLUMN notification_preferences SET DEFAULT (без reminder), выравнивая БД под канон.

Изменено в schema.sql: ничего по телу/структуре — DEFAULT-текст §users уже корректен с v8.45; правка только в шапке (версия v8.49→v8.50). Метаданные-only ALTER, без переписывания таблицы, существующих строк не трогает (squawk: 0 issues).

Счётчики: таблиц / индексов / RLS-политик / функций / триггеров — без изменений.

v8.49 (2026-06-19) — G7-B: колонка impersonation_tokens.session_token_hash

G7-B: колонка session_token_hash VARCHAR(255) добавлена в таблицу impersonation_tokens — хранит bcrypt-хэш машинного ключа доступа lpimp_*, выдаваемого ИИ-агенту для сессии impersonation. NULL пока ключ не выдан. Миграция 2026_06_19_160000 (guarded: ADD COLUMN IF NOT EXISTS).

Изменено в schema.sql:

  • impersonation_tokens — добавлена колонка session_token_hash VARCHAR(255) после session_ended_at; комментарий «bcrypt машинного ключа lpimp_ (NULL пока ключ не выдан; G7-B)».

Счётчики: таблиц 77 / индексов 123 / RLS-политик 41 / функций 5 / триггеров 15 (без изменений).

v8.48 (2026-06-19) — G7-A: таблица support_requests

G7-A: таблица support_requests (заявки клиента в техподдержку) — RLS tenant_isolation, индекс idx_support_requests_tenant, GRANTs для crm_app_user/crm_supplier_worker. Миграция 2026_06_19_140000 (guarded: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, DROP POLICY IF EXISTS перед CREATE POLICY).

Добавлено в schema.sql:

  • support_requests — tenant-RLS таблица заявок клиента в техподдержку (поля: id, tenant_id, user_id, name, contact, message, created_at); индекс idx_support_requests_tenant (tenant_id, created_at DESC); RLS-политика support_requests_tenant_isolation; GRANT SELECT, INSERT для crm_app_user, crm_supplier_worker; GRANT USAGE, SELECT ON SEQUENCE support_requests_id_seq.

Счётчики: таблиц 76→77 (regular 66→67) / индексов 122→123 / RLS-политик 40→41 / функций 5 / триггеров 15.

v8.47 (2026-06-19) — Закрыт остаток дрейфа схемы: project_routing_snapshots

Закрыт остаток дрейфа схемы: добавлена таблица project_routing_snapshots (партиционированная по snapshot_date, 2 индекса, RLS, GRANT-ы, 2 стартовые партиции m05/m06; миграция 2026_05_27_120000 заguard-нута — добавлены DROP POLICY IF EXISTS и IF NOT EXISTS). Таблица balance_freeze_log уже присутствовала в schema.sql (Billing v2 Spec C, v8.35) с RLS tenant_isolation, индексом balance_freeze_log_tenant_idx и GRANT-ами — не дублирована; её миграция 2026_05_24_100000 уже полностью идемпотентна и не тронута. После этого 0 таблиц БД отсутствуют в schema.sql.

Добавлено в schema.sql:

  • project_routing_snapshots — партиционированный (PARTITION BY RANGE (snapshot_date), composite PK (snapshot_date, project_id), FK tenant_id→tenants ON DELETE CASCADE) слепок маршрутизации проекта + индексы project_routing_snapshots_tenant_date_idx, project_routing_snapshots_signal_idx + RLS project_routing_snapshots_tenant_isolation + GRANT для crm_app_user/crm_supplier_worker + 2 стартовые партиции _y2026_m05 / _y2026_m06.

Guard миграции 2026_05_27_120000: CREATE TABLECREATE TABLE IF NOT EXISTS, оба CREATE INDEXIF NOT EXISTS, обе партиции PARTITION OFIF NOT EXISTS, перед CREATE POLICY добавлен DROP POLICY IF EXISTS (PG не знает CREATE POLICY IF NOT EXISTS).

Счётчики: таблиц 75→76 (regular 66, partitioned parents 9→10) / партиций 14→16 / индексов 120→122 / RLS-политик 39→40 / функций 5 / триггеров 15.

v8.46 (2026-06-19) — Sync дрейфа: слой lead-region в schema.sql

Добавлен слой lead-region, отсутствовавший в schema.sql (на проде живёт с 31.05.2026, DDL держался только в дельта-миграции 2026_05_31_100000 — см. v8.40, где это сделано осознанно «как v8.39»). Реконсиляция дрейфа: теперь объекты есть в теле канонической схемы, а миграция guard-нута (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS) и под migrate:fresh (schema.sql уже содержит объекты) становится no-op.

Источник: миграция app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php.

Добавлено в schema.sql:

  • phone_ranges_imports — журнал импортов реестра Россвязи (regular, без RLS).
  • phone_ranges — реестр диапазонов нумерации Россвязи (regular, без RLS) + индекс idx_phone_ranges_lookup + GRANT SELECT для crm_app_user/crm_supplier_worker.
  • lead_region_resolution_log — партиционированный (PARTITION BY RANGE (received_at), composite PK (id, received_at)) аудит резолва региона лида + индексы idx_lrrl_lead_id, idx_lrrl_source + 2 стартовые партиции _y2026_m05 / _y2026_m06 + GRANT SELECT, INSERT для crm_supplier_worker, GRANT SELECT для crm_app_user.
  • supplier_leads +4 колонки: resolved_subject_code (CHECK 1..89), region_source (CHECK enum dadata/rossvyaz/tag/unknown), dadata_qc, phone_operator.
  • deals +2 колонки: phone_operator, region_substituted (NOT NULL DEFAULT FALSE). NB: обе отсутствовали в теле schema.sql (не только phone_operator).

Счётчики: таблиц 72 → 75 (regular 64 → 66, partitioned parents 8 → 9) / партиций 12 → 14 / индексов 117 → 120. RLS-политики / функции / триггеры — без изменений (lead-region — SaaS-level, без RLS).

v8.45 (2026-06-19) — G3: удалена таблица reminders

Фича «Напоминания» снята с продукта (находка go-live G3). Код фичи уже удалён из приложения; этот шаг убирает таблицу из БД и канонической схемы.

Миграция: app/database/migrations/2026_06_19_130000_drop_reminders_table.php.

Удалено:

  • reminders — таблица напоминаний по сделкам (раздел 17.5) удалена (Schema::dropIfExists('reminders')).
  • 4 индекса idx_reminders_* (idx_reminders_due, idx_reminders_deal, idx_reminders_tenant_user_active, idx_reminders_tenant_active) — уходят каскадом вместе с таблицей.
  • RLS-политика tenant_isolation ON reminders + ENABLE ROW LEVEL SECURITY.
  • COMMENT'ы на таблицу и колонки (created_by, assignee_id, completed_at).
  • Ключ reminder убран из DEFAULT users.notification_preferences (остальные ключи — new_lead, low_balance, zero_balance и т.д. — не тронуты).
  • В down() миграции — полный verbatim DDL (CREATE TABLE + 4 индекса + RLS + политика) для отката.

Счётчики: таблиц 73 → 72 (regular 65 → 64) / индексов 121 → 117 / RLS-политик 40 → 39. Функции/триггеры — без изменений.

v8.44 (2026-06-19) — G2-B: дайджест новых сделок по умолчанию

Включение почтового дайджеста новых сделок по умолчанию (находка go-live G2, часть B; движок — G2-A). Структурно схема не меняется — правка дефолтного значения.

Спека: docs/superpowers/specs/2026-06-19-g2b-new-lead-email-default-on-design.md. План: docs/superpowers/plans/2026-06-19-g2b-new-lead-email-default-on-plan.md. Миграция: app/database/migrations/2026_06_19_120000_default_new_lead_email_on.php.

Изменено:

  • users.notification_preferences DEFAULT: new_lead.email false → true. Новые пользователи получают дайджест по умолчанию; существующие дотягиваются миграцией (UPDATE ... jsonb_set('{new_lead,email}','true') только живых, только текущих false, остальные ключи не трогаются). Откат дотяжки в down() не делается (исходный false и выставленный true неразличимы).
  • Счётчики таблиц/индексов/RLS/функций/триггеров — без изменений.

v8.43 (2026-06-18) — G1/SP2 реквизиты клиента: tenant_requisites

Backend реквизитов клиента (находка go-live G1, под-проект SP2). Новая таблица tenant_requisites (1:1 с tenants) — лёгкие поля (тип лица, контакт, телефон) гейтят создание первого проекта; «тяжёлые» (ИНН, наименование, КПП, ОГРН, юр.адрес, банковский блок) — nullable, дозаполняются клиентом в личном кабинете на этапе оплаты.

Спека: docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md. План: docs/superpowers/plans/2026-06-18-g1-sp2-requisites-gate-plan.md. Миграция: app/database/migrations/2026_06_18_140000_create_tenant_requisites.php.

Добавлено:

  • tenant_requisites — таблица реквизитов, UNIQUE(tenant_id), FK→tenants ON DELETE CASCADE. Поля: subject_type (individual/sole_proprietor/legal_entity), contact_name, contact_phone (нормализованный +7XXXXXXXXXX), inn, legal_name, kpp, ogrn, legal_address, банковский блок (bank_name/bank_bik/bank_account/corr_account), dadata_raw (JSONB), dadata_synced_at, requisites_completed_at.
  • RLS tenant_requisites_tenant_isolation (USING + WITH CHECK по app.current_tenant_id).
  • GRANT: crm_app_user (S/I/U), crm_supplier_worker (S/I/U/D); USAGE/SELECT на sequence.
  • Счётчики в шапке схемы: защищённых таблиц 35→36, RLS-политик 35→36 (+1 с WITH CHECK).

v8.42 (2026-06-18) — G1/SP1 самозапись клиента: код подтверждения почты в email_verifications

Backend самозаписи клиента (находка go-live G1, под-проект SP1). В таблицу email_verifications добавлены поля под 6-значный код подтверждения почты — самозапись создаёт тенанта в статусе pending_email_confirm и подтверждает почту кодом (механика зеркалит impersonation_tokens).

Спека: docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md. План: docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md. Миграция: app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php.

Добавлено:

  • email_verifications.code_hashVARCHAR(255), bcrypt-хеш 6-значного кода (plain в БД не хранится). token остаётся внутренним UUID строки.
  • email_verifications.failed_attemptsSMALLINT NOT NULL DEFAULT 0, лимит 5 неверных вводов (как impersonation_tokens.failed_attempts). TTL строки — 15 минут (expires_at). Счётчики таблиц/индексов/RLS/функций/триггеров в шапке схемы — без изменений.
  • GRANT SELECT, INSERT, UPDATE на email_verifications для 4 ролей (самозапись пишет через BYPASSRLS — нет tenant-GUC на публичном роуте).
  • tenants.status расширен VARCHAR(20)VARCHAR(30) — латентный дефект схемы: CHECK допускал 'pending_email_confirm' (21 символ), но колонка была 20 → значение не влезало. Самозапись (G1/SP1) первой реально ставит этот статус (ALTER COLUMN status TYPE VARCHAR(30)).

v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at)

Частичный индекс для ретеншена ПДн удалённых лидов. Команда pd:scrub-soft-deleted-deals (планировщик, ежедневно 03:30 МСК) анонимизирует phone/contact_name/phones soft-deleted сделок старше срока system_settings.pd_scrub_soft_deleted_deals_days (по умолчанию no-op, если не задан).

Спека: docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md. Миграция: app/database/migrations/2026_06_17_120000_add_deals_deleted_at_index.php.

Добавлено:

  • Индекс deals_deleted_at_indexCREATE INDEX ON deals (deleted_at) WHERE deleted_at IS NOT NULL (partial, на партиционированном родителе → распространяется на все партиции). Дешёвая выборка soft-deleted сделок командой-ретеншеном. Счётчик индексов в шапке схемы 120 → 121.

v8.40 (2026-05-31) — lead region resolution (phone_ranges + resolution_log + supplier_leads/deals columns)

Резолюция настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback) и переключение LeadRouter на каскадную маршрутизацию по региону. Эта запись покрывает только схемные изменения Session 1 (таблицы и колонки); бизнес-логика — в последующих сессиях.

Спека: docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md v0.5. План: docs/superpowers/plans/2026-05-29-lead-region-resolution.md. Миграция: app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php.

Добавлено:

  • phone_ranges_imports — журнал импортов реестра Россвязи (SaaS-level, без RLS). Поля: source_url, rows_inserted/rows_updated, checksum_sha256, status (in_progress/completed/failed/rolled_back), error, completed_at. GRANT SELECT crm_app_user + crm_supplier_worker.
  • phone_ranges — реестр диапазонов нумерации Россвязи (SaaS-level, без RLS — публичные данные). Поля: def_code (код ABC/DEF), from_num/to_num, operator, region, region_normalized, subject_code (1..89), imported_at, import_idphone_ranges_imports. 3 CHECK (def_code 300..999, subject_code 1..89, from_numto_num). Индекс idx_phone_ranges_lookup (def_code, from_num, to_num). GRANT SELECT crm_app_user + crm_supplier_worker.
  • lead_region_resolution_log — PARTITION BY RANGE (received_at), composite PK (id, received_at). Аудит резолва региона на лид: phone_masked, subject_code_resolved/ subject_code_from_tag, region_source (dadata/rossvyaz/tag/unknown), dadata_qc/ dadata_provider/dadata_type/dadata_response_masked (JSONB), rossvyaz_matched, actual_subject_code/substituted_subject_code (1..89), routing_step (1..3), phone_operator, cache_hit, duration_ms, resolved_at. Индексы idx_lrrl_lead_id + idx_lrrl_source (region_source, received_at). GRANT SELECT,INSERT crm_supplier_worker / SELECT crm_app_user. Стартовые партиции lead_region_resolution_log_y2026_m05, _y2026_m06.
  • MonthlyPartitionManager::PARTITIONED_TABLES +entry 'lead_region_resolution_log' => 'received_at'.
  • system_settings +key partition_retention_months_lead_region_resolution_log = '12' (retention ~365 дней).

Изменено:

  • supplier_leads +4 колонки: resolved_subject_code (CHECK 1..89), region_source (CHECK dadata/rossvyaz/tag/unknown), dadata_qc, phone_operator. Persistent-idempotency резолва (retry не повторяет DaData-вызов).
  • deals +2 колонки: phone_operator, region_substituted BOOLEAN NOT NULL DEFAULT FALSE (флаг подмены региона на запасном канале — routing_step 3).

NB консолидация: как и v8.39 (project_routing_snapshots), полный DDL живёт в дельта-миграции, а не в теле schema.sql — тело отражает последнюю точку консолидации, заголовок/CHANGELOG ведут дельты. Свежий деплой: миграция 0001 грузит schema.sql → дельта-миграция 2026_05_31 добавляет эти объекты. Иначе был бы двойной CREATE TABLE (0001 + дельта) и migrate упал бы.

NB GRANT'ы: план Task 1.3 указывал crm_readonly, но этой роли на dev/прод нет — фактические GRANT'ы выданы crm_app_user + crm_supplier_worker (проверено по pg_roles).

NB 152-ФЗ: phone_masked в логе — маскированный телефон (7XXX***YYYY), dadata_response_masked хранит ответ DaData без сырого номера (spec §7.1). Полное pg_anonymizer-маскирование — шаг раскатки (spec §7.2), вне Session 1.


v8.39 (2026-05-27) — project_routing_snapshots (Slepok routing Этап 2)

Новая партиционированная таблица снимков маршрутизации. Используется для хранения «вчерашнего заказа клиента» — зафиксированных параметров маршрутизации на момент формирования слепка поставщика (21:00 МСК). LeadRouter будет читать snapshot вместо live projects.*, чтобы клиенты не теряли оплаченные лиды после правок настроек.

Спека: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md.

Добавлено:

  • project_routing_snapshots — PARTITION BY RANGE (snapshot_date). Composite PK (snapshot_date, project_id). FK tenant_id→tenants ON DELETE CASCADE. RLS-политика project_routing_snapshots_tenant_isolation (tenant_id = current_tenant_id). Индексы: project_routing_snapshots_tenant_date_idx + project_routing_snapshots_signal_idx. GRANT SELECT/INSERT/UPDATE crm_app_user; GRANT SELECT/INSERT/UPDATE/DELETE crm_supplier_worker.
  • Initial partitions: project_routing_snapshots_y2026_m05, project_routing_snapshots_y2026_m06
  • MonthlyPartitionManager::PARTITIONED_TABLES +entry 'project_routing_snapshots' => 'snapshot_date'
  • system_settings +key partition_retention_months_project_routing_snapshots = 3 (retention 90 дней)

v8.38 (2026-05-26) — projects.paused_at + projects_paused_at_idx (Supplier Snapshot Guard)

Защита от прямого убытка Лидерры при удалении/смене источника проекта в окне между слепком поставщика (21:00 МСК) и доставкой по этому слепку. Сценарий: клиент создал проект → ушёл к поставщику в 21:00 → клиент удалил после 21:00 → поставщик утром начал слать лиды по слепку → у нас нет проекта → лиды приняты (202), сделки не созданы, баланс не списан, но поставщик в CSV выставит за них счёт.

Полная спека и тесты: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md.

Изменено:

  • projects.paused_at TIMESTAMPTZ NULL — новая колонка. Anchor для SupplierSnapshotGuard. Устанавливается в NOW() при is_active = false, сбрасывается в NULL при is_active = true.
  • CREATE INDEX projects_paused_at_idx ON projects(paused_at) — индекс для grace-проверки.

Backfill (delta-миграция): UPDATE projects SET paused_at = updated_at WHERE is_active = false AND paused_at IS NULL — для уже paused проектов, updated_at — best-effort approximation момента паузы.

Связано: app/database/migrations/2026_05_26_120000_add_paused_at_to_projects.php, app/app/Services/Project/SupplierSnapshotGuard.php, app/app/Services/Project/ProjectService.php.

v8.37 (2026-05-25) — supplier_*.platform: VARCHAR(4)→VARCHAR(8) + ENUM расширен на DIRECT

Phase 3 supplier webhook reliability — приём проектов без B[123]_ префикса как платформа DIRECT. На проде 25.05.2026 для tenant client1 зафиксировано ~67 потерянных лидов/сутки из-за того, что webhook-validation regex '^B[123]_.+$' отвергал проекты вида client.carmoney.ru, cashmotor.ru, cabinet.caranga.ru и числовые callback-IDs. Phase 3 принимает их end-to-end под новой платформой DIRECT.

Изменено:

  • supplier_projects.platform VARCHAR(4)→VARCHAR(8)DIRECT (6 символов) не вмещался.
  • project_supplier_links.platform VARCHAR(4)→VARCHAR(8) — то же.
  • supplier_leads.platform VARCHAR(4)→VARCHAR(8) — то же.
  • chk_supplier_projects_platform: IN ('B1','B2','B3')IN ('B1','B2','B3','DIRECT').
  • chk_psl_platform: то же расширение enum.
  • chk_supplier_leads_platform: то же расширение enum.

Добавлено:

  • suppliers row code='direct'DIRECT — Прямые проекты, cost_rub=1.00, accepts_types={websites,calls,sms}, channel='sites'. Используется LedgerService::resolveSupplierId fallback'ом для DIRECT-платформенных лидов.

Не изменено:

  • chk_supplier_projects_b1_not_for_sms — деноминирует B1+SMS, DIRECT+SMS не блокирует.
  • Индексы, FK, RLS-политики — без изменений.

Метрики: 0 новых таблиц, 0 новых индексов; 3 CHECK расширены, 3 колонки расширены, 1 seed-row.

Миграции:

  • 2026_05_25_120000_add_direct_platform_to_supplier_projects — DDL (idempotent через DROP+ADD CHECK).
  • 2026_05_25_120100_seed_direct_supplier — seed suppliers.code='direct' через raw SQL INSERT ON CONFLICT DO NOTHING.

Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3.

v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк

Поставщик crm.bp-gr.ru периодически кладёт телефон/URL в поле «project» CSV-выгрузки «Запрос номеров». Парсер CsvReconcileJob корректно их скипает (extractPlatform()null), но раньше эти строки попадали и в числитель count($missing), и в знаменатель total_csv_rows формулы drift'а → стабильный false-positive drift_alert ~40-50% при каждом hourly-запуске (на проде 10 запусков подряд → admin-блок «Здоровье резервного канала» показывал «down»).

Добавлено:

  • Колонка supplier_csv_reconcile_log.unparseable_count INTEGER NOT NULL DEFAULT 0 — кол-во CSV-строк за окно, у которых project не парсится в платформу B1/B2/B3.

Изменено:

  • CsvReconcileJob: считает $unparseableCount отдельно, новая формула drift_ratio = max(0, missing unparseable) / max(1, total unparseable) — только «реальные» пропуски от parseable-строк, без вклада junk'а.

Метрики: +1 колонка. (Сверять с header db/schema.sql.) Таблиц / индексов / RLS — без изменений.

Миграция: 2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log (idempotent ADD COLUMN IF NOT EXISTS на pgsql_supplier connection — Спек B pattern).

Тесты: app/tests/Feature/Supplier/CsvReconcileJobTest.php — +2 кейса (100 matched + 10 junk → status=ok / mixed 95+5junk+3real → drift по реальным). Существующие 7 кейсов — без изменений (drift при unparseable=0 идентичен старой формуле).

v8.35 (2026-05-24) — legacy direct webhook removal

Финальная уборка прямого webhook-канала (тенант → Лидерра). Вся инфраструктура канала упразднена; CSV-канал (поставщик → Лидерра) сохранён полностью.

Удалено:

  • Таблица webhook_log (partitioned RANGE по received_at) + все дочерние партиции (DROP CASCADE). Хранила payload входящих webhook от тенантов. Канал прямого приёма упразднён.
  • Таблица rejected_deals_log (регулярная) — журнал отвергнутых лидов прямого webhook-канала.
  • Колонки tenants.webhook_token + tenants.webhook_token_rotated_at — токен аутентификации прямого webhook. Индекс idx_tenants_webhook_token удалён вместе с колонкой.
  • Seed-строка low_balance_threshold_leads в system_settings — использовалась только удалённым LowBalanceNotification mailable'ом.
  • Seed-строки webhook_log_retention_days + webhook_log_retention_months в system_settings.

Оставлено (НЕ удалено):

  • webhook_dedup_keys — используется CSV-каналом (HistoricalImportService) для идемпотентности.
  • failed_webhook_jobs.webhook_log_id — orphan BIGINT (без FK с v8.31/W1); оставлен.
  • outbound_webhook_subscriptions + outbound_webhook_deliveries — исходящий webhook (тенант → внешний URL); не затронут.

Метрики: −2 таблицы / −5 индексов / −2 RLS-политики. 66 base tables (65 regular + 8 partitioned parents) / 120 indexes / 40 RLS policies.

Миграция: 2026_05_24_140000_drop_legacy_webhook_artefacts

Связанные изменения кода:

  • MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
  • PdErasureService::eraseSubject() — убрана секция erasure по webhook_log

v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index

  • −индекс deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL — телефонный дедуп удалён (Spec B), индекс больше не используется. Колонка deals.duplicate_of_id оставлена спящей (drop отдельной задачей).
  • Метрики: −1 индекс. (Сверять с header db/schema.sql.)

v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей (Phase 1)

  • +таблица supplier_lead_deliveries (PK supplier_lead_id+tenant_id, FK на supplier_leads ON DELETE CASCADE, deal_id без FK — deals партиционирована, RLS tenant_isolation). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути (RouteSupplierLeadJob). INSERT-логика будет добавлена в следующем коммите.
  • Метрики: +1 таблица, +1 RLS-политика. (Сверять с header db/schema.sql.)

История записей:

v8.32 — 2026-05-23 — balance_transactions.type +'migration'

Расширение CHECK-ограничения balance_transactions_type_check девятым значением 'migration' — технический тип для одноразовой Billing v2 Spec A конвертации legacy tenants.balance_leads в tenants.balance_rub по цене ступени 1 (artisan-команда billing:migrate-leads-to-rub). Без down() потери данных: миграция переоткрывает CHECK с тем же набором, минус 'migration'. Совместимо с партиционированием balance_transactions (v8.31): ADD/DROP CONSTRAINT на partitioned parent распространяется на партиции.

Применение:

  • Миграция: 2026_05_23_100001_extend_balance_transactions_type_for_migration
  • Константа: App\Models\BalanceTransaction::TYPE_MIGRATION
  • План: docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md (Task A.1)
  • Спек: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §3.2.3

v8.31 — 2026-05-23 — партиционирование 7 audit-таблиц (hole #2)

Закрывает дыру #2 аудита журналирования: все 7 audit-таблиц переведены на RANGE-партиционирование помесячно. Управление партициями — MonthlyPartitionManager (extended до 9 таблиц) + cron partitions:create-months + cron partitions:drop-expired (новый).

Таблицы, переведённые на партиционирование:

Таблица Partition key PK до PK после
auth_log created_at (id) (id, created_at)
activity_log created_at (id) (id, created_at)
tenant_operations_log created_at (id) (id, created_at)
webhook_log received_at (id) (id, received_at)
balance_transactions created_at (id) (id, created_at)
pd_processing_log created_at (id) (id, created_at)
saas_admin_audit_log created_at (id) (id, created_at)

FK удалены (W1):

  • failed_webhook_jobs.webhook_log_id — FK снят, колонка сохранена как BIGINT (без ссылочной целостности; composite PK партиционированной таблицы несовместим с одиночным FK-столбцом)
  • rejected_deals_log.webhook_log_id — аналогично

Partition naming format: <table>_y<YYYY>_m<MM> (пример: auth_log_y2026_m05). Применён и к ранее существующим таблицам deals / supplier_lead_costs — partition children в schema.sql переименованы.

tenant_operations_log: RLS и триггеры перенесены из inline-определения таблицы в централизованные секции (единообразно с остальными таблицами). Счётчик триггеров: 5 → 6 пар.

Retention defaults (в system_settings через migration):

  • auth_log_retention_months = 24
  • activity_log_retention_months = 36
  • tenant_operations_log_retention_months = 24
  • webhook_log_retention_months = 3
  • balance_transactions_retention_months = 84
  • pd_processing_log_retention_months = 36
  • saas_admin_audit_log_retention_months = 84

Миграция: 2026_05_23_000002_partition_audit_tables.php.

Метрики после: 74 таблицы (65 regular + 9 partitioned parents) / 125 индексов / 41 RLS / 6 пар audit-триггеров / 5 user-функций.

v8.30 — 2026-05-23 — scheduler_heartbeats (hole #6 cron heartbeat)

+1 таблица scheduler_heartbeats — SaaS-уровневый пульс всех cron-задач (дыра #6 аудита журналирования). Без RLS (не тенант-уровневая). PK = command_name VARCHAR(200).

Колонки:

  • command_name VARCHAR(200) NOT NULL PRIMARY KEY — имя команды / FQCN джоба
  • last_run_at TIMESTAMPTZ — последний запуск (любой исход)
  • last_success_at TIMESTAMPTZ — последний успешный запуск
  • last_error TEXT — последнее сообщение ошибки (до 2000 символов)
  • runtime_ms INT — время выполнения последнего запуска в мс
  • consecutive_failures INT NOT NULL DEFAULT 0 — счётчик последовательных ошибок
  • created_at / updated_at TIMESTAMPTZ DEFAULT NOW()

Индексов нет — 11 строк (по числу cron-задач), полное сканирование дешевле индекса.

Запись: UPSERT через SchedulerHeartbeatTracker::recordRunResult() / recordRun() в routes/console.php (before/after/onFailure хуки каждой cron-задачи).

Мониторинг: SchedulerCheckHeartbeats (hourly) — создаёт incidents_log + email при пропавшем пульсе (>2× ожидаемого интервала) или consecutive_failures >= 3.

Миграция: 2026_05_23_000001_create_scheduler_heartbeats_table.php. Метрики после: 67 таблиц (65 regular + 2 partitioned) / 126 индексов / 41 RLS / 15 триггеров.

v8.29 — 2026-05-22 — webhook_log: supplier audit columns

webhook_log таблица расширена для аудита входящих запросов поставщика:

  • tenant_id сделан nullable (platform-level события не имеют tenant context)
  • +4 колонки: source VARCHAR(50), status VARCHAR(50), lead_id BIGINT, ip_address INET, created_at TIMESTAMPTZ
  • +1 индекс idx_webhook_log_status(status, created_at DESC)

Колонки охватывают 4 исхода SupplierWebhookController::receive(): received (202) / rejected_secret (404) / rejected_ip (404) / rate_limited (429).

Миграция: 2026_05_22_000002_webhook_log_supplier_columns.php / db/migrations/2026_05_22_002_webhook_log_supplier_columns.sql. Метрики после: 66 таблиц / 126 индексов / 41 RLS / 15 триггеров.

v8.28 — 2026-05-22 — tenant_operations_log (P2 operational journaling)

+1 таблица tenant_operations_log — журнал тенант-уровневых операций вне сделок (проекты, API-ключи, исходящий webhook URL и т.п.). Структура параллельна activity_log, но без deal_id NOT NULL. Защищена hash-chain: триггеры audit_chain_hash() (INSERT) и audit_block_mutation() (UPDATE/DELETE → исключение). RLS tenant_isolation по current_setting('app.current_tenant_id'). +2 индекса (tenant×created + entity lookup). Миграция: 2026_05_22_000001_tenant_operations_log.php / db/migrations/2026_05_22_001_tenant_operations_log.sql. Метрики после: 66 таблиц (64 regular) / 125 индексов / 41 RLS / 15 триггеров.

v8.27 — 2026-05-21 — DROP COLUMN projects.archived_at

  • DROP COLUMN projects.archived_at — фича «архив» полностью убрана и заменена настоящим удалением с защитой по сделкам (ProjectService::delete()). Миграция 2026_05_21_000000_drop_projects_archived_at.php.

v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)

supplier_projects +1 колонка subject_code SMALLINT NULL (1..89; NULL = пул «Вся РФ»), +1 CHECK chk_supplier_projects_subject_code. Unique-индекс supplier_projects_platform_unique_key_unique (platform, unique_key) → заменён на supplier_projects_platform_key_subject_unique (platform, unique_key, subject_code) NULLS NOT DISTINCT (пул «Вся РФ» уникален per источник×платформа). Эпик: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.2. Миграция: 2026_05_20_100000_supplier_projects_subject_code.php (Schema::hasColumn + pg_constraint guards). Индексы: −1 +1 (нет дельты count). RLS не затронут (SaaS-level).

+1 таблица SaaS-level project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at): M:N замена 3 FK-слотов projects.supplier_b{1,2,3}_project_id (per-субъект модель). +2 FK (оба ON DELETE CASCADE), +1 CHECK chk_psl_platform, +1 UNIQUE uq_psl_project_supplier, +2 индекса. Без RLS (как supplier_projects). Старые FK-колонки остаются (двойная запись) до follow-up. Миграция: 2026_05_20_101000_create_project_supplier_links.php.

v8.26 (доп) — 2026-05-20 — deals.subject_code

deals +1 колонка subject_code SMALLINT NULL — субъект РФ из тега поставщика (raw_payload[tag]); отдельно от region_code (ISO, phone-derived). Наследуется 12 партициями. Миграция: 2026_05_20_102000_deals_subject_code.php.

v8.26 (доп) — 2026-05-20 — seed system_settings.supplier_export_mode

Сид-строка supplier_export_mode='batch' (тумблер режима экспорта; online|batch). Не структурное изменение. Миграция: 2026_05_20_103000_seed_supplier_export_mode.php.

v8.26 (доп) — 2026-05-20 — deals.subject_code range CHECK (defensive parity)

+1 CHECK chk_deals_subject_code на партиционированной deals (subject_code IS NULL OR BETWEEN 1 AND 89). Закрывает review-finding Plan 1 — defensive parity с chk_supplier_projects_subject_code (malformed tag → silent garbage). NOT VALID + VALIDATE (squawk-safe). Миграция: 2026_05_20_105000_deals_subject_code_check.php.

v8.25 — 2026-05-19 — supplier_manual_sync_queue (Tier 3 резерва канала миграции проектов)

+1 таблица SaaS-level (без tenant_id / RLS, как supplier_csv_reconcile_log):

  • supplier_manual_sync_queue — очередь яруса 3 резерва канала миграции проектов (spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5).
  • +3 CHECK: chk_smsq_platform (B1/B2/B3), chk_smsq_operation (create/update), chk_smsq_status (pending/resolved/cancelled).
  • +2 индекса: idx_smsq_status_created, idx_smsq_project.
  • +2 FK: project_id → projects ON DELETE CASCADE; resolved_by_user_id → users ON DELETE SET NULL.

Метрики после: 64 базовые таблицы (62 regular + 2 partitioned parents), 12 партиций, 121 индекс, 40 RLS-политик, 5 функций, 13 триггеров.

Миграция: 2026_05_19_120000_create_supplier_manual_sync_queue.php (idempotent guard через to_regclass).

v8.24 — 2026-05-18 — supplier_leads.vid → nullable

ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL. Резервный CSV-канал (Путь 2): отчёт поставщика «Запрос номеров» не содержит vid → CSV-recovered лиды имеют vid=NULL. UNIQUE-индекс idx_supplier_leads_vid_unique сохранён (в PostgreSQL NULL ≠ NULL — множественные NULL не конфликтуют). Миграция: 2026_05_18_140000_supplier_leads_vid_nullable.php. RLS не затронут.

v8.23 — 2026-05-17 — Редизайн «Сделки» (воронка статусов 14 → 5)

Изменения:

Воронка статусов 14 → 5: seed lead_statuses (new/viewed/in_progress/won/lost). Инкрементальная миграция 2026_05_17_120000_deals_funnel_14_to_5_statuses.php ремапит deals.status, tenant_status_overrides.status_slug, import_unknown_statuses.mapped_to_slug. Редизайн страницы «Сделки», спека docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md. Структурных изменений нет — только seed lead_statuses (14 → 5 строк); schema baseline без изменений (64 базовых таблиц / 12 партиций / 119 индексов / 40 RLS / 5 функций / 13 триггеров).

v8.22 — 2026-05-17 — Plan 6 (C9 — Subject-level regions)

Изменения:

  • projects +1 колонка: regions INT[] NOT NULL DEFAULT '{}'
  • projects +1 GIN-индекс: idx_projects_regions
  • projects +1 COMMENT ON COLUMN на regions

Не изменено (deprecated, удаление в Plan 6.5):

  • projects.region_mask (помечен inline-комментарием DEPRECATED)
  • projects.region_mode
  • CHECK chk_projects_region_mask_range

Семантика:

  • regions=[] → «вся РФ» (паритет с legacy region_mask=255 + region_mode='include')
  • regions=[82,83] → проект принимает лиды только из Москвы (82) и Санкт-Петербурга (83)

Schema baseline после v8.22: 64 базовых таблиц / 12 партиций / 119 индексов (+1 GIN) / 40 RLS / 5 функций / 13 триггеров.

Применение: инкрементальная миграция 2026_05_17_100000_plan6_regions_subject_level.php (ALTER TABLE projects ADD COLUMN regions + CREATE INDEX ... USING GIN, guard'ы hasColumn / IF NOT EXISTS).

Связано: docs/superpowers/specs/2026-05-14-plan-6-regions-subject-level-design.md

v8.21 — 2026-05-16 — Sprint 4 (историческая миграция лидов §6)

  • +1 таблица import_unknown_statuses (tenant-level маппинг неизвестных статусов CSV; RLS tenant_isolation; UNIQUE (tenant_id, status_ru); partial index idx_import_unknown_statuses_unresolved).
  • +5 колонок в import_log: entity_type, source_system, mapping_config, unknown_statuses_count, dry_run.
  • GRANTs: import_unknown_statuses покрыта umbrella GRANT ... ON ALL TABLES + ALTER DEFAULT PRIVILEGES (db/02_grants.sql) — явный per-table grant не требуется (как у import_log).
  • Миграция: 2026_05_16_120000_sprint4_historical_import_schema.php (guard'ы hasTable/hasColumn).

v8.20 (11.05.2026 — Plan 5)

Added:

  • projects.archived_at TIMESTAMPTZ NULL — для soft archive flow (отличие от is_active=false который = pause). Migration: app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php
  • tenants.limits JSONB NOT NULL DEFAULT '{}' — per-tenant override лимитов тарифа; используется ProjectService::create() для проверки max_projects. Migration: app/database/migrations/2026_05_11_150000_add_limits_to_tenants.php

v8.19 (2026-05-11) — Plan 4 Billing + CSV Reconcile + Admin

Изменения:

  • tenants + колонка delivered_in_month INTEGER NOT NULL DEFAULT 0 CHECK >= 0 (per-tenant счётчик для tier-lookup).
  • lead_charges + колонка charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub') + CHECK chk_lead_charges_prepaid_zero_price (prepaid → price=0).
  • supplier_leads + колонка recovered_from_csv_at TIMESTAMPTZ + partial index.
  • Новая таблица supplier_csv_reconcile_log (SaaS-level, без RLS) + 2 индекса.
  • 0 RLS-политик изменено.

Метрики: 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS-политик (без изменений).

Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md.

  • v8.18 (10.05.2026) — Plan 2/5 Task 1: подготовка слоя данных для supplier-webhook + sharing routing (spec §5–§6). Новая таблица supplier_leads (SaaS-level, без RLS) — raw-payload входящих webhook'ов от поставщика, FK на supplier_projects(id) ON DELETE SET NULL, 3 CHECK (platform enum / source enum / deals_count nonneg), 3 индекса (idx_received_at DESC + idx_supplier_project partial + UNIQUE на vid для idempotency). Новая колонка projects.delivered_today INTEGER NOT NULL DEFAULT 0 CHECK (>=0) — дневной счётчик для проверки квоты, сбрасывается cron'ом в 00:00 МСК. 2 строки в system_settings: supplier_webhook_secret (string, placeholder __SET_ON_DEPLOY__) — platform-wide секрет в URL; supplier_ip_allowlist (json, default []) — IP/CIDR поставщика. REVOKE: supplier_leads defense-in-depth (закомментирован, conditional wrapper аналогично supplier_projects). Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5–§6. Метрики: 60 → 61 базовая таблица (+1) / 111 → 114 индексов (+3) / 39 RLS-политик (без изменений — supplier_leads SaaS-level) / функции/триггеры без изменений.
  • v8.17 (10.05.2026 поздний вечер) — Plan 1/5 Task 2 fix (закрытие code-review BLOCKER#1 + WARNING#3): добавлены 3 FK constraints projects.supplier_b{1,2,3}_project_id → supplier_projects(id) ON DELETE SET NULL (заведены в v8.12 как placeholder BIGINT — FK был обещан в комментарии, но не добавлен в Task 2 commit). +3 partial индекса (idx_projects_supplier_b{1,2,3}_project_id WHERE NOT NULL) для FK lookup performance. +1 CHECK chk_projects_b1_not_for_sms (defense-in-depth: дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне — signal_type <> 'sms' OR supplier_b1_project_id IS NULL). Метрики: 60 базовых таблиц (без изменений) / 111 индексов (+3) / 39 RLS-политик (без изменений) / функции/триггеры без изменений.
  • v8.16 (10.05.2026) — Plan 1/5 Task 5: создание supplier_sync_log SaaS-level append-only audit log для AJAX-синхронизаций с поставщиком crm.bp-gr.ru. Колонки: id, supplier_project_id (nullable BIGINT, FK на supplier_projects ON DELETE SET NULL — лог переживает удаление supplier-проекта для audit-trail), action (VARCHAR(32)), request_payload (jsonb), response_body (jsonb), http_status (smallint), error_message (text), duration_ms (uint), created_at. 1 CHECK (chk_supplier_sync_log_action — action IN create/update/delete/disable/session_refresh). 3 индекса: btree на supplier_project_id (drill-down по проекту), btree на action (фильтрация по типу события), btree на created_at (timeline-запросы для алертов). НЕ tenant-scoped — события агрегатные на уровне SaaS. REVOKE ALL FROM crm_app_user (миграция оборачивает в DO $$ EXISTS-check). Используется для retry-логики, отладки rt-project-* AJAX и алертов менеджеру при failed sync. Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3. Метрики: 60 базовых таблиц (+1) / 108 индексов (+3) / RLS/функции/триггеры без изменений.
  • v8.15 (10.05.2026) — Plan 1/5 Task 4: создание lead_charges append-only ledger списаний за каждый доставленный лид. Колонки: id, tenant_id, deal_id, deal_received_at, tier_no (smallint), price_per_lead_kopecks (uint), charged_at, created_at. Composite FK lead_charges_deals_fk(deal_id, deal_received_at) → deals(id, received_at) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED — DEFERRABLE обязательно для атомарного INSERT deal+charge в одной транзакции (deals партиционирована, обычный FK не работает на партиционированную с composite ключом). FK на tenants(id) ON DELETE CASCADE. 2 индекса: btree (tenant_id, charged_at) для отчётов клиенту, btree (deal_id, deal_received_at) для drill-down по сделке. Tenant-scoped — RLS tenant_isolation ENABLE+FORCE с USING+WITH CHECK на current_setting('app.current_tenant_id')::bigint. Append-only гарантия для биллинга/аудита: GRANT SELECT, INSERT (без UPDATE/DELETE) для tenant-приложения через crm_app_user (миграция оборачивает в DO $$ EXISTS-check для совместимости с dev без роли). Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4. Метрики: 59 базовых таблиц (+1) / 105 индексов (+2) / 39 RLS-политик (+1) / функции/триггеры без изменений.
  • v8.14 (10.05.2026) — Plan 1/5 Task 3: создание pricing_tiers SaaS-level таблицы для конфигурации 7-ступенчатого объёмного тарифа (volume billing). Колонки: id, tier_no (smallint 1..7), leads_in_tier (uint nullable; NULL = «всё свыше» для последней ступени), price_per_lead_kopecks (uint, копейки integer — избегаем floating-point округлений в money-расчётах; 1 руб = 100 коп.), is_active (default true), effective_from (date), timestamps. 1 CHECK constraint (chk_pricing_tiers_tier_notier_no BETWEEN 1 AND 7). 2 индекса: UNIQUE на (tier_no, effective_from), btree на (is_active, effective_from). НЕ tenant-scoped — конфигурация админом Лидерры; RLS НЕ применяется. Per-tenant override out of scope для MVP (один тариф на всю Лидерру). SELECT-only для tenant-приложения: REVOKE ALL FROM crm_app_user + GRANT SELECT TO crm_app_user (миграция оборачивает оба в DO $$ EXISTS-check для совместимости с dev без роли). Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.2. Метрики: 58 базовых таблиц (+1) / 103 индекса (+2) / RLS/функции/триггеры без изменений.
  • v8.13 (10.05.2026) — Plan 1/5 Task 2: создание supplier_projects SaaS-level агрегатной таблицы для проектов у поставщиков B1/B2/B3. Колонки: id, platform (B1/B2/B3), signal_type (site/call/sms), unique_key (TEXT — domain/phone/sender+keyword/sender), supplier_external_id, current_limit (uint, default 0), current_workdays (jsonb), current_regions (jsonb), sync_status (pending/ok/failed), last_synced_at, inactive_since, timestamps. 4 CHECK constraints (chk_supplier_projects_platform, chk_supplier_projects_signal_type, chk_supplier_projects_sync_status, chk_supplier_projects_b1_not_for_sms — B1 не поддерживает СМС). 3 индекса: UNIQUE на (platform, unique_key), btree на sync_status, btree на inactive_since. НЕ tenant-scoped — sharing-model между Лидерра-tenant'ами; RLS НЕ применяется. Defense-in-depth: REVOKE ALL FROM crm_app_user (миграция оборачивает в DO $$ EXISTS-check для совместимости с dev без роли). Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.2. Метрики: 57 базовых таблиц (+1) / 101 индекс (+3) / RLS/функции/триггеры без изменений.
  • v8.12 (10.05.2026) — Plan 1/5 Task 1: расширение projects для supplier integration. +signal_type (enum site/call/sms), +signal_identifier (text), +sms_senders (jsonb array), +sms_keyword (nullable text), +delivered_in_month (uint), +supplier_b{1,2,3}_project_id (nullable BIGINT placeholder, FK добавятся в Task 2 после создания supplier_projects). 3 CHECK constraints (signal_type enum; sms_senders required for sms; signal_identifier required for site/call) + 1 composite index idx_projects_tenant_signal(tenant_id, signal_type, signal_identifier). Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1.
  • v8.11 (09.05.2026) — hygiene-фиксы аудита 2026-05-09: P0-02 RLS на impersonation_tokens + O-perf-02/03 индексы FK-колонок webhook_log_id на failed_webhook_jobs и rejected_deals_log. См. ниже §S.
  • v8.10 (09.05.2026)in_app_notifications таблица для bell-icon UI (P0 этап 2): event/title/body/payload/read_at + RLS tenant isolation + 2 индекса (unread + recent). См. ниже §T.
  • v8.9 (09.05.2026) — bulk soft-delete для UI applyBulkDelete: deals.deleted_at TIMESTAMPTZ (NULL = живая сделка) + partial index (tenant_id, status) WHERE deleted_at IS NULL. См. ниже §U.
  • v8.8 (09.05.2026)users.totp_secret тип VARCHAR(255)TEXT. Encrypted 32-байт TOTP secret после Crypt::encryptString = 256 chars (>255), php artisan tinker показал runtime PDOException на confirm wizard. См. ниже §V.
  • v8.7 (08.05.2026 поздний вечер) — CTO-17 addendum: FK webhook_dedup_keys → deals стал DEFERRABLE INITIALLY DEFERRED. См. ниже §W.
  • v8.6 (08.05.2026 поздний вечер) — CTO-17: webhook_dedup_keys взамен UNIQUE на партиционированной deals. См. ниже §X.
  • v8.5 (07.05.2026) — реализация 27 решений аудита C (Открытые_вопросы v1.12). См. ниже §Y.
  • v8.4 (06.05.2026) — синхронизация с narrative §19.10 (outbound webhook). См. ниже §Z.
  • v8.3 (05.05.2026) — после параллельного аудита crm.bp-gr.ru, партии 1215. См. ниже §A.
  • v8.2 (04.05.2026) — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.

Связано:

  • Прил_М_Analiz_originala_v8_3.md v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.13.3).
  • Открытые_вопросы_v8_3.md v1.12 — закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.
  • README_АРХИВ_v8_4.md — состав архива.
  • CRM_bp-gr_Инструкция_v8_4.md v8.4 §19.10 — outbound webhook (источник изменений v8.4, финал 06.05.2026).
  • CRM_bp-gr_Инструкция_v8_5.md (готовится) — narrative-обоснование v8.5 для §10/§12.5.5/§14/§17/§19.10/§22/§23.10/Прил.И.

Замечание о нумерации: внутри каждой записи разделы пронумерованы с префиксом записи (Y.0, Y.1, …, Z.0, Z.1, …, A.0, A.1, …, B.0, B.1, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация ## 0, ## 1 исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).


Запись S — v8.10 → v8.11 (09.05.2026) — hygiene-фиксы аудита

Источник: docs/audit_2026-05-09.md (commit b6ae8dd).

S.1. Изменения

  1. P0-02: Добавлены ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY и CREATE POLICY tenant_isolation ON impersonation_tokens (схема ~строка 540).
  2. O-perf-02: Добавлен индекс idx_failed_webhook_jobs_log на failed_webhook_jobs(webhook_log_id).
  3. O-perf-03: Добавлен индекс idx_rejected_deals_log_webhook на rejected_deals_log(webhook_log_id).

S.2. Метрики (после v8.11)

  • 56 базовых таблиц + 12 партиций (без изменений, 68 CREATE TABLE)
  • 97 индексов (было 95, +2)
  • 38 RLS-политик (было 37, +1 = tenant_isolation на impersonation_tokens)
  • 5 функций, 13 триггеров (без изменений)

S.3. Применение

Для нового стенда: cd app && php artisan migrate:fresh. Для существующих данных — ALTER TABLE + CREATE POLICY + CREATE INDEX CONCURRENTLY тремя раздельными DDL.


Запись T — v8.9 → v8.10 (09.05.2026)

Источник изменений: этап 2 (этап 2a) плана P0 «Notification delivery». Email-канал реализован в v1.65 (этап 1), но in-app канал (bell-icon в AppLayout) требует persistence: при триггере события (new_lead/reminder/...) запись в БД, чтобы UI мог:

  1. показывать unread-счётчик у иконки колокольчика (даже если user'а нет в момент события);
  2. накапливать историю «10 последних» при заходе на страницу;
  3. сохранять прочитанные/непрочитанные между сессиями.

Что изменилось:

  1. Новая таблица in_app_notifications (после reminders в schema, оба про работу/коммуникации):

    CREATE TABLE in_app_notifications (
        id           BIGSERIAL PRIMARY KEY,
        tenant_id    BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
        user_id      BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        event        VARCHAR(50) NOT NULL,        -- new_lead|reminder|...
        title        VARCHAR(255) NOT NULL,
        body         TEXT,
        deal_id      BIGINT,                       -- БЕЗ FK (deals партиционирована)
        payload      JSONB DEFAULT '{}'::jsonb,    -- доп. поля для UI
        read_at      TIMESTAMPTZ,
        created_at   TIMESTAMPTZ DEFAULT NOW()
    );
    
  2. Индекс idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL — основной UI-флоу «непрочитанные user'а».

  3. Индекс idx_in_app_notifications_user_recent (user_id, created_at DESC) — «последние 50» (с прочитанными).

  4. RLS tenant_isolation на in_app_notifications (стандартная политика по current_setting('app.current_tenant_id')).

Почему отдельная таблица, а не Laravel notifications:

  • Laravel default notifications — generic morphable, не tenant-scoped, нет нашей RLS-обёртки;
  • наша event-матрица фиксирована (8 событий из users.notification_preferences), generic-морфы избыточны;
  • удобнее JOIN'ить по deal_id для UI-link (deep-link на DealDetailDrawer).

Backend changes (отдельный коммит):

  • App\Models\InAppNotification — Eloquent с payload cast array, read_at cast datetime.
  • NotificationService::notifyInApp(User $user, string $event, array $opts) — INSERT row с применением пользовательских prefs (notification_preferences[event].inapp=true).
  • notifyNewLead теперь шлёт ДВА канала: email (если prefs.email=true) И in-app (если prefs.inapp=true). По schema-default new_lead.inapp=true — большинство получит in-app, и только подписавшиеся — email.
  • INSERT в in_app_notifications обёрнут в транзакцию с SET LOCAL app.current_tenant_id для RLS-WITH CHECK (USING/WITH CHECK симметричны без явного WITH CHECK).

Frontend changes (этап 2b, отдельный коммит):

  • API endpoints (GET /api/notifications + PATCH /api/notifications/{id}/read + PATCH /mark-all-read).
  • Pinia store useNotificationsStore с polling 30 сек для unread-count.
  • Bell-icon в AppLayout.topbar с pip + v-menu для последних 10.

Миграция production-БД:

CREATE TABLE in_app_notifications (...);  -- см. выше
CREATE INDEX CONCURRENTLY idx_in_app_notifications_user_unread ...;
CREATE INDEX CONCURRENTLY idx_in_app_notifications_user_recent ...;
ALTER TABLE in_app_notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = current_setting('app.current_tenant_id')::bigint);

DOWN: DROP TABLE in_app_notifications (необратимо без архива истории, на MVP допустимо).

Метрики после v8.10: 56 таблиц + 12 партиций + 95 индексов (+2 от 93) + 37 RLS (+1 от 36) + 5 функций + 13 триггеров.


Запись U — v8.8 → v8.9 (09.05.2026)

Источник изменений: этап 5/5 авто-плана — backend-persistence для UI-операции applyBulkDelete в DealsView. До этого изменения bulk-delete выполнялся только локально (mutation dealsState.splice без API-вызова), при reload-btn удалённые сделки возвращались.

Что изменилось:

  1. deals.deleted_at TIMESTAMPTZ — новая колонка. NULL = живая сделка, not null = soft-deleted (момент удаления).
  2. CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL — partial index по самому частому UI-фильтру (DealsView::index скрывает удалённые).

Почему soft-delete (не hard):

  • Партиционированная deals имеет CASCADE-FK от webhook_dedup_keys (через composite-FK (deal_id, deal_received_at)). Hard-delete пройдёт CASCADE и удалит dedup-keys → следующий webhook с тем же vid создаст дубль (нарушение идемпотентности §5.5).
  • Soft-delete сохраняет dedup-keys и позволяет restore-flow (отдельный endpoint POST /api/deals/{id}/restore — отдельный коммит).
  • applyBulkDelete в UI: «Удалить N сделок» с двойным подтверждением. На production — soft-delete + email-уведомление tenant'у.
  • UX-pattern: на API-fail локальный update НЕ откатывается (как у applyBulkStatus в v1.52) — пользователь видит что хотел, перезагрузит позже.

Backend changes (DealController):

  • index / show / transition / update / export все добавили whereNull('deleted_at') фильтр. Soft-deleted скрыты от регулярных flow.
  • destroy — новый endpoint DELETE /api/deals?tenant_id=X&ids[]=...: bulk-update deleted_at=NOW() через RLS + defense-in-depth where(tenant_id). Запись ActivityLog event=deal.deleted для каждой удалённой сделки.

Frontend changes:

  • dealsApi.bulkDeleteDeals(payload) — DELETE helper.
  • DealsView::applyBulkDelete async: optimistic local-removal + backend-вызов если auth.user; на success — toast «Удалено N»; на fail — warning toast (без auto-rollback — UX-pattern как в bulk-status).

Миграция production-БД:

ALTER TABLE deals ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE INDEX CONCURRENTLY ON deals (tenant_id, status) WHERE deleted_at IS NULL;

ALTER TABLE на партиционированной deals distributes колонку во все партиции автоматически (PG 14+). CONCURRENTLY для index — без блокировки production-таблицы.


Запись V — v8.7 → v8.8 (09.05.2026)

Источник изменений: реализация 2FA setup wizard (AuthController::useRecoveryCode + TwoFactorSetupController::confirm) поймал PDOException String data, right truncated: 7 ... character varying(255) при сохранении encrypted TOTP secret.

Корневая причина:

  • Google2FA::generateSecretKey() возвращает 32-символьный base32-secret.
  • Eloquent cast 'encrypted' через Crypt::encryptString упаковывает в JSON {iv,value,mac,tag} base64 → длина около 256 chars для 32-байт исходника.
  • Schema v8.7 имеет users.totp_secret VARCHAR(255) — не вмещается.

Изменение:

-- До
totp_secret     VARCHAR(255),                          -- ШИФРУЕТСЯ Crypt::encrypt
-- После
totp_secret     TEXT,                                  -- ШИФРУЕТСЯ Crypt::encrypt (encrypted ~256 chars > VARCHAR(255))

saas_admin_users.totp_secret_enc уже был TEXT в v8.5 (§22.4.2 / Прил. Г.4.2 — encrypted) — теперь users.totp_secret приведена к тому же паттерну.

Деплой:

ALTER TABLE users ALTER COLUMN totp_secret TYPE TEXT;

Данных в production пока нет (фаза 2 на dev), миграция безболезненная. На dev прогнан php artisan migrate:fresh для liderra и liderra_testing.

Связано:

  • CLAUDE.md v1.40 — schema v8.7 → v8.8 в §0/§2.
  • app/app/Models/User.php — добавлен cast 'totp_secret' => 'encrypted'.
  • app/app/Http/Controllers/Api/TwoFactorSetupController.php — новый wizard.

Запись W — v8.6 → v8.7 (08.05.2026 поздний вечер)

Источник изменений:

  • CTO-17 addendum (фаза 1, Webhook PoC) — при имплементации App\Jobs\ProcessWebhookJob по спецификации narrative §5.5 v8.6 Pest-тест поймал FK violation:
    • SQLSTATE[23503]: Foreign key violation … webhook_dedup_keys_deal_id_deal_received_at_fkey … Ключ (deal_id, deal_received_at)=(N, …) отсутствует в таблице "deals".
    • §5.5 v8.6 спецификация: INSERT в webhook_dedup_keys через nextval('deals_id_seq') ДО INSERT в deals. Без DEFERRABLE FK проверяется immediate — нарушается до момента INSERT в deals.

Эволюция решения (две стадии):

  1. Стадия 1 — DEFERRABLE INITIALLY DEFERRED FK (что попало в schema.sql v8.7). Каноничный PG-паттерн для composite FK с child-first INSERT'ом в одной транзакции. В bare-транзакции (production worker) — работает: constraint проверяется на COMMIT.
  2. Стадия 2 — pivot на advisory lock (что попало в production-код). При запуске Pest с DatabaseTransactions trait DEFERRED FK всё равно падает: PG проверяет deferred constraints на RELEASE SAVEPOINT (внутренняя DB::transaction() Job'а становится savepoint при наличии outer-txn от теста), не на outer COMMIT. Это PG-семантика subtransactions, не Laravel-bug. Воспроизводится stand-alone PHP-скриптом.

Финальный архитектурный паттерн (v8.7 production code):

App\Jobs\ProcessWebhookJob::upsertDeal():

$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);

$existing = DB::selectOne(
    'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
    [$tenant->id, $sourceCrmId],
);

if ($existing !== null) {
    // UPDATE deal по composite-ключу
} else {
    // INSERT deal первым (FK immediate OK), затем INSERT dedup_key
}

Альтернативы отброшены:

  • Reverse INSERT order (deals → dedup_keys) без advisory lock — race condition между concurrent webhook'ами с одинаковым vid: оба INSERT'ят deal, второй получает unique violation на dedup_key и оставляет orphan deal. Требует cleanup-логики, может упасть при retry.
  • SELECT FOR UPDATE на dedup_keys — race condition (FOR UPDATE на несуществующей строке не блокирует).
  • Advisory lock покрывает оба сценария: serialization для одинакового vid, lock авто-освобождается на COMMIT/ROLLBACK.

Schema-изменение (v8.7):

CREATE TABLE webhook_dedup_keys (
    ...
    FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at)
        ON DELETE CASCADE
        DEFERRABLE INITIALLY DEFERRED  -- v8.7
);

DEFERRABLE сохранён как defense-in-depth — позволяет альтернативные паттерны записи в production-коде без savepoints (например, batch-импорт CSV в одной транзакции). ON DELETE CASCADE по-прежнему срабатывает immediate на DELETE строки в deals.

Импакт:

  • db/schema.sql:1315-1325 — единственная DDL-правка (DEFERRABLE INITIALLY DEFERRED + блок-комментарий).
  • Метрики schema без изменений: 55 таблиц + 12 партиций, 92 индекса, 36 RLS-политик, 36 ENABLE RLS, 5 функций, 13 триггеров.
  • Narrative ТЗ §11 (DDL webhook_dedup_keys) обновлён — добавлен DEFERRABLE + объяснение defense-in-depth.
  • Narrative ТЗ §5.5 (PHP-код) обновлён — переписан на advisory lock + INSERT-deal-первым.
  • Narrative ТЗ §6.5 (CSV-импорт) и §2.4 (поток) обновлены — упоминают advisory lock.

Проверка (08.05.2026 поздний вечер):

  • php artisan migrate:fresh --env=testing на liderra_testing БД — прошёл.
  • Pest 31/31 на полном test suite (DealModelTest 6 + ProcessWebhookJobTest 6 + RlsSmokeTest 4 + TenantModelsTest 8 + SetTenantContextTest 5 + ExampleTest 2) — все зелёные.
  • Всё backward-compat: dev-БД liderra пересоздана через тот же migrate:fresh.

Связано:

  • Открытые_вопросы_v8_3.md v1.21 — блок «CTO-17 addendum» (08.05.2026 поздний вечер).
  • CRM_bp-gr_Инструкция_v8_5.md §11 (in-place hygiene) — DDL webhook_dedup_keys синхронизирован с DEFERRABLE.
  • CLAUDE.md v1.12 — schema v8.6 → v8.7 в §0.

Запись X — v8.5 → v8.6 (08.05.2026 поздний вечер)

Источник изменений:

  • CTO-17 (фаза 1, реальный запуск миграции) — переоткрытие schema.sql v8.5 после первой попытки php artisan migrate:fresh на dev-БД liderra. PostgreSQL 16 отклонил DDL CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL (schema.sql:1263 v8.5):
    • SQLSTATE[0A000]: Feature not supported: 7 ОШИБКА: ограничение уникальности в секционированной таблице должно включать все секционирующие столбцы. DETAIL: В ограничении UNIQUE таблицы "deals" не хватает столбца "received_at", входящего в ключ секционирования.
    • PostgreSQL требует, чтобы UNIQUE на партиционированной таблице включал все partition key columns. deals партиционируется по received_at.
  • Включить received_at в UNIQUE нельзя — ломает идемпотентность webhook'ов от crm.bp-gr.ru. Тот же vid при retry с разным timestamp создаст дубль вместо UPDATE существующей сделки. Это противоречит ТЗ §15 («Используется для идемпотентности») и §16 (ON CONFLICT (tenant_id, source_crm_id) DO UPDATE).

Решение (08.05.2026, заказчик: «архитектурный фикс»):

Идемпотентность вынесена в отдельную не-партиционированную таблицу webhook_dedup_keys:

Поле Тип Назначение
tenant_id BIGINT NOT NULL + source_crm_id = идемпотентный ключ webhook'а
source_crm_id BIGINT NOT NULL vid из webhook crm.bp-gr.ru
deal_id BIGINT NOT NULL ссылка на сделку
deal_received_at TIMESTAMPTZ NOT NULL partition key для FK на партиционированную deals
created_at, updated_at TIMESTAMPTZ стандартный аудит
  • PRIMARY KEY: (tenant_id, source_crm_id) — обеспечивает глобальную идемпотентность независимо от партиционирования deals.
  • FOREIGN KEY: (deal_id, deal_received_at) REFERENCES deals (id, received_at) ON DELETE CASCADE — composite FK на partitioned-таблицу, корректно работает на PG 16.
  • INDEX: idx_webhook_dedup_keys_deal (deal_id, deal_received_at) для обратного lookup'а из deal к dedup-ключу.
  • RLS: tenant_isolation USING + WITH CHECK по tenant_id = current_setting('app.current_tenant_id')::bigint. Defense-in-depth поверх FK.

Изменение в deals:

  • CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULLCREATE INDEX (без UNIQUE). Индекс остаётся для скорости lookup'а master-сделок (Биз-19 dedup query 24-часового окна).

Изменение webhook handler logic (narrative ТЗ §15-16):

  • Было: INSERT INTO deals ... ON CONFLICT (tenant_id, source_crm_id) DO UPDATE (одна транзакция, одна вставка).
  • Стало: двустадийная операция в одной транзакции:
    1. INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at) VALUES (...) ON CONFLICT (tenant_id, source_crm_id) DO UPDATE SET deal_received_at = EXCLUDED.deal_received_at, updated_at = NOW() RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at;
    2. Если is_new = trueINSERT INTO deals ... с pre-allocated deal_id (через nextval('deals_id_seq')); иначе UPDATE deals SET ... WHERE id = :deal_id AND received_at = :deal_received_at.
  • Trade-off: +1 запрос на webhook. На MVP-нагрузке (≤10 RPS на тенанта) незначимо. На высоких нагрузках можно оптимизировать через CTE-комбо.

Метрики schema.sql:

Метрика v8.5 v8.6 Δ
Таблицы 54 55 +1 (webhook_dedup_keys)
Партиции 12 12
Индексы 91 92 +1 (idx_webhook_dedup_keys_deal)
RLS-политики 35 36 +1 (tenant_isolation ON webhook_dedup_keys)
ENABLE RLS 35 36 +1
Роли БД 4 4
Триггеры 12 12
Функции 4 4

Затронутые файлы:

  • db/schema.sql: заголовок v8.5→v8.6, строки ~1263 (CREATE INDEX без UNIQUE), новый блок webhook_dedup_keys после партиций deals (~1283), ALTER TABLE webhook_dedup_keys ENABLE RLS (§12), CREATE POLICY tenant_isolation ON webhook_dedup_keys (§12).
  • db/CHANGELOG_schema.md: запись X (этот блок).
  • docs/CRM_bp-gr_Инструкция_v8_5.md: §15 + §16 — обновить SQL-примеры с ON CONFLICT (tenant_id, source_crm_id) DO UPDATE на двустадийную логику. Техдолг следующих сессий (см. реестр Открытых вопросов CTO-17).
  • docs/Открытые_вопросы_v8_3.md: добавить запись CTO-17 (закрыт фиксом, но техдолг по narrative).

Совместимость:

  • БД v8.5 → v8.6: для пустой БД (dev) — migrate:fresh. Для production-БД с данными миграция не применима (но v8.5 на production ещё не разворачивался — это первая live-проверка). На v8.5-dev записей deals 0 — нет потери данных.

Запись Y — v8.4 → v8.5 (07.05.2026)

Источник изменений:

  • Закрытие 27 вопросов аудита C от 07.05.2026 (Открытые_вопросы v1.12, §13.10). Решение заказчика: «A везде» (рекомендованные варианты).
  • 8 P0 разблокированы для триггера фазы 1 (composer create-project laravel/laravel app): Биз-17/18/19, CTO-13, OPEN-И-13/14/15/16.
  • 12 P1 + 7 P2 — реализация в фазах 1–3.

Y.0. Сводка

Параметр v8.4 v8.5 Δ
Логических таблиц 53 54 +1 (project_user_assignments)
Партиций 12 12 =0
Индексов 86 91 +5
RLS-политик 34 35 (+ WITH CHECK на 2 существующих) +1 (project_user_assignments)
Защищённых таблиц (ENABLE) 34 35 +1
Ролей БД 3 4 +1 (crm_audit_writer)
Триггеров 0 12 +12
Функций 0 4 +4
Колонок (приращение) +26 +26 (см. §Y.2)
Полей в tenants 23 25 +2 (api_key_limit, telegram_bot_token)

Y.1. Новые таблицы

Y.1.1. project_user_assignments (CTO-16)

M2M-связь «проект ↔ менеджеры» с per-assignment skills (JSONB-массив). На MVP при projects.assignment_strategy='manual' (Биз-17 default) таблица не используется. После Post-MVP cron lead-router читает active members проекта и распределяет лидов согласно стратегии.

Поля: project_id, user_id, skills JSONB, is_active, created_at, updated_at. PK = (project_id, user_id).

Индекс: idx_project_user_assignments_user partial WHERE is_active=TRUE.

RLS: tenant_isolation через JOIN на projects.tenant_id (USING + WITH CHECK).

Y.2. Новые колонки

Y.2.1. P0-блок (8)

  • projects.assignment_strategy (Биз-17) VARCHAR(32) NOT NULL DEFAULT 'manual' + CHECK IN ('manual','round_robin','least_loaded'). MVP = manual; round_robin/least_loaded зарезервированы для Post-MVP.
  • projects.ttfr_target_minutes (Биз-18) INT NOT NULL DEFAULT 15 + CHECK BETWEEN 1 AND 1440. SLA по Time To First Response, alert при просрочке.
  • deals.duplicate_of_id (Биз-19) BIGINT (без FK — партиционированная таблица). Окно дедупа 24 ч, проверяется приложением через индекс (tenant_id, phone).
  • deals.escalated_count (OPEN-И-25) INT NOT NULL DEFAULT 0 + CHECK >= 0. Счётчик переназначений cron'ом leads:escalate-stale.
  • deals.assigned_at (OPEN-И-25) TIMESTAMPTZ. Момент назначения менеджера.
  • saas_admin_users.sso_provider (OPEN-И-13) VARCHAR(32) NOT NULL DEFAULT 'yandex360' + CHECK IN ('yandex360','local').
  • saas_admin_users.is_break_glass (OPEN-И-13) BOOLEAN NOT NULL DEFAULT FALSE. Аварийный аккаунт при недоступности IDP.
  • auth_log.log_hash, activity_log.log_hash, pd_processing_log.log_hash, saas_admin_audit_log.log_hash, balance_transactions.log_hash (OPEN-И-15) BYTEA — заполняется триггером audit_chain_hash() (см. §Y.4.1).

Y.2.2. P1-блок (12)

  • tenants.api_key_limit (OPEN-И-19) INT NOT NULL DEFAULT 5 + CHECK BETWEEN 1 AND 10.
  • tenants.telegram_bot_token (Биз-20) TEXT (зашифровано Crypt::encryptString).
  • users.telegram_user_id (Биз-20) BIGINT (Telegram chat_id).
  • impersonation_tokens.second_approver_id (CTO-15 + Ю-9) BIGINT REFERENCES saas_admin_users(id).
  • impersonation_tokens.second_approval_at (CTO-15 + Ю-9) TIMESTAMPTZ.
  • deals.utm_source/utm_medium/utm_campaign/utm_content (CTO-14) 4 × VARCHAR(100).
  • suppliers.quality_score (Биз-22) NUMERIC(3,2) NOT NULL DEFAULT 1.00 + CHECK BETWEEN 0.00 AND 9.99.
  • deals.time_in_form_seconds (Биз-22) INT. Сколько секунд физлицо заполняло форму.
  • deals.lead_score (Биз-22) NUMERIC(5,2) (заполняется триггером calc_lead_score(), см. §Y.4.2).

Y.2.3. P2-блок (7) — 2 новых колонки + остальные через DDL/триггеры

  • deals.region_code (Биз-23) VARCHAR(8). ISO 3166-2:RU; автоопределение по prefix phone в PhonePrefixService.
  • deals.city (Биз-23) VARCHAR(100). Свободный текст из webhook/enrichment.

(Остальные P2-решения реализованы через DDL-блоки: триггеры/функции в §Y.4, закомментированный задел call_recordings для Биз-12/OPEN-И-26 — в новой секции 17 schema.sql.)

Y.2.4. ALTER (1)

  • api_keys.expires_at (OPEN-И-17 + OPEN-И-19): из NULL-able без default → NOT NULL DEFAULT NOW() + INTERVAL '365 days'. Миграция: backfill всем существующим NULL-ключам NOW() + 365d перед применением SET NOT NULL.

Y.3. Новые индексы (5)

  • (tenant_id, utm_source) WHERE utm_source IS NOT NULL на deals (CTO-14). Для когортной аналитики §12.5.5.
  • (tenant_id, region_code) WHERE region_code IS NOT NULL на deals (Биз-23). Для гео-фильтра в §10.3.
  • (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL на deals (Биз-19). Для UI-цепочки дублей и cleanup при удалении master'а.
  • (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected') на deals (OPEN-И-25). Для cron leads:escalate-stale.
  • idx_project_user_assignments_user(user_id) WHERE is_active = TRUE (CTO-16). Для «список моих назначений».

Индекс (tenant_id, phone, received_at) для Биз-19 24ч-lookup НЕ создан — существующий (tenant_id, phone) справляется с фильтрацией приложением (24-часовое окно — мелкая выборка).

Y.4. Новые функции и триггеры

Y.4.1. audit_chain_hash() + audit_block_mutation() + 10 audit-триггеров (OPEN-И-15)

audit_chain_hash() RETURNS TRIGGER:
  prev_hash := SELECT log_hash FROM TG_TABLE_NAME ORDER BY id DESC LIMIT 1;
  NEW.log_hash := digest(COALESCE(prev_hash, '') || NEW::text::bytea, 'sha256');
  RETURN NEW;

audit_block_mutation() RETURNS TRIGGER:
  RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME;

На каждой из 5 audit-таблиц по 2 триггера: BEFORE INSERT (hash chain) + BEFORE UPDATE OR DELETE (block mutation). Итого 10 триггеров.

Юридический эффект: любая модификация audit-журнала после INSERT'а технически запрещена. При попытке INSERT с поддельной строкой между существующими — пересчёт цепочки в cron audit:verify-chain обнаружит разрыв (sha256-mismatch).

Защита от ALTER TABLE … DISABLE TRIGGER: даже при отключении триггеров роль crm_audit_writer (см. §Y.5) имеет только INSERT — UPDATE/DELETE заблокированы на уровне permissions.

Y.4.2. calc_lead_score() + триггер trg_deals_calc_lead_score (Биз-22)

calc_lead_score() RETURNS TRIGGER:
  IF NEW.time_in_form_seconds IS NULL  lead_score := NULL; RETURN.
  quality := SELECT s.quality_score FROM project_suppliers ps JOIN suppliers s
             WHERE ps.project_id = NEW.project_id AND ps.is_active AND s.is_active
             ORDER BY s.sort_order, s.id LIMIT 1;
  NEW.lead_score := LEAST(quality * (time_in_form_seconds / 60.0), 99.99);

Триггер BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals. Использован триггер вместо GENERATED ALWAYS AS … STORED потому что PostgreSQL не разрешает в STORED-выражениях JOIN на foreign tables.

Y.4.3. report_jobs_log_export() + триггер trg_report_jobs_export_log (OPEN-И-20)

AFTER INSERT ON report_jobs → INSERT INTO pd_processing_log (action='exported', purpose='report_job_<id>'). Закрывает риск пропуска audit-записи при экспорте лидов из app-кода.

Y.5. Новая роль crm_audit_writer (OPEN-И-15 + OPEN-И-23)

CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
GRANT USAGE ON SCHEMA public;
GRANT INSERT ON auth_log, activity_log, pd_processing_log,
                saas_admin_audit_log, balance_transactions;
GRANT USAGE ON sequences соответствующих таблиц.
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.

Application пишет в audit-таблицы под этой ролью через temporary SET ROLE crm_audit_writer, что обеспечивает невозможность fraud-удаления записей даже от super_admin SaaS.

Y.6. RLS WITH CHECK (OPEN-И-14)

Существующие политики tenant_isolation на двух таблицах обогащены WITH CHECK:

  • saas_invoice_items — нельзя вставить invoice_item ссылающуюся на чужой invoice.
  • deal_tag_pivot — нельзя пометить deal чужим тегом.

До v8.5 защита была только на USING (SELECT/UPDATE filter), INSERT мог пройти при наличии knowledge о tag_id чужого тенанта.

Новая политика project_user_assignments создана с обоими USING + WITH CHECK сразу.

Y.7. REVOKE ALL на 6 saas-таблицах (OPEN-И-14)

Defense-in-depth: к этим таблицам tenant-приложение (роль crm_app_user) доступа НЕ должно иметь даже теоретически.

REVOKE ALL ON saas_admin_users        FROM crm_app_user;
REVOKE ALL ON saas_admin_sessions     FROM crm_app_user;
REVOKE ALL ON saas_admin_audit_log    FROM crm_app_user;
REVOKE ALL ON incidents_log           FROM crm_app_user;
REVOKE ALL ON pd_subject_requests     FROM crm_app_user;
REVOKE ALL ON impersonation_tokens    FROM crm_app_user;

Y.8. Изменения, не отражённые в schema (только в narrative)

  • CTO-13 (e2e-тест SET LOCAL через PgBouncer transaction-pooling) — план в narrative §22 + Прил. И; без DDL.
  • OPEN-И-16 (Sentry whitelist + regex) — конфигурация Laravel config/sentry.php before_send; без DDL.
  • OPEN-И-18 (DNS-rebinding защита resolve→pin→connect) — реализация в App\Services\Webhook\SSRFGuard; без DDL.
  • OPEN-И-21 (Anti-DDoS: Nginx + Yandex SmartCaptcha + disposable-blacklist) — конфиг Nginx + Laravel middleware; без DDL.
  • OPEN-И-22 (per-tenant DEK Yandex KMS) — на уровне backup-сервиса (Прил. И); без DDL.
  • OPEN-И-24 (pg_anonymizer процедура) — Прил. И, расширение PG ставится в фазе 3 (Прил. Н).
  • Биз-20 Telegram-канал — спринт 9, реализация в фазе 2; DDL уже добавлен (telegram_user_id, telegram_bot_token), используется по факту с фазы 2.
  • Биз-21 generic outbound marketing.conversion — расширение whitelist событий в App\Services\Outbound\EventTypes; без DDL (хранится в outbound_webhook_subscriptions.events).

Y.9. Что НЕ добавлено в v8.5 (отложено)

  • Таблицы crm_connections / crm_field_mappings (Уровень 2 OPEN-И-2) — спринты 1415 (как и в v8.4).
  • Структуры под Биз-12 (телефония + call recording) — call_recordings оставлена закомментированным заделом в секции 17 schema.sql; реальная активация Post-MVP при первом запросе клиента.
  • Расширения PG pg_partman/pgaudit/pg_anonymizer — фаза 3 по Прил. Н.

Y.10. Совместимость

  • Forward-only: v8.5 разворачивается с нуля и не требует миграции с v8.4 (база ещё не в production — фаза 0).
  • Будущая прод-миграция (после Б-1 → спринт 11+) — единственная транзакция BEGIN; \i schema.sql; COMMIT; от пустой базы до текущей версии.
  • Backfill для api_keys.expires_at — при переходе с v8.4 на v8.5 на dev/staging выполнить UPDATE api_keys SET expires_at = NOW() + INTERVAL '365 days' WHERE expires_at IS NULL; ДО применения ALTER ... SET NOT NULL. На production это не требуется (база с нуля).

Запись Z — v8.3 → v8.4 (06.05.2026)

Источник изменений:

  • Переписывание narrative v8.3 → v8.4 06.05.2026, раздел §19.10 «Outbound webhook».
  • Решение OPEN-И-2 (закрыто 04.05.2026): Уровень 1 стратегии CRM-интеграций — outbound webhook на MVP.
  • Тех-долг шапки narrative v8.4: «при правке §7 добавить DDL outbound_webhook_subscriptions и outbound_webhook_deliveries».

Z.0. Сводка

Параметр v8.3 v8.4 Δ
Логических таблиц 51 53 +2
Партиций 12 12 =0
Индексов 81 86 +5
RLS-политик 31 33 +2
Защищённых таблиц (ENABLE) 32 34 +2
Полей в tenants 23 23 =0

Z.1. Новые таблицы

Z.1.1. outbound_webhook_subscriptions

Регистрация подписок тенантов на исходящие события сделок. Hash secret + key_prefix аналогично api_keys (раздел 19.3 narrative). Список событий — JSONB-массив с whitelist на стороне приложения. Не более 10 активных подписок на тенанта (проверка в Application layer; SQL-слой обеспечивает только базовый CHECK на структуру events).

Поля: id, tenant_id, user_id, name, target_url, secret_hash, secret_prefix, events JSONB, custom_headers JSONB, is_active, paused_at, last_delivery_at, last_failure_at, consecutive_failures, created_at, updated_at.

Индексы: idx_outbound_subs_tenant_active (partial WHERE is_active), idx_outbound_subs_secret_prefix.

Z.1.2. outbound_webhook_deliveries

Журнал попыток доставки. Retention 90 дней (как webhook_log). Status-флоу: pending → success | failed → permanently_failed после 7 попыток. Retry с возрастающим интервалом (30 сек / 5 мин / 30 мин / 2 ч / 6 ч / 24 ч — см. narrative §19.10.6).

Поля: id, tenant_id, subscription_id, delivery_uuid, event, payload JSONB, attempt_number SMALLINT, status, http_status_code, response_body, response_time_ms, error_message, scheduled_at, started_at, finished_at, next_retry_at, created_at.

Индексы: idx_outbound_deliveries_subscription (по подписке), idx_outbound_deliveries_status_pending (partial для воркера retry), idx_outbound_deliveries_created.

Z.2. RLS-политики

Обе таблицы получили ENABLE ROW LEVEL SECURITY + CREATE POLICY tenant_isolation по tenant_id — стандартный паттерн tenant-таблиц (как api_keys, webhook_log).

Z.3. Что НЕ добавлено в v8.4

  • Таблицы crm_connections / crm_field_mappings (упомянуты в плане v8.4 для §7) — отложены до спринта 1415 (старт реализации Уровня 2 — нативный коннектор amoCRM, OPEN-И-2). DDL появится в schema v8.5+ при подготовке этих спринтов. До этого момента outbound webhook Уровня 1 (этой записи) — единственный канал интеграции с внешними CRM, и он работает без crm_connections.

Z.4. Совместимость

  • Forward-only: v8.4 разворачивается с нуля и не требует миграции с v8.3 (база ещё не в production — фаза 0).
  • При первом деплое в production (спринт 11 после Б-1) — миграция от пустой базы до v8.4 одной транзакцией.

Z.5. Hotfix 06.05.2026 — P0-блокеры миграции

По итогам аудита B (06.05.2026) в schema.sql v8.4 найдены P0-проблемы, из-за которых psql -f schema.sql падал бы на пустой базе. Это правки внутри той же версии v8.4 (метрики 53/86/33/34 не меняются — только перенос FK и снятие битых WHERE-предикатов с partial-индексов).

Z.5.1. Forward-FK в SaaS-блоке → ALTER TABLE после CREATE TABLE tenants

saas_admin_sessions (Ю-1, импersonation) и impersonation_tokens объявлены выше по тексту, чем CREATE TABLE tenants (раздел 3 narrative — SaaS-админка идёт перед tenant-данными). Inline-FK REFERENCES tenants(id) внутри их CREATE TABLE падает на forward-reference при разворачивании с нуля.

Решение: в обоих CREATE TABLE поля оставлены типа BIGINT без inline-FK; ниже, сразу после CREATE INDEX-блока tenants, добавлены два ALTER TABLE ... ADD CONSTRAINT FOREIGN KEY ... REFERENCES tenants(id) (с ON DELETE CASCADE для impersonation_tokens.tenant_id). Аналогично уже было сделано для saas_admin_sessions.impersonating_token_idimpersonation_tokens(id) в исходной v8.4.

Z.5.2. Partial index по expires_at с предикатом WHERE expires_at > NOW()

Два индекса (idx_saas_admin_sessions_expires, idx_sessions_expires) использовали WHERE expires_at > NOW(). PostgreSQL запрещает в предикате частичного индекса непостоянные функции (NOW() — STABLE, не IMMUTABLE) — CREATE INDEX падает.

Решение: оба индекса переведены на полное поле без WHERE. Поле expires_at объявлено NOT NULL, поэтому partial по IS NOT NULL бессмыслен. Индексы используются cron-очисткой expired-сессий — полный индекс корректен.

Z.5.3. outbound_webhook_subscriptions.events — снят DEFAULT '[]'

events JSONB NOT NULL DEFAULT '[]' конфликтовал с CHECK (jsonb_array_length(events) > 0): любой INSERT без явного events падал бы. Снят DEFAULT, остался NOT NULL — приложение обязано явно передать список событий ≥ 1 элемента.

Z.5.4. deal_tag_pivot — добавлены ENABLE RLS + tenant_isolation

Связь dealsdeal_tags. У pivot нет tenant_id, у deals (партиционированной) RLS не работает в виде WHERE tenant_id = ... — используется RLS через JOIN на deal_tags(tenant_id). Добавлен паттерн как у saas_invoice_items (invoice_id IN (...)).

Z.5.5. Метрики после hotfix

grep -c '^CREATE TABLE ' = 65 (53 логических + 12 партиций), grep -c '^CREATE INDEX\|^CREATE UNIQUE INDEX' = 86, grep -c '^ALTER TABLE.*ENABLE ROW LEVEL SECURITY' = 34, grep -c '^CREATE POLICY' = 34 (1:1 соответствие, см. Z.5.4). Forward-FK на tenants(id) отсутствуют (первая ссылка на стр. 517 — после CREATE TABLE на стр. 506). Шапка schema.sql:107-108 синхронизирована.

Z.5.6. Изменение метрик Z.0 после hotfix B-5

Метрика До hotfix После Z.5.4
RLS-политик 33 34 (+1 deal_tag_pivot)
Защищённых таблиц (ENABLE) 33 34 (+1 deal_tag_pivot)

Запись A — v8.2 → v8.3 (05.05.2026)

Источники изменений:

  • Параллельный аудит crm.bp-gr.ru 05.05.2026 (партии 12, 13, 14, 15).
  • Прил. М v1.1 (Analiz_originala_v8_3.md), §3.5 — детальное обоснование.
  • Открытые_вопросы v1.6 (Открытые_вопросы_v8_3.md), раздел 12 — Биз-14/15/16.

A.0. Сводка

Параметр v8.2 v8.3 Δ
Таблиц 51 51 =0 (без новых таблиц — reminders уже была в v8.2)
Полей в deals 14 12 -2 (удалены reminder_text, reminder_at)
Полей в suppliers 11 16 +5 (capabilities)
Полей в tenants 22 23 +1 (desired_daily_numbers)
Полей в reminders 9 11 +2 (assignee_id, completed_at; user_idcreated_by; is_done удалено)
Индексов 80 81 +1 нетто (-1 idx_deals_reminder, +2 reminders)
RLS-политик 31 31 =0 (RLS на reminders уже была)
Записей в system_settings 22 25 +3 (cron purge-deleted)

A.1. Что изменилось в коде backend (Laravel)

A.1.1. Eloquent-модели — изменённые

App\Models\Reminder (была, перепись):

  • $fillable: убрать user_id, is_done. Добавить: created_by, assignee_id, completed_at.
  • $casts: completed_at => 'datetime', is_sent => 'boolean'.
  • Удалить старый scope scopeNotDone() — заменить на scopeActive() с условием whereNull('completed_at').
  • Удалить старый scope scopeForUser($userId) — заменить на scopeCreatedBy($userId) (по новому полю).
  • Новый scope scopeAssignedTo($userId) — для будущей фичи назначения (на MVP всегда NULL).
  • Relations:
    • creator()belongsTo(User::class, 'created_by') (был user()).
    • assignee()belongsTo(User::class, 'assignee_id') (новый, для Post-MVP).
    • deal()belongsTo(Deal::class) (без изменений).
  • Метод markCompleted() — вместо is_done = true ставит completed_at = now().
  • Метод isCompleted(): bool — проверка completed_at !== null.

App\Models\Deal:

  • $fillable: убрать reminder_text, reminder_at.
  • $casts: убрать reminder_at => 'datetime'.
  • Удалить accessor/mutator для reminder_text и reminder_at (если были).
  • Новый relation: reminders()hasMany(Reminder::class) (вместо одиночных полей).
  • Новый accessor latestActiveReminder() — для UI карточки сделки (показать ближайшее активное напоминание).
  • Helper hasActiveReminder(): bool — заменяет проверку $deal->reminder_at !== null.

App\Models\Supplier:

  • $fillable дополнить: channel, supports_sender_name, supports_keyword, supports_csv_upload, supports_domains_list.
  • $casts: 4 boolean-поля → 'boolean'.
  • Новый метод availableFields(): array — возвращает массив имён полей, которые UI должен показать в форме проекта (на основании capabilities).
    • Пример: для B2 — ['sender_name', 'keyword']; для B3 — ['sender_name']; для B1 — ['domains_list', 'csv_upload'].
  • Новый scope scopeByChannel(string $channel) — для фильтрации (sites/calls/sms).
  • Helper-метод Supplier::intersectionCapabilities(Collection $suppliers): array — для пересечения capabilities при выборе нескольких поставщиков (для B2+B3 → ['sender_name'], без keyword).

App\Models\Tenant:

  • $fillable дополнить: desired_daily_numbers.
  • $casts: desired_daily_numbers => 'integer'.
  • Helper getDesiredDailyNumbers(): ?int — для отображения в UI кабинета и в админке SaaS.

A.1.2. Сервисы — новые/изменённые

App\Services\ReminderService (был, перепись для множественных):

  • create(Deal $deal, User $creator, array $data): Reminder — создаёт через $deal->reminders()->create([...]).
  • update(Reminder $reminder, array $data): void.
  • complete(Reminder $reminder): void — ставит completed_at = now() (вместо is_done = true).
  • delete(Reminder $reminder): void — soft- или hard-delete (по решению заказчика; на MVP — hard-delete, паритет с оригиналом).
  • getActiveForDeal(Deal $deal): Collection — все активные напоминания сделки.
  • getDashboardForUser(User $user, string $filter): Collection — фильтр today|last|future|none (паритет с ?reminders=... в оригинале).
    • today: DATE(remind_at) = CURRENT_DATE AND completed_at IS NULL.
    • last: remind_at < NOW() AND completed_at IS NULL (просроченные).
    • future: remind_at >= TOMORROW AND completed_at IS NULL.
    • none: для Deal — те, у которых reminders()->active()->count() = 0.

App\Services\SupplierCapabilityService (новый):

  • getRelevantFieldsForProject(Project $project): array — на основании выбранных поставщиков проекта возвращает массив релевантных полей формы.
  • validateProjectFields(Project $project, array $input): array — server-side валидация: для проекта с B3 нельзя передать keyword (выбросить exception).

App\Console\Commands\Projects\PurgeDeleted (новая cron-задача, Биз-14):

  • Имя: projects:purge-deleted.
  • Расписание: из system_settings.projects_purge_deleted_cron (по умолчанию 0 4 * * *).
  • Условия запуска: system_settings.projects_purge_deleted_enabled = true (по умолчанию false).
  • Логика: проходит по всем тенантам, для каждого вызывает Project::onlyTrashed()->where('deleted_at', '<', now()->subDays($ttl))->forceDelete() где $ttl = system_settings.projects_purge_deleted_ttl_days (по умолчанию 180).
  • Логирует в incidents_log каждый цикл с количеством физически удалённых проектов.
  • На MVP cron включён в коде, но disabled через settings — включается админом SaaS вручную после согласования с юристом.

A.1.3. Контроллеры — изменённые

App\Http\Controllers\Api\Tenant\ReminderController (новый):

  • GET /api/v1/deals/{deal}/reminders — список активных напоминаний сделки.
  • POST /api/v1/deals/{deal}/reminders — создать.
  • PATCH /api/v1/reminders/{reminder} — изменить.
  • POST /api/v1/reminders/{reminder}/complete — пометить выполненным.
  • DELETE /api/v1/reminders/{reminder} — удалить.
  • GET /api/v1/reminders/dashboard?filter=today|last|future|none — паритет с оригиналом.

App\Http\Controllers\Api\Tenant\DealController:

  • В endpoint GET /api/v1/deals добавить query-параметр reminders=today|last|future|none (паритет с ?reminders=... оригинала).
  • В endpoint GET /api/v1/deals/{deal} присоединить reminders в response через with('reminders').
  • Из endpoint'ов POST и PATCH для Deal убрать валидацию полей reminder_text и reminder_at (теперь только через reminders API).

App\Http\Controllers\Api\Tenant\ProjectController:

  • В response GET /api/v1/projects/{project} добавить вычисленное поле available_fields (через SupplierCapabilityService).
  • В endpoint'ах POST/PATCH валидация поля keyword зависит от supports_keyword выбранного поставщика.

App\Http\Controllers\Api\Tenant\TenantController:

  • В response GET /api/v1/tenant добавить desired_daily_numbers.
  • В endpoint PATCH /api/v1/tenant разрешить редактирование desired_daily_numbers (только админ тенанта).

App\Http\Controllers\SaasAdmin\TenantController:

  • В response GET /admin/tenants/{tenant} отображать desired_daily_numbers в карточке тенанта (для саппорта).

A.1.4. Validation Requests

App\Http\Requests\StoreReminderRequest (новый):

  • text: nullable|string|max:255.
  • remind_at: required|date|after:now.
  • assignee_id: nullable|integer|exists:users,id (на MVP не используется).

App\Http\Requests\StoreProjectRequest, UpdateProjectRequest:

  • Условная валидация по capabilities выбранных поставщиков — через Rule::when():

    'keyword' => [
        Rule::when(
            $this->supplierSupports('keyword'),
            ['nullable', 'string', 'max:50'],
            ['prohibited']
        ),
    ],
    

App\Http\Requests\UpdateTenantRequest:

  • desired_daily_numbers: nullable|integer|min:1.

A.1.5. Vuetify-frontend — компоненты

<DealCard.vue>:

  • Удалить старую панель «Напоминание» с одиночными полями reminder_text + reminder_at.
  • Добавить компонент <RemindersList> — список активных напоминаний с возможностью добавить/редактировать/закрыть/удалить.
  • Кнопка «+ Добавить напоминание» открывает модалку <ReminderForm>.

<ReminderForm.vue> (новый):

  • Поля: text (textarea, лимит 255), remind_at (date-picker + time-picker).
  • На MVP без полей assignee_id, priority, channel, recurrence (паритет с оригиналом).

<DealList.vue>:

  • Добавить дропдаун «Задачи» в шапку списка с 4 пунктами: «Дела на сегодня» / «Просроченные дела» / «Предстоящие дела» / «Сделки без задач» (URL-параметр reminders=today|last|future|none).

<ProjectForm.vue>:

  • При выборе поставщиков (project_suppliers) автоматически показывать/скрывать поля на основании available_fields из API.
  • Если выбран B2 — показать sender_name и keyword; B3 — только sender_name; B1 — domains_list и csv_upload.
  • Если выбраны несколько — показывать пересечение (B2+B3 → только sender_name).

<TenantSettings.vue> (или <ProfilePage.vue>):

  • Добавить поле «Целевое количество лидов в день» (desired_daily_numbers) — number input. Подсказка: «Желаемый объём — сигнал для нашего саппорта».

<SaasAdminTenantCard.vue>:

  • В админке SaaS отображать desired_daily_numbers в карточке тенанта (read-only для саппорта; редактируемое только для admin/superadmin).

A.2. Миграция данных существующих dev-окружений

Если у вас уже развёрнуто dev-окружение со схемой v8.2 и нужно мигрировать на v8.3 без потери данных:

BEGIN;

-- 1. Миграция данных reminder_text + reminder_at в reminders
INSERT INTO reminders (tenant_id, deal_id, text, remind_at, created_by, created_at)
  SELECT 
      d.tenant_id, 
      d.id, 
      d.reminder_text,
      d.reminder_at,
      COALESCE(d.manager_id, (SELECT id FROM users WHERE tenant_id = d.tenant_id LIMIT 1)),
      d.received_at
    FROM deals d
   WHERE d.reminder_at IS NOT NULL;

-- 2. Удаление старых полей и индекса
ALTER TABLE deals DROP COLUMN reminder_text;
ALTER TABLE deals DROP COLUMN reminder_at;
DROP INDEX IF EXISTS idx_deals_reminder;

-- 3. Реструктуризация reminders: user_id → created_by, is_done → completed_at
ALTER TABLE reminders RENAME COLUMN user_id TO created_by;
ALTER TABLE reminders ADD COLUMN assignee_id BIGINT REFERENCES users(id);
ALTER TABLE reminders ADD COLUMN completed_at TIMESTAMPTZ;
ALTER TABLE reminders ADD COLUMN updated_at TIMESTAMPTZ;

-- Перенести is_done = true → completed_at = now() (приближённо)
UPDATE reminders SET completed_at = COALESCE(sent_at, created_at, NOW())
  WHERE is_done = TRUE;

ALTER TABLE reminders DROP COLUMN is_done;

-- Пересоздать индексы (старые с is_done больше не валидны)
DROP INDEX IF EXISTS idx_reminders_due;
DROP INDEX IF EXISTS idx_reminders_tenant_user_due;

CREATE INDEX idx_reminders_due
    ON reminders(remind_at) WHERE is_sent = FALSE AND completed_at IS NULL;
CREATE INDEX idx_reminders_deal
    ON reminders(deal_id);
CREATE INDEX idx_reminders_tenant_user_active
    ON reminders(tenant_id, created_by, remind_at) WHERE completed_at IS NULL;
CREATE INDEX idx_reminders_tenant_active
    ON reminders(tenant_id, remind_at) WHERE completed_at IS NULL;

-- 4. Расширение suppliers
ALTER TABLE suppliers
    ADD COLUMN channel               VARCHAR(20) NOT NULL DEFAULT 'sites'
        CHECK (channel IN ('sites','calls','sms')),
    ADD COLUMN supports_sender_name  BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN supports_keyword      BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN supports_csv_upload   BOOLEAN NOT NULL DEFAULT TRUE,
    ADD COLUMN supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE;

UPDATE suppliers SET 
    channel = 'sites', supports_sender_name = FALSE, supports_keyword = FALSE
  WHERE code = 'b1';

UPDATE suppliers SET 
    channel = 'sms', supports_sender_name = TRUE, supports_keyword = TRUE,
    supports_csv_upload = FALSE, supports_domains_list = FALSE
  WHERE code = 'b2';

UPDATE suppliers SET 
    channel = 'sms', supports_sender_name = TRUE, supports_keyword = FALSE,
    supports_csv_upload = FALSE, supports_domains_list = FALSE
  WHERE code = 'b3';

-- 5. tenants.desired_daily_numbers
ALTER TABLE tenants
    ADD COLUMN desired_daily_numbers INT
        CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0);

-- 6. system_settings: schema_version + 3 новых ключа
UPDATE system_settings SET value = '8.3' WHERE key = 'schema_version';

INSERT INTO system_settings (key, value, type, description) VALUES
  ('projects_purge_deleted_enabled', 'false', 'bool',
   'Включён ли cron физического удаления soft-deleted проектов после TTL'),
  ('projects_purge_deleted_ttl_days', '180', 'int',
   'TTL для физического удаления soft-deleted проектов (дней). 180 = 6 месяцев.'),
  ('projects_purge_deleted_cron', '0 4 * * *', 'string',
   'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)');

COMMIT;

Важно: на проде (когда появится) — миграция через Laravel migration с явным review backend-разработчиком. Этот SQL — только для dev-окружения.


A.3. Тестирование

A.3.1. Unit-тесты, которые нужно обновить

  • tests/Unit/Models/ReminderTest.php — переписать тесты создания/чтения/обновления (поля created_by, completed_at, scope active).
  • tests/Unit/Models/DealTest.php — удалить тесты на reminder_text/reminder_at. Добавить тесты на relation reminders() и helper hasActiveReminder().
  • tests/Unit/Models/SupplierTest.php — добавить тесты на availableFields() и intersectionCapabilities().
  • tests/Unit/Services/ReminderServiceTest.php — новый тест-класс на все методы сервиса (включая фильтр-сценарии today|last|future|none).
  • tests/Unit/Services/SupplierCapabilityServiceTest.php — новый.

A.3.2. Feature-тесты

  • tests/Feature/Api/Tenant/ReminderApiTest.php — новый: CRUD endpoints для reminder + dashboard.
  • tests/Feature/Api/Tenant/DealApiTest.php — обновить: убрать сценарии с reminder_text/reminder_at, добавить с фильтром ?reminders=today.
  • tests/Feature/Api/Tenant/ProjectApiTest.php — добавить: при выборе B3 запрос с полем keyword должен вернуть 422.
  • tests/Feature/Console/PurgeDeletedTest.php — новый: создать просроченные soft-deleted проекты, запустить команду, проверить что физически удалены.

A.3.3. Browser-тесты (Dusk)

  • tests/Browser/DealCardRemindersTest.php — добавить несколько напоминаний, отметить выполненным, удалить.
  • tests/Browser/ProjectFormSupplierFieldsTest.php — проверить, что при смене поставщика поля динамически появляются/скрываются.

A.4. Документация — что обновить

  • Прил. М v1.1 — Analiz_originala_v8_3.md (раздел 9, §3.5).
  • Открытые_вопросы v1.6 (раздел 12 с Биз-14/15/16).
  • schema.sql v8.3.
  • CHANGELOG записи A в этом файле CHANGELOG_schema.md (после оптимизации архива v8.3++ optimized 05.05.2026 объединён с CHANGELOG записи B = v8.1 → v8.2).
  • README_АРХИВ_v8_3.md → v8.3++ optimized (16 файлов).
  • ⏸ Прил. Б+В (ER + State machines, объединены в Приложение_Б_В_БД_диаграммы_v8_3.md в v8.3++ optimized) — ER-часть от v8.1, требует обновления до v8.2 + v8.3 при следующей итерации (state machines изменений не получили).
  • ⏸ v8.4 narrative — раскрытие 30 решений интервью + интеграция выводов аудита партий 1–15 (см. Прил. М §4 + §9.6).

A.5. Хронология изменений

  • v8.1 → v8.2 (04.05.2026): suppliers + project_suppliers + лимиты проектов + processing_restricted + incidents_log. Источник: интервью 04.05 + аудит 1–11 партий.
  • v8.2 → v8.3 (05.05.2026): reminders переписана + suppliers capabilities + tenants.desired_daily_numbers + cron purge-deleted. Источник: параллельный аудит партий 12–15.

Конец CHANGELOG v8.3. Источник: Прил. М v1.1, §3.5; Открытые_вопросы v1.6, раздел 12; schema.sql v8.3.


Запись B — v8.1 → v8.2 (04.05.2026)

Источники изменений:

  • Интервью с заказчиком 04.05.2026 (OPEN-Д-1, OPEN-Д-5, OPEN-И-1).
  • Аудит crm.bp-gr.ru 04.05.2026, партии 1–11 (раскрытие сущности «Поставщик», динамические лимиты).
  • Прил. М v1.0 (Analiz_originala_v8_3.md) — детальное обоснование.

B.0. Сводка

Параметр v8.1 v8.2 Δ
Таблиц 47 51 +4
Полей в projects 7 13 +6
Полей в pd_subject_requests 11 12 +1
Полей в supplier_lead_costs 7 7 =0 (supplier_codesupplier_id)
Полей в supplier_invoices 17 17 =0 (supplier_codesupplier_id)
Индексов 67 80 +13
RLS-политик 29 30 +1 (project_limit_adjustments)
Seed-записей в suppliers 3 +3 (B1/B2/B3)
Записей в system_settings 19 23 +4

B.1. Что изменилось в коде backend (Laravel)

B.1.1. Eloquent-модели — новые

App\Models\Supplier            → suppliers
App\Models\ProjectSupplier     → project_suppliers (m2m через through)
App\Models\IncidentsLog        → incidents_log (только из админки SaaS)
App\Models\ProjectLimitAdjustment → project_limit_adjustments

B.1.2. Eloquent-модели — изменённые

App\Models\Project:

  • Добавить $fillable: daily_limit_target, effective_daily_limit_today, region_mask, region_mode, delivery_days_mask.
  • $casts: effective_limit_calculated_at => 'datetime'.
  • Новый relation: suppliers() через belongsToMany(Supplier::class, 'project_suppliers')->withPivot('settings', 'is_active').
  • Новый relation: limitAdjustments() через hasMany(ProjectLimitAdjustment::class).
  • Helper effectiveDailyLimit(): int — возвращает effective_daily_limit_today ?? daily_limit_target.

App\Models\PdSubjectRequest:

  • Добавить $casts: processing_restricted => 'boolean'.
  • Scope scopeRestricted($query) для выборки тех, у кого processing_restricted = TRUE.

App\Models\SupplierLeadCost:

  • Удалить из $fillable: supplier_code.
  • Добавить в $fillable: supplier_id.
  • Новый relation: supplier() через belongsTo(Supplier::class).

App\Models\SupplierInvoice:

  • То же — supplier_codesupplier_id + relation supplier().

B.1.3. Сервисы — новые

App\Services\Limits\EffectiveLimitCalculator (см. Прил. М §3.2):

public function recalculate(Project $project): int {
    $tenant = $project->tenant;
    $balance = $tenant->balance_rub;
    $leadCost = $tenant->effective_lead_cost();
    $maxByMoney = (int) floor($balance / $leadCost);
    $effective = min($project->daily_limit_target, $maxByMoney);

    if ($effective !== $project->effective_daily_limit_today) {
        ProjectLimitAdjustment::create([
            'tenant_id' => $tenant->id,
            'project_id' => $project->id,
            'target_limit' => $project->daily_limit_target,
            'effective_limit' => $effective,
            'adjustment_reason' => $this->detectReason(...),
            'balance_at_calc_rub' => $balance,
            'lead_cost_at_calc_rub' => $leadCost,
        ]);
        $project->update([
            'effective_daily_limit_today' => $effective,
            'effective_limit_calculated_at' => now(),
        ]);
    }
    return $effective;
}

Триггеры вызова:

  1. Cron limits:recalc в 00:00 МСК для всех is_active=TRUE проектов (adjustment_reason='daily_recalc').
  2. После BalanceTransaction::commit() — для всех проектов тенанта (balance_recovered или balance_low).
  3. После списания за лид в ProcessWebhookJob — если effective < target (balance_low).
  4. При ProjectCreated event (project_created).
  5. При TariffChanged event (tariff_change).
  6. При Project::update(['daily_limit_target' => ...]) (target_changed).

App\Services\Pd\ProcessingRestrictionGuard:

  • Middleware / observer, проверяющий pd_subject_requests.processing_restricted для всех мутаций ПДн.
  • При TRUE → выбрасывает App\Exceptions\Pd\ProcessingRestrictedException.
  • См. Прил. Д v8.2.

App\Services\Incidents\IncidentLogger:

  • API для админки SaaS — создание / закрытие инцидентов.
  • При type='data_breach' — автоматическая отправка нотификации в Slack on-call + создание задачи compliance.
  • См. Прил. И v8.2 раздел 6.

B.1.4. API endpoints — новые

GET    /api/v1/projects/{id}/effective-limit    — текущий effective_daily_limit_today + причина
GET    /api/v1/projects/{id}/limit-adjustments  — лог автокоррекций для UI клиента (последние 30)
GET    /api/v1/suppliers                        — публичный каталог B1/B2/B3 для селектора в форме проекта

Админка SaaS:

GET    /admin/incidents                          — журнал инцидентов
POST   /admin/incidents                          — создать (требует severity, summary, started_at)
PATCH  /admin/incidents/{id}                     — обновить (root_cause, postmortem_url)
POST   /admin/incidents/{id}/resolve             — закрыть инцидент (выставить resolved_at)
GET    /admin/pd-subject-requests/{id}/restrict  — toggle processing_restricted (compliance)

B.1.5. Job'ы и события — новые

App\Jobs\Limits\RecalculateProjectLimits          (cron limits:recalc)
App\Events\Project\LimitAdjusted                  (для аудита и UI push)
App\Events\Pd\ProcessingRestrictionToggled        (для аудита)
App\Events\Incident\Created
App\Events\Incident\Resolved

B.1.6. UI / Vuetify

Карточка проекта (см. Прил. М §4.5):

  • Чекбоксы поставщиков B1/B2/B3 (мульти-чекбокс из suppliers где is_active=TRUE).
  • При выборе B2 — раскрытие подформы по схеме suppliers.settings_schema (поля sender_name, keyword).
  • Number-input «Целевой дневной лимит» (daily_limit_target).
  • Readonly-blob «Реальный лимит сегодня: X (скорректировано по балансу, см. подробнее)» с ссылкой на /limit-adjustments.
  • Toggle «Включить/Исключить регионы» (region_mode).
  • Дерево 8 округов (мульти-чекбокс) — преобразуется в region_mask на сабмите.
  • 7 чекбоксов дней приёма — преобразуются в delivery_days_mask на сабмите.

Админка SaaS — новые экраны:

  • /admin/incidents — таблица + форма создания (Прил. Г v8.2).
  • /admin/pd-subject-requests/{id} — кнопка «Ограничить обработку» (compliance).

B.2. Что изменилось в данных существующих таблиц

B.2.1. supplier_lead_costs

  • Все строки: supplier_id → ID строки b1 в suppliers (бэкфил в патче).
  • supplier_code — удалено.
  • Логика чтения себестоимости: было supplier_lead_costs.cost_rub (snapshot из system_settings.supplier_default_cost_rub). Стало то же самое, но cost_rub теперь должен браться из suppliers.cost_rub через supplier_id для всех новых строк. Для старых остаётся snapshot.

B.2.2. supplier_invoices

  • То же — supplier_codesupplier_id.

B.2.3. projects

  • 6 новых полей с дефолтами:
    • daily_limit_target = 10 (10 лидов/день).
    • region_mask = 255 (все 8 округов разрешены).
    • region_mode = 'include'.
    • delivery_days_mask = 127 (все 7 дней).
    • effective_daily_limit_today = NULL (требует первого пересчёта).
    • effective_limit_calculated_at = NULL.

Важно: после применения патча — запустить php artisan limits:recalc --all ОДИН РАЗ, чтобы заполнить effective_daily_limit_today для всех существующих проектов.

B.2.4. pd_subject_requests

  • Новое поле processing_restricted = FALSE для всех существующих строк (default).
  • Compliance-админу — пройти список открытых обращений и выставить флаг там, где он по факту должен быть TRUE.

B.2.5. system_settings

  • 4 новых ключа: schema_version, limits_recalc_cron_enabled, limits_recalc_minute_offset, limits_balance_low_log_enabled.

B.3. Что НЕ изменилось (но требует внимания в v8.4)

B.3.1. Схема статусов сделок

Свободная state-machine подтверждена аудитом (партия 11). У нас тоже свободная — изменений не нужно. Будет явно зафиксировано в narrative §8 v8.4.

B.3.2. deals

Карточка сделки в оригинале не имеет файлов / задач (партия 11.6). У нас тоже — паритет. Без изменений в схеме.

B.3.3. Биллинг

В оригинале мульти-кошельковая модель (4 счётчика), у нас — одновалютная. Заказчик подтверждает упрощение (Биз-11) — без изменений.

B.3.4. RLS на deals для processing_restricted

В этом патче RLS-политика на deals для блокировки выборки по субъектам с processing_restricted=TRUE НЕ добавлена. Причина: сложная логика связи (deal через phone/email с pd_subject_requests). Будет добавлено в v8.3 после уточнения с юристом и compliance-админом.


B.4. Шаги применения

B.4.1. Установка с нуля (новые dev / staging окружения)

# 1. Создать БД
createdb -E UTF8 liderra

# 2. Применить консолидированную schema.sql v8.2
psql $DB_URL -f schema.sql

# 3. Smoke-проверки
psql $DB_URL -c "SELECT value FROM system_settings WHERE key = 'schema_version';"
# Ожидается: 8.2

psql $DB_URL -c "SELECT COUNT(*) FROM suppliers WHERE code IN ('b1','b2','b3');"
# Ожидается: 3

psql $DB_URL -c "SELECT COUNT(*) FROM lead_statuses;"
# Ожидается: 14

psql $DB_URL -c "SELECT COUNT(*) FROM tariff_plans WHERE is_active = TRUE;"
# Ожидается: 4

B.4.2. Миграция с v8.1 на v8.2 (существующие dev/staging)

Поскольку в проекте сейчас используется консолидированный подход (один файл schema.sql вместо последовательности патчей), для миграции существующих окружений с v8.1 нужно:

Вариант А — пересоздание БД (рекомендуется для dev/staging до публичного запуска):

# 1. Backup данных, которые нужно сохранить (если есть)
pg_dump --data-only $DB_URL > data-backup-$(date +%Y%m%d).sql

# 2. Drop & recreate
dropdb liderra && createdb -E UTF8 liderra
psql $DB_URL -f schema.sql

# 3. Восстановить нужные данные (если есть)
# Внимание: формат supplier_lead_costs изменился (supplier_code → supplier_id),
# при восстановлении из старого dump'а понадобится трансформация.

Вариант Б — ручная миграция (только если данные нельзя терять):

  • Сравнить v8.1 и v8.2 (например, через git diff schema.sql.v8.1 schema.sql).

  • Применить вручную ALTER'ы из дельты.

  • Бэкфил supplier_lead_costs.supplier_id ← suppliers WHERE code='b1'.

  • В этом случае может быть полезно сгенерировать diff-патч на лету:

    diff -u schema.sql.v8.1 schema.sql > migration-v8.1-to-v8.2.diff
    

B.4.3. Production (когда дойдём)

К моменту production-запуска проект ещё не имеет реальных данных, поэтому применяется как «установка с нуля» (вариант А). Если позже потребуется миграция production → следующая версия — соберу отдельный патч-файл с ALTER'ами (см. также Прил. И v8.2 раздел 5 «Migration runbook»).

После применения:

  • Запустить php artisan limits:recalc --all — заполнит effective_daily_limit_today для всех существующих проектов.
  • Мониторить Sentry на ProcessingRestrictedException — это нормальные ожидаемые exception'ы при попытках работать с restricted-субъектами.

B.5. Откат

Поскольку файл консолидированный, отката «как такового» нет — есть только восстановление из backup'а.

Рекомендация: на dev/staging — pg_dump перед каждым применением schema.sql.

Если нужно «вернуться на v8.1» на свежем deploy:

  1. Достать предыдущую версию schema.sql из git (git show <commit>:schema.sql > schema.sql.v8.1).
  2. Drop & recreate БД.
  3. Применить старую schema.

При наличии данных — отдельный rollback-патч можно собрать по запросу (DROP TABLE incidents_log/suppliers/project_suppliers/project_limit_adjustments + DROP COLUMN из projects/pd_subject_requests + восстановление supplier_code).


B.6. Связь с Прил. М

Все 7 групп изменений детально обоснованы в Analiz_originala_v8_3.md (Прил. М v1.0):

Группа изменений Раздел Прил. М
1. processing_restricted §3.3
2. incidents_log §3.3
3. suppliers §2.1, §3.1
4. project_suppliers §2.1, §3.1
5. Миграция supplier_code → supplier_id §3.1
6. Расширение projects §2.2, §3.2
7. project_limit_adjustments §2.2, §3.2

Версия changelog: 1.0 от 04.05.2026.


Журнал ведётся при каждом изменении schema.sql. Новые записи добавляются сверху, перед записью A. Документ объединён из CHANGELOG-v8_2.md и CHANGELOG-v8_3.md в рамках оптимизации архива v8.3++ → v8.3++ optimized 05.05.2026. Версия документа: 1.0 от 05.05.2026.