311 KiB
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, которую поток «за показы»
не заполняет вообще. Клиент видел красный ярлык «Отклонено» без единого слова объяснения.
Лента — одно место, через которое проходят и слова Яндекса, и ответ клиента, и приложенные
им документы.
Почему body — text, а не 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 (migrate → rollback → migrate → повторный 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.99–v9.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:166–184):
«обновляем строго через 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.99–v9.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 UNSIGNEDnullable) — 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_to →
date:Y-m-d, run_days → integer, client_cpm_rub → decimal:2, mode → string, дефолт
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_rub → nullable (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_DRAFT…STATUS_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_wallets — ENABLE+FORCE ROW LEVEL SECURITY, политика tenant_isolation USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) на
обеих таблицах. GRANT: ad_campaign_ads — crm_app_user SELECT,
INSERT, UPDATE (объявления не удаляются, только меняют
moderation_status); ad_campaign_phones — crm_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_transactions — append-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_at — timestamps=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). FKsender_id → sales_sms_senders(id).sales_sms_messages— строка журнала на один номер. FKcampaign_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'не как умолчание, а как факт: они уже лежат в яндексовом сегменте. CHECKchk_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'— статус канала ВК. CHECKchk_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 BIGINT→sales_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.titleVARCHAR(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 NULL → sales_prospects(id) ON DELETE CASCADE |
карточка |
sales_user_id |
BIGINT NULL → sales_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'; guardNOT 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_nameVARCHAR(500) NULL— юрлицо (подтягивается по ИНН из ДаData, правится руками).firm_nameостаётся брендом/вывеской — его показываем на карточке канбана.sales_prospects.contactsJSONB 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.source—VARCHAR(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_*; GRANTSELECT,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, DEFAULTnew),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) WHEREinn 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,kindCHECK (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,roleCHECK (manager|head),is_active,base_salary_rub DECIMAL(12,2)(оклад),current_tariff_id → sales_tariffs,created_by → sales_users(самоссылочный FK).sales_client_assignments— привязка «один менеджер на клиента» (UNIQUEtenant_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),statusCHECK (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(гость сайта, PKid UUID, каналы/utm/referrer/устройство/гео/признакиis_datacenter/is_human, опциональные FKtenant_id→tenants,user_id→users, оба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_events→occurred_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_agent →
MASKED 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_readONautopodbor_runsFOR 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— журнал слияний конкурентов + снимок поглощённых карточек (absorbedJSONB) для истории и возврата («Вернуть карточку»). FKtenant_id→tenantsCASCADE,user_id→usersSET NULL,survivor_id→autopodbor_competitorsSET NULL (история переживает удаление выжившей/сотрудника). Индекс(tenant_id, created_at), per-tenant RLStenant_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— CHECKIN ('proposal','field')→IN ('proposal','field','archived')(синкано в этот DDL, строка 1447).autopodbor_competitors.box— тот же CHECK в БД расширен миграцией доarchived; в DDLschema.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_found—JSONB(nullable): список мест[{label,url},…]; число подтверждений = его длина. Кликабельные ссылки на сайт/карточки справочников.office—VARCHAR(255)(nullable): подпись филиала/адрес точки из карточки справочника.confirmations—SMALLINT NOT NULL DEFAULT 1: число подтверждений номера (сортировка «больше подтверждений — выше»).
Добавление безопасно: nullable / со значением по умолчанию — старые строки не переписываются.
RLS таблицы (tenant_isolation) — построчная, новые колонки её не меняют (rls-reviewer review).
Canon-sync (ранее не синканные инкременты 29.06, внесены в тот же DDL):
phone_typeVARCHAR(12)+CHECK (… IN ('city','mobile','tollfree'))— тип номера (DaData). Миграция2026_06_29_120100.boxVARCHAR(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. FKtenant_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. UNIQUEautopodbor_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. UNIQUEautopodbor_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, без FK — balance_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: TIMESTAMP → TIMESTAMPTZ
(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_at → TIMESTAMPTZ.
Счётчики: 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), FKtenant_id→tenantsON DELETE CASCADE) слепок маршрутизации проекта + индексыproject_routing_snapshots_tenant_date_idx,project_routing_snapshots_signal_idx+ RLSproject_routing_snapshots_tenant_isolation+GRANTдляcrm_app_user/crm_supplier_worker+ 2 стартовые партиции_y2026_m05/_y2026_m06.
Guard миграции 2026_05_27_120000: CREATE TABLE → CREATE TABLE IF NOT EXISTS, оба
CREATE INDEX → IF NOT EXISTS, обе партиции PARTITION OF → IF 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убран из DEFAULTusers.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_preferencesDEFAULT:new_lead.emailfalse → 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_hash—VARCHAR(255), bcrypt-хеш 6-значного кода (plain в БД не хранится).tokenостаётся внутренним UUID строки.email_verifications.failed_attempts—SMALLINT 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_index—CREATE 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 SELECTcrm_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_id→phone_ranges_imports. 3 CHECK (def_code300..999,subject_code1..89,from_num≤to_num). Индексidx_phone_ranges_lookup (def_code, from_num, to_num). GRANT SELECTcrm_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,INSERTcrm_supplier_worker/ SELECTcrm_app_user. Стартовые партицииlead_region_resolution_log_y2026_m05,_y2026_m06.MonthlyPartitionManager::PARTITIONED_TABLES+entry'lead_region_resolution_log' => 'received_at'.system_settings+keypartition_retention_months_lead_region_resolution_log = '12'(retention ~365 дней).
Изменено:
supplier_leads+4 колонки:resolved_subject_code(CHECK 1..89),region_source(CHECKdadata/rossvyaz/tag/unknown),dadata_qc,phone_operator. Persistent-idempotency резолва (retry не повторяет DaData-вызов).deals+2 колонки:phone_operator,region_substitutedBOOLEAN NOT NULL DEFAULT FALSE (флаг подмены региона на запасном канале —routing_step3).
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). FKtenant_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+keypartition_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.platformVARCHAR(4)→VARCHAR(8) —DIRECT(6 символов) не вмещался.project_supplier_links.platformVARCHAR(4)→VARCHAR(8) — то же.supplier_leads.platformVARCHAR(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.
Добавлено:
suppliersrowcode='direct'—DIRECT — Прямые проекты,cost_rub=1.00,accepts_types={websites,calls,sms},channel='sites'. ИспользуетсяLedgerService::resolveSupplierIdfallback'ом для 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— seedsuppliers.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_countINTEGER 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— использовалась только удалённымLowBalanceNotificationmailable'ом. - 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_logPdErasureService::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(PKsupplier_lead_id+tenant_id, FK наsupplier_leadsON DELETE CASCADE,deal_idбез FK —dealsпартиционирована, RLStenant_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 = 24activity_log_retention_months = 36tenant_operations_log_retention_months = 24webhook_log_retention_months = 3balance_transactions_retention_months = 84pd_processing_log_retention_months = 36saas_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).
v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)
+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 резерва канала миграции проектов (specdocs/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_regionsprojects+1 COMMENT ON COLUMN наregions
Не изменено (deprecated, удаление в Plan 6.5):
projects.region_mask(помечен inline-комментарием DEPRECATED)projects.region_mode- CHECK
chk_projects_region_mask_range
Семантика:
regions=[]→ «вся РФ» (паритет с legacyregion_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; RLStenant_isolation; UNIQUE(tenant_id, status_ru); partial indexidx_import_unknown_statuses_unresolved). - +5 колонок в
import_log:entity_type,source_system,mapping_config,unknown_statuses_count,dry_run. - GRANTs:
import_unknown_statusesпокрыта umbrellaGRANT ... 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.phptenants.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')+ CHECKchk_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_leadsdefense-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 CHECKchk_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_logSaaS-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_chargesappend-only ledger списаний за каждый доставленный лид. Колонки: id, tenant_id, deal_id, deal_received_at, tier_no (smallint), price_per_lead_kopecks (uint), charged_at, created_at. Composite FKlead_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 — RLStenant_isolationENABLE+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_tiersSaaS-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_no—tier_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_projectsSaaS-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 indexidx_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, партии 12–15. См. ниже §A.
- v8.2 (04.05.2026) — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.
Связано:
Прил_М_Analiz_originala_v8_3.mdv1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.1–3.3).Открытые_вопросы_v8_3.mdv1.12 — закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.README_АРХИВ_v8_4.md— состав архива.CRM_bp-gr_Инструкция_v8_4.mdv8.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. Изменения
- P0-02: Добавлены
ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITYиCREATE POLICY tenant_isolation ON impersonation_tokens(схема ~строка 540). - O-perf-02: Добавлен индекс
idx_failed_webhook_jobs_logнаfailed_webhook_jobs(webhook_log_id). - 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 мог:
- показывать unread-счётчик у иконки колокольчика (даже если user'а нет в момент события);
- накапливать историю «10 последних» при заходе на страницу;
- сохранять прочитанные/непрочитанные между сессиями.
Что изменилось:
-
Новая таблица
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() ); -
Индекс
idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL— основной UI-флоу «непрочитанные user'а». -
Индекс
idx_in_app_notifications_user_recent (user_id, created_at DESC)— «последние 50» (с прочитанными). -
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 сpayloadcastarray,read_atcastdatetime.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-defaultnew_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 удалённые сделки возвращались.
Что изменилось:
deals.deleted_at TIMESTAMPTZ— новая колонка. NULL = живая сделка,not null= soft-deleted (момент удаления).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— новый endpointDELETE /api/deals?tenant_id=X&ids[]=...: bulk-updatedeleted_at=NOW()через RLS + defense-in-depthwhere(tenant_id). ЗаписьActivityLog event=deal.deletedдля каждой удалённой сделки.
Frontend changes:
dealsApi.bulkDeleteDeals(payload)— DELETE helper.DealsView::applyBulkDeleteasync: 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.mdv1.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. БезDEFERRABLEFK проверяется immediate — нарушается до момента INSERT вdeals.
Эволюция решения (две стадии):
- Стадия 1 — DEFERRABLE INITIALLY DEFERRED FK (что попало в schema.sql v8.7). Каноничный PG-паттерн для composite FK с child-first INSERT'ом в одной транзакции. В bare-транзакции (production worker) — работает: constraint проверяется на COMMIT.
- Стадия 2 — pivot на advisory lock (что попало в production-код). При запуске Pest с
DatabaseTransactionstrait 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.mdv1.21 — блок «CTO-17 addendum» (08.05.2026 поздний вечер).CRM_bp-gr_Инструкция_v8_5.md §11(in-place hygiene) — DDLwebhook_dedup_keysсинхронизирован с DEFERRABLE.CLAUDE.mdv1.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 отклонил DDLCREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL(schema.sql:1263v8.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 NULL→CREATE 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(одна транзакция, одна вставка). - Стало: двустадийная операция в одной транзакции:
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;- Если
is_new = true→INSERT INTO deals ...с pre-allocateddeal_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'+ CHECKIN ('manual','round_robin','least_loaded'). MVP = manual; round_robin/least_loaded зарезервированы для Post-MVP.projects.ttfr_target_minutes(Биз-18)INT NOT NULL DEFAULT 15+ CHECKBETWEEN 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'+ CHECKIN ('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+ CHECKBETWEEN 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+ CHECKBETWEEN 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). Для cronleads: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.phpbefore_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) — спринты 14–15 (как и в 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) — отложены до спринта 14–15 (старт реализации Уровня 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_id → impersonation_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
Связь deals ↔ deal_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_id→created_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'].
- Пример: для B2 —
- Новый 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(теперь только черезremindersAPI).
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, scopeactive).tests/Unit/Models/DealTest.php— удалить тесты наreminder_text/reminder_at. Добавить тесты на relationreminders()и helperhasActiveReminder().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_code → supplier_id) |
Полей в supplier_invoices |
17 | 17 | =0 (supplier_code → supplier_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_code→supplier_id+ relationsupplier().
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;
}
Триггеры вызова:
- Cron
limits:recalcв 00:00 МСК для всехis_active=TRUEпроектов (adjustment_reason='daily_recalc'). - После
BalanceTransaction::commit()— для всех проектов тенанта (balance_recoveredилиbalance_low). - После списания за лид в
ProcessWebhookJob— еслиeffective < target(balance_low). - При
ProjectCreatedevent (project_created). - При
TariffChangedevent (tariff_change). - При
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_code→supplier_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:
- Достать предыдущую версию schema.sql из git (
git show <commit>:schema.sql > schema.sql.v8.1). - Drop & recreate БД.
- Применить старую 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.