Files
portal/db/schema.sql
T

3354 lines
220 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings)
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
-- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 RLS-политика / 5 функций / 15 триггеров
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK projects.supplier_b{1,2,3}_project_id → supplier_projects (ON DELETE SET NULL) + 3 partial index + CHECK chk_projects_b1_not_for_sms (defense-in-depth дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне). Закрывает code-review BLOCKER#1 + WARNING#3 от 10.05.2026 поздний вечер)
-- Базовая версия: v8.16 (10.05.2026 — Plan 1/5 Task 5: supplier_sync_log SaaS-level audit log AJAX-синхронизаций с поставщиком + 1 CHECK (action enum) + 3 индекса + nullable FK на supplier_projects (ON DELETE SET NULL) + REVOKE ALL для crm_app_user)
-- Базовая версия: v8.15 (10.05.2026 — Plan 1/5 Task 4: lead_charges append-only ledger списаний за доставленный лид + composite DEFERRABLE FK на deals + RLS tenant_isolation + 2 индекса + GRANT SELECT,INSERT для crm_app_user)
-- Базовая версия: v8.14 (10.05.2026 — Plan 1/5 Task 3: pricing_tiers SaaS-level конфигурация 7-ступенчатого объёмного тарифа в копейках + 1 CHECK (tier_no 1..7) + 2 индекса + REVOKE ALL + GRANT SELECT для crm_app_user)
-- Базовая версия: v8.13 (10.05.2026 — Plan 1/5 Task 2: supplier_projects SaaS-level агрегатная таблица для проектов у поставщиков B1/B2/B3 + 4 CHECK + 3 индекса + REVOKE ALL FROM crm_app_user)
-- Базовая версия: v8.12 (10.05.2026 — расширение projects для supplier integration: signal_type/identifier/sms_senders/sms_keyword/delivered_in_month/supplier_b{1,2,3}_project_id + 3 CHECK + 1 composite index)
-- Базовая версия: v8.11 (09.05.2026 — hygiene-фиксы аудита: P0-02 RLS на impersonation_tokens + O-perf-02/03 индексы FK-колонок webhook_log_id)
-- Базовая версия: v8.10 (09.05.2026 — in_app_notifications для bell-icon UI; 2 индекса (unread + recent); RLS tenant isolation)
-- Базовая версия: v8.5 (07.05.2026, реализация 27 решений аудита C из реестра v1.12)
-- СУБД: PostgreSQL 16
-- Кодировка: UTF8, локаль ru_RU.UTF-8
-- =============================================================================
--
-- ИСТОЧНИК: документация v8.5 (CRM_bp-gr_Инструкция_v8_5.md), 28 разделов.
-- Каждый блок помечен ссылкой на исходный раздел.
--
-- АРХИТЕКТУРНАЯ МОДЕЛЬ:
-- • Реселлер. Клиент-тенант создаёт проект на нашем портале → мы передаём
-- проект в crm.bp-gr.ru (исходящий API) → crm.bp-gr.ru собирает по нему
-- лиды и шлёт нам webhook → мы передаём клиенту через интерфейс/push.
-- • Договор с crm.bp-gr.ru подписан, мы — официальный корп. клиент.
-- • Мы — самостоятельный оператор ПДн. crm.bp-gr.ru передаёт данные на
-- основании договора. Источник заявок физлиц не контролируется нами.
-- • Поставщики (раскрыто в аудите 04.05.2026, партия 10): внутри crm.bp-gr.ru
-- есть три суб-канала приёма данных — B1 (Сайты/Звонки), B2 (СМС с keyword),
-- B3 (СМС по sender_name). У нас они представлены таблицей suppliers.
-- • Capabilities поставщиков (партия 13.3.5, цитата UI оригинала):
-- 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) — фазы 12:
-- 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) — фазы 13:
-- 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) —
-- регистрация подписок тенантов на исходящие события (deal.created,
-- deal.status_changed, …). Хеш secret + key_prefix аналогично api_keys.
-- 2. Новая таблица outbound_webhook_deliveries (раздел 19.10.6) —
-- журнал попыток доставки с retention 90 дней. attempt_number 1..7,
-- статусы pending/success/failed/permanently_failed.
-- 3. RLS на обе новые таблицы (политика по tenant_id, как остальные).
-- 4. Закрывает тех-долг шапки narrative v8.4 («при правке §7 добавить DDL
-- outbound_webhook_subscriptions и outbound_webhook_deliveries»).
--
-- ИЗМЕНЕНИЯ v8.2 → v8.3:
-- ИЗ ПАРАЛЛЕЛЬНОГО АУДИТА ПАРТИЙ 12–15 (Прил. М v1.1, 05.05.2026):
-- 1. reminders.user_id → reminders.created_by (семантика "from", партия 12.2.4).
-- + поле reminders.completed_at TIMESTAMPTZ (для аудита выполнения).
-- Удалена reminders.is_done — заменена на NOT NULL (completed_at IS NULL).
-- Сохранены is_sent + sent_at для cron нотификаций.
-- 2. Удалены deals.reminder_text, deals.reminder_at и idx_deals_reminder.
-- Множественные напоминания на сделку (партия 12.2.5: histories[].type='reminder').
-- 3. Расширение suppliers 5 полями capabilities (партия 13.3.5):
-- channel, supports_sender_name, supports_keyword, supports_csv_upload,
-- supports_domains_list. Seed B1/B2/B3 обновлён.
-- 4. tenants.desired_daily_numbers INT NULL (партия 13.2.2, Биз-16):
-- целевое количество лидов в день — сигнал для саппорта (не лимит, не биллинг).
-- 5. system_settings: 3 новых ключа для cron projects:purge-deleted (Биз-14):
-- projects_purge_deleted_enabled (по умолчанию false),
-- projects_purge_deleted_ttl_days (по умолчанию 180),
-- projects_purge_deleted_cron (расписание).
--
-- ИЗМЕНЕНИЯ v8.1 → v8.2 (для контекста):
-- ИЗ ИНТЕРВЬЮ С ЗАКАЗЧИКОМ 04.05.2026:
-- 1. pd_subject_requests.processing_restricted (OPEN-Д-1, ст.21 152-ФЗ).
-- 2. Таблица incidents_log (OPEN-Д-5 / OPEN-И-1, журнал инцидентов SaaS).
-- ИЗ АУДИТА crm.bp-gr.ru (Прил. М v1.0, 04.05.2026):
-- 3. Таблица suppliers + seed B1/B2/B3 (партия 10.2).
-- 4. Таблица project_suppliers (m2m, паритет с формой создания проекта).
-- 5. Миграция supplier_lead_costs.supplier_code → supplier_id (FK).
-- 6. Миграция supplier_invoices.supplier_code → supplier_id (FK).
-- 7. Расширение projects 6 полями: daily_limit_target,
-- effective_daily_limit_today, effective_limit_calculated_at,
-- region_mask, region_mode, delivery_days_mask (партия 10.3, 10.6).
-- 8. Таблица project_limit_adjustments + RLS-политика (партия 10.7).
--
-- ИЗМЕНЕНИЯ v8.0 → v8.1 (для контекста):
-- • Админка SaaS: saas_admin_users, saas_admin_recovery_codes,
-- saas_admin_sessions, saas_admin_audit_log.
-- • Impersonation (Ю-1): impersonation_tokens.
-- • Себестоимость / поставщики (Ю-2): supplier_lead_costs (партиционированная),
-- supplier_invoices.
-- • Chargeback (Ю-3): tenants.chargeback_unrecovered_rub.
-- • Уведомления (CTO-4): users.notification_preferences JSONB.
-- • RLS (CTO-5): включён на MVP — политики на 30 tenant-таблиц.
-- • 152-ФЗ: pd_subject_requests, второй consent_type.
--
-- ПОРЯДОК СОЗДАНИЯ:
-- 1) Расширения
-- 2) Справочники без зависимостей (включая suppliers с capabilities — РАСШИРЕНО в v8.3)
-- 3) Tenants (после tariff_plans, +desired_daily_numbers в v8.3)
-- 4) Tenant-уровневые таблицы (включая projects + project_suppliers
-- + project_limit_adjustments)
-- 5) Партиционированная deals + партиции (БЕЗ полей reminder_* в v8.3)
-- 6) Логи и журналы (включая reminders с created_by + completed_at в v8.3)
-- 7) Биллинг SaaS-уровня (включая chargeback)
-- 8) Себестоимость и поставщики (Ю-2, supplier_id вместо supplier_code в v8.2)
-- 9) Отчёты
-- 10) 152-ФЗ (с processing_restricted в v8.2)
-- 11) Админка SaaS + impersonation + incidents_log
-- 12) ALTER TABLE для forward refs
-- 13) Заполнение справочников (lead_statuses, suppliers с capabilities,
-- system_settings с purge-deleted, tariff_plans)
-- 14) Row-Level Security (CTO-5: включён на MVP)
-- 15) Роли БД (CTO-5)
--
-- РАЗМЕР СХЕМЫ v8.5:
-- • 54 логических таблицы (53 из v8.4 + project_user_assignments v8.5).
-- • 12 партиций (6 у deals + 6 у supplier_lead_costs).
-- • 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.
-- =============================================================================
-- =============================================================================
-- 1. РАСШИРЕНИЯ
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- триграммный поиск (на будущее)
CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- GIN-индексы по btree-типам
-- =============================================================================
-- 2. СПРАВОЧНИКИ И SAAS-УРОВЕНЬ БЕЗ ЗАВИСИМОСТЕЙ
-- =============================================================================
-- -----------------------------------------------------------------------------
-- legal_entities — юр. лица оператора SaaS (раздел 20.3)
-- -----------------------------------------------------------------------------
CREATE TABLE legal_entities (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL, -- "ooo_main", "ip_ivanov"
name VARCHAR(500) NOT NULL,
short_name VARCHAR(255), -- "ООО Ромашка"
legal_form VARCHAR(20) NOT NULL, -- 'OOO', 'IP', 'AO', 'PAO', 'NKO'
-- Реквизиты
inn VARCHAR(12) NOT NULL,
kpp VARCHAR(9), -- NULL для ИП
ogrn VARCHAR(15),
okpo VARCHAR(10),
-- Адреса
legal_address TEXT,
actual_address TEXT,
-- Банк
bank_name VARCHAR(255),
bank_account VARCHAR(20),
bank_bik VARCHAR(9),
bank_corr VARCHAR(20),
-- Подписант
director_name VARCHAR(255),
director_post VARCHAR(255) DEFAULT 'Генеральный директор',
director_basis VARCHAR(255) DEFAULT 'Устава', -- Устава / Доверенности №...
-- НДС
vat_mode VARCHAR(20) DEFAULT 'no_vat' -- vat20, vat10, vat7, vat5, vat0, no_vat, usn_6, usn_15
CHECK (vat_mode IN ('vat20','vat10','vat7','vat5','vat0','no_vat','usn_6','usn_15')),
-- Файлы (S3-пути)
signature_path VARCHAR(500), -- скан подписи (PNG, прозрачный фон)
stamp_path VARCHAR(500), -- скан печати
logo_path VARCHAR(500),
-- Управление
is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_legal_entities_default ON legal_entities(is_default) WHERE is_default = TRUE;
-- -----------------------------------------------------------------------------
-- tariff_plans — каталог тарифов (раздел 20.2.1)
-- -----------------------------------------------------------------------------
CREATE TABLE tariff_plans (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL, -- 'start', 'basic', 'pro', 'custom_corp_xyz'
name VARCHAR(255) NOT NULL,
description TEXT,
-- Модель оплаты
billing_model VARCHAR(50) NOT NULL -- 'per_lead', 'monthly', 'hybrid', 'custom'
CHECK (billing_model IN ('per_lead','monthly','hybrid','custom')),
price_per_lead DECIMAL(10,2), -- для per_lead и hybrid
price_monthly DECIMAL(10,2), -- абонплата (monthly и hybrid)
included_leads INT, -- лидов в подписку (monthly и hybrid)
-- Лимиты и фичи (расширяемо)
limits JSONB DEFAULT '{}', -- {"max_users":5,"max_projects":10,"api_rps":60}
features JSONB DEFAULT '[]', -- ["kanban","advanced_analytics","api","2fa","custom_domain"]
-- Trial
trial_bonus_leads INT DEFAULT 0, -- индивидуальный стартовый бонус
-- Видимость
is_active BOOLEAN DEFAULT TRUE,
is_public BOOLEAN DEFAULT TRUE, -- виден на странице регистрации
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
-- -----------------------------------------------------------------------------
-- lead_statuses — справочник статусов воронки (раздел 7.3, 8.1)
-- 5 статусов воронки (все системные)
-- -----------------------------------------------------------------------------
CREATE TABLE lead_statuses (
slug VARCHAR(50) PRIMARY KEY,
name_ru VARCHAR(100) NOT NULL,
is_system BOOLEAN DEFAULT FALSE, -- TRUE = нельзя переименовать
sort_order INT,
color_hex VARCHAR(7),
description TEXT
);
-- -----------------------------------------------------------------------------
-- suppliers — каталог поставщиков данных (НОВАЯ в v8.2, Прил. М §3.1)
-- -----------------------------------------------------------------------------
-- Контекст: аудит crm.bp-gr.ru от 04.05.2026 (партия 10) раскрыл архитектурный
-- слой "Поставщик" — три внутренних канала приёма данных в оригинале:
-- - B1 — основной для типов "Сайты" и "Звонки"
-- - B2 — поддерживает "СМС" через "Наименование отправителя" + "Ключевое слово"
-- - B3 — поддерживает "СМС" только по "Наименованию отправителя"
--
-- Реселлерская модель Ю-2: мы покупаем у crm.bp-gr.ru как у одного контрагента,
-- но внутри crm.bp-gr.ru есть эти три суб-поставщика. На MVP делаем плоский
-- список (B1, B2, B3 — три отдельные строки), без иерархии. Если в будущем
-- появятся другие первичные поставщики — добавится parent_supplier_id или
-- отдельный уровень supplier_channels.
--
-- НЕ tenant-уровневая (общая для всех тенантов). RLS не применяется.
-- Чтение — всем, запись — только под crm_admin_user.
-- -----------------------------------------------------------------------------
CREATE TABLE suppliers (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE, -- 'b1', 'b2', 'b3'
name VARCHAR(255) NOT NULL, -- 'B1 — Сайты и Звонки'
description TEXT,
accepts_types VARCHAR(20)[] NOT NULL, -- ['websites','calls'] | ['sms']
cost_rub DECIMAL(10,2) NOT NULL, -- закупочная цена одного лида
settings_schema JSONB, -- JSON Schema для per-supplier настроек проекта
-- Capabilities (v8.3, партия 13.3.5 аудита — цитата UI оригинала):
-- "Указать в связке 'Наименование отправителя' и 'Ключевое слово' можно
-- только по поставщику B2. Поставщик B3 работает только по наименованию
-- отправителя".
-- Используется UI карточки проекта для генерации релевантных полей.
-- При выборе нескольких поставщиков — пересечение capabilities (intersection).
channel VARCHAR(20) NOT NULL DEFAULT 'sites'
CHECK (channel IN ('sites','calls','sms')),
supports_sender_name BOOLEAN NOT NULL DEFAULT FALSE,
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(),
updated_at TIMESTAMPTZ,
CONSTRAINT chk_suppliers_accepts_types_not_empty
CHECK (array_length(accepts_types, 1) > 0)
);
CREATE INDEX idx_suppliers_active ON suppliers(is_active, sort_order)
WHERE is_active = TRUE;
COMMENT ON TABLE suppliers IS
'Каталог поставщиков данных. На MVP — три строки (B1/B2/B3), '
'суб-поставщики crm.bp-gr.ru, раскрытые в партии 10 аудита 04.05.2026. '
'Capabilities (channel, supports_*) добавлены в v8.3 по итогам партии 13.3.5.';
COMMENT ON COLUMN suppliers.settings_schema IS
'JSON Schema для per-supplier настроек проекта. Для B2: sender_name + keyword. '
'Для B3: только sender_name. UI генерирует форму project_suppliers.settings.';
COMMENT ON COLUMN suppliers.channel IS
'Тип канала приёма данных. Определяет, какие поля проекта релевантны '
'(sites — домены/CSV; calls — телефонные номера; sms — sender_name + keyword).';
COMMENT ON COLUMN suppliers.supports_sender_name IS
'Поддерживает ли поставщик поле "Наименование отправителя" (для SMS-канала). '
'B2=TRUE, B3=TRUE, B1=FALSE.';
COMMENT ON COLUMN suppliers.supports_keyword IS
'Поддерживает ли поставщик поле "Ключевое слово". '
'Только B2=TRUE (партия 13.3.5: "только по поставщику B2").';
-- -----------------------------------------------------------------------------
-- document_sequences — нумерация счетов/УПД (раздел 7.4, 20.10.4)
-- Блокировка FOR UPDATE при выдаче следующего номера — избегаем дыр
-- -----------------------------------------------------------------------------
CREATE TABLE document_sequences (
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
document_type VARCHAR(20) NOT NULL, -- invoice, upd, upd_correction
year INT NOT NULL,
last_number INT NOT NULL DEFAULT 0,
PRIMARY KEY (legal_entity_id, document_type, year)
);
-- -----------------------------------------------------------------------------
-- saas_admin_users — админы SaaS (НОВАЯ, спецификация админки v8.1)
-- -----------------------------------------------------------------------------
CREATE TABLE saas_admin_users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
full_name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- bcrypt cost=12
role VARCHAR(20) NOT NULL
CHECK (role IN ('super_admin','support','finance','compliance','dev_oncall','read_only')),
is_active BOOLEAN DEFAULT TRUE,
-- 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"]
-- Безопасность
password_changed_at TIMESTAMPTZ DEFAULT NOW(),
failed_login_count INT DEFAULT 0,
locked_until TIMESTAMPTZ,
-- Аудит
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by BIGINT REFERENCES saas_admin_users(id),
last_login_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ -- soft delete
);
CREATE INDEX idx_saas_admin_users_active ON saas_admin_users(is_active) WHERE deleted_at IS NULL;
-- -----------------------------------------------------------------------------
-- saas_admin_recovery_codes — recovery-коды 2FA для админов (НОВАЯ)
-- -----------------------------------------------------------------------------
CREATE TABLE saas_admin_recovery_codes (
id BIGSERIAL PRIMARY KEY,
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_saas_admin_recovery_user ON saas_admin_recovery_codes(admin_user_id) WHERE used_at IS NULL;
-- -----------------------------------------------------------------------------
-- saas_admin_sessions — сессии админов (НОВАЯ)
-- Ю-1: добавлены два поля для режима impersonation. В обычном режиме оба NULL.
-- В режиме impersonation оба заполнены, FK на impersonation_tokens (см. ниже).
-- -----------------------------------------------------------------------------
CREATE TABLE saas_admin_sessions (
id BIGSERIAL PRIMARY KEY,
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
ip_address INET NOT NULL, -- проверяется на каждом запросе vs allow-list
user_agent TEXT,
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
-- Impersonation (Ю-1)
impersonating_tenant_id BIGINT, -- FK на tenants(id) добавлен ниже после CREATE TABLE tenants
impersonating_token_id BIGINT, -- FK добавлен ниже после CREATE TABLE impersonation_tokens
CONSTRAINT chk_admin_session_impersonation CHECK (
(impersonating_tenant_id IS NULL AND impersonating_token_id IS NULL)
OR (impersonating_tenant_id IS NOT NULL AND impersonating_token_id IS NOT NULL)
)
);
CREATE INDEX idx_saas_admin_sessions_user ON saas_admin_sessions(admin_user_id, last_active_at DESC);
-- expires_at NOT NULL → partial index по WHERE expires_at IS NOT NULL бесполезен; индекс по полю — для cron-очистки expired-сессий.
CREATE INDEX idx_saas_admin_sessions_expires ON saas_admin_sessions(expires_at);
CREATE INDEX idx_saas_admin_sessions_imp ON saas_admin_sessions(impersonating_tenant_id) WHERE impersonating_tenant_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- impersonation_tokens — одноразовые коды для режима "Войти как клиент" (Ю-1)
-- Workflow:
-- 1. Админ нажимает "Войти как…", указывает основание (>= 30 символов).
-- 2. Создаётся запись, на tenant.contact_email уходит письмо с 6-значным кодом.
-- 3. Срок жизни кода — 15 минут.
-- 4. Клиент передаёт код админу любым способом, админ вводит в админке.
-- 5. Создаётся saas_admin_sessions с impersonating_*, открывается клиентское
-- приложение с принудительным красным баннером.
-- 6. Сессия живёт 1 час, потом session_ended_at = NOW().
-- 7. После 5 неверных вводов кода — токен инвалидируется (failed_attempts).
-- 8. По завершении сессии — email клиенту со списком действий админа.
--
-- НЕ tenant-уровневая (saas-уровневая, как saas_admin_*). RLS не применяется
-- намеренно: токены создаются и используются только из админки SaaS под
-- crm_admin_user (BYPASSRLS). Tenant-приложение к этой таблице не обращается.
-- Изоляция между админами обеспечивается на уровне приложения (поле
-- requested_by) и audit log'а.
-- -----------------------------------------------------------------------------
CREATE TABLE impersonation_tokens (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, -- FK + ON DELETE CASCADE на tenants(id) добавлены ниже после CREATE TABLE tenants
requested_by BIGINT NOT NULL REFERENCES saas_admin_users(id),
code_hash VARCHAR(255) NOT NULL, -- bcrypt 6-значного кода
reason TEXT NOT NULL, -- основание (мин. 30 символов)
sent_to_email VARCHAR(255) NOT NULL, -- snapshot tenant.contact_email на момент запроса
expires_at TIMESTAMPTZ NOT NULL, -- created_at + 15 минут
used_at TIMESTAMPTZ, -- момент успешного ввода
session_id BIGINT REFERENCES saas_admin_sessions(id),
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)
);
CREATE INDEX idx_imp_tokens_active ON impersonation_tokens(tenant_id, expires_at)
WHERE used_at IS NULL AND failed_attempts < 5 AND invalidated_at IS NULL;
CREATE INDEX idx_imp_tokens_admin ON impersonation_tokens(requested_by, created_at DESC);
-- v8.11 (audit P0-02): RLS-isolation между тенантами для одноразовых impersonation-токенов
ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON impersonation_tokens
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
-- Forward FK на impersonation_tokens
ALTER TABLE saas_admin_sessions
ADD CONSTRAINT fk_admin_sessions_imp_token
FOREIGN KEY (impersonating_token_id) REFERENCES impersonation_tokens(id);
-- -----------------------------------------------------------------------------
-- system_settings — глобальные настройки SaaS (раздел 3.4)
-- updated_by — FK на saas_admin_users (РАСШИРЕНИЕ v8.1: в v8.0 без FK)
-- -----------------------------------------------------------------------------
CREATE TABLE system_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
type VARCHAR(20) DEFAULT 'string' -- string, int, decimal, json, bool
CHECK (type IN ('string','int','decimal','json','bool')),
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by BIGINT REFERENCES saas_admin_users(id) -- было BIGINT без FK в v8.0
);
-- -----------------------------------------------------------------------------
-- Laravel-стандартные таблицы (DDL отсутствует в v8.0, реконструировано)
-- -----------------------------------------------------------------------------
-- Очередь Laravel — failed jobs
CREATE TABLE failed_jobs (
id BIGSERIAL PRIMARY KEY,
uuid UUID UNIQUE NOT NULL,
connection TEXT NOT NULL,
queue TEXT NOT NULL,
payload TEXT NOT NULL,
exception TEXT NOT NULL,
failed_at TIMESTAMPTZ DEFAULT NOW()
);
-- Сброс паролей (Laravel-стандарт, раздел 7.1 упоминает таблицу)
CREATE TABLE password_resets (
email VARCHAR(255) PRIMARY KEY,
token VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_password_resets_token ON password_resets(token);
-- Подтверждение email (упомянуто в 4.1 шаг 2 и 7.1)
CREATE TABLE email_verifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users
email VARCHAR(255) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_email_verifications_token ON email_verifications(token) WHERE verified_at IS NULL;
-- =============================================================================
-- 3. TENANTS (раздел 3.4)
-- FK на tariff_plans уже создан выше
-- =============================================================================
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
subdomain VARCHAR(63) UNIQUE NOT NULL, -- "client1"
organization_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'active'
CHECK (status IN ('active','suspended','pending_email_confirm','deleted')),
webhook_token VARCHAR(64) UNIQUE NOT NULL,
webhook_token_rotated_at TIMESTAMPTZ,
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
locale VARCHAR(10) DEFAULT 'ru',
-- Биллинг
current_tariff_id BIGINT REFERENCES tariff_plans(id),
balance_rub DECIMAL(12,2) DEFAULT 0,
balance_leads INT DEFAULT 0,
is_trial BOOLEAN DEFAULT TRUE,
trial_leads_used INT DEFAULT 0,
-- Chargeback (Ю-3): непокрытая балансом часть внешнего возврата.
-- Если > 0 → tenants.status = 'suspended', клиент видит экран погашения в /billing.
chargeback_unrecovered_rub DECIMAL(12,2) DEFAULT 0
CHECK (chargeback_unrecovered_rub >= 0),
-- Активность
last_activity_at TIMESTAMPTZ,
last_webhook_at TIMESTAMPTZ,
-- Целевой объём лидов в день — сигнал для саппорта (Биз-16, партия 13.2.2).
-- НЕ лимит, НЕ биллинговая величина. Используется только для отображения
-- в админке SaaS (саппорту видно, сколько клиент хочет получать в день).
desired_daily_numbers INT
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0),
-- v8.19 (Plan 4): месячный счётчик доставленных лидов per-tenant.
-- Сбрасывается ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Europe/Moscow).
-- Используется PricingTierResolver на горячем пути RouteSupplierLeadJob
-- для O(1) lookup'а текущей ступени тарифа.
delivered_in_month INT NOT NULL DEFAULT 0
CHECK (delivered_in_month >= 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,
-- Plan 5 Task 3: per-tenant override лимитов тарифа.
-- Используется ProjectService::create() для проверки max_projects.
-- {"max_users":5,"max_projects":10,"api_rps":60}
limits JSONB NOT NULL DEFAULT '{}',
-- Метаданные
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5)
);
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
-- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id).
-- Добавляем здесь, потому что tenants создаётся ниже по тексту схемы.
ALTER TABLE saas_admin_sessions
ADD CONSTRAINT fk_admin_sessions_imp_tenant
FOREIGN KEY (impersonating_tenant_id) REFERENCES tenants(id);
ALTER TABLE impersonation_tokens
ADD CONSTRAINT fk_impersonation_tokens_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
-- -----------------------------------------------------------------------------
-- tenant_custom_domains (раздел 3.3, опционально, post-MVP)
-- -----------------------------------------------------------------------------
CREATE TABLE tenant_custom_domains (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain VARCHAR(255) UNIQUE NOT NULL,
ssl_status VARCHAR(20) DEFAULT 'pending'
CHECK (ssl_status IN ('pending','active','error')),
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- 4. TENANT-УРОВНЕВЫЕ БАЗОВЫЕ ТАБЛИЦЫ
-- =============================================================================
-- -----------------------------------------------------------------------------
-- users (раздел 7.3)
-- -----------------------------------------------------------------------------
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
phone VARCHAR(20),
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
avatar_path VARCHAR(500),
-- 2FA
totp_secret TEXT, -- ШИФРУЕТСЯ Crypt::encrypt (encrypted ~256 chars > VARCHAR(255))
totp_enabled BOOLEAN DEFAULT FALSE,
-- Уведомления (раздел 18.5 v8.0 + CTO-4 решение).
-- Матрица 8 типов × до 3 каналов (in-app / push / email).
-- Звук — отдельный глобальный toggle, потому что играет именно в открытой вкладке.
notification_preferences JSONB NOT NULL DEFAULT '{
"new_lead": {"inapp": true, "push": true, "email": false},
"reminder": {"inapp": true, "push": true, "email": true},
"low_balance": {"email": true},
"zero_balance": {"email": true},
"topup_success": {"email": true},
"invoice_paid": {"email": true},
"new_device_login": {"email": true},
"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,
last_active_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
-- Аудит
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ, -- soft delete + анонимизация
UNIQUE (tenant_id, email),
-- CTO-4: базовая защита, что notification_preferences — JSON-объект.
-- Полная валидация структуры — на уровне приложения через Form Request.
CONSTRAINT chk_users_notif_prefs_object CHECK (jsonb_typeof(notification_preferences) = 'object')
);
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email) WHERE deleted_at IS NULL;
-- FK на email_verifications.user_id (forward reference)
ALTER TABLE email_verifications
ADD CONSTRAINT fk_email_verifications_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- -----------------------------------------------------------------------------
-- projects (раздел 7.3, 9; расширено в v8.2 по аудиту партии 10)
-- -----------------------------------------------------------------------------
-- Расширения v8.2 — 6 полей для соответствия модели оригинала (Прил. М §3.2):
-- • daily_limit_target — целевой лимит лидов в день, заданный клиентом
-- • effective_daily_limit_today — реальный лимит после автокоррекции по балансу
-- • effective_limit_calculated_at — когда последний раз пересчитывали
-- • region_mask + region_mode — фильтр по 8 федеральным округам РФ (партия 10.3 секция 6-7)
-- • delivery_days_mask — битмаска 7 дней приёма лидов (партия 10.3 секция 11)
--
-- Бизнес-правило автокоррекции (партия 10.6):
-- effective_daily_limit_today = MIN(daily_limit_target, FLOOR(balance / lead_cost))
-- Пересчитывает сервис EffectiveLimitCalculator (Laravel) по триггерам:
-- - cron limits:recalc в 00:00 МСК для всех активных проектов;
-- - после balance_transactions.commit;
-- - после списания за лид (если effective < target);
-- - при создании проекта; при смене тарифа.
-- -----------------------------------------------------------------------------
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, -- "чистое" имя без префиксов B1_/B2_/B3_
tag VARCHAR(255),
type VARCHAR(20) DEFAULT 'webhook'
CHECK (type IN ('webhook','manual','import')),
-- РАСШИРЕНИЕ v8.12: supplier integration (Plan 1/5 Task 1, spec §2.1)
signal_type VARCHAR(16), -- 'site' | 'call' | 'sms' | NULL (до выбора поставщика)
signal_identifier TEXT, -- URL/телефон для site/call; NULL для sms
sms_senders JSONB, -- массив sender-имён (для signal_type='sms')
sms_keyword TEXT, -- ключевое слово (опционально, signal_type='sms')
is_active BOOLEAN DEFAULT TRUE,
-- РАСШИРЕНИЕ v8.2: динамические лимиты (партия 10.6 аудита)
daily_limit_target INT NOT NULL DEFAULT 10, -- что хочет клиент (default 10 = паритет с оригиналом)
effective_daily_limit_today INT, -- что реально на сегодня (NULL = ещё не считалось)
-- РАСШИРЕНИЕ v8.12: счётчик доставленных в текущем месяце (для месячного лимита)
delivered_in_month INTEGER NOT NULL DEFAULT 0
CHECK (delivered_in_month >= 0),
-- РАСШИРЕНИЕ v8.18 (Plan 2/5): дневной счётчик доставленных лидов.
-- Сбрасывается cron'ом ResetDeliveredTodayCommand в 00:00 МСК.
-- Используется в LeadRouter для проверки квоты (delivered_today < effective_daily_limit).
delivered_today INTEGER NOT NULL DEFAULT 0
CHECK (delivered_today >= 0),
-- РАСШИРЕНИЕ v8.12: ссылки на supplier_projects (B1/B2/B3) — FK добавлены ниже после CREATE TABLE supplier_projects (v8.17)
supplier_b1_project_id BIGINT,
supplier_b2_project_id BIGINT,
supplier_b3_project_id BIGINT,
effective_limit_calculated_at TIMESTAMPTZ,
-- РАСШИРЕНИЕ v8.2: регионы и дни (партия 10.3 секции 6, 7, 11)
region_mask INT NOT NULL DEFAULT 255, -- DEPRECATED Plan 6.5: см. regions INT[]
-- битмаска 8 ФО РФ: бит 1=Центральный, 2=Северо-Западный, 4=Южный,
-- 8=Северо-Кавказский, 16=Приволжский, 32=Уральский, 64=Сибирский,
-- 128=Дальневосточный. 255 = все 8 округов.
region_mode VARCHAR(10) NOT NULL DEFAULT 'include'
CHECK (region_mode IN ('include','exclude')),
-- 'include' = принимать только из выбранных, 'exclude' = принимать кроме выбранных
-- v8.20 (Plan 6): Subject-level regions array. 89 codes из resources/js/constants/regions.ts.
-- Пустой массив = «вся РФ» (паритет с legacy region_mask=255 + region_mode='include').
-- region_mask/region_mode остаются для legacy reader'ов (PhonePrefixService, LeadRouter),
-- DEPRECATED — удаляются в Plan 6.5 после переключения читателей.
regions INT[] NOT NULL DEFAULT '{}'::INT[],
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),
CONSTRAINT chk_projects_daily_limit_positive
CHECK (daily_limit_target > 0),
CONSTRAINT chk_projects_effective_limit_nonnegative
CHECK (effective_daily_limit_today IS NULL OR effective_daily_limit_today >= 0),
CONSTRAINT chk_projects_region_mask_range
CHECK (region_mask BETWEEN 0 AND 255),
CONSTRAINT chk_projects_delivery_days_mask_range
CHECK (delivery_days_mask BETWEEN 0 AND 127),
-- РАСШИРЕНИЕ v8.12: supplier integration (Plan 1/5 Task 1)
CONSTRAINT chk_projects_signal_type
CHECK (signal_type IS NULL OR signal_type IN ('site','call','sms')),
CONSTRAINT chk_projects_sms_senders_required
CHECK (
signal_type <> 'sms'
OR (sms_senders IS NOT NULL AND jsonb_typeof(sms_senders) = 'array' AND jsonb_array_length(sms_senders) > 0)
),
CONSTRAINT chk_projects_signal_identifier_required
CHECK (
signal_type NOT IN ('site','call')
OR (signal_identifier IS NOT NULL AND length(trim(signal_identifier)) > 0)
)
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
CREATE INDEX idx_projects_tag ON projects(tag);
-- РАСШИРЕНИЕ v8.12: composite index для lookup по signal-полям (resolveSignalSource)
CREATE INDEX idx_projects_tenant_signal
ON projects(tenant_id, signal_type, signal_identifier);
-- v8.20 (Plan 6): GIN-индекс для outbound regions queries.
CREATE INDEX idx_projects_regions ON projects USING GIN (regions);
COMMENT ON COLUMN projects.daily_limit_target IS
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
'сегодня — в effective_daily_limit_today (может быть меньше из-за '
'автокоррекции по балансу).';
COMMENT ON COLUMN projects.effective_daily_limit_today IS
'Реальный лимит на сегодня после автокоррекции по балансу. Формула: '
'MIN(daily_limit_target, FLOOR(balance / lead_cost)). Пересчитывается cron '
'limits:recalc в 00:00 МСК и при изменении баланса. NULL = не считалось.';
COMMENT ON COLUMN projects.regions IS
'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.22).';
-- -----------------------------------------------------------------------------
-- supplier_projects — SaaS-level агрегатные проекты у поставщиков (v8.13, Plan 1/5 Task 2)
-- -----------------------------------------------------------------------------
-- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.2.
-- Один supplier_project отражает проект, фактически созданный/синхронизированный у поставщика
-- (B1/B2/B3). Несколько Лидерра-tenant'ов могут шарить один supplier_project (sharing-model
-- из §2.3 spec'а): для сайта/звонка — по domain/phone; для СМС — по (sender, keyword) на B2
-- или (sender) на B3.
--
-- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Доступ из tenant-приложения
-- закрыт через REVOKE ALL FROM crm_app_user (defense-in-depth, аналогично 6 saas-таблицам
-- из v8.5 OPEN-И-14). Сервис-слой синхронизации работает под elevated ролью.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_projects (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3
signal_type VARCHAR(16) NOT NULL, -- site / call / sms
unique_key TEXT NOT NULL, -- domain / phone / sender+keyword / sender
supplier_external_id VARCHAR(64), -- внутренний id у поставщика
current_limit INTEGER NOT NULL DEFAULT 0
CHECK (current_limit >= 0),
current_workdays JSONB, -- объединение workdays активных tenant'ов
current_regions JSONB, -- объединение regions активных tenant'ов
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26)
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
last_synced_at TIMESTAMPTZ,
inactive_since TIMESTAMPTZ, -- момент когда последний tenant отвалился (TTL 180 дней)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_supplier_projects_platform
CHECK (platform IN ('B1','B2','B3')),
CONSTRAINT chk_supplier_projects_signal_type
CHECK (signal_type IN ('site','call','sms')),
CONSTRAINT chk_supplier_projects_sync_status
CHECK (sync_status IN ('pending','ok','failed')),
-- B1 не поддерживает СМС (см. spec §2.2 — таблица platform×signal_type)
CONSTRAINT chk_supplier_projects_b1_not_for_sms
CHECK (NOT (platform = 'B1' AND signal_type = 'sms')),
CONSTRAINT chk_supplier_projects_subject_code
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
);
CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique
ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT;
CREATE INDEX supplier_projects_sync_status_index
ON supplier_projects(sync_status);
CREATE INDEX supplier_projects_inactive_since_index
ON supplier_projects(inactive_since);
-- v8.17 (Plan 1/5 Task 2 fix): FK constraints для projects.supplier_b{1,2,3}_project_id.
-- Колонки заведены в v8.12 как placeholder BIGINT (ALTER TABLE projects); FK невозможно было
-- добавить inline до создания supplier_projects. Здесь фиксируем ссылочную целостность.
-- ON DELETE SET NULL: при удалении supplier_project не каскадим удаление tenant-проектов,
-- но обнуляем привязку — клиент сможет переподключить через resolveOrStub при следующем sync.
ALTER TABLE projects
ADD CONSTRAINT projects_supplier_b1_project_id_fk
FOREIGN KEY (supplier_b1_project_id) REFERENCES supplier_projects(id) ON DELETE SET NULL,
ADD CONSTRAINT projects_supplier_b2_project_id_fk
FOREIGN KEY (supplier_b2_project_id) REFERENCES supplier_projects(id) ON DELETE SET NULL,
ADD CONSTRAINT projects_supplier_b3_project_id_fk
FOREIGN KEY (supplier_b3_project_id) REFERENCES supplier_projects(id) ON DELETE SET NULL;
CREATE INDEX idx_projects_supplier_b1_project_id ON projects(supplier_b1_project_id) WHERE supplier_b1_project_id IS NOT NULL;
CREATE INDEX idx_projects_supplier_b2_project_id ON projects(supplier_b2_project_id) WHERE supplier_b2_project_id IS NOT NULL;
CREATE INDEX idx_projects_supplier_b3_project_id ON projects(supplier_b3_project_id) WHERE supplier_b3_project_id IS NOT NULL;
-- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id).
CREATE TABLE project_supplier_links (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
platform VARCHAR(4) NOT NULL,
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
);
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
CREATE INDEX idx_psl_project ON project_supplier_links(project_id);
-- v8.17 (Plan 1/5 Task 2 fix): defense-in-depth — Project-уровень тоже запрещает B1+SMS.
-- supplier_projects уже имеет CHECK chk_supplier_projects_b1_not_for_sms; здесь дублируем
-- на projects, чтобы исключить логическую несостыковку «sms-проект привязан к B1-supplier».
ALTER TABLE projects
ADD CONSTRAINT chk_projects_b1_not_for_sms
CHECK (signal_type <> 'sms' OR supplier_b1_project_id IS NULL);
-- v8.13 (Plan 1/5 Task 2): defense-in-depth — REVOKE ALL FROM crm_app_user.
-- SaaS-уровневая таблица, tenant-приложению доступ запрещён даже теоретически.
-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) —
-- миграция оборачивает REVOKE в DO $$ EXISTS check.
--
-- REVOKE ALL ON supplier_projects FROM crm_app_user;
-- -----------------------------------------------------------------------------
-- pricing_tiers — SaaS-level конфигурация 7-ступенчатого объёмного тарифа (v8.14, Plan 1/5 Task 3)
-- -----------------------------------------------------------------------------
-- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.2.
-- Клиент Лидерры платит ступенчато: чем больше лидов получено в текущем месяце —
-- тем дешевле каждый следующий. Конфигурируется админом Лидерры; tenant'ы читают.
--
-- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Per-tenant override
-- out of scope для MVP (один тариф на всю Лидерру).
--
-- Цена хранится в копейках (integer) — избегаем floating-point округлений в money-расчётах
-- (1 руб = 100 копеек). UI converts at the edge (accessor в Eloquent-модели, Plan Task 7).
--
-- Поле leads_in_tier nullable: для последней ступени NULL = «всё свыше» (без верхней границы).
--
-- Доступ из tenant-приложения: SELECT only (через elevated service-role идут writes).
-- Conditional: dev runs as postgres superuser без crm_app_user — миграция оборачивает
-- REVOKE/GRANT в DO $$ EXISTS-check.
-- -----------------------------------------------------------------------------
CREATE TABLE pricing_tiers (
id BIGSERIAL PRIMARY KEY,
tier_no SMALLINT NOT NULL
CHECK (tier_no >= 0), -- unsigned-эквивалент: tier_no >= 0; диапазон 1..7 — отдельный CHECK ниже
leads_in_tier INTEGER -- NULL = «всё свыше» для последней ступени
CHECK (leads_in_tier IS NULL OR leads_in_tier >= 0),
price_per_lead_kopecks INTEGER NOT NULL
CHECK (price_per_lead_kopecks >= 0), -- копейки, integer (избегаем float)
is_active BOOLEAN NOT NULL DEFAULT TRUE,
effective_from DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_pricing_tiers_tier_no
CHECK (tier_no BETWEEN 1 AND 7)
);
CREATE UNIQUE INDEX pricing_tiers_tier_effective_unique
ON pricing_tiers(tier_no, effective_from);
CREATE INDEX pricing_tiers_is_active_effective_from_index
ON pricing_tiers(is_active, effective_from);
-- v8.14 (Plan 1/5 Task 3): SELECT-only для tenant-приложения.
-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) —
-- миграция оборачивает REVOKE/GRANT в DO $$ EXISTS check.
--
-- REVOKE ALL ON pricing_tiers FROM crm_app_user;
-- GRANT SELECT ON pricing_tiers TO crm_app_user;
-- -----------------------------------------------------------------------------
-- lead_charges — append-only ledger списаний за доставленный лид (v8.15, Plan 1/5 Task 4)
-- -----------------------------------------------------------------------------
-- Tenant-scoped (RLS), append-only — каждая запись = факт списания с баланса клиента
-- по текущей ступени pricing_tiers при доставке лида в его проект.
-- Composite FK на партиционированную deals(id, received_at) — DEFERRABLE INITIALLY DEFERRED
-- для атомарного INSERT deal+charge в одной транзакции.
-- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4
-- -----------------------------------------------------------------------------
CREATE TABLE lead_charges (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
deal_id BIGINT NOT NULL,
deal_received_at TIMESTAMPTZ NOT NULL,
tier_no SMALLINT NOT NULL,
price_per_lead_kopecks INTEGER NOT NULL,
charge_source VARCHAR(8) NOT NULL DEFAULT 'rub'
CHECK (charge_source IN ('prepaid','rub')),
charged_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_lead_charges_prepaid_zero_price
CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0)
);
-- Composite FK на deals(id, received_at) добавляется в самом конце файла,
-- ПОСЛЕ создания партиционированной deals (section 5). DEFERRABLE INITIALLY DEFERRED —
-- обязательно для атомарного INSERT deal+charge в одной транзакции.
CREATE INDEX lead_charges_tenant_id_charged_at_index
ON lead_charges(tenant_id, charged_at);
CREATE INDEX lead_charges_deal_id_deal_received_at_index
ON lead_charges(deal_id, deal_received_at);
-- RLS: tenant_isolation. ENABLE + FORCE — даже владелец таблицы (postgres) подчиняется
-- политике, иначе superuser обходит RLS. POLICY с USING + WITH CHECK защищает все DML.
ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY;
ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON lead_charges
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint);
-- v8.15 (Plan 1/5 Task 4): SELECT + INSERT для tenant-приложения (append-only ledger).
-- UPDATE/DELETE недопустимы — append-only гарантия для биллинга/аудита.
-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) —
-- миграция оборачивает GRANT в DO $$ EXISTS check.
--
-- GRANT SELECT, INSERT ON lead_charges TO crm_app_user;
-- GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO crm_app_user;
-- -----------------------------------------------------------------------------
-- supplier_sync_log — SaaS-level audit log AJAX-синхронизаций с поставщиком (v8.16, Plan 1/5 Task 5)
-- -----------------------------------------------------------------------------
-- Append-only journal попыток rt-project-{save,update,delete} + session_refresh
-- к поставщику crm.bp-gr.ru. Используется для отладки, retry-логики и алертов.
-- НЕ tenant-scoped — события агрегатные на уровне SaaS (один supplier_project разделяется
-- между tenants, лог общий).
-- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_sync_log (
id BIGSERIAL PRIMARY KEY,
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
action VARCHAR(32) NOT NULL,
request_payload JSONB,
response_body JSONB,
http_status SMALLINT,
error_message TEXT,
duration_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_supplier_sync_log_action
CHECK (action IN ('create','update','delete','disable','session_refresh'))
);
CREATE INDEX supplier_sync_log_supplier_project_id_index ON supplier_sync_log(supplier_project_id);
CREATE INDEX supplier_sync_log_action_index ON supplier_sync_log(action);
CREATE INDEX supplier_sync_log_created_at_index ON supplier_sync_log(created_at);
-- v8.16 (Plan 1/5 Task 5): SaaS-level audit log; tenant-приложение не должно его читать/писать
-- напрямую. REVOKE ALL применяется в production через db/02_grants.sql; в dev — DO $$ EXISTS check.
--
-- REVOKE ALL ON supplier_sync_log FROM crm_app_user;
-- -----------------------------------------------------------------------------
-- supplier_csv_reconcile_log — журнал hourly CSV reconciliation (v8.19, Plan 4)
-- -----------------------------------------------------------------------------
-- SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log.
-- CsvReconcileJob записывает 1 строку на hourly run: started_at, окно,
-- метрики, drift_ratio. drift > 5% → email алерт; alert_email_sent_at timestamp.
-- Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_csv_reconcile_log (
id BIGSERIAL PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
window_start TIMESTAMPTZ NOT NULL,
window_end TIMESTAMPTZ NOT NULL,
total_csv_rows INTEGER,
matched_count INTEGER,
recovered_count INTEGER,
drift_ratio NUMERIC(5,4),
status VARCHAR(16) NOT NULL DEFAULT 'running'
CHECK (status IN ('running','ok','drift_alert','failed')),
error_message TEXT,
alert_email_sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX supplier_csv_reconcile_log_started_at_index
ON supplier_csv_reconcile_log(started_at DESC);
CREATE INDEX supplier_csv_reconcile_log_status_index
ON supplier_csv_reconcile_log(status)
WHERE status IN ('drift_alert','failed');
-- -----------------------------------------------------------------------------
-- supplier_manual_sync_queue — Tier 3 очередь резерва канала миграции проектов (v8.25)
-- -----------------------------------------------------------------------------
-- SaaS-level (не tenant-scoped, без RLS, как supplier_csv_reconcile_log).
-- FailoverProjectChannel записывает строку при провале ярусов 1-2: оператор
-- админ-экрана вносит проект вручную в crm.bp-gr.ru и помечает row resolved.
-- Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_manual_sync_queue (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
platform VARCHAR(8) NOT NULL,
operation VARCHAR(16) NOT NULL,
external_id VARCHAR(64),
payload_snapshot JSONB NOT NULL,
failure_reason VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
);
CREATE INDEX idx_smsq_status_created
ON supplier_manual_sync_queue (status, created_at DESC);
CREATE INDEX idx_smsq_project
ON supplier_manual_sync_queue (project_id);
-- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser.
-- -----------------------------------------------------------------------------
-- project_suppliers — m2m связь "проект ↔ поставщики" (НОВАЯ в v8.2)
-- -----------------------------------------------------------------------------
-- Контекст: в карточке создания проекта в оригинале (партия 10.4) есть три
-- чекбокса B1/B2/B3 — все checked по умолчанию. В карточке редактирования
-- они readonly (партия 10.3). В нашем аналоге сохраняем UX, но разрешаем
-- редактирование (улучшение).
--
-- Per-supplier настройки (settings) хранят значения по схеме
-- suppliers.settings_schema. Например, для B2: {"sender_name": "MYCO", "keyword": "ЗАКАЗ"}
--
-- TENANT-уровневая через FK на projects (RLS наследуется через project.tenant_id).
-- -----------------------------------------------------------------------------
CREATE TABLE project_suppliers (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- по schema suppliers.settings_schema
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
PRIMARY KEY (project_id, supplier_id)
);
CREATE INDEX idx_project_suppliers_supplier ON project_suppliers(supplier_id);
CREATE INDEX idx_project_suppliers_active
ON project_suppliers(project_id)
WHERE is_active = TRUE;
COMMENT ON TABLE project_suppliers IS
'M2M связь "проект ↔ поставщики". Один проект может принимать данные '
'от нескольких поставщиков. По умолчанию при создании проекта подключаются '
'все 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)
-- -----------------------------------------------------------------------------
-- Контекст: партия 10.7 зафиксировала в истории действий проекта системные
-- записи "Проект был запущен с лимитом N по причине нехватки баланса.
-- По факту M". Это лог автокоррекции, который у нас будет вести сервис
-- EffectiveLimitCalculator при каждом изменении effective_daily_limit_today.
--
-- Назначение:
-- - аналитика: сколько раз лимит срезался по балансу;
-- - compliance: следить, что коррекция работает корректно;
-- - расследование споров с клиентом ("почему лидов было меньше ожидаемого").
--
-- TENANT-уровневая (через tenant_id) → RLS-политика tenant_isolation.
-- -----------------------------------------------------------------------------
CREATE TABLE project_limit_adjustments (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
target_limit INT NOT NULL, -- что хотел клиент
effective_limit INT NOT NULL, -- что получилось
adjustment_reason VARCHAR(50) NOT NULL,
balance_at_calc_rub DECIMAL(12,2), -- snapshot баланса на момент расчёта
lead_cost_at_calc_rub DECIMAL(10,2), -- snapshot стоимости лида (из тарифа)
calculated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT chk_project_limit_adj_reason
CHECK (adjustment_reason IN (
'balance_low', -- скорректирован вниз из-за нехватки баланса
'balance_recovered', -- восстановлен до target после пополнения
'manual_override', -- админ вручную изменил
'tariff_change', -- сменился тариф → пересчёт
'daily_recalc', -- плановый пересчёт в 00:00 МСК
'project_created', -- первый расчёт для нового проекта
'target_changed' -- клиент изменил daily_limit_target
)),
CONSTRAINT chk_project_limit_adj_limits_nonneg
CHECK (target_limit >= 0 AND effective_limit >= 0),
CONSTRAINT chk_project_limit_adj_effective_le_target
CHECK (effective_limit <= target_limit OR adjustment_reason = 'manual_override')
);
CREATE INDEX idx_project_limit_adj_project_calc
ON project_limit_adjustments(project_id, calculated_at DESC);
CREATE INDEX idx_project_limit_adj_tenant_calc
ON project_limit_adjustments(tenant_id, calculated_at DESC);
CREATE INDEX idx_project_limit_adj_balance_low
ON project_limit_adjustments(tenant_id, calculated_at DESC)
WHERE adjustment_reason = 'balance_low';
COMMENT ON TABLE project_limit_adjustments IS
'Лог автоматических корректировок дневного лимита проекта. Заполняется '
'сервисом EffectiveLimitCalculator (Laravel) при каждом изменении '
'projects.effective_daily_limit_today.';
-- -----------------------------------------------------------------------------
-- tenant_status_overrides — переименование настраиваемых статусов (раздел 7.3, 8.5)
-- -----------------------------------------------------------------------------
CREATE TABLE tenant_status_overrides (
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status_slug VARCHAR(50) NOT NULL REFERENCES lead_statuses(slug),
custom_name VARCHAR(100) NOT NULL,
PRIMARY KEY (tenant_id, status_slug)
);
-- -----------------------------------------------------------------------------
-- Безопасность: recovery codes, sessions, api keys, auth log (раздел 18.4, 22.8)
-- -----------------------------------------------------------------------------
CREATE TABLE user_recovery_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL, -- bcrypt hash
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_recovery_user ON user_recovery_codes(user_id) WHERE used_at IS NULL;
CREATE TABLE user_sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
ip_address INET,
user_agent TEXT,
geo_country VARCHAR(50),
geo_city VARCHAR(100),
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_user ON user_sessions(user_id, last_active_at DESC);
-- expires_at NOT NULL → partial index по WHERE expires_at IS NOT NULL бесполезен; индекс по полю — для cron-очистки expired-сессий.
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at);
CREATE TABLE api_keys (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(255) UNIQUE NOT NULL, -- bcrypt hash
key_prefix VARCHAR(10) NOT NULL, -- первые 8 символов для UI
scopes JSONB DEFAULT '["read"]', -- ["read","write","delete"]
last_used_at TIMESTAMPTZ,
last_used_ip INET,
-- 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()
);
CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE is_active = TRUE;
-- -----------------------------------------------------------------------------
-- outbound_webhook_subscriptions — подписки тенанта на исходящие события (v8.4)
-- См. narrative §19.10 (Outbound webhook). Уровень 1 стратегии CRM-интеграций
-- (OPEN-И-2). Хеш secret + key_prefix — как в api_keys (раздел 19.3).
-- Список событий хранится в JSONB events с whitelist в приложении (deal.created,
-- deal.status_changed, deal.manager_changed, deal.commented, deal.tag_added,
-- deal.tag_removed, deal.deleted, deal.restored — синхронно с activity_log).
-- -----------------------------------------------------------------------------
CREATE TABLE outbound_webhook_subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id), -- кто создал
name VARCHAR(255) NOT NULL, -- "n8n CRM-копия", "Корпоративный data-pipeline"
target_url VARCHAR(2048) NOT NULL, -- только https:// (валидация в приложении)
secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash; оригинал показывается 1 раз
secret_prefix VARCHAR(10) NOT NULL, -- "whsec_a3f7…" для UI
events JSONB NOT NULL, -- ["deal.created","deal.status_changed",…]; DEFAULT '[]' нельзя — CHECK требует ≥1 элемент
custom_headers JSONB DEFAULT '{}', -- доп. headers получателю (например, X-Tenant-Auth)
is_active BOOLEAN DEFAULT TRUE,
paused_at TIMESTAMPTZ, -- если временно остановлена
last_delivery_at TIMESTAMPTZ, -- последняя успешная доставка
last_failure_at TIMESTAMPTZ, -- последняя неудачная доставка
consecutive_failures INT DEFAULT 0, -- сброс при success
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
-- Защита от misuse: не более 10 активных подписок на тенанта (см. §19.10.9).
-- Это правило проверяется в приложении (нет красивого SQL-варианта без trigger),
-- здесь — мягкая граница CHECK на массив events для самосогласованности.
CONSTRAINT chk_outbound_subs_events CHECK (jsonb_typeof(events) = 'array' AND jsonb_array_length(events) > 0)
);
CREATE INDEX idx_outbound_subs_tenant_active ON outbound_webhook_subscriptions(tenant_id, is_active) WHERE is_active = TRUE;
CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions(secret_prefix);
-- -----------------------------------------------------------------------------
-- outbound_webhook_deliveries — журнал попыток доставки (v8.4)
-- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток
-- от 30 секунд до 24 часов).
-- -----------------------------------------------------------------------------
CREATE TABLE outbound_webhook_deliveries (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
subscription_id BIGINT NOT NULL REFERENCES outbound_webhook_subscriptions(id) ON DELETE CASCADE,
delivery_uuid UUID NOT NULL, -- X-Liderra-Delivery, выдаётся 1 раз на событие
event VARCHAR(50) NOT NULL, -- deal.status_changed и т. п.
payload JSONB NOT NULL, -- тело отправляемого запроса
attempt_number SMALLINT NOT NULL DEFAULT 1
CHECK (attempt_number BETWEEN 1 AND 7),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','success','failed','permanently_failed')),
-- Результат HTTP-запроса (NULL пока pending)
http_status_code SMALLINT, -- 200..599 или NULL для transport-ошибок
response_body TEXT, -- первые 1 КБ тела ответа
response_time_ms INT, -- длительность запроса
error_message VARCHAR(500), -- DNS error / timeout / SSL error / …
-- Временные метки
scheduled_at TIMESTAMPTZ NOT NULL, -- когда должна выполниться попытка
started_at TIMESTAMPTZ, -- фактическое начало запроса
finished_at TIMESTAMPTZ, -- завершение (success/failed)
next_retry_at TIMESTAMPTZ, -- если failed и attempt < 7
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_outbound_deliveries_subscription ON outbound_webhook_deliveries(subscription_id, created_at DESC);
CREATE INDEX idx_outbound_deliveries_status_pending ON outbound_webhook_deliveries(status, scheduled_at) WHERE status = 'pending';
CREATE INDEX idx_outbound_deliveries_created ON outbound_webhook_deliveries(created_at DESC);
-- -----------------------------------------------------------------------------
-- auth_log — лог входов (раздел 22.8)
-- РАСШИРЕНИЕ v8.1: добавлены actor_type и saas_admin_user_id для объединения
-- логов входов клиентских пользователей и админов SaaS в одной таблице.
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2).
-- PK изменён с (id) на (id, created_at) для совместимости с RANGE-партиционированием PG 16.
-- Стартовые партиции создаются миграцией 2026_05_23_000002_partition_audit_tables
-- и далее cron'ом partitions:create-months.
CREATE TABLE auth_log (
id BIGSERIAL,
actor_type VARCHAR(20) NOT NULL DEFAULT 'tenant_user'
CHECK (actor_type IN ('tenant_user','saas_admin')),
tenant_id BIGINT REFERENCES tenants(id), -- NULL для админов SaaS
user_id BIGINT REFERENCES users(id), -- NULL если saas_admin
saas_admin_user_id BIGINT REFERENCES saas_admin_users(id), -- NULL если tenant_user
email VARCHAR(255), -- если actor не найден
event VARCHAR(50) NOT NULL, -- login_success, login_failed, password_reset, ...
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 NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
-- Целостность: actor должен быть ровно одного типа
CONSTRAINT chk_auth_log_actor CHECK (
(actor_type = 'tenant_user' AND user_id IS NOT NULL AND saas_admin_user_id IS NULL)
OR (actor_type = 'saas_admin' AND saas_admin_user_id IS NOT NULL AND user_id IS NULL)
OR (actor_type = 'tenant_user' AND user_id IS NULL AND saas_admin_user_id IS NULL AND email IS NOT NULL)
-- Третий вариант: попытка входа с email, но user_id неизвестен (login_failed для неcуществующего email)
),
PRIMARY KEY (id, created_at) -- v8.31: composite PK (partition key required)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_auth_log_tenant_user ON auth_log(tenant_id, user_id, created_at DESC);
CREATE INDEX idx_auth_log_admin ON auth_log(saas_admin_user_id, created_at DESC) WHERE saas_admin_user_id IS NOT NULL;
CREATE INDEX idx_auth_log_ip_failed ON auth_log(ip_address, created_at DESC) WHERE event = 'login_failed';
CREATE INDEX idx_auth_log_email ON auth_log(email, created_at DESC);
-- -----------------------------------------------------------------------------
-- push_subscriptions — подписки браузеров на Web Push (раздел 17.5)
-- -----------------------------------------------------------------------------
CREATE TABLE push_subscriptions (
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,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
UNIQUE (user_id, endpoint)
);
-- -----------------------------------------------------------------------------
-- comment_templates — шаблоны быстрых комментариев (раздел 15.4)
-- -----------------------------------------------------------------------------
CREATE TABLE comment_templates (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id), -- NULL = глобальный для тенанта
name VARCHAR(255) NOT NULL,
text TEXT NOT NULL,
sort_order INT DEFAULT 0,
usage_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_templates_tenant_user ON comment_templates(tenant_id, user_id);
-- -----------------------------------------------------------------------------
-- deal_tags — теги для сделок (раздел 16.3)
-- -----------------------------------------------------------------------------
CREATE TABLE deal_tags (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color_hex VARCHAR(7) NOT NULL,
description TEXT,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (tenant_id, name)
);
-- -----------------------------------------------------------------------------
-- import_log — журнал CSV-импорта (раздел 6.7, опциональный модуль)
-- -----------------------------------------------------------------------------
CREATE TABLE import_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id),
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500),
rows_total INT DEFAULT 0,
rows_added INT DEFAULT 0,
rows_updated INT DEFAULT 0,
rows_skipped INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending','processing','done','failed')),
error_message TEXT,
started_at TIMESTAMPTZ DEFAULT NOW(),
finished_at TIMESTAMPTZ,
-- Sprint 4 (H2): enrichment-колонки для исторической миграции лидов (раздел 6.4)
entity_type VARCHAR(20) NOT NULL DEFAULT 'leads'
CHECK (entity_type IN ('leads','projects')),
source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru',
mapping_config JSONB,
unknown_statuses_count INT NOT NULL DEFAULT 0,
dry_run BOOLEAN NOT NULL DEFAULT FALSE
);
-- -----------------------------------------------------------------------------
-- import_unknown_statuses — tenant-level маппинг неизвестных статусов CSV (раздел 6.4)
-- Sprint 4 (H1): русский статус из CSV, не найденный в STATUS_RU_TO_SLUG, пишется сюда.
-- Wizard (§6.6) проставляет mapped_to_slug; повторный импорт применяет маппинг.
-- -----------------------------------------------------------------------------
CREATE TABLE import_unknown_statuses (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
status_ru VARCHAR(100) NOT NULL,
occurrences INT NOT NULL DEFAULT 0,
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
resolved_at TIMESTAMPTZ,
resolved_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
UNIQUE (tenant_id, status_ru)
);
CREATE INDEX idx_import_unknown_statuses_unresolved
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL;
-- =============================================================================
-- 5. DEALS — партиционированная по received_at (раздел 7.3)
-- ВАЖНО: PRIMARY KEY обязательно включает ключ партиционирования (received_at)
-- =============================================================================
CREATE TABLE deals (
id BIGSERIAL,
tenant_id BIGINT NOT NULL,
source_crm_id BIGINT, -- vid из webhook (NULL для ручных)
project_id BIGINT NOT NULL,
phone VARCHAR(20) NOT NULL,
phones JSONB, -- дополнительные телефоны
status VARCHAR(50) NOT NULL DEFAULT 'new', -- slug из lead_statuses
contact_name VARCHAR(255),
comment TEXT,
-- v8.3: поля reminder_text и reminder_at удалены, заменены на таблицу reminders
-- (множественные напоминания на сделку, паритет с 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),
subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно
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, -- ключ партиционирования
-- v8.9 (этап 5/5 авто-плана): bulk soft-delete для applyBulkDelete UI-операции.
-- Hard-delete на партиционированной таблице сложнее (CASCADE через webhook_dedup_keys),
-- soft-delete безопаснее для restore-flow в течение N дней. NULL = живая сделка.
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
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),
-- v8.26: subject_code диапазон субъекта РФ 1..89 (defensive parity с supplier_projects).
CONSTRAINT chk_deals_subject_code
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
) PARTITION BY RANGE (received_at);
-- Индексы на родительской таблице наследуются партициями
CREATE INDEX ON deals (tenant_id, status);
CREATE INDEX ON deals (tenant_id, project_id);
CREATE INDEX ON deals (tenant_id, phone);
CREATE INDEX ON deals (tenant_id, manager_id);
-- v8.3: idx_deals_reminder удалён вместе с полем reminder_at
-- v8.6 (CTO-17): UNIQUE INDEX по (tenant_id, source_crm_id) удалён — PostgreSQL запрещает
-- UNIQUE на партиционированной таблице без partition key (`received_at`). Идемпотентность
-- webhook'ов перенесена в новую таблицу `webhook_dedup_keys` (см. ниже после партиций).
-- Здесь оставлен обычный INDEX для скорости lookup'а master-сделок (Биз-19 dedup query).
CREATE 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;
-- (OPEN-И-25) cron leads:escalate-stale — выбирает unclosed deals по assigned_at.
CREATE INDEX ON deals (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected');
-- v8.9: фильтр soft-deleted в DealController::index/show/transition. Partial index
-- по tenant_id, status WHERE deleted_at IS NULL — самый частый паттерн UI-запросов.
CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL;
-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
-- v8.31: переименованы в формат <table>_y<YYYY>_m<MM> (совпадает с MonthlyPartitionManager::partitionName()).
CREATE TABLE deals_y2026_m05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE deals_y2026_m06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE deals_y2026_m07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE deals_y2026_m08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE deals_y2026_m09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE deals_y2026_m10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
-- -----------------------------------------------------------------------------
-- webhook_dedup_keys — идемпотентность webhook'ов от crm.bp-gr.ru
-- v8.6 (CTO-17, 08.05.2026): архитектурная замена UNIQUE INDEX на deals.
-- -----------------------------------------------------------------------------
-- ПРОБЛЕМА: PostgreSQL запрещает UNIQUE на партиционированной таблице без
-- partition key. UNIQUE по (tenant_id, source_crm_id) на deals невозможен,
-- т.к. partition key — received_at. Включить received_at в UNIQUE нельзя —
-- ломает идемпотентность (тот же vid может прийти при retry с разным
-- received_at, и UPSERT перестаёт работать как дедуп).
--
-- РЕШЕНИЕ: отдельная не-партиционированная таблица с PRIMARY KEY
-- (tenant_id, source_crm_id) → ссылка на deal_id. ON CONFLICT работает.
-- Webhook handler (ProcessWebhookJob) выполняет 2-стадийную операцию в одной
-- транзакции:
-- 1. INSERT INTO webhook_dedup_keys VALUES (...)
-- ON CONFLICT (tenant_id, source_crm_id) DO UPDATE
-- RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at;
-- 2. Если is_new — INSERT в deals; иначе UPDATE deals по (deal_id, deal_received_at).
--
-- ОКНО ДЕДУПЛИКАЦИИ: глобальное (нет TTL). Если crm.bp-gr.ru пришлёт тот же vid
-- через 6 месяцев — это тот же лид (UPDATE существующей сделки). Так задумано:
-- vid в crm.bp-gr.ru монотонно растёт, повторов не бывает.
--
-- ON DELETE CASCADE: если deal удалён, dedup-ключ тоже удаляется (composite FK
-- на (deal_id, deal_received_at)).
-- -----------------------------------------------------------------------------
-- v8.7 (08.05.2026 поздний вечер, CTO-17 addendum): FK DEFERRABLE INITIALLY DEFERRED.
-- ProcessWebhookJob (§5.5) делает INSERT в webhook_dedup_keys ДО INSERT в deals
-- (нужно nextval('deals_id_seq') до создания строки в партиционированной deals).
-- DEFERRED → constraint проверяется на COMMIT, в одной транзакции порядок INSERT'ов
-- (dedup_key → deal) валиден. ON DELETE CASCADE по-прежнему срабатывает immediate
-- на DELETE строки в deals.
CREATE TABLE webhook_dedup_keys (
tenant_id BIGINT NOT NULL,
source_crm_id BIGINT NOT NULL,
deal_id BIGINT NOT NULL,
deal_received_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, source_crm_id),
FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX idx_webhook_dedup_keys_deal ON webhook_dedup_keys (deal_id, deal_received_at);
-- =============================================================================
-- 6. ЛОГИ И ЖУРНАЛЫ (зависят от deals/users)
-- ВАЖНО: deal_id — без FK на deals (партиционирование). Целостность — на уровне приложения.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- deal_tag_pivot — связь сделок и тегов (раздел 16.3)
-- -----------------------------------------------------------------------------
CREATE TABLE deal_tag_pivot (
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
tag_id BIGINT NOT NULL REFERENCES deal_tags(id) ON DELETE CASCADE,
tagged_at TIMESTAMPTZ DEFAULT NOW(),
tagged_by BIGINT REFERENCES users(id),
PRIMARY KEY (deal_id, tag_id)
);
CREATE INDEX idx_deal_tag_pivot_tag ON deal_tag_pivot(tag_id);
-- -----------------------------------------------------------------------------
-- activity_log — журнал действий по сделкам (раздел 14.4)
-- РЕТЕНШН: 3 года активно, далее в S3 Glacier
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
CREATE TABLE activity_log (
id BIGSERIAL,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id), -- NULL для системных событий
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
event VARCHAR(100) NOT NULL, -- deal.created, deal.status_changed, ...
old_value TEXT,
new_value TEXT,
context JSONB,
ip_address INET,
user_agent TEXT,
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
PRIMARY KEY (id, created_at) -- v8.31: composite PK
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_activity_tenant_deal_created ON activity_log(tenant_id, deal_id, created_at DESC);
CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id, created_at DESC) WHERE user_id IS NOT NULL;
-- =============================================================================
-- tenant_operations_log — журнал тенант-уровневых операций вне сделок
-- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain.
-- =============================================================================
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
-- RLS и триггеры перенесены в секцию RLS/Triggers (единообразно с другими партиционированными).
CREATE TABLE tenant_operations_log (
id BIGSERIAL,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id), -- NULL для системных
entity_type VARCHAR(50) NOT NULL, -- 'project', 'api_key', 'webhook_settings'
entity_id BIGINT, -- NULL если bulk
event VARCHAR(100) NOT NULL, -- 'project.created', 'api_key.regenerated', ...
payload_before JSONB,
payload_after JSONB,
ip_address INET,
user_agent TEXT,
log_hash BYTEA, -- hash chain
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
PRIMARY KEY (id, created_at) -- v8.31: composite PK
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_tenant_ops_tenant_created
ON tenant_operations_log(tenant_id, created_at DESC);
CREATE INDEX idx_tenant_ops_entity
ON tenant_operations_log(tenant_id, entity_type, entity_id, created_at DESC)
WHERE entity_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- reminders — напоминания по сделкам (раздел 17.5)
-- v8.3: расширено по итогам партии 12.2 аудита.
-- В оригинале реализовано через model.histories[].type='reminder' — массив
-- записей с полями {id, time, time_dt, note, from, to}, где from = создатель
-- (логин), to = null (зарезервировано на будущее под assignee).
-- На MVP паритет: created_by + опциональный assignee_id (по умолчанию NULL).
-- completed_at — наше расширение для аудита (в оригинале нет timestamp закрытия).
-- -----------------------------------------------------------------------------
CREATE TABLE reminders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
text VARCHAR(255), -- = note в оригинале, лимит 255
remind_at TIMESTAMPTZ NOT NULL,
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- = histories[].from в оригинале
assignee_id BIGINT REFERENCES users(id), -- зарезервировано (= to, всегда NULL
-- в оригинале, наше расширение Post-MVP)
completed_at TIMESTAMPTZ, -- NULL = активное; not NULL = выполнено
-- (наше расширение, в оригинале нет)
is_sent BOOLEAN DEFAULT FALSE, -- для cron уведомлений
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
-- Индексы для основных flow:
-- 1) Cron уведомлений: «найти неотправленные напоминания, время которых наступило».
CREATE INDEX idx_reminders_due
ON reminders(remind_at)
WHERE is_sent = FALSE AND completed_at IS NULL;
-- 2) UI карточки сделки: «все напоминания этой сделки».
CREATE INDEX idx_reminders_deal
ON reminders(deal_id);
-- 3) Дашборд «Дела на сегодня / Просроченные / Предстоящие» (из оригинала
-- `?reminders=today|last|future|none` — партия 12.2.6).
CREATE INDEX idx_reminders_tenant_user_active
ON reminders(tenant_id, created_by, remind_at)
WHERE completed_at IS NULL;
-- 4) Аудит: все активные напоминания тенанта.
CREATE INDEX idx_reminders_tenant_active
ON reminders(tenant_id, remind_at)
WHERE completed_at IS NULL;
COMMENT ON TABLE reminders IS
'Напоминания на сделке (раздел 17.5). v8.3: множественные на сделку '
'(паритет с histories[].type=reminder оригинала, партия 12.2.5). '
'Дашборд задач — фильтры today/last/future/none через remind_at + completed_at.';
COMMENT ON COLUMN reminders.created_by IS
'Кто создал напоминание (= histories[].from в оригинале).';
COMMENT ON COLUMN reminders.assignee_id IS
'Кому назначено напоминание. На MVP — всегда NULL (паритет: оригинал имеет '
'поле to: null, не используется в UI). Поле зарезервировано для Post-MVP.';
COMMENT ON COLUMN reminders.completed_at IS
'Время выполнения/закрытия напоминания. Наше расширение сверх паритета '
'(в оригинале closed = удаление записи). NULL = активное.';
-- -----------------------------------------------------------------------------
-- in_app_notifications (раздел 18.5 v8.10) — bell-icon в UI.
-- На каждого user'а пишется row при триггере события (new_lead/reminder/...),
-- если notification_preferences[event].inapp=true. UI читает unread count
-- через GET /api/notifications?unread=1 и список последних 50.
-- РЕТЕНШН: 90 дней или 1000 записей на user (cleanup-job, отдельная задача).
-- -----------------------------------------------------------------------------
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|low_balance|zero_balance|topup_success|invoice_paid|new_device_login|marketing
title VARCHAR(255) NOT NULL,
body TEXT,
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
payload JSONB DEFAULT '{}'::jsonb, -- доп. поля для UI: {link, action_label, ...}
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Главный index UI-флоу: «непрочитанные user'а в порядке свежести».
CREATE INDEX idx_in_app_notifications_user_unread
ON in_app_notifications(user_id, created_at DESC)
WHERE read_at IS NULL;
-- «Последние 50 уведомлений» (с прочитанными).
CREATE INDEX idx_in_app_notifications_user_recent
ON in_app_notifications(user_id, created_at DESC);
COMMENT ON TABLE in_app_notifications IS
'In-app уведомления (раздел 18.5, schema v8.10). Канал inapp в матрице '
'users.notification_preferences. INSERT при триггере события, UPDATE '
'read_at при клике. RLS: tenant isolation.';
-- -----------------------------------------------------------------------------
-- webhook_log — лог принятых webhook (раздел 5.7)
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at).
-- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK
-- партиционированной таблицы с единичным FK-столбцом).
CREATE TABLE webhook_log (
id BIGSERIAL,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook)
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
processed_at TIMESTAMPTZ,
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
error TEXT,
-- v8.29: supplier webhook audit columns
source VARCHAR(50), -- 'supplier' | 'tenant'
status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
lead_id BIGINT, -- supplier_leads.id при статусе 'received'
ip_address INET, -- клиентский IP
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, received_at) -- v8.31: composite PK
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC);
-- -----------------------------------------------------------------------------
-- failed_webhook_jobs — джобы webhook, упавшие после 3 ретраев (упомянуто в 7.1)
-- DDL отсутствует в v8.0, реконструировано
-- -----------------------------------------------------------------------------
CREATE TABLE failed_webhook_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
raw_payload JSONB NOT NULL,
exception TEXT NOT NULL,
retry_count INT DEFAULT 3,
failed_at TIMESTAMPTZ DEFAULT NOW(),
-- Возможность ручного retry из админки
retried_at TIMESTAMPTZ,
retried_by BIGINT REFERENCES saas_admin_users(id),
resolved_at TIMESTAMPTZ
);
CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC) WHERE resolved_at IS NULL;
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02)
-- -----------------------------------------------------------------------------
-- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7)
-- РЕТЕНШН: бессрочно (опционально 12 месяцев)
-- -----------------------------------------------------------------------------
CREATE TABLE rejected_deals_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ...
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);
CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03)
-- -----------------------------------------------------------------------------
-- supplier_leads — SaaS-level raw-payload входящих webhook'ов (v8.18, Plan 2/5)
-- -----------------------------------------------------------------------------
-- Контекст: см. spec §5.1. Поставщик POST'ит на /api/webhook/supplier/{secret}
-- (общий URL, не per-tenant). На момент INSERT'а tenant ещё не определён —
-- routing происходит в RouteSupplierLeadJob через supplier_project + LeadRouter.
-- Поэтому таблица НЕ tenant-scoped, RLS НЕ применяется (как supplier_projects).
--
-- supplier_project_id заполняется job'ом ПОСЛЕ парсинга `project` из payload
-- (`B1_vashinvestor.ru` → platform=B1, signal_identifier=vashinvestor.ru).
-- ON DELETE SET NULL: при удалении supplier_project не каскадим — сохраняем
-- raw для аудита.
--
-- source: 'webhook' (основной канал §5.1) | 'csv_recovery' (резерв §5.2 —
-- Plan 4 CSV reconciliation). На Plan 2 поддерживаем только 'webhook';
-- enum extensibility — для Plan 4.
--
-- deals_created_count: сколько deal-копий было создано после routing'а
-- (sharing-model: 0..N). Заполняется job'ом в конце обработки. NULL до processed.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_leads (
id BIGSERIAL PRIMARY KEY,
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
platform VARCHAR(4) NOT NULL,
raw_payload JSONB NOT NULL,
vid BIGINT, -- nullable: NULL у CSV-recovered лидов (Путь 2)
phone VARCHAR(20) NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
processed_at TIMESTAMPTZ,
-- v8.19 (Plan 4 CSV reconcile): NULL для лидов из webhook (основной канал).
-- Заполняется CsvReconcileJob при восстановлении лида, пропущенного webhook'ом.
recovered_from_csv_at TIMESTAMPTZ,
deals_created_count INTEGER,
error TEXT,
CONSTRAINT chk_supplier_leads_platform
CHECK (platform IN ('B1','B2','B3')),
CONSTRAINT chk_supplier_leads_source
CHECK (source IN ('webhook','csv_recovery')),
CONSTRAINT chk_supplier_leads_deals_count_nonneg
CHECK (deals_created_count IS NULL OR deals_created_count >= 0)
);
CREATE INDEX idx_supplier_leads_received_at ON supplier_leads(received_at DESC);
CREATE INDEX idx_supplier_leads_supplier_project ON supplier_leads(supplier_project_id) WHERE supplier_project_id IS NOT NULL;
CREATE UNIQUE INDEX idx_supplier_leads_vid_unique ON supplier_leads(vid);
CREATE INDEX supplier_leads_recovered_from_csv_partial
ON supplier_leads(recovered_from_csv_at)
WHERE recovered_from_csv_at IS NOT NULL;
-- v8.18 (Plan 2/5): defense-in-depth — REVOKE ALL FROM crm_app_user.
-- SaaS-level таблица. tenant-приложение читать не должно (raw-payload — наша зона).
-- Conditional wrapper см. в supplier_projects (dev=postgres superuser без crm_app_user).
--
-- REVOKE ALL ON supplier_leads FROM crm_app_user;
-- =============================================================================
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
-- разные поставки с одним телефоном остаются отдельными платными лидами.
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
-- при гонках / перезапусках задачи / CSV-восстановлении.
-- =============================================================================
CREATE TABLE supplier_lead_deliveries (
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (supplier_lead_id, tenant_id)
);
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
-- Явные GRANT'ы для 4 ролей (mirror webhook_dedup_keys): на prod таблица
-- создаётся crm_supplier_worker, default privileges не наследуются от
-- postgres-superuser на чужие creator-role. DO block — idempotent + dev-safe
-- (на dev ролей нет → silent skip).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT ALL PRIVILEGES ON supplier_lead_deliveries TO crm_migrator;
END IF;
END $$;
-- =============================================================================
-- 7. БИЛЛИНГ (SAAS-уровень)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- payment_gateways — конфигурация шлюзов (раздел 20.4.2)
-- -----------------------------------------------------------------------------
CREATE TABLE payment_gateways (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL, -- 'yookassa', 'robokassa', 'tinkoff', ...
name VARCHAR(255) NOT NULL, -- "ЮKassa (ООО Ромашка)"
driver VARCHAR(50) NOT NULL, -- класс драйвера в коде
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
config TEXT NOT NULL, -- ШИФРОВАН Crypt::encrypt({"shop_id":"...","secret_key":"..."})
is_active BOOLEAN DEFAULT FALSE,
accepts_methods JSONB DEFAULT '[]', -- ["card","yoomoney","qiwi","sbp","bank_transfer"]
min_amount_rub DECIMAL(10,2) DEFAULT 100.00,
max_amount_rub DECIMAL(12,2),
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
-- -----------------------------------------------------------------------------
-- tariff_subscriptions — подписки тенантов на тарифы (раздел 20.2.3 + CTO-1, CTO-2)
--
-- Жизненный цикл (CTO-1, CTO-2):
-- • Смена тарифа → новая запись 'scheduled', started_at = ближайшие 00:00 МСК.
-- • Cron tariffs:apply-scheduled (каждые 510 минут):
-- - переводит scheduled → active, если started_at <= NOW();
-- - переводит active → superseded, если для тенанта появилась новая active;
-- - переводит active → expired, если expires_at <= NOW();
-- - при expired каскадно ставит tenants.status = 'suspended' (CTO-2).
-- • Все записи (включая superseded/expired/cancelled) хранятся БЕССРОЧНО — это
-- полная история смен тарифа, нужна бухгалтерии и аудиту.
--
-- Семантика статусов:
-- scheduled — будущая активация в 00:00 МСК (CTO-1: вариант Б)
-- active — текущий тариф тенанта
-- superseded — заменён новой подпиской (закрыт сменой тарифа)
-- expired — истёк по expires_at, тенант → suspended (CTO-2: вариант Б)
-- cancelled — отменён админом SaaS при блокировке тенанта (Биз-2: клиент сам не отменяет)
-- -----------------------------------------------------------------------------
CREATE TABLE tariff_subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
tariff_plan_id BIGINT NOT NULL REFERENCES tariff_plans(id),
started_at TIMESTAMPTZ NOT NULL, -- для scheduled — момент в будущем (00:00 МСК)
expires_at TIMESTAMPTZ, -- NULL = бессрочно (для per_lead)
status VARCHAR(20) DEFAULT 'active'
CHECK (status IN ('scheduled','active','superseded','expired','cancelled')),
cancelled_at TIMESTAMPTZ, -- момент cancelled (только для cancelled)
superseded_at TIMESTAMPTZ, -- момент superseded (когда новая active заменила эту)
custom_overrides JSONB, -- индивидуальные условия (price_per_lead, included_leads)
-- created_by: либо tenant_user_id, либо saas_admin_user_id — определяется контекстом
created_by_type VARCHAR(20) -- 'tenant_user' | 'saas_admin' | 'system'
CHECK (created_by_type IN ('tenant_user','saas_admin','system')),
created_by_id BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- CTO-1: гарантия "одна active подписка на тенанта" на уровне БД.
CREATE UNIQUE INDEX idx_subscriptions_one_active_per_tenant
ON tariff_subscriptions(tenant_id) WHERE status = 'active';
-- Аналогичная гарантия для scheduled — иначе можно случайно создать две будущие подписки.
CREATE UNIQUE INDEX idx_subscriptions_one_scheduled_per_tenant
ON tariff_subscriptions(tenant_id) WHERE status = 'scheduled';
CREATE INDEX idx_subscriptions_tenant_status ON tariff_subscriptions(tenant_id, status);
CREATE INDEX idx_subscriptions_apply_scheduled ON tariff_subscriptions(started_at) WHERE status = 'scheduled';
CREATE INDEX idx_subscriptions_apply_expired ON tariff_subscriptions(expires_at) WHERE status = 'active' AND expires_at IS NOT NULL;
-- -----------------------------------------------------------------------------
-- saas_invoices — счета на оплату (раздел 20.10.1)
-- -----------------------------------------------------------------------------
CREATE TABLE saas_invoices (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
invoice_number VARCHAR(50) NOT NULL, -- СЧ-2026-00123
-- Плательщик
payer_type VARCHAR(20) NOT NULL
CHECK (payer_type IN ('legal','individual')),
payer_name VARCHAR(500),
payer_inn VARCHAR(12),
payer_kpp VARCHAR(9),
payer_address TEXT,
payer_email VARCHAR(255),
-- Финансы
amount_net DECIMAL(12,2) NOT NULL,
vat_rate DECIMAL(5,2) DEFAULT 0,
vat_amount DECIMAL(12,2) DEFAULT 0,
amount_total DECIMAL(12,2) NOT NULL,
payment_purpose TEXT,
-- Связи
transaction_id BIGINT, -- FK добавлен ниже после saas_transactions
-- Файлы
pdf_path VARCHAR(500),
-- Статус
status VARCHAR(20) DEFAULT 'issued'
CHECK (status IN ('draft','issued','paid','overdue','cancelled')),
issued_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- по умолчанию +5 рабочих дней
paid_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
UNIQUE (legal_entity_id, invoice_number)
);
-- -----------------------------------------------------------------------------
-- saas_invoice_items — позиции счёта (раздел 20.10.1)
-- -----------------------------------------------------------------------------
CREATE TABLE saas_invoice_items (
id BIGSERIAL PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES saas_invoices(id) ON DELETE CASCADE,
name VARCHAR(500) NOT NULL,
okpd2 VARCHAR(20),
quantity DECIMAL(10,3) DEFAULT 1,
unit VARCHAR(50) DEFAULT 'усл.',
price DECIMAL(12,2) NOT NULL,
amount_net DECIMAL(12,2) NOT NULL,
vat_rate DECIMAL(5,2),
vat_amount DECIMAL(12,2),
amount_total DECIMAL(12,2) NOT NULL
);
-- -----------------------------------------------------------------------------
-- saas_upd_documents — УПД (раздел 20.10.2)
-- -----------------------------------------------------------------------------
CREATE TABLE saas_upd_documents (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
upd_number VARCHAR(50) NOT NULL,
upd_function VARCHAR(10) DEFAULT 'СЧФ'
CHECK (upd_function IN ('СЧФ','ДОП')),
correction_for BIGINT REFERENCES saas_upd_documents(id), -- если корректировочный
-- Покупатель
buyer_type VARCHAR(20) NOT NULL
CHECK (buyer_type IN ('legal','individual')),
buyer_name VARCHAR(500),
buyer_inn VARCHAR(12),
buyer_kpp VARCHAR(9),
buyer_address TEXT,
-- Финансы
amount_net DECIMAL(12,2) NOT NULL,
vat_rate DECIMAL(5,2),
vat_amount DECIMAL(12,2),
amount_total DECIMAL(12,2) NOT NULL,
-- Связи
invoice_id BIGINT REFERENCES saas_invoices(id),
transaction_id BIGINT, -- FK добавлен ниже
-- Файлы
pdf_path VARCHAR(500),
-- Статус
status VARCHAR(20) DEFAULT 'issued'
CHECK (status IN ('draft','issued','signed','cancelled')),
issued_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (legal_entity_id, upd_number)
);
-- -----------------------------------------------------------------------------
-- saas_transactions — транзакции SaaS-биллинга (раздел 20.5 + CTO-3, Ю-3)
--
-- Жизненный цикл (CTO-3):
-- • [*] → pending: POST /api/v1/billing/topup (карта/СБП/ЮMoney/QIWI).
-- • [*] → success: банк.перевод, админ подтверждает в админке (раздел 20.9).
-- • pending → success: webhook payment.succeeded ИЛИ polling-fallback от cron.
-- • pending → failed: webhook payment.canceled / cron-таймаут 30 мин / hard 24ч.
-- • success → refunded: через refund_requests.processed.
-- Источник refund_requests:
-- - admin_initiated (наш возврат, раздел 20.6);
-- - external_chargeback (Ю-3: webhook refund.succeeded без refund_requests
-- → refund_requests создаётся автоматически).
-- • failed → success ЗАПРЕЩЁН (CTO-3). Поздний webhook payment.succeeded по
-- failed-транзакции → алерт в админке для finance, ручной разбор.
--
-- Cron payments:cancel-stale (каждые 510 минут):
-- • status='pending' AND created_at < NOW() - INTERVAL '30 minutes':
-- - если gateway_payment_id IS NULL → failed, failure_reason='gateway_create_timeout';
-- - иначе driver.getPaymentStatus(): succeeded → success (polling-fallback);
-- canceled/failed → failed;
-- pending → пропустить;
-- unreachable → пропустить.
-- • Hard timeout 24 часа: status='pending' AND created_at < NOW() - INTERVAL '24 hours'
-- → failed, failure_reason='hard_timeout_24h' независимо от ответа шлюза.
--
-- Допустимые failure_reason (для фильтров в админке):
-- gateway_canceled, gateway_declined, gateway_create_timeout,
-- hard_timeout_24h, cron_polling_failed, gateway_unknown,
-- late_webhook_ignored (для логирования поздних webhook на failed транзакции).
-- -----------------------------------------------------------------------------
CREATE TABLE saas_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL
CHECK (type IN ('topup','refund','manual_credit','manual_debit')),
amount_rub DECIMAL(12,2) NOT NULL,
balance_rub_after DECIMAL(12,2),
leads_credited INT DEFAULT 0,
-- Шлюз
gateway_id BIGINT REFERENCES payment_gateways(id),
gateway_code VARCHAR(50),
gateway_payment_id VARCHAR(255),
gateway_idempotence_key UUID,
-- Способ оплаты
payment_method VARCHAR(50), -- 'card','yoomoney','sbp','bank_transfer','qiwi','webmoney'
-- Юр. лицо получатель
legal_entity_id BIGINT REFERENCES legal_entities(id),
-- Документы
invoice_id BIGINT REFERENCES saas_invoices(id),
upd_id BIGINT REFERENCES saas_upd_documents(id),
-- Статус
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending','success','failed','refunded')),
description TEXT,
failure_reason VARCHAR(50), -- enum в комментарии выше
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_saas_tx_tenant_created ON saas_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_saas_tx_status ON saas_transactions(status) WHERE status IN ('pending');
CREATE INDEX idx_saas_tx_gateway ON saas_transactions(gateway_code, gateway_payment_id);
CREATE UNIQUE INDEX idx_saas_tx_idempotence ON saas_transactions(gateway_idempotence_key) WHERE gateway_idempotence_key IS NOT NULL;
-- CTO-3: для cron payments:cancel-stale (каждые 5-10 минут)
CREATE INDEX idx_saas_tx_pending_stale ON saas_transactions(created_at) WHERE status = 'pending';
-- Forward FK на saas_transactions
ALTER TABLE saas_invoices ADD CONSTRAINT fk_saas_invoices_tx FOREIGN KEY (transaction_id) REFERENCES saas_transactions(id);
ALTER TABLE saas_upd_documents ADD CONSTRAINT fk_saas_upd_tx FOREIGN KEY (transaction_id) REFERENCES saas_transactions(id);
-- -----------------------------------------------------------------------------
-- refund_requests — запросы на возврат (раздел 20.6 + Ю-3)
-- РАСШИРЕНИЯ v8.1:
-- • admin_user_id получил FK на saas_admin_users.
-- • source — admin_initiated или external_chargeback (Ю-3). При chargeback
-- запись создаётся автоматически из webhook без участия админа.
-- -----------------------------------------------------------------------------
CREATE TABLE refund_requests (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
transaction_id BIGINT NOT NULL REFERENCES saas_transactions(id),
requested_amount_rub DECIMAL(12,2),
reason TEXT,
-- Источник возврата (Ю-3)
source VARCHAR(20) NOT NULL DEFAULT 'admin_initiated'
CHECK (source IN ('admin_initiated','external_chargeback')),
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','processed')),
-- admin_user_id NULL для source='external_chargeback' (автоматическое создание из webhook)
admin_user_id BIGINT REFERENCES saas_admin_users(id),
admin_decision TEXT,
gateway_refund_id VARCHAR(255),
correction_upd_id BIGINT REFERENCES saas_upd_documents(id),
requested_at TIMESTAMPTZ DEFAULT NOW(),
decided_at TIMESTAMPTZ,
processed_at TIMESTAMPTZ,
-- Целостность: для admin_initiated админ обязателен после approved/rejected
CONSTRAINT chk_refund_admin_decision CHECK (
(source = 'external_chargeback')
OR (status = 'pending')
OR (admin_user_id IS NOT NULL)
)
);
CREATE INDEX idx_refund_requests_pending ON refund_requests(status, requested_at) WHERE status = 'pending';
CREATE INDEX idx_refund_requests_chargeback ON refund_requests(tenant_id, requested_at DESC) WHERE source = 'external_chargeback';
-- -----------------------------------------------------------------------------
-- balance_transactions — внутренний лид-биллинг (раздел 7.3, 21)
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
CREATE TABLE balance_transactions (
id BIGSERIAL,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL
CHECK (type IN ('trial_bonus','topup','lead_charge','refund',
'manual_adjustment','historical_import',
'chargeback_writedown', -- Ю-3: списание непокрытой части chargeback в долг тенанта
'chargeback_repayment', -- Ю-3: погашение долга через /billing
'migration' -- Billing v2 Spec A: конвертация balance_leads → balance_rub
)),
amount_rub DECIMAL(12,2) DEFAULT 0,
amount_leads INT DEFAULT 0,
balance_rub_after DECIMAL(12,2),
balance_leads_after INT,
description TEXT,
related_type VARCHAR(100), -- "App\\Models\\Deal" / "SaasTransaction" / etc
related_id BIGINT,
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 NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
PRIMARY KEY (id, created_at) -- v8.31: composite PK
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_balance_tenant_created ON balance_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_balance_tenant_type ON balance_transactions(tenant_id, type);
-- =============================================================================
-- 8. ОТЧЁТЫ
-- =============================================================================
-- -----------------------------------------------------------------------------
-- report_jobs — задания на генерацию отчётов (раздел 13.5)
-- -----------------------------------------------------------------------------
CREATE TABLE report_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id),
type VARCHAR(50) NOT NULL, -- deals_export, projects_summary, conversion_funnel, ...
parameters JSONB,
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending','processing','done','failed')),
file_path VARCHAR(500), -- s3://bucket/path/file.xlsx
file_size BIGINT,
generation_seconds INT,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
finished_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ -- автоудаление файла
);
CREATE INDEX idx_report_jobs_tenant_user ON report_jobs(tenant_id, user_id, created_at DESC);
-- =============================================================================
-- 8.5. СЕБЕСТОИМОСТЬ И ПОСТАВЩИКИ (Ю-2)
-- =============================================================================
--
-- Реселлерская модель: мы покупаем лиды у crm.bp-gr.ru, перепродаём тенантам.
-- На MVP единая закупочная цена для всех лидов (system_settings.supplier_default_cost_rub).
-- На v2 предусмотрена эволюция к точным ценам по проектам/категориям.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- supplier_lead_costs — себестоимость каждого лида (Ю-2; в v8.2 supplier_id вместо supplier_code)
-- Партиционирована по received_at синхронно с deals.
-- В ProcessWebhookJob создаётся в той же транзакции, что и deals + balance_transactions.
-- -----------------------------------------------------------------------------
-- ИЗМЕНЕНИЯ v8.2: supplier_code (VARCHAR DEFAULT 'crm_bp_gr') заменён на
-- supplier_id (BIGINT FK → suppliers.id). cost_rub теперь snapshot из
-- suppliers.cost_rub конкретного поставщика (B1/B2/B3), а не из
-- system_settings.supplier_default_cost_rub.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_lead_costs (
id BIGSERIAL,
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
received_at TIMESTAMPTZ NOT NULL, -- ключ партиционирования (= deals.received_at)
supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
cost_rub DECIMAL(10,2) NOT NULL, -- snapshot suppliers.cost_rub на момент приёма
supplier_lead_id BIGINT, -- vid из webhook crm.bp-gr.ru
supplier_invoice_id BIGINT, -- FK добавлен ниже после supplier_invoices
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
-- Индексы на родительской таблице наследуются партициями
CREATE INDEX ON supplier_lead_costs (deal_id, received_at);
CREATE INDEX ON supplier_lead_costs (supplier_invoice_id) WHERE supplier_invoice_id IS NOT NULL;
CREATE INDEX ON supplier_lead_costs (supplier_id, received_at DESC); -- v8.2: аналитика "лиды по поставщикам"
-- Партиции синхронно с deals (создаются cron на 2 месяца вперёд)
-- v8.31: переименованы в формат <table>_y<YYYY>_m<MM>.
CREATE TABLE supplier_lead_costs_y2026_m05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE supplier_lead_costs_y2026_m06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE supplier_lead_costs_y2026_m07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE supplier_lead_costs_y2026_m08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE supplier_lead_costs_y2026_m09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE supplier_lead_costs_y2026_m10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
-- -----------------------------------------------------------------------------
-- supplier_invoices — счета от crm.bp-gr.ru к нам (Ю-2; в v8.2 supplier_id)
-- Заполняется finance вручную при получении счёта от поставщика.
-- Используется для:
-- - сверки: SUM(supplier_lead_costs.cost_rub) за период vs amount_rub счёта;
-- - выявления расхождений (отображается в discrepancy_rub);
-- - бухгалтерского следа;
-- - отчёта "расходы по поставщику".
-- ИЗМЕНЕНИЯ v8.2: supplier_code → supplier_id (FK на suppliers).
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_invoices (
id BIGSERIAL PRIMARY KEY,
supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
invoice_number VARCHAR(100),
period_from DATE NOT NULL,
period_to DATE NOT NULL,
leads_count INT, -- по счёту от поставщика
leads_count_actual INT, -- по нашим данным (заполняется при сверке)
amount_rub DECIMAL(12,2), -- по счёту
amount_rub_actual DECIMAL(12,2), -- по нашим данным
discrepancy_rub DECIMAL(12,2)
GENERATED ALWAYS AS (COALESCE(amount_rub,0) - COALESCE(amount_rub_actual,0)) STORED,
pdf_path VARCHAR(500),
paid_at TIMESTAMPTZ,
paid_by BIGINT REFERENCES saas_admin_users(id),
notes TEXT,
status VARCHAR(20) DEFAULT 'received'
CHECK (status IN ('received','reconciled','paid','disputed')),
reconciled_by BIGINT REFERENCES saas_admin_users(id),
reconciled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT chk_supplier_invoices_period CHECK (period_to >= period_from)
);
CREATE INDEX idx_supplier_invoices_period ON supplier_invoices(period_from, period_to);
CREATE INDEX idx_supplier_invoices_unpaid ON supplier_invoices(status) WHERE status IN ('received','reconciled');
CREATE INDEX idx_supplier_invoices_supplier ON supplier_invoices(supplier_id); -- v8.2
-- Forward FK
ALTER TABLE supplier_lead_costs
ADD CONSTRAINT fk_supplier_lead_costs_invoice
FOREIGN KEY (supplier_invoice_id) REFERENCES supplier_invoices(id);
-- =============================================================================
-- 9. 152-ФЗ
-- =============================================================================
-- -----------------------------------------------------------------------------
-- tenant_consents — согласия на обработку ПДн (раздел 22.9.1)
-- -----------------------------------------------------------------------------
CREATE TABLE tenant_consents (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id),
consent_type VARCHAR(50) NOT NULL, -- pd_processing, marketing, oferta_v1, ...
document_version VARCHAR(20), -- "v2.1"
given_at TIMESTAMPTZ DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_consents_tenant ON tenant_consents(tenant_id, consent_type);
-- -----------------------------------------------------------------------------
-- pd_processing_log — журнал обработки ПДн (раздел 22.9.3)
-- РАСШИРЕНИЕ v8.1: разделение actor_user_id на два поля с FK
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
CREATE TABLE pd_processing_log (
id BIGSERIAL,
tenant_id BIGINT REFERENCES tenants(id),
subject_type VARCHAR(50), -- 'user', 'lead'
subject_id BIGINT,
action VARCHAR(50), -- 'created', 'viewed', 'updated', 'deleted', 'exported'
purpose VARCHAR(255), -- 'lead_processing', 'analytics', 'support_request'
-- Актор: ровно один из двух
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 NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
CONSTRAINT chk_pd_actor CHECK (
(actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL)
OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NOT NULL)
OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NULL) -- системное действие
),
PRIMARY KEY (id, created_at) -- v8.31: composite PK
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_pd_log_tenant ON pd_processing_log(tenant_id, created_at DESC);
CREATE INDEX idx_pd_log_admin_actor ON pd_processing_log(actor_admin_user_id, created_at DESC) WHERE actor_admin_user_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- pd_subject_requests — обращения субъектов ПДн (НОВАЯ в v8.1, расширена в v8.2)
-- Срок ответа по 152-ФЗ — 30 дней
-- -----------------------------------------------------------------------------
-- РАСШИРЕНИЕ v8.2 (OPEN-Д-1, ст.21 ч.5 152-ФЗ):
-- processing_restricted BOOLEAN — субъект ПДн вправе требовать "прекращения
-- обработки" (не путать с удалением: данные сохраняются, но запрещаются любые
-- операции, кроме хранения). При TRUE сервисы при чтении/мутации ПДн
-- субъекта поднимают ProcessingRestrictedException (App\Services\Pd\ProcessingRestrictionGuard).
--
-- НЕ tenant-уровневая (saas-уровневая). RLS не применяется намеренно:
-- обращение от субъекта ПДн поступает ДО идентификации тенанта (субъект может
-- не знать, в каком тенанте лежат его данные; tenant_id — nullable, заполняется
-- compliance-админом по результатам поиска). Доступ — только из админки SaaS
-- под crm_admin_user (BYPASSRLS), tenant-приложение к этой таблице не
-- обращается. Связь с тенантом фиксируется через nullable tenant_id для
-- маршрутизации запросов на удаление/исправление.
-- -----------------------------------------------------------------------------
CREATE TABLE pd_subject_requests (
id BIGSERIAL PRIMARY KEY,
received_at TIMESTAMPTZ DEFAULT NOW(),
subject_email VARCHAR(255),
subject_phone VARCHAR(20),
subject_full_name VARCHAR(255),
request_type VARCHAR(30) NOT NULL
CHECK (request_type IN ('access','rectification','deletion','objection')),
description TEXT,
status VARCHAR(20) DEFAULT 'received'
CHECK (status IN ('received','in_progress','completed','rejected')),
-- Связь с тенантом (если запрос относится к лиду в конкретном тенанте)
tenant_id BIGINT REFERENCES tenants(id),
-- Обработка
assigned_admin_id BIGINT REFERENCES saas_admin_users(id),
response_sent_at TIMESTAMPTZ,
response_text TEXT,
-- 30 дней по 152-ФЗ. v8.6 (CTO-17): GENERATED ALWAYS AS отклонён PostgreSQL 16
-- (`+ INTERVAL '30 days'` не immutable — зависит от timezone/DST). Заполняется
-- триггером `trg_pd_subject_requests_deadline` (см. функцию ниже).
deadline_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
-- РАСШИРЕНИЕ v8.2: ст.21 ч.5 152-ФЗ
processing_restricted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_pd_requests_status ON pd_subject_requests(status, deadline_at) WHERE status IN ('received','in_progress');
CREATE INDEX idx_pd_requests_assigned ON pd_subject_requests(assigned_admin_id) WHERE status IN ('received','in_progress');
CREATE INDEX idx_pd_requests_restricted ON pd_subject_requests(processing_restricted) WHERE processing_restricted = TRUE;
COMMENT ON COLUMN pd_subject_requests.processing_restricted IS
'Реализация ст.21 ч.5 152-ФЗ. При TRUE все операции с ПДн субъекта '
'блокируются на уровне сервиса (ProcessingRestrictedException). '
'Сохраняется только право на хранение и удаление. Снимается только '
'compliance-админом по обоснованному запросу.';
-- -----------------------------------------------------------------------------
-- incidents_log — журнал инцидентов SaaS (НОВАЯ в v8.2, OPEN-Д-5 / OPEN-И-1)
-- -----------------------------------------------------------------------------
-- Контекст: журнал инцидентов SaaS-уровня — недоступность, утечка, инцидент
-- безопасности, сбой биллинга и т.п. Заполняется on-call дежурным из админки
-- SaaS (Прил. И v8.2 раздел 6).
--
-- Связь с pd_subject_requests: при инциденте утечки ПДн в incidents_log
-- сохраняется массив ID связанных обращений субъектов
-- (related_pd_subject_request_ids).
--
-- НЕ tenant-уровневая (наши SaaS-инциденты, не клиентские).
-- RLS не применяется. Доступ — только под crm_admin_user.
-- -----------------------------------------------------------------------------
CREATE TABLE incidents_log (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL
CHECK (type IN (
'service_outage', -- недоступность сервиса
'data_breach', -- утечка данных
'security_incident', -- иной инцидент безопасности
'billing_failure', -- сбой платёжного шлюза
'data_corruption', -- порча данных
'integration_failure', -- сбой внешней интеграции
'performance_degradation', -- деградация производительности
'other'
)),
severity VARCHAR(20) NOT NULL
CHECK (severity IN ('low','medium','high','critical')),
started_at TIMESTAMPTZ NOT NULL,
detected_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
summary TEXT NOT NULL, -- краткое описание (1-2 предложения)
root_cause TEXT, -- заполняется после postmortem
postmortem_url VARCHAR(500), -- ссылка на полный документ
related_pd_subject_request_ids BIGINT[], -- если инцидент связан с обращениями субъектов
affected_tenant_ids BIGINT[], -- какие тенанты затронуты
affected_users_count INT, -- сколько пользователей затронуто (примерно)
notification_sent_at TIMESTAMPTZ, -- когда отправили уведомление пострадавшим
rkn_notified_at TIMESTAMPTZ, -- если data_breach — когда уведомили РКН (152-ФЗ ст.18.1 ч.3.1)
created_by_admin_id BIGINT NOT NULL REFERENCES saas_admin_users(id),
closed_by_admin_id BIGINT REFERENCES saas_admin_users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT chk_incidents_resolved_after_start
CHECK (resolved_at IS NULL OR resolved_at >= started_at),
CONSTRAINT chk_incidents_detected_after_start
CHECK (detected_at >= started_at)
);
CREATE INDEX idx_incidents_started ON incidents_log(started_at DESC);
CREATE INDEX idx_incidents_severity_unresolved
ON incidents_log(severity)
WHERE resolved_at IS NULL;
CREATE INDEX idx_incidents_type ON incidents_log(type, started_at DESC);
CREATE INDEX idx_incidents_data_breach ON incidents_log(rkn_notified_at) WHERE type = 'data_breach';
COMMENT ON TABLE incidents_log IS
'Журнал инцидентов SaaS-уровня. Заполняется on-call дежурным из админки '
'SaaS (Прил. И v8.2 раздел 6). Не tenant-уровневый, RLS не применяется. '
'Связан с pd_subject_requests через related_pd_subject_request_ids '
'для случаев утечки ПДн.';
COMMENT ON COLUMN incidents_log.rkn_notified_at IS
'Для type=data_breach. Срок уведомления РКН — 24 часа с момента '
'установления факта утечки (152-ФЗ ст.18.1 ч.3.1 в редакции 02.07.2021).';
-- =============================================================================
-- 10. ПУЛЬС ПЛАНИРОВЩИКА (SCHEDULER HEARTBEAT) — SaaS-level (hole #6)
-- Без RLS — системная таблица SaaS уровня.
-- Одна строка на каждую cron-задачу (PK = command_name).
-- -----------------------------------------------------------------------------
CREATE TABLE scheduler_heartbeats (
command_name VARCHAR(200) NOT NULL PRIMARY KEY,
last_run_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error TEXT,
runtime_ms INT,
consecutive_failures INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE scheduler_heartbeats IS
'Пульс планировщика: одна строка на cron-задачу, обновляется при каждом запуске. '
'SaaS-level, без RLS. Используется SchedulerCheckHeartbeats для детекции '
'пропавших или постоянно падающих задач (hole #6).';
-- =============================================================================
-- 11. АДМИНКА SAAS — ЖУРНАЛ ДЕЙСТВИЙ (НОВАЯ)
-- saas_admin_users уже создана выше (нужна была для FK от других таблиц)
-- =============================================================================
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
-- Без RLS: доступна только crm_admin_user (BYPASSRLS).
CREATE TABLE saas_admin_audit_log (
id BIGSERIAL,
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id),
action VARCHAR(100) NOT NULL, -- 'tenant.suspend', 'refund.approve', 'system_settings.update', ...
target_type VARCHAR(50), -- 'tenant', 'saas_transaction', 'system_setting', ...
target_id BIGINT,
target_tenant_id BIGINT REFERENCES tenants(id), -- денормализация для быстрого фильтра
payload_before JSONB,
payload_after JSONB,
reason TEXT, -- обязательное основание для критичных действий
ip_address INET NOT NULL,
user_agent TEXT,
-- Four-eyes: для операций > порога нужно подтверждение второго админа
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 NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
PRIMARY KEY (id, created_at) -- v8.31: composite PK
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_admin_audit_admin ON saas_admin_audit_log(admin_user_id, created_at DESC);
CREATE INDEX idx_admin_audit_tenant ON saas_admin_audit_log(target_tenant_id, created_at DESC) WHERE target_tenant_id IS NOT NULL;
CREATE INDEX idx_admin_audit_action ON saas_admin_audit_log(action, created_at DESC);
CREATE INDEX idx_admin_audit_pending ON saas_admin_audit_log(approved_at) WHERE requires_approval = TRUE AND approved_at IS NULL;
-- =============================================================================
-- 11. ЗАПОЛНЕНИЕ СПРАВОЧНИКОВ
-- =============================================================================
-- 5 статусов воронки (редизайн «Сделки» 2026-05-17 — было 14)
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
('new', 'Новая сделка', TRUE, 1, '#3B82F6'),
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
('in_progress', 'В работе', TRUE, 3, '#06B6D4'),
('won', 'Сделка', TRUE, 4, '#10B981'),
('lost', 'Не реализовано', TRUE, 5, '#6B7280');
-- НОВОЕ в v8.2: каталог поставщиков B1/B2/B3
-- Раскрыты в партии 10.2 аудита crm.bp-gr.ru от 04.05.2026.
-- Прил. М §3.1 — обоснование модели.
-- Seed B1/B2/B3 — три суб-поставщика crm.bp-gr.ru, выявленные в партии 10.2 аудита.
-- v8.3: добавлены поля capabilities (channel, supports_*) — партия 13.3.5 аудита.
-- Цены 1.00 ₽ — placeholders, фактические значения берутся из договора с поставщиком
-- (ждём от заказчика по Б-1).
INSERT INTO suppliers (code, name, description, accepts_types, cost_rub, settings_schema,
channel, supports_sender_name, supports_keyword,
supports_csv_upload, supports_domains_list, sort_order)
VALUES
('b1', 'B1 — Сайты и Звонки',
'Основной поставщик для проектов типов «Сайты» и «Звонки». Принимает домены и телефонные номера через ingress-API.',
ARRAY['websites','calls'],
1.00,
'{"type":"object","properties":{},"additionalProperties":false}'::jsonb,
'sites', -- channel
FALSE, -- supports_sender_name
FALSE, -- supports_keyword
TRUE, -- supports_csv_upload (партия 13.3: загрузка CSV-доменов)
TRUE, -- supports_domains_list (textarea со списком доменов)
1),
('b2', 'B2 — СМС с ключевым словом',
'Поставщик СМС-лидов, работающий по связке «Наименование отправителя + Ключевое слово».',
ARRAY['sms'],
1.00,
'{"type":"object","required":["sender_name","keyword"],"properties":{"sender_name":{"type":"string","maxLength":11},"keyword":{"type":"string","maxLength":50}}}'::jsonb,
'sms', -- channel
TRUE, -- supports_sender_name
TRUE, -- supports_keyword (только B2 — партия 13.3.5)
FALSE, -- supports_csv_upload (для SMS не применимо)
FALSE, -- supports_domains_list (для SMS не применимо)
2),
('b3', 'B3 — СМС по наименованию',
'Поставщик СМС-лидов, работающий только по «Наименованию отправителя» (без ключевого слова).',
ARRAY['sms'],
1.00,
'{"type":"object","required":["sender_name"],"properties":{"sender_name":{"type":"string","maxLength":11}}}'::jsonb,
'sms', -- channel
TRUE, -- supports_sender_name
FALSE, -- supports_keyword (B3 не поддерживает — партия 13.3.5)
FALSE, -- supports_csv_upload
FALSE, -- supports_domains_list
3);
-- Глобальные настройки SaaS (раздел 3.4 + расширения v8.1 + v8.2 + v8.3)
INSERT INTO system_settings (key, value, type, description) VALUES
('schema_version', '8.3', 'string', 'Текущая версия схемы БД'),
('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'),
('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'),
('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'),
('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'),
('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'),
('webhook_hmac_required', 'false', 'bool', 'Обязательная HMAC-подпись X-Webhook-Signature: sha256=<hex(hmac(body,token))>. true → запросы без подписи 401. false → подпись опциональна, но если пришла — проверяется (backward-compat).'),
('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'),
('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'),
('password_min_length', '10', 'int', 'Минимальная длина пароля'),
('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'),
-- VAPID (Web Push, раздел 17.4)
('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'),
('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'),
('vapid_subject', 'mailto:admin@example.com', 'string', 'VAPID subject'),
-- Лимиты админки (АДМ §3, ДЕФ-3, ДЕФ-4)
('admin_support_topup_limit_rub_24h', '1000', 'decimal', 'Лимит ручного начисления для роли support за 24ч (₽)'),
('admin_support_topup_limit_leads_24h','10', 'int', 'Лимит ручного начисления для роли support за 24ч (лидов)'),
('admin_four_eyes_threshold_rub', '50000','decimal', 'Порог суммы, выше которого требуется подтверждение второго админа'),
-- CTO-3: таймауты pending-транзакций
('payments_pending_soft_timeout_min', '30', 'int', 'Soft-таймаут pending транзакции, после которого cron делает polling шлюза (минут)'),
('payments_pending_hard_timeout_hours','24', 'int', 'Hard-таймаут pending транзакции, после которого failed независимо от ответа шлюза (часов)'),
-- Ю-2: реселлерская модель (supplier_default_cost_rub оставлен как fallback для legacy-кода)
('supplier_default_cost_rub', '1.00', 'decimal', 'Закупочная цена одного лида (fallback). Новый код читает suppliers.cost_rub.'),
-- v8.2: динамические лимиты проектов (партия 10.6 аудита)
('limits_recalc_cron_enabled', 'true', 'bool', 'Включён ли cron limits:recalc для автокоррекции effective_daily_limit_today'),
('limits_recalc_minute_offset', '0', 'int', 'Смещение запуска cron limits:recalc от 00:00 МСК (минут). 0 = ровно в полночь.'),
('limits_balance_low_log_enabled', 'true', 'bool', 'Логировать ли в project_limit_adjustments каждое изменение лимита по balance_low'),
-- v8.3: cron projects:purge-deleted (Биз-14, партия 13.2.3 аудита)
-- Оригинал хранит soft-deleted проекты ≥3 месяцев, точное TTL не определено.
-- У нас дефолт 6 месяцев + cron отключён (включается админом SaaS вручную после
-- согласования с юристом по 152-ФЗ ст.5 п.7 «минимизация хранения»).
('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 МСК ежедневно)'),
-- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
-- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'),
('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'),
('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'),
('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'),
('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'),
('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'),
('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)');
-- 4 стартовых тарифа-заглушки (Биз-1: вариант Б).
-- Структура зафиксирована (code, billing_model — контракт с кодом).
-- Цены 1.00 ₽ — заглушки, заказчик заменит через админку до публичного запуска.
INSERT INTO tariff_plans (code, name, description, billing_model,
price_per_lead, price_monthly, included_leads,
limits, features, trial_bonus_leads,
is_active, is_public, sort_order)
VALUES
('start', 'Старт', 'Базовый функционал. Платите только за лиды.',
'per_lead', 1.00, NULL, NULL,
'{"max_users":1,"max_projects":3,"api_rps":60}'::jsonb,
'["webhook","basic_analytics"]'::jsonb,
50, TRUE, TRUE, 1),
('basic', 'Базовый', 'Для небольших отделов. Абонплата + сверхлимитные лиды.',
'hybrid', 1.00, 1.00, 100,
'{"max_users":3,"max_projects":10,"api_rps":60}'::jsonb,
'["webhook","kanban","basic_analytics"]'::jsonb,
50, TRUE, TRUE, 2),
('pro', 'Про', 'Расширенная аналитика, API, Kanban.',
'hybrid', 1.00, 1.00, 300,
'{"max_users":10,"max_projects":50,"api_rps":120}'::jsonb,
'["webhook","kanban","advanced_analytics","api","2fa"]'::jsonb,
50, TRUE, TRUE, 3),
('enterprise', 'Корпоративный', 'Индивидуальные условия, кастомный домен, SLA.',
'custom', NULL, NULL, NULL,
'{}'::jsonb,
'["webhook","kanban","advanced_analytics","api","2fa","custom_domain"]'::jsonb,
50, TRUE, FALSE, 4);
-- =============================================================================
-- 12. ROW-LEVEL SECURITY (CTO-5: ВКЛЮЧЁН НА MVP)
-- =============================================================================
--
-- Перед каждым запросом приложение делает SET LOCAL app.current_tenant_id = X
-- в той же транзакции. Подробнее — в спецификации (раздел 22.6 v8.1).
--
-- Роли БД (см. раздел 13 ниже):
-- crm_app_user — обычная роль, на неё действуют политики;
-- crm_admin_user — BYPASSRLS, для админки SaaS (legitimate cross-tenant);
-- crm_migrator — BYPASSRLS, для миграций.
--
-- Для очередей: базовый класс TenantAwareJob в начале handle() устанавливает
-- app.current_tenant_id из своих параметров. Обязательно для всех job-классов,
-- работающих с tenant-данными.
-- =============================================================================
-- Шаблон политики (применяется ко всем tenant-таблицам):
-- USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_status_overrides ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_custom_domains ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_recovery_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_verifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE comment_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE deal_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE import_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline)
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже)
ALTER TABLE saas_upd_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE refund_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE balance_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE report_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_consents ENABLE ROW LEVEL SECURITY;
ALTER TABLE pd_processing_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE project_suppliers ENABLE ROW LEVEL SECURITY; -- v8.2
ALTER TABLE project_limit_adjustments ENABLE ROW LEVEL SECURITY; -- v8.2 (Прил. М §3.2)
-- auth_log: специальный случай, политика ниже (для tenant_user строк)
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
-- v8.4: outbound webhook (раздел 19.10)
ALTER TABLE outbound_webhook_subscriptions ENABLE ROW LEVEL SECURITY;
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)
ALTER TABLE webhook_dedup_keys ENABLE ROW LEVEL SECURITY; -- v8.6 (CTO-17)
ALTER TABLE in_app_notifications ENABLE ROW LEVEL SECURITY; -- v8.10 (P0 этап 2)
-- Базовая политика для таблиц с прямым tenant_id
CREATE POLICY tenant_isolation ON users USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON projects USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON deals USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tenant_status_overrides USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tenant_custom_domains USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON api_keys USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON push_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON comment_templates USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON saas_transactions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON refund_requests USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON balance_transactions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON report_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON tenant_consents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON pd_processing_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON project_limit_adjustments USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.2
CREATE POLICY tenant_isolation ON outbound_webhook_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.4
CREATE POLICY tenant_isolation ON outbound_webhook_deliveries USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.4
CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.10
-- v8.2: project_suppliers — фильтр через JOIN на projects (tenant_id у проекта, не у связи)
CREATE POLICY tenant_isolation ON project_suppliers USING (
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
-- Для user_*-таблиц фильтр через JOIN на users
CREATE POLICY tenant_isolation ON user_recovery_codes USING (
user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
CREATE POLICY tenant_isolation ON user_sessions USING (
user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
CREATE POLICY tenant_isolation ON email_verifications USING (
user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
-- 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)
);
-- v8.6 (CTO-17): webhook_dedup_keys — tenant-уровневая по tenant_id напрямую.
-- WITH CHECK на INSERT/UPDATE — нельзя зарегистрировать дедуп-ключ в чужом тенанте
-- (defense-in-depth поверх tenant_id NOT NULL + FK на deals).
CREATE POLICY tenant_isolation ON webhook_dedup_keys USING (
tenant_id = current_setting('app.current_tenant_id')::bigint
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant_id')::bigint
);
-- auth_log: tenant_user и saas_admin строки разные. Здесь — только tenant_user.
CREATE POLICY tenant_isolation ON auth_log USING (
actor_type = 'tenant_user'
AND tenant_id = current_setting('app.current_tenant_id')::bigint
);
-- Таблицы supplier_lead_costs, supplier_invoices, suppliers — НЕ tenant-уровневые
-- (это наши данные у поставщика), доступны только из админки (под ролью
-- crm_admin_user с BYPASSRLS). Для основного приложения недоступны.
-- Исключение: project_suppliers — tenant-уровневая через JOIN на projects (см. RLS-политику выше).
-- =============================================================================
-- 13. РОЛИ БД (CTO-5)
-- =============================================================================
--
-- Создаются вручную в production-окружении после первого деплоя схемы.
-- На dev можно использовать суперпользователя.
--
-- ВАЖНО: пароли НЕ в этом файле. Заполнить в production через secret manager.
-- CREATE ROLE crm_app_user LOGIN PASSWORD '<from-secrets>';
-- CREATE ROLE crm_admin_user LOGIN PASSWORD '<from-secrets>' BYPASSRLS;
-- CREATE ROLE crm_migrator LOGIN PASSWORD '<from-secrets>' BYPASSRLS CREATEDB;
-- GRANT crm_app_user-у:
-- USAGE на схему public;
-- SELECT, INSERT, UPDATE, DELETE на все tenant-таблицы;
-- SELECT, INSERT, UPDATE на tenants (для onboarding/blocking);
-- USAGE на все sequences;
-- запрещено: TRUNCATE, DROP, alteration схемы.
--
-- GRANT crm_admin_user-у:
-- все права crm_app_user, плюс
-- SELECT, INSERT, UPDATE на saas_admin_*, supplier_*, system_settings, tariff_plans,
-- 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.';
-- 6 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
-- v8.31: tenant_operations_log перенесён из inline-определения таблицы; итого 6 пар.
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_tenant_ops
BEFORE INSERT ON tenant_operations_log
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_tenant_ops
BEFORE UPDATE OR DELETE ON tenant_operations_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.';
-- v8.6 (CTO-17): trigger для pd_subject_requests.deadline_at — замена
-- `GENERATED ALWAYS AS (received_at + INTERVAL '30 days') STORED`, отклонённого
-- PostgreSQL 16 как не-immutable (`+ INTERVAL` зависит от timezone/DST).
CREATE OR REPLACE FUNCTION set_pd_subject_request_deadline() RETURNS TRIGGER AS $$
BEGIN
NEW.deadline_at := COALESCE(NEW.received_at, NOW()) + INTERVAL '30 days';
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_pd_subject_requests_deadline
BEFORE INSERT OR UPDATE OF received_at ON pd_subject_requests
FOR EACH ROW EXECUTE FUNCTION set_pd_subject_request_deadline();
COMMENT ON FUNCTION set_pd_subject_request_deadline() IS
'v8.6 (CTO-17): расчёт deadline_at = received_at + 30 дней (152-ФЗ). '
'Заменяет GENERATED STORED column, отклонённый PG 16 как не-immutable.';
-- =============================================================================
-- 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);
-- =============================================================================
-- ОТЛОЖЕННЫЕ FK НА ПАРТИЦИОНИРОВАННЫЕ ТАБЛИЦЫ
-- =============================================================================
-- Composite FK на partitioned deals(id, received_at) для lead_charges (Plan 1/5 Task 4, v8.15).
-- Размещён в конце файла, ПОСЛЕ section 5 (deals), чтобы при `migrate:fresh` PostgreSQL
-- видел deals при ADD CONSTRAINT. DEFERRABLE INITIALLY DEFERRED обязателен для
-- атомарного INSERT deal+charge в одной транзакции.
ALTER TABLE lead_charges
ADD CONSTRAINT lead_charges_deals_fk
FOREIGN KEY (deal_id, deal_received_at)
REFERENCES deals(id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED;
-- =============================================================================
-- КОНЕЦ schema.sql
-- =============================================================================