a42647c6fe
Шаг 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>
2180 lines
159 KiB
Markdown
2180 lines
159 KiB
Markdown
# 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, партии 12–15. См. ниже §A.
|
||
- **v8.2 (04.05.2026)** — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.
|
||
|
||
**Связано:**
|
||
|
||
- `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.1–3.3).
|
||
- `Открытые_вопросы_v8_3.md` v1.12 — закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.
|
||
- `README_АРХИВ_v8_4.md` — состав архива.
|
||
- `CRM_bp-gr_Инструкция_v8_4.md` v8.4 §19.10 — outbound webhook (источник изменений v8.4, финал 06.05.2026).
|
||
- `CRM_bp-gr_Инструкция_v8_5.md` (готовится) — narrative-обоснование v8.5 для §10/§12.5.5/§14/§17/§19.10/§22/§23.10/Прил.И.
|
||
|
||
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Y.0`, `Y.1`, …, `Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
|
||
|
||
---
|
||
|
||
# Запись S — v8.10 → v8.11 (09.05.2026) — hygiene-фиксы аудита
|
||
|
||
**Источник:** [docs/audit_2026-05-09.md](../docs/audit_2026-05-09.md) (commit `b6ae8dd`).
|
||
|
||
## S.1. Изменения
|
||
|
||
1. **P0-02:** Добавлены `ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY` и `CREATE POLICY tenant_isolation ON impersonation_tokens` (схема ~строка 540).
|
||
2. **O-perf-02:** Добавлен индекс `idx_failed_webhook_jobs_log` на `failed_webhook_jobs(webhook_log_id)`.
|
||
3. **O-perf-03:** Добавлен индекс `idx_rejected_deals_log_webhook` на `rejected_deals_log(webhook_log_id)`.
|
||
|
||
## S.2. Метрики (после v8.11)
|
||
|
||
- 56 базовых таблиц + 12 партиций (без изменений, 68 CREATE TABLE)
|
||
- **97 индексов** (было 95, +2)
|
||
- **38 RLS-политик** (было 37, +1 = `tenant_isolation` на `impersonation_tokens`)
|
||
- 5 функций, 13 триггеров (без изменений)
|
||
|
||
## S.3. Применение
|
||
|
||
Для нового стенда: `cd app && php artisan migrate:fresh`. Для существующих данных — `ALTER TABLE` + `CREATE POLICY` + `CREATE INDEX CONCURRENTLY` тремя раздельными DDL.
|
||
|
||
---
|
||
|
||
# Запись T — v8.9 → v8.10 (09.05.2026)
|
||
|
||
**Источник изменений:** этап 2 (этап 2a) плана P0 «Notification delivery». Email-канал реализован в v1.65 (этап 1), но in-app канал (bell-icon в `AppLayout`) требует persistence: при триггере события (new_lead/reminder/...) запись в БД, чтобы UI мог:
|
||
|
||
1. показывать unread-счётчик у иконки колокольчика (даже если user'а нет в момент события);
|
||
2. накапливать историю «10 последних» при заходе на страницу;
|
||
3. сохранять прочитанные/непрочитанные между сессиями.
|
||
|
||
**Что изменилось:**
|
||
|
||
1. Новая таблица `in_app_notifications` (после `reminders` в schema, оба про работу/коммуникации):
|
||
|
||
```sql
|
||
CREATE TABLE in_app_notifications (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
event VARCHAR(50) NOT NULL, -- new_lead|reminder|...
|
||
title VARCHAR(255) NOT NULL,
|
||
body TEXT,
|
||
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
|
||
payload JSONB DEFAULT '{}'::jsonb, -- доп. поля для UI
|
||
read_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
```
|
||
|
||
2. Индекс `idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL` — основной UI-флоу «непрочитанные user'а».
|
||
3. Индекс `idx_in_app_notifications_user_recent (user_id, created_at DESC)` — «последние 50» (с прочитанными).
|
||
4. RLS `tenant_isolation` на `in_app_notifications` (стандартная политика по `current_setting('app.current_tenant_id')`).
|
||
|
||
**Почему отдельная таблица, а не Laravel `notifications`:**
|
||
|
||
- Laravel default `notifications` — generic morphable, не tenant-scoped, нет нашей RLS-обёртки;
|
||
- наша event-матрица фиксирована (8 событий из `users.notification_preferences`), generic-морфы избыточны;
|
||
- удобнее JOIN'ить по `deal_id` для UI-link (deep-link на DealDetailDrawer).
|
||
|
||
**Backend changes (отдельный коммит):**
|
||
|
||
- `App\Models\InAppNotification` — Eloquent с `payload` cast `array`, `read_at` cast `datetime`.
|
||
- `NotificationService::notifyInApp(User $user, string $event, array $opts)` — INSERT row с применением пользовательских prefs (`notification_preferences[event].inapp=true`).
|
||
- `notifyNewLead` теперь шлёт ДВА канала: email (если prefs.email=true) И in-app (если prefs.inapp=true). По schema-default `new_lead.inapp=true` — большинство получит in-app, и только подписавшиеся — email.
|
||
- INSERT в `in_app_notifications` обёрнут в транзакцию с `SET LOCAL app.current_tenant_id` для RLS-WITH CHECK ([USING/WITH CHECK симметричны без явного WITH CHECK](https://www.postgresql.org/docs/16/sql-createpolicy.html)).
|
||
|
||
**Frontend changes (этап 2b, отдельный коммит):**
|
||
|
||
- API endpoints (GET /api/notifications + PATCH /api/notifications/{id}/read + PATCH /mark-all-read).
|
||
- Pinia store `useNotificationsStore` с polling 30 сек для unread-count.
|
||
- Bell-icon в `AppLayout.topbar` с pip + `v-menu` для последних 10.
|
||
|
||
**Миграция production-БД:**
|
||
|
||
```sql
|
||
CREATE TABLE in_app_notifications (...); -- см. выше
|
||
CREATE INDEX CONCURRENTLY idx_in_app_notifications_user_unread ...;
|
||
CREATE INDEX CONCURRENTLY idx_in_app_notifications_user_recent ...;
|
||
ALTER TABLE in_app_notifications ENABLE ROW LEVEL SECURITY;
|
||
CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
```
|
||
|
||
DOWN: `DROP TABLE in_app_notifications` (необратимо без архива истории, на MVP допустимо).
|
||
|
||
**Метрики после v8.10:** 56 таблиц + 12 партиций + **95 индексов** (+2 от 93) + **37 RLS** (+1 от 36) + 5 функций + 13 триггеров.
|
||
|
||
---
|
||
|
||
# Запись U — v8.8 → v8.9 (09.05.2026)
|
||
|
||
**Источник изменений:** этап 5/5 авто-плана — backend-persistence для UI-операции applyBulkDelete в DealsView. До этого изменения bulk-delete выполнялся только локально (mutation `dealsState.splice` без API-вызова), при reload-btn удалённые сделки возвращались.
|
||
|
||
**Что изменилось:**
|
||
|
||
1. `deals.deleted_at TIMESTAMPTZ` — новая колонка. NULL = живая сделка, `not null` = soft-deleted (момент удаления).
|
||
2. `CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL` — partial index по самому частому UI-фильтру (DealsView::index скрывает удалённые).
|
||
|
||
**Почему soft-delete (не hard):**
|
||
|
||
- Партиционированная `deals` имеет CASCADE-FK от `webhook_dedup_keys` (через composite-FK `(deal_id, deal_received_at)`). Hard-delete пройдёт CASCADE и удалит dedup-keys → следующий webhook с тем же `vid` создаст дубль (нарушение идемпотентности §5.5).
|
||
- Soft-delete сохраняет dedup-keys и позволяет restore-flow (отдельный endpoint POST /api/deals/{id}/restore — отдельный коммит).
|
||
- `applyBulkDelete` в UI: «Удалить N сделок» с двойным подтверждением. На production — soft-delete + email-уведомление tenant'у.
|
||
- UX-pattern: на API-fail локальный update НЕ откатывается (как у applyBulkStatus в v1.52) — пользователь видит что хотел, перезагрузит позже.
|
||
|
||
**Backend changes (DealController):**
|
||
|
||
- `index` / `show` / `transition` / `update` / `export` все добавили `whereNull('deleted_at')` фильтр. Soft-deleted скрыты от регулярных flow.
|
||
- `destroy` — новый endpoint `DELETE /api/deals?tenant_id=X&ids[]=...`: bulk-update `deleted_at=NOW()` через RLS + defense-in-depth `where(tenant_id)`. Запись `ActivityLog event=deal.deleted` для каждой удалённой сделки.
|
||
|
||
**Frontend changes:**
|
||
|
||
- `dealsApi.bulkDeleteDeals(payload)` — DELETE helper.
|
||
- `DealsView::applyBulkDelete` async: optimistic local-removal + backend-вызов если auth.user; на success — toast «Удалено N»; на fail — warning toast (без auto-rollback — UX-pattern как в bulk-status).
|
||
|
||
**Миграция production-БД:**
|
||
|
||
```sql
|
||
ALTER TABLE deals ADD COLUMN deleted_at TIMESTAMPTZ;
|
||
CREATE INDEX CONCURRENTLY ON deals (tenant_id, status) WHERE deleted_at IS NULL;
|
||
```
|
||
|
||
ALTER TABLE на партиционированной `deals` distributes колонку во все партиции автоматически (PG 14+). CONCURRENTLY для index — без блокировки production-таблицы.
|
||
|
||
---
|
||
|
||
# Запись V — v8.7 → v8.8 (09.05.2026)
|
||
|
||
**Источник изменений:** реализация 2FA setup wizard (`AuthController::useRecoveryCode` + `TwoFactorSetupController::confirm`) поймал PDOException `String data, right truncated: 7 ... character varying(255)` при сохранении encrypted TOTP secret.
|
||
|
||
**Корневая причина:**
|
||
|
||
- `Google2FA::generateSecretKey()` возвращает 32-символьный base32-secret.
|
||
- Eloquent cast `'encrypted'` через `Crypt::encryptString` упаковывает в JSON `{iv,value,mac,tag}` base64 → длина около **256 chars** для 32-байт исходника.
|
||
- Schema v8.7 имеет `users.totp_secret VARCHAR(255)` — не вмещается.
|
||
|
||
**Изменение:**
|
||
|
||
```sql
|
||
-- До
|
||
totp_secret VARCHAR(255), -- ШИФРУЕТСЯ Crypt::encrypt
|
||
-- После
|
||
totp_secret TEXT, -- ШИФРУЕТСЯ Crypt::encrypt (encrypted ~256 chars > VARCHAR(255))
|
||
```
|
||
|
||
`saas_admin_users.totp_secret_enc` уже был `TEXT` в v8.5 (§22.4.2 / Прил. Г.4.2 — encrypted) — теперь users.totp_secret приведена к тому же паттерну.
|
||
|
||
**Деплой:**
|
||
|
||
```sql
|
||
ALTER TABLE users ALTER COLUMN totp_secret TYPE TEXT;
|
||
```
|
||
|
||
Данных в production пока нет (фаза 2 на dev), миграция безболезненная. На dev прогнан `php artisan migrate:fresh` для liderra и liderra_testing.
|
||
|
||
**Связано:**
|
||
|
||
- `CLAUDE.md` v1.40 — schema v8.7 → v8.8 в §0/§2.
|
||
- `app/app/Models/User.php` — добавлен cast `'totp_secret' => 'encrypted'`.
|
||
- `app/app/Http/Controllers/Api/TwoFactorSetupController.php` — новый wizard.
|
||
|
||
---
|
||
|
||
# Запись W — v8.6 → v8.7 (08.05.2026 поздний вечер)
|
||
|
||
**Источник изменений:**
|
||
|
||
- **CTO-17 addendum (фаза 1, Webhook PoC)** — при имплементации `App\Jobs\ProcessWebhookJob` по спецификации narrative §5.5 v8.6 Pest-тест поймал FK violation:
|
||
- `SQLSTATE[23503]: Foreign key violation … webhook_dedup_keys_deal_id_deal_received_at_fkey … Ключ (deal_id, deal_received_at)=(N, …) отсутствует в таблице "deals".`
|
||
- §5.5 v8.6 спецификация: INSERT в `webhook_dedup_keys` через `nextval('deals_id_seq')` ДО INSERT в `deals`. Без `DEFERRABLE` FK проверяется immediate — нарушается до момента INSERT в `deals`.
|
||
|
||
**Эволюция решения (две стадии):**
|
||
|
||
1. **Стадия 1 — DEFERRABLE INITIALLY DEFERRED FK** (что попало в schema.sql v8.7). Каноничный PG-паттерн для composite FK с child-first INSERT'ом в одной транзакции. В bare-транзакции (production worker) — работает: constraint проверяется на COMMIT.
|
||
2. **Стадия 2 — pivot на advisory lock** (что попало в production-код). При запуске Pest с `DatabaseTransactions` trait DEFERRED FK всё равно падает: PG проверяет deferred constraints на **RELEASE SAVEPOINT** (внутренняя `DB::transaction()` Job'а становится savepoint при наличии outer-txn от теста), не на outer COMMIT. Это PG-семантика subtransactions, не Laravel-bug. Воспроизводится stand-alone PHP-скриптом.
|
||
|
||
**Финальный архитектурный паттерн (v8.7 production code):**
|
||
|
||
`App\Jobs\ProcessWebhookJob::upsertDeal()`:
|
||
|
||
```php
|
||
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
|
||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||
|
||
$existing = DB::selectOne(
|
||
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
|
||
[$tenant->id, $sourceCrmId],
|
||
);
|
||
|
||
if ($existing !== null) {
|
||
// UPDATE deal по composite-ключу
|
||
} else {
|
||
// INSERT deal первым (FK immediate OK), затем INSERT dedup_key
|
||
}
|
||
```
|
||
|
||
**Альтернативы отброшены:**
|
||
|
||
- Reverse INSERT order (deals → dedup_keys) без advisory lock — race condition между concurrent webhook'ами с одинаковым `vid`: оба INSERT'ят deal, второй получает unique violation на dedup_key и оставляет orphan deal. Требует cleanup-логики, может упасть при retry.
|
||
- SELECT FOR UPDATE на dedup_keys — race condition (FOR UPDATE на несуществующей строке не блокирует).
|
||
- Advisory lock покрывает оба сценария: serialization для одинакового vid, lock авто-освобождается на COMMIT/ROLLBACK.
|
||
|
||
**Schema-изменение (v8.7):**
|
||
|
||
```sql
|
||
CREATE TABLE webhook_dedup_keys (
|
||
...
|
||
FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at)
|
||
ON DELETE CASCADE
|
||
DEFERRABLE INITIALLY DEFERRED -- v8.7
|
||
);
|
||
```
|
||
|
||
`DEFERRABLE` сохранён как **defense-in-depth** — позволяет альтернативные паттерны записи в production-коде без savepoints (например, batch-импорт CSV в одной транзакции). `ON DELETE CASCADE` по-прежнему срабатывает immediate на DELETE строки в `deals`.
|
||
|
||
**Импакт:**
|
||
|
||
- `db/schema.sql:1315-1325` — единственная DDL-правка (`DEFERRABLE INITIALLY DEFERRED` + блок-комментарий).
|
||
- Метрики schema без изменений: 55 таблиц + 12 партиций, 92 индекса, 36 RLS-политик, 36 ENABLE RLS, 5 функций, 13 триггеров.
|
||
- Narrative ТЗ §11 (DDL `webhook_dedup_keys`) обновлён — добавлен `DEFERRABLE` + объяснение defense-in-depth.
|
||
- Narrative ТЗ §5.5 (PHP-код) обновлён — переписан на advisory lock + INSERT-deal-первым.
|
||
- Narrative ТЗ §6.5 (CSV-импорт) и §2.4 (поток) обновлены — упоминают advisory lock.
|
||
|
||
**Проверка (08.05.2026 поздний вечер):**
|
||
|
||
- `php artisan migrate:fresh --env=testing` на `liderra_testing` БД — прошёл.
|
||
- Pest **31/31** на полном test suite (DealModelTest 6 + ProcessWebhookJobTest 6 + RlsSmokeTest 4 + TenantModelsTest 8 + SetTenantContextTest 5 + ExampleTest 2) — все зелёные.
|
||
- Всё backward-compat: dev-БД `liderra` пересоздана через тот же `migrate:fresh`.
|
||
|
||
**Связано:**
|
||
|
||
- `Открытые_вопросы_v8_3.md` v1.21 — блок «CTO-17 addendum» (08.05.2026 поздний вечер).
|
||
- `CRM_bp-gr_Инструкция_v8_5.md §11` (in-place hygiene) — DDL `webhook_dedup_keys` синхронизирован с DEFERRABLE.
|
||
- `CLAUDE.md` v1.12 — schema v8.6 → v8.7 в §0.
|
||
|
||
---
|
||
|
||
# Запись X — v8.5 → v8.6 (08.05.2026 поздний вечер)
|
||
|
||
**Источник изменений:**
|
||
|
||
- **CTO-17 (фаза 1, реальный запуск миграции)** — переоткрытие schema.sql v8.5 после первой попытки `php artisan migrate:fresh` на dev-БД `liderra`. PostgreSQL 16 отклонил DDL `CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL` (`schema.sql:1263` v8.5):
|
||
- `SQLSTATE[0A000]: Feature not supported: 7 ОШИБКА: ограничение уникальности в секционированной таблице должно включать все секционирующие столбцы. DETAIL: В ограничении UNIQUE таблицы "deals" не хватает столбца "received_at", входящего в ключ секционирования.`
|
||
- PostgreSQL требует, чтобы UNIQUE на партиционированной таблице включал все partition key columns. `deals` партиционируется по `received_at`.
|
||
- Включить `received_at` в UNIQUE нельзя — ломает идемпотентность webhook'ов от crm.bp-gr.ru. Тот же `vid` при retry с разным timestamp создаст дубль вместо UPDATE существующей сделки. Это противоречит ТЗ §15 («Используется для идемпотентности») и §16 (`ON CONFLICT (tenant_id, source_crm_id) DO UPDATE`).
|
||
|
||
**Решение (08.05.2026, заказчик: «архитектурный фикс»):**
|
||
|
||
Идемпотентность вынесена в отдельную не-партиционированную таблицу `webhook_dedup_keys`:
|
||
|
||
| Поле | Тип | Назначение |
|
||
|---|---|---|
|
||
| `tenant_id` | `BIGINT NOT NULL` | + `source_crm_id` = идемпотентный ключ webhook'а |
|
||
| `source_crm_id` | `BIGINT NOT NULL` | `vid` из webhook crm.bp-gr.ru |
|
||
| `deal_id` | `BIGINT NOT NULL` | ссылка на сделку |
|
||
| `deal_received_at` | `TIMESTAMPTZ NOT NULL` | partition key для FK на партиционированную `deals` |
|
||
| `created_at`, `updated_at` | `TIMESTAMPTZ` | стандартный аудит |
|
||
|
||
- **PRIMARY KEY**: `(tenant_id, source_crm_id)` — обеспечивает глобальную идемпотентность независимо от партиционирования `deals`.
|
||
- **FOREIGN KEY**: `(deal_id, deal_received_at) REFERENCES deals (id, received_at) ON DELETE CASCADE` — composite FK на partitioned-таблицу, корректно работает на PG 16.
|
||
- **INDEX**: `idx_webhook_dedup_keys_deal (deal_id, deal_received_at)` для обратного lookup'а из deal к dedup-ключу.
|
||
- **RLS**: tenant_isolation USING + WITH CHECK по `tenant_id = current_setting('app.current_tenant_id')::bigint`. Defense-in-depth поверх FK.
|
||
|
||
**Изменение в `deals`:**
|
||
|
||
- `CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL` → `CREATE INDEX` (без UNIQUE). Индекс остаётся для скорости lookup'а master-сделок (Биз-19 dedup query 24-часового окна).
|
||
|
||
**Изменение webhook handler logic (narrative ТЗ §15-16):**
|
||
|
||
- **Было:** `INSERT INTO deals ... ON CONFLICT (tenant_id, source_crm_id) DO UPDATE` (одна транзакция, одна вставка).
|
||
- **Стало:** двустадийная операция в одной транзакции:
|
||
1. `INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at) VALUES (...) ON CONFLICT (tenant_id, source_crm_id) DO UPDATE SET deal_received_at = EXCLUDED.deal_received_at, updated_at = NOW() RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at;`
|
||
2. Если `is_new = true` → `INSERT INTO deals ...` с pre-allocated `deal_id` (через `nextval('deals_id_seq')`); иначе `UPDATE deals SET ... WHERE id = :deal_id AND received_at = :deal_received_at`.
|
||
- **Trade-off:** +1 запрос на webhook. На MVP-нагрузке (≤10 RPS на тенанта) незначимо. На высоких нагрузках можно оптимизировать через CTE-комбо.
|
||
|
||
**Метрики schema.sql:**
|
||
|
||
| Метрика | v8.5 | v8.6 | Δ |
|
||
|---|---|---|---|
|
||
| Таблицы | 54 | 55 | +1 (`webhook_dedup_keys`) |
|
||
| Партиции | 12 | 12 | — |
|
||
| Индексы | 91 | 92 | +1 (`idx_webhook_dedup_keys_deal`) |
|
||
| RLS-политики | 35 | 36 | +1 (`tenant_isolation ON webhook_dedup_keys`) |
|
||
| ENABLE RLS | 35 | 36 | +1 |
|
||
| Роли БД | 4 | 4 | — |
|
||
| Триггеры | 12 | 12 | — |
|
||
| Функции | 4 | 4 | — |
|
||
|
||
**Затронутые файлы:**
|
||
|
||
- `db/schema.sql`: заголовок v8.5→v8.6, строки ~1263 (CREATE INDEX без UNIQUE), новый блок `webhook_dedup_keys` после партиций deals (~1283), `ALTER TABLE webhook_dedup_keys ENABLE RLS` (§12), `CREATE POLICY tenant_isolation ON webhook_dedup_keys` (§12).
|
||
- `db/CHANGELOG_schema.md`: запись X (этот блок).
|
||
- `docs/CRM_bp-gr_Инструкция_v8_5.md`: §15 + §16 — обновить SQL-примеры с `ON CONFLICT (tenant_id, source_crm_id) DO UPDATE` на двустадийную логику. **Техдолг следующих сессий** (см. реестр Открытых вопросов CTO-17).
|
||
- `docs/Открытые_вопросы_v8_3.md`: добавить запись CTO-17 (закрыт фиксом, но техдолг по narrative).
|
||
|
||
**Совместимость:**
|
||
|
||
- БД v8.5 → v8.6: для пустой БД (dev) — `migrate:fresh`. Для production-БД с данными миграция не применима (но v8.5 на production ещё не разворачивался — это первая live-проверка). На v8.5-dev записей deals 0 — нет потери данных.
|
||
|
||
---
|
||
|
||
# Запись Y — v8.4 → v8.5 (07.05.2026)
|
||
|
||
**Источник изменений:**
|
||
|
||
- Закрытие 27 вопросов аудита C от 07.05.2026 (Открытые_вопросы v1.12, §13.10). Решение заказчика: «A везде» (рекомендованные варианты).
|
||
- 8 P0 разблокированы для триггера фазы 1 (`composer create-project laravel/laravel app`): Биз-17/18/19, CTO-13, OPEN-И-13/14/15/16.
|
||
- 12 P1 + 7 P2 — реализация в фазах 1–3.
|
||
|
||
## Y.0. Сводка
|
||
|
||
| Параметр | v8.4 | v8.5 | Δ |
|
||
|---|---|---|---|
|
||
| Логических таблиц | 53 | **54** | +1 (project_user_assignments) |
|
||
| Партиций | 12 | 12 | =0 |
|
||
| Индексов | 86 | **91** | +5 |
|
||
| RLS-политик | 34 | **35** (+ WITH CHECK на 2 существующих) | +1 (project_user_assignments) |
|
||
| Защищённых таблиц (ENABLE) | 34 | **35** | +1 |
|
||
| Ролей БД | 3 | **4** | +1 (crm_audit_writer) |
|
||
| Триггеров | 0 | **12** | +12 |
|
||
| Функций | 0 | **4** | +4 |
|
||
| Колонок (приращение) | — | **+26** | +26 (см. §Y.2) |
|
||
| Полей в `tenants` | 23 | 25 | +2 (api_key_limit, telegram_bot_token) |
|
||
|
||
## Y.1. Новые таблицы
|
||
|
||
### Y.1.1. `project_user_assignments` (CTO-16)
|
||
|
||
M2M-связь «проект ↔ менеджеры» с per-assignment skills (JSONB-массив). На MVP при `projects.assignment_strategy='manual'` (Биз-17 default) таблица не используется. После Post-MVP cron lead-router читает active members проекта и распределяет лидов согласно стратегии.
|
||
|
||
**Поля:** `project_id, user_id, skills JSONB, is_active, created_at, updated_at`. PK = `(project_id, user_id)`.
|
||
|
||
**Индекс:** `idx_project_user_assignments_user` partial WHERE `is_active=TRUE`.
|
||
|
||
**RLS:** `tenant_isolation` через JOIN на `projects.tenant_id` (USING + WITH CHECK).
|
||
|
||
## Y.2. Новые колонки
|
||
|
||
### Y.2.1. P0-блок (8)
|
||
|
||
- **`projects.assignment_strategy`** (Биз-17) `VARCHAR(32) NOT NULL DEFAULT 'manual'` + CHECK `IN ('manual','round_robin','least_loaded')`. MVP = manual; round_robin/least_loaded зарезервированы для Post-MVP.
|
||
- **`projects.ttfr_target_minutes`** (Биз-18) `INT NOT NULL DEFAULT 15` + CHECK `BETWEEN 1 AND 1440`. SLA по Time To First Response, alert при просрочке.
|
||
- **`deals.duplicate_of_id`** (Биз-19) `BIGINT` (без FK — партиционированная таблица). Окно дедупа 24 ч, проверяется приложением через индекс `(tenant_id, phone)`.
|
||
- **`deals.escalated_count`** (OPEN-И-25) `INT NOT NULL DEFAULT 0` + CHECK `>= 0`. Счётчик переназначений cron'ом `leads:escalate-stale`.
|
||
- **`deals.assigned_at`** (OPEN-И-25) `TIMESTAMPTZ`. Момент назначения менеджера.
|
||
- **`saas_admin_users.sso_provider`** (OPEN-И-13) `VARCHAR(32) NOT NULL DEFAULT 'yandex360'` + CHECK `IN ('yandex360','local')`.
|
||
- **`saas_admin_users.is_break_glass`** (OPEN-И-13) `BOOLEAN NOT NULL DEFAULT FALSE`. Аварийный аккаунт при недоступности IDP.
|
||
- **`auth_log.log_hash`, `activity_log.log_hash`, `pd_processing_log.log_hash`, `saas_admin_audit_log.log_hash`, `balance_transactions.log_hash`** (OPEN-И-15) `BYTEA` — заполняется триггером `audit_chain_hash()` (см. §Y.4.1).
|
||
|
||
### Y.2.2. P1-блок (12)
|
||
|
||
- **`tenants.api_key_limit`** (OPEN-И-19) `INT NOT NULL DEFAULT 5` + CHECK `BETWEEN 1 AND 10`.
|
||
- **`tenants.telegram_bot_token`** (Биз-20) `TEXT` (зашифровано Crypt::encryptString).
|
||
- **`users.telegram_user_id`** (Биз-20) `BIGINT` (Telegram chat_id).
|
||
- **`impersonation_tokens.second_approver_id`** (CTO-15 + Ю-9) `BIGINT REFERENCES saas_admin_users(id)`.
|
||
- **`impersonation_tokens.second_approval_at`** (CTO-15 + Ю-9) `TIMESTAMPTZ`.
|
||
- **`deals.utm_source/utm_medium/utm_campaign/utm_content`** (CTO-14) 4 × `VARCHAR(100)`.
|
||
- **`suppliers.quality_score`** (Биз-22) `NUMERIC(3,2) NOT NULL DEFAULT 1.00` + CHECK `BETWEEN 0.00 AND 9.99`.
|
||
- **`deals.time_in_form_seconds`** (Биз-22) `INT`. Сколько секунд физлицо заполняло форму.
|
||
- **`deals.lead_score`** (Биз-22) `NUMERIC(5,2)` (заполняется триггером `calc_lead_score()`, см. §Y.4.2).
|
||
|
||
### Y.2.3. P2-блок (7) — 2 новых колонки + остальные через DDL/триггеры
|
||
|
||
- **`deals.region_code`** (Биз-23) `VARCHAR(8)`. ISO 3166-2:RU; автоопределение по prefix phone в PhonePrefixService.
|
||
- **`deals.city`** (Биз-23) `VARCHAR(100)`. Свободный текст из webhook/enrichment.
|
||
|
||
(Остальные P2-решения реализованы через DDL-блоки: триггеры/функции в §Y.4, закомментированный задел `call_recordings` для Биз-12/OPEN-И-26 — в новой секции 17 schema.sql.)
|
||
|
||
### Y.2.4. ALTER (1)
|
||
|
||
- **`api_keys.expires_at`** (OPEN-И-17 + OPEN-И-19): из NULL-able без default → `NOT NULL DEFAULT NOW() + INTERVAL '365 days'`. Миграция: backfill всем существующим NULL-ключам `NOW() + 365d` перед применением `SET NOT NULL`.
|
||
|
||
## Y.3. Новые индексы (5)
|
||
|
||
- **`(tenant_id, utm_source) WHERE utm_source IS NOT NULL`** на `deals` (CTO-14). Для когортной аналитики §12.5.5.
|
||
- **`(tenant_id, region_code) WHERE region_code IS NOT NULL`** на `deals` (Биз-23). Для гео-фильтра в §10.3.
|
||
- **`(duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** на `deals` (Биз-19). Для UI-цепочки дублей и cleanup при удалении master'а.
|
||
- **`(tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected')`** на `deals` (OPEN-И-25). Для cron `leads:escalate-stale`.
|
||
- **`idx_project_user_assignments_user(user_id) WHERE is_active = TRUE`** (CTO-16). Для «список моих назначений».
|
||
|
||
Индекс `(tenant_id, phone, received_at)` для Биз-19 24ч-lookup НЕ создан — существующий `(tenant_id, phone)` справляется с фильтрацией приложением (24-часовое окно — мелкая выборка).
|
||
|
||
## Y.4. Новые функции и триггеры
|
||
|
||
### Y.4.1. `audit_chain_hash()` + `audit_block_mutation()` + 10 audit-триггеров (OPEN-И-15)
|
||
|
||
```sql
|
||
audit_chain_hash() RETURNS TRIGGER:
|
||
prev_hash := SELECT log_hash FROM TG_TABLE_NAME ORDER BY id DESC LIMIT 1;
|
||
NEW.log_hash := digest(COALESCE(prev_hash, '') || NEW::text::bytea, 'sha256');
|
||
RETURN NEW;
|
||
|
||
audit_block_mutation() RETURNS TRIGGER:
|
||
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME;
|
||
```
|
||
|
||
На каждой из 5 audit-таблиц по 2 триггера: `BEFORE INSERT` (hash chain) + `BEFORE UPDATE OR DELETE` (block mutation). Итого 10 триггеров.
|
||
|
||
**Юридический эффект:** любая модификация audit-журнала после INSERT'а технически запрещена. При попытке INSERT с поддельной строкой между существующими — пересчёт цепочки в cron `audit:verify-chain` обнаружит разрыв (sha256-mismatch).
|
||
|
||
**Защита от ALTER TABLE … DISABLE TRIGGER:** даже при отключении триггеров роль `crm_audit_writer` (см. §Y.5) имеет только INSERT — UPDATE/DELETE заблокированы на уровне permissions.
|
||
|
||
### Y.4.2. `calc_lead_score()` + триггер `trg_deals_calc_lead_score` (Биз-22)
|
||
|
||
```sql
|
||
calc_lead_score() RETURNS TRIGGER:
|
||
IF NEW.time_in_form_seconds IS NULL → lead_score := NULL; RETURN.
|
||
quality := SELECT s.quality_score FROM project_suppliers ps JOIN suppliers s
|
||
WHERE ps.project_id = NEW.project_id AND ps.is_active AND s.is_active
|
||
ORDER BY s.sort_order, s.id LIMIT 1;
|
||
NEW.lead_score := LEAST(quality * (time_in_form_seconds / 60.0), 99.99);
|
||
```
|
||
|
||
Триггер `BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals`. Использован триггер вместо `GENERATED ALWAYS AS … STORED` потому что PostgreSQL не разрешает в STORED-выражениях JOIN на foreign tables.
|
||
|
||
### Y.4.3. `report_jobs_log_export()` + триггер `trg_report_jobs_export_log` (OPEN-И-20)
|
||
|
||
`AFTER INSERT ON report_jobs → INSERT INTO pd_processing_log (action='exported', purpose='report_job_<id>')`. Закрывает риск пропуска audit-записи при экспорте лидов из app-кода.
|
||
|
||
## Y.5. Новая роль `crm_audit_writer` (OPEN-И-15 + OPEN-И-23)
|
||
|
||
```sql
|
||
CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
|
||
GRANT USAGE ON SCHEMA public;
|
||
GRANT INSERT ON auth_log, activity_log, pd_processing_log,
|
||
saas_admin_audit_log, balance_transactions;
|
||
GRANT USAGE ON sequences соответствующих таблиц.
|
||
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.
|
||
```
|
||
|
||
Application пишет в audit-таблицы под этой ролью через temporary `SET ROLE crm_audit_writer`, что обеспечивает невозможность fraud-удаления записей даже от `super_admin` SaaS.
|
||
|
||
## Y.6. RLS WITH CHECK (OPEN-И-14)
|
||
|
||
Существующие политики `tenant_isolation` на двух таблицах обогащены `WITH CHECK`:
|
||
|
||
- **`saas_invoice_items`** — нельзя вставить invoice_item ссылающуюся на чужой invoice.
|
||
- **`deal_tag_pivot`** — нельзя пометить deal чужим тегом.
|
||
|
||
До v8.5 защита была только на USING (SELECT/UPDATE filter), INSERT мог пройти при наличии knowledge о `tag_id` чужого тенанта.
|
||
|
||
Новая политика `project_user_assignments` создана с обоими USING + WITH CHECK сразу.
|
||
|
||
## Y.7. REVOKE ALL на 6 saas-таблицах (OPEN-И-14)
|
||
|
||
Defense-in-depth: к этим таблицам tenant-приложение (роль `crm_app_user`) доступа НЕ должно иметь даже теоретически.
|
||
|
||
```sql
|
||
REVOKE ALL ON saas_admin_users FROM crm_app_user;
|
||
REVOKE ALL ON saas_admin_sessions FROM crm_app_user;
|
||
REVOKE ALL ON saas_admin_audit_log FROM crm_app_user;
|
||
REVOKE ALL ON incidents_log FROM crm_app_user;
|
||
REVOKE ALL ON pd_subject_requests FROM crm_app_user;
|
||
REVOKE ALL ON impersonation_tokens FROM crm_app_user;
|
||
```
|
||
|
||
## Y.8. Изменения, не отражённые в schema (только в narrative)
|
||
|
||
- **CTO-13** (e2e-тест `SET LOCAL` через PgBouncer transaction-pooling) — план в narrative §22 + Прил. И; без DDL.
|
||
- **OPEN-И-16** (Sentry whitelist + regex) — конфигурация Laravel `config/sentry.php` `before_send`; без DDL.
|
||
- **OPEN-И-18** (DNS-rebinding защита resolve→pin→connect) — реализация в `App\Services\Webhook\SSRFGuard`; без DDL.
|
||
- **OPEN-И-21** (Anti-DDoS: Nginx + Yandex SmartCaptcha + disposable-blacklist) — конфиг Nginx + Laravel middleware; без DDL.
|
||
- **OPEN-И-22** (per-tenant DEK Yandex KMS) — на уровне backup-сервиса (Прил. И); без DDL.
|
||
- **OPEN-И-24** (`pg_anonymizer` процедура) — Прил. И, расширение PG ставится в фазе 3 (Прил. Н).
|
||
- **Биз-20** Telegram-канал — спринт 9, реализация в фазе 2; DDL уже добавлен (telegram_user_id, telegram_bot_token), используется по факту с фазы 2.
|
||
- **Биз-21** generic outbound `marketing.conversion` — расширение whitelist событий в `App\Services\Outbound\EventTypes`; без DDL (хранится в `outbound_webhook_subscriptions.events`).
|
||
|
||
## Y.9. Что НЕ добавлено в v8.5 (отложено)
|
||
|
||
- Таблицы `crm_connections` / `crm_field_mappings` (Уровень 2 OPEN-И-2) — спринты 14–15 (как и в v8.4).
|
||
- Структуры под Биз-12 (телефония + call recording) — `call_recordings` оставлена закомментированным заделом в секции 17 schema.sql; реальная активация Post-MVP при первом запросе клиента.
|
||
- Расширения PG `pg_partman`/`pgaudit`/`pg_anonymizer` — фаза 3 по Прил. Н.
|
||
|
||
## Y.10. Совместимость
|
||
|
||
- **Forward-only:** v8.5 разворачивается с нуля и не требует миграции с v8.4 (база ещё не в production — фаза 0).
|
||
- **Будущая прод-миграция** (после Б-1 → спринт 11+) — единственная транзакция `BEGIN; \i schema.sql; COMMIT;` от пустой базы до текущей версии.
|
||
- **Backfill для `api_keys.expires_at`** — при переходе с v8.4 на v8.5 на dev/staging выполнить `UPDATE api_keys SET expires_at = NOW() + INTERVAL '365 days' WHERE expires_at IS NULL;` ДО применения `ALTER ... SET NOT NULL`. На production это не требуется (база с нуля).
|
||
|
||
---
|
||
|
||
# Запись Z — v8.3 → v8.4 (06.05.2026)
|
||
|
||
**Источник изменений:**
|
||
|
||
- Переписывание narrative v8.3 → v8.4 06.05.2026, раздел §19.10 «Outbound webhook».
|
||
- Решение OPEN-И-2 (закрыто 04.05.2026): Уровень 1 стратегии CRM-интеграций — outbound webhook на MVP.
|
||
- Тех-долг шапки narrative v8.4: «при правке §7 добавить DDL `outbound_webhook_subscriptions` и `outbound_webhook_deliveries`».
|
||
|
||
## Z.0. Сводка
|
||
|
||
| Параметр | v8.3 | v8.4 | Δ |
|
||
|---|---|---|---|
|
||
| Логических таблиц | 51 | **53** | +2 |
|
||
| Партиций | 12 | 12 | =0 |
|
||
| Индексов | 81 | **86** | +5 |
|
||
| RLS-политик | 31 | **33** | +2 |
|
||
| Защищённых таблиц (ENABLE) | 32 | **34** | +2 |
|
||
| Полей в `tenants` | 23 | 23 | =0 |
|
||
|
||
## Z.1. Новые таблицы
|
||
|
||
### Z.1.1. `outbound_webhook_subscriptions`
|
||
|
||
Регистрация подписок тенантов на исходящие события сделок. Hash secret + key_prefix аналогично `api_keys` (раздел 19.3 narrative). Список событий — JSONB-массив с whitelist на стороне приложения. Не более 10 активных подписок на тенанта (проверка в Application layer; SQL-слой обеспечивает только базовый CHECK на структуру `events`).
|
||
|
||
**Поля:** `id, tenant_id, user_id, name, target_url, secret_hash, secret_prefix, events JSONB, custom_headers JSONB, is_active, paused_at, last_delivery_at, last_failure_at, consecutive_failures, created_at, updated_at`.
|
||
|
||
**Индексы:** `idx_outbound_subs_tenant_active` (partial WHERE is_active), `idx_outbound_subs_secret_prefix`.
|
||
|
||
### Z.1.2. `outbound_webhook_deliveries`
|
||
|
||
Журнал попыток доставки. Retention 90 дней (как `webhook_log`). Status-флоу: `pending → success | failed → permanently_failed` после 7 попыток. Retry с возрастающим интервалом (30 сек / 5 мин / 30 мин / 2 ч / 6 ч / 24 ч — см. narrative §19.10.6).
|
||
|
||
**Поля:** `id, tenant_id, subscription_id, delivery_uuid, event, payload JSONB, attempt_number SMALLINT, status, http_status_code, response_body, response_time_ms, error_message, scheduled_at, started_at, finished_at, next_retry_at, created_at`.
|
||
|
||
**Индексы:** `idx_outbound_deliveries_subscription` (по подписке), `idx_outbound_deliveries_status_pending` (partial для воркера retry), `idx_outbound_deliveries_created`.
|
||
|
||
## Z.2. RLS-политики
|
||
|
||
Обе таблицы получили `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY tenant_isolation` по `tenant_id` — стандартный паттерн tenant-таблиц (как `api_keys`, `webhook_log`).
|
||
|
||
## Z.3. Что НЕ добавлено в v8.4
|
||
|
||
- Таблицы `crm_connections` / `crm_field_mappings` (упомянуты в плане v8.4 для §7) — отложены до **спринта 14–15** (старт реализации Уровня 2 — нативный коннектор amoCRM, OPEN-И-2). DDL появится в schema v8.5+ при подготовке этих спринтов. До этого момента outbound webhook Уровня 1 (этой записи) — единственный канал интеграции с внешними CRM, и он работает без `crm_connections`.
|
||
|
||
## Z.4. Совместимость
|
||
|
||
- **Forward-only:** v8.4 разворачивается с нуля и не требует миграции с v8.3 (база ещё не в production — фаза 0).
|
||
- **При первом деплое в production** (спринт 11 после Б-1) — миграция от пустой базы до v8.4 одной транзакцией.
|
||
|
||
## Z.5. Hotfix 06.05.2026 — P0-блокеры миграции
|
||
|
||
По итогам аудита B (06.05.2026) в `schema.sql` v8.4 найдены P0-проблемы, из-за которых `psql -f schema.sql` падал бы на пустой базе. Это правки внутри той же версии v8.4 (метрики 53/86/33/34 не меняются — только перенос FK и снятие битых `WHERE`-предикатов с partial-индексов).
|
||
|
||
### Z.5.1. Forward-FK в SaaS-блоке → ALTER TABLE после CREATE TABLE tenants
|
||
|
||
`saas_admin_sessions` (Ю-1, импersonation) и `impersonation_tokens` объявлены **выше** по тексту, чем `CREATE TABLE tenants` (раздел 3 narrative — SaaS-админка идёт перед tenant-данными). Inline-FK `REFERENCES tenants(id)` внутри их `CREATE TABLE` падает на forward-reference при разворачивании с нуля.
|
||
|
||
**Решение:** в обоих `CREATE TABLE` поля оставлены типа `BIGINT` без inline-FK; ниже, сразу после `CREATE INDEX`-блока tenants, добавлены два `ALTER TABLE ... ADD CONSTRAINT FOREIGN KEY ... REFERENCES tenants(id)` (с `ON DELETE CASCADE` для `impersonation_tokens.tenant_id`). Аналогично уже было сделано для `saas_admin_sessions.impersonating_token_id` → `impersonation_tokens(id)` в исходной v8.4.
|
||
|
||
### Z.5.2. Partial index по `expires_at` с предикатом `WHERE expires_at > NOW()`
|
||
|
||
Два индекса (`idx_saas_admin_sessions_expires`, `idx_sessions_expires`) использовали `WHERE expires_at > NOW()`. PostgreSQL запрещает в предикате частичного индекса непостоянные функции (`NOW()` — STABLE, не IMMUTABLE) — `CREATE INDEX` падает.
|
||
|
||
**Решение:** оба индекса переведены на полное поле без `WHERE`. Поле `expires_at` объявлено `NOT NULL`, поэтому partial по `IS NOT NULL` бессмыслен. Индексы используются cron-очисткой expired-сессий — полный индекс корректен.
|
||
|
||
### Z.5.3. `outbound_webhook_subscriptions.events` — снят DEFAULT '[]'
|
||
|
||
`events JSONB NOT NULL DEFAULT '[]'` конфликтовал с `CHECK (jsonb_array_length(events) > 0)`: любой INSERT без явного `events` падал бы. Снят DEFAULT, остался `NOT NULL` — приложение обязано явно передать список событий ≥ 1 элемента.
|
||
|
||
### Z.5.4. `deal_tag_pivot` — добавлены ENABLE RLS + tenant_isolation
|
||
|
||
Связь `deals` ↔ `deal_tags`. У pivot нет `tenant_id`, у `deals` (партиционированной) RLS не работает в виде `WHERE tenant_id = ...` — используется RLS через JOIN на `deal_tags(tenant_id)`. Добавлен паттерн как у `saas_invoice_items` (`invoice_id IN (...)`).
|
||
|
||
### Z.5.5. Метрики после hotfix
|
||
|
||
`grep -c '^CREATE TABLE '` = **65** (53 логических + 12 партиций), `grep -c '^CREATE INDEX\|^CREATE UNIQUE INDEX'` = **86**, `grep -c '^ALTER TABLE.*ENABLE ROW LEVEL SECURITY'` = **34**, `grep -c '^CREATE POLICY'` = **34** (1:1 соответствие, см. Z.5.4). Forward-FK на `tenants(id)` отсутствуют (первая ссылка на стр. 517 — после CREATE TABLE на стр. 506). Шапка schema.sql:107-108 синхронизирована.
|
||
|
||
### Z.5.6. Изменение метрик Z.0 после hotfix B-5
|
||
|
||
| Метрика | До hotfix | После Z.5.4 |
|
||
|---|---|---|
|
||
| RLS-политик | 33 | **34** (+1 deal_tag_pivot) |
|
||
| Защищённых таблиц (ENABLE) | 33 | **34** (+1 deal_tag_pivot) |
|
||
|
||
---
|
||
|
||
# Запись A — v8.2 → v8.3 (05.05.2026)
|
||
|
||
**Источники изменений:**
|
||
|
||
- Параллельный аудит crm.bp-gr.ru 05.05.2026 (партии 12, 13, 14, 15).
|
||
- Прил. М v1.1 (`Analiz_originala_v8_3.md`), §3.5 — детальное обоснование.
|
||
- Открытые_вопросы v1.6 (`Открытые_вопросы_v8_3.md`), раздел 12 — Биз-14/15/16.
|
||
|
||
## A.0. Сводка
|
||
|
||
| Параметр | v8.2 | v8.3 | Δ |
|
||
|---|---|---|---|
|
||
| Таблиц | 51 | **51** | =0 (без новых таблиц — `reminders` уже была в v8.2) |
|
||
| Полей в `deals` | 14 | **12** | -2 (удалены `reminder_text`, `reminder_at`) |
|
||
| Полей в `suppliers` | 11 | **16** | +5 (capabilities) |
|
||
| Полей в `tenants` | 22 | **23** | +1 (`desired_daily_numbers`) |
|
||
| Полей в `reminders` | 9 | **11** | +2 (`assignee_id`, `completed_at`; `user_id`→`created_by`; `is_done` удалено) |
|
||
| Индексов | 80 | **81** | +1 нетто (-1 `idx_deals_reminder`, +2 `reminders`) |
|
||
| RLS-политик | 31 | **31** | =0 (RLS на `reminders` уже была) |
|
||
| Записей в `system_settings` | 22 | **25** | +3 (cron purge-deleted) |
|
||
|
||
---
|
||
|
||
## A.1. Что изменилось в коде backend (Laravel)
|
||
|
||
### A.1.1. Eloquent-модели — изменённые
|
||
|
||
**`App\Models\Reminder`** (была, перепись):
|
||
|
||
- `$fillable`: убрать `user_id`, `is_done`. Добавить: `created_by`, `assignee_id`, `completed_at`.
|
||
- `$casts`: `completed_at => 'datetime'`, `is_sent => 'boolean'`.
|
||
- **Удалить** старый scope `scopeNotDone()` — заменить на `scopeActive()` с условием `whereNull('completed_at')`.
|
||
- **Удалить** старый scope `scopeForUser($userId)` — заменить на `scopeCreatedBy($userId)` (по новому полю).
|
||
- Новый scope `scopeAssignedTo($userId)` — для будущей фичи назначения (на MVP всегда NULL).
|
||
- Relations:
|
||
- `creator()` → `belongsTo(User::class, 'created_by')` (был `user()`).
|
||
- `assignee()` → `belongsTo(User::class, 'assignee_id')` (новый, для Post-MVP).
|
||
- `deal()` → `belongsTo(Deal::class)` (без изменений).
|
||
- Метод `markCompleted()` — вместо `is_done = true` ставит `completed_at = now()`.
|
||
- Метод `isCompleted(): bool` — проверка `completed_at !== null`.
|
||
|
||
**`App\Models\Deal`:**
|
||
|
||
- `$fillable`: убрать `reminder_text`, `reminder_at`.
|
||
- `$casts`: убрать `reminder_at => 'datetime'`.
|
||
- **Удалить** accessor/mutator для `reminder_text` и `reminder_at` (если были).
|
||
- Новый relation: `reminders()` → `hasMany(Reminder::class)` (вместо одиночных полей).
|
||
- Новый accessor `latestActiveReminder()` — для UI карточки сделки (показать ближайшее активное напоминание).
|
||
- Helper `hasActiveReminder(): bool` — заменяет проверку `$deal->reminder_at !== null`.
|
||
|
||
**`App\Models\Supplier`:**
|
||
|
||
- `$fillable` дополнить: `channel`, `supports_sender_name`, `supports_keyword`, `supports_csv_upload`, `supports_domains_list`.
|
||
- `$casts`: 4 boolean-поля → `'boolean'`.
|
||
- Новый метод `availableFields(): array` — возвращает массив имён полей, которые UI должен показать в форме проекта (на основании capabilities).
|
||
- Пример: для B2 — `['sender_name', 'keyword']`; для B3 — `['sender_name']`; для B1 — `['domains_list', 'csv_upload']`.
|
||
- Новый scope `scopeByChannel(string $channel)` — для фильтрации (`sites`/`calls`/`sms`).
|
||
- Helper-метод `Supplier::intersectionCapabilities(Collection $suppliers): array` — для пересечения capabilities при выборе нескольких поставщиков (для B2+B3 → `['sender_name']`, без `keyword`).
|
||
|
||
**`App\Models\Tenant`:**
|
||
|
||
- `$fillable` дополнить: `desired_daily_numbers`.
|
||
- `$casts`: `desired_daily_numbers => 'integer'`.
|
||
- Helper `getDesiredDailyNumbers(): ?int` — для отображения в UI кабинета и в админке SaaS.
|
||
|
||
### A.1.2. Сервисы — новые/изменённые
|
||
|
||
**`App\Services\ReminderService`** (был, перепись для множественных):
|
||
|
||
- `create(Deal $deal, User $creator, array $data): Reminder` — создаёт через `$deal->reminders()->create([...])`.
|
||
- `update(Reminder $reminder, array $data): void`.
|
||
- `complete(Reminder $reminder): void` — ставит `completed_at = now()` (вместо `is_done = true`).
|
||
- `delete(Reminder $reminder): void` — soft- или hard-delete (по решению заказчика; на MVP — hard-delete, паритет с оригиналом).
|
||
- `getActiveForDeal(Deal $deal): Collection` — все активные напоминания сделки.
|
||
- `getDashboardForUser(User $user, string $filter): Collection` — фильтр `today|last|future|none` (паритет с `?reminders=...` в оригинале).
|
||
- `today`: `DATE(remind_at) = CURRENT_DATE AND completed_at IS NULL`.
|
||
- `last`: `remind_at < NOW() AND completed_at IS NULL` (просроченные).
|
||
- `future`: `remind_at >= TOMORROW AND completed_at IS NULL`.
|
||
- `none`: для `Deal` — те, у которых `reminders()->active()->count() = 0`.
|
||
|
||
**`App\Services\SupplierCapabilityService`** (новый):
|
||
|
||
- `getRelevantFieldsForProject(Project $project): array` — на основании выбранных поставщиков проекта возвращает массив релевантных полей формы.
|
||
- `validateProjectFields(Project $project, array $input): array` — server-side валидация: для проекта с B3 нельзя передать `keyword` (выбросить exception).
|
||
|
||
**`App\Console\Commands\Projects\PurgeDeleted`** (новая cron-задача, Биз-14):
|
||
|
||
- Имя: `projects:purge-deleted`.
|
||
- Расписание: из `system_settings.projects_purge_deleted_cron` (по умолчанию `0 4 * * *`).
|
||
- Условия запуска: `system_settings.projects_purge_deleted_enabled = true` (по умолчанию `false`).
|
||
- Логика: проходит по всем тенантам, для каждого вызывает `Project::onlyTrashed()->where('deleted_at', '<', now()->subDays($ttl))->forceDelete()` где `$ttl = system_settings.projects_purge_deleted_ttl_days` (по умолчанию 180).
|
||
- Логирует в `incidents_log` каждый цикл с количеством физически удалённых проектов.
|
||
- **На MVP cron включён в коде, но disabled через settings** — включается админом SaaS вручную после согласования с юристом.
|
||
|
||
### A.1.3. Контроллеры — изменённые
|
||
|
||
**`App\Http\Controllers\Api\Tenant\ReminderController`** (новый):
|
||
|
||
- `GET /api/v1/deals/{deal}/reminders` — список активных напоминаний сделки.
|
||
- `POST /api/v1/deals/{deal}/reminders` — создать.
|
||
- `PATCH /api/v1/reminders/{reminder}` — изменить.
|
||
- `POST /api/v1/reminders/{reminder}/complete` — пометить выполненным.
|
||
- `DELETE /api/v1/reminders/{reminder}` — удалить.
|
||
- `GET /api/v1/reminders/dashboard?filter=today|last|future|none` — паритет с оригиналом.
|
||
|
||
**`App\Http\Controllers\Api\Tenant\DealController`:**
|
||
|
||
- В endpoint `GET /api/v1/deals` добавить query-параметр `reminders=today|last|future|none` (паритет с `?reminders=...` оригинала).
|
||
- В endpoint `GET /api/v1/deals/{deal}` присоединить `reminders` в response через `with('reminders')`.
|
||
- Из endpoint'ов `POST` и `PATCH` для `Deal` **убрать** валидацию полей `reminder_text` и `reminder_at` (теперь только через `reminders` API).
|
||
|
||
**`App\Http\Controllers\Api\Tenant\ProjectController`:**
|
||
|
||
- В response `GET /api/v1/projects/{project}` добавить вычисленное поле `available_fields` (через `SupplierCapabilityService`).
|
||
- В endpoint'ах `POST`/`PATCH` валидация поля `keyword` зависит от `supports_keyword` выбранного поставщика.
|
||
|
||
**`App\Http\Controllers\Api\Tenant\TenantController`:**
|
||
|
||
- В response `GET /api/v1/tenant` добавить `desired_daily_numbers`.
|
||
- В endpoint `PATCH /api/v1/tenant` разрешить редактирование `desired_daily_numbers` (только админ тенанта).
|
||
|
||
**`App\Http\Controllers\SaasAdmin\TenantController`:**
|
||
|
||
- В response `GET /admin/tenants/{tenant}` отображать `desired_daily_numbers` в карточке тенанта (для саппорта).
|
||
|
||
### A.1.4. Validation Requests
|
||
|
||
**`App\Http\Requests\StoreReminderRequest`** (новый):
|
||
|
||
- `text`: `nullable|string|max:255`.
|
||
- `remind_at`: `required|date|after:now`.
|
||
- `assignee_id`: `nullable|integer|exists:users,id` (на MVP не используется).
|
||
|
||
**`App\Http\Requests\StoreProjectRequest`, `UpdateProjectRequest`:**
|
||
|
||
- Условная валидация по capabilities выбранных поставщиков — через `Rule::when()`:
|
||
|
||
```php
|
||
'keyword' => [
|
||
Rule::when(
|
||
$this->supplierSupports('keyword'),
|
||
['nullable', 'string', 'max:50'],
|
||
['prohibited']
|
||
),
|
||
],
|
||
```
|
||
|
||
**`App\Http\Requests\UpdateTenantRequest`:**
|
||
|
||
- `desired_daily_numbers`: `nullable|integer|min:1`.
|
||
|
||
### A.1.5. Vuetify-frontend — компоненты
|
||
|
||
**`<DealCard.vue>`:**
|
||
|
||
- Удалить старую панель «Напоминание» с одиночными полями `reminder_text` + `reminder_at`.
|
||
- Добавить компонент `<RemindersList>` — список активных напоминаний с возможностью добавить/редактировать/закрыть/удалить.
|
||
- Кнопка «+ Добавить напоминание» открывает модалку `<ReminderForm>`.
|
||
|
||
**`<ReminderForm.vue>`** (новый):
|
||
|
||
- Поля: `text` (textarea, лимит 255), `remind_at` (date-picker + time-picker).
|
||
- На MVP **без** полей `assignee_id`, `priority`, `channel`, `recurrence` (паритет с оригиналом).
|
||
|
||
**`<DealList.vue>`:**
|
||
|
||
- Добавить дропдаун «Задачи» в шапку списка с 4 пунктами: «Дела на сегодня» / «Просроченные дела» / «Предстоящие дела» / «Сделки без задач» (URL-параметр `reminders=today|last|future|none`).
|
||
|
||
**`<ProjectForm.vue>`:**
|
||
|
||
- При выборе поставщиков (`project_suppliers`) автоматически показывать/скрывать поля на основании `available_fields` из API.
|
||
- Если выбран B2 — показать `sender_name` и `keyword`; B3 — только `sender_name`; B1 — `domains_list` и `csv_upload`.
|
||
- Если выбраны несколько — показывать пересечение (B2+B3 → только `sender_name`).
|
||
|
||
**`<TenantSettings.vue>`** (или `<ProfilePage.vue>`):
|
||
|
||
- Добавить поле «Целевое количество лидов в день» (`desired_daily_numbers`) — number input. Подсказка: «Желаемый объём — сигнал для нашего саппорта».
|
||
|
||
**`<SaasAdminTenantCard.vue>`:**
|
||
|
||
- В админке SaaS отображать `desired_daily_numbers` в карточке тенанта (read-only для саппорта; редактируемое только для admin/superadmin).
|
||
|
||
---
|
||
|
||
## A.2. Миграция данных существующих dev-окружений
|
||
|
||
Если у вас уже развёрнуто dev-окружение со схемой v8.2 и нужно мигрировать на v8.3 без потери данных:
|
||
|
||
```sql
|
||
BEGIN;
|
||
|
||
-- 1. Миграция данных reminder_text + reminder_at в reminders
|
||
INSERT INTO reminders (tenant_id, deal_id, text, remind_at, created_by, created_at)
|
||
SELECT
|
||
d.tenant_id,
|
||
d.id,
|
||
d.reminder_text,
|
||
d.reminder_at,
|
||
COALESCE(d.manager_id, (SELECT id FROM users WHERE tenant_id = d.tenant_id LIMIT 1)),
|
||
d.received_at
|
||
FROM deals d
|
||
WHERE d.reminder_at IS NOT NULL;
|
||
|
||
-- 2. Удаление старых полей и индекса
|
||
ALTER TABLE deals DROP COLUMN reminder_text;
|
||
ALTER TABLE deals DROP COLUMN reminder_at;
|
||
DROP INDEX IF EXISTS idx_deals_reminder;
|
||
|
||
-- 3. Реструктуризация reminders: user_id → created_by, is_done → completed_at
|
||
ALTER TABLE reminders RENAME COLUMN user_id TO created_by;
|
||
ALTER TABLE reminders ADD COLUMN assignee_id BIGINT REFERENCES users(id);
|
||
ALTER TABLE reminders ADD COLUMN completed_at TIMESTAMPTZ;
|
||
ALTER TABLE reminders ADD COLUMN updated_at TIMESTAMPTZ;
|
||
|
||
-- Перенести is_done = true → completed_at = now() (приближённо)
|
||
UPDATE reminders SET completed_at = COALESCE(sent_at, created_at, NOW())
|
||
WHERE is_done = TRUE;
|
||
|
||
ALTER TABLE reminders DROP COLUMN is_done;
|
||
|
||
-- Пересоздать индексы (старые с is_done больше не валидны)
|
||
DROP INDEX IF EXISTS idx_reminders_due;
|
||
DROP INDEX IF EXISTS idx_reminders_tenant_user_due;
|
||
|
||
CREATE INDEX idx_reminders_due
|
||
ON reminders(remind_at) WHERE is_sent = FALSE AND completed_at IS NULL;
|
||
CREATE INDEX idx_reminders_deal
|
||
ON reminders(deal_id);
|
||
CREATE INDEX idx_reminders_tenant_user_active
|
||
ON reminders(tenant_id, created_by, remind_at) WHERE completed_at IS NULL;
|
||
CREATE INDEX idx_reminders_tenant_active
|
||
ON reminders(tenant_id, remind_at) WHERE completed_at IS NULL;
|
||
|
||
-- 4. Расширение suppliers
|
||
ALTER TABLE suppliers
|
||
ADD COLUMN channel VARCHAR(20) NOT NULL DEFAULT 'sites'
|
||
CHECK (channel IN ('sites','calls','sms')),
|
||
ADD COLUMN supports_sender_name BOOLEAN NOT NULL DEFAULT FALSE,
|
||
ADD COLUMN supports_keyword BOOLEAN NOT NULL DEFAULT FALSE,
|
||
ADD COLUMN supports_csv_upload BOOLEAN NOT NULL DEFAULT TRUE,
|
||
ADD COLUMN supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE;
|
||
|
||
UPDATE suppliers SET
|
||
channel = 'sites', supports_sender_name = FALSE, supports_keyword = FALSE
|
||
WHERE code = 'b1';
|
||
|
||
UPDATE suppliers SET
|
||
channel = 'sms', supports_sender_name = TRUE, supports_keyword = TRUE,
|
||
supports_csv_upload = FALSE, supports_domains_list = FALSE
|
||
WHERE code = 'b2';
|
||
|
||
UPDATE suppliers SET
|
||
channel = 'sms', supports_sender_name = TRUE, supports_keyword = FALSE,
|
||
supports_csv_upload = FALSE, supports_domains_list = FALSE
|
||
WHERE code = 'b3';
|
||
|
||
-- 5. tenants.desired_daily_numbers
|
||
ALTER TABLE tenants
|
||
ADD COLUMN desired_daily_numbers INT
|
||
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0);
|
||
|
||
-- 6. system_settings: schema_version + 3 новых ключа
|
||
UPDATE system_settings SET value = '8.3' WHERE key = 'schema_version';
|
||
|
||
INSERT INTO system_settings (key, value, type, description) VALUES
|
||
('projects_purge_deleted_enabled', 'false', 'bool',
|
||
'Включён ли cron физического удаления soft-deleted проектов после TTL'),
|
||
('projects_purge_deleted_ttl_days', '180', 'int',
|
||
'TTL для физического удаления soft-deleted проектов (дней). 180 = 6 месяцев.'),
|
||
('projects_purge_deleted_cron', '0 4 * * *', 'string',
|
||
'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)');
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
**Важно:** на проде (когда появится) — миграция через Laravel migration с явным review backend-разработчиком. Этот SQL — только для dev-окружения.
|
||
|
||
---
|
||
|
||
## A.3. Тестирование
|
||
|
||
### A.3.1. Unit-тесты, которые нужно обновить
|
||
|
||
- `tests/Unit/Models/ReminderTest.php` — переписать тесты создания/чтения/обновления (поля `created_by`, `completed_at`, scope `active`).
|
||
- `tests/Unit/Models/DealTest.php` — удалить тесты на `reminder_text`/`reminder_at`. Добавить тесты на relation `reminders()` и helper `hasActiveReminder()`.
|
||
- `tests/Unit/Models/SupplierTest.php` — добавить тесты на `availableFields()` и `intersectionCapabilities()`.
|
||
- `tests/Unit/Services/ReminderServiceTest.php` — новый тест-класс на все методы сервиса (включая фильтр-сценарии `today|last|future|none`).
|
||
- `tests/Unit/Services/SupplierCapabilityServiceTest.php` — новый.
|
||
|
||
### A.3.2. Feature-тесты
|
||
|
||
- `tests/Feature/Api/Tenant/ReminderApiTest.php` — новый: CRUD endpoints для reminder + dashboard.
|
||
- `tests/Feature/Api/Tenant/DealApiTest.php` — обновить: убрать сценарии с `reminder_text`/`reminder_at`, добавить с фильтром `?reminders=today`.
|
||
- `tests/Feature/Api/Tenant/ProjectApiTest.php` — добавить: при выборе B3 запрос с полем `keyword` должен вернуть 422.
|
||
- `tests/Feature/Console/PurgeDeletedTest.php` — новый: создать просроченные soft-deleted проекты, запустить команду, проверить что физически удалены.
|
||
|
||
### A.3.3. Browser-тесты (Dusk)
|
||
|
||
- `tests/Browser/DealCardRemindersTest.php` — добавить несколько напоминаний, отметить выполненным, удалить.
|
||
- `tests/Browser/ProjectFormSupplierFieldsTest.php` — проверить, что при смене поставщика поля динамически появляются/скрываются.
|
||
|
||
---
|
||
|
||
## A.4. Документация — что обновить
|
||
|
||
- ✅ Прил. М v1.1 — `Analiz_originala_v8_3.md` (раздел 9, §3.5).
|
||
- ✅ Открытые_вопросы v1.6 (раздел 12 с Биз-14/15/16).
|
||
- ✅ schema.sql v8.3.
|
||
- ✅ CHANGELOG записи A в этом файле `CHANGELOG_schema.md` (после оптимизации архива v8.3++ optimized 05.05.2026 объединён с CHANGELOG записи B = v8.1 → v8.2).
|
||
- ✅ README_АРХИВ_v8_3.md → v8.3++ optimized (16 файлов).
|
||
- ⏸ Прил. Б+В (ER + State machines, объединены в `Приложение_Б_В_БД_диаграммы_v8_3.md` в v8.3++ optimized) — ER-часть от v8.1, требует обновления до v8.2 + v8.3 при следующей итерации (state machines изменений не получили).
|
||
- ⏸ v8.4 narrative — раскрытие 30 решений интервью + интеграция выводов аудита партий 1–15 (см. Прил. М §4 + §9.6).
|
||
|
||
---
|
||
|
||
## A.5. Хронология изменений
|
||
|
||
- **v8.1 → v8.2** (04.05.2026): suppliers + project_suppliers + лимиты проектов + processing_restricted + incidents_log. Источник: интервью 04.05 + аудит 1–11 партий.
|
||
- **v8.2 → v8.3** (05.05.2026): reminders переписана + suppliers capabilities + tenants.desired_daily_numbers + cron purge-deleted. Источник: параллельный аудит партий 12–15.
|
||
|
||
---
|
||
|
||
*Конец CHANGELOG v8.3.*
|
||
*Источник: Прил. М v1.1, §3.5; Открытые_вопросы v1.6, раздел 12; schema.sql v8.3.*
|
||
|
||
---
|
||
|
||
# Запись B — v8.1 → v8.2 (04.05.2026)
|
||
|
||
**Источники изменений:**
|
||
|
||
- Интервью с заказчиком 04.05.2026 (OPEN-Д-1, OPEN-Д-5, OPEN-И-1).
|
||
- Аудит crm.bp-gr.ru 04.05.2026, партии 1–11 (раскрытие сущности «Поставщик», динамические лимиты).
|
||
- Прил. М v1.0 (`Analiz_originala_v8_3.md`) — детальное обоснование.
|
||
|
||
## B.0. Сводка
|
||
|
||
| Параметр | v8.1 | v8.2 | Δ |
|
||
|---|---|---|---|
|
||
| Таблиц | 47 | **51** | +4 |
|
||
| Полей в `projects` | 7 | **13** | +6 |
|
||
| Полей в `pd_subject_requests` | 11 | **12** | +1 |
|
||
| Полей в `supplier_lead_costs` | 7 | 7 | =0 (`supplier_code` → `supplier_id`) |
|
||
| Полей в `supplier_invoices` | 17 | 17 | =0 (`supplier_code` → `supplier_id`) |
|
||
| Индексов | 67 | **80** | +13 |
|
||
| RLS-политик | 29 | **30** | +1 (`project_limit_adjustments`) |
|
||
| Seed-записей в `suppliers` | — | **3** | +3 (B1/B2/B3) |
|
||
| Записей в `system_settings` | 19 | **23** | +4 |
|
||
|
||
---
|
||
|
||
## B.1. Что изменилось в коде backend (Laravel)
|
||
|
||
### B.1.1. Eloquent-модели — новые
|
||
|
||
```
|
||
App\Models\Supplier → suppliers
|
||
App\Models\ProjectSupplier → project_suppliers (m2m через through)
|
||
App\Models\IncidentsLog → incidents_log (только из админки SaaS)
|
||
App\Models\ProjectLimitAdjustment → project_limit_adjustments
|
||
```
|
||
|
||
### B.1.2. Eloquent-модели — изменённые
|
||
|
||
**`App\Models\Project`:**
|
||
|
||
- Добавить `$fillable`: `daily_limit_target`, `effective_daily_limit_today`, `region_mask`, `region_mode`, `delivery_days_mask`.
|
||
- `$casts`: `effective_limit_calculated_at => 'datetime'`.
|
||
- Новый relation: `suppliers()` через `belongsToMany(Supplier::class, 'project_suppliers')->withPivot('settings', 'is_active')`.
|
||
- Новый relation: `limitAdjustments()` через `hasMany(ProjectLimitAdjustment::class)`.
|
||
- Helper `effectiveDailyLimit(): int` — возвращает `effective_daily_limit_today ?? daily_limit_target`.
|
||
|
||
**`App\Models\PdSubjectRequest`:**
|
||
|
||
- Добавить `$casts`: `processing_restricted => 'boolean'`.
|
||
- Scope `scopeRestricted($query)` для выборки тех, у кого `processing_restricted = TRUE`.
|
||
|
||
**`App\Models\SupplierLeadCost`:**
|
||
|
||
- Удалить из `$fillable`: `supplier_code`.
|
||
- Добавить в `$fillable`: `supplier_id`.
|
||
- Новый relation: `supplier()` через `belongsTo(Supplier::class)`.
|
||
|
||
**`App\Models\SupplierInvoice`:**
|
||
|
||
- То же — `supplier_code` → `supplier_id` + relation `supplier()`.
|
||
|
||
### B.1.3. Сервисы — новые
|
||
|
||
**`App\Services\Limits\EffectiveLimitCalculator`** (см. Прил. М §3.2):
|
||
|
||
```php
|
||
public function recalculate(Project $project): int {
|
||
$tenant = $project->tenant;
|
||
$balance = $tenant->balance_rub;
|
||
$leadCost = $tenant->effective_lead_cost();
|
||
$maxByMoney = (int) floor($balance / $leadCost);
|
||
$effective = min($project->daily_limit_target, $maxByMoney);
|
||
|
||
if ($effective !== $project->effective_daily_limit_today) {
|
||
ProjectLimitAdjustment::create([
|
||
'tenant_id' => $tenant->id,
|
||
'project_id' => $project->id,
|
||
'target_limit' => $project->daily_limit_target,
|
||
'effective_limit' => $effective,
|
||
'adjustment_reason' => $this->detectReason(...),
|
||
'balance_at_calc_rub' => $balance,
|
||
'lead_cost_at_calc_rub' => $leadCost,
|
||
]);
|
||
$project->update([
|
||
'effective_daily_limit_today' => $effective,
|
||
'effective_limit_calculated_at' => now(),
|
||
]);
|
||
}
|
||
return $effective;
|
||
}
|
||
```
|
||
|
||
**Триггеры вызова:**
|
||
|
||
1. Cron `limits:recalc` в 00:00 МСК для всех `is_active=TRUE` проектов (`adjustment_reason='daily_recalc'`).
|
||
2. После `BalanceTransaction::commit()` — для всех проектов тенанта (`balance_recovered` или `balance_low`).
|
||
3. После списания за лид в `ProcessWebhookJob` — если `effective < target` (`balance_low`).
|
||
4. При `ProjectCreated` event (`project_created`).
|
||
5. При `TariffChanged` event (`tariff_change`).
|
||
6. При `Project::update(['daily_limit_target' => ...])` (`target_changed`).
|
||
|
||
**`App\Services\Pd\ProcessingRestrictionGuard`:**
|
||
|
||
- Middleware / observer, проверяющий `pd_subject_requests.processing_restricted` для всех мутаций ПДн.
|
||
- При TRUE → выбрасывает `App\Exceptions\Pd\ProcessingRestrictedException`.
|
||
- См. Прил. Д v8.2.
|
||
|
||
**`App\Services\Incidents\IncidentLogger`:**
|
||
|
||
- API для админки SaaS — создание / закрытие инцидентов.
|
||
- При `type='data_breach'` — автоматическая отправка нотификации в Slack on-call + создание задачи compliance.
|
||
- См. Прил. И v8.2 раздел 6.
|
||
|
||
### B.1.4. API endpoints — новые
|
||
|
||
```
|
||
GET /api/v1/projects/{id}/effective-limit — текущий effective_daily_limit_today + причина
|
||
GET /api/v1/projects/{id}/limit-adjustments — лог автокоррекций для UI клиента (последние 30)
|
||
GET /api/v1/suppliers — публичный каталог B1/B2/B3 для селектора в форме проекта
|
||
```
|
||
|
||
**Админка SaaS:**
|
||
|
||
```
|
||
GET /admin/incidents — журнал инцидентов
|
||
POST /admin/incidents — создать (требует severity, summary, started_at)
|
||
PATCH /admin/incidents/{id} — обновить (root_cause, postmortem_url)
|
||
POST /admin/incidents/{id}/resolve — закрыть инцидент (выставить resolved_at)
|
||
GET /admin/pd-subject-requests/{id}/restrict — toggle processing_restricted (compliance)
|
||
```
|
||
|
||
### B.1.5. Job'ы и события — новые
|
||
|
||
```
|
||
App\Jobs\Limits\RecalculateProjectLimits (cron limits:recalc)
|
||
App\Events\Project\LimitAdjusted (для аудита и UI push)
|
||
App\Events\Pd\ProcessingRestrictionToggled (для аудита)
|
||
App\Events\Incident\Created
|
||
App\Events\Incident\Resolved
|
||
```
|
||
|
||
### B.1.6. UI / Vuetify
|
||
|
||
**Карточка проекта (см. Прил. М §4.5):**
|
||
|
||
- Чекбоксы поставщиков B1/B2/B3 (мульти-чекбокс из `suppliers` где `is_active=TRUE`).
|
||
- При выборе B2 — раскрытие подформы по схеме `suppliers.settings_schema` (поля `sender_name`, `keyword`).
|
||
- Number-input «Целевой дневной лимит» (`daily_limit_target`).
|
||
- Readonly-blob «Реальный лимит сегодня: X (скорректировано по балансу, см. подробнее)» с ссылкой на `/limit-adjustments`.
|
||
- Toggle «Включить/Исключить регионы» (`region_mode`).
|
||
- Дерево 8 округов (мульти-чекбокс) — преобразуется в `region_mask` на сабмите.
|
||
- 7 чекбоксов дней приёма — преобразуются в `delivery_days_mask` на сабмите.
|
||
|
||
**Админка SaaS — новые экраны:**
|
||
|
||
- `/admin/incidents` — таблица + форма создания (Прил. Г v8.2).
|
||
- `/admin/pd-subject-requests/{id}` — кнопка «Ограничить обработку» (compliance).
|
||
|
||
---
|
||
|
||
## B.2. Что изменилось в данных существующих таблиц
|
||
|
||
### B.2.1. `supplier_lead_costs`
|
||
|
||
- Все строки: `supplier_id` → ID строки `b1` в `suppliers` (бэкфил в патче).
|
||
- `supplier_code` — удалено.
|
||
- Логика чтения себестоимости: было `supplier_lead_costs.cost_rub` (snapshot из `system_settings.supplier_default_cost_rub`). Стало то же самое, но cost_rub теперь должен браться из `suppliers.cost_rub` через `supplier_id` для всех новых строк. Для старых остаётся snapshot.
|
||
|
||
### B.2.2. `supplier_invoices`
|
||
|
||
- То же — `supplier_code` → `supplier_id`.
|
||
|
||
### B.2.3. `projects`
|
||
|
||
- 6 новых полей с дефолтами:
|
||
- `daily_limit_target = 10` (10 лидов/день).
|
||
- `region_mask = 255` (все 8 округов разрешены).
|
||
- `region_mode = 'include'`.
|
||
- `delivery_days_mask = 127` (все 7 дней).
|
||
- `effective_daily_limit_today = NULL` (требует первого пересчёта).
|
||
- `effective_limit_calculated_at = NULL`.
|
||
|
||
**Важно:** после применения патча — запустить `php artisan limits:recalc --all` ОДИН РАЗ, чтобы заполнить `effective_daily_limit_today` для всех существующих проектов.
|
||
|
||
### B.2.4. `pd_subject_requests`
|
||
|
||
- Новое поле `processing_restricted = FALSE` для всех существующих строк (default).
|
||
- Compliance-админу — пройти список открытых обращений и выставить флаг там, где он по факту должен быть TRUE.
|
||
|
||
### B.2.5. `system_settings`
|
||
|
||
- 4 новых ключа: `schema_version`, `limits_recalc_cron_enabled`, `limits_recalc_minute_offset`, `limits_balance_low_log_enabled`.
|
||
|
||
---
|
||
|
||
## B.3. Что НЕ изменилось (но требует внимания в v8.4)
|
||
|
||
### B.3.1. Схема статусов сделок
|
||
|
||
Свободная state-machine подтверждена аудитом (партия 11). У нас тоже свободная — изменений не нужно. Будет явно зафиксировано в narrative §8 v8.4.
|
||
|
||
### B.3.2. `deals`
|
||
|
||
Карточка сделки в оригинале не имеет файлов / задач (партия 11.6). У нас тоже — паритет. Без изменений в схеме.
|
||
|
||
### B.3.3. Биллинг
|
||
|
||
В оригинале мульти-кошельковая модель (4 счётчика), у нас — одновалютная. Заказчик подтверждает упрощение (Биз-11) — без изменений.
|
||
|
||
### B.3.4. RLS на `deals` для `processing_restricted`
|
||
|
||
В этом патче RLS-политика на `deals` для блокировки выборки по субъектам с `processing_restricted=TRUE` **НЕ** добавлена. Причина: сложная логика связи (deal через phone/email с pd_subject_requests). Будет добавлено в v8.3 после уточнения с юристом и compliance-админом.
|
||
|
||
---
|
||
|
||
## B.4. Шаги применения
|
||
|
||
### B.4.1. Установка с нуля (новые dev / staging окружения)
|
||
|
||
```bash
|
||
# 1. Создать БД
|
||
createdb -E UTF8 liderra
|
||
|
||
# 2. Применить консолидированную schema.sql v8.2
|
||
psql $DB_URL -f schema.sql
|
||
|
||
# 3. Smoke-проверки
|
||
psql $DB_URL -c "SELECT value FROM system_settings WHERE key = 'schema_version';"
|
||
# Ожидается: 8.2
|
||
|
||
psql $DB_URL -c "SELECT COUNT(*) FROM suppliers WHERE code IN ('b1','b2','b3');"
|
||
# Ожидается: 3
|
||
|
||
psql $DB_URL -c "SELECT COUNT(*) FROM lead_statuses;"
|
||
# Ожидается: 14
|
||
|
||
psql $DB_URL -c "SELECT COUNT(*) FROM tariff_plans WHERE is_active = TRUE;"
|
||
# Ожидается: 4
|
||
```
|
||
|
||
### B.4.2. Миграция с v8.1 на v8.2 (существующие dev/staging)
|
||
|
||
Поскольку в проекте сейчас используется **консолидированный** подход (один файл schema.sql вместо последовательности патчей), для миграции существующих окружений с v8.1 нужно:
|
||
|
||
**Вариант А — пересоздание БД (рекомендуется для dev/staging до публичного запуска):**
|
||
|
||
```bash
|
||
# 1. Backup данных, которые нужно сохранить (если есть)
|
||
pg_dump --data-only $DB_URL > data-backup-$(date +%Y%m%d).sql
|
||
|
||
# 2. Drop & recreate
|
||
dropdb liderra && createdb -E UTF8 liderra
|
||
psql $DB_URL -f schema.sql
|
||
|
||
# 3. Восстановить нужные данные (если есть)
|
||
# Внимание: формат supplier_lead_costs изменился (supplier_code → supplier_id),
|
||
# при восстановлении из старого dump'а понадобится трансформация.
|
||
```
|
||
|
||
**Вариант Б — ручная миграция** (только если данные нельзя терять):
|
||
|
||
- Сравнить v8.1 и v8.2 (например, через `git diff schema.sql.v8.1 schema.sql`).
|
||
- Применить вручную ALTER'ы из дельты.
|
||
- Бэкфил `supplier_lead_costs.supplier_id ← suppliers WHERE code='b1'`.
|
||
- В этом случае может быть полезно сгенерировать diff-патч на лету:
|
||
|
||
```bash
|
||
diff -u schema.sql.v8.1 schema.sql > migration-v8.1-to-v8.2.diff
|
||
```
|
||
|
||
### B.4.3. Production (когда дойдём)
|
||
|
||
К моменту production-запуска проект ещё не имеет реальных данных, поэтому применяется как «установка с нуля» (вариант А). Если позже потребуется миграция production → следующая версия — соберу отдельный патч-файл с ALTER'ами (см. также Прил. И v8.2 раздел 5 «Migration runbook»).
|
||
|
||
После применения:
|
||
|
||
- Запустить `php artisan limits:recalc --all` — заполнит `effective_daily_limit_today` для всех существующих проектов.
|
||
- Мониторить Sentry на `ProcessingRestrictedException` — это нормальные ожидаемые exception'ы при попытках работать с restricted-субъектами.
|
||
|
||
---
|
||
|
||
## B.5. Откат
|
||
|
||
Поскольку файл консолидированный, отката «как такового» нет — есть только восстановление из backup'а.
|
||
|
||
**Рекомендация:** на dev/staging — `pg_dump` перед каждым применением schema.sql.
|
||
|
||
Если нужно «вернуться на v8.1» на свежем deploy:
|
||
|
||
1. Достать предыдущую версию schema.sql из git (`git show <commit>:schema.sql > schema.sql.v8.1`).
|
||
2. Drop & recreate БД.
|
||
3. Применить старую schema.
|
||
|
||
При наличии данных — отдельный rollback-патч можно собрать по запросу (DROP TABLE incidents_log/suppliers/project_suppliers/project_limit_adjustments + DROP COLUMN из projects/pd_subject_requests + восстановление supplier_code).
|
||
|
||
---
|
||
|
||
## B.6. Связь с Прил. М
|
||
|
||
Все 7 групп изменений детально обоснованы в `Analiz_originala_v8_3.md` (Прил. М v1.0):
|
||
|
||
| Группа изменений | Раздел Прил. М |
|
||
|---|---|
|
||
| 1. `processing_restricted` | §3.3 |
|
||
| 2. `incidents_log` | §3.3 |
|
||
| 3. `suppliers` | §2.1, §3.1 |
|
||
| 4. `project_suppliers` | §2.1, §3.1 |
|
||
| 5. Миграция `supplier_code → supplier_id` | §3.1 |
|
||
| 6. Расширение `projects` | §2.2, §3.2 |
|
||
| 7. `project_limit_adjustments` | §2.2, §3.2 |
|
||
|
||
---
|
||
|
||
*Версия changelog: 1.0 от 04.05.2026.*
|
||
|
||
---
|
||
|
||
*Журнал ведётся при каждом изменении `schema.sql`. Новые записи добавляются сверху, перед записью A.*
|
||
*Документ объединён из CHANGELOG-v8_2.md и CHANGELOG-v8_3.md в рамках оптимизации архива v8.3++ → v8.3++ optimized 05.05.2026.*
|
||
*Версия документа: 1.0 от 05.05.2026.*
|