-- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидпоток») -- Версия: v8.3 (05.05.2026, после параллельного аудита партий 12–15) -- СУБД: PostgreSQL 16 -- Кодировка: UTF8, локаль ru_RU.UTF-8 -- ============================================================================= -- -- ИСТОЧНИК: документация v8.3 (CRM_bp-gr_Инструкция_v8_3.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.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.3: -- • 51 логическая таблица (без изменений: reminders уже была в v8.2, -- никаких новых таблиц — только ALTER и обновление seed). -- • 12 партиций (6 у deals + 6 у supplier_lead_costs). -- • 81 индекс (80 в v8.2 минус idx_deals_reminder + 2 новых на reminders -- с учётом переписи таблицы: idx_reminders_due, idx_reminders_deal, -- idx_reminders_tenant_user_active, idx_reminders_tenant_active). -- • 31 RLS-политика (без изменений — reminders уже была защищена). -- • 32 защищённых таблиц (без изменений). -- • 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; -- ----------------------------------------------------------------------------- -- 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 (каждые 5–10 минут): -- - переводит 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 (каждые 5–10 минут): -- • 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; -- Базовая политика для таблиц с прямым 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 -- 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 ''; -- CREATE ROLE crm_admin_user LOGIN PASSWORD '' BYPASSRLS; -- CREATE ROLE crm_migrator LOGIN PASSWORD '' 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 -- =============================================================================