Files
portal/db/schema.sql
T
Дмитрий c8db9a26c4 docs(narrative): v8.4 финал (§23.10 + 13/13 + rename _v8_3 → _v8_4)
- §23.10 Админка SaaS: расширен с 5 до 10 подсекций — Биз-16
  (колонка «Желаемое × факт сегодня» с цветовым кодированием),
  Ю-2 (поставщики, дашборд маржи, сверка счетов), OPEN-Д-5/И-1
  (incidents_log: 8 типов × 4 severity, 24ч SLA уведомления РКН для
  data_breach по 152-ФЗ ст.18.1 ч.3.1), Прил. Д (workflow обращений
  субъектов ПДн с 30-дневным SLA), таблица преимуществ vs оригинал.
- Шапка narrative: убрано «in progress», блок «Что нового в v8.4»
  дополнен §23.10. Подвал: имя файла v8.4.
- Переименование: CRM_bp-gr_Инструкция_v8_3.md → _v8_4.md.
- Кросс-ссылки обновлены: CLAUDE.md (§0/§2/§6/§8 — версии, метрики
  схемы 53/86/33, счётчик прототипов 3/8), README.md (версии, статусы
  прототипов, репо CoralMinister), db/schema.sql, db/CHANGELOG_schema.md,
  web/index.html.
- .lychee.toml: exclude приватного github.com/CoralMinister/lidpotok
  (404 анонимно — норма).
- Plan_narrative_v8_4.md удалён (план v8.4 выполнен полностью, 13/13).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:14:53 +07:00

2084 lines
132 KiB
SQL
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.4 (06.05.2026, синхронизация с narrative §19.10 outbound webhook)
-- Базовая версия: v8.3 (05.05.2026, после параллельного аудита партий 12–15)
-- СУБД: PostgreSQL 16
-- Кодировка: UTF8, локаль ru_RU.UTF-8
-- =============================================================================
--
-- ИСТОЧНИК: документация v8.4 (CRM_bp-gr_Инструкция_v8_4.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.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.4:
-- • 53 логических таблицы (51 из v8.3 + outbound_webhook_subscriptions
-- + outbound_webhook_deliveries).
-- • 12 партиций (6 у deals + 6 у supplier_lead_costs).
-- • 86 индексов (81 из v8.3 + 5 новых: idx_outbound_subs_tenant_active,
-- idx_outbound_subs_secret_prefix, idx_outbound_deliveries_subscription,
-- idx_outbound_deliveries_status_pending, idx_outbound_deliveries_created).
-- • 33 RLS-политики (31 из v8.3 + 2 на outbound_webhook_*).
-- • 34 защищённых таблиц с ENABLE ROW LEVEL SECURITY (32 из v8.3 + 2 новых).
-- • 3 роли БД (без изменений).
-- =============================================================================
-- =============================================================================
-- 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)
-- 14 статусов: 6 системных + 8 настраиваемых
-- -----------------------------------------------------------------------------
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 со списком доменов
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 (обязательно для всех админов)
totp_secret_enc TEXT, -- зашифровано Crypt::encryptString
totp_enabled_at TIMESTAMPTZ,
-- 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 REFERENCES tenants(id),
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);
CREATE INDEX idx_saas_admin_sessions_expires ON saas_admin_sessions(expires_at) WHERE expires_at > NOW();
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 REFERENCES tenants(id) ON DELETE CASCADE,
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, -- ручная инвалидация админом или клиентом
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);
-- 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),
-- Метаданные
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;
-- -----------------------------------------------------------------------------
-- 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 VARCHAR(255), -- ШИФРУЕТСЯ Crypt::encrypt
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 уведомлений
-- Статус
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')),
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 = ещё не считалось)
effective_limit_calculated_at TIMESTAMPTZ,
-- РАСШИРЕНИЕ v8.2: регионы и дни (партия 10.3 секции 6, 7, 11)
region_mask INT NOT NULL DEFAULT 255,
-- битмаска 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' = принимать кроме выбранных
delivery_days_mask INT NOT NULL DEFAULT 127,
-- битмаска дней недели: бит 1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс.
-- 127 = все 7 дней (паритет с формой создания нового проекта в оригинале).
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)
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
CREATE INDEX idx_projects_tag ON projects(tag);
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 = не считалось.';
-- -----------------------------------------------------------------------------
-- 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_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);
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at) WHERE expires_at > NOW();
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,
expires_at TIMESTAMPTZ, -- NULL = бессрочный
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 DEFAULT '[]', -- ["deal.created","deal.status_changed",…]
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-Lidpotok-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 в одной таблице.
-- -----------------------------------------------------------------------------
CREATE TABLE auth_log (
id BIGSERIAL PRIMARY KEY,
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', ...
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Целостность: 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)
)
);
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
);
-- =============================================================================
-- 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 не ставится (партиционированная)
is_test BOOLEAN DEFAULT FALSE,
received_at TIMESTAMPTZ NOT NULL, -- ключ партиционирования
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
PRIMARY KEY (id, received_at)
) 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
CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL;
-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
CREATE TABLE deals_2026_05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE deals_2026_06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE deals_2026_07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE deals_2026_08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE deals_2026_09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE deals_2026_10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
-- =============================================================================
-- 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
-- -----------------------------------------------------------------------------
CREATE TABLE activity_log (
id BIGSERIAL PRIMARY KEY,
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,
created_at TIMESTAMPTZ DEFAULT NOW()
);
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;
-- -----------------------------------------------------------------------------
-- 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 = активное.';
-- -----------------------------------------------------------------------------
-- webhook_log — лог принятых webhook (раздел 5.7)
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
-- -----------------------------------------------------------------------------
CREATE TABLE webhook_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
received_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
error TEXT
);
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_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 REFERENCES webhook_log(id),
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;
-- -----------------------------------------------------------------------------
-- 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 REFERENCES webhook_log(id),
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);
-- =============================================================================
-- 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)
-- -----------------------------------------------------------------------------
CREATE TABLE balance_transactions (
id BIGSERIAL PRIMARY KEY,
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
)),
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),
created_at TIMESTAMPTZ DEFAULT NOW()
);
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 месяца вперёд)
CREATE TABLE supplier_lead_costs_2026_05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE supplier_lead_costs_2026_06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE supplier_lead_costs_2026_07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE supplier_lead_costs_2026_08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE supplier_lead_costs_2026_09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE supplier_lead_costs_2026_10 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
-- -----------------------------------------------------------------------------
CREATE TABLE pd_processing_log (
id BIGSERIAL PRIMARY KEY,
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,
created_at TIMESTAMPTZ DEFAULT NOW(),
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) -- системное действие
)
);
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-ФЗ
deadline_at TIMESTAMPTZ GENERATED ALWAYS AS (received_at + INTERVAL '30 days') STORED,
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. АДМИНКА SAAS — ЖУРНАЛ ДЕЙСТВИЙ (НОВАЯ)
-- saas_admin_users уже создана выше (нужна была для FK от других таблиц)
-- =============================================================================
CREATE TABLE saas_admin_audit_log (
id BIGSERIAL PRIMARY KEY,
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,
created_at TIMESTAMPTZ DEFAULT NOW()
);
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. ЗАПОЛНЕНИЕ СПРАВОЧНИКОВ
-- =============================================================================
-- 14 статусов воронки (раздел 7.3, 8.1)
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
('new', 'Новые', TRUE, 1, '#3B82F6'),
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
('worked', 'Проработан', TRUE, 3, '#06B6D4'),
('base', 'База', FALSE, 4, '#64748B'),
('missed', 'Недозвон', FALSE, 5, '#F59E0B'),
('negotiations', 'Переговоры', FALSE, 6, '#EAB308'),
('waiting_payment', 'Ожидаем оплаты', FALSE, 7, '#A78BFA'),
('partnership', 'Партнерка', FALSE, 8, '#EC4899'),
('paid', 'Оплачено', TRUE, 9, '#10B981'),
('closed', 'Закрыто и не реализовано', TRUE, 10, '#6B7280'),
('test_drive', 'Тест драйв', FALSE,11, '#14B8A6'),
('hot', 'Горячий', FALSE,12, '#EF4444'),
('replacement', 'На замену', FALSE,13, '#F97316'),
('final_missed', 'Конечный недозвон', TRUE, 14, '#1F2937');
-- НОВОЕ в 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'),
('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 МСК ежедневно)');
-- 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 activity_log ENABLE ROW LEVEL SECURITY;
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;
-- Базовая политика для таблиц с прямым 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 activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
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
-- 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
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)
);
-- 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).
-- =============================================================================
-- КОНЕЦ schema.sql
-- =============================================================================