-- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») -- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project) -- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал)) -- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) -- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings) -- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) -- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns) -- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure) -- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) -- Метрики: 73 базовые таблицы (65 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 120 индексов / 40 RLS-политик / 5 функций / 15 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) -- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) -- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log) -- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth) -- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK projects.supplier_b{1,2,3}_project_id → supplier_projects (ON DELETE SET NULL) + 3 partial index + CHECK chk_projects_b1_not_for_sms (defense-in-depth дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне). Закрывает code-review BLOCKER#1 + WARNING#3 от 10.05.2026 поздний вечер) -- Базовая версия: v8.16 (10.05.2026 — Plan 1/5 Task 5: supplier_sync_log SaaS-level audit log AJAX-синхронизаций с поставщиком + 1 CHECK (action enum) + 3 индекса + nullable FK на supplier_projects (ON DELETE SET NULL) + REVOKE ALL для crm_app_user) -- Базовая версия: v8.15 (10.05.2026 — Plan 1/5 Task 4: lead_charges append-only ledger списаний за доставленный лид + composite DEFERRABLE FK на deals + RLS tenant_isolation + 2 индекса + GRANT SELECT,INSERT для crm_app_user) -- Базовая версия: v8.14 (10.05.2026 — Plan 1/5 Task 3: pricing_tiers SaaS-level конфигурация 7-ступенчатого объёмного тарифа в копейках + 1 CHECK (tier_no 1..7) + 2 индекса + REVOKE ALL + GRANT SELECT для crm_app_user) -- Базовая версия: v8.13 (10.05.2026 — Plan 1/5 Task 2: supplier_projects SaaS-level агрегатная таблица для проектов у поставщиков B1/B2/B3 + 4 CHECK + 3 индекса + REVOKE ALL FROM crm_app_user) -- Базовая версия: v8.12 (10.05.2026 — расширение projects для supplier integration: signal_type/identifier/sms_senders/sms_keyword/delivered_in_month/supplier_b{1,2,3}_project_id + 3 CHECK + 1 composite index) -- Базовая версия: v8.11 (09.05.2026 — hygiene-фиксы аудита: P0-02 RLS на impersonation_tokens + O-perf-02/03 индексы FK-колонок webhook_log_id) -- Базовая версия: v8.10 (09.05.2026 — in_app_notifications для bell-icon UI; 2 индекса (unread + recent); RLS tenant isolation) -- Базовая версия: v8.5 (07.05.2026, реализация 27 решений аудита C из реестра v1.12) -- СУБД: PostgreSQL 16 -- Кодировка: UTF8, локаль ru_RU.UTF-8 -- ============================================================================= -- -- ИСТОЧНИК: документация v8.5 (CRM_bp-gr_Инструкция_v8_5.md), 28 разделов. -- Каждый блок помечен ссылкой на исходный раздел. -- -- АРХИТЕКТУРНАЯ МОДЕЛЬ: -- • Реселлер. Клиент-тенант создаёт проект на нашем портале → мы передаём -- проект в crm.bp-gr.ru (исходящий API) → crm.bp-gr.ru собирает по нему -- лиды и шлёт нам webhook → мы передаём клиенту через интерфейс/push. -- • Договор с crm.bp-gr.ru подписан, мы — официальный корп. клиент. -- • Мы — самостоятельный оператор ПДн. crm.bp-gr.ru передаёт данные на -- основании договора. Источник заявок физлиц не контролируется нами. -- • Поставщики (раскрыто в аудите 04.05.2026, партия 10): внутри crm.bp-gr.ru -- есть три суб-канала приёма данных — B1 (Сайты/Звонки), B2 (СМС с keyword), -- B3 (СМС по sender_name). У нас они представлены таблицей suppliers. -- • Capabilities поставщиков (партия 13.3.5, цитата UI оригинала): -- B2 — sender_name + keyword; B3 — только sender_name; B1 — sites/calls. -- Для генерации UI-формы проекта suppliers содержит поля supports_*. -- -- ИЗМЕНЕНИЯ v8.4 → v8.5: -- ИЗ ЗАКРЫТИЯ АУДИТА C (Открытые_вопросы v1.12, 07.05.2026, 27 решений): -- P0 (8) — обязательны до триггера фазы 1: -- 1. Биз-17 — projects.assignment_strategy VARCHAR(32) DEFAULT 'manual' -- + CHECK IN ('manual','round_robin','least_loaded'). MVP = manual. -- 2. Биз-18 — projects.ttfr_target_minutes INT DEFAULT 15 (Time To First -- Response SLA, alert при просрочке через event bus + UI badge). -- 3. Биз-19 — deals.duplicate_of_id BIGINT NULL ON DELETE SET NULL + -- индекс (tenant_id, phone, received_at) для O(log n) lookup в окне -- 24 ч. Антифрод: дубль помечается, но НЕ списывается с баланса. -- 4. CTO-13 — обязательный e2e-тест SET LOCAL app.current_tenant_id -- через PgBouncer transaction-pooling в спринте 1. Без изменений -- схемы, только тест-план в narrative §22 + Прил. И. -- 5. OPEN-И-13 — saas_admin_users.sso_provider VARCHAR(32) DEFAULT -- 'yandex360' + saas_admin_users.is_break_glass BOOLEAN DEFAULT -- FALSE (OIDC + JIT-provisioning + локальный 2FA выключен, -- fallback — break-glass super_admin). -- 6. OPEN-И-14 — WITH CHECK на политики deal_tag_pivot, saas_invoice_items -- + REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users, -- saas_admin_sessions, saas_admin_audit_log, incidents_log, -- pd_subject_requests, impersonation_tokens). -- 7. OPEN-И-15 — append-only audit hash chain. Колонка log_hash BYTEA -- NOT NULL на 5 audit-таблицах (auth_log, activity_log, -- pd_processing_log, saas_admin_audit_log, balance_transactions). -- 5 BEFORE UPDATE/DELETE триггеров с RAISE EXCEPTION + функция -- audit_chain_hash() = sha256(prev.log_hash || row). Новая роль -- crm_audit_writer (только INSERT) — REVOKE UPDATE/DELETE даже у -- super_admin через триггеры. -- 8. OPEN-И-16 — Sentry whitelist + regex маска phone/email/password/ -- secret/token/api_key. Без изменений схемы — конфигурация в Laravel -- config/sentry.php (narrative §22 «Sentry PII-scrubbing»). -- P1 (12) — фазы 1–2: -- 9. Биз-20 — Telegram-бот в спринте 9. users.telegram_user_id BIGINT -- NULL + tenants.telegram_bot_token TEXT NULL (зашифровано). -- 10. Биз-21 — generic outbound `marketing.conversion` через §19.10 -- (расширение whitelist событий). Без изменений схемы. -- 11. Биз-22 — простой scoring. suppliers.quality_score NUMERIC(3,2) -- DEFAULT 1.00 + deals.time_in_form_seconds INT NULL + deals.lead_score -- NUMERIC(5,2) GENERATED ALWAYS AS (...) STORED. -- 12. CTO-14 — UTM-поля. deals.utm_source/utm_medium/utm_campaign/ -- utm_content VARCHAR(100) NULL + индекс (tenant_id, utm_source). -- 13. CTO-15 — two-person impersonation. impersonation_tokens. -- second_approver_id BIGINT NULL REFERENCES saas_admin_users(id) + -- second_approval_at TIMESTAMPTZ NULL. -- 14. CTO-16 — skill-based routing. Новая таблица project_user_assignments -- (project_id, user_id, skills JSONB) + RLS-политика через JOIN на -- projects.tenant_id + индекс на (project_id). -- 15. OPEN-И-17 — TTL 365 дней на api_keys/webhook_token/outbound. -- secret_hash. ALTER api_keys.expires_at SET DEFAULT NOW() + 365d. -- 16. OPEN-И-18 — DNS-rebinding защита. Без изменений схемы — реализация -- в SSRFGuard service (narrative §19.10). -- 17. OPEN-И-19 — лимит api_keys. tenants.api_key_limit INT DEFAULT 5 -- NOT NULL CHECK (api_key_limit BETWEEN 1 AND 10) + ALTER api_keys. -- expires_at SET NOT NULL (миграция: backfill всем NOW + 365d). -- 18. OPEN-И-20 — signed URL + триггер audit. Триггер trg_report_jobs_ -- export_log AFTER INSERT ON report_jobs → INSERT pd_processing_log -- action='exported'. -- 19. OPEN-И-21 — Anti-DDoS. Без изменений схемы — Nginx + Yandex -- SmartCaptcha + disposable-blacklist (narrative §22 + Прил. И). -- 20. Ю-9 — hard-блок impersonation для всех ролей кроме compliance -- при processing_restricted=TRUE. Реализация в SaasAdminAuthService -- (narrative §22.7), без изменений схемы. -- P2 (7) — фазы 1–3: -- 21. Биз-23 — гео-таргетинг. deals.region_code VARCHAR(8) NULL + -- deals.city VARCHAR(100) NULL + индекс (tenant_id, region_code). -- 22. Биз-24 — алерт saappоrtу о просрочке waiting_payment → paid -- через 48 ч. Cron payments:notify-stale (narrative §17), без -- изменений схемы кроме system_settings ключей. -- 23. OPEN-И-22 — per-tenant DEK в Yandex KMS. Без изменений схемы — -- encryption envelope на уровне backup-сервиса (Прил. И). -- 24. OPEN-И-23 — роль crm_audit_writer уже создана в OPEN-И-15. -- Здесь — только подтверждение: INSERT-only access; UPDATE/DELETE -- блокируются триггерами. -- 25. OPEN-И-24 — pg_anonymizer процедура. Документация в Прил. И, -- без изменений схемы (расширение ставится в фазе 3 по Прил. Н). -- 26. OPEN-И-25 — эскалация лидов. deals.assigned_at TIMESTAMPTZ NULL + -- deals.escalated_count INT DEFAULT 0. Cron leads:escalate-stale -- (narrative §10 + §17). -- 27. OPEN-И-26 — задел под call-recording (Биз-12 Post-MVP). -- Закомментированный DDL call_recordings(...) в конце файла. -- Итого: +1 таблица (project_user_assignments), +26 колонок (suppliers. -- quality_score; saas_admin_users.sso_provider/is_break_glass; -- impersonation_tokens.second_approver_id/second_approval_at; -- tenants.api_key_limit/telegram_bot_token; projects.assignment_strategy/ -- ttfr_target_minutes; users.telegram_user_id; deals.assigned_at/ -- escalated_count/duplicate_of_id/utm_source/utm_medium/utm_campaign/ -- utm_content/region_code/city/time_in_form_seconds/lead_score; +log_hash -- на 5 audit-таблицах) + ALTER api_keys.expires_at NOT NULL DEFAULT -- NOW()+365d, +12 триггеров (5×2 audit append-only + 1 report_jobs export -- log + 1 deals lead_score), +4 функции (audit_chain_hash, -- audit_block_mutation, report_jobs_log_export, calc_lead_score), -- +1 роль (crm_audit_writer), +5 индексов, +2 политики с WITH CHECK, -- +1 политика (project_user_assignments), REVOKE на 6 saas-таблицах. -- -- ИЗМЕНЕНИЯ v8.3 → v8.4: -- ИЗ ПЕРЕПИСЫВАНИЯ NARRATIVE v8.4 (06.05.2026, §19.10 outbound webhook): -- 1. Новая таблица outbound_webhook_subscriptions (раздел 19.10) — -- регистрация подписок тенантов на исходящие события (deal.created, -- deal.status_changed, …). Хеш secret + key_prefix аналогично api_keys. -- 2. Новая таблица outbound_webhook_deliveries (раздел 19.10.6) — -- журнал попыток доставки с retention 90 дней. attempt_number 1..7, -- статусы pending/success/failed/permanently_failed. -- 3. RLS на обе новые таблицы (политика по tenant_id, как остальные). -- 4. Закрывает тех-долг шапки narrative v8.4 («при правке §7 добавить DDL -- outbound_webhook_subscriptions и outbound_webhook_deliveries»). -- -- ИЗМЕНЕНИЯ v8.2 → v8.3: -- ИЗ ПАРАЛЛЕЛЬНОГО АУДИТА ПАРТИЙ 12–15 (Прил. М v1.1, 05.05.2026): -- 1. reminders.user_id → reminders.created_by (семантика "from", партия 12.2.4). -- + поле reminders.completed_at TIMESTAMPTZ (для аудита выполнения). -- Удалена reminders.is_done — заменена на NOT NULL (completed_at IS NULL). -- Сохранены is_sent + sent_at для cron нотификаций. -- 2. Удалены deals.reminder_text, deals.reminder_at и idx_deals_reminder. -- Множественные напоминания на сделку (партия 12.2.5: histories[].type='reminder'). -- 3. Расширение suppliers 5 полями capabilities (партия 13.3.5): -- channel, supports_sender_name, supports_keyword, supports_csv_upload, -- supports_domains_list. Seed B1/B2/B3 обновлён. -- 4. tenants.desired_daily_numbers INT NULL (партия 13.2.2, Биз-16): -- целевое количество лидов в день — сигнал для саппорта (не лимит, не биллинг). -- 5. system_settings: 3 новых ключа для cron projects:purge-deleted (Биз-14): -- projects_purge_deleted_enabled (по умолчанию false), -- projects_purge_deleted_ttl_days (по умолчанию 180), -- projects_purge_deleted_cron (расписание). -- -- ИЗМЕНЕНИЯ v8.1 → v8.2 (для контекста): -- ИЗ ИНТЕРВЬЮ С ЗАКАЗЧИКОМ 04.05.2026: -- 1. pd_subject_requests.processing_restricted (OPEN-Д-1, ст.21 152-ФЗ). -- 2. Таблица incidents_log (OPEN-Д-5 / OPEN-И-1, журнал инцидентов SaaS). -- ИЗ АУДИТА crm.bp-gr.ru (Прил. М v1.0, 04.05.2026): -- 3. Таблица suppliers + seed B1/B2/B3 (партия 10.2). -- 4. Таблица project_suppliers (m2m, паритет с формой создания проекта). -- 5. Миграция supplier_lead_costs.supplier_code → supplier_id (FK). -- 6. Миграция supplier_invoices.supplier_code → supplier_id (FK). -- 7. Расширение projects 6 полями: daily_limit_target, -- effective_daily_limit_today, effective_limit_calculated_at, -- region_mask, region_mode, delivery_days_mask (партия 10.3, 10.6). -- 8. Таблица project_limit_adjustments + RLS-политика (партия 10.7). -- -- ИЗМЕНЕНИЯ v8.0 → v8.1 (для контекста): -- • Админка SaaS: saas_admin_users, saas_admin_recovery_codes, -- saas_admin_sessions, saas_admin_audit_log. -- • Impersonation (Ю-1): impersonation_tokens. -- • Себестоимость / поставщики (Ю-2): supplier_lead_costs (партиционированная), -- supplier_invoices. -- • Chargeback (Ю-3): tenants.chargeback_unrecovered_rub. -- • Уведомления (CTO-4): users.notification_preferences JSONB. -- • RLS (CTO-5): включён на MVP — политики на 30 tenant-таблиц. -- • 152-ФЗ: pd_subject_requests, второй consent_type. -- -- ПОРЯДОК СОЗДАНИЯ: -- 1) Расширения -- 2) Справочники без зависимостей (включая suppliers с capabilities — РАСШИРЕНО в v8.3) -- 3) Tenants (после tariff_plans, +desired_daily_numbers в v8.3) -- 4) Tenant-уровневые таблицы (включая projects + project_suppliers -- + project_limit_adjustments) -- 5) Партиционированная deals + партиции (БЕЗ полей reminder_* в v8.3) -- 6) Логи и журналы (включая reminders с created_by + completed_at в v8.3) -- 7) Биллинг SaaS-уровня (включая chargeback) -- 8) Себестоимость и поставщики (Ю-2, supplier_id вместо supplier_code в v8.2) -- 9) Отчёты -- 10) 152-ФЗ (с processing_restricted в v8.2) -- 11) Админка SaaS + impersonation + incidents_log -- 12) ALTER TABLE для forward refs -- 13) Заполнение справочников (lead_statuses, suppliers с capabilities, -- system_settings с purge-deleted, tariff_plans) -- 14) Row-Level Security (CTO-5: включён на MVP) -- 15) Роли БД (CTO-5) -- -- РАЗМЕР СХЕМЫ v8.5: -- • 54 логических таблицы (53 из v8.4 + project_user_assignments v8.5). -- • 12 партиций (6 у deals + 6 у supplier_lead_costs). -- • 91 индекс (86 из v8.4 + 5 новых: idx_deals_utm_source, -- idx_deals_region_code, idx_deals_duplicate_of, idx_deals_assigned_at_open, -- idx_project_user_assignments_user). -- • 35 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN). -- Из них 2 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items). -- • 35 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам). -- • 4 роли БД (3 из v8.4 + crm_audit_writer — только INSERT на 5 audit-таблицах). -- • 12 триггеров: на 5 audit-таблицах (auth_log/activity_log/pd_processing_log/ -- saas_admin_audit_log/balance_transactions) — по 2 (BEFORE INSERT для hash -- chain + BEFORE UPDATE/DELETE для запрета мутаций) = 10; +1 на report_jobs -- (AFTER INSERT — журнал экспорта в pd_processing_log по 152-ФЗ ст.18); -- +1 на deals (BEFORE INSERT/UPDATE — расчёт lead_score через -- supplier.quality_score × time_in_form, Биз-22). -- • 4 функции: audit_chain_hash() (SHA-256 hash chain для tamper-detection), -- audit_block_mutation() (RAISE EXCEPTION для запрета UPDATE/DELETE), -- report_jobs_log_export() (auto-логирование экспорта), -- calc_lead_score() (расчёт lead score без ML). -- • REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users, -- saas_admin_sessions, saas_admin_audit_log, incidents_log, -- pd_subject_requests, impersonation_tokens) — defense-in-depth. -- ============================================================================= -- ============================================================================= -- 1. РАСШИРЕНИЯ -- ============================================================================= CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- триграммный поиск (на будущее) CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- GIN-индексы по btree-типам -- ============================================================================= -- 2. СПРАВОЧНИКИ И SAAS-УРОВЕНЬ БЕЗ ЗАВИСИМОСТЕЙ -- ============================================================================= -- ----------------------------------------------------------------------------- -- legal_entities — юр. лица оператора SaaS (раздел 20.3) -- ----------------------------------------------------------------------------- CREATE TABLE legal_entities ( id BIGSERIAL PRIMARY KEY, code VARCHAR(50) UNIQUE NOT NULL, -- "ooo_main", "ip_ivanov" name VARCHAR(500) NOT NULL, short_name VARCHAR(255), -- "ООО Ромашка" legal_form VARCHAR(20) NOT NULL, -- 'OOO', 'IP', 'AO', 'PAO', 'NKO' -- Реквизиты inn VARCHAR(12) NOT NULL, kpp VARCHAR(9), -- NULL для ИП ogrn VARCHAR(15), okpo VARCHAR(10), -- Адреса legal_address TEXT, actual_address TEXT, -- Банк bank_name VARCHAR(255), bank_account VARCHAR(20), bank_bik VARCHAR(9), bank_corr VARCHAR(20), -- Подписант director_name VARCHAR(255), director_post VARCHAR(255) DEFAULT 'Генеральный директор', director_basis VARCHAR(255) DEFAULT 'Устава', -- Устава / Доверенности №... -- НДС vat_mode VARCHAR(20) DEFAULT 'no_vat' -- vat20, vat10, vat7, vat5, vat0, no_vat, usn_6, usn_15 CHECK (vat_mode IN ('vat20','vat10','vat7','vat5','vat0','no_vat','usn_6','usn_15')), -- Файлы (S3-пути) signature_path VARCHAR(500), -- скан подписи (PNG, прозрачный фон) stamp_path VARCHAR(500), -- скан печати logo_path VARCHAR(500), -- Управление is_active BOOLEAN DEFAULT TRUE, is_default BOOLEAN DEFAULT FALSE, sort_order INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ ); CREATE UNIQUE INDEX idx_legal_entities_default ON legal_entities(is_default) WHERE is_default = TRUE; -- ----------------------------------------------------------------------------- -- tariff_plans — каталог тарифов (раздел 20.2.1) -- ----------------------------------------------------------------------------- CREATE TABLE tariff_plans ( id BIGSERIAL PRIMARY KEY, code VARCHAR(50) UNIQUE NOT NULL, -- 'start', 'basic', 'pro', 'custom_corp_xyz' name VARCHAR(255) NOT NULL, description TEXT, -- Модель оплаты billing_model VARCHAR(50) NOT NULL -- 'per_lead', 'monthly', 'hybrid', 'custom' CHECK (billing_model IN ('per_lead','monthly','hybrid','custom')), price_per_lead DECIMAL(10,2), -- для per_lead и hybrid price_monthly DECIMAL(10,2), -- абонплата (monthly и hybrid) included_leads INT, -- лидов в подписку (monthly и hybrid) -- Лимиты и фичи (расширяемо) limits JSONB DEFAULT '{}', -- {"max_users":5,"max_projects":10,"api_rps":60} features JSONB DEFAULT '[]', -- ["kanban","advanced_analytics","api","2fa","custom_domain"] -- Trial trial_bonus_leads INT DEFAULT 0, -- индивидуальный стартовый бонус -- Видимость is_active BOOLEAN DEFAULT TRUE, is_public BOOLEAN DEFAULT TRUE, -- виден на странице регистрации sort_order INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ ); -- ----------------------------------------------------------------------------- -- lead_statuses — справочник статусов воронки (раздел 7.3, 8.1) -- 5 статусов воронки (все системные) -- ----------------------------------------------------------------------------- CREATE TABLE lead_statuses ( slug VARCHAR(50) PRIMARY KEY, name_ru VARCHAR(100) NOT NULL, is_system BOOLEAN DEFAULT FALSE, -- TRUE = нельзя переименовать sort_order INT, color_hex VARCHAR(7), description TEXT ); -- ----------------------------------------------------------------------------- -- suppliers — каталог поставщиков данных (НОВАЯ в v8.2, Прил. М §3.1) -- ----------------------------------------------------------------------------- -- Контекст: аудит crm.bp-gr.ru от 04.05.2026 (партия 10) раскрыл архитектурный -- слой "Поставщик" — три внутренних канала приёма данных в оригинале: -- - B1 — основной для типов "Сайты" и "Звонки" -- - B2 — поддерживает "СМС" через "Наименование отправителя" + "Ключевое слово" -- - B3 — поддерживает "СМС" только по "Наименованию отправителя" -- -- Реселлерская модель Ю-2: мы покупаем у crm.bp-gr.ru как у одного контрагента, -- но внутри crm.bp-gr.ru есть эти три суб-поставщика. На MVP делаем плоский -- список (B1, B2, B3 — три отдельные строки), без иерархии. Если в будущем -- появятся другие первичные поставщики — добавится parent_supplier_id или -- отдельный уровень supplier_channels. -- -- НЕ tenant-уровневая (общая для всех тенантов). RLS не применяется. -- Чтение — всем, запись — только под crm_admin_user. -- ----------------------------------------------------------------------------- CREATE TABLE suppliers ( id BIGSERIAL PRIMARY KEY, code VARCHAR(50) NOT NULL UNIQUE, -- 'b1', 'b2', 'b3' name VARCHAR(255) NOT NULL, -- 'B1 — Сайты и Звонки' description TEXT, accepts_types VARCHAR(20)[] NOT NULL, -- ['websites','calls'] | ['sms'] cost_rub DECIMAL(10,2) NOT NULL, -- закупочная цена одного лида settings_schema JSONB, -- JSON Schema для per-supplier настроек проекта -- Capabilities (v8.3, партия 13.3.5 аудита — цитата UI оригинала): -- "Указать в связке 'Наименование отправителя' и 'Ключевое слово' можно -- только по поставщику B2. Поставщик B3 работает только по наименованию -- отправителя". -- Используется UI карточки проекта для генерации релевантных полей. -- При выборе нескольких поставщиков — пересечение capabilities (intersection). channel VARCHAR(20) NOT NULL DEFAULT 'sites' CHECK (channel IN ('sites','calls','sms')), supports_sender_name BOOLEAN NOT NULL DEFAULT FALSE, supports_keyword BOOLEAN NOT NULL DEFAULT FALSE, supports_csv_upload BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: загрузка CSV-доменов supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: textarea со списком доменов -- v8.5 (Биз-22): простая lead scoring модель без ML. -- Используется в формуле deals.lead_score = supplier_quality × time_in_form. -- 1.00 = baseline; админ SaaS вручную поднимает/понижает в /admin/suppliers -- по фактической конверсии за месяц. Post-MVP — auto-adjustment по cron. quality_score NUMERIC(3,2) NOT NULL DEFAULT 1.00 CHECK (quality_score BETWEEN 0.00 AND 9.99), is_active BOOLEAN DEFAULT TRUE, sort_order INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, CONSTRAINT chk_suppliers_accepts_types_not_empty CHECK (array_length(accepts_types, 1) > 0) ); CREATE INDEX idx_suppliers_active ON suppliers(is_active, sort_order) WHERE is_active = TRUE; COMMENT ON TABLE suppliers IS 'Каталог поставщиков данных. На MVP — три строки (B1/B2/B3), ' 'суб-поставщики crm.bp-gr.ru, раскрытые в партии 10 аудита 04.05.2026. ' 'Capabilities (channel, supports_*) добавлены в v8.3 по итогам партии 13.3.5.'; COMMENT ON COLUMN suppliers.settings_schema IS 'JSON Schema для per-supplier настроек проекта. Для B2: sender_name + keyword. ' 'Для B3: только sender_name. UI генерирует форму project_suppliers.settings.'; COMMENT ON COLUMN suppliers.channel IS 'Тип канала приёма данных. Определяет, какие поля проекта релевантны ' '(sites — домены/CSV; calls — телефонные номера; sms — sender_name + keyword).'; COMMENT ON COLUMN suppliers.supports_sender_name IS 'Поддерживает ли поставщик поле "Наименование отправителя" (для SMS-канала). ' 'B2=TRUE, B3=TRUE, B1=FALSE.'; COMMENT ON COLUMN suppliers.supports_keyword IS 'Поддерживает ли поставщик поле "Ключевое слово". ' 'Только B2=TRUE (партия 13.3.5: "только по поставщику B2").'; -- ----------------------------------------------------------------------------- -- document_sequences — нумерация счетов/УПД (раздел 7.4, 20.10.4) -- Блокировка FOR UPDATE при выдаче следующего номера — избегаем дыр -- ----------------------------------------------------------------------------- CREATE TABLE document_sequences ( legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id), document_type VARCHAR(20) NOT NULL, -- invoice, upd, upd_correction year INT NOT NULL, last_number INT NOT NULL DEFAULT 0, PRIMARY KEY (legal_entity_id, document_type, year) ); -- ----------------------------------------------------------------------------- -- saas_admin_users — админы SaaS (НОВАЯ, спецификация админки v8.1) -- ----------------------------------------------------------------------------- CREATE TABLE saas_admin_users ( id BIGSERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, full_name VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- bcrypt cost=12 role VARCHAR(20) NOT NULL CHECK (role IN ('super_admin','support','finance','compliance','dev_oncall','read_only')), is_active BOOLEAN DEFAULT TRUE, -- 2FA (обязательно для всех админов; v8.5 OPEN-И-13: при sso_provider != 'local' -- локальный 2FA выключен и заменён на 2FA провайдера IDP) totp_secret_enc TEXT, -- зашифровано Crypt::encryptString totp_enabled_at TIMESTAMPTZ, -- v8.5 (OPEN-И-13): SSO + JIT-provisioning + break-glass. -- sso_provider — кто аутентифицировал (Yandex 360 OIDC по умолчанию; -- 'local' — fallback для break-glass и dev). При is_break_glass=TRUE этот -- аккаунт обходит SSO для аварийного входа при недоступности IDP, но force-2FA -- через TOTP остаётся. Логика guard'ов — в SaasAdminAuthService (narrative §22.7). sso_provider VARCHAR(32) NOT NULL DEFAULT 'yandex360' CHECK (sso_provider IN ('yandex360','local')), is_break_glass BOOLEAN NOT NULL DEFAULT FALSE, -- IP allow-list (per-user, дополняет глобальный) allowed_ips JSONB, -- ["10.0.0.0/8","1.2.3.4"] -- Безопасность password_changed_at TIMESTAMPTZ DEFAULT NOW(), failed_login_count INT DEFAULT 0, locked_until TIMESTAMPTZ, -- Аудит created_at TIMESTAMPTZ DEFAULT NOW(), created_by BIGINT REFERENCES saas_admin_users(id), last_login_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ -- soft delete ); CREATE INDEX idx_saas_admin_users_active ON saas_admin_users(is_active) WHERE deleted_at IS NULL; -- ----------------------------------------------------------------------------- -- saas_admin_recovery_codes — recovery-коды 2FA для админов (НОВАЯ) -- ----------------------------------------------------------------------------- CREATE TABLE saas_admin_recovery_codes ( id BIGSERIAL PRIMARY KEY, admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id) ON DELETE CASCADE, code_hash VARCHAR(255) NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_saas_admin_recovery_user ON saas_admin_recovery_codes(admin_user_id) WHERE used_at IS NULL; -- ----------------------------------------------------------------------------- -- saas_admin_sessions — сессии админов (НОВАЯ) -- Ю-1: добавлены два поля для режима impersonation. В обычном режиме оба NULL. -- В режиме impersonation оба заполнены, FK на impersonation_tokens (см. ниже). -- ----------------------------------------------------------------------------- CREATE TABLE saas_admin_sessions ( id BIGSERIAL PRIMARY KEY, admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id) ON DELETE CASCADE, token_hash VARCHAR(255) UNIQUE NOT NULL, ip_address INET NOT NULL, -- проверяется на каждом запросе vs allow-list user_agent TEXT, last_active_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- Impersonation (Ю-1) impersonating_tenant_id BIGINT, -- FK на tenants(id) добавлен ниже после CREATE TABLE tenants impersonating_token_id BIGINT, -- FK добавлен ниже после CREATE TABLE impersonation_tokens CONSTRAINT chk_admin_session_impersonation CHECK ( (impersonating_tenant_id IS NULL AND impersonating_token_id IS NULL) OR (impersonating_tenant_id IS NOT NULL AND impersonating_token_id IS NOT NULL) ) ); CREATE INDEX idx_saas_admin_sessions_user ON saas_admin_sessions(admin_user_id, last_active_at DESC); -- expires_at NOT NULL → partial index по WHERE expires_at IS NOT NULL бесполезен; индекс по полю — для cron-очистки expired-сессий. CREATE INDEX idx_saas_admin_sessions_expires ON saas_admin_sessions(expires_at); CREATE INDEX idx_saas_admin_sessions_imp ON saas_admin_sessions(impersonating_tenant_id) WHERE impersonating_tenant_id IS NOT NULL; -- ----------------------------------------------------------------------------- -- impersonation_tokens — одноразовые коды для режима "Войти как клиент" (Ю-1) -- Workflow: -- 1. Админ нажимает "Войти как…", указывает основание (>= 30 символов). -- 2. Создаётся запись, на tenant.contact_email уходит письмо с 6-значным кодом. -- 3. Срок жизни кода — 15 минут. -- 4. Клиент передаёт код админу любым способом, админ вводит в админке. -- 5. Создаётся saas_admin_sessions с impersonating_*, открывается клиентское -- приложение с принудительным красным баннером. -- 6. Сессия живёт 1 час, потом session_ended_at = NOW(). -- 7. После 5 неверных вводов кода — токен инвалидируется (failed_attempts). -- 8. По завершении сессии — email клиенту со списком действий админа. -- -- НЕ tenant-уровневая (saas-уровневая, как saas_admin_*). RLS не применяется -- намеренно: токены создаются и используются только из админки SaaS под -- crm_admin_user (BYPASSRLS). Tenant-приложение к этой таблице не обращается. -- Изоляция между админами обеспечивается на уровне приложения (поле -- requested_by) и audit log'а. -- ----------------------------------------------------------------------------- CREATE TABLE impersonation_tokens ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, -- FK + ON DELETE CASCADE на tenants(id) добавлены ниже после CREATE TABLE tenants requested_by BIGINT NOT NULL REFERENCES saas_admin_users(id), code_hash VARCHAR(255) NOT NULL, -- bcrypt 6-значного кода reason TEXT NOT NULL, -- основание (мин. 30 символов) sent_to_email VARCHAR(255) NOT NULL, -- snapshot tenant.contact_email на момент запроса expires_at TIMESTAMPTZ NOT NULL, -- created_at + 15 минут used_at TIMESTAMPTZ, -- момент успешного ввода session_id BIGINT REFERENCES saas_admin_sessions(id), session_ended_at TIMESTAMPTZ, failed_attempts INT DEFAULT 0, -- защита от брутфорса invalidated_at TIMESTAMPTZ, -- ручная инвалидация админом или клиентом -- v8.5 (CTO-15 + Ю-9): two-person approval для тенантов с -- pd_subject_request.processing_restricted=TRUE ИЛИ chargeback_unrecovered_rub > 0. -- При создании токена SaasAdminAuthService проверяет флаги тенанта; если -- хотя бы один TRUE — second_approver_id обязателен (роль 'compliance'). -- Полный flow guard'ов в narrative §22.7.X. NULL для обычных тенантов. second_approver_id BIGINT REFERENCES saas_admin_users(id), second_approval_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT chk_imp_reason_len CHECK (length(reason) >= 30) ); CREATE INDEX idx_imp_tokens_active ON impersonation_tokens(tenant_id, expires_at) WHERE used_at IS NULL AND failed_attempts < 5 AND invalidated_at IS NULL; CREATE INDEX idx_imp_tokens_admin ON impersonation_tokens(requested_by, created_at DESC); -- v8.11 (audit P0-02): RLS-isolation между тенантами для одноразовых impersonation-токенов ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON impersonation_tokens USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- Forward FK на impersonation_tokens ALTER TABLE saas_admin_sessions ADD CONSTRAINT fk_admin_sessions_imp_token FOREIGN KEY (impersonating_token_id) REFERENCES impersonation_tokens(id); -- ----------------------------------------------------------------------------- -- system_settings — глобальные настройки SaaS (раздел 3.4) -- updated_by — FK на saas_admin_users (РАСШИРЕНИЕ v8.1: в v8.0 без FK) -- ----------------------------------------------------------------------------- CREATE TABLE system_settings ( key VARCHAR(100) PRIMARY KEY, value TEXT NOT NULL, type VARCHAR(20) DEFAULT 'string' -- string, int, decimal, json, bool CHECK (type IN ('string','int','decimal','json','bool')), description TEXT, updated_at TIMESTAMPTZ DEFAULT NOW(), updated_by BIGINT REFERENCES saas_admin_users(id) -- было BIGINT без FK в v8.0 ); -- ----------------------------------------------------------------------------- -- Laravel-стандартные таблицы (DDL отсутствует в v8.0, реконструировано) -- ----------------------------------------------------------------------------- -- Очередь Laravel — failed jobs CREATE TABLE failed_jobs ( id BIGSERIAL PRIMARY KEY, uuid UUID UNIQUE NOT NULL, connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at TIMESTAMPTZ DEFAULT NOW() ); -- Сброс паролей (Laravel-стандарт, раздел 7.1 упоминает таблицу) CREATE TABLE password_resets ( email VARCHAR(255) PRIMARY KEY, token VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_password_resets_token ON password_resets(token); -- Подтверждение email (упомянуто в 4.1 шаг 2 и 7.1) CREATE TABLE email_verifications ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users email VARCHAR(255) NOT NULL, token VARCHAR(255) UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL, verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_email_verifications_token ON email_verifications(token) WHERE verified_at IS NULL; -- ============================================================================= -- 3. TENANTS (раздел 3.4) -- FK на tariff_plans уже создан выше -- ============================================================================= CREATE TABLE tenants ( id BIGSERIAL PRIMARY KEY, subdomain VARCHAR(63) UNIQUE NOT NULL, -- "client1" organization_name VARCHAR(255) NOT NULL, contact_email VARCHAR(255) NOT NULL, status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active','suspended','pending_email_confirm','deleted')), -- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal) timezone VARCHAR(50) DEFAULT 'Europe/Moscow', locale VARCHAR(10) DEFAULT 'ru', -- Биллинг current_tariff_id BIGINT REFERENCES tariff_plans(id), balance_rub DECIMAL(12,2) DEFAULT 0, balance_leads INT DEFAULT 0, is_trial BOOLEAN DEFAULT TRUE, trial_leads_used INT DEFAULT 0, -- Chargeback (Ю-3): непокрытая балансом часть внешнего возврата. -- Если > 0 → tenants.status = 'suspended', клиент видит экран погашения в /billing. chargeback_unrecovered_rub DECIMAL(12,2) DEFAULT 0 CHECK (chargeback_unrecovered_rub >= 0), -- Активность last_activity_at TIMESTAMPTZ, last_webhook_at TIMESTAMPTZ, -- Целевой объём лидов в день — сигнал для саппорта (Биз-16, партия 13.2.2). -- НЕ лимит, НЕ биллинговая величина. Используется только для отображения -- в админке SaaS (саппорту видно, сколько клиент хочет получать в день). desired_daily_numbers INT CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0), -- v8.19 (Plan 4): месячный счётчик доставленных лидов per-tenant. -- Сбрасывается ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Europe/Moscow). -- Используется PricingTierResolver на горячем пути RouteSupplierLeadJob -- для O(1) lookup'а текущей ступени тарифа. delivered_in_month INT NOT NULL DEFAULT 0 CHECK (delivered_in_month >= 0), -- v8.5 (OPEN-И-19): hard-limit количества активных API-ключей. -- Защита от DoS через создание тысяч ключей. Default 5 — комфортный -- baseline; max 10 — для intg-партнёров. Hard limit, не soft warning. api_key_limit INT NOT NULL DEFAULT 5 CHECK (api_key_limit BETWEEN 1 AND 10), -- v8.5 (Биз-20): Telegram-бот для нотификаций менеджерам. Спринт 9. -- Хранится в зашифрованном виде через Crypt::encryptString. Вне спринта 9 -- остаётся NULL и фича не активна (UI скрывает Telegram-настройки). telegram_bot_token TEXT, -- Plan 5 Task 3: per-tenant override лимитов тарифа. -- Используется ProjectService::create() для проверки max_projects. -- {"max_users":5,"max_projects":10,"api_rps":60} limits JSONB NOT NULL DEFAULT '{}', -- Метаданные created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5) ); CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL; -- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal) CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL; -- Forward FK на tenants для SaaS-админских таблиц, объявленных выше -- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id). -- Добавляем здесь, потому что tenants создаётся ниже по тексту схемы. ALTER TABLE saas_admin_sessions ADD CONSTRAINT fk_admin_sessions_imp_tenant FOREIGN KEY (impersonating_tenant_id) REFERENCES tenants(id); ALTER TABLE impersonation_tokens ADD CONSTRAINT fk_impersonation_tokens_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE; -- ----------------------------------------------------------------------------- -- tenant_custom_domains (раздел 3.3, опционально, post-MVP) -- ----------------------------------------------------------------------------- CREATE TABLE tenant_custom_domains ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, domain VARCHAR(255) UNIQUE NOT NULL, ssl_status VARCHAR(20) DEFAULT 'pending' CHECK (ssl_status IN ('pending','active','error')), verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); -- ============================================================================= -- 4. TENANT-УРОВНЕВЫЕ БАЗОВЫЕ ТАБЛИЦЫ -- ============================================================================= -- ----------------------------------------------------------------------------- -- users (раздел 7.3) -- ----------------------------------------------------------------------------- CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, first_name VARCHAR(255), last_name VARCHAR(255), phone VARCHAR(20), timezone VARCHAR(50) DEFAULT 'Europe/Moscow', avatar_path VARCHAR(500), -- 2FA totp_secret TEXT, -- ШИФРУЕТСЯ Crypt::encrypt (encrypted ~256 chars > VARCHAR(255)) totp_enabled BOOLEAN DEFAULT FALSE, -- Уведомления (раздел 18.5 v8.0 + CTO-4 решение). -- Матрица 8 типов × до 3 каналов (in-app / push / email). -- Звук — отдельный глобальный toggle, потому что играет именно в открытой вкладке. notification_preferences JSONB NOT NULL DEFAULT '{ "new_lead": {"inapp": true, "push": true, "email": false}, "reminder": {"inapp": true, "push": true, "email": true}, "low_balance": {"email": true}, "zero_balance": {"email": true}, "topup_success": {"email": true}, "invoice_paid": {"email": true}, "new_device_login": {"email": true}, "marketing": {"email": false} }'::jsonb, sound_enabled BOOLEAN DEFAULT TRUE, -- общий toggle звука для in-app уведомлений -- v8.5 (Биз-20): Telegram-канал нотификаций. Спринт 9. До спринта 9 -- остаётся NULL и UI скрывает Telegram-настройки. Подключение — через -- бот /start: пользователь отправляет код, бот связывает chat_id с user_id. telegram_user_id BIGINT, -- Telegram chat_id -- Статус email_verified_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ, last_active_at TIMESTAMPTZ, is_active BOOLEAN DEFAULT TRUE, -- Аудит created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, -- soft delete + анонимизация UNIQUE (tenant_id, email), -- CTO-4: базовая защита, что notification_preferences — JSON-объект. -- Полная валидация структуры — на уровне приложения через Form Request. CONSTRAINT chk_users_notif_prefs_object CHECK (jsonb_typeof(notification_preferences) = 'object') ); CREATE INDEX idx_users_tenant_email ON users(tenant_id, email) WHERE deleted_at IS NULL; -- FK на email_verifications.user_id (forward reference) ALTER TABLE email_verifications ADD CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -- ----------------------------------------------------------------------------- -- projects (раздел 7.3, 9; расширено в v8.2 по аудиту партии 10) -- ----------------------------------------------------------------------------- -- Расширения v8.2 — 6 полей для соответствия модели оригинала (Прил. М §3.2): -- • daily_limit_target — целевой лимит лидов в день, заданный клиентом -- • effective_daily_limit_today — реальный лимит после автокоррекции по балансу -- • effective_limit_calculated_at — когда последний раз пересчитывали -- • region_mask + region_mode — фильтр по 8 федеральным округам РФ (партия 10.3 секция 6-7) -- • delivery_days_mask — битмаска 7 дней приёма лидов (партия 10.3 секция 11) -- -- Бизнес-правило автокоррекции (партия 10.6): -- effective_daily_limit_today = MIN(daily_limit_target, FLOOR(balance / lead_cost)) -- Пересчитывает сервис EffectiveLimitCalculator (Laravel) по триггерам: -- - cron limits:recalc в 00:00 МСК для всех активных проектов; -- - после balance_transactions.commit; -- - после списания за лид (если effective < target); -- - при создании проекта; при смене тарифа. -- ----------------------------------------------------------------------------- CREATE TABLE projects ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, -- "чистое" имя без префиксов B1_/B2_/B3_ tag VARCHAR(255), type VARCHAR(20) DEFAULT 'webhook' CHECK (type IN ('webhook','manual','import')), -- РАСШИРЕНИЕ v8.12: supplier integration (Plan 1/5 Task 1, spec §2.1) signal_type VARCHAR(16), -- 'site' | 'call' | 'sms' | NULL (до выбора поставщика) signal_identifier TEXT, -- URL/телефон для site/call; NULL для sms sms_senders JSONB, -- массив sender-имён (для signal_type='sms') sms_keyword TEXT, -- ключевое слово (опционально, signal_type='sms') is_active BOOLEAN DEFAULT TRUE, -- РАСШИРЕНИЕ v8.2: динамические лимиты (партия 10.6 аудита) daily_limit_target INT NOT NULL DEFAULT 10, -- что хочет клиент (default 10 = паритет с оригиналом) effective_daily_limit_today INT, -- что реально на сегодня (NULL = ещё не считалось) -- РАСШИРЕНИЕ v8.12: счётчик доставленных в текущем месяце (для месячного лимита) delivered_in_month INTEGER NOT NULL DEFAULT 0 CHECK (delivered_in_month >= 0), -- РАСШИРЕНИЕ v8.18 (Plan 2/5): дневной счётчик доставленных лидов. -- Сбрасывается cron'ом ResetDeliveredTodayCommand в 00:00 МСК. -- Используется в LeadRouter для проверки квоты (delivered_today < effective_daily_limit). delivered_today INTEGER NOT NULL DEFAULT 0 CHECK (delivered_today >= 0), -- РАСШИРЕНИЕ v8.12: ссылки на supplier_projects (B1/B2/B3) — FK добавлены ниже после CREATE TABLE supplier_projects (v8.17) supplier_b1_project_id BIGINT, supplier_b2_project_id BIGINT, supplier_b3_project_id BIGINT, effective_limit_calculated_at TIMESTAMPTZ, -- РАСШИРЕНИЕ v8.2: регионы и дни (партия 10.3 секции 6, 7, 11) region_mask INT NOT NULL DEFAULT 255, -- DEPRECATED Plan 6.5: см. regions INT[] -- битмаска 8 ФО РФ: бит 1=Центральный, 2=Северо-Западный, 4=Южный, -- 8=Северо-Кавказский, 16=Приволжский, 32=Уральский, 64=Сибирский, -- 128=Дальневосточный. 255 = все 8 округов. region_mode VARCHAR(10) NOT NULL DEFAULT 'include' CHECK (region_mode IN ('include','exclude')), -- 'include' = принимать только из выбранных, 'exclude' = принимать кроме выбранных -- v8.20 (Plan 6): Subject-level regions array. 89 codes из resources/js/constants/regions.ts. -- Пустой массив = «вся РФ» (паритет с legacy region_mask=255 + region_mode='include'). -- region_mask/region_mode остаются для legacy reader'ов (PhonePrefixService, LeadRouter), -- DEPRECATED — удаляются в Plan 6.5 после переключения читателей. regions INT[] NOT NULL DEFAULT '{}'::INT[], delivery_days_mask INT NOT NULL DEFAULT 127, -- битмаска дней недели: бит 1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс. -- 127 = все 7 дней (паритет с формой создания нового проекта в оригинале). -- v8.5 (Биз-17): стратегия автораспределения лидов между менеджерами. -- MVP = 'manual' (паритет с оригиналом, ручное назначение в карточке сделки). -- 'round_robin'/'least_loaded' зарезервированы для Post-MVP — реализация -- через таблицу project_user_assignments (CTO-16) + cron-балансировку. assignment_strategy VARCHAR(32) NOT NULL DEFAULT 'manual' CHECK (assignment_strategy IN ('manual','round_robin','least_loaded')), -- v8.5 (Биз-18): Time To First Response SLA в минутах. -- При просрочке (NOW() - deals.received_at > ttfr_target_minutes AND -- deals.status='new' AND deals.manager_id IS NULL) — alert менеджеру и -- админу через event bus + UI badge на карточке. Метрика на дашборде §12.5.5. ttfr_target_minutes INT NOT NULL DEFAULT 15 CHECK (ttfr_target_minutes BETWEEN 1 AND 1440), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, UNIQUE (tenant_id, name), CONSTRAINT chk_projects_daily_limit_positive CHECK (daily_limit_target > 0), CONSTRAINT chk_projects_effective_limit_nonnegative CHECK (effective_daily_limit_today IS NULL OR effective_daily_limit_today >= 0), CONSTRAINT chk_projects_region_mask_range CHECK (region_mask BETWEEN 0 AND 255), CONSTRAINT chk_projects_delivery_days_mask_range CHECK (delivery_days_mask BETWEEN 0 AND 127), -- РАСШИРЕНИЕ v8.12: supplier integration (Plan 1/5 Task 1) CONSTRAINT chk_projects_signal_type CHECK (signal_type IS NULL OR signal_type IN ('site','call','sms')), CONSTRAINT chk_projects_sms_senders_required CHECK ( signal_type <> 'sms' OR (sms_senders IS NOT NULL AND jsonb_typeof(sms_senders) = 'array' AND jsonb_array_length(sms_senders) > 0) ), CONSTRAINT chk_projects_signal_identifier_required CHECK ( signal_type NOT IN ('site','call') OR (signal_identifier IS NOT NULL AND length(trim(signal_identifier)) > 0) ) ); CREATE INDEX idx_projects_tenant ON projects(tenant_id); CREATE INDEX idx_projects_tag ON projects(tag); -- РАСШИРЕНИЕ v8.12: composite index для lookup по signal-полям (resolveSignalSource) CREATE INDEX idx_projects_tenant_signal ON projects(tenant_id, signal_type, signal_identifier); -- v8.20 (Plan 6): GIN-индекс для outbound regions queries. CREATE INDEX idx_projects_regions ON projects USING GIN (regions); COMMENT ON COLUMN projects.daily_limit_target IS 'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на ' 'сегодня — в effective_daily_limit_today (может быть меньше из-за ' 'автокоррекции по балансу).'; COMMENT ON COLUMN projects.effective_daily_limit_today IS 'Реальный лимит на сегодня после автокоррекции по балансу. Формула: ' 'MIN(daily_limit_target, FLOOR(balance / lead_cost)). Пересчитывается cron ' 'limits:recalc в 00:00 МСК и при изменении баланса. NULL = не считалось.'; COMMENT ON COLUMN projects.regions IS 'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.22).'; -- ----------------------------------------------------------------------------- -- supplier_projects — SaaS-level агрегатные проекты у поставщиков (v8.13, Plan 1/5 Task 2) -- ----------------------------------------------------------------------------- -- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.2. -- Один supplier_project отражает проект, фактически созданный/синхронизированный у поставщика -- (B1/B2/B3). Несколько Лидерра-tenant'ов могут шарить один supplier_project (sharing-model -- из §2.3 spec'а): для сайта/звонка — по domain/phone; для СМС — по (sender, keyword) на B2 -- или (sender) на B3. -- -- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Доступ из tenant-приложения -- закрыт через REVOKE ALL FROM crm_app_user (defense-in-depth, аналогично 6 saas-таблицам -- из v8.5 OPEN-И-14). Сервис-слой синхронизации работает под elevated ролью. -- ----------------------------------------------------------------------------- CREATE TABLE supplier_projects ( id BIGSERIAL PRIMARY KEY, platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3 signal_type VARCHAR(16) NOT NULL, -- site / call / sms unique_key TEXT NOT NULL, -- domain / phone / sender+keyword / sender supplier_external_id VARCHAR(64), -- внутренний id у поставщика current_limit INTEGER NOT NULL DEFAULT 0 CHECK (current_limit >= 0), current_workdays JSONB, -- объединение workdays активных tenant'ов current_regions JSONB, -- объединение regions активных tenant'ов subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26) sync_status VARCHAR(16) NOT NULL DEFAULT 'pending', last_synced_at TIMESTAMPTZ, inactive_since TIMESTAMPTZ, -- момент когда последний tenant отвалился (TTL 180 дней) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3')), CONSTRAINT chk_supplier_projects_signal_type CHECK (signal_type IN ('site','call','sms')), CONSTRAINT chk_supplier_projects_sync_status CHECK (sync_status IN ('pending','ok','failed')), -- B1 не поддерживает СМС (см. spec §2.2 — таблица platform×signal_type) CONSTRAINT chk_supplier_projects_b1_not_for_sms CHECK (NOT (platform = 'B1' AND signal_type = 'sms')), CONSTRAINT chk_supplier_projects_subject_code CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) ); CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT; CREATE INDEX supplier_projects_sync_status_index ON supplier_projects(sync_status); CREATE INDEX supplier_projects_inactive_since_index ON supplier_projects(inactive_since); -- v8.17 (Plan 1/5 Task 2 fix): FK constraints для projects.supplier_b{1,2,3}_project_id. -- Колонки заведены в v8.12 как placeholder BIGINT (ALTER TABLE projects); FK невозможно было -- добавить inline до создания supplier_projects. Здесь фиксируем ссылочную целостность. -- ON DELETE SET NULL: при удалении supplier_project не каскадим удаление tenant-проектов, -- но обнуляем привязку — клиент сможет переподключить через resolveOrStub при следующем sync. ALTER TABLE projects ADD CONSTRAINT projects_supplier_b1_project_id_fk FOREIGN KEY (supplier_b1_project_id) REFERENCES supplier_projects(id) ON DELETE SET NULL, ADD CONSTRAINT projects_supplier_b2_project_id_fk FOREIGN KEY (supplier_b2_project_id) REFERENCES supplier_projects(id) ON DELETE SET NULL, ADD CONSTRAINT projects_supplier_b3_project_id_fk FOREIGN KEY (supplier_b3_project_id) REFERENCES supplier_projects(id) ON DELETE SET NULL; CREATE INDEX idx_projects_supplier_b1_project_id ON projects(supplier_b1_project_id) WHERE supplier_b1_project_id IS NOT NULL; CREATE INDEX idx_projects_supplier_b2_project_id ON projects(supplier_b2_project_id) WHERE supplier_b2_project_id IS NOT NULL; CREATE INDEX idx_projects_supplier_b3_project_id ON projects(supplier_b3_project_id) WHERE supplier_b3_project_id IS NOT NULL; -- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id). CREATE TABLE project_supplier_links ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE, platform VARCHAR(4) NOT NULL, subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')), CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id) ); CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id); CREATE INDEX idx_psl_project ON project_supplier_links(project_id); -- v8.17 (Plan 1/5 Task 2 fix): defense-in-depth — Project-уровень тоже запрещает B1+SMS. -- supplier_projects уже имеет CHECK chk_supplier_projects_b1_not_for_sms; здесь дублируем -- на projects, чтобы исключить логическую несостыковку «sms-проект привязан к B1-supplier». ALTER TABLE projects ADD CONSTRAINT chk_projects_b1_not_for_sms CHECK (signal_type <> 'sms' OR supplier_b1_project_id IS NULL); -- v8.13 (Plan 1/5 Task 2): defense-in-depth — REVOKE ALL FROM crm_app_user. -- SaaS-уровневая таблица, tenant-приложению доступ запрещён даже теоретически. -- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) — -- миграция оборачивает REVOKE в DO $$ EXISTS check. -- -- REVOKE ALL ON supplier_projects FROM crm_app_user; -- ----------------------------------------------------------------------------- -- pricing_tiers — SaaS-level конфигурация 7-ступенчатого объёмного тарифа (v8.14, Plan 1/5 Task 3) -- ----------------------------------------------------------------------------- -- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.2. -- Клиент Лидерры платит ступенчато: чем больше лидов получено в текущем месяце — -- тем дешевле каждый следующий. Конфигурируется админом Лидерры; tenant'ы читают. -- -- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Per-tenant override -- out of scope для MVP (один тариф на всю Лидерру). -- -- Цена хранится в копейках (integer) — избегаем floating-point округлений в money-расчётах -- (1 руб = 100 копеек). UI converts at the edge (accessor в Eloquent-модели, Plan Task 7). -- -- Поле leads_in_tier nullable: для последней ступени NULL = «всё свыше» (без верхней границы). -- -- Доступ из tenant-приложения: SELECT only (через elevated service-role идут writes). -- Conditional: dev runs as postgres superuser без crm_app_user — миграция оборачивает -- REVOKE/GRANT в DO $$ EXISTS-check. -- ----------------------------------------------------------------------------- CREATE TABLE pricing_tiers ( id BIGSERIAL PRIMARY KEY, tier_no SMALLINT NOT NULL CHECK (tier_no >= 0), -- unsigned-эквивалент: tier_no >= 0; диапазон 1..7 — отдельный CHECK ниже leads_in_tier INTEGER -- NULL = «всё свыше» для последней ступени CHECK (leads_in_tier IS NULL OR leads_in_tier >= 0), price_per_lead_kopecks INTEGER NOT NULL CHECK (price_per_lead_kopecks >= 0), -- копейки, integer (избегаем float) is_active BOOLEAN NOT NULL DEFAULT TRUE, effective_from DATE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT chk_pricing_tiers_tier_no CHECK (tier_no BETWEEN 1 AND 7) ); CREATE UNIQUE INDEX pricing_tiers_tier_effective_unique ON pricing_tiers(tier_no, effective_from); CREATE INDEX pricing_tiers_is_active_effective_from_index ON pricing_tiers(is_active, effective_from); -- v8.14 (Plan 1/5 Task 3): SELECT-only для tenant-приложения. -- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) — -- миграция оборачивает REVOKE/GRANT в DO $$ EXISTS check. -- -- REVOKE ALL ON pricing_tiers FROM crm_app_user; -- GRANT SELECT ON pricing_tiers TO crm_app_user; -- ----------------------------------------------------------------------------- -- lead_charges — append-only ledger списаний за доставленный лид (v8.15, Plan 1/5 Task 4) -- ----------------------------------------------------------------------------- -- Tenant-scoped (RLS), append-only — каждая запись = факт списания с баланса клиента -- по текущей ступени pricing_tiers при доставке лида в его проект. -- Composite FK на партиционированную deals(id, received_at) — DEFERRABLE INITIALLY DEFERRED -- для атомарного INSERT deal+charge в одной транзакции. -- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4 -- ----------------------------------------------------------------------------- CREATE TABLE lead_charges ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, deal_id BIGINT NOT NULL, deal_received_at TIMESTAMPTZ NOT NULL, tier_no SMALLINT NOT NULL, price_per_lead_kopecks INTEGER NOT NULL, charge_source VARCHAR(8) NOT NULL DEFAULT 'rub' CHECK (charge_source IN ('prepaid','rub')), charged_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_lead_charges_prepaid_zero_price CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0) ); -- Composite FK на deals(id, received_at) добавляется в самом конце файла, -- ПОСЛЕ создания партиционированной deals (section 5). DEFERRABLE INITIALLY DEFERRED — -- обязательно для атомарного INSERT deal+charge в одной транзакции. CREATE INDEX lead_charges_tenant_id_charged_at_index ON lead_charges(tenant_id, charged_at); CREATE INDEX lead_charges_deal_id_deal_received_at_index ON lead_charges(deal_id, deal_received_at); -- RLS: tenant_isolation. ENABLE + FORCE — даже владелец таблицы (postgres) подчиняется -- политике, иначе superuser обходит RLS. POLICY с USING + WITH CHECK защищает все DML. ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY; ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON lead_charges USING (tenant_id = current_setting('app.current_tenant_id')::bigint) WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.15 (Plan 1/5 Task 4): SELECT + INSERT для tenant-приложения (append-only ledger). -- UPDATE/DELETE недопустимы — append-only гарантия для биллинга/аудита. -- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) — -- миграция оборачивает GRANT в DO $$ EXISTS check. -- -- GRANT SELECT, INSERT ON lead_charges TO crm_app_user; -- GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO crm_app_user; -- ----------------------------------------------------------------------------- -- supplier_sync_log — SaaS-level audit log AJAX-синхронизаций с поставщиком (v8.16, Plan 1/5 Task 5) -- ----------------------------------------------------------------------------- -- Append-only journal попыток rt-project-{save,update,delete} + session_refresh -- к поставщику crm.bp-gr.ru. Используется для отладки, retry-логики и алертов. -- НЕ tenant-scoped — события агрегатные на уровне SaaS (один supplier_project разделяется -- между tenants, лог общий). -- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3 -- ----------------------------------------------------------------------------- CREATE TABLE supplier_sync_log ( id BIGSERIAL PRIMARY KEY, supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL, action VARCHAR(32) NOT NULL, request_payload JSONB, response_body JSONB, http_status SMALLINT, error_message TEXT, duration_ms INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_supplier_sync_log_action CHECK (action IN ('create','update','delete','disable','session_refresh')) ); CREATE INDEX supplier_sync_log_supplier_project_id_index ON supplier_sync_log(supplier_project_id); CREATE INDEX supplier_sync_log_action_index ON supplier_sync_log(action); CREATE INDEX supplier_sync_log_created_at_index ON supplier_sync_log(created_at); -- v8.16 (Plan 1/5 Task 5): SaaS-level audit log; tenant-приложение не должно его читать/писать -- напрямую. REVOKE ALL применяется в production через db/02_grants.sql; в dev — DO $$ EXISTS check. -- -- REVOKE ALL ON supplier_sync_log FROM crm_app_user; -- ----------------------------------------------------------------------------- -- supplier_csv_reconcile_log — журнал hourly CSV reconciliation (v8.19, Plan 4) -- ----------------------------------------------------------------------------- -- SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log. -- CsvReconcileJob записывает 1 строку на hourly run: started_at, окно, -- метрики, drift_ratio. drift > 5% → email алерт; alert_email_sent_at timestamp. -- Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3 -- ----------------------------------------------------------------------------- CREATE TABLE supplier_csv_reconcile_log ( id BIGSERIAL PRIMARY KEY, started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ, window_start TIMESTAMPTZ NOT NULL, window_end TIMESTAMPTZ NOT NULL, total_csv_rows INTEGER, matched_count INTEGER, recovered_count INTEGER, -- Кол-во CSV-строк, у которых поле «project» не парсится в платформу B1/B2/B3 -- (поставщик иногда кладёт телефон/URL в «Name» вместо названия проекта). -- Используется CsvReconcileJob для корректного расчёта drift'а — без вычитания -- этих строк формула стабильно даёт false-positive drift_alert ~40-50%. unparseable_count INTEGER NOT NULL DEFAULT 0, drift_ratio NUMERIC(5,4), status VARCHAR(16) NOT NULL DEFAULT 'running' CHECK (status IN ('running','ok','drift_alert','failed')), error_message TEXT, alert_email_sent_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX supplier_csv_reconcile_log_started_at_index ON supplier_csv_reconcile_log(started_at DESC); CREATE INDEX supplier_csv_reconcile_log_status_index ON supplier_csv_reconcile_log(status) WHERE status IN ('drift_alert','failed'); -- ----------------------------------------------------------------------------- -- supplier_manual_sync_queue — Tier 3 очередь резерва канала миграции проектов (v8.25) -- ----------------------------------------------------------------------------- -- SaaS-level (не tenant-scoped, без RLS, как supplier_csv_reconcile_log). -- FailoverProjectChannel записывает строку при провале ярусов 1-2: оператор -- админ-экрана вносит проект вручную в crm.bp-gr.ru и помечает row resolved. -- Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5 -- ----------------------------------------------------------------------------- CREATE TABLE supplier_manual_sync_queue ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, platform VARCHAR(8) NOT NULL, operation VARCHAR(16) NOT NULL, external_id VARCHAR(64), payload_snapshot JSONB NOT NULL, failure_reason VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ, CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')), CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')), CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled')) ); CREATE INDEX idx_smsq_status_created ON supplier_manual_sync_queue (status, created_at DESC); CREATE INDEX idx_smsq_project ON supplier_manual_sync_queue (project_id); -- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser. -- ----------------------------------------------------------------------------- -- project_suppliers — m2m связь "проект ↔ поставщики" (НОВАЯ в v8.2) -- ----------------------------------------------------------------------------- -- Контекст: в карточке создания проекта в оригинале (партия 10.4) есть три -- чекбокса B1/B2/B3 — все checked по умолчанию. В карточке редактирования -- они readonly (партия 10.3). В нашем аналоге сохраняем UX, но разрешаем -- редактирование (улучшение). -- -- Per-supplier настройки (settings) хранят значения по схеме -- suppliers.settings_schema. Например, для B2: {"sender_name": "MYCO", "keyword": "ЗАКАЗ"} -- -- TENANT-уровневая через FK на projects (RLS наследуется через project.tenant_id). -- ----------------------------------------------------------------------------- CREATE TABLE project_suppliers ( project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, supplier_id BIGINT NOT NULL REFERENCES suppliers(id), settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- по schema suppliers.settings_schema is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, PRIMARY KEY (project_id, supplier_id) ); CREATE INDEX idx_project_suppliers_supplier ON project_suppliers(supplier_id); CREATE INDEX idx_project_suppliers_active ON project_suppliers(project_id) WHERE is_active = TRUE; COMMENT ON TABLE project_suppliers IS 'M2M связь "проект ↔ поставщики". Один проект может принимать данные ' 'от нескольких поставщиков. По умолчанию при создании проекта подключаются ' 'все is_active=TRUE поставщики (паритет с формой создания в оригинале).'; -- ----------------------------------------------------------------------------- -- project_user_assignments — m2m "проект ↔ менеджеры" + skills (НОВАЯ v8.5, CTO-16) -- ----------------------------------------------------------------------------- -- Назначение: skill-based / regional routing для projects.assignment_strategy -- IN ('round_robin','least_loaded') (Биз-17 Post-MVP). На MVP при assignment_ -- strategy='manual' таблица не используется — менеджер выбирается вручную в -- карточке сделки. После Post-MVP cron lead-router-будет читать active members -- проекта из этой таблицы и распределять входящих лидов. -- -- skills — JSONB-массив строк (slug-и навыков): ['it_b2b','retail','sms_sender_NAME1']. -- При assignment_strategy='round_robin' — игнорируется. При 'least_loaded' — -- для статистики. Skill-based routing (intersection projects.required_skills -- × users.skills) — Post-MVP-расширение. -- -- TENANT-уровневая через JOIN на projects (RLS по tenant_id проекта). -- ----------------------------------------------------------------------------- CREATE TABLE project_user_assignments ( project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, skills JSONB NOT NULL DEFAULT '[]'::jsonb, -- ['it_b2b','retail',...] is_active BOOLEAN DEFAULT TRUE, -- временно отключить менеджера в проекте created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, PRIMARY KEY (project_id, user_id), CONSTRAINT chk_pua_skills_array CHECK (jsonb_typeof(skills) = 'array') ); CREATE INDEX idx_project_user_assignments_user ON project_user_assignments(user_id) WHERE is_active = TRUE; COMMENT ON TABLE project_user_assignments IS 'M2M "проект ↔ менеджеры" с per-assignment skills для skill-based routing. ' 'При projects.assignment_strategy=''manual'' (MVP) таблица не используется. ' 'При round_robin/least_loaded (Post-MVP) — пул активных менеджеров проекта.'; -- ----------------------------------------------------------------------------- -- project_limit_adjustments — лог автокоррекций лимитов (НОВАЯ в v8.2) -- ----------------------------------------------------------------------------- -- Контекст: партия 10.7 зафиксировала в истории действий проекта системные -- записи "Проект был запущен с лимитом N по причине нехватки баланса. -- По факту M". Это лог автокоррекции, который у нас будет вести сервис -- EffectiveLimitCalculator при каждом изменении effective_daily_limit_today. -- -- Назначение: -- - аналитика: сколько раз лимит срезался по балансу; -- - compliance: следить, что коррекция работает корректно; -- - расследование споров с клиентом ("почему лидов было меньше ожидаемого"). -- -- TENANT-уровневая (через tenant_id) → RLS-политика tenant_isolation. -- ----------------------------------------------------------------------------- CREATE TABLE project_limit_adjustments ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, target_limit INT NOT NULL, -- что хотел клиент effective_limit INT NOT NULL, -- что получилось adjustment_reason VARCHAR(50) NOT NULL, balance_at_calc_rub DECIMAL(12,2), -- snapshot баланса на момент расчёта lead_cost_at_calc_rub DECIMAL(10,2), -- snapshot стоимости лида (из тарифа) calculated_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT chk_project_limit_adj_reason CHECK (adjustment_reason IN ( 'balance_low', -- скорректирован вниз из-за нехватки баланса 'balance_recovered', -- восстановлен до target после пополнения 'manual_override', -- админ вручную изменил 'tariff_change', -- сменился тариф → пересчёт 'daily_recalc', -- плановый пересчёт в 00:00 МСК 'project_created', -- первый расчёт для нового проекта 'target_changed' -- клиент изменил daily_limit_target )), CONSTRAINT chk_project_limit_adj_limits_nonneg CHECK (target_limit >= 0 AND effective_limit >= 0), CONSTRAINT chk_project_limit_adj_effective_le_target CHECK (effective_limit <= target_limit OR adjustment_reason = 'manual_override') ); CREATE INDEX idx_project_limit_adj_project_calc ON project_limit_adjustments(project_id, calculated_at DESC); CREATE INDEX idx_project_limit_adj_tenant_calc ON project_limit_adjustments(tenant_id, calculated_at DESC); CREATE INDEX idx_project_limit_adj_balance_low ON project_limit_adjustments(tenant_id, calculated_at DESC) WHERE adjustment_reason = 'balance_low'; COMMENT ON TABLE project_limit_adjustments IS 'Лог автоматических корректировок дневного лимита проекта. Заполняется ' 'сервисом EffectiveLimitCalculator (Laravel) при каждом изменении ' 'projects.effective_daily_limit_today.'; -- ----------------------------------------------------------------------------- -- tenant_status_overrides — переименование настраиваемых статусов (раздел 7.3, 8.5) -- ----------------------------------------------------------------------------- CREATE TABLE tenant_status_overrides ( tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, status_slug VARCHAR(50) NOT NULL REFERENCES lead_statuses(slug), custom_name VARCHAR(100) NOT NULL, PRIMARY KEY (tenant_id, status_slug) ); -- ----------------------------------------------------------------------------- -- Безопасность: recovery codes, sessions, api keys, auth log (раздел 18.4, 22.8) -- ----------------------------------------------------------------------------- CREATE TABLE user_recovery_codes ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, code_hash VARCHAR(255) NOT NULL, -- bcrypt hash used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_recovery_user ON user_recovery_codes(user_id) WHERE used_at IS NULL; CREATE TABLE user_sessions ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) UNIQUE NOT NULL, ip_address INET, user_agent TEXT, geo_country VARCHAR(50), geo_city VARCHAR(100), last_active_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_sessions_user ON user_sessions(user_id, last_active_at DESC); -- expires_at NOT NULL → partial index по WHERE expires_at IS NOT NULL бесполезен; индекс по полю — для cron-очистки expired-сессий. CREATE INDEX idx_sessions_expires ON user_sessions(expires_at); CREATE TABLE api_keys ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id), name VARCHAR(255) NOT NULL, key_hash VARCHAR(255) UNIQUE NOT NULL, -- bcrypt hash key_prefix VARCHAR(10) NOT NULL, -- первые 8 символов для UI scopes JSONB DEFAULT '["read"]', -- ["read","write","delete"] last_used_at TIMESTAMPTZ, last_used_ip INET, -- v8.5 (OPEN-И-17): TTL обязательный, max 365 дней. Бессрочные ключи -- запрещены — security-best-practice. Cron secrets:notify-expiring -- уведомляет владельца за 30 дней до expiry. UI «продлить» ставит +365d -- и пишет в audit. До v8.5 expires_at был NULL-able — миграция backfill'ит -- всем существующим ключам NOW() + 365d при ALTER NOT NULL. expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '365 days'), is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE is_active = TRUE; -- ----------------------------------------------------------------------------- -- outbound_webhook_subscriptions — подписки тенанта на исходящие события (v8.4) -- См. narrative §19.10 (Outbound webhook). Уровень 1 стратегии CRM-интеграций -- (OPEN-И-2). Хеш secret + key_prefix — как в api_keys (раздел 19.3). -- Список событий хранится в JSONB events с whitelist в приложении (deal.created, -- deal.status_changed, deal.manager_changed, deal.commented, deal.tag_added, -- deal.tag_removed, deal.deleted, deal.restored — синхронно с activity_log). -- ----------------------------------------------------------------------------- CREATE TABLE outbound_webhook_subscriptions ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id), -- кто создал name VARCHAR(255) NOT NULL, -- "n8n CRM-копия", "Корпоративный data-pipeline" target_url VARCHAR(2048) NOT NULL, -- только https:// (валидация в приложении) secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash; оригинал показывается 1 раз secret_prefix VARCHAR(10) NOT NULL, -- "whsec_a3f7…" для UI events JSONB NOT NULL, -- ["deal.created","deal.status_changed",…]; DEFAULT '[]' нельзя — CHECK требует ≥1 элемент custom_headers JSONB DEFAULT '{}', -- доп. headers получателю (например, X-Tenant-Auth) is_active BOOLEAN DEFAULT TRUE, paused_at TIMESTAMPTZ, -- если временно остановлена last_delivery_at TIMESTAMPTZ, -- последняя успешная доставка last_failure_at TIMESTAMPTZ, -- последняя неудачная доставка consecutive_failures INT DEFAULT 0, -- сброс при success created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, -- Защита от misuse: не более 10 активных подписок на тенанта (см. §19.10.9). -- Это правило проверяется в приложении (нет красивого SQL-варианта без trigger), -- здесь — мягкая граница CHECK на массив events для самосогласованности. CONSTRAINT chk_outbound_subs_events CHECK (jsonb_typeof(events) = 'array' AND jsonb_array_length(events) > 0) ); CREATE INDEX idx_outbound_subs_tenant_active ON outbound_webhook_subscriptions(tenant_id, is_active) WHERE is_active = TRUE; CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions(secret_prefix); -- ----------------------------------------------------------------------------- -- outbound_webhook_deliveries — журнал попыток доставки (v8.4) -- Retention 90 дней. См. §19.10.6 (retry-логика 7 попыток -- от 30 секунд до 24 часов). -- ----------------------------------------------------------------------------- CREATE TABLE outbound_webhook_deliveries ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, subscription_id BIGINT NOT NULL REFERENCES outbound_webhook_subscriptions(id) ON DELETE CASCADE, delivery_uuid UUID NOT NULL, -- X-Liderra-Delivery, выдаётся 1 раз на событие event VARCHAR(50) NOT NULL, -- deal.status_changed и т. п. payload JSONB NOT NULL, -- тело отправляемого запроса attempt_number SMALLINT NOT NULL DEFAULT 1 CHECK (attempt_number BETWEEN 1 AND 7), status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','success','failed','permanently_failed')), -- Результат HTTP-запроса (NULL пока pending) http_status_code SMALLINT, -- 200..599 или NULL для transport-ошибок response_body TEXT, -- первые 1 КБ тела ответа response_time_ms INT, -- длительность запроса error_message VARCHAR(500), -- DNS error / timeout / SSL error / … -- Временные метки scheduled_at TIMESTAMPTZ NOT NULL, -- когда должна выполниться попытка started_at TIMESTAMPTZ, -- фактическое начало запроса finished_at TIMESTAMPTZ, -- завершение (success/failed) next_retry_at TIMESTAMPTZ, -- если failed и attempt < 7 created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_outbound_deliveries_subscription ON outbound_webhook_deliveries(subscription_id, created_at DESC); CREATE INDEX idx_outbound_deliveries_status_pending ON outbound_webhook_deliveries(status, scheduled_at) WHERE status = 'pending'; CREATE INDEX idx_outbound_deliveries_created ON outbound_webhook_deliveries(created_at DESC); -- ----------------------------------------------------------------------------- -- auth_log — лог входов (раздел 22.8) -- РАСШИРЕНИЕ v8.1: добавлены actor_type и saas_admin_user_id для объединения -- логов входов клиентских пользователей и админов SaaS в одной таблице. -- ----------------------------------------------------------------------------- -- v8.31: партиционирована помесячно по created_at (hole #2). -- PK изменён с (id) на (id, created_at) для совместимости с RANGE-партиционированием PG 16. -- Стартовые партиции создаются миграцией 2026_05_23_000002_partition_audit_tables -- и далее cron'ом partitions:create-months. CREATE TABLE auth_log ( id BIGSERIAL, actor_type VARCHAR(20) NOT NULL DEFAULT 'tenant_user' CHECK (actor_type IN ('tenant_user','saas_admin')), tenant_id BIGINT REFERENCES tenants(id), -- NULL для админов SaaS user_id BIGINT REFERENCES users(id), -- NULL если saas_admin saas_admin_user_id BIGINT REFERENCES saas_admin_users(id), -- NULL если tenant_user email VARCHAR(255), -- если actor не найден event VARCHAR(50) NOT NULL, -- login_success, login_failed, password_reset, ... ip_address INET, user_agent TEXT, failure_reason VARCHAR(100), -- 'invalid_password', 'invalid_2fa', ... -- v8.5 (OPEN-И-15): hash chain для tamper-detection. -- Заполняется триггером trg_audit_chain_hash_auth_log (функция -- audit_chain_hash() — см. секцию «Аудит append-only» внизу файла): -- log_hash = sha256(prev.log_hash || NEW::text). Если кто-то удалит -- среднюю строку или вставит фейковую — пересчёт цепочки покажет -- разрыв. UPDATE/DELETE заблокированы триггером BEFORE. log_hash BYTEA, -- NULL → fill via trigger BEFORE INSERT created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) -- Целостность: actor должен быть ровно одного типа CONSTRAINT chk_auth_log_actor CHECK ( (actor_type = 'tenant_user' AND user_id IS NOT NULL AND saas_admin_user_id IS NULL) OR (actor_type = 'saas_admin' AND saas_admin_user_id IS NOT NULL AND user_id IS NULL) OR (actor_type = 'tenant_user' AND user_id IS NULL AND saas_admin_user_id IS NULL AND email IS NOT NULL) -- Третий вариант: попытка входа с email, но user_id неизвестен (login_failed для неcуществующего email) ), PRIMARY KEY (id, created_at) -- v8.31: composite PK (partition key required) ) PARTITION BY RANGE (created_at); CREATE INDEX idx_auth_log_tenant_user ON auth_log(tenant_id, user_id, created_at DESC); CREATE INDEX idx_auth_log_admin ON auth_log(saas_admin_user_id, created_at DESC) WHERE saas_admin_user_id IS NOT NULL; CREATE INDEX idx_auth_log_ip_failed ON auth_log(ip_address, created_at DESC) WHERE event = 'login_failed'; CREATE INDEX idx_auth_log_email ON auth_log(email, created_at DESC); -- ----------------------------------------------------------------------------- -- push_subscriptions — подписки браузеров на Web Push (раздел 17.5) -- ----------------------------------------------------------------------------- CREATE TABLE push_subscriptions ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ, UNIQUE (user_id, endpoint) ); -- ----------------------------------------------------------------------------- -- comment_templates — шаблоны быстрых комментариев (раздел 15.4) -- ----------------------------------------------------------------------------- CREATE TABLE comment_templates ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT REFERENCES users(id), -- NULL = глобальный для тенанта name VARCHAR(255) NOT NULL, text TEXT NOT NULL, sort_order INT DEFAULT 0, usage_count INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ ); CREATE INDEX idx_templates_tenant_user ON comment_templates(tenant_id, user_id); -- ----------------------------------------------------------------------------- -- deal_tags — теги для сделок (раздел 16.3) -- ----------------------------------------------------------------------------- CREATE TABLE deal_tags ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, color_hex VARCHAR(7) NOT NULL, description TEXT, created_by BIGINT REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE (tenant_id, name) ); -- ----------------------------------------------------------------------------- -- import_log — журнал CSV-импорта (раздел 6.7, опциональный модуль) -- ----------------------------------------------------------------------------- CREATE TABLE import_log ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id), filename VARCHAR(255) NOT NULL, file_path VARCHAR(500), rows_total INT DEFAULT 0, rows_added INT DEFAULT 0, rows_updated INT DEFAULT 0, rows_skipped INT DEFAULT 0, status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending','processing','done','failed')), error_message TEXT, started_at TIMESTAMPTZ DEFAULT NOW(), finished_at TIMESTAMPTZ, -- Sprint 4 (H2): enrichment-колонки для исторической миграции лидов (раздел 6.4) entity_type VARCHAR(20) NOT NULL DEFAULT 'leads' CHECK (entity_type IN ('leads','projects')), source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru', mapping_config JSONB, unknown_statuses_count INT NOT NULL DEFAULT 0, dry_run BOOLEAN NOT NULL DEFAULT FALSE ); -- ----------------------------------------------------------------------------- -- import_unknown_statuses — tenant-level маппинг неизвестных статусов CSV (раздел 6.4) -- Sprint 4 (H1): русский статус из CSV, не найденный в STATUS_RU_TO_SLUG, пишется сюда. -- Wizard (§6.6) проставляет mapped_to_slug; повторный импорт применяет маппинг. -- ----------------------------------------------------------------------------- CREATE TABLE import_unknown_statuses ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL, status_ru VARCHAR(100) NOT NULL, occurrences INT NOT NULL DEFAULT 0, mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug), resolved_at TIMESTAMPTZ, resolved_by BIGINT REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, UNIQUE (tenant_id, status_ru) ); CREATE INDEX idx_import_unknown_statuses_unresolved ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL; -- ============================================================================= -- 5. DEALS — партиционированная по received_at (раздел 7.3) -- ВАЖНО: PRIMARY KEY обязательно включает ключ партиционирования (received_at) -- ============================================================================= CREATE TABLE deals ( id BIGSERIAL, tenant_id BIGINT NOT NULL, source_crm_id BIGINT, -- vid из webhook (NULL для ручных) project_id BIGINT NOT NULL, phone VARCHAR(20) NOT NULL, phones JSONB, -- дополнительные телефоны status VARCHAR(50) NOT NULL DEFAULT 'new', -- slug из lead_statuses contact_name VARCHAR(255), comment TEXT, -- v8.3: поля reminder_text и reminder_at удалены, заменены на таблицу reminders -- (множественные напоминания на сделку, паритет с histories[].type='reminder' -- оригинала — партия 12.2.5 аудита). manager_id BIGINT, -- FK не ставится (партиционированная) -- v8.5 (OPEN-И-25): момент назначения менеджера. Используется cron'ом -- leads:escalate-stale (каждые 30 мин): если NOW() - assigned_at > 4h -- AND status NOT IN ('closed','rejected') → reassign + email + escalated_count++. -- NULL для неназначенных лидов (тогда работает Биз-18 TTFR с received_at). assigned_at TIMESTAMPTZ, escalated_count INT NOT NULL DEFAULT 0, -- v8.5 (Биз-19): антифрод-дедуп по phone. duplicate_of_id указывает на -- master-сделку, дубль НЕ списывает лид с баланса. Окно — 24 ч от -- received_at master'а (проверяется приложением через индекс tenant_id+phone). -- Master помечается duplicate_of_id=NULL, дубли указывают на её id. -- ON DELETE SET NULL — если master удалён, дубли остаются как self-standing. -- БЕЗ FK (deals партиционирована — partition-wise FK не поддерживается). duplicate_of_id BIGINT, -- v8.5 (CTO-14): UTM-метки для когортной аналитики §12.5.5. -- Заполняются из webhook payload (если приходят) или landing page (utm_* -- query params, переданные в форму). Партиционные индексы — ниже. utm_source VARCHAR(100), utm_medium VARCHAR(100), utm_campaign VARCHAR(100), utm_content VARCHAR(100), -- v8.5 (Биз-23): гео-таргетинг сделок. region_code = ISO 3166-2:RU -- (например 'RU-MOW' Москва, 'RU-SPE' СПб) — автоопределяется по prefix -- phone через PhonePrefixService (Минсвязи API offline). NULL если не -- удалось определить. city — свободный текст (приходит из webhook или -- enrichment-сервиса). Используется для filter в §10.3 + аналитики §12. region_code VARCHAR(8), subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно city VARCHAR(100), -- v8.5 (Биз-22): простая lead scoring модель без ML. -- time_in_form_seconds — сколько секунд физлицо заполняло форму -- (приходит в webhook как seconds_to_submit; NULL если поставщик не -- передаёт). lead_score — generated stored = supplier.quality_score * -- (time_in_form / 60), clamp в [0.00, 99.99]. Простая эвристика: дольше -- заполнял → серьёзнее интерес × качество поставщика. -- Расчёт через trigger BEFORE INSERT/UPDATE (см. функцию ниже), не GENERATED -- (нужен JOIN на suppliers, что не разрешено в STORED-выражениях PG). time_in_form_seconds INT, lead_score NUMERIC(5,2), is_test BOOLEAN DEFAULT FALSE, received_at TIMESTAMPTZ NOT NULL, -- ключ партиционирования -- v8.9 (этап 5/5 авто-плана): bulk soft-delete для applyBulkDelete UI-операции. -- Hard-delete на партиционированной таблице сложнее (CASCADE через webhook_dedup_keys), -- soft-delete безопаснее для restore-flow в течение N дней. NULL = живая сделка. deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, PRIMARY KEY (id, received_at), CONSTRAINT chk_deals_lead_score_range CHECK (lead_score IS NULL OR (lead_score >= 0.00 AND lead_score <= 99.99)), CONSTRAINT chk_deals_escalated_count_nonneg CHECK (escalated_count >= 0), -- v8.26: subject_code диапазон субъекта РФ 1..89 (defensive parity с supplier_projects). CONSTRAINT chk_deals_subject_code CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) ) PARTITION BY RANGE (received_at); -- Индексы на родительской таблице наследуются партициями CREATE INDEX ON deals (tenant_id, status); CREATE INDEX ON deals (tenant_id, project_id); CREATE INDEX ON deals (tenant_id, phone); CREATE INDEX ON deals (tenant_id, manager_id); -- v8.3: idx_deals_reminder удалён вместе с полем reminder_at -- v8.6 (CTO-17): UNIQUE INDEX по (tenant_id, source_crm_id) удалён — PostgreSQL запрещает -- UNIQUE на партиционированной таблице без partition key (`received_at`). Идемпотентность -- webhook'ов перенесена в новую таблицу `webhook_dedup_keys` (см. ниже после партиций). -- Здесь оставлен обычный INDEX для скорости lookup'а master-сделок (Биз-19 dedup query). CREATE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL; -- v8.5 индексы: -- (CTO-14) когортная аналитика по UTM. Партиционируется автоматически. CREATE INDEX ON deals (tenant_id, utm_source) WHERE utm_source IS NOT NULL; -- (Биз-23) гео-фильтр в §10.3 + аналитика по регионам. CREATE INDEX ON deals (tenant_id, region_code) WHERE region_code IS NOT NULL; -- (OPEN-И-25) cron leads:escalate-stale — выбирает unclosed deals по assigned_at. CREATE INDEX ON deals (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected'); -- v8.9: фильтр soft-deleted в DealController::index/show/transition. Partial index -- по tenant_id, status WHERE deleted_at IS NULL — самый частый паттерн UI-запросов. CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL; -- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд). -- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026). -- v8.31: переименованы в формат