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

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

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

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

2180 lines
159 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.59, консолидированная — разворачивает БД с нуля).
## v8.59 (2026-06-30) — Автоподбор: богатый провенанс источника + canon-sync инкрементов 29.06
Шаг 2 «Конкурентного поля»: один номер часто встречается в нескольких местах — в коде
сайта и в карточках справочников (2ГИС/Яндекс) с РАЗНЫМИ адресами. Старый контракт хранил
лишь одно `provenance_url/label` — список «где нашли» терялся. Добавлены три колонки в
`autopodbor_sources`; фронт (`SourceDto.where_found/confirmations/office`) уже умеет их
показывать кликабельным списком с подтверждениями.
Миграция: `app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php`
(идемпотентная: `ADD COLUMN` под guard'ом `information_schema.columns`).
**Добавлено (`autopodbor_sources`):**
- **`where_found`** — `JSONB` (nullable): список мест `[{label,url},…]`; число подтверждений
= его длина. Кликабельные ссылки на сайт/карточки справочников.
- **`office`** — `VARCHAR(255)` (nullable): подпись филиала/адрес точки из карточки справочника.
- **`confirmations`** — `SMALLINT NOT NULL DEFAULT 1`: число подтверждений номера (сортировка
«больше подтверждений — выше»).
Добавление безопасно: nullable / со значением по умолчанию — старые строки не переписываются.
RLS таблицы (`tenant_isolation`) — построчная, новые колонки её не меняют (rls-reviewer review).
**Canon-sync (ранее не синканные инкременты 29.06, внесены в тот же DDL):**
- **`phone_type`** `VARCHAR(12)` + `CHECK (… IN ('city','mobile','tollfree'))` — тип номера
(DaData). Миграция `2026_06_29_120100`.
- **`box`** `VARCHAR(16) NOT NULL DEFAULT 'proposal'` + `CHECK (… IN ('proposal','field'))`
+ индекс `autopodbor_sources_competitor_box_idx (competitor_id, box)` — «два ящика» (§14.1).
Миграция `2026_06_29_120000`.
**⏸ Известный хвост (отдельный canon-sync):** колонка `box` у `autopodbor_competitors`
(+ `CHECK` + индекс `autopodbor_competitors_tenant_box_idx`, та же миграция `2026_06_29_120000`)
в DDL `schema.sql` ещё не синкана. Точная пересверка сводных счётчиков (таблицы/индексы/RLS)
отложена — прецедент v8.54/v8.55. Воркстри-фича `avtopodbor`, на проде НЕ разворачивалась.
## v8.58 (2026-06-28) — Автоподбор конкурентов: 3 таблицы (autopodbor_runs/competitors/sources)
Фундамент фичи «Автоподбор конкурентов» (ИИ-агент находит клиенту конкурентов и их
источники). Три tenant-isolated таблицы. RLS-политики сразу в харднинг-форме v8.57
(`NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`).
Миграции: `app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php`,
`…_100100_create_autopodbor_competitors.php`, `…_100200_create_autopodbor_sources.php`.
**Добавлено:**
- **`autopodbor_runs`** — платный «прогон» агента: `kind` (`search`/`study`/`resolve`),
`status` (`queued`/`running`/`done`/`empty`/`failed`), `region_code`, `params` (jsonb),
`competitor_id`, `price_rub_charged` (decimal 12,2), `balance_transaction_id`, `error_code`,
`created_at`/`started_at`/`finished_at`. FK `tenant_id`→tenants CASCADE.
Индексы `(tenant_id, status)`, `(tenant_id, kind, status)`.
- **`autopodbor_competitors`** — конкурент: `search_run_id` (FK→autopodbor_runs SET NULL),
`name`, `description`, `is_federal`, `relevance_pct`, `origin` (`auto`/`manual`/`resolve`),
`site_url`, `directory_urls` (jsonb), `provenance` (jsonb), `dedup_key`,
`study_run_id` (FK→autopodbor_runs SET NULL), `studied_at`, `created_at`.
UNIQUE `autopodbor_competitor_dedup` `(tenant_id, search_run_id, dedup_key)`.
- **`autopodbor_sources`** — источник конкурента: `competitor_id` (FK CASCADE),
`study_run_id` (FK→autopodbor_runs CASCADE), `signal_type` (`site`/`call`), `identifier`
(голова домена / `7xxxxxxxxxx`), `phone_kind` (`real`/`substitute`/null), `provenance_url`,
`provenance_label`, `dedup_key`, `created_project_id` (FK→projects SET NULL), `created_at`.
UNIQUE `autopodbor_source_dedup` `(competitor_id, dedup_key)`.
- **RLS:** все три — `ENABLE` + `FORCE ROW LEVEL SECURITY` + политика `tenant_isolation`
в форме `USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`.
**NB:** UNIQUE с NULL в `search_run_id` (Postgres допускает несколько NULL) → дедуп «своих
конкурентов» (search_run_id NULL) обеспечивается в коде (`AutopodborDedup`), не индексом.
Счётчик RLS-политик: 44 → 47 (+3).
---
## v8.57 (2026-06-26) — RLS GUC hardening: NULLIF во всех политиках tenant_isolation (инцидент входа)
**Инцидент.** После переезда на Yandex Managed PG (PgBouncer transaction pooling, порт 6432)
вход в портал начал падать: 60 ошибок за день, **все на таблице `users`**. Причина —
политики `tenant_isolation` вычисляли `current_setting('app.current_tenant_id')::bigint`.
На пуло-соединении, обслуживающем auth-bootstrap (резолв `users` по email/id ДО tenant-контекста),
GUC `app.current_tenant_id` либо пуст (`''`**22P02** invalid input syntax for type bigint),
либо не задан (→ **42704** unrecognized configuration parameter). На прямом self-managed
подключении роль обходила RLS (BYPASSRLS), на управляемой базе BYPASSRLS-атрибута нет —
обход только через `srv_bypass` для служебных ролей, а приложение ходит под изолированной
`crm_app_user`.
**Фикс.** Все **44** политики `tenant_isolation` приведены к
`NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`:
- флаг `, true` → нет 42704 (параметр не задан → NULL, не ошибка);
- `NULLIF(..., '')` → нет 22P02 (пусто → NULL, не ошибка).
Пустой/незаданный GUC → `tenant_id = NULL` → 0 строк. **Изоляция при ЗАДАННОМ tenant НЕ меняется**
(NULLIF возвращает само значение → выражение идентично прежнему).
**5 bootstrap-таблиц** (`users`, `auth_log`, `email_verifications`, `user_recovery_codes`,
`user_sessions`) дополнительно получили разрешающую ветку `NULLIF(...) IS NULL OR ...`
они читаются/пишутся на auth-роутах БЕЗ `tenant`-middleware (вход, регистрация-подтверждение,
2FA-восстановление, запись сессии), где GUC штатно пуст. Доступ там — по точному
`user_id`/email/token, не перебором; при заданном tenant ветка `IS NULL` ложна → обычная изоляция.
Решение DB-only (не код-фикс `SET LOCAL` в каждом эндпоинте) выбрано ради надёжности —
ни один вызов не теряется. **rls-reviewer: APPROVE-WITH-NITS** (изоляция при заданном tenant
не ослаблена; standing-инвариант — доступ к этим 5 при пустом GUC только exact-match —
задокументирован).
**Структурно БД не меняется** — переписаны только `USING`/`WITH CHECK` 44 политик.
Счётчики таблиц/индексов/RLS-политик(44)/функций/триггеров — без изменений.
**Миграция:** `db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql` (идемпотентна:
`DROP POLICY IF EXISTS` + `CREATE`; обёрнута в `BEGIN/COMMIT`). Применена на боевой кластер
`c9q2cvtjpq3hgq6l0r96` (под членством `crm_migrator`): 44 safe / 0 unsafe, `lead_charges`
FORCE RLS сохранён, изоляция проверена (`deals` empty=0 / tenant2=1013), вход endpoint = 422.
Защита от повторного ввода небезопасного приведения — тест `RlsGucHardeningGuardTest`.
## v8.56 (2026-06-26) — Путь А (Managed PG), шов C: пересчёт аудита без session_replication_role
Тело функции `audit_block_mutation()` доработано: при метке сессии `app.audit_rebuild='on'`
И (текущая роль superuser — dev/test postgres; ЛИБО член `crm_migrator` — покрывает
`crm_supplier_worker`, под которым идёт пересчёт на проде) мутация аудит-строки
пропускается. Это заменяет `SET session_replication_role='replica'` (superuser-only,
**недоступно в Yandex Managed PostgreSQL**) при пересборке hash-цепочки командой
`audit:rebuild-chain`. **Append-only гарантия сохранена:** без метки любой UPDATE/DELETE
аудита по-прежнему запрещён (`ERRCODE check_violation`). EXISTS-гард на `crm_migrator`
не даёт функции падать на dev, где ролей `crm_*` нет.
```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, партии 1215. См. ниже §A.
- **v8.2 (04.05.2026)** — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.
**Связано:**
- `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.13.3).
- `Открытые_вопросы_v8_3.md` v1.12 — закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.
- `README_АРХИВ_v8_4.md` — состав архива.
- `CRM_bp-gr_Инструкция_v8_4.md` v8.4 §19.10 — outbound webhook (источник изменений v8.4, финал 06.05.2026).
- `CRM_bp-gr_Инструкция_v8_5.md` (готовится) — narrative-обоснование v8.5 для §10/§12.5.5/§14/§17/§19.10/§22/§23.10/Прил.И.
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Y.0`, `Y.1`, …, `Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
---
# Запись S — v8.10 → v8.11 (09.05.2026) — hygiene-фиксы аудита
**Источник:** [docs/audit_2026-05-09.md](../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) — спринты 1415 (как и в v8.4).
- Структуры под Биз-12 (телефония + call recording) — `call_recordings` оставлена закомментированным заделом в секции 17 schema.sql; реальная активация Post-MVP при первом запросе клиента.
- Расширения PG `pg_partman`/`pgaudit`/`pg_anonymizer` — фаза 3 по Прил. Н.
## Y.10. Совместимость
- **Forward-only:** v8.5 разворачивается с нуля и не требует миграции с v8.4 (база ещё не в production — фаза 0).
- **Будущая прод-миграция** (после Б-1 → спринт 11+) — единственная транзакция `BEGIN; \i schema.sql; COMMIT;` от пустой базы до текущей версии.
- **Backfill для `api_keys.expires_at`** — при переходе с v8.4 на v8.5 на dev/staging выполнить `UPDATE api_keys SET expires_at = NOW() + INTERVAL '365 days' WHERE expires_at IS NULL;` ДО применения `ALTER ... SET NOT NULL`. На production это не требуется (база с нуля).
---
# Запись Z — v8.3 → v8.4 (06.05.2026)
**Источник изменений:**
- Переписывание narrative v8.3 → v8.4 06.05.2026, раздел §19.10 «Outbound webhook».
- Решение OPEN-И-2 (закрыто 04.05.2026): Уровень 1 стратегии CRM-интеграций — outbound webhook на MVP.
- Тех-долг шапки narrative v8.4: «при правке §7 добавить DDL `outbound_webhook_subscriptions` и `outbound_webhook_deliveries`».
## Z.0. Сводка
| Параметр | v8.3 | v8.4 | Δ |
|---|---|---|---|
| Логических таблиц | 51 | **53** | +2 |
| Партиций | 12 | 12 | =0 |
| Индексов | 81 | **86** | +5 |
| RLS-политик | 31 | **33** | +2 |
| Защищённых таблиц (ENABLE) | 32 | **34** | +2 |
| Полей в `tenants` | 23 | 23 | =0 |
## Z.1. Новые таблицы
### Z.1.1. `outbound_webhook_subscriptions`
Регистрация подписок тенантов на исходящие события сделок. Hash secret + key_prefix аналогично `api_keys` (раздел 19.3 narrative). Список событий — JSONB-массив с whitelist на стороне приложения. Не более 10 активных подписок на тенанта (проверка в Application layer; SQL-слой обеспечивает только базовый CHECK на структуру `events`).
**Поля:** `id, tenant_id, user_id, name, target_url, secret_hash, secret_prefix, events JSONB, custom_headers JSONB, is_active, paused_at, last_delivery_at, last_failure_at, consecutive_failures, created_at, updated_at`.
**Индексы:** `idx_outbound_subs_tenant_active` (partial WHERE is_active), `idx_outbound_subs_secret_prefix`.
### Z.1.2. `outbound_webhook_deliveries`
Журнал попыток доставки. Retention 90 дней (как `webhook_log`). Status-флоу: `pending → success | failed → permanently_failed` после 7 попыток. Retry с возрастающим интервалом (30 сек / 5 мин / 30 мин / 2 ч / 6 ч / 24 ч — см. narrative §19.10.6).
**Поля:** `id, tenant_id, subscription_id, delivery_uuid, event, payload JSONB, attempt_number SMALLINT, status, http_status_code, response_body, response_time_ms, error_message, scheduled_at, started_at, finished_at, next_retry_at, created_at`.
**Индексы:** `idx_outbound_deliveries_subscription` (по подписке), `idx_outbound_deliveries_status_pending` (partial для воркера retry), `idx_outbound_deliveries_created`.
## Z.2. RLS-политики
Обе таблицы получили `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY tenant_isolation` по `tenant_id` — стандартный паттерн tenant-таблиц (как `api_keys`, `webhook_log`).
## Z.3. Что НЕ добавлено в v8.4
- Таблицы `crm_connections` / `crm_field_mappings` (упомянуты в плане v8.4 для §7) — отложены до **спринта 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.*