# 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.57, консолидированная — разворачивает БД с нуля).

## 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_*` нет.

```sql
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 сверяет глазами, что
заливка прошла ровно — от этого зависят заказанные у поставщика на завтра лиды.

```sql
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 МСК (вне окна) досылает их обычным путём.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- **`project_routing_snapshots`** — партиционированный (`PARTITION BY RANGE (snapshot_date)`,
  composite PK `(snapshot_date, project_id)`, 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 TABLE` → `CREATE TABLE IF NOT EXISTS`, оба
`CREATE INDEX` → `IF NOT EXISTS`, обе партиции `PARTITION OF` → `IF NOT EXISTS`, перед
`CREATE POLICY` добавлен `DROP POLICY IF EXISTS` (PG не знает `CREATE POLICY IF NOT EXISTS`).

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

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

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

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

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

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

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

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

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

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

**Удалено:**

- **`reminders`** — таблица напоминаний по сделкам (раздел 17.5) удалена
  (`Schema::dropIfExists('reminders')`).
- **4 индекса** `idx_reminders_*` (`idx_reminders_due`, `idx_reminders_deal`,
  `idx_reminders_tenant_user_active`, `idx_reminders_tenant_active`) — уходят
  каскадом вместе с таблицей.
- **RLS-политика** `tenant_isolation ON reminders` + `ENABLE ROW LEVEL SECURITY`.
- **COMMENT'ы** на таблицу и колонки (`created_by`, `assignee_id`, `completed_at`).
- Ключ **`reminder`** убран из 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_hash`** — `VARCHAR(255)`, bcrypt-хеш 6-значного кода
  (plain в БД не хранится). `token` остаётся внутренним UUID строки.
- **`email_verifications.failed_attempts`** — `SMALLINT NOT NULL DEFAULT 0`, лимит 5
  неверных вводов (как `impersonation_tokens.failed_attempts`). TTL строки — 15 минут
  (`expires_at`). Счётчики таблиц/индексов/RLS/функций/триггеров в шапке схемы — без изменений.
- **GRANT SELECT, INSERT, UPDATE** на `email_verifications` для 4 ролей (самозапись пишет
  через BYPASSRLS — нет tenant-GUC на публичном роуте).
- **`tenants.status` расширен `VARCHAR(20)` → `VARCHAR(30)`** — латентный дефект схемы: CHECK
  допускал `'pending_email_confirm'` (21 символ), но колонка была 20 → значение не влезало.
  Самозапись (G1/SP1) первой реально ставит этот статус (`ALTER COLUMN status TYPE VARCHAR(30)`).

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

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

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

**Добавлено:**

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

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

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

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

**Добавлено:**

- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (SaaS-level, без RLS).
  Поля: `source_url`, `rows_inserted`/`rows_updated`, `checksum_sha256`, `status`
  (`in_progress`/`completed`/`failed`/`rolled_back`), `error`, `completed_at`.
  GRANT 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_id`→`phone_ranges_imports`. 3 CHECK
  (`def_code` 300..999, `subject_code` 1..89, `from_num` ≤ `to_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).

## v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)

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

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

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

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

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

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

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

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

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

- `supplier_manual_sync_queue` — очередь яруса 3 резерва канала миграции проектов
  (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](../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_no` — `tier_no BETWEEN 1 AND 7`). 2 индекса: UNIQUE на (tier_no, effective_from), btree на (is_active, effective_from). **НЕ tenant-scoped** — конфигурация админом Лидерры; RLS НЕ применяется. Per-tenant override out of scope для MVP (один тариф на всю Лидерру). SELECT-only для tenant-приложения: REVOKE ALL FROM crm_app_user + GRANT SELECT TO crm_app_user (миграция оборачивает оба в DO $$ EXISTS-check для совместимости с dev без роли). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §7.2. Метрики: 58 базовых таблиц (+1) / 103 индекса (+2) / RLS/функции/триггеры без изменений.
- **v8.13 (10.05.2026)** — Plan 1/5 Task 2: создание `supplier_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, партии 12–15. См. ниже §A.
- **v8.2 (04.05.2026)** — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.

**Связано:**

- `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.1–3.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](../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, оба про работу/коммуникации):

   ```sql
   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](https://www.postgresql.org/docs/16/sql-createpolicy.html)).

**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-БД:**

```sql
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-БД:**

```sql
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)` — не вмещается.

**Изменение:**

```sql
-- До
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 приведена к тому же паттерну.

**Деплой:**

```sql
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()`:

```php
$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):**

```sql
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 NULL` → `CREATE INDEX` (без UNIQUE). Индекс остаётся для скорости lookup'а master-сделок (Биз-19 dedup query 24-часового окна).

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

- **Было:** `INSERT INTO deals ... ON CONFLICT (tenant_id, source_crm_id) DO UPDATE` (одна транзакция, одна вставка).
- **Стало:** двустадийная операция в одной транзакции:
  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 = true` → `INSERT 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)

```sql
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)

```sql
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)

```sql
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`) доступа НЕ должно иметь даже теоретически.

```sql
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) — спринты 14–15 (как и в v8.4).
- Структуры под Биз-12 (телефония + call recording) — `call_recordings` оставлена закомментированным заделом в секции 17 schema.sql; реальная активация Post-MVP при первом запросе клиента.
- Расширения PG `pg_partman`/`pgaudit`/`pg_anonymizer` — фаза 3 по Прил. Н.

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

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

---

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

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

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

## Z.0. Сводка

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

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

### Z.1.1. `outbound_webhook_subscriptions`

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

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

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

### Z.1.2. `outbound_webhook_deliveries`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

## A.0. Сводка

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

---

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

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

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

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

**`App\Models\Deal`:**

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

**`App\Models\Supplier`:**

- `$fillable` дополнить: `channel`, `supports_sender_name`, `supports_keyword`, `supports_csv_upload`, `supports_domains_list`.
- `$casts`: 4 boolean-поля → `'boolean'`.
- Новый метод `availableFields(): array` — возвращает массив имён полей, которые UI должен показать в форме проекта (на основании capabilities).
  - Пример: для B2 — `['sender_name', 'keyword']`; для B3 — `['sender_name']`; для B1 — `['domains_list', 'csv_upload']`.
- Новый 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()`:

  ```php
  '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 без потери данных:

```sql
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_code` → `supplier_id`) |
| Полей в `supplier_invoices` | 17 | 17 | =0 (`supplier_code` → `supplier_id`) |
| Индексов | 67 | **80** | +13 |
| RLS-политик | 29 | **30** | +1 (`project_limit_adjustments`) |
| Seed-записей в `suppliers` | — | **3** | +3 (B1/B2/B3) |
| Записей в `system_settings` | 19 | **23** | +4 |

---

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

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

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

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

**`App\Models\Project`:**

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

**`App\Models\PdSubjectRequest`:**

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

**`App\Models\SupplierLeadCost`:**

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

**`App\Models\SupplierInvoice`:**

- То же — `supplier_code` → `supplier_id` + relation `supplier()`.

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

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

```php
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_code` → `supplier_id`.

### B.2.3. `projects`

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

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

### B.2.4. `pd_subject_requests`

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

### B.2.5. `system_settings`

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

---

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

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

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

### B.3.2. `deals`

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

### B.3.3. Биллинг

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

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

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

---

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

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

```bash
# 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 до публичного запуска):**

```bash
# 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-патч на лету:

  ```bash
  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.*
