schema(v8.5): реализация 27 решений аудита C — DDL + CHANGELOG + метрики
Закрывает все 27 решений из Открытые_вопросы v1.12 §13.10 на уровне schema.sql. Narrative-обновление (§10/§12.5.5/§14/§17/§19.10/§22/§23.10/ Прил.И) — отдельным коммитом. Метрики v8.4 → v8.5: - 53 → 54 таблицы (+1: project_user_assignments) - 86 → 91 индекс (+5) - 34 → 35 RLS-политик (+1) + WITH CHECK на 2 существующих - 34 → 35 ENABLE RLS (+1) - 3 → 4 роли (+crm_audit_writer) - 0 → 12 триггеров (5×2 audit append-only + 1 report_jobs export + 1 deals lead_score) - 0 → 4 функции (audit_chain_hash, audit_block_mutation, report_jobs_log_export, calc_lead_score) - +26 колонок: suppliers.quality_score; saas_admin_users (sso_provider, is_break_glass); impersonation_tokens (second_approver_id, second_approval_at); tenants (api_key_limit, telegram_bot_token); projects (assignment_strategy, ttfr_target_minutes); users (telegram_user_id); deals (assigned_at, escalated_count, duplicate_of_id, utm_source/medium/campaign/content, region_code, city, time_in_form_seconds, lead_score); +log_hash на 5 audit-таблицах - ALTER api_keys.expires_at SET NOT NULL DEFAULT NOW()+365d - REVOKE ALL на 6 saas-таблицах от crm_app_user P0 (8) разблокировали триггер фазы 1 (composer create-project): - Биз-17 (manual routing), Биз-18 (TTFR 15м), Биз-19 (24ч-дедуп без списания) - CTO-13 (e2e SET LOCAL+PgBouncer тест в спринте 1; план в narrative) - OPEN-И-13 (OIDC+JIT+break-glass), OPEN-И-14 (WITH CHECK + REVOKE) - OPEN-И-15 (append-only audit + hash chain + crm_audit_writer) - OPEN-И-16 (Sentry whitelist+regex; конфигурация в Laravel, не DDL) Self-review: 0 orphan-FK, 0 дубликатов CREATE TABLE, метрики совпадают с grep'ами; markdownlint+cspell чистые. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,8 @@
|
||||
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.2+) |
|
||||
| Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.0+) |
|
||||
| Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_4.md](docs/CRM_bp-gr_Инструкция_v8_4.md) (v8.4 от 06.05.2026) |
|
||||
| Схема БД | [db/schema.sql](db/schema.sql) (v8.4) |
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.9+) |
|
||||
| Схема БД | [db/schema.sql](db/schema.sql) (v8.5 от 07.05.2026 — реализация 27 решений аудита C, narrative v8.5 готовится) |
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.12+) |
|
||||
| Брендбук | [docs/brandbook.md](docs/brandbook.md) (v1.1) |
|
||||
| Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) |
|
||||
| Состав архива | [docs/README_АРХИВ_v8_4.md](docs/README_АРХИВ_v8_4.md) (v8.4 от 06.05.2026) |
|
||||
@@ -46,7 +46,7 @@
|
||||
|---|---|
|
||||
| Backend | PHP 8.3 + Laravel 11 |
|
||||
| Frontend | Vue 3 + **Vuetify 3** (НЕ Tailwind, НЕ Inertia, НЕ Livewire, НЕ Filament) |
|
||||
| БД | PostgreSQL 16 (53 таблицы + 12 партиций, 86 индексов, 33 RLS-политики, 3 роли БД) |
|
||||
| БД | PostgreSQL 16 (54 таблицы + 12 партиций, 91 индекс, 34 RLS-политики, 4 роли БД, 12 триггеров, 4 функции — после v8.5 от 07.05.2026) |
|
||||
| Кэш / очереди | Redis 7 |
|
||||
| Pooler | PgBouncer (transaction pooling) |
|
||||
| Облако | Yandex Cloud, регион `ru-central1` (Москва) |
|
||||
@@ -204,7 +204,7 @@ trivy image lidpotok:latest
|
||||
|
||||
| Файл | Что проверять |
|
||||
|---|---|
|
||||
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики (53 таблицы + 12 партиций, 86 индексов, 33 RLS), 0 дубликатов `CREATE TABLE` |
|
||||
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики (54 таблицы + 12 партиций, 91 индекс, 34 RLS, 35 ENABLE RLS, 4 роли, 12 триггеров, 4 функции — v8.5), 0 дубликатов `CREATE TABLE` |
|
||||
| narrative `.md` | Версии в шапке/колонтитуле, 0 «готовится»/«TBD», кросс-ссылки на актуальные имена файлов |
|
||||
| Прил. А–Н | Версия совпадает с narrative; все упомянутые подразделы существуют |
|
||||
| Прил. Н (этот реестр инструментов) | Ровно 28 в активном наборе; 0 дублей; синхронность с этим CLAUDE.md |
|
||||
|
||||
@@ -76,7 +76,7 @@ lidpotok/
|
||||
|
||||
---
|
||||
|
||||
*Прил. Л v0.4 от 06.05.2026 — 3/8 прототипов готовы (01–03), narrative и schema на v8.4.*
|
||||
*Прил. Л v0.4 от 06.05.2026 — 3/8 прототипов готовы (01–03), narrative на v8.4, schema на v8.5 (07.05.2026 — реализация 27 решений аудита C; narrative v8.5 готовится).*
|
||||
|
||||
## Документация для разработчика
|
||||
|
||||
@@ -87,7 +87,7 @@ lidpotok/
|
||||
| [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) v1.2 | Продуктовые правила работы Claude в проекте |
|
||||
| [docs/README_АРХИВ_v8_4.md](docs/README_АРХИВ_v8_4.md) v8.4 | Состав архива, навигатор по документам |
|
||||
| [docs/CRM_bp-gr_Инструкция_v8_4.md](docs/CRM_bp-gr_Инструкция_v8_4.md) v8.4 | Главное ТЗ из 28 разделов (финал 06.05.2026) |
|
||||
| [db/schema.sql](db/schema.sql) v8.4 | Схема БД PostgreSQL 16 (53 таблицы + 12 партиций, 86 индексов, 33 RLS-политики) |
|
||||
| [db/schema.sql](db/schema.sql) v8.5 | Схема БД PostgreSQL 16 (54 таблицы + 12 партиций, 91 индекс, 34 RLS-политики, 4 роли, 12 триггеров, 4 функции — после v8.5 от 07.05.2026) |
|
||||
|
||||
## Репозиторий
|
||||
|
||||
|
||||
@@ -688,3 +688,4 @@ soft
|
||||
дедупликации
|
||||
DEK
|
||||
BYTEA
|
||||
trg
|
||||
|
||||
+186
-4
@@ -1,11 +1,12 @@
|
||||
# CHANGELOG schema.sql — Лидпоток
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит три записи в обратном хронологическом порядке (v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит четыре записи в обратном хронологическом порядке (v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.4, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.5, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
**История записей:**
|
||||
|
||||
- **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.
|
||||
@@ -13,11 +14,192 @@
|
||||
**Связано:**
|
||||
|
||||
- `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.1–3.3).
|
||||
- `Открытые_вопросы_v8_3.md` v1.10 — продуктовые вопросы Биз-10..16, OPEN-И-2.
|
||||
- `Открытые_вопросы_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/Прил.И.
|
||||
|
||||
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
|
||||
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Y.0`, `Y.1`, …, `Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
|
||||
|
||||
---
|
||||
|
||||
# Запись 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 это не требуется (база с нуля).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+529
-15
@@ -1,12 +1,12 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидпоток»)
|
||||
-- Версия: v8.4 (06.05.2026, синхронизация с narrative §19.10 outbound webhook)
|
||||
-- Базовая версия: v8.3 (05.05.2026, после параллельного аудита партий 12–15)
|
||||
-- Версия: v8.5 (07.05.2026, реализация 27 решений аудита C из реестра v1.12)
|
||||
-- Базовая версия: v8.4 (06.05.2026, синхронизация с narrative §19.10 outbound webhook)
|
||||
-- СУБД: PostgreSQL 16
|
||||
-- Кодировка: UTF8, локаль ru_RU.UTF-8
|
||||
-- =============================================================================
|
||||
--
|
||||
-- ИСТОЧНИК: документация v8.4 (CRM_bp-gr_Инструкция_v8_4.md), 28 разделов.
|
||||
-- ИСТОЧНИК: документация v8.5 (CRM_bp-gr_Инструкция_v8_5.md), 28 разделов.
|
||||
-- Каждый блок помечен ссылкой на исходный раздел.
|
||||
--
|
||||
-- АРХИТЕКТУРНАЯ МОДЕЛЬ:
|
||||
@@ -23,6 +23,100 @@
|
||||
-- B2 — sender_name + keyword; B3 — только sender_name; B1 — sites/calls.
|
||||
-- Для генерации UI-формы проекта suppliers содержит поля supports_*.
|
||||
--
|
||||
-- ИЗМЕНЕНИЯ v8.4 → v8.5:
|
||||
-- ИЗ ЗАКРЫТИЯ АУДИТА C (Открытые_вопросы v1.12, 07.05.2026, 27 решений):
|
||||
-- P0 (8) — обязательны до триггера фазы 1:
|
||||
-- 1. Биз-17 — projects.assignment_strategy VARCHAR(32) DEFAULT 'manual'
|
||||
-- + CHECK IN ('manual','round_robin','least_loaded'). MVP = manual.
|
||||
-- 2. Биз-18 — projects.ttfr_target_minutes INT DEFAULT 15 (Time To First
|
||||
-- Response SLA, alert при просрочке через event bus + UI badge).
|
||||
-- 3. Биз-19 — deals.duplicate_of_id BIGINT NULL ON DELETE SET NULL +
|
||||
-- индекс (tenant_id, phone, received_at) для O(log n) lookup в окне
|
||||
-- 24 ч. Антифрод: дубль помечается, но НЕ списывается с баланса.
|
||||
-- 4. CTO-13 — обязательный e2e-тест SET LOCAL app.current_tenant_id
|
||||
-- через PgBouncer transaction-pooling в спринте 1. Без изменений
|
||||
-- схемы, только тест-план в narrative §22 + Прил. И.
|
||||
-- 5. OPEN-И-13 — saas_admin_users.sso_provider VARCHAR(32) DEFAULT
|
||||
-- 'yandex360' + saas_admin_users.is_break_glass BOOLEAN DEFAULT
|
||||
-- FALSE (OIDC + JIT-provisioning + локальный 2FA выключен,
|
||||
-- fallback — break-glass super_admin).
|
||||
-- 6. OPEN-И-14 — WITH CHECK на политики deal_tag_pivot, saas_invoice_items
|
||||
-- + REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users,
|
||||
-- saas_admin_sessions, saas_admin_audit_log, incidents_log,
|
||||
-- pd_subject_requests, impersonation_tokens).
|
||||
-- 7. OPEN-И-15 — append-only audit hash chain. Колонка log_hash BYTEA
|
||||
-- NOT NULL на 5 audit-таблицах (auth_log, activity_log,
|
||||
-- pd_processing_log, saas_admin_audit_log, balance_transactions).
|
||||
-- 5 BEFORE UPDATE/DELETE триггеров с RAISE EXCEPTION + функция
|
||||
-- audit_chain_hash() = sha256(prev.log_hash || row). Новая роль
|
||||
-- crm_audit_writer (только INSERT) — REVOKE UPDATE/DELETE даже у
|
||||
-- super_admin через триггеры.
|
||||
-- 8. OPEN-И-16 — Sentry whitelist + regex маска phone/email/password/
|
||||
-- secret/token/api_key. Без изменений схемы — конфигурация в Laravel
|
||||
-- config/sentry.php (narrative §22 «Sentry PII-scrubbing»).
|
||||
-- P1 (12) — фазы 1–2:
|
||||
-- 9. Биз-20 — Telegram-бот в спринте 9. users.telegram_user_id BIGINT
|
||||
-- NULL + tenants.telegram_bot_token TEXT NULL (зашифровано).
|
||||
-- 10. Биз-21 — generic outbound `marketing.conversion` через §19.10
|
||||
-- (расширение whitelist событий). Без изменений схемы.
|
||||
-- 11. Биз-22 — простой scoring. suppliers.quality_score NUMERIC(3,2)
|
||||
-- DEFAULT 1.00 + deals.time_in_form_seconds INT NULL + deals.lead_score
|
||||
-- NUMERIC(5,2) GENERATED ALWAYS AS (...) STORED.
|
||||
-- 12. CTO-14 — UTM-поля. deals.utm_source/utm_medium/utm_campaign/
|
||||
-- utm_content VARCHAR(100) NULL + индекс (tenant_id, utm_source).
|
||||
-- 13. CTO-15 — two-person impersonation. impersonation_tokens.
|
||||
-- second_approver_id BIGINT NULL REFERENCES saas_admin_users(id) +
|
||||
-- second_approval_at TIMESTAMPTZ NULL.
|
||||
-- 14. CTO-16 — skill-based routing. Новая таблица project_user_assignments
|
||||
-- (project_id, user_id, skills JSONB) + RLS-политика через JOIN на
|
||||
-- projects.tenant_id + индекс на (project_id).
|
||||
-- 15. OPEN-И-17 — TTL 365 дней на api_keys/webhook_token/outbound.
|
||||
-- secret_hash. ALTER api_keys.expires_at SET DEFAULT NOW() + 365d.
|
||||
-- 16. OPEN-И-18 — DNS-rebinding защита. Без изменений схемы — реализация
|
||||
-- в SSRFGuard service (narrative §19.10).
|
||||
-- 17. OPEN-И-19 — лимит api_keys. tenants.api_key_limit INT DEFAULT 5
|
||||
-- NOT NULL CHECK (api_key_limit BETWEEN 1 AND 10) + ALTER api_keys.
|
||||
-- expires_at SET NOT NULL (миграция: backfill всем NOW + 365d).
|
||||
-- 18. OPEN-И-20 — signed URL + триггер audit. Триггер trg_report_jobs_
|
||||
-- export_log AFTER INSERT ON report_jobs → INSERT pd_processing_log
|
||||
-- action='exported'.
|
||||
-- 19. OPEN-И-21 — Anti-DDoS. Без изменений схемы — Nginx + Yandex
|
||||
-- SmartCaptcha + disposable-blacklist (narrative §22 + Прил. И).
|
||||
-- 20. Ю-9 — hard-блок impersonation для всех ролей кроме compliance
|
||||
-- при processing_restricted=TRUE. Реализация в SaasAdminAuthService
|
||||
-- (narrative §22.7), без изменений схемы.
|
||||
-- P2 (7) — фазы 1–3:
|
||||
-- 21. Биз-23 — гео-таргетинг. deals.region_code VARCHAR(8) NULL +
|
||||
-- deals.city VARCHAR(100) NULL + индекс (tenant_id, region_code).
|
||||
-- 22. Биз-24 — алерт saappоrtу о просрочке waiting_payment → paid
|
||||
-- через 48 ч. Cron payments:notify-stale (narrative §17), без
|
||||
-- изменений схемы кроме system_settings ключей.
|
||||
-- 23. OPEN-И-22 — per-tenant DEK в Yandex KMS. Без изменений схемы —
|
||||
-- encryption envelope на уровне backup-сервиса (Прил. И).
|
||||
-- 24. OPEN-И-23 — роль crm_audit_writer уже создана в OPEN-И-15.
|
||||
-- Здесь — только подтверждение: INSERT-only access; UPDATE/DELETE
|
||||
-- блокируются триггерами.
|
||||
-- 25. OPEN-И-24 — pg_anonymizer процедура. Документация в Прил. И,
|
||||
-- без изменений схемы (расширение ставится в фазе 3 по Прил. Н).
|
||||
-- 26. OPEN-И-25 — эскалация лидов. deals.assigned_at TIMESTAMPTZ NULL +
|
||||
-- deals.escalated_count INT DEFAULT 0. Cron leads:escalate-stale
|
||||
-- (narrative §10 + §17).
|
||||
-- 27. OPEN-И-26 — задел под call-recording (Биз-12 Post-MVP).
|
||||
-- Закомментированный DDL call_recordings(...) в конце файла.
|
||||
-- Итого: +1 таблица (project_user_assignments), +26 колонок (suppliers.
|
||||
-- quality_score; saas_admin_users.sso_provider/is_break_glass;
|
||||
-- impersonation_tokens.second_approver_id/second_approval_at;
|
||||
-- tenants.api_key_limit/telegram_bot_token; projects.assignment_strategy/
|
||||
-- ttfr_target_minutes; users.telegram_user_id; deals.assigned_at/
|
||||
-- escalated_count/duplicate_of_id/utm_source/utm_medium/utm_campaign/
|
||||
-- utm_content/region_code/city/time_in_form_seconds/lead_score; +log_hash
|
||||
-- на 5 audit-таблицах) + ALTER api_keys.expires_at NOT NULL DEFAULT
|
||||
-- NOW()+365d, +12 триггеров (5×2 audit append-only + 1 report_jobs export
|
||||
-- log + 1 deals lead_score), +4 функции (audit_chain_hash,
|
||||
-- audit_block_mutation, report_jobs_log_export, calc_lead_score),
|
||||
-- +1 роль (crm_audit_writer), +5 индексов, +2 политики с WITH CHECK,
|
||||
-- +1 политика (project_user_assignments), REVOKE на 6 saas-таблицах.
|
||||
--
|
||||
-- ИЗМЕНЕНИЯ v8.3 → v8.4:
|
||||
-- ИЗ ПЕРЕПИСЫВАНИЯ NARRATIVE v8.4 (06.05.2026, §19.10 outbound webhook):
|
||||
-- 1. Новая таблица outbound_webhook_subscriptions (раздел 19.10) —
|
||||
@@ -97,16 +191,29 @@
|
||||
-- 14) Row-Level Security (CTO-5: включён на MVP)
|
||||
-- 15) Роли БД (CTO-5)
|
||||
--
|
||||
-- РАЗМЕР СХЕМЫ v8.4:
|
||||
-- • 53 логических таблицы (51 из v8.3 + outbound_webhook_subscriptions
|
||||
-- + outbound_webhook_deliveries).
|
||||
-- РАЗМЕР СХЕМЫ v8.5:
|
||||
-- • 54 логических таблицы (53 из v8.4 + project_user_assignments v8.5).
|
||||
-- • 12 партиций (6 у deals + 6 у supplier_lead_costs).
|
||||
-- • 86 индексов (81 из v8.3 + 5 новых: idx_outbound_subs_tenant_active,
|
||||
-- idx_outbound_subs_secret_prefix, idx_outbound_deliveries_subscription,
|
||||
-- idx_outbound_deliveries_status_pending, idx_outbound_deliveries_created).
|
||||
-- • 34 RLS-политики (31 из v8.3 + 2 на outbound_webhook_* + 1 на deal_tag_pivot v8.4-hotfix).
|
||||
-- • 34 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам).
|
||||
-- • 3 роли БД (без изменений).
|
||||
-- • 91 индекс (86 из v8.4 + 5 новых: idx_deals_utm_source,
|
||||
-- idx_deals_region_code, idx_deals_duplicate_of, idx_deals_assigned_at_open,
|
||||
-- idx_project_user_assignments_user).
|
||||
-- • 35 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN).
|
||||
-- Из них 2 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items).
|
||||
-- • 35 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам).
|
||||
-- • 4 роли БД (3 из v8.4 + crm_audit_writer — только INSERT на 5 audit-таблицах).
|
||||
-- • 12 триггеров: на 5 audit-таблицах (auth_log/activity_log/pd_processing_log/
|
||||
-- saas_admin_audit_log/balance_transactions) — по 2 (BEFORE INSERT для hash
|
||||
-- chain + BEFORE UPDATE/DELETE для запрета мутаций) = 10; +1 на report_jobs
|
||||
-- (AFTER INSERT — журнал экспорта в pd_processing_log по 152-ФЗ ст.18);
|
||||
-- +1 на deals (BEFORE INSERT/UPDATE — расчёт lead_score через
|
||||
-- supplier.quality_score × time_in_form, Биз-22).
|
||||
-- • 4 функции: audit_chain_hash() (SHA-256 hash chain для tamper-detection),
|
||||
-- audit_block_mutation() (RAISE EXCEPTION для запрета UPDATE/DELETE),
|
||||
-- report_jobs_log_export() (auto-логирование экспорта),
|
||||
-- calc_lead_score() (расчёт lead score без ML).
|
||||
-- • REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users,
|
||||
-- saas_admin_sessions, saas_admin_audit_log, incidents_log,
|
||||
-- pd_subject_requests, impersonation_tokens) — defense-in-depth.
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
@@ -247,6 +354,12 @@ CREATE TABLE suppliers (
|
||||
supports_keyword BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
supports_csv_upload BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: загрузка CSV-доменов
|
||||
supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: textarea со списком доменов
|
||||
-- v8.5 (Биз-22): простая lead scoring модель без ML.
|
||||
-- Используется в формуле deals.lead_score = supplier_quality × time_in_form.
|
||||
-- 1.00 = baseline; админ SaaS вручную поднимает/понижает в /admin/suppliers
|
||||
-- по фактической конверсии за месяц. Post-MVP — auto-adjustment по cron.
|
||||
quality_score NUMERIC(3,2) NOT NULL DEFAULT 1.00
|
||||
CHECK (quality_score BETWEEN 0.00 AND 9.99),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -304,9 +417,18 @@ CREATE TABLE saas_admin_users (
|
||||
role VARCHAR(20) NOT NULL
|
||||
CHECK (role IN ('super_admin','support','finance','compliance','dev_oncall','read_only')),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
-- 2FA (обязательно для всех админов)
|
||||
-- 2FA (обязательно для всех админов; v8.5 OPEN-И-13: при sso_provider != 'local'
|
||||
-- локальный 2FA выключен и заменён на 2FA провайдера IDP)
|
||||
totp_secret_enc TEXT, -- зашифровано Crypt::encryptString
|
||||
totp_enabled_at TIMESTAMPTZ,
|
||||
-- v8.5 (OPEN-И-13): SSO + JIT-provisioning + break-glass.
|
||||
-- sso_provider — кто аутентифицировал (Yandex 360 OIDC по умолчанию;
|
||||
-- 'local' — fallback для break-glass и dev). При is_break_glass=TRUE этот
|
||||
-- аккаунт обходит SSO для аварийного входа при недоступности IDP, но force-2FA
|
||||
-- через TOTP остаётся. Логика guard'ов — в SaasAdminAuthService (narrative §22.7).
|
||||
sso_provider VARCHAR(32) NOT NULL DEFAULT 'yandex360'
|
||||
CHECK (sso_provider IN ('yandex360','local')),
|
||||
is_break_glass BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- IP allow-list (per-user, дополняет глобальный)
|
||||
allowed_ips JSONB, -- ["10.0.0.0/8","1.2.3.4"]
|
||||
-- Безопасность
|
||||
@@ -398,6 +520,13 @@ CREATE TABLE impersonation_tokens (
|
||||
session_ended_at TIMESTAMPTZ,
|
||||
failed_attempts INT DEFAULT 0, -- защита от брутфорса
|
||||
invalidated_at TIMESTAMPTZ, -- ручная инвалидация админом или клиентом
|
||||
-- v8.5 (CTO-15 + Ю-9): two-person approval для тенантов с
|
||||
-- pd_subject_request.processing_restricted=TRUE ИЛИ chargeback_unrecovered_rub > 0.
|
||||
-- При создании токена SaasAdminAuthService проверяет флаги тенанта; если
|
||||
-- хотя бы один TRUE — second_approver_id обязателен (роль 'compliance').
|
||||
-- Полный flow guard'ов в narrative §22.7.X. NULL для обычных тенантов.
|
||||
second_approver_id BIGINT REFERENCES saas_admin_users(id),
|
||||
second_approval_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT chk_imp_reason_len CHECK (length(reason) >= 30)
|
||||
);
|
||||
@@ -499,6 +628,15 @@ CREATE TABLE tenants (
|
||||
-- в админке SaaS (саппорту видно, сколько клиент хочет получать в день).
|
||||
desired_daily_numbers INT
|
||||
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0),
|
||||
-- v8.5 (OPEN-И-19): hard-limit количества активных API-ключей.
|
||||
-- Защита от DoS через создание тысяч ключей. Default 5 — комфортный
|
||||
-- baseline; max 10 — для intg-партнёров. Hard limit, не soft warning.
|
||||
api_key_limit INT NOT NULL DEFAULT 5
|
||||
CHECK (api_key_limit BETWEEN 1 AND 10),
|
||||
-- v8.5 (Биз-20): Telegram-бот для нотификаций менеджерам. Спринт 9.
|
||||
-- Хранится в зашифрованном виде через Crypt::encryptString. Вне спринта 9
|
||||
-- остаётся NULL и фича не активна (UI скрывает Telegram-настройки).
|
||||
telegram_bot_token TEXT,
|
||||
-- Метаданные
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
@@ -569,6 +707,10 @@ CREATE TABLE users (
|
||||
"marketing": {"email": false}
|
||||
}'::jsonb,
|
||||
sound_enabled BOOLEAN DEFAULT TRUE, -- общий toggle звука для in-app уведомлений
|
||||
-- v8.5 (Биз-20): Telegram-канал нотификаций. Спринт 9. До спринта 9
|
||||
-- остаётся NULL и UI скрывает Telegram-настройки. Подключение — через
|
||||
-- бот /start: пользователь отправляет код, бот связывает chat_id с user_id.
|
||||
telegram_user_id BIGINT, -- Telegram chat_id
|
||||
-- Статус
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
@@ -633,6 +775,18 @@ CREATE TABLE projects (
|
||||
delivery_days_mask INT NOT NULL DEFAULT 127,
|
||||
-- битмаска дней недели: бит 1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс.
|
||||
-- 127 = все 7 дней (паритет с формой создания нового проекта в оригинале).
|
||||
-- v8.5 (Биз-17): стратегия автораспределения лидов между менеджерами.
|
||||
-- MVP = 'manual' (паритет с оригиналом, ручное назначение в карточке сделки).
|
||||
-- 'round_robin'/'least_loaded' зарезервированы для Post-MVP — реализация
|
||||
-- через таблицу project_user_assignments (CTO-16) + cron-балансировку.
|
||||
assignment_strategy VARCHAR(32) NOT NULL DEFAULT 'manual'
|
||||
CHECK (assignment_strategy IN ('manual','round_robin','least_loaded')),
|
||||
-- v8.5 (Биз-18): Time To First Response SLA в минутах.
|
||||
-- При просрочке (NOW() - deals.received_at > ttfr_target_minutes AND
|
||||
-- deals.status='new' AND deals.manager_id IS NULL) — alert менеджеру и
|
||||
-- админу через event bus + UI badge на карточке. Метрика на дашборде §12.5.5.
|
||||
ttfr_target_minutes INT NOT NULL DEFAULT 15
|
||||
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE (tenant_id, name),
|
||||
@@ -694,6 +848,43 @@ COMMENT ON TABLE project_suppliers IS
|
||||
'все is_active=TRUE поставщики (паритет с формой создания в оригинале).';
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- project_user_assignments — m2m "проект ↔ менеджеры" + skills (НОВАЯ v8.5, CTO-16)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Назначение: skill-based / regional routing для projects.assignment_strategy
|
||||
-- IN ('round_robin','least_loaded') (Биз-17 Post-MVP). На MVP при assignment_
|
||||
-- strategy='manual' таблица не используется — менеджер выбирается вручную в
|
||||
-- карточке сделки. После Post-MVP cron lead-router-будет читать active members
|
||||
-- проекта из этой таблицы и распределять входящих лидов.
|
||||
--
|
||||
-- skills — JSONB-массив строк (slug-и навыков): ['it_b2b','retail','sms_sender_NAME1'].
|
||||
-- При assignment_strategy='round_robin' — игнорируется. При 'least_loaded' —
|
||||
-- для статистики. Skill-based routing (intersection projects.required_skills
|
||||
-- × users.skills) — Post-MVP-расширение.
|
||||
--
|
||||
-- TENANT-уровневая через JOIN на projects (RLS по tenant_id проекта).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE project_user_assignments (
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
skills JSONB NOT NULL DEFAULT '[]'::jsonb, -- ['it_b2b','retail',...]
|
||||
is_active BOOLEAN DEFAULT TRUE, -- временно отключить менеджера в проекте
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (project_id, user_id),
|
||||
CONSTRAINT chk_pua_skills_array CHECK (jsonb_typeof(skills) = 'array')
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_user_assignments_user
|
||||
ON project_user_assignments(user_id)
|
||||
WHERE is_active = TRUE;
|
||||
|
||||
COMMENT ON TABLE project_user_assignments IS
|
||||
'M2M "проект ↔ менеджеры" с per-assignment skills для skill-based routing. '
|
||||
'При projects.assignment_strategy=''manual'' (MVP) таблица не используется. '
|
||||
'При round_robin/least_loaded (Post-MVP) — пул активных менеджеров проекта.';
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- project_limit_adjustments — лог автокоррекций лимитов (НОВАЯ в v8.2)
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -803,7 +994,12 @@ CREATE TABLE api_keys (
|
||||
scopes JSONB DEFAULT '["read"]', -- ["read","write","delete"]
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip INET,
|
||||
expires_at TIMESTAMPTZ, -- NULL = бессрочный
|
||||
-- v8.5 (OPEN-И-17): TTL обязательный, max 365 дней. Бессрочные ключи
|
||||
-- запрещены — security-best-practice. Cron secrets:notify-expiring
|
||||
-- уведомляет владельца за 30 дней до expiry. UI «продлить» ставит +365d
|
||||
-- и пишет в audit. До v8.5 expires_at был NULL-able — миграция backfill'ит
|
||||
-- всем существующим ключам NOW() + 365d при ALTER NOT NULL.
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '365 days'),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -897,6 +1093,13 @@ CREATE TABLE auth_log (
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
failure_reason VARCHAR(100), -- 'invalid_password', 'invalid_2fa', ...
|
||||
-- v8.5 (OPEN-И-15): hash chain для tamper-detection.
|
||||
-- Заполняется триггером trg_audit_chain_hash_auth_log (функция
|
||||
-- audit_chain_hash() — см. секцию «Аудит append-only» внизу файла):
|
||||
-- log_hash = sha256(prev.log_hash || NEW::text). Если кто-то удалит
|
||||
-- среднюю строку или вставит фейковую — пересчёт цепочки покажет
|
||||
-- разрыв. UPDATE/DELETE заблокированы триггером BEFORE.
|
||||
log_hash BYTEA, -- NULL → fill via trigger BEFORE INSERT
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
-- Целостность: actor должен быть ровно одного типа
|
||||
CONSTRAINT chk_auth_log_actor CHECK (
|
||||
@@ -1003,11 +1206,52 @@ CREATE TABLE deals (
|
||||
-- (множественные напоминания на сделку, паритет с histories[].type='reminder'
|
||||
-- оригинала — партия 12.2.5 аудита).
|
||||
manager_id BIGINT, -- FK не ставится (партиционированная)
|
||||
-- v8.5 (OPEN-И-25): момент назначения менеджера. Используется cron'ом
|
||||
-- leads:escalate-stale (каждые 30 мин): если NOW() - assigned_at > 4h
|
||||
-- AND status NOT IN ('closed','rejected') → reassign + email + escalated_count++.
|
||||
-- NULL для неназначенных лидов (тогда работает Биз-18 TTFR с received_at).
|
||||
assigned_at TIMESTAMPTZ,
|
||||
escalated_count INT NOT NULL DEFAULT 0,
|
||||
-- v8.5 (Биз-19): антифрод-дедуп по phone. duplicate_of_id указывает на
|
||||
-- master-сделку, дубль НЕ списывает лид с баланса. Окно — 24 ч от
|
||||
-- received_at master'а (проверяется приложением через индекс tenant_id+phone).
|
||||
-- Master помечается duplicate_of_id=NULL, дубли указывают на её id.
|
||||
-- ON DELETE SET NULL — если master удалён, дубли остаются как self-standing.
|
||||
-- БЕЗ FK (deals партиционирована — partition-wise FK не поддерживается).
|
||||
duplicate_of_id BIGINT,
|
||||
-- v8.5 (CTO-14): UTM-метки для когортной аналитики §12.5.5.
|
||||
-- Заполняются из webhook payload (если приходят) или landing page (utm_*
|
||||
-- query params, переданные в форму). Партиционные индексы — ниже.
|
||||
utm_source VARCHAR(100),
|
||||
utm_medium VARCHAR(100),
|
||||
utm_campaign VARCHAR(100),
|
||||
utm_content VARCHAR(100),
|
||||
-- v8.5 (Биз-23): гео-таргетинг сделок. region_code = ISO 3166-2:RU
|
||||
-- (например 'RU-MOW' Москва, 'RU-SPE' СПб) — автоопределяется по prefix
|
||||
-- phone через PhonePrefixService (Минсвязи API offline). NULL если не
|
||||
-- удалось определить. city — свободный текст (приходит из webhook или
|
||||
-- enrichment-сервиса). Используется для filter в §10.3 + аналитики §12.
|
||||
region_code VARCHAR(8),
|
||||
city VARCHAR(100),
|
||||
-- v8.5 (Биз-22): простая lead scoring модель без ML.
|
||||
-- time_in_form_seconds — сколько секунд физлицо заполняло форму
|
||||
-- (приходит в webhook как seconds_to_submit; NULL если поставщик не
|
||||
-- передаёт). lead_score — generated stored = supplier.quality_score *
|
||||
-- (time_in_form / 60), clamp в [0.00, 99.99]. Простая эвристика: дольше
|
||||
-- заполнял → серьёзнее интерес × качество поставщика.
|
||||
-- Расчёт через trigger BEFORE INSERT/UPDATE (см. функцию ниже), не GENERATED
|
||||
-- (нужен JOIN на suppliers, что не разрешено в STORED-выражениях PG).
|
||||
time_in_form_seconds INT,
|
||||
lead_score NUMERIC(5,2),
|
||||
is_test BOOLEAN DEFAULT FALSE,
|
||||
received_at TIMESTAMPTZ NOT NULL, -- ключ партиционирования
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (id, received_at)
|
||||
PRIMARY KEY (id, received_at),
|
||||
CONSTRAINT chk_deals_lead_score_range
|
||||
CHECK (lead_score IS NULL OR (lead_score >= 0.00 AND lead_score <= 99.99)),
|
||||
CONSTRAINT chk_deals_escalated_count_nonneg
|
||||
CHECK (escalated_count >= 0)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
-- Индексы на родительской таблице наследуются партициями
|
||||
@@ -1017,6 +1261,16 @@ CREATE INDEX ON deals (tenant_id, phone);
|
||||
CREATE INDEX ON deals (tenant_id, manager_id);
|
||||
-- v8.3: idx_deals_reminder удалён вместе с полем reminder_at
|
||||
CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL;
|
||||
-- v8.5 индексы:
|
||||
-- (CTO-14) когортная аналитика по UTM. Партиционируется автоматически.
|
||||
CREATE INDEX ON deals (tenant_id, utm_source) WHERE utm_source IS NOT NULL;
|
||||
-- (Биз-23) гео-фильтр в §10.3 + аналитика по регионам.
|
||||
CREATE INDEX ON deals (tenant_id, region_code) WHERE region_code IS NOT NULL;
|
||||
-- (Биз-19) lookup дублей master'а через duplicate_of_id для UI (показать
|
||||
-- цепочку дублей) и для cleanup при удалении master'а.
|
||||
CREATE INDEX ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL;
|
||||
-- (OPEN-И-25) cron leads:escalate-stale — выбирает unclosed deals по assigned_at.
|
||||
CREATE INDEX ON deals (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected');
|
||||
|
||||
-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
|
||||
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
|
||||
@@ -1062,6 +1316,7 @@ CREATE TABLE activity_log (
|
||||
context JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -1486,6 +1741,7 @@ CREATE TABLE balance_transactions (
|
||||
user_id BIGINT REFERENCES users(id), -- кто инициировал (NULL для системных)
|
||||
-- Для manual_adjustment — кто из админов SaaS сделал
|
||||
admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -1647,6 +1903,7 @@ CREATE TABLE pd_processing_log (
|
||||
actor_tenant_user_id BIGINT REFERENCES users(id),
|
||||
actor_admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||||
ip_address INET,
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT chk_pd_actor CHECK (
|
||||
(actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL)
|
||||
@@ -1801,6 +2058,7 @@ CREATE TABLE saas_admin_audit_log (
|
||||
requires_approval BOOLEAN DEFAULT FALSE,
|
||||
approved_by BIGINT REFERENCES saas_admin_users(id),
|
||||
approved_at TIMESTAMPTZ,
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -2004,6 +2262,7 @@ ALTER TABLE outbound_webhook_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
-- v8.4 hotfix: deal_tag_pivot — link между deals (partitioned, RLS есть) и deal_tags (RLS через tenant_id).
|
||||
-- В самой pivot нет tenant_id, поэтому фильтр через JOIN на deal_tags (tag_id).
|
||||
ALTER TABLE deal_tag_pivot ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project_user_assignments ENABLE ROW LEVEL SECURITY; -- v8.5 (CTO-16)
|
||||
|
||||
-- Базовая политика для таблиц с прямым tenant_id
|
||||
CREATE POLICY tenant_isolation ON users USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
@@ -2051,13 +2310,32 @@ CREATE POLICY tenant_isolation ON email_verifications USING (
|
||||
);
|
||||
|
||||
-- saas_invoice_items: фильтр через invoice_id
|
||||
-- v8.5 (OPEN-И-14): WITH CHECK защищает INSERT/UPDATE — нельзя вставить
|
||||
-- строку invoice_item ссылающуюся на чужой invoice. До v8.5 защита только
|
||||
-- на USING (SELECT/UPDATE filter), INSERT мог пройти если invoice_id — чужой.
|
||||
CREATE POLICY tenant_isolation ON saas_invoice_items USING (
|
||||
invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
)
|
||||
WITH CHECK (
|
||||
invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
);
|
||||
|
||||
-- v8.4 hotfix: deal_tag_pivot — фильтр через tag_id → deal_tags(tenant_id)
|
||||
-- v8.5 (OPEN-И-14): добавлено WITH CHECK — нельзя пометить deal чужим тегом.
|
||||
CREATE POLICY tenant_isolation ON deal_tag_pivot USING (
|
||||
tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
)
|
||||
WITH CHECK (
|
||||
tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
);
|
||||
|
||||
-- v8.5 (CTO-16): project_user_assignments — фильтр через project_id → projects(tenant_id).
|
||||
-- WITH CHECK на INSERT/UPDATE — нельзя назначить менеджера в чужой проект.
|
||||
CREATE POLICY tenant_isolation ON project_user_assignments USING (
|
||||
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
)
|
||||
WITH CHECK (
|
||||
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||||
);
|
||||
|
||||
-- auth_log: tenant_user и saas_admin строки разные. Здесь — только tenant_user.
|
||||
@@ -2098,6 +2376,242 @@ CREATE POLICY tenant_isolation ON auth_log USING (
|
||||
-- legal_entities, payment_gateways;
|
||||
-- нет DELETE на финансовых таблицах (только soft markers).
|
||||
|
||||
-- v8.5 (OPEN-И-15 + OPEN-И-23): новая роль crm_audit_writer.
|
||||
-- INSERT-only на 5 audit-таблиц. UPDATE/DELETE заблокированы триггерами
|
||||
-- (см. секцию «14. АУДИТ APPEND-ONLY + HASH CHAIN»). Application пишет
|
||||
-- в audit-таблицы под этой ролью (через temporary SET ROLE), что обеспечивает
|
||||
-- невозможность fraud-удаления записей даже от super_admin.
|
||||
--
|
||||
-- CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
|
||||
--
|
||||
-- GRANT crm_audit_writer-у:
|
||||
-- USAGE на схему public;
|
||||
-- INSERT (только) на auth_log, activity_log, pd_processing_log,
|
||||
-- saas_admin_audit_log, balance_transactions;
|
||||
-- USAGE на sequences соответствующих таблиц;
|
||||
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.
|
||||
|
||||
-- v8.5 (OPEN-И-14): defense-in-depth — REVOKE ALL на 6 saas-таблицах
|
||||
-- от crm_app_user. К этим таблицам tenant-приложение доступа НЕ должно
|
||||
-- иметь даже теоретически (RLS + REVOKE = 2 барьера).
|
||||
--
|
||||
-- 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;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 14. АУДИТ APPEND-ONLY + HASH CHAIN (v8.5, OPEN-И-15)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Назначение: обеспечить юридическую доказательность audit-журналов.
|
||||
-- • Триггеры BEFORE UPDATE/DELETE → RAISE EXCEPTION делают записи неизменяемыми.
|
||||
-- • Триггер BEFORE INSERT заполняет log_hash = sha256(prev_hash || NEW::text).
|
||||
-- Любое удаление средней строки или вставка фейка → разрыв цепочки при
|
||||
-- пересчёте (cron audit:verify-chain в Прил. И).
|
||||
-- • Роль crm_audit_writer (INSERT-only) дополняет триггеры: даже если
|
||||
-- админ выполнит ALTER TABLE … DISABLE TRIGGER, у него под crm_audit_writer
|
||||
-- не будет UPDATE/DELETE прав — пройти оба слоя одновременно невозможно
|
||||
-- без `super_admin` PostgreSQL (которого у админов SaaS нет в production).
|
||||
--
|
||||
-- Затронутые таблицы (5):
|
||||
-- - auth_log
|
||||
-- - activity_log
|
||||
-- - pd_processing_log
|
||||
-- - saas_admin_audit_log
|
||||
-- - balance_transactions
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit_chain_hash() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
prev_hash BYTEA;
|
||||
BEGIN
|
||||
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
|
||||
-- TG_TABLE_NAME — имя таблицы, через которое триггер сработал; используем
|
||||
-- format/EXECUTE для полиморфности.
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
|
||||
TG_TABLE_NAME
|
||||
) INTO prev_hash;
|
||||
|
||||
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL — берём
|
||||
-- пустую байтовую строку (первая запись цепочки).
|
||||
NEW.log_hash := digest(
|
||||
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
|
||||
'sha256'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION audit_chain_hash() IS
|
||||
'v8.5 (OPEN-И-15): SHA-256 hash chain для audit-таблиц. Заполняет '
|
||||
'NEW.log_hash перед INSERT. Цепочка: digest(prev_hash || NEW::text, ''sha256''). '
|
||||
'При попытке UPDATE/DELETE — отдельный триггер RAISE EXCEPTION.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
|
||||
USING ERRCODE = 'check_violation';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION audit_block_mutation() IS
|
||||
'v8.5 (OPEN-И-15): запрещает UPDATE/DELETE на audit-таблицах. '
|
||||
'Совместно с REVOKE на роли — два слоя защиты от tampering.';
|
||||
|
||||
-- 5 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_auth_log
|
||||
BEFORE INSERT ON auth_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_auth_log
|
||||
BEFORE UPDATE OR DELETE ON auth_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_activity_log
|
||||
BEFORE INSERT ON activity_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_activity_log
|
||||
BEFORE UPDATE OR DELETE ON activity_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_pd_log
|
||||
BEFORE INSERT ON pd_processing_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_pd_log
|
||||
BEFORE UPDATE OR DELETE ON pd_processing_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_saas_admin_audit
|
||||
BEFORE INSERT ON saas_admin_audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_saas_admin_audit
|
||||
BEFORE UPDATE OR DELETE ON saas_admin_audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_balance_tx
|
||||
BEFORE INSERT ON balance_transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_balance_tx
|
||||
BEFORE UPDATE OR DELETE ON balance_transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 15. ТРИГГЕР ЭКСПОРТА В pd_processing_log (v8.5, OPEN-И-20)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- При создании записи в report_jobs (выгрузка лидов в файл) автоматически
|
||||
-- пишем в pd_processing_log action='exported' для соответствия 152-ФЗ ст.18
|
||||
-- ч.2 (фиксация всех операций обработки ПДн). До v8.5 такая запись делалась
|
||||
-- из app-кода — риск пропуска при ошибке программиста.
|
||||
|
||||
CREATE OR REPLACE FUNCTION report_jobs_log_export() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO pd_processing_log (
|
||||
tenant_id, subject_type, subject_id, action, purpose,
|
||||
actor_tenant_user_id, ip_address
|
||||
) VALUES (
|
||||
NEW.tenant_id,
|
||||
'lead', -- bulk-выгрузка лидов
|
||||
NULL, -- subject_id NULL = bulk
|
||||
'exported',
|
||||
'report_job_' || NEW.id,
|
||||
NEW.user_id,
|
||||
NULL -- IP уже в saas_admin_audit_log/auth_log если нужно
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_report_jobs_export_log
|
||||
AFTER INSERT ON report_jobs
|
||||
FOR EACH ROW EXECUTE FUNCTION report_jobs_log_export();
|
||||
|
||||
COMMENT ON FUNCTION report_jobs_log_export() IS
|
||||
'v8.5 (OPEN-И-20): автоматически пишет в pd_processing_log при создании '
|
||||
'report_jobs. Закрывает риск пропуска audit-записи на стороне приложения.';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 16. ТРИГГЕР РАСЧЁТА LEAD_SCORE (v8.5, Биз-22)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Простая модель без ML: lead_score = supplier.quality_score *
|
||||
-- LEAST(time_in_form_seconds / 60.0, 99.99 / supplier.quality_score).
|
||||
-- Если time_in_form_seconds NULL — lead_score NULL.
|
||||
-- Поставщик вычисляется через project → project_suppliers → supplier (в MVP
|
||||
-- предполагается ровно один активный поставщик на проект; если их несколько
|
||||
-- — берётся первый по sort_order). Полная модель — Post-MVP.
|
||||
|
||||
CREATE OR REPLACE FUNCTION calc_lead_score() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
quality NUMERIC(3,2);
|
||||
BEGIN
|
||||
IF NEW.time_in_form_seconds IS NULL THEN
|
||||
NEW.lead_score := NULL;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT s.quality_score INTO quality
|
||||
FROM project_suppliers ps
|
||||
JOIN suppliers s ON s.id = ps.supplier_id
|
||||
WHERE ps.project_id = NEW.project_id
|
||||
AND ps.is_active = TRUE
|
||||
AND s.is_active = TRUE
|
||||
ORDER BY s.sort_order ASC, s.id ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF quality IS NULL THEN
|
||||
NEW.lead_score := NULL;
|
||||
ELSE
|
||||
NEW.lead_score := LEAST(
|
||||
quality * (NEW.time_in_form_seconds::numeric / 60.0),
|
||||
99.99
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_deals_calc_lead_score
|
||||
BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals
|
||||
FOR EACH ROW EXECUTE FUNCTION calc_lead_score();
|
||||
|
||||
COMMENT ON FUNCTION calc_lead_score() IS
|
||||
'v8.5 (Биз-22): простой scoring lead_score = supplier.quality_score * '
|
||||
'(time_in_form_seconds / 60), clamped в [0, 99.99]. Без ML.';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 17. POST-MVP DDL ЗАКОММЕНТИРОВАН (v8.5, OPEN-И-26)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Биз-12 (Post-MVP): телефонная интеграция и call-recording.
|
||||
-- DDL ниже — задел для ретрофита, чтобы при добавлении функции в Post-MVP
|
||||
-- не пришлось проектировать с нуля. ПДн-маркировка выставлена сразу:
|
||||
-- recording_path хранит ссылки на S3 (сами записи лежат в S3+KMS), их
|
||||
-- доступ — через signed URL TTL 1 ч с обязательным INSERT в pd_processing_log.
|
||||
--
|
||||
-- CREATE TABLE call_recordings (
|
||||
-- id BIGSERIAL PRIMARY KEY,
|
||||
-- tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
-- deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
|
||||
-- call_started_at TIMESTAMPTZ NOT NULL,
|
||||
-- duration_sec INT NOT NULL CHECK (duration_sec >= 0),
|
||||
-- direction VARCHAR(10) NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||||
-- recording_path VARCHAR(500), -- s3://bucket/recordings/...
|
||||
-- transcript TEXT, -- результат STT (Yandex SpeechKit)
|
||||
-- created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
-- );
|
||||
-- CREATE INDEX ON call_recordings (tenant_id, deal_id, call_started_at DESC);
|
||||
-- ALTER TABLE call_recordings ENABLE ROW LEVEL SECURITY;
|
||||
-- CREATE POLICY tenant_isolation ON call_recordings
|
||||
-- USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- КОНЕЦ schema.sql
|
||||
|
||||
Reference in New Issue
Block a user