-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.57 (26.06.2026 — RLS GUC hardening: ВСЕ 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — устранён класс отказов на Managed PG/PgBouncer, когда GUC app.current_tenant_id пуст ('' → 22P02) либо не задан (→ 42704). Инцидент 26.06: вход в портал падал на резолве users (60 ошибок, все на users). 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку «NULLIF(...) IS NULL OR ...» — читаются/пишутся ДО tenant-контекста на auth-роутах без 'tenant' middleware; при ЗАДАННОМ tenant изоляция НЕ меняется (rls-reviewer APPROVE). Структурно БД НЕ меняется — переписаны только USING/WITH CHECK (счётчики таблиц/индексов/RLS=44/функций/триггеров без изменений). Миграция 2026_06_26_153000_rls_nullif_guc_hardening (идемпотентна). Применена на боевой кластер; lead_charges FORCE RLS сохранён.)
-- Базовая версия: v8.56 (26.06.2026 — Путь А, шов C: тело функции audit_block_mutation() пропускает пересчёт hash-цепочки по метке GUC app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО session_replication_role (superuser-only, недоступен в Yandex Managed PG). Append-only сохранён: без метки UPDATE/DELETE аудита запрещён. Структурно БД НЕ меняется — только тело функции (счётчики таблиц/индексов/RLS/функций/триггеров без изменений, функций по-прежнему 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. AuditRebuildChain команда переведена на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe).)
-- Базовая версия: v8.55 (25.06.2026 — Эпик 5 отчёт заливки: +1 таблица supplier_sync_runs (сводка по вечерней заливке проектов поставщику — групп/синк/ручная/отложено/упало + status; SaaS-level без RLS/tenant_id как supplier_csv_reconcile_log, пишет crm_supplier_worker BYPASSRLS, читает SaaS-admin) + 1 явный индекс idx_supplier_sync_runs_created. Миграция 2026_06_25_130000. Структурно +1 regular-таблица + 1 индекс. NB: сводные счётчики несут известный дрейф рантайм-счётчика (ср. сверка 23.06) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.)
-- Базовая версия: v8.54 (25.06.2026 — Эпик 4 online-defer: +1 таблица supplier_deferred_sync (системная очередь отложенных онлайн-правок в окне 18:00→00:00 МСК, project_id PK → projects ON DELETE CASCADE, без RLS/tenant_id — доступ только crm_supplier_worker BYPASSRLS, покрыт blanket-грантом ON ALL TABLES в db/02_grants.sql как supplier_manual_sync_queue). Миграция 2026_06_25_120000. Структурно +1 regular-таблица; явных CREATE INDEX +0 (PK неявный). NB: сводные счётчики таблиц/индексов несут известный дрейф рантайм-счётчика (ср. сверка 23.06 RLS 42→44) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.)
-- Базовая версия: v8.53 (25.06.2026 — canon-sync: тело функции audit_chain_hash() приведено в соответствие с миграцией 2026_05_30_000001_add_advisory_lock_to_audit_chain_hash — добавлен per-partition pg_advisory_xact_lock(lock_key из TG_RELID) против разветвления hash-цепочки при конкурентных INSERT. Канон отставал: миграция уже live (migrate:fresh даёт функцию С блокировкой), но тело schema.sql её не содержало. Структурно БД НЕ меняется (хеш-формула verbatim, прод корректен через миграцию) — синхронизирован только текст канона. Счётчики таблиц/индексов/RLS/функций/триггеров без изменений.)
-- Базовая версия: v8.52 (22.06.2026 — billing-yookassa: +1 колонка saas_transactions.balance_transaction_id (BIGINT nullable, БЕЗ FK — balance_transactions партиционирована) — прослеживаемость онлайн-пополнения оплата→строка ledger. Структурно +1 колонка; счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_22_170000 (guarded ADD COLUMN IF NOT EXISTS). Также seed-флаги billing_yookassa_enabled/billing_receipt_enabled в system_settings (рубильник онлайн-оплаты, дефолт false).)
-- Базовая версия: v8.51 (22.06.2026 — RLS hardening: ENABLE ROW LEVEL SECURITY на tenants + политика tenants_self_isolation (USING id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) — защита-в-глубину; ключ id (не tenant_id); админка (crm_admin_user) и онбординг (pgsql_supplier) под BYPASSRLS не задеты. + project_routing_snapshots.created_at TIMESTAMP→TIMESTAMPTZ (squawk prefer-timestamptz; ALTER TYPE на партиционированной таблице переписывает партиции под ACCESS EXCLUSIVE — катить в окно низкой нагрузки). Счётчики: RLS-политик 41→42; таблицы/индексы/функции/триггеры без изменений. Миграция 2026_06_22_150000)
-- Базовая версия: v8.50 (22.06.2026 — FN-2: корректирующая миграция DEFAULT users.notification_preferences — убран мёртвый ключ "reminder" из реального DB-дефолта (миграция 2026_06_19_130000 v8.45 поправила тело schema.sql, но забыла ALTER COLUMN SET DEFAULT, DB-дефолт оставался с reminder). Тело schema.sql §users БЕЗ изменений (уже корректно с v8.45) — правка только шапки. Метаданные-only ALTER. Счётчики без изменений. Миграция 2026_06_22_120000)
-- Базовая версия: v8.49 (19.06.2026 — G7-B: impersonation_tokens.session_token_hash под машинный ключ ИИ (bcrypt lpimp_, NULL пока ключ не выдан). Структурно: +1 колонка; счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_19_160000 (guarded: ADD COLUMN IF NOT EXISTS))
-- Базовая версия: v8.48 (19.06.2026 — G7-A: таблица support_requests (заявки клиента в техподдержку), RLS tenant_isolation, индекс idx_support_requests_tenant, GRANTs. Миграция 2026_06_19_140000 (guarded). Счётчики: таблиц 76→77 (regular 66→67) / индексов 122→123 / RLS-политик 40→41)
-- Базовая версия: v8.47 (19.06.2026 — Закрыт остаток дрейфа схемы: добавлена project_routing_snapshots (partitioned по snapshot_date, composite PK (snapshot_date, project_id), FK tenant_id→tenants, 2 индекса tenant_date/signal, RLS, GRANT, 2 стартовые партиции m05/m06; миграция 2026_05_27_120000 заguard-нута — DROP POLICY IF EXISTS + IF NOT EXISTS). balance_freeze_log уже присутствовал в schema.sql (Billing v2 Spec C) — не дублирован; миграция 2026_05_24_100000 уже идемпотентна. После этого 0 таблиц БД отсутствуют в schema.sql. Счётчики: таблиц 75→76 (regular 66, partitioned parents 9→10) / партиций 14→16 / индексов 120→122 / RLS-политик 39→40)
-- Базовая версия: v8.46 (19.06.2026 — Sync дрейфа lead-region: слой миграции 2026_05_31_100000 (live на проде с 31.05) добавлен в тело schema.sql, отсутствовал ранее (v8.40 держал DDL только в дельта-миграции). Добавлены таблицы phone_ranges_imports, phone_ranges (+idx_phone_ranges_lookup, GRANT) — regular; lead_region_resolution_log — partitioned-parent (PARTITION BY received_at, +idx_lrrl_lead_id/idx_lrrl_source, +2 стартовые партиции m05/m06, GRANT). +4 колонки supplier_leads (resolved_subject_code/region_source/dadata_qc/phone_operator), +2 колонки deals (phone_operator/region_substituted). Счётчики: таблиц 72→75 (regular 64→66, partitioned parents 8→9) / партиций 12→14 / индексов 117→120. Миграция 2026_05_31_100000 (теперь guard-нута под migrate:fresh))
-- Базовая версия: v8.45 (19.06.2026 — G3: DROP таблицы reminders (фича «Напоминания» снята с продукта). Удалены: таблица reminders, 4 индекса (idx_reminders_due/deal/tenant_user_active/tenant_active), RLS-политика tenant_isolation, COMMENT'ы; ключ "reminder" убран из DEFAULT users.notification_preferences. Счётчики: таблиц 73→72 (regular 65→64) / индексов 121→117 / RLS-политик 40→39. Миграция 2026_06_19_130000_drop_reminders_table)
-- Базовая версия: v8.44 (19.06.2026 — G2-B: users.notification_preferences DEFAULT new_lead.email false→true (почтовый дайджест новых сделок по умолчанию; существующие пользователи дотягиваются UPDATE false→true, только живые). Структурно без изменений — правка дефолтного значения; счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_19_120000, спека docs/superpowers/specs/2026-06-19-g2b-new-lead-email-default-on-design.md)
-- Базовая версия: v8.43 (18.06.2026 — G1/SP2 реквизиты клиента: tenant_requisites (1:1 tenants), RLS tenant isolation, лёгкие поля (тип лица/контакт/телефон) гейтят первый проект; тяжёлые (ИНН/КПП/ОГРН/юр.адрес/банк) nullable. Миграция 2026_06_18_140000, спека docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md)
-- Базовая версия: v8.42 (18.06.2026 — G1/SP1 самозапись клиента: email_verifications +code_hash +failed_attempts (6-значный код подтверждения почты, bcrypt, TTL 15м, 5 попыток) + tenants.status расширен VARCHAR(20)→(30) (латентный дефект: CHECK допускал 21-символьное 'pending_email_confirm', колонка была 20). Счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_18_120000, спека docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md)
-- Базовая версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md)
-- Базовая версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39))
-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
-- Базовая версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
-- Базовая версия: 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()))
-- Метрики: 79 базовых таблиц (69 regular + 10 partitioned parents: deals + supplier_lead_costs + 6 audit + lead_region_resolution_log + project_routing_snapshots) + 16 партиций / 124 индекса (+1 явный: idx_supplier_sync_runs_created) / 44 RLS-политик / 5 функций / 15 триггеров
-- NB (23.06.2026 сверка-чистка перед запуском): RLS-политик исправлено 42→44 — реальное число активных CREATE POLICY в теле == 44 == на боевом liderra.ru (pg_policies). Расхождение было исторический недоучёт в бегущем счётчике шапки (tenant_requisites v8.43 и др. добавлены в тело без правки сводной метрики). Структурно БД не менялась — только корректность числа.
-- Базовая версия: 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 убрана в v8.45 — G3, фича «Напоминания» снята)
--   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).
--   • 36 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN
--     + 1 на tenant_requisites — G1/SP2).
--     Из них 3 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items, tenant_requisites).
--   • 36 защищённых таблиц с 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,
    session_token_hash VARCHAR(255),                       -- bcrypt машинного ключа lpimp_ (NULL пока ключ не выдан; G7-B)
    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 = NULLIF(current_setting('app.current_tenant_id', true), '')::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,          -- внутр. uuid строки; секрет — code_hash
    code_hash       VARCHAR(255),                          -- v8.42: bcrypt 6-значного кода самозаписи (G1/SP1)
    failed_attempts SMALLINT NOT NULL DEFAULT 0,           -- v8.42: лимит 5 неверных вводов
    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(30) DEFAULT 'active'   -- v8.42: 20→30, 'pending_email_confirm' = 21 симв.
                        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)
    -- v8.35 (Billing v2 Spec C): флаг заморозки по балансу.
    -- Не NULL = тенант заморожен (баланс < стоимость дня лидов). PreflightBalanceService.
    frozen_by_balance_at TIMESTAMPTZ NULL
);

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;
-- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
CREATE INDEX tenants_frozen_by_balance_idx  ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
-- v8.51 (RLS hardening): защита-в-глубину на tenants. Ключ — id (не tenant_id).
-- Под crm_app_user видна только своя компания; админка (crm_admin_user) и онбординг
-- (pgsql_supplier) — BYPASSRLS, их не задевает. USING-only (как project_routing_snapshots).
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenants_self_isolation
    ON tenants
    USING (id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);

-- 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()
);


-- -----------------------------------------------------------------------------
-- tenant_requisites (G1/SP2) — реквизиты клиента (1:1), дозаполняются в ЛК.
-- Лёгкие поля (тип, контакт) — гейт первого проекта; «тяжёлые» (ИНН, КПП, ОГРН,
-- р/с) nullable, заполняются на этапе оплаты.
-- -----------------------------------------------------------------------------
CREATE TABLE tenant_requisites (
    id                       BIGSERIAL PRIMARY KEY,
    tenant_id                BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
    subject_type             VARCHAR(20) NOT NULL
                             CHECK (subject_type IN ('individual','sole_proprietor','legal_entity')),
    contact_name             VARCHAR(255) NOT NULL,
    contact_phone            VARCHAR(16)  NOT NULL,        -- нормализованный +7XXXXXXXXXX
    inn                      VARCHAR(12),                  -- 10 (юр) / 12 (ИП), контрольная цифра
    legal_name               VARCHAR(255),
    kpp                      VARCHAR(9),
    ogrn                     VARCHAR(15),
    legal_address            TEXT,
    bank_name                VARCHAR(255),
    bank_bik                 VARCHAR(9),
    bank_account             VARCHAR(20),
    corr_account             VARCHAR(20),
    dadata_raw               JSONB,
    dadata_synced_at         TIMESTAMPTZ,
    requisites_completed_at  TIMESTAMPTZ,                  -- выставлен при заполнении р/с
    created_at               TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at               TIMESTAMPTZ
);

ALTER TABLE tenant_requisites ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_requisites_tenant_isolation
    ON tenant_requisites
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);

GRANT SELECT, INSERT, UPDATE ON tenant_requisites TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_requisites TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE tenant_requisites_id_seq TO crm_app_user, crm_supplier_worker;


-- =============================================================================
-- 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": 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;


-- -----------------------------------------------------------------------------
-- support_requests (G7-A) — заявки клиента в техподдержку.
-- RLS по tenant_id; created_at — DB DEFAULT now(); разбор вручную (письмо + журнал).
-- -----------------------------------------------------------------------------
CREATE TABLE support_requests (
    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,
    contact    VARCHAR(255) NOT NULL,
    message    TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_support_requests_tenant ON support_requests (tenant_id, created_at DESC);
ALTER TABLE support_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY support_requests_tenant_isolation ON support_requests
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
GRANT SELECT, INSERT ON support_requests TO crm_app_user, crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE support_requests_id_seq TO crm_app_user, crm_supplier_worker;
COMMENT ON TABLE support_requests IS 'Заявки клиента в техподдержку (G7-A). RLS по tenant_id; разбор вручную (письмо + журнал).';


-- -----------------------------------------------------------------------------
-- 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.38: anchor для SupplierSnapshotGuard.
    -- is_active=false → paused_at := NOW(); is_active=true → paused_at := NULL.
    -- Используется для расчёта grace-периода до разблокировки удаления/смены источника
    -- (docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md).
    paused_at                       TIMESTAMPTZ,
    -- РАСШИРЕНИЕ 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,
    -- v8.35 (Billing v2 Spec C): флаг точечной блокировки проекта по preflight.
    -- Не NULL = проект заблокирован (не хватает баланса на ближайшие N лидов).
    preflight_blocked_at            TIMESTAMPTZ NULL,
    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);
-- v8.38: индекс для SupplierSnapshotGuard grace-проверки.
CREATE INDEX projects_paused_at_idx ON projects(paused_at);
-- v8.35: частичный индекс для preflight-блокировки (sparse — большинство проектов не заблокированы)
CREATE INDEX projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;

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(8)  NOT NULL,                          -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
    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','DIRECT')),
    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(8) NOT NULL,                           -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
    subject_code            SMALLINT,                                      -- субъект РФ 1..89; NULL = пул «Вся РФ»
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT')),
    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 = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::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_sync_runs — сводка по вечерней заливке проектов поставщику (v8.55, Эпик 5)
-- -----------------------------------------------------------------------------
-- SyncSupplierProjectsJob по завершении пишет одну строку с итогами заливки (групп
-- всего / синхронизировано / в ручную / отложено / упало). SaaS-admin сверяет глазами
-- на экране «Интеграция с поставщиком», что заливка прошла ровно — от этого зависят
-- заказанные у поставщика на завтра лиды. SaaS-level (без RLS/tenant_id, как
-- supplier_csv_reconcile_log); пишет crm_supplier_worker (BYPASSRLS), читает SaaS-admin.
-- Миграция 2026_06_25_130000. GRANT в миграции + blanket ON ALL TABLES (db/02_grants.sql).
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_sync_runs (
    id            BIGSERIAL PRIMARY KEY,
    started_at    TIMESTAMPTZ NOT NULL,
    finished_at   TIMESTAMPTZ,
    groups_total  INTEGER NOT NULL DEFAULT 0,
    synced_ok     INTEGER NOT NULL DEFAULT 0,
    manual_queued INTEGER NOT NULL DEFAULT 0,
    deferred      INTEGER NOT NULL DEFAULT 0,
    failed        INTEGER NOT NULL DEFAULT 0,
    status        VARCHAR(16) NOT NULL DEFAULT 'ok'
                  CHECK (status IN ('ok','partial','failed','aborted')),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_supplier_sync_runs_created ON supplier_sync_runs (created_at DESC);


-- -----------------------------------------------------------------------------
-- 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.


-- -----------------------------------------------------------------------------
-- supplier_deferred_sync — очередь отложенных онлайн-правок (v8.54, Эпик 4)
-- -----------------------------------------------------------------------------
-- Онлайн-режим в окне 18:00→00:00 МСК НЕ шлёт правки поставщику сразу (перезаписал бы
-- зафиксированный слепок 21:00). Проект кладётся сюда; FlushDeferredOnlineSyncJob (00:05 МСК)
-- досылает вне окна. Системная очередь (как supplier_manual_sync_queue) — без RLS/tenant_id,
-- доступ только из supplier-flow джоба под crm_supplier_worker (BYPASSRLS).
-- project_id — PK: естественный дедуп (ON CONFLICT DO NOTHING при повторных правках).
-- Миграция 2026_06_25_120000 (явный GRANT crm_supplier_worker). Прод дополнительно покрыт
-- blanket-грантом ON ALL TABLES в db/02_grants.sql (как supplier_manual_sync_queue, отдельной
-- строки нет). Dev: postgres superuser.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_deferred_sync (
    project_id BIGINT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);


-- -----------------------------------------------------------------------------
-- 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 = вся РФ/неизвестно
    -- v8.40 (lead region resolution): оператор связи (UI-карточка) + флаг подмены
    -- региона каскадом DaData→Россвязь→tag (миграция 2026_05_31_100000).
    phone_operator     TEXT,
    region_substituted BOOLEAN NOT NULL DEFAULT FALSE,
    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;
-- F-P1 (152-ФЗ ретеншен): дешёвая выборка soft-deleted сделок командой
-- pd:scrub-soft-deleted-deals. Partial — только удалённые строки (малое подмножество).
CREATE INDEX        ON deals (deleted_at) WHERE deleted_at IS NOT NULL;

-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
-- v8.31: переименованы в формат <table>_y<YYYY>_m<MM> (совпадает с MonthlyPartitionManager::partitionName()).
CREATE TABLE deals_y2026_m05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE deals_y2026_m06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE deals_y2026_m07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE deals_y2026_m08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE deals_y2026_m09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE deals_y2026_m10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');


-- -----------------------------------------------------------------------------
-- webhook_dedup_keys — идемпотентность webhook'ов от crm.bp-gr.ru
-- v8.6 (CTO-17, 08.05.2026): архитектурная замена UNIQUE INDEX на deals.
-- -----------------------------------------------------------------------------
-- ПРОБЛЕМА: PostgreSQL запрещает UNIQUE на партиционированной таблице без
-- partition key. UNIQUE по (tenant_id, source_crm_id) на deals невозможен,
-- т.к. partition key — received_at. Включить received_at в UNIQUE нельзя —
-- ломает идемпотентность (тот же vid может прийти при retry с разным
-- received_at, и UPSERT перестаёт работать как дедуп).
--
-- РЕШЕНИЕ: отдельная не-партиционированная таблица с PRIMARY KEY
-- (tenant_id, source_crm_id) → ссылка на deal_id. ON CONFLICT работает.
-- Webhook handler (ProcessWebhookJob) выполняет 2-стадийную операцию в одной
-- транзакции:
--   1. INSERT INTO webhook_dedup_keys VALUES (...)
--      ON CONFLICT (tenant_id, source_crm_id) DO UPDATE
--      RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at;
--   2. Если is_new — INSERT в deals; иначе UPDATE deals по (deal_id, deal_received_at).
--
-- ОКНО ДЕДУПЛИКАЦИИ: глобальное (нет TTL). Если crm.bp-gr.ru пришлёт тот же vid
-- через 6 месяцев — это тот же лид (UPDATE существующей сделки). Так задумано:
-- vid в crm.bp-gr.ru монотонно растёт, повторов не бывает.
--
-- ON DELETE CASCADE: если deal удалён, dedup-ключ тоже удаляется (composite FK
-- на (deal_id, deal_received_at)).
-- -----------------------------------------------------------------------------
-- v8.7 (08.05.2026 поздний вечер, CTO-17 addendum): FK DEFERRABLE INITIALLY DEFERRED.
-- ProcessWebhookJob (§5.5) делает INSERT в webhook_dedup_keys ДО INSERT в deals
-- (нужно nextval('deals_id_seq') до создания строки в партиционированной deals).
-- DEFERRED → constraint проверяется на COMMIT, в одной транзакции порядок INSERT'ов
-- (dedup_key → deal) валиден. ON DELETE CASCADE по-прежнему срабатывает immediate
-- на DELETE строки в deals.
CREATE TABLE webhook_dedup_keys (
    tenant_id           BIGINT NOT NULL,
    source_crm_id       BIGINT NOT NULL,
    deal_id             BIGINT NOT NULL,
    deal_received_at    TIMESTAMPTZ NOT NULL,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ,
    PRIMARY KEY (tenant_id, source_crm_id),
    FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at)
        ON DELETE CASCADE
        DEFERRABLE INITIALLY DEFERRED
);

CREATE INDEX idx_webhook_dedup_keys_deal ON webhook_dedup_keys (deal_id, deal_received_at);


-- =============================================================================
-- 6. ЛОГИ И ЖУРНАЛЫ (зависят от deals/users)
-- ВАЖНО: deal_id — без FK на deals (партиционирование). Целостность — на уровне приложения.
-- =============================================================================

-- -----------------------------------------------------------------------------
-- deal_tag_pivot — связь сделок и тегов (раздел 16.3)
-- -----------------------------------------------------------------------------
CREATE TABLE deal_tag_pivot (
    deal_id         BIGINT NOT NULL,                       -- БЕЗ FK (deals партиционирована)
    tag_id          BIGINT NOT NULL REFERENCES deal_tags(id) ON DELETE CASCADE,
    tagged_at       TIMESTAMPTZ DEFAULT NOW(),
    tagged_by       BIGINT REFERENCES users(id),
    PRIMARY KEY (deal_id, tag_id)
);

CREATE INDEX idx_deal_tag_pivot_tag ON deal_tag_pivot(tag_id);


-- -----------------------------------------------------------------------------
-- activity_log — журнал действий по сделкам (раздел 14.4)
-- РЕТЕНШН: 3 года активно, далее в S3 Glacier
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
CREATE TABLE activity_log (
    id              BIGSERIAL,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT REFERENCES users(id),           -- NULL для системных событий
    deal_id         BIGINT NOT NULL,                       -- БЕЗ FK (deals партиционирована)
    event           VARCHAR(100) NOT NULL,                 -- deal.created, deal.status_changed, ...
    old_value       TEXT,
    new_value       TEXT,
    context         JSONB,
    ip_address      INET,
    user_agent      TEXT,
    log_hash        BYTEA,                                 -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),    -- v8.31: NOT NULL (partition key)
    PRIMARY KEY (id, created_at)                           -- v8.31: composite PK
) PARTITION BY RANGE (created_at);

CREATE INDEX idx_activity_tenant_deal_created ON activity_log(tenant_id, deal_id, created_at DESC);
CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id, created_at DESC) WHERE user_id IS NOT NULL;

-- =============================================================================
-- tenant_operations_log — журнал тенант-уровневых операций вне сделок
-- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain.
-- =============================================================================
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
-- RLS и триггеры перенесены в секцию RLS/Triggers (единообразно с другими партиционированными).
CREATE TABLE tenant_operations_log (
    id              BIGSERIAL,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT REFERENCES users(id),           -- NULL для системных
    entity_type     VARCHAR(50) NOT NULL,                  -- 'project', 'api_key', 'webhook_settings'
    entity_id       BIGINT,                                -- NULL если bulk
    event           VARCHAR(100) NOT NULL,                 -- 'project.created', 'api_key.regenerated', ...
    payload_before  JSONB,
    payload_after   JSONB,
    ip_address      INET,
    user_agent      TEXT,
    log_hash        BYTEA,                                 -- hash chain
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),    -- v8.31: NOT NULL (partition key)
    PRIMARY KEY (id, created_at)                           -- v8.31: composite PK
) PARTITION BY RANGE (created_at);

CREATE INDEX idx_tenant_ops_tenant_created
    ON tenant_operations_log(tenant_id, created_at DESC);
CREATE INDEX idx_tenant_ops_entity
    ON tenant_operations_log(tenant_id, entity_type, entity_id, created_at DESC)
    WHERE entity_id IS NOT NULL;

-- -----------------------------------------------------------------------------
-- in_app_notifications (раздел 18.5 v8.10) — bell-icon в UI.
-- На каждого user'а пишется row при триггере события (new_lead/reminder/...),
-- если notification_preferences[event].inapp=true. UI читает unread count
-- через GET /api/notifications?unread=1 и список последних 50.
-- РЕТЕНШН: 90 дней или 1000 записей на user (cleanup-job, отдельная задача).
-- -----------------------------------------------------------------------------
CREATE TABLE in_app_notifications (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    event           VARCHAR(50) NOT NULL,         -- new_lead|reminder|low_balance|zero_balance|topup_success|invoice_paid|new_device_login|marketing
    title           VARCHAR(255) NOT NULL,
    body            TEXT,
    deal_id         BIGINT,                        -- БЕЗ FK (deals партиционирована)
    payload         JSONB DEFAULT '{}'::jsonb,     -- доп. поля для UI: {link, action_label, ...}
    read_at         TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Главный index UI-флоу: «непрочитанные user'а в порядке свежести».
CREATE INDEX idx_in_app_notifications_user_unread
    ON in_app_notifications(user_id, created_at DESC)
    WHERE read_at IS NULL;

-- «Последние 50 уведомлений» (с прочитанными).
CREATE INDEX idx_in_app_notifications_user_recent
    ON in_app_notifications(user_id, created_at DESC);

COMMENT ON TABLE in_app_notifications IS
    'In-app уведомления (раздел 18.5, schema v8.10). Канал inapp в матрице '
    'users.notification_preferences. INSERT при триггере события, UPDATE '
    'read_at при клике. RLS: tenant isolation.';


-- webhook_log удалена в v8.35 (legacy direct webhook removal).
-- Канал входящих webhook (прямой приём от тенантов) упразднён.
-- webhook_dedup_keys сохранена — используется CSV-каналом (HistoricalImportService).


-- -----------------------------------------------------------------------------
-- failed_webhook_jobs — джобы webhook, упавшие после 3 ретраев (упомянуто в 7.1)
-- DDL отсутствует в v8.0, реконструировано
-- -----------------------------------------------------------------------------
CREATE TABLE failed_webhook_jobs (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
    webhook_log_id  BIGINT,                                -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
    raw_payload     JSONB NOT NULL,
    exception       TEXT NOT NULL,
    retry_count     INT DEFAULT 3,
    failed_at       TIMESTAMPTZ DEFAULT NOW(),
    -- Возможность ручного retry из админки
    retried_at      TIMESTAMPTZ,
    retried_by      BIGINT REFERENCES saas_admin_users(id),
    resolved_at     TIMESTAMPTZ
);

CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC) WHERE resolved_at IS NULL;
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id);  -- v8.11 (audit O-perf-02)


-- rejected_deals_log удалена в v8.35 (legacy direct webhook removal).
-- Rejection-журнал для прямого webhook-канала упразднён вместе с каналом.


-- -----------------------------------------------------------------------------
-- supplier_leads — SaaS-level raw-payload входящих webhook'ов (v8.18, Plan 2/5)
-- -----------------------------------------------------------------------------
-- Контекст: см. spec §5.1. Поставщик POST'ит на /api/webhook/supplier/{secret}
-- (общий URL, не per-tenant). На момент INSERT'а tenant ещё не определён —
-- routing происходит в RouteSupplierLeadJob через supplier_project + LeadRouter.
-- Поэтому таблица НЕ tenant-scoped, RLS НЕ применяется (как supplier_projects).
--
-- supplier_project_id заполняется job'ом ПОСЛЕ парсинга `project` из payload
-- (`B1_vashinvestor.ru` → platform=B1, signal_identifier=vashinvestor.ru).
-- ON DELETE SET NULL: при удалении supplier_project не каскадим — сохраняем
-- raw для аудита.
--
-- source: 'webhook' (основной канал §5.1) | 'csv_recovery' (резерв §5.2 —
-- Plan 4 CSV reconciliation). На Plan 2 поддерживаем только 'webhook';
-- enum extensibility — для Plan 4.
--
-- deals_created_count: сколько deal-копий было создано после routing'а
-- (sharing-model: 0..N). Заполняется job'ом в конце обработки. NULL до processed.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_leads (
    id                      BIGSERIAL PRIMARY KEY,
    supplier_project_id     BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
    platform                VARCHAR(8) NOT NULL,                          -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
    raw_payload             JSONB NOT NULL,
    vid                     BIGINT,                                -- nullable: NULL у CSV-recovered лидов (Путь 2)
    phone                   VARCHAR(20) NOT NULL,
    received_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    source                  VARCHAR(16) NOT NULL DEFAULT 'webhook',
    processed_at            TIMESTAMPTZ,
    -- v8.19 (Plan 4 CSV reconcile): NULL для лидов из webhook (основной канал).
    -- Заполняется CsvReconcileJob при восстановлении лида, пропущенного webhook'ом.
    recovered_from_csv_at   TIMESTAMPTZ,
    deals_created_count     INTEGER,
    error                   TEXT,
    -- v8.40 (lead region resolution): денормализованный display + persistent
    -- idempotency для retry резолва региона лида (миграция 2026_05_31_100000).
    resolved_subject_code   SMALLINT
        CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
    region_source           TEXT
        CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
    dadata_qc               SMALLINT,
    phone_operator          TEXT,

    CONSTRAINT chk_supplier_leads_platform
        CHECK (platform IN ('B1','B2','B3','DIRECT')),
    CONSTRAINT chk_supplier_leads_source
        CHECK (source IN ('webhook','csv_recovery')),
    CONSTRAINT chk_supplier_leads_deals_count_nonneg
        CHECK (deals_created_count IS NULL OR deals_created_count >= 0)
);

CREATE INDEX idx_supplier_leads_received_at ON supplier_leads(received_at DESC);
CREATE INDEX idx_supplier_leads_supplier_project ON supplier_leads(supplier_project_id) WHERE supplier_project_id IS NOT NULL;
CREATE UNIQUE INDEX idx_supplier_leads_vid_unique ON supplier_leads(vid);
CREATE INDEX supplier_leads_recovered_from_csv_partial
    ON supplier_leads(recovered_from_csv_at)
    WHERE recovered_from_csv_at IS NOT NULL;

-- v8.18 (Plan 2/5): defense-in-depth — REVOKE ALL FROM crm_app_user.
-- SaaS-level таблица. tenant-приложение читать не должно (raw-payload — наша зона).
-- Conditional wrapper см. в supplier_projects (dev=postgres superuser без crm_app_user).
--
-- REVOKE ALL ON supplier_leads FROM crm_app_user;

-- =============================================================================
-- Lead-region (телефон → регион) — реестр Россвязи + аудит резолва
-- v8.40 (31.05.2026), миграция 2026_05_31_100000. SaaS-level (без RLS):
-- phone_ranges/phone_ranges_imports — публичные данные; lead_region_resolution_log
-- — партиционированный аудит (паттерн activity_log). Порядок: imports → phone_ranges
-- (FK на imports) → lead_region_resolution_log.
-- =============================================================================

-- phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаётся первым)
CREATE TABLE phone_ranges_imports (
    id              BIGSERIAL PRIMARY KEY,
    imported_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    source_url      TEXT NOT NULL,
    rows_inserted   INTEGER NOT NULL DEFAULT 0,
    rows_updated    INTEGER NOT NULL DEFAULT 0,
    checksum_sha256 TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'in_progress'
        CHECK (status IN ('in_progress','completed','failed','rolled_back')),
    error           TEXT,
    completed_at    TIMESTAMPTZ
);
COMMENT ON TABLE phone_ranges_imports IS
    'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';

-- phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS — публичные данные)
CREATE TABLE phone_ranges (
    id                BIGSERIAL PRIMARY KEY,
    def_code          SMALLINT NOT NULL,
    from_num          BIGINT NOT NULL,
    to_num            BIGINT NOT NULL,
    operator          TEXT NOT NULL,
    region            TEXT NOT NULL,
    region_normalized TEXT,
    subject_code      SMALLINT,
    imported_at       TIMESTAMPTZ NOT NULL,
    import_id         BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
    CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
    CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
    CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
    'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.';

GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;

-- lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log)
CREATE TABLE lead_region_resolution_log (
    id                       BIGSERIAL,
    supplier_lead_id         BIGINT NOT NULL,
    received_at              TIMESTAMPTZ NOT NULL,
    phone_masked             TEXT NOT NULL,
    subject_code_resolved    SMALLINT,
    subject_code_from_tag    SMALLINT,
    region_source            TEXT NOT NULL
        CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
    dadata_qc                SMALLINT,
    dadata_provider          TEXT,
    dadata_type              TEXT,
    dadata_response_masked   JSONB,
    rossvyaz_matched         BOOLEAN NOT NULL DEFAULT FALSE,
    actual_subject_code      SMALLINT
        CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
    substituted_subject_code SMALLINT
        CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
    routing_step             SMALLINT
        CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
    phone_operator           TEXT,
    cache_hit                BOOLEAN NOT NULL DEFAULT FALSE,
    duration_ms              INTEGER,
    resolved_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
COMMENT ON TABLE lead_region_resolution_log IS
    'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).';

GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;

-- Стартовые партиции (далее их подхватывает partitions:create-months / MonthlyPartitionManager).
CREATE TABLE lead_region_resolution_log_y2026_m05
    PARTITION OF lead_region_resolution_log
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06
    PARTITION OF lead_region_resolution_log
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');


-- =============================================================================
-- project_routing_snapshots — слепки маршрутизации проекта (Slepok routing Этап 2)
-- v8.39 (27.05.2026), миграция 2026_05_27_120000. Партиционирована по snapshot_date,
-- composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation.
-- NB: НЕ ссылается на projects(id) — проект может быть удалён, а снимок переживает
-- (хвост слепка ещё летит). Партиции помесячно (MonthlyPartitionManager, retention 3m).
-- =============================================================================
CREATE TABLE project_routing_snapshots (
    snapshot_date      DATE        NOT NULL,
    project_id         BIGINT      NOT NULL,
    tenant_id          BIGINT      NOT NULL,
    daily_limit        INT         NOT NULL CHECK (daily_limit >= 0),
    delivery_days_mask INT         NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
    regions            INT[]       NOT NULL DEFAULT '{}',
    signal_type        TEXT        NOT NULL CHECK (signal_type IN ('call','site','sms')),
    signal_identifier  TEXT,
    sms_senders        JSONB,
    sms_keyword        TEXT,
    expected_volume    INT         NOT NULL CHECK (expected_volume >= 0),
    delivered_count    INT         NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
    created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (snapshot_date, project_id),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) PARTITION BY RANGE (snapshot_date);
CREATE INDEX project_routing_snapshots_tenant_date_idx
    ON project_routing_snapshots (tenant_id, snapshot_date);
CREATE INDEX project_routing_snapshots_signal_idx
    ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_routing_snapshots_tenant_isolation
    ON project_routing_snapshots
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;

-- Стартовые партиции (далее их подхватывает partitions:create-months / MonthlyPartitionManager).
CREATE TABLE project_routing_snapshots_y2026_m05
    PARTITION OF project_routing_snapshots
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE project_routing_snapshots_y2026_m06
    PARTITION OF project_routing_snapshots
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');


-- =============================================================================
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
-- разные поставки с одним телефоном остаются отдельными платными лидами.
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
-- при гонках / перезапусках задачи / CSV-восстановлении.
-- =============================================================================
CREATE TABLE supplier_lead_deliveries (
    supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
    tenant_id        BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    deal_id          BIGINT,            -- созданная сделка; без FK (deals партиционирована)
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (supplier_lead_id, tenant_id)
);

ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);

-- Явные GRANT'ы для 4 ролей (mirror webhook_dedup_keys): на prod таблица
-- создаётся crm_supplier_worker, default privileges не наследуются от
-- postgres-superuser на чужие creator-role. DO block — idempotent + dev-safe
-- (на dev ролей нет → silent skip).
DO $$
BEGIN
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
        GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_app_user;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
        GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_admin_user;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
        GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_supplier_worker;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
        GRANT ALL PRIVILEGES ON supplier_lead_deliveries TO crm_migrator;
    END IF;
END $$;


-- =============================================================================
-- 7. БИЛЛИНГ (SAAS-уровень)
-- =============================================================================

-- -----------------------------------------------------------------------------
-- payment_gateways — конфигурация шлюзов (раздел 20.4.2)
-- -----------------------------------------------------------------------------
CREATE TABLE payment_gateways (
    id              BIGSERIAL PRIMARY KEY,
    code            VARCHAR(50) UNIQUE NOT NULL,           -- 'yookassa', 'robokassa', 'tinkoff', ...
    name            VARCHAR(255) NOT NULL,                 -- "ЮKassa (ООО Ромашка)"
    driver          VARCHAR(50) NOT NULL,                  -- класс драйвера в коде
    legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
    config          TEXT NOT NULL,                         -- ШИФРОВАН Crypt::encrypt({"shop_id":"...","secret_key":"..."})
    is_active       BOOLEAN DEFAULT FALSE,
    accepts_methods JSONB DEFAULT '[]',                    -- ["card","yoomoney","qiwi","sbp","bank_transfer"]
    min_amount_rub  DECIMAL(10,2) DEFAULT 100.00,
    max_amount_rub  DECIMAL(12,2),
    sort_order      INT DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ
);


-- -----------------------------------------------------------------------------
-- tariff_subscriptions — подписки тенантов на тарифы (раздел 20.2.3 + CTO-1, CTO-2)
--
-- Жизненный цикл (CTO-1, CTO-2):
--   • Смена тарифа → новая запись 'scheduled', started_at = ближайшие 00:00 МСК.
--   • Cron tariffs:apply-scheduled (каждые 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),
    -- Прослеживаемость: ссылка на строку ledger balance_transactions, созданную
    -- при зачислении (provenance оплата→журнал). БЕЗ FK — balance_transactions
    -- партиционирована (PARTITION BY created_at), внешний ключ к ней невозможен.
    balance_transaction_id  BIGINT,
    -- Статус
    status                  VARCHAR(20) DEFAULT 'pending'
                            CHECK (status IN ('pending','success','failed','refunded')),
    description             TEXT,
    failure_reason          VARCHAR(50),                   -- enum в комментарии выше
    created_at              TIMESTAMPTZ DEFAULT NOW(),
    completed_at            TIMESTAMPTZ
);

CREATE INDEX idx_saas_tx_tenant_created ON saas_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_saas_tx_status         ON saas_transactions(status) WHERE status IN ('pending');
CREATE INDEX idx_saas_tx_gateway        ON saas_transactions(gateway_code, gateway_payment_id);
CREATE UNIQUE INDEX idx_saas_tx_idempotence ON saas_transactions(gateway_idempotence_key) WHERE gateway_idempotence_key IS NOT NULL;
-- CTO-3: для cron payments:cancel-stale (каждые 5-10 минут)
CREATE INDEX idx_saas_tx_pending_stale  ON saas_transactions(created_at) WHERE status = 'pending';

-- Forward FK на saas_transactions
ALTER TABLE saas_invoices       ADD CONSTRAINT fk_saas_invoices_tx     FOREIGN KEY (transaction_id) REFERENCES saas_transactions(id);
ALTER TABLE saas_upd_documents  ADD CONSTRAINT fk_saas_upd_tx          FOREIGN KEY (transaction_id) REFERENCES saas_transactions(id);


-- -----------------------------------------------------------------------------
-- refund_requests — запросы на возврат (раздел 20.6 + Ю-3)
-- РАСШИРЕНИЯ v8.1:
--   • admin_user_id получил FK на saas_admin_users.
--   • source — admin_initiated или external_chargeback (Ю-3). При chargeback
--     запись создаётся автоматически из webhook без участия админа.
-- -----------------------------------------------------------------------------
CREATE TABLE refund_requests (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES tenants(id),
    transaction_id      BIGINT NOT NULL REFERENCES saas_transactions(id),
    requested_amount_rub DECIMAL(12,2),
    reason              TEXT,
    -- Источник возврата (Ю-3)
    source              VARCHAR(20) NOT NULL DEFAULT 'admin_initiated'
                        CHECK (source IN ('admin_initiated','external_chargeback')),
    status              VARCHAR(20) DEFAULT 'pending'
                        CHECK (status IN ('pending','approved','rejected','processed')),
    -- admin_user_id NULL для source='external_chargeback' (автоматическое создание из webhook)
    admin_user_id       BIGINT REFERENCES saas_admin_users(id),
    admin_decision      TEXT,
    gateway_refund_id   VARCHAR(255),
    correction_upd_id   BIGINT REFERENCES saas_upd_documents(id),
    requested_at        TIMESTAMPTZ DEFAULT NOW(),
    decided_at          TIMESTAMPTZ,
    processed_at        TIMESTAMPTZ,
    -- Целостность: для admin_initiated админ обязателен после approved/rejected
    CONSTRAINT chk_refund_admin_decision CHECK (
        (source = 'external_chargeback')
        OR (status = 'pending')
        OR (admin_user_id IS NOT NULL)
    )
);

CREATE INDEX idx_refund_requests_pending     ON refund_requests(status, requested_at) WHERE status = 'pending';
CREATE INDEX idx_refund_requests_chargeback  ON refund_requests(tenant_id, requested_at DESC) WHERE source = 'external_chargeback';


-- -----------------------------------------------------------------------------
-- balance_transactions — внутренний лид-биллинг (раздел 7.3, 21)
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
CREATE TABLE balance_transactions (
    id                      BIGSERIAL,
    tenant_id               BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    type                    VARCHAR(50) NOT NULL
                            CHECK (type IN ('trial_bonus','topup','lead_charge','refund',
                                            'manual_adjustment','historical_import',
                                            'chargeback_writedown',  -- Ю-3: списание непокрытой части chargeback в долг тенанта
                                            'chargeback_repayment',  -- Ю-3: погашение долга через /billing
                                            'migration'              -- Billing v2 Spec A: конвертация balance_leads → balance_rub
                            )),
    amount_rub              DECIMAL(12,2) DEFAULT 0,
    amount_leads            INT DEFAULT 0,
    balance_rub_after       DECIMAL(12,2),
    balance_leads_after     INT,
    description             TEXT,
    related_type            VARCHAR(100),                  -- "App\\Models\\Deal" / "SaasTransaction" / etc
    related_id              BIGINT,
    user_id                 BIGINT REFERENCES users(id),   -- кто инициировал (NULL для системных)
    -- Для manual_adjustment — кто из админов SaaS сделал
    admin_user_id           BIGINT REFERENCES saas_admin_users(id),
    log_hash                BYTEA,                         -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
    PRIMARY KEY (id, created_at)                           -- v8.31: composite PK
) PARTITION BY RANGE (created_at);

CREATE INDEX idx_balance_tenant_created ON balance_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_balance_tenant_type    ON balance_transactions(tenant_id, type);


-- =============================================================================
-- 8. ОТЧЁТЫ
-- =============================================================================

-- -----------------------------------------------------------------------------
-- report_jobs — задания на генерацию отчётов (раздел 13.5)
-- -----------------------------------------------------------------------------
CREATE TABLE report_jobs (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id             BIGINT NOT NULL REFERENCES users(id),
    type                VARCHAR(50) NOT NULL,              -- deals_export, projects_summary, conversion_funnel, ...
    parameters          JSONB,
    status              VARCHAR(20) DEFAULT 'pending'
                        CHECK (status IN ('pending','processing','done','failed')),
    file_path           VARCHAR(500),                       -- s3://bucket/path/file.xlsx
    file_size           BIGINT,
    generation_seconds  INT,
    error_message       TEXT,
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    finished_at         TIMESTAMPTZ,
    expires_at          TIMESTAMPTZ                         -- автоудаление файла
);

CREATE INDEX idx_report_jobs_tenant_user ON report_jobs(tenant_id, user_id, created_at DESC);


-- =============================================================================
-- 8.5. СЕБЕСТОИМОСТЬ И ПОСТАВЩИКИ (Ю-2)
-- =============================================================================
--
-- Реселлерская модель: мы покупаем лиды у crm.bp-gr.ru, перепродаём тенантам.
-- На MVP единая закупочная цена для всех лидов (system_settings.supplier_default_cost_rub).
-- На v2 предусмотрена эволюция к точным ценам по проектам/категориям.
-- =============================================================================

-- -----------------------------------------------------------------------------
-- supplier_lead_costs — себестоимость каждого лида (Ю-2; в v8.2 supplier_id вместо supplier_code)
-- Партиционирована по received_at синхронно с deals.
-- В ProcessWebhookJob создаётся в той же транзакции, что и deals + balance_transactions.
-- -----------------------------------------------------------------------------
-- ИЗМЕНЕНИЯ v8.2: supplier_code (VARCHAR DEFAULT 'crm_bp_gr') заменён на
-- supplier_id (BIGINT FK → suppliers.id). cost_rub теперь snapshot из
-- suppliers.cost_rub конкретного поставщика (B1/B2/B3), а не из
-- system_settings.supplier_default_cost_rub.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_lead_costs (
    id                  BIGSERIAL,
    deal_id             BIGINT NOT NULL,                   -- БЕЗ FK (deals партиционирована)
    received_at         TIMESTAMPTZ NOT NULL,              -- ключ партиционирования (= deals.received_at)
    supplier_id         BIGINT NOT NULL REFERENCES suppliers(id),
    cost_rub            DECIMAL(10,2) NOT NULL,            -- snapshot suppliers.cost_rub на момент приёма
    supplier_lead_id    BIGINT,                            -- vid из webhook crm.bp-gr.ru
    supplier_invoice_id BIGINT,                            -- FK добавлен ниже после supplier_invoices
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);

-- Индексы на родительской таблице наследуются партициями
CREATE INDEX ON supplier_lead_costs (deal_id, received_at);
CREATE INDEX ON supplier_lead_costs (supplier_invoice_id) WHERE supplier_invoice_id IS NOT NULL;
CREATE INDEX ON supplier_lead_costs (supplier_id, received_at DESC);     -- v8.2: аналитика "лиды по поставщикам"

-- Партиции синхронно с deals (создаются cron на 2 месяца вперёд)
-- v8.31: переименованы в формат <table>_y<YYYY>_m<MM>.
CREATE TABLE supplier_lead_costs_y2026_m05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE supplier_lead_costs_y2026_m06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE supplier_lead_costs_y2026_m07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE supplier_lead_costs_y2026_m08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE supplier_lead_costs_y2026_m09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE supplier_lead_costs_y2026_m10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');


-- -----------------------------------------------------------------------------
-- supplier_invoices — счета от crm.bp-gr.ru к нам (Ю-2; в v8.2 supplier_id)
-- Заполняется finance вручную при получении счёта от поставщика.
-- Используется для:
--   - сверки: SUM(supplier_lead_costs.cost_rub) за период vs amount_rub счёта;
--   - выявления расхождений (отображается в discrepancy_rub);
--   - бухгалтерского следа;
--   - отчёта "расходы по поставщику".
-- ИЗМЕНЕНИЯ v8.2: supplier_code → supplier_id (FK на suppliers).
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_invoices (
    id                      BIGSERIAL PRIMARY KEY,
    supplier_id             BIGINT NOT NULL REFERENCES suppliers(id),
    invoice_number          VARCHAR(100),
    period_from             DATE NOT NULL,
    period_to               DATE NOT NULL,
    leads_count             INT,                            -- по счёту от поставщика
    leads_count_actual      INT,                            -- по нашим данным (заполняется при сверке)
    amount_rub              DECIMAL(12,2),                  -- по счёту
    amount_rub_actual       DECIMAL(12,2),                  -- по нашим данным
    discrepancy_rub         DECIMAL(12,2)
                            GENERATED ALWAYS AS (COALESCE(amount_rub,0) - COALESCE(amount_rub_actual,0)) STORED,
    pdf_path                VARCHAR(500),
    paid_at                 TIMESTAMPTZ,
    paid_by                 BIGINT REFERENCES saas_admin_users(id),
    notes                   TEXT,
    status                  VARCHAR(20) DEFAULT 'received'
                            CHECK (status IN ('received','reconciled','paid','disputed')),
    reconciled_by           BIGINT REFERENCES saas_admin_users(id),
    reconciled_at           TIMESTAMPTZ,
    created_at              TIMESTAMPTZ DEFAULT NOW(),
    updated_at              TIMESTAMPTZ,
    CONSTRAINT chk_supplier_invoices_period CHECK (period_to >= period_from)
);

CREATE INDEX idx_supplier_invoices_period   ON supplier_invoices(period_from, period_to);
CREATE INDEX idx_supplier_invoices_unpaid   ON supplier_invoices(status) WHERE status IN ('received','reconciled');
CREATE INDEX idx_supplier_invoices_supplier ON supplier_invoices(supplier_id);  -- v8.2

-- Forward FK
ALTER TABLE supplier_lead_costs
    ADD CONSTRAINT fk_supplier_lead_costs_invoice
    FOREIGN KEY (supplier_invoice_id) REFERENCES supplier_invoices(id);


-- =============================================================================
-- 9. 152-ФЗ
-- =============================================================================

-- -----------------------------------------------------------------------------
-- tenant_consents — согласия на обработку ПДн (раздел 22.9.1)
-- -----------------------------------------------------------------------------
CREATE TABLE tenant_consents (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    consent_type    VARCHAR(50) NOT NULL,                  -- pd_processing, marketing, oferta_v1, ...
    document_version VARCHAR(20),                           -- "v2.1"
    given_at        TIMESTAMPTZ DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT,
    revoked_at      TIMESTAMPTZ
);

CREATE INDEX idx_consents_tenant ON tenant_consents(tenant_id, consent_type);


-- -----------------------------------------------------------------------------
-- pd_processing_log — журнал обработки ПДн (раздел 22.9.3)
-- РАСШИРЕНИЕ v8.1: разделение actor_user_id на два поля с FK
-- -----------------------------------------------------------------------------
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
CREATE TABLE pd_processing_log (
    id                      BIGSERIAL,
    tenant_id               BIGINT REFERENCES tenants(id),
    subject_type            VARCHAR(50),                   -- 'user', 'lead'
    subject_id              BIGINT,
    action                  VARCHAR(50),                   -- 'created', 'viewed', 'updated', 'deleted', 'exported'
    purpose                 VARCHAR(255),                  -- 'lead_processing', 'analytics', 'support_request'
    -- Актор: ровно один из двух
    actor_tenant_user_id    BIGINT REFERENCES users(id),
    actor_admin_user_id     BIGINT REFERENCES saas_admin_users(id),
    ip_address              INET,
    log_hash                BYTEA,                         -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
    CONSTRAINT chk_pd_actor CHECK (
        (actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL)
        OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NOT NULL)
        OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NULL)  -- системное действие
    ),
    PRIMARY KEY (id, created_at)                           -- v8.31: composite PK
) PARTITION BY RANGE (created_at);

CREATE INDEX idx_pd_log_tenant      ON pd_processing_log(tenant_id, created_at DESC);
CREATE INDEX idx_pd_log_admin_actor ON pd_processing_log(actor_admin_user_id, created_at DESC) WHERE actor_admin_user_id IS NOT NULL;


-- -----------------------------------------------------------------------------
-- pd_subject_requests — обращения субъектов ПДн (НОВАЯ в v8.1, расширена в v8.2)
-- Срок ответа по 152-ФЗ — 30 дней
-- -----------------------------------------------------------------------------
-- РАСШИРЕНИЕ v8.2 (OPEN-Д-1, ст.21 ч.5 152-ФЗ):
--   processing_restricted BOOLEAN — субъект ПДн вправе требовать "прекращения
--   обработки" (не путать с удалением: данные сохраняются, но запрещаются любые
--   операции, кроме хранения). При TRUE сервисы при чтении/мутации ПДн
--   субъекта поднимают ProcessingRestrictedException (App\Services\Pd\ProcessingRestrictionGuard).
--
-- НЕ tenant-уровневая (saas-уровневая). RLS не применяется намеренно:
-- обращение от субъекта ПДн поступает ДО идентификации тенанта (субъект может
-- не знать, в каком тенанте лежат его данные; tenant_id — nullable, заполняется
-- compliance-админом по результатам поиска). Доступ — только из админки SaaS
-- под crm_admin_user (BYPASSRLS), tenant-приложение к этой таблице не
-- обращается. Связь с тенантом фиксируется через nullable tenant_id для
-- маршрутизации запросов на удаление/исправление.
-- -----------------------------------------------------------------------------
CREATE TABLE pd_subject_requests (
    id                      BIGSERIAL PRIMARY KEY,
    received_at             TIMESTAMPTZ DEFAULT NOW(),
    subject_email           VARCHAR(255),
    subject_phone           VARCHAR(20),
    subject_full_name       VARCHAR(255),
    request_type            VARCHAR(30) NOT NULL
                            CHECK (request_type IN ('access','rectification','deletion','objection')),
    description             TEXT,
    status                  VARCHAR(20) DEFAULT 'received'
                            CHECK (status IN ('received','in_progress','completed','rejected')),
    -- Связь с тенантом (если запрос относится к лиду в конкретном тенанте)
    tenant_id               BIGINT REFERENCES tenants(id),
    -- Обработка
    assigned_admin_id       BIGINT REFERENCES saas_admin_users(id),
    response_sent_at        TIMESTAMPTZ,
    response_text           TEXT,
    -- 30 дней по 152-ФЗ. v8.6 (CTO-17): GENERATED ALWAYS AS отклонён PostgreSQL 16
    -- (`+ INTERVAL '30 days'` не immutable — зависит от timezone/DST). Заполняется
    -- триггером `trg_pd_subject_requests_deadline` (см. функцию ниже).
    deadline_at             TIMESTAMPTZ NOT NULL,
    completed_at            TIMESTAMPTZ,
    -- РАСШИРЕНИЕ v8.2: ст.21 ч.5 152-ФЗ
    processing_restricted   BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE INDEX idx_pd_requests_status     ON pd_subject_requests(status, deadline_at) WHERE status IN ('received','in_progress');
CREATE INDEX idx_pd_requests_assigned   ON pd_subject_requests(assigned_admin_id)   WHERE status IN ('received','in_progress');
CREATE INDEX idx_pd_requests_restricted ON pd_subject_requests(processing_restricted) WHERE processing_restricted = TRUE;

COMMENT ON COLUMN pd_subject_requests.processing_restricted IS
    'Реализация ст.21 ч.5 152-ФЗ. При TRUE все операции с ПДн субъекта '
    'блокируются на уровне сервиса (ProcessingRestrictedException). '
    'Сохраняется только право на хранение и удаление. Снимается только '
    'compliance-админом по обоснованному запросу.';


-- -----------------------------------------------------------------------------
-- incidents_log — журнал инцидентов SaaS (НОВАЯ в v8.2, OPEN-Д-5 / OPEN-И-1)
-- -----------------------------------------------------------------------------
-- Контекст: журнал инцидентов SaaS-уровня — недоступность, утечка, инцидент
-- безопасности, сбой биллинга и т.п. Заполняется on-call дежурным из админки
-- SaaS (Прил. И v8.2 раздел 6).
--
-- Связь с pd_subject_requests: при инциденте утечки ПДн в incidents_log
-- сохраняется массив ID связанных обращений субъектов
-- (related_pd_subject_request_ids).
--
-- НЕ tenant-уровневая (наши SaaS-инциденты, не клиентские).
-- RLS не применяется. Доступ — только под crm_admin_user.
-- -----------------------------------------------------------------------------
CREATE TABLE incidents_log (
    id                              BIGSERIAL PRIMARY KEY,
    type                            VARCHAR(50) NOT NULL
                                    CHECK (type IN (
                                        'service_outage',          -- недоступность сервиса
                                        'data_breach',             -- утечка данных
                                        'security_incident',       -- иной инцидент безопасности
                                        'billing_failure',         -- сбой платёжного шлюза
                                        'data_corruption',         -- порча данных
                                        'integration_failure',     -- сбой внешней интеграции
                                        'performance_degradation', -- деградация производительности
                                        'other'
                                    )),
    severity                        VARCHAR(20) NOT NULL
                                    CHECK (severity IN ('low','medium','high','critical')),
    started_at                      TIMESTAMPTZ NOT NULL,
    detected_at                     TIMESTAMPTZ NOT NULL,
    resolved_at                     TIMESTAMPTZ,
    summary                         TEXT NOT NULL,                  -- краткое описание (1-2 предложения)
    root_cause                      TEXT,                           -- заполняется после postmortem
    postmortem_url                  VARCHAR(500),                   -- ссылка на полный документ
    related_pd_subject_request_ids  BIGINT[],                       -- если инцидент связан с обращениями субъектов
    affected_tenant_ids             BIGINT[],                       -- какие тенанты затронуты
    affected_users_count            INT,                            -- сколько пользователей затронуто (примерно)
    notification_sent_at            TIMESTAMPTZ,                    -- когда отправили уведомление пострадавшим
    rkn_notified_at                 TIMESTAMPTZ,                    -- если data_breach — когда уведомили РКН (152-ФЗ ст.18.1 ч.3.1)
    created_by_admin_id             BIGINT NOT NULL REFERENCES saas_admin_users(id),
    closed_by_admin_id              BIGINT REFERENCES saas_admin_users(id),
    created_at                      TIMESTAMPTZ DEFAULT NOW(),
    updated_at                      TIMESTAMPTZ,
    CONSTRAINT chk_incidents_resolved_after_start
        CHECK (resolved_at IS NULL OR resolved_at >= started_at),
    CONSTRAINT chk_incidents_detected_after_start
        CHECK (detected_at >= started_at)
);

CREATE INDEX idx_incidents_started ON incidents_log(started_at DESC);
CREATE INDEX idx_incidents_severity_unresolved
    ON incidents_log(severity)
    WHERE resolved_at IS NULL;
CREATE INDEX idx_incidents_type        ON incidents_log(type, started_at DESC);
CREATE INDEX idx_incidents_data_breach ON incidents_log(rkn_notified_at) WHERE type = 'data_breach';

COMMENT ON TABLE incidents_log IS
    'Журнал инцидентов SaaS-уровня. Заполняется on-call дежурным из админки '
    'SaaS (Прил. И v8.2 раздел 6). Не tenant-уровневый, RLS не применяется. '
    'Связан с pd_subject_requests через related_pd_subject_request_ids '
    'для случаев утечки ПДн.';

COMMENT ON COLUMN incidents_log.rkn_notified_at IS
    'Для type=data_breach. Срок уведомления РКН — 24 часа с момента '
    'установления факта утечки (152-ФЗ ст.18.1 ч.3.1 в редакции 02.07.2021).';


-- =============================================================================
-- 10. ПУЛЬС ПЛАНИРОВЩИКА (SCHEDULER HEARTBEAT) — SaaS-level (hole #6)
-- Без RLS — системная таблица SaaS уровня.
-- Одна строка на каждую cron-задачу (PK = command_name).
-- -----------------------------------------------------------------------------
CREATE TABLE scheduler_heartbeats (
    command_name            VARCHAR(200) NOT NULL PRIMARY KEY,
    last_run_at             TIMESTAMPTZ,
    last_success_at         TIMESTAMPTZ,
    last_error              TEXT,
    runtime_ms              INT,
    consecutive_failures    INT NOT NULL DEFAULT 0,
    created_at              TIMESTAMPTZ DEFAULT NOW(),
    updated_at              TIMESTAMPTZ DEFAULT NOW()
);

COMMENT ON TABLE scheduler_heartbeats IS
    'Пульс планировщика: одна строка на cron-задачу, обновляется при каждом запуске. '
    'SaaS-level, без RLS. Используется SchedulerCheckHeartbeats для детекции '
    'пропавших или постоянно падающих задач (hole #6).';

-- =============================================================================
-- 11. АДМИНКА SAAS — ЖУРНАЛ ДЕЙСТВИЙ (НОВАЯ)
-- saas_admin_users уже создана выше (нужна была для FK от других таблиц)
-- =============================================================================

-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
-- Без RLS: доступна только crm_admin_user (BYPASSRLS).
CREATE TABLE saas_admin_audit_log (
    id                  BIGSERIAL,
    admin_user_id       BIGINT NOT NULL REFERENCES saas_admin_users(id),
    action              VARCHAR(100) NOT NULL,             -- 'tenant.suspend', 'refund.approve', 'system_settings.update', ...
    target_type         VARCHAR(50),                       -- 'tenant', 'saas_transaction', 'system_setting', ...
    target_id           BIGINT,
    target_tenant_id    BIGINT REFERENCES tenants(id),     -- денормализация для быстрого фильтра
    payload_before      JSONB,
    payload_after       JSONB,
    reason              TEXT,                              -- обязательное основание для критичных действий
    ip_address          INET NOT NULL,
    user_agent          TEXT,
    -- Four-eyes: для операций > порога нужно подтверждение второго админа
    requires_approval   BOOLEAN DEFAULT FALSE,
    approved_by         BIGINT REFERENCES saas_admin_users(id),
    approved_at         TIMESTAMPTZ,
    log_hash            BYTEA,                             -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
    PRIMARY KEY (id, created_at)                           -- v8.31: composite PK
) PARTITION BY RANGE (created_at);

CREATE INDEX idx_admin_audit_admin   ON saas_admin_audit_log(admin_user_id, created_at DESC);
CREATE INDEX idx_admin_audit_tenant  ON saas_admin_audit_log(target_tenant_id, created_at DESC) WHERE target_tenant_id IS NOT NULL;
CREATE INDEX idx_admin_audit_action  ON saas_admin_audit_log(action, created_at DESC);
CREATE INDEX idx_admin_audit_pending ON saas_admin_audit_log(approved_at) WHERE requires_approval = TRUE AND approved_at IS NULL;


-- =============================================================================
-- 11. ЗАПОЛНЕНИЕ СПРАВОЧНИКОВ
-- =============================================================================

-- 5 статусов воронки (редизайн «Сделки» 2026-05-17 — было 14)
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
    ('new',         'Новая сделка',     TRUE, 1, '#3B82F6'),
    ('viewed',      'Просмотрено',      TRUE, 2, '#8B5CF6'),
    ('in_progress', 'В работе',         TRUE, 3, '#06B6D4'),
    ('won',         'Сделка',           TRUE, 4, '#10B981'),
    ('lost',        'Не реализовано',   TRUE, 5, '#6B7280');


-- НОВОЕ в v8.2: каталог поставщиков B1/B2/B3
-- Раскрыты в партии 10.2 аудита crm.bp-gr.ru от 04.05.2026.
-- Прил. М §3.1 — обоснование модели.
-- Seed B1/B2/B3 — три суб-поставщика crm.bp-gr.ru, выявленные в партии 10.2 аудита.
-- v8.3: добавлены поля capabilities (channel, supports_*) — партия 13.3.5 аудита.
-- Цены 1.00 ₽ — placeholders, фактические значения берутся из договора с поставщиком
-- (ждём от заказчика по Б-1).
INSERT INTO suppliers (code, name, description, accepts_types, cost_rub, settings_schema,
                       channel, supports_sender_name, supports_keyword,
                       supports_csv_upload, supports_domains_list, sort_order)
VALUES
    ('b1', 'B1 — Сайты и Звонки',
        'Основной поставщик для проектов типов «Сайты» и «Звонки». Принимает домены и телефонные номера через ingress-API.',
        ARRAY['websites','calls'],
        1.00,
        '{"type":"object","properties":{},"additionalProperties":false}'::jsonb,
        'sites',         -- channel
        FALSE,           -- supports_sender_name
        FALSE,           -- supports_keyword
        TRUE,            -- supports_csv_upload (партия 13.3: загрузка CSV-доменов)
        TRUE,            -- supports_domains_list (textarea со списком доменов)
        1),
    ('b2', 'B2 — СМС с ключевым словом',
        'Поставщик СМС-лидов, работающий по связке «Наименование отправителя + Ключевое слово».',
        ARRAY['sms'],
        1.00,
        '{"type":"object","required":["sender_name","keyword"],"properties":{"sender_name":{"type":"string","maxLength":11},"keyword":{"type":"string","maxLength":50}}}'::jsonb,
        'sms',           -- channel
        TRUE,            -- supports_sender_name
        TRUE,            -- supports_keyword (только B2 — партия 13.3.5)
        FALSE,           -- supports_csv_upload (для SMS не применимо)
        FALSE,           -- supports_domains_list (для SMS не применимо)
        2),
    ('b3', 'B3 — СМС по наименованию',
        'Поставщик СМС-лидов, работающий только по «Наименованию отправителя» (без ключевого слова).',
        ARRAY['sms'],
        1.00,
        '{"type":"object","required":["sender_name"],"properties":{"sender_name":{"type":"string","maxLength":11}}}'::jsonb,
        'sms',           -- channel
        TRUE,            -- supports_sender_name
        FALSE,           -- supports_keyword (B3 не поддерживает — партия 13.3.5)
        FALSE,           -- supports_csv_upload
        FALSE,           -- supports_domains_list
        3);


-- Глобальные настройки SaaS (раздел 3.4 + расширения v8.1 + v8.2 + v8.3)
INSERT INTO system_settings (key, value, type, description) VALUES
    ('schema_version',                '8.3', 'string', 'Текущая версия схемы БД'),
    ('trial_bonus_leads',             '50',  'int',    'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'),
    -- low_balance_threshold_leads удалён в v8.35 (использовался только LowBalanceNotification — удалена вместе с webhook-каналом)
    ('inactive_warn_months',          '11',  'int',    'Через сколько месяцев простоя слать предупреждение'),
    ('inactive_delete_months',        '12',  'int',    'Через сколько месяцев простоя удалять данные'),
    ('webhook_rate_limit_rps',        '100', 'int',    'Лимит запросов в секунду на токен Webhook'),
    ('webhook_hmac_required',         'false', 'bool', 'Обязательная HMAC-подпись X-Webhook-Signature: sha256=<hex(hmac(body,token))>. true → запросы без подписи 401. false → подпись опциональна, но если пришла — проверяется (backward-compat).'),
    ('api_rate_limit_per_minute',     '60',  'int',    'Лимит запросов API на ключ в минуту'),
    ('login_max_attempts',            '5',   'int',    'Макс. неудачных попыток входа в окне 15 минут'),
    ('password_min_length',           '10',  'int',    'Минимальная длина пароля'),
    -- webhook_log_retention_days удалён в v8.35 (webhook_log таблица удалена)
    -- VAPID (Web Push, раздел 17.4)
    ('vapid_public_key',              '',    'string', 'VAPID public key (для подписки)'),
    ('vapid_private_key',             '',    'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'),
    ('vapid_subject',                 'mailto:admin@example.com', 'string', 'VAPID subject'),
    -- Лимиты админки (АДМ §3, ДЕФ-3, ДЕФ-4)
    ('admin_support_topup_limit_rub_24h',  '1000', 'decimal', 'Лимит ручного начисления для роли support за 24ч (₽)'),
    ('admin_support_topup_limit_leads_24h','10',   'int',     'Лимит ручного начисления для роли support за 24ч (лидов)'),
    ('admin_four_eyes_threshold_rub',      '50000','decimal', 'Порог суммы, выше которого требуется подтверждение второго админа'),
    -- CTO-3: таймауты pending-транзакций
    ('payments_pending_soft_timeout_min',  '30',   'int',     'Soft-таймаут pending транзакции, после которого cron делает polling шлюза (минут)'),
    ('payments_pending_hard_timeout_hours','24',   'int',     'Hard-таймаут pending транзакции, после которого failed независимо от ответа шлюза (часов)'),
    -- Ю-2: реселлерская модель (supplier_default_cost_rub оставлен как fallback для legacy-кода)
    ('supplier_default_cost_rub',          '1.00', 'decimal', 'Закупочная цена одного лида (fallback). Новый код читает suppliers.cost_rub.'),
    -- v8.2: динамические лимиты проектов (партия 10.6 аудита)
    ('limits_recalc_cron_enabled',         'true', 'bool',    'Включён ли cron limits:recalc для автокоррекции effective_daily_limit_today'),
    ('limits_recalc_minute_offset',        '0',    'int',     'Смещение запуска cron limits:recalc от 00:00 МСК (минут). 0 = ровно в полночь.'),
    ('limits_balance_low_log_enabled',     'true', 'bool',    'Логировать ли в project_limit_adjustments каждое изменение лимита по balance_low'),
    -- v8.3: cron projects:purge-deleted (Биз-14, партия 13.2.3 аудита)
    -- Оригинал хранит soft-deleted проекты ≥3 месяцев, точное TTL не определено.
    -- У нас дефолт 6 месяцев + cron отключён (включается админом SaaS вручную после
    -- согласования с юристом по 152-ФЗ ст.5 п.7 «минимизация хранения»).
    ('projects_purge_deleted_enabled',     'false', 'bool',   'Включён ли cron физического удаления soft-deleted проектов после TTL'),
    ('projects_purge_deleted_ttl_days',    '180',   'int',    'TTL для физического удаления soft-deleted проектов (дней). 180 = 6 месяцев.'),
    ('projects_purge_deleted_cron',        '0 4 * * *', 'string', 'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)'),
    -- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
    ('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
    ('supplier_ip_allowlist',   '[]',                'json',   'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
    -- v8.31: retention для audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
    -- webhook_log_retention_months удалён в v8.35 (webhook_log таблица удалена).
    ('auth_log_retention_months',              '24', 'int', 'Retention auth_log в месяцах (hole #2)'),
    ('activity_log_retention_months',          '36', 'int', 'Retention activity_log (hole #2)'),
    ('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'),
    ('balance_transactions_retention_months',  '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'),
    ('pd_processing_log_retention_months',     '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'),
    ('saas_admin_audit_log_retention_months',  '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)'),
    -- v8.49 (billing-yookassa): рубильники онлайн-оплаты
    ('billing_yookassa_enabled', 'false', 'bool', 'Рубильник онлайн-оплаты ЮKassa: false=мгновенная заглушка, true=реальный шлюз'),
    ('billing_receipt_enabled',  'false', 'bool', 'Передавать чек 54-ФЗ в ЮKassa (включать после подключения ОФД)');


-- 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 = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)

ALTER TABLE users                    ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects                 ENABLE ROW LEVEL SECURITY;
ALTER TABLE deals                    ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_status_overrides  ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_custom_domains    ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_recovery_codes      ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions            ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_verifications      ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_keys                 ENABLE ROW LEVEL SECURITY;
ALTER TABLE push_subscriptions       ENABLE ROW LEVEL SECURITY;
ALTER TABLE comment_templates        ENABLE ROW LEVEL SECURITY;
ALTER TABLE deal_tags                ENABLE ROW LEVEL SECURITY;
ALTER TABLE import_log               ENABLE ROW LEVEL SECURITY;
ALTER TABLE import_unknown_statuses  ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_log             ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_operations_log    ENABLE ROW LEVEL SECURITY;  -- v8.31: перенесено сюда (была inline)
-- webhook_log / rejected_deals_log: таблицы удалены в v8.35
ALTER TABLE failed_webhook_jobs      ENABLE ROW LEVEL SECURITY;
ALTER TABLE tariff_subscriptions     ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_invoices            ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_invoice_items       ENABLE ROW LEVEL SECURITY;  -- через invoice_id косвенно (см. политику ниже)
ALTER TABLE saas_upd_documents       ENABLE ROW LEVEL SECURITY;
ALTER TABLE saas_transactions        ENABLE ROW LEVEL SECURITY;
ALTER TABLE refund_requests          ENABLE ROW LEVEL SECURITY;
ALTER TABLE balance_transactions     ENABLE ROW LEVEL SECURITY;
ALTER TABLE report_jobs              ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_consents          ENABLE ROW LEVEL SECURITY;
ALTER TABLE pd_processing_log        ENABLE ROW LEVEL SECURITY;
ALTER TABLE project_suppliers        ENABLE ROW LEVEL SECURITY;  -- v8.2
ALTER TABLE project_limit_adjustments ENABLE ROW LEVEL SECURITY;  -- v8.2 (Прил. М §3.2)
-- auth_log: специальный случай, политика ниже (для tenant_user строк)
ALTER TABLE auth_log                 ENABLE ROW LEVEL SECURITY;
-- v8.4: outbound webhook (раздел 19.10)
ALTER TABLE outbound_webhook_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE outbound_webhook_deliveries    ENABLE ROW LEVEL SECURITY;
-- v8.4 hotfix: deal_tag_pivot — link между deals (partitioned, RLS есть) и deal_tags (RLS через tenant_id).
-- В самой pivot нет tenant_id, поэтому фильтр через JOIN на deal_tags (tag_id).
ALTER TABLE deal_tag_pivot                 ENABLE ROW LEVEL SECURITY;
ALTER TABLE project_user_assignments       ENABLE ROW LEVEL SECURITY;  -- v8.5 (CTO-16)
ALTER TABLE webhook_dedup_keys             ENABLE ROW LEVEL SECURITY;  -- v8.6 (CTO-17)
ALTER TABLE in_app_notifications           ENABLE ROW LEVEL SECURITY;  -- v8.10 (P0 этап 2)

-- v8.57 (26.06.2026): все политики приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint —
-- защита от 22P02 (GUC='') и 42704 (GUC не задан) на Managed PG под PgBouncer (инцидент входа 26.06).
-- Bootstrap-таблицы (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно
-- получают "NULLIF(...) IS NULL OR ..." — они читаются/пишутся ДО tenant-контекста (auth-роуты без 'tenant'
-- middleware). При ЗАДАННОМ tenant изоляция не меняется. Подробности — db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql.
-- Базовая политика для таблиц с прямым tenant_id
CREATE POLICY tenant_isolation ON users                   USING (NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON projects                USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON deals                   USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON tenant_status_overrides USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON tenant_custom_domains   USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON api_keys                USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON push_subscriptions      USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON comment_templates       USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON deal_tags               USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON import_log              USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON activity_log            USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON tenant_operations_log   USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);  -- v8.31: перенесено из inline
-- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены)
CREATE POLICY tenant_isolation ON failed_webhook_jobs     USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON tariff_subscriptions    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON saas_invoices           USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON saas_upd_documents      USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON saas_transactions       USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON refund_requests         USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON balance_transactions    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON report_jobs             USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON tenant_consents         USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON pd_processing_log       USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON project_limit_adjustments USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);  -- v8.2
CREATE POLICY tenant_isolation ON outbound_webhook_subscriptions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);  -- v8.4
CREATE POLICY tenant_isolation ON outbound_webhook_deliveries    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);  -- v8.4
CREATE POLICY tenant_isolation ON in_app_notifications           USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);  -- v8.10

-- v8.2: project_suppliers — фильтр через JOIN на projects (tenant_id у проекта, не у связи)
CREATE POLICY tenant_isolation ON project_suppliers USING (
    project_id IN (SELECT id FROM projects WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);

-- Для user_*-таблиц фильтр через JOIN на users
CREATE POLICY tenant_isolation ON user_recovery_codes USING (
    NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL
    OR user_id IN (SELECT id FROM users WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);
CREATE POLICY tenant_isolation ON user_sessions USING (
    NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL
    OR user_id IN (SELECT id FROM users WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);
CREATE POLICY tenant_isolation ON email_verifications USING (
    NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL
    OR user_id IN (SELECT id FROM users WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);

-- saas_invoice_items: фильтр через invoice_id
-- v8.5 (OPEN-И-14): WITH CHECK защищает INSERT/UPDATE — нельзя вставить
-- строку invoice_item ссылающуюся на чужой invoice. До v8.5 защита только
-- на USING (SELECT/UPDATE filter), INSERT мог пройти если invoice_id — чужой.
CREATE POLICY tenant_isolation ON saas_invoice_items USING (
    invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
)
WITH CHECK (
    invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);

-- v8.4 hotfix: deal_tag_pivot — фильтр через tag_id → deal_tags(tenant_id)
-- v8.5 (OPEN-И-14): добавлено WITH CHECK — нельзя пометить deal чужим тегом.
CREATE POLICY tenant_isolation ON deal_tag_pivot USING (
    tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
)
WITH CHECK (
    tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);

-- v8.5 (CTO-16): project_user_assignments — фильтр через project_id → projects(tenant_id).
-- WITH CHECK на INSERT/UPDATE — нельзя назначить менеджера в чужой проект.
CREATE POLICY tenant_isolation ON project_user_assignments USING (
    project_id IN (SELECT id FROM projects WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
)
WITH CHECK (
    project_id IN (SELECT id FROM projects WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);

-- v8.6 (CTO-17): webhook_dedup_keys — tenant-уровневая по tenant_id напрямую.
-- WITH CHECK на INSERT/UPDATE — нельзя зарегистрировать дедуп-ключ в чужом тенанте
-- (defense-in-depth поверх tenant_id NOT NULL + FK на deals).
CREATE POLICY tenant_isolation ON webhook_dedup_keys USING (
    tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
)
WITH CHECK (
    tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
);

-- auth_log: tenant_user и saas_admin строки разные. Здесь — только tenant_user.
CREATE POLICY tenant_isolation ON auth_log USING (
    NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL
    OR (actor_type = 'tenant_user'
        AND tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
);

-- Таблицы supplier_lead_costs, supplier_invoices, suppliers — НЕ tenant-уровневые
-- (это наши данные у поставщика), доступны только из админки (под ролью
-- crm_admin_user с BYPASSRLS). Для основного приложения недоступны.
-- Исключение: project_suppliers — tenant-уровневая через JOIN на projects (см. RLS-политику выше).


-- =============================================================================
-- 13. РОЛИ БД (CTO-5)
-- =============================================================================
--
-- Создаются вручную в production-окружении после первого деплоя схемы.
-- На dev можно использовать суперпользователя.
--
-- ВАЖНО: пароли НЕ в этом файле. Заполнить в production через secret manager.

-- CREATE ROLE crm_app_user   LOGIN PASSWORD '<from-secrets>';
-- CREATE ROLE crm_admin_user LOGIN PASSWORD '<from-secrets>' BYPASSRLS;
-- CREATE ROLE crm_migrator   LOGIN PASSWORD '<from-secrets>' BYPASSRLS CREATEDB;

-- GRANT crm_app_user-у:
--   USAGE на схему public;
--   SELECT, INSERT, UPDATE, DELETE на все tenant-таблицы;
--   SELECT, INSERT, UPDATE на tenants (для onboarding/blocking);
--   USAGE на все sequences;
--   запрещено: TRUNCATE, DROP, alteration схемы.
--
-- GRANT crm_admin_user-у:
--   все права crm_app_user, плюс
--   SELECT, INSERT, UPDATE на saas_admin_*, supplier_*, system_settings, tariff_plans,
--   legal_entities, payment_gateways;
--   нет DELETE на финансовых таблицах (только soft markers).

-- v8.5 (OPEN-И-15 + OPEN-И-23): новая роль crm_audit_writer.
-- INSERT-only на 5 audit-таблиц. UPDATE/DELETE заблокированы триггерами
-- (см. секцию «14. АУДИТ APPEND-ONLY + HASH CHAIN»). Application пишет
-- в audit-таблицы под этой ролью (через temporary SET ROLE), что обеспечивает
-- невозможность fraud-удаления записей даже от super_admin.
--
-- CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
--
-- GRANT crm_audit_writer-у:
--   USAGE на схему public;
--   INSERT (только) на auth_log, activity_log, pd_processing_log,
--          saas_admin_audit_log, balance_transactions;
--   USAGE на sequences соответствующих таблиц;
--   запрещено: SELECT, UPDATE, DELETE, TRUNCATE.

-- v8.5 (OPEN-И-14): defense-in-depth — REVOKE ALL на 6 saas-таблицах
-- от crm_app_user. К этим таблицам tenant-приложение доступа НЕ должно
-- иметь даже теоретически (RLS + REVOKE = 2 барьера).
--
-- REVOKE ALL ON saas_admin_users        FROM crm_app_user;
-- REVOKE ALL ON saas_admin_sessions     FROM crm_app_user;
-- REVOKE ALL ON saas_admin_audit_log    FROM crm_app_user;
-- REVOKE ALL ON incidents_log           FROM crm_app_user;
-- REVOKE ALL ON pd_subject_requests     FROM crm_app_user;
-- REVOKE ALL ON impersonation_tokens    FROM crm_app_user;


-- =============================================================================
-- 14. АУДИТ APPEND-ONLY + HASH CHAIN (v8.5, OPEN-И-15)
-- =============================================================================
--
-- Назначение: обеспечить юридическую доказательность audit-журналов.
--   • Триггеры BEFORE UPDATE/DELETE → RAISE EXCEPTION делают записи неизменяемыми.
--   • Триггер BEFORE INSERT заполняет log_hash = sha256(prev_hash || NEW::text).
--     Любое удаление средней строки или вставка фейка → разрыв цепочки при
--     пересчёте (cron audit:verify-chain в Прил. И).
--   • Роль crm_audit_writer (INSERT-only) дополняет триггеры: даже если
--     админ выполнит ALTER TABLE … DISABLE TRIGGER, у него под crm_audit_writer
--     не будет UPDATE/DELETE прав — пройти оба слоя одновременно невозможно
--     без `super_admin` PostgreSQL (которого у админов SaaS нет в production).
--
-- Затронутые таблицы (5):
--   - auth_log
--   - activity_log
--   - pd_processing_log
--   - saas_admin_audit_log
--   - balance_transactions

CREATE OR REPLACE FUNCTION audit_chain_hash() RETURNS TRIGGER AS $$
DECLARE
    prev_hash BYTEA;
    lock_key  BIGINT;
BEGIN
    -- Per-partition advisory lock (миграция 2026_05_30_000001): конкурентные
    -- INSERT-воркеры читают один и тот же prev_hash до коммита → строки берут хеш
    -- от одного предшественника → цепочка разветвляется. Ключ из физического OID
    -- партиции (TG_RELID): разные партиции → разные ключи (не блокируют друг друга),
    -- одна партиция → сериализация. Снимается в конце транзакции.
    lock_key := ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint;
    PERFORM pg_advisory_xact_lock(lock_key);

    -- Берём log_hash последней строки этой таблицы. NULL для первой записи.
    -- TG_TABLE_NAME — имя таблицы, через которое триггер сработал; используем
    -- format/EXECUTE для полиморфности.
    EXECUTE format(
        'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
        TG_TABLE_NAME
    ) INTO prev_hash;

    -- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL — берём
    -- пустую байтовую строку (первая запись цепочки).
    NEW.log_hash := digest(
        COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
        'sha256'
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

COMMENT ON FUNCTION audit_chain_hash() IS
    'v8.5 (OPEN-И-15): SHA-256 hash chain для audit-таблиц. Заполняет '
    'NEW.log_hash перед INSERT. Цепочка: digest(prev_hash || NEW::text, ''sha256''). '
    'Per-partition pg_advisory_xact_lock (миграция 2026_05_30_000001) сериализует '
    'конкурентные INSERT в одну партицию против разветвления цепочки. '
    'При попытке UPDATE/DELETE — отдельный триггер RAISE EXCEPTION.';

CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS TRIGGER AS $$
BEGIN
    -- v8.56 (Путь А, Managed PG): пересчёт hash-цепочки БЕЗ session_replication_role
    -- (superuser-only, недоступен в управляемой базе). Пропускаем мутацию при метке
    -- app.audit_rebuild='on' И (superuser — dev/test postgres; ЛИБО член crm_migrator —
    -- покрывает crm_supplier_worker, под которым идёт AuditRebuildChain на проде).
    -- EXISTS-гард — чтобы не падать на dev, где ролей crm_* нет. Миграция
    -- 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag.
    IF current_setting('app.audit_rebuild', true) = 'on' THEN
        IF (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN
            RETURN COALESCE(NEW, OLD);
        END IF;
        IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
            IF pg_has_role(current_user, 'crm_migrator', 'MEMBER') THEN
                RETURN COALESCE(NEW, OLD);
            END IF;
        END IF;
    END IF;
    RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
        USING ERRCODE = 'check_violation';
END;
$$ LANGUAGE plpgsql;

COMMENT ON FUNCTION audit_block_mutation() IS
    'v8.5 (OPEN-И-15): запрещает UPDATE/DELETE на audit-таблицах. '
    'Совместно с REVOKE на роли — два слоя защиты от tampering.';

-- 6 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
-- v8.31: tenant_operations_log перенесён из inline-определения таблицы; итого 6 пар.

CREATE TRIGGER trg_audit_chain_hash_auth_log
    BEFORE INSERT ON auth_log
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_auth_log
    BEFORE UPDATE OR DELETE ON auth_log
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();

CREATE TRIGGER trg_audit_chain_hash_activity_log
    BEFORE INSERT ON activity_log
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_activity_log
    BEFORE UPDATE OR DELETE ON activity_log
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();

CREATE TRIGGER trg_audit_chain_hash_tenant_ops
    BEFORE INSERT ON tenant_operations_log
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_tenant_ops
    BEFORE UPDATE OR DELETE ON tenant_operations_log
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();

CREATE TRIGGER trg_audit_chain_hash_pd_log
    BEFORE INSERT ON pd_processing_log
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_pd_log
    BEFORE UPDATE OR DELETE ON pd_processing_log
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();

CREATE TRIGGER trg_audit_chain_hash_saas_admin_audit
    BEFORE INSERT ON saas_admin_audit_log
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_saas_admin_audit
    BEFORE UPDATE OR DELETE ON saas_admin_audit_log
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();

CREATE TRIGGER trg_audit_chain_hash_balance_tx
    BEFORE INSERT ON balance_transactions
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_balance_tx
    BEFORE UPDATE OR DELETE ON balance_transactions
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();


-- =============================================================================
-- 15. ТРИГГЕР ЭКСПОРТА В pd_processing_log (v8.5, OPEN-И-20)
-- =============================================================================
--
-- При создании записи в report_jobs (выгрузка лидов в файл) автоматически
-- пишем в pd_processing_log action='exported' для соответствия 152-ФЗ ст.18
-- ч.2 (фиксация всех операций обработки ПДн). До v8.5 такая запись делалась
-- из app-кода — риск пропуска при ошибке программиста.

CREATE OR REPLACE FUNCTION report_jobs_log_export() RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO pd_processing_log (
        tenant_id, subject_type, subject_id, action, purpose,
        actor_tenant_user_id, ip_address
    ) VALUES (
        NEW.tenant_id,
        'lead',           -- bulk-выгрузка лидов
        NULL,             -- subject_id NULL = bulk
        'exported',
        'report_job_' || NEW.id,
        NEW.user_id,
        NULL              -- IP уже в saas_admin_audit_log/auth_log если нужно
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_report_jobs_export_log
    AFTER INSERT ON report_jobs
    FOR EACH ROW EXECUTE FUNCTION report_jobs_log_export();

COMMENT ON FUNCTION report_jobs_log_export() IS
    'v8.5 (OPEN-И-20): автоматически пишет в pd_processing_log при создании '
    'report_jobs. Закрывает риск пропуска audit-записи на стороне приложения.';


-- =============================================================================
-- 16. ТРИГГЕР РАСЧЁТА LEAD_SCORE (v8.5, Биз-22)
-- =============================================================================
--
-- Простая модель без ML: lead_score = supplier.quality_score *
-- LEAST(time_in_form_seconds / 60.0, 99.99 / supplier.quality_score).
-- Если time_in_form_seconds NULL — lead_score NULL.
-- Поставщик вычисляется через project → project_suppliers → supplier (в MVP
-- предполагается ровно один активный поставщик на проект; если их несколько
-- — берётся первый по sort_order). Полная модель — Post-MVP.

CREATE OR REPLACE FUNCTION calc_lead_score() RETURNS TRIGGER AS $$
DECLARE
    quality NUMERIC(3,2);
BEGIN
    IF NEW.time_in_form_seconds IS NULL THEN
        NEW.lead_score := NULL;
        RETURN NEW;
    END IF;

    SELECT s.quality_score INTO quality
    FROM project_suppliers ps
    JOIN suppliers s ON s.id = ps.supplier_id
    WHERE ps.project_id = NEW.project_id
      AND ps.is_active = TRUE
      AND s.is_active = TRUE
    ORDER BY s.sort_order ASC, s.id ASC
    LIMIT 1;

    IF quality IS NULL THEN
        NEW.lead_score := NULL;
    ELSE
        NEW.lead_score := LEAST(
            quality * (NEW.time_in_form_seconds::numeric / 60.0),
            99.99
        );
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_deals_calc_lead_score
    BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals
    FOR EACH ROW EXECUTE FUNCTION calc_lead_score();

COMMENT ON FUNCTION calc_lead_score() IS
    'v8.5 (Биз-22): простой scoring lead_score = supplier.quality_score * '
    '(time_in_form_seconds / 60), clamped в [0, 99.99]. Без ML.';


-- v8.6 (CTO-17): trigger для pd_subject_requests.deadline_at — замена
-- `GENERATED ALWAYS AS (received_at + INTERVAL '30 days') STORED`, отклонённого
-- PostgreSQL 16 как не-immutable (`+ INTERVAL` зависит от timezone/DST).
CREATE OR REPLACE FUNCTION set_pd_subject_request_deadline() RETURNS TRIGGER AS $$
BEGIN
    NEW.deadline_at := COALESCE(NEW.received_at, NOW()) + INTERVAL '30 days';
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_pd_subject_requests_deadline
    BEFORE INSERT OR UPDATE OF received_at ON pd_subject_requests
    FOR EACH ROW EXECUTE FUNCTION set_pd_subject_request_deadline();

COMMENT ON FUNCTION set_pd_subject_request_deadline() IS
    'v8.6 (CTO-17): расчёт deadline_at = received_at + 30 дней (152-ФЗ). '
    'Заменяет GENERATED STORED column, отклонённый PG 16 как не-immutable.';


-- =============================================================================
-- 17. POST-MVP DDL ЗАКОММЕНТИРОВАН (v8.5, OPEN-И-26)
-- =============================================================================
--
-- Биз-12 (Post-MVP): телефонная интеграция и call-recording.
-- DDL ниже — задел для ретрофита, чтобы при добавлении функции в Post-MVP
-- не пришлось проектировать с нуля. ПДн-маркировка выставлена сразу:
-- recording_path хранит ссылки на S3 (сами записи лежат в S3+KMS), их
-- доступ — через signed URL TTL 1 ч с обязательным INSERT в pd_processing_log.
--
-- CREATE TABLE call_recordings (
--     id              BIGSERIAL PRIMARY KEY,
--     tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
--     deal_id         BIGINT NOT NULL,                  -- БЕЗ FK (deals партиционирована)
--     call_started_at TIMESTAMPTZ NOT NULL,
--     duration_sec    INT NOT NULL CHECK (duration_sec >= 0),
--     direction       VARCHAR(10) NOT NULL CHECK (direction IN ('inbound','outbound')),
--     recording_path  VARCHAR(500),                     -- s3://bucket/recordings/...
--     transcript      TEXT,                             -- результат STT (Yandex SpeechKit)
--     created_at      TIMESTAMPTZ DEFAULT NOW()
-- );
-- CREATE INDEX ON call_recordings (tenant_id, deal_id, call_started_at DESC);
-- ALTER TABLE call_recordings ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY tenant_isolation ON call_recordings
--     USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);


-- =============================================================================
-- ОТЛОЖЕННЫЕ FK НА ПАРТИЦИОНИРОВАННЫЕ ТАБЛИЦЫ
-- =============================================================================
-- Composite FK на partitioned deals(id, received_at) для lead_charges (Plan 1/5 Task 4, v8.15).
-- Размещён в конце файла, ПОСЛЕ section 5 (deals), чтобы при `migrate:fresh` PostgreSQL
-- видел deals при ADD CONSTRAINT. DEFERRABLE INITIALLY DEFERRED обязателен для
-- атомарного INSERT deal+charge в одной транзакции.

ALTER TABLE lead_charges
    ADD CONSTRAINT lead_charges_deals_fk
    FOREIGN KEY (deal_id, deal_received_at)
    REFERENCES deals(id, received_at)
    ON DELETE CASCADE
    DEFERRABLE INITIALLY DEFERRED;


-- =============================================================================
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
-- (Billing v2 Spec C, v8.35). INSERT-only: записи не обновляются.
-- =============================================================================
CREATE TABLE balance_freeze_log (
    id                   BIGSERIAL PRIMARY KEY,
    tenant_id            BIGINT NOT NULL REFERENCES tenants(id),
    event_type           VARCHAR(30) NOT NULL,
    triggered_by         VARCHAR(30) NOT NULL,
    balance_rub_at_event DECIMAL(12,2) NOT NULL,
    required_leads       INTEGER NOT NULL,
    capacity_leads       INTEGER NOT NULL,
    total_daily_limit    INTEGER NOT NULL,
    details              JSONB,
    created_at           TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON balance_freeze_log
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);

CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);

DO $$
BEGIN
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
        GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
        GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
        GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
        GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
        GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
        GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
        GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_admin;
        GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_admin;
    END IF;
END $$;

-- =============================================================================
-- КОНЕЦ schema.sql
-- =============================================================================
