Files
portal/db/CHANGELOG_schema.md
T
Дмитрий a42647c6fe feat(автоподбор): богатый провенанс источника — список «где нашли», офис, подтверждения
Шаг 2 «Конкурентного поля»: один номер встречается в нескольких местах —
код сайта плюс карточки 2ГИС/Яндекс с разными адресами. Раньше хранилось
одно provenance_url/label — список терялся. Теперь сквозной провод
движок→контракт→джоб→БД→API; фронт уже умел показывать кликабельным
списком с подтверждениями.

- autopodbor_sources +3 колонки where_found/office/confirmations
  миграция 2026_06_30_120000, идемпотентная, RLS-review APPROVE 7/7
- canon-sync schema.sql v8.59 плюс CHANGELOG, вкл. catch-up phone_type/box 29.06
- тесты бэкенда автоподбора 122/122

НЕ на проде, воркстри avtopodbor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:40:30 +03:00

159 KiB
Raw Blame History

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

Назначение: консолидированный журнал изменений schema.sql. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.

Файл схемы: schema.sql (текущая версия — v8.59, консолидированная — разворачивает БД с нуля).

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

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

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

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

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

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

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

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

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

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

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

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

Добавлено:

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Удалено:

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

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

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

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

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

Изменено:

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

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

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

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

Добавлено:

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

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

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

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

Добавлено:

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

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

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

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

Добавлено:

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

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

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

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

Добавлено:

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

Изменено:

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

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

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

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


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

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

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

Добавлено:

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

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

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

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

Изменено:

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

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

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

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

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

Изменено:

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

Добавлено:

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

Не изменено:

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

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

Миграции:

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

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

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

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

Добавлено:

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

Изменено:

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

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

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

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

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

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

Удалено:

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

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

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

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

Миграция: 2026_05_24_140000_drop_legacy_webhook_artefacts

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

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

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

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

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

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

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

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

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

Применение:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Колонки:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Изменения:

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

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

Изменения:

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

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

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

Семантика:

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

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

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

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

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

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

v8.20 (11.05.2026 — Plan 5)

Added:

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

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

Изменения:

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

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

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

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

Связано:

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

Backend changes (DealController):

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

Frontend changes:

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

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

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

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


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

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

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

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

Изменение:

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

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

Деплой:

ALTER TABLE users ALTER COLUMN totp_secret TYPE TEXT;

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

Связано:

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

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

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

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

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

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

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

App\Jobs\ProcessWebhookJob::upsertDeal():

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

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

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

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

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

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

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

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

Импакт:

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

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

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

Связано:

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

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

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

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

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

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

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

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

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

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

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

Метрики schema.sql:

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

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

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

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

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

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

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

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

Y.0. Сводка

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

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

Y.1.1. project_user_assignments (CTO-16)

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

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

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

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

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

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

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

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

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

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

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

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

Y.2.4. ALTER (1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Z.0. Сводка

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

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

Z.1.1. outbound_webhook_subscriptions

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

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

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

Z.1.2. outbound_webhook_deliveries

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A.0. Сводка

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

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

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

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

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

App\Models\Deal:

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

App\Models\Supplier:

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

App\Models\Tenant:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

App\Http\Controllers\SaasAdmin\TenantController:

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

A.1.4. Validation Requests

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

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

App\Http\Requests\StoreProjectRequest, UpdateProjectRequest:

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

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

App\Http\Requests\UpdateTenantRequest:

  • desired_daily_numbers: nullable|integer|min:1.

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

<DealCard.vue>:

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

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

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

<DealList.vue>:

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

<ProjectForm.vue>:

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

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

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

<SaasAdminTenantCard.vue>:

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

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

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

BEGIN;

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

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

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

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

ALTER TABLE reminders DROP COLUMN is_done;

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

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

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

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

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

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

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

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

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

COMMIT;

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


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

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

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

A.3.2. Feature-тесты

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

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

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

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

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

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

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

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


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

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

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

B.0. Сводка

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

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

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

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

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

App\Models\Project:

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

App\Models\PdSubjectRequest:

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

App\Models\SupplierLeadCost:

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

App\Models\SupplierInvoice:

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

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

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

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

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

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

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

App\Services\Pd\ProcessingRestrictionGuard:

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

App\Services\Incidents\IncidentLogger:

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

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

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

Админка SaaS:

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

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

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

B.1.6. UI / Vuetify

Карточка проекта (см. Прил. М §4.5):

  • Чекбоксы поставщиков B1/B2/B3 (мульти-чекбокс из suppliers где is_active=TRUE).
  • При выборе B2 — раскрытие подформы по схеме suppliers.settings_schema (поля sender_name, keyword).
  • Number-input «Целевой дневной лимит» (daily_limit_target).
  • Readonly-blob «Реальный лимит сегодня: X (скорректировано по балансу, см. подробнее)» с ссылкой на /limit-adjustments.
  • Toggle «Включить/Исключить регионы» (region_mode).
  • Дерево 8 округов (мульти-чекбокс) — преобразуется в region_mask на сабмите.
  • 7 чекбоксов дней приёма — преобразуются в delivery_days_mask на сабмите.

Админка SaaS — новые экраны:

  • /admin/incidents — таблица + форма создания (Прил. Г v8.2).
  • /admin/pd-subject-requests/{id} — кнопка «Ограничить обработку» (compliance).

B.2. Что изменилось в данных существующих таблиц

B.2.1. supplier_lead_costs

  • Все строки: supplier_id → ID строки b1 в suppliers (бэкфил в патче).
  • supplier_code — удалено.
  • Логика чтения себестоимости: было supplier_lead_costs.cost_rub (snapshot из system_settings.supplier_default_cost_rub). Стало то же самое, но cost_rub теперь должен браться из suppliers.cost_rub через supplier_id для всех новых строк. Для старых остаётся snapshot.

B.2.2. supplier_invoices

  • То же — supplier_codesupplier_id.

B.2.3. projects

  • 6 новых полей с дефолтами:
    • daily_limit_target = 10 (10 лидов/день).
    • region_mask = 255 (все 8 округов разрешены).
    • region_mode = 'include'.
    • delivery_days_mask = 127 (все 7 дней).
    • effective_daily_limit_today = NULL (требует первого пересчёта).
    • effective_limit_calculated_at = NULL.

Важно: после применения патча — запустить php artisan limits:recalc --all ОДИН РАЗ, чтобы заполнить effective_daily_limit_today для всех существующих проектов.

B.2.4. pd_subject_requests

  • Новое поле processing_restricted = FALSE для всех существующих строк (default).
  • Compliance-админу — пройти список открытых обращений и выставить флаг там, где он по факту должен быть TRUE.

B.2.5. system_settings

  • 4 новых ключа: schema_version, limits_recalc_cron_enabled, limits_recalc_minute_offset, limits_balance_low_log_enabled.

B.3. Что НЕ изменилось (но требует внимания в v8.4)

B.3.1. Схема статусов сделок

Свободная state-machine подтверждена аудитом (партия 11). У нас тоже свободная — изменений не нужно. Будет явно зафиксировано в narrative §8 v8.4.

B.3.2. deals

Карточка сделки в оригинале не имеет файлов / задач (партия 11.6). У нас тоже — паритет. Без изменений в схеме.

B.3.3. Биллинг

В оригинале мульти-кошельковая модель (4 счётчика), у нас — одновалютная. Заказчик подтверждает упрощение (Биз-11) — без изменений.

B.3.4. RLS на deals для processing_restricted

В этом патче RLS-политика на deals для блокировки выборки по субъектам с processing_restricted=TRUE НЕ добавлена. Причина: сложная логика связи (deal через phone/email с pd_subject_requests). Будет добавлено в v8.3 после уточнения с юристом и compliance-админом.


B.4. Шаги применения

B.4.1. Установка с нуля (новые dev / staging окружения)

# 1. Создать БД
createdb -E UTF8 liderra

# 2. Применить консолидированную schema.sql v8.2
psql $DB_URL -f schema.sql

# 3. Smoke-проверки
psql $DB_URL -c "SELECT value FROM system_settings WHERE key = 'schema_version';"
# Ожидается: 8.2

psql $DB_URL -c "SELECT COUNT(*) FROM suppliers WHERE code IN ('b1','b2','b3');"
# Ожидается: 3

psql $DB_URL -c "SELECT COUNT(*) FROM lead_statuses;"
# Ожидается: 14

psql $DB_URL -c "SELECT COUNT(*) FROM tariff_plans WHERE is_active = TRUE;"
# Ожидается: 4

B.4.2. Миграция с v8.1 на v8.2 (существующие dev/staging)

Поскольку в проекте сейчас используется консолидированный подход (один файл schema.sql вместо последовательности патчей), для миграции существующих окружений с v8.1 нужно:

Вариант А — пересоздание БД (рекомендуется для dev/staging до публичного запуска):

# 1. Backup данных, которые нужно сохранить (если есть)
pg_dump --data-only $DB_URL > data-backup-$(date +%Y%m%d).sql

# 2. Drop & recreate
dropdb liderra && createdb -E UTF8 liderra
psql $DB_URL -f schema.sql

# 3. Восстановить нужные данные (если есть)
# Внимание: формат supplier_lead_costs изменился (supplier_code → supplier_id),
# при восстановлении из старого dump'а понадобится трансформация.

Вариант Б — ручная миграция (только если данные нельзя терять):

  • Сравнить v8.1 и v8.2 (например, через git diff schema.sql.v8.1 schema.sql).

  • Применить вручную ALTER'ы из дельты.

  • Бэкфил supplier_lead_costs.supplier_id ← suppliers WHERE code='b1'.

  • В этом случае может быть полезно сгенерировать diff-патч на лету:

    diff -u schema.sql.v8.1 schema.sql > migration-v8.1-to-v8.2.diff
    

B.4.3. Production (когда дойдём)

К моменту production-запуска проект ещё не имеет реальных данных, поэтому применяется как «установка с нуля» (вариант А). Если позже потребуется миграция production → следующая версия — соберу отдельный патч-файл с ALTER'ами (см. также Прил. И v8.2 раздел 5 «Migration runbook»).

После применения:

  • Запустить php artisan limits:recalc --all — заполнит effective_daily_limit_today для всех существующих проектов.
  • Мониторить Sentry на ProcessingRestrictedException — это нормальные ожидаемые exception'ы при попытках работать с restricted-субъектами.

B.5. Откат

Поскольку файл консолидированный, отката «как такового» нет — есть только восстановление из backup'а.

Рекомендация: на dev/staging — pg_dump перед каждым применением schema.sql.

Если нужно «вернуться на v8.1» на свежем deploy:

  1. Достать предыдущую версию schema.sql из git (git show <commit>:schema.sql > schema.sql.v8.1).
  2. Drop & recreate БД.
  3. Применить старую schema.

При наличии данных — отдельный rollback-патч можно собрать по запросу (DROP TABLE incidents_log/suppliers/project_suppliers/project_limit_adjustments + DROP COLUMN из projects/pd_subject_requests + восстановление supplier_code).


B.6. Связь с Прил. М

Все 7 групп изменений детально обоснованы в Analiz_originala_v8_3.md (Прил. М v1.0):

Группа изменений Раздел Прил. М
1. processing_restricted §3.3
2. incidents_log §3.3
3. suppliers §2.1, §3.1
4. project_suppliers §2.1, §3.1
5. Миграция supplier_code → supplier_id §3.1
6. Расширение projects §2.2, §3.2
7. project_limit_adjustments §2.2, §3.2

Версия changelog: 1.0 от 04.05.2026.


Журнал ведётся при каждом изменении schema.sql. Новые записи добавляются сверху, перед записью A. Документ объединён из CHANGELOG-v8_2.md и CHANGELOG-v8_3.md в рамках оптимизации архива v8.3++ → v8.3++ optimized 05.05.2026. Версия документа: 1.0 от 05.05.2026.