9d2e7270de
- StoreProjectRequest: 3-way conditional validation (site domain regex, call 7\d{10}, sms senders required)
- ProjectService::create(): max_projects limit check via Tenant.limits JSONB + dispatch SyncSupplierProjectJob
- ProjectController: constructor DI + store() method returning 201
- SyncSupplierProjectJob: stub (Task 4 полная реализация)
- POST /api/projects route inside auth:sanctum+tenant group (name projects.store)
- Migration add_limits_to_tenants: JSONB DEFAULT '{}' per-tenant limits column
- Tenant model: limits added to fillable + casts as array
- schema.sql/CHANGELOG: tenants.limits documented in v8.20
- phpstan-baseline: +8 actingAs entries for new test file
- Quirk: region_mode in request uses 'include'/'exclude' (schema CHECK) not 'all'/'whitelist' (plan spec typo)
- Quirk: Project::first() → Project::where('signal_identifier','x.ru')->latest()->first() (no RefreshDatabase, persistent test DB)
- 8/8 ProjectsStoreTest passed; 699/706 total (4 pre-existing failures unchanged)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3133 lines
203 KiB
PL/PgSQL
3133 lines
203 KiB
PL/PgSQL
-- =============================================================================
|
||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||
-- Версия: 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 лимитов)
|
||
-- Метрики: 62 базовые таблицы + 12 партиций / 117 индексов / 39 RLS-политик / 5 функций / 13 триггеров
|
||
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
|
||
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
|
||
-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK projects.supplier_b{1,2,3}_project_id → supplier_projects (ON DELETE SET NULL) + 3 partial index + CHECK chk_projects_b1_not_for_sms (defense-in-depth дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне). Закрывает code-review BLOCKER#1 + WARNING#3 от 10.05.2026 поздний вечер)
|
||
-- Базовая версия: v8.16 (10.05.2026 — Plan 1/5 Task 5: supplier_sync_log SaaS-level audit log AJAX-синхронизаций с поставщиком + 1 CHECK (action enum) + 3 индекса + nullable FK на supplier_projects (ON DELETE SET NULL) + REVOKE ALL для crm_app_user)
|
||
-- Базовая версия: v8.15 (10.05.2026 — Plan 1/5 Task 4: lead_charges append-only ledger списаний за доставленный лид + composite DEFERRABLE FK на deals + RLS tenant_isolation + 2 индекса + GRANT SELECT,INSERT для crm_app_user)
|
||
-- Базовая версия: v8.14 (10.05.2026 — Plan 1/5 Task 3: pricing_tiers SaaS-level конфигурация 7-ступенчатого объёмного тарифа в копейках + 1 CHECK (tier_no 1..7) + 2 индекса + REVOKE ALL + GRANT SELECT для crm_app_user)
|
||
-- Базовая версия: v8.13 (10.05.2026 — Plan 1/5 Task 2: supplier_projects SaaS-level агрегатная таблица для проектов у поставщиков B1/B2/B3 + 4 CHECK + 3 индекса + REVOKE ALL FROM crm_app_user)
|
||
-- Базовая версия: v8.12 (10.05.2026 — расширение projects для supplier integration: signal_type/identifier/sms_senders/sms_keyword/delivered_in_month/supplier_b{1,2,3}_project_id + 3 CHECK + 1 composite index)
|
||
-- Базовая версия: v8.11 (09.05.2026 — hygiene-фиксы аудита: P0-02 RLS на impersonation_tokens + O-perf-02/03 индексы FK-колонок webhook_log_id)
|
||
-- Базовая версия: v8.10 (09.05.2026 — in_app_notifications для bell-icon UI; 2 индекса (unread + recent); RLS tenant isolation)
|
||
-- Базовая версия: v8.5 (07.05.2026, реализация 27 решений аудита C из реестра v1.12)
|
||
-- СУБД: PostgreSQL 16
|
||
-- Кодировка: UTF8, локаль ru_RU.UTF-8
|
||
-- =============================================================================
|
||
--
|
||
-- ИСТОЧНИК: документация v8.5 (CRM_bp-gr_Инструкция_v8_5.md), 28 разделов.
|
||
-- Каждый блок помечен ссылкой на исходный раздел.
|
||
--
|
||
-- АРХИТЕКТУРНАЯ МОДЕЛЬ:
|
||
-- • Реселлер. Клиент-тенант создаёт проект на нашем портале → мы передаём
|
||
-- проект в crm.bp-gr.ru (исходящий API) → crm.bp-gr.ru собирает по нему
|
||
-- лиды и шлёт нам webhook → мы передаём клиенту через интерфейс/push.
|
||
-- • Договор с crm.bp-gr.ru подписан, мы — официальный корп. клиент.
|
||
-- • Мы — самостоятельный оператор ПДн. crm.bp-gr.ru передаёт данные на
|
||
-- основании договора. Источник заявок физлиц не контролируется нами.
|
||
-- • Поставщики (раскрыто в аудите 04.05.2026, партия 10): внутри crm.bp-gr.ru
|
||
-- есть три суб-канала приёма данных — B1 (Сайты/Звонки), B2 (СМС с keyword),
|
||
-- B3 (СМС по sender_name). У нас они представлены таблицей suppliers.
|
||
-- • Capabilities поставщиков (партия 13.3.5, цитата UI оригинала):
|
||
-- B2 — sender_name + keyword; B3 — только sender_name; B1 — sites/calls.
|
||
-- Для генерации UI-формы проекта suppliers содержит поля supports_*.
|
||
--
|
||
-- ИЗМЕНЕНИЯ v8.4 → v8.5:
|
||
-- ИЗ ЗАКРЫТИЯ АУДИТА C (Открытые_вопросы v1.12, 07.05.2026, 27 решений):
|
||
-- P0 (8) — обязательны до триггера фазы 1:
|
||
-- 1. Биз-17 — projects.assignment_strategy VARCHAR(32) DEFAULT 'manual'
|
||
-- + CHECK IN ('manual','round_robin','least_loaded'). MVP = manual.
|
||
-- 2. Биз-18 — projects.ttfr_target_minutes INT DEFAULT 15 (Time To First
|
||
-- Response SLA, alert при просрочке через event bus + UI badge).
|
||
-- 3. Биз-19 — deals.duplicate_of_id BIGINT NULL ON DELETE SET NULL +
|
||
-- индекс (tenant_id, phone, received_at) для O(log n) lookup в окне
|
||
-- 24 ч. Антифрод: дубль помечается, но НЕ списывается с баланса.
|
||
-- 4. CTO-13 — обязательный e2e-тест SET LOCAL app.current_tenant_id
|
||
-- через PgBouncer transaction-pooling в спринте 1. Без изменений
|
||
-- схемы, только тест-план в narrative §22 + Прил. И.
|
||
-- 5. OPEN-И-13 — saas_admin_users.sso_provider VARCHAR(32) DEFAULT
|
||
-- 'yandex360' + saas_admin_users.is_break_glass BOOLEAN DEFAULT
|
||
-- FALSE (OIDC + JIT-provisioning + локальный 2FA выключен,
|
||
-- fallback — break-glass super_admin).
|
||
-- 6. OPEN-И-14 — WITH CHECK на политики deal_tag_pivot, saas_invoice_items
|
||
-- + REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users,
|
||
-- saas_admin_sessions, saas_admin_audit_log, incidents_log,
|
||
-- pd_subject_requests, impersonation_tokens).
|
||
-- 7. OPEN-И-15 — append-only audit hash chain. Колонка log_hash BYTEA
|
||
-- NOT NULL на 5 audit-таблицах (auth_log, activity_log,
|
||
-- pd_processing_log, saas_admin_audit_log, balance_transactions).
|
||
-- 5 BEFORE UPDATE/DELETE триггеров с RAISE EXCEPTION + функция
|
||
-- audit_chain_hash() = sha256(prev.log_hash || row). Новая роль
|
||
-- crm_audit_writer (только INSERT) — REVOKE UPDATE/DELETE даже у
|
||
-- super_admin через триггеры.
|
||
-- 8. OPEN-И-16 — Sentry whitelist + regex маска phone/email/password/
|
||
-- secret/token/api_key. Без изменений схемы — конфигурация в Laravel
|
||
-- config/sentry.php (narrative §22 «Sentry PII-scrubbing»).
|
||
-- P1 (12) — фазы 1–2:
|
||
-- 9. Биз-20 — Telegram-бот в спринте 9. users.telegram_user_id BIGINT
|
||
-- NULL + tenants.telegram_bot_token TEXT NULL (зашифровано).
|
||
-- 10. Биз-21 — generic outbound `marketing.conversion` через §19.10
|
||
-- (расширение whitelist событий). Без изменений схемы.
|
||
-- 11. Биз-22 — простой scoring. suppliers.quality_score NUMERIC(3,2)
|
||
-- DEFAULT 1.00 + deals.time_in_form_seconds INT NULL + deals.lead_score
|
||
-- NUMERIC(5,2) GENERATED ALWAYS AS (...) STORED.
|
||
-- 12. CTO-14 — UTM-поля. deals.utm_source/utm_medium/utm_campaign/
|
||
-- utm_content VARCHAR(100) NULL + индекс (tenant_id, utm_source).
|
||
-- 13. CTO-15 — two-person impersonation. impersonation_tokens.
|
||
-- second_approver_id BIGINT NULL REFERENCES saas_admin_users(id) +
|
||
-- second_approval_at TIMESTAMPTZ NULL.
|
||
-- 14. CTO-16 — skill-based routing. Новая таблица project_user_assignments
|
||
-- (project_id, user_id, skills JSONB) + RLS-политика через JOIN на
|
||
-- projects.tenant_id + индекс на (project_id).
|
||
-- 15. OPEN-И-17 — TTL 365 дней на api_keys/webhook_token/outbound.
|
||
-- secret_hash. ALTER api_keys.expires_at SET DEFAULT NOW() + 365d.
|
||
-- 16. OPEN-И-18 — DNS-rebinding защита. Без изменений схемы — реализация
|
||
-- в SSRFGuard service (narrative §19.10).
|
||
-- 17. OPEN-И-19 — лимит api_keys. tenants.api_key_limit INT DEFAULT 5
|
||
-- NOT NULL CHECK (api_key_limit BETWEEN 1 AND 10) + ALTER api_keys.
|
||
-- expires_at SET NOT NULL (миграция: backfill всем NOW + 365d).
|
||
-- 18. OPEN-И-20 — signed URL + триггер audit. Триггер trg_report_jobs_
|
||
-- export_log AFTER INSERT ON report_jobs → INSERT pd_processing_log
|
||
-- action='exported'.
|
||
-- 19. OPEN-И-21 — Anti-DDoS. Без изменений схемы — Nginx + Yandex
|
||
-- SmartCaptcha + disposable-blacklist (narrative §22 + Прил. И).
|
||
-- 20. Ю-9 — hard-блок impersonation для всех ролей кроме compliance
|
||
-- при processing_restricted=TRUE. Реализация в SaasAdminAuthService
|
||
-- (narrative §22.7), без изменений схемы.
|
||
-- P2 (7) — фазы 1–3:
|
||
-- 21. Биз-23 — гео-таргетинг. deals.region_code VARCHAR(8) NULL +
|
||
-- deals.city VARCHAR(100) NULL + индекс (tenant_id, region_code).
|
||
-- 22. Биз-24 — алерт saappоrtу о просрочке waiting_payment → paid
|
||
-- через 48 ч. Cron payments:notify-stale (narrative §17), без
|
||
-- изменений схемы кроме system_settings ключей.
|
||
-- 23. OPEN-И-22 — per-tenant DEK в Yandex KMS. Без изменений схемы —
|
||
-- encryption envelope на уровне backup-сервиса (Прил. И).
|
||
-- 24. OPEN-И-23 — роль crm_audit_writer уже создана в OPEN-И-15.
|
||
-- Здесь — только подтверждение: INSERT-only access; UPDATE/DELETE
|
||
-- блокируются триггерами.
|
||
-- 25. OPEN-И-24 — pg_anonymizer процедура. Документация в Прил. И,
|
||
-- без изменений схемы (расширение ставится в фазе 3 по Прил. Н).
|
||
-- 26. OPEN-И-25 — эскалация лидов. deals.assigned_at TIMESTAMPTZ NULL +
|
||
-- deals.escalated_count INT DEFAULT 0. Cron leads:escalate-stale
|
||
-- (narrative §10 + §17).
|
||
-- 27. OPEN-И-26 — задел под call-recording (Биз-12 Post-MVP).
|
||
-- Закомментированный DDL call_recordings(...) в конце файла.
|
||
-- Итого: +1 таблица (project_user_assignments), +26 колонок (suppliers.
|
||
-- quality_score; saas_admin_users.sso_provider/is_break_glass;
|
||
-- impersonation_tokens.second_approver_id/second_approval_at;
|
||
-- tenants.api_key_limit/telegram_bot_token; projects.assignment_strategy/
|
||
-- ttfr_target_minutes; users.telegram_user_id; deals.assigned_at/
|
||
-- escalated_count/duplicate_of_id/utm_source/utm_medium/utm_campaign/
|
||
-- utm_content/region_code/city/time_in_form_seconds/lead_score; +log_hash
|
||
-- на 5 audit-таблицах) + ALTER api_keys.expires_at NOT NULL DEFAULT
|
||
-- NOW()+365d, +12 триггеров (5×2 audit append-only + 1 report_jobs export
|
||
-- log + 1 deals lead_score), +4 функции (audit_chain_hash,
|
||
-- audit_block_mutation, report_jobs_log_export, calc_lead_score),
|
||
-- +1 роль (crm_audit_writer), +5 индексов, +2 политики с WITH CHECK,
|
||
-- +1 политика (project_user_assignments), REVOKE на 6 saas-таблицах.
|
||
--
|
||
-- ИЗМЕНЕНИЯ v8.3 → v8.4:
|
||
-- ИЗ ПЕРЕПИСЫВАНИЯ NARRATIVE v8.4 (06.05.2026, §19.10 outbound webhook):
|
||
-- 1. Новая таблица outbound_webhook_subscriptions (раздел 19.10) —
|
||
-- регистрация подписок тенантов на исходящие события (deal.created,
|
||
-- deal.status_changed, …). Хеш secret + key_prefix аналогично api_keys.
|
||
-- 2. Новая таблица outbound_webhook_deliveries (раздел 19.10.6) —
|
||
-- журнал попыток доставки с retention 90 дней. attempt_number 1..7,
|
||
-- статусы pending/success/failed/permanently_failed.
|
||
-- 3. RLS на обе новые таблицы (политика по tenant_id, как остальные).
|
||
-- 4. Закрывает тех-долг шапки narrative v8.4 («при правке §7 добавить DDL
|
||
-- outbound_webhook_subscriptions и outbound_webhook_deliveries»).
|
||
--
|
||
-- ИЗМЕНЕНИЯ v8.2 → v8.3:
|
||
-- ИЗ ПАРАЛЛЕЛЬНОГО АУДИТА ПАРТИЙ 12–15 (Прил. М v1.1, 05.05.2026):
|
||
-- 1. reminders.user_id → reminders.created_by (семантика "from", партия 12.2.4).
|
||
-- + поле reminders.completed_at TIMESTAMPTZ (для аудита выполнения).
|
||
-- Удалена reminders.is_done — заменена на NOT NULL (completed_at IS NULL).
|
||
-- Сохранены is_sent + sent_at для cron нотификаций.
|
||
-- 2. Удалены deals.reminder_text, deals.reminder_at и idx_deals_reminder.
|
||
-- Множественные напоминания на сделку (партия 12.2.5: histories[].type='reminder').
|
||
-- 3. Расширение suppliers 5 полями capabilities (партия 13.3.5):
|
||
-- channel, supports_sender_name, supports_keyword, supports_csv_upload,
|
||
-- supports_domains_list. Seed B1/B2/B3 обновлён.
|
||
-- 4. tenants.desired_daily_numbers INT NULL (партия 13.2.2, Биз-16):
|
||
-- целевое количество лидов в день — сигнал для саппорта (не лимит, не биллинг).
|
||
-- 5. system_settings: 3 новых ключа для cron projects:purge-deleted (Биз-14):
|
||
-- projects_purge_deleted_enabled (по умолчанию false),
|
||
-- projects_purge_deleted_ttl_days (по умолчанию 180),
|
||
-- projects_purge_deleted_cron (расписание).
|
||
--
|
||
-- ИЗМЕНЕНИЯ v8.1 → v8.2 (для контекста):
|
||
-- ИЗ ИНТЕРВЬЮ С ЗАКАЗЧИКОМ 04.05.2026:
|
||
-- 1. pd_subject_requests.processing_restricted (OPEN-Д-1, ст.21 152-ФЗ).
|
||
-- 2. Таблица incidents_log (OPEN-Д-5 / OPEN-И-1, журнал инцидентов SaaS).
|
||
-- ИЗ АУДИТА crm.bp-gr.ru (Прил. М v1.0, 04.05.2026):
|
||
-- 3. Таблица suppliers + seed B1/B2/B3 (партия 10.2).
|
||
-- 4. Таблица project_suppliers (m2m, паритет с формой создания проекта).
|
||
-- 5. Миграция supplier_lead_costs.supplier_code → supplier_id (FK).
|
||
-- 6. Миграция supplier_invoices.supplier_code → supplier_id (FK).
|
||
-- 7. Расширение projects 6 полями: daily_limit_target,
|
||
-- effective_daily_limit_today, effective_limit_calculated_at,
|
||
-- region_mask, region_mode, delivery_days_mask (партия 10.3, 10.6).
|
||
-- 8. Таблица project_limit_adjustments + RLS-политика (партия 10.7).
|
||
--
|
||
-- ИЗМЕНЕНИЯ v8.0 → v8.1 (для контекста):
|
||
-- • Админка SaaS: saas_admin_users, saas_admin_recovery_codes,
|
||
-- saas_admin_sessions, saas_admin_audit_log.
|
||
-- • Impersonation (Ю-1): impersonation_tokens.
|
||
-- • Себестоимость / поставщики (Ю-2): supplier_lead_costs (партиционированная),
|
||
-- supplier_invoices.
|
||
-- • Chargeback (Ю-3): tenants.chargeback_unrecovered_rub.
|
||
-- • Уведомления (CTO-4): users.notification_preferences JSONB.
|
||
-- • RLS (CTO-5): включён на MVP — политики на 30 tenant-таблиц.
|
||
-- • 152-ФЗ: pd_subject_requests, второй consent_type.
|
||
--
|
||
-- ПОРЯДОК СОЗДАНИЯ:
|
||
-- 1) Расширения
|
||
-- 2) Справочники без зависимостей (включая suppliers с capabilities — РАСШИРЕНО в v8.3)
|
||
-- 3) Tenants (после tariff_plans, +desired_daily_numbers в v8.3)
|
||
-- 4) Tenant-уровневые таблицы (включая projects + project_suppliers
|
||
-- + project_limit_adjustments)
|
||
-- 5) Партиционированная deals + партиции (БЕЗ полей reminder_* в v8.3)
|
||
-- 6) Логи и журналы (включая reminders с created_by + completed_at в v8.3)
|
||
-- 7) Биллинг SaaS-уровня (включая chargeback)
|
||
-- 8) Себестоимость и поставщики (Ю-2, supplier_id вместо supplier_code в v8.2)
|
||
-- 9) Отчёты
|
||
-- 10) 152-ФЗ (с processing_restricted в v8.2)
|
||
-- 11) Админка SaaS + impersonation + incidents_log
|
||
-- 12) ALTER TABLE для forward refs
|
||
-- 13) Заполнение справочников (lead_statuses, suppliers с capabilities,
|
||
-- system_settings с purge-deleted, tariff_plans)
|
||
-- 14) Row-Level Security (CTO-5: включён на MVP)
|
||
-- 15) Роли БД (CTO-5)
|
||
--
|
||
-- РАЗМЕР СХЕМЫ v8.5:
|
||
-- • 54 логических таблицы (53 из v8.4 + project_user_assignments v8.5).
|
||
-- • 12 партиций (6 у deals + 6 у supplier_lead_costs).
|
||
-- • 91 индекс (86 из v8.4 + 5 новых: idx_deals_utm_source,
|
||
-- idx_deals_region_code, idx_deals_duplicate_of, idx_deals_assigned_at_open,
|
||
-- idx_project_user_assignments_user).
|
||
-- • 35 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN).
|
||
-- Из них 2 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items).
|
||
-- • 35 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам).
|
||
-- • 4 роли БД (3 из v8.4 + crm_audit_writer — только INSERT на 5 audit-таблицах).
|
||
-- • 12 триггеров: на 5 audit-таблицах (auth_log/activity_log/pd_processing_log/
|
||
-- saas_admin_audit_log/balance_transactions) — по 2 (BEFORE INSERT для hash
|
||
-- chain + BEFORE UPDATE/DELETE для запрета мутаций) = 10; +1 на report_jobs
|
||
-- (AFTER INSERT — журнал экспорта в pd_processing_log по 152-ФЗ ст.18);
|
||
-- +1 на deals (BEFORE INSERT/UPDATE — расчёт lead_score через
|
||
-- supplier.quality_score × time_in_form, Биз-22).
|
||
-- • 4 функции: audit_chain_hash() (SHA-256 hash chain для tamper-detection),
|
||
-- audit_block_mutation() (RAISE EXCEPTION для запрета UPDATE/DELETE),
|
||
-- report_jobs_log_export() (auto-логирование экспорта),
|
||
-- calc_lead_score() (расчёт lead score без ML).
|
||
-- • REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users,
|
||
-- saas_admin_sessions, saas_admin_audit_log, incidents_log,
|
||
-- pd_subject_requests, impersonation_tokens) — defense-in-depth.
|
||
-- =============================================================================
|
||
|
||
|
||
-- =============================================================================
|
||
-- 1. РАСШИРЕНИЯ
|
||
-- =============================================================================
|
||
|
||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- триграммный поиск (на будущее)
|
||
CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- GIN-индексы по btree-типам
|
||
|
||
|
||
-- =============================================================================
|
||
-- 2. СПРАВОЧНИКИ И SAAS-УРОВЕНЬ БЕЗ ЗАВИСИМОСТЕЙ
|
||
-- =============================================================================
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- legal_entities — юр. лица оператора SaaS (раздел 20.3)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE legal_entities (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(50) UNIQUE NOT NULL, -- "ooo_main", "ip_ivanov"
|
||
name VARCHAR(500) NOT NULL,
|
||
short_name VARCHAR(255), -- "ООО Ромашка"
|
||
legal_form VARCHAR(20) NOT NULL, -- 'OOO', 'IP', 'AO', 'PAO', 'NKO'
|
||
-- Реквизиты
|
||
inn VARCHAR(12) NOT NULL,
|
||
kpp VARCHAR(9), -- NULL для ИП
|
||
ogrn VARCHAR(15),
|
||
okpo VARCHAR(10),
|
||
-- Адреса
|
||
legal_address TEXT,
|
||
actual_address TEXT,
|
||
-- Банк
|
||
bank_name VARCHAR(255),
|
||
bank_account VARCHAR(20),
|
||
bank_bik VARCHAR(9),
|
||
bank_corr VARCHAR(20),
|
||
-- Подписант
|
||
director_name VARCHAR(255),
|
||
director_post VARCHAR(255) DEFAULT 'Генеральный директор',
|
||
director_basis VARCHAR(255) DEFAULT 'Устава', -- Устава / Доверенности №...
|
||
-- НДС
|
||
vat_mode VARCHAR(20) DEFAULT 'no_vat' -- vat20, vat10, vat7, vat5, vat0, no_vat, usn_6, usn_15
|
||
CHECK (vat_mode IN ('vat20','vat10','vat7','vat5','vat0','no_vat','usn_6','usn_15')),
|
||
-- Файлы (S3-пути)
|
||
signature_path VARCHAR(500), -- скан подписи (PNG, прозрачный фон)
|
||
stamp_path VARCHAR(500), -- скан печати
|
||
logo_path VARCHAR(500),
|
||
-- Управление
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
is_default BOOLEAN DEFAULT FALSE,
|
||
sort_order INT DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE UNIQUE INDEX idx_legal_entities_default ON legal_entities(is_default) WHERE is_default = TRUE;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- tariff_plans — каталог тарифов (раздел 20.2.1)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE tariff_plans (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(50) UNIQUE NOT NULL, -- 'start', 'basic', 'pro', 'custom_corp_xyz'
|
||
name VARCHAR(255) NOT NULL,
|
||
description TEXT,
|
||
-- Модель оплаты
|
||
billing_model VARCHAR(50) NOT NULL -- 'per_lead', 'monthly', 'hybrid', 'custom'
|
||
CHECK (billing_model IN ('per_lead','monthly','hybrid','custom')),
|
||
price_per_lead DECIMAL(10,2), -- для per_lead и hybrid
|
||
price_monthly DECIMAL(10,2), -- абонплата (monthly и hybrid)
|
||
included_leads INT, -- лидов в подписку (monthly и hybrid)
|
||
-- Лимиты и фичи (расширяемо)
|
||
limits JSONB DEFAULT '{}', -- {"max_users":5,"max_projects":10,"api_rps":60}
|
||
features JSONB DEFAULT '[]', -- ["kanban","advanced_analytics","api","2fa","custom_domain"]
|
||
-- Trial
|
||
trial_bonus_leads INT DEFAULT 0, -- индивидуальный стартовый бонус
|
||
-- Видимость
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
is_public BOOLEAN DEFAULT TRUE, -- виден на странице регистрации
|
||
sort_order INT DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- lead_statuses — справочник статусов воронки (раздел 7.3, 8.1)
|
||
-- 14 статусов: 6 системных + 8 настраиваемых
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE lead_statuses (
|
||
slug VARCHAR(50) PRIMARY KEY,
|
||
name_ru VARCHAR(100) NOT NULL,
|
||
is_system BOOLEAN DEFAULT FALSE, -- TRUE = нельзя переименовать
|
||
sort_order INT,
|
||
color_hex VARCHAR(7),
|
||
description TEXT
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- suppliers — каталог поставщиков данных (НОВАЯ в v8.2, Прил. М §3.1)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Контекст: аудит crm.bp-gr.ru от 04.05.2026 (партия 10) раскрыл архитектурный
|
||
-- слой "Поставщик" — три внутренних канала приёма данных в оригинале:
|
||
-- - B1 — основной для типов "Сайты" и "Звонки"
|
||
-- - B2 — поддерживает "СМС" через "Наименование отправителя" + "Ключевое слово"
|
||
-- - B3 — поддерживает "СМС" только по "Наименованию отправителя"
|
||
--
|
||
-- Реселлерская модель Ю-2: мы покупаем у crm.bp-gr.ru как у одного контрагента,
|
||
-- но внутри crm.bp-gr.ru есть эти три суб-поставщика. На MVP делаем плоский
|
||
-- список (B1, B2, B3 — три отдельные строки), без иерархии. Если в будущем
|
||
-- появятся другие первичные поставщики — добавится parent_supplier_id или
|
||
-- отдельный уровень supplier_channels.
|
||
--
|
||
-- НЕ tenant-уровневая (общая для всех тенантов). RLS не применяется.
|
||
-- Чтение — всем, запись — только под crm_admin_user.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE suppliers (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(50) NOT NULL UNIQUE, -- 'b1', 'b2', 'b3'
|
||
name VARCHAR(255) NOT NULL, -- 'B1 — Сайты и Звонки'
|
||
description TEXT,
|
||
accepts_types VARCHAR(20)[] NOT NULL, -- ['websites','calls'] | ['sms']
|
||
cost_rub DECIMAL(10,2) NOT NULL, -- закупочная цена одного лида
|
||
settings_schema JSONB, -- JSON Schema для per-supplier настроек проекта
|
||
-- Capabilities (v8.3, партия 13.3.5 аудита — цитата UI оригинала):
|
||
-- "Указать в связке 'Наименование отправителя' и 'Ключевое слово' можно
|
||
-- только по поставщику B2. Поставщик B3 работает только по наименованию
|
||
-- отправителя".
|
||
-- Используется UI карточки проекта для генерации релевантных полей.
|
||
-- При выборе нескольких поставщиков — пересечение capabilities (intersection).
|
||
channel VARCHAR(20) NOT NULL DEFAULT 'sites'
|
||
CHECK (channel IN ('sites','calls','sms')),
|
||
supports_sender_name BOOLEAN NOT NULL DEFAULT FALSE,
|
||
supports_keyword BOOLEAN NOT NULL DEFAULT FALSE,
|
||
supports_csv_upload BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: загрузка CSV-доменов
|
||
supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: textarea со списком доменов
|
||
-- v8.5 (Биз-22): простая lead scoring модель без ML.
|
||
-- Используется в формуле deals.lead_score = supplier_quality × time_in_form.
|
||
-- 1.00 = baseline; админ SaaS вручную поднимает/понижает в /admin/suppliers
|
||
-- по фактической конверсии за месяц. Post-MVP — auto-adjustment по cron.
|
||
quality_score NUMERIC(3,2) NOT NULL DEFAULT 1.00
|
||
CHECK (quality_score BETWEEN 0.00 AND 9.99),
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
sort_order INT DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
CONSTRAINT chk_suppliers_accepts_types_not_empty
|
||
CHECK (array_length(accepts_types, 1) > 0)
|
||
);
|
||
|
||
CREATE INDEX idx_suppliers_active ON suppliers(is_active, sort_order)
|
||
WHERE is_active = TRUE;
|
||
|
||
COMMENT ON TABLE suppliers IS
|
||
'Каталог поставщиков данных. На MVP — три строки (B1/B2/B3), '
|
||
'суб-поставщики crm.bp-gr.ru, раскрытые в партии 10 аудита 04.05.2026. '
|
||
'Capabilities (channel, supports_*) добавлены в v8.3 по итогам партии 13.3.5.';
|
||
|
||
COMMENT ON COLUMN suppliers.settings_schema IS
|
||
'JSON Schema для per-supplier настроек проекта. Для B2: sender_name + keyword. '
|
||
'Для B3: только sender_name. UI генерирует форму project_suppliers.settings.';
|
||
|
||
COMMENT ON COLUMN suppliers.channel IS
|
||
'Тип канала приёма данных. Определяет, какие поля проекта релевантны '
|
||
'(sites — домены/CSV; calls — телефонные номера; sms — sender_name + keyword).';
|
||
|
||
COMMENT ON COLUMN suppliers.supports_sender_name IS
|
||
'Поддерживает ли поставщик поле "Наименование отправителя" (для SMS-канала). '
|
||
'B2=TRUE, B3=TRUE, B1=FALSE.';
|
||
|
||
COMMENT ON COLUMN suppliers.supports_keyword IS
|
||
'Поддерживает ли поставщик поле "Ключевое слово". '
|
||
'Только B2=TRUE (партия 13.3.5: "только по поставщику B2").';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- document_sequences — нумерация счетов/УПД (раздел 7.4, 20.10.4)
|
||
-- Блокировка FOR UPDATE при выдаче следующего номера — избегаем дыр
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE document_sequences (
|
||
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
|
||
document_type VARCHAR(20) NOT NULL, -- invoice, upd, upd_correction
|
||
year INT NOT NULL,
|
||
last_number INT NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (legal_entity_id, document_type, year)
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_admin_users — админы SaaS (НОВАЯ, спецификация админки v8.1)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_admin_users (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
email VARCHAR(255) UNIQUE NOT NULL,
|
||
full_name VARCHAR(255) NOT NULL,
|
||
password_hash VARCHAR(255) NOT NULL, -- bcrypt cost=12
|
||
role VARCHAR(20) NOT NULL
|
||
CHECK (role IN ('super_admin','support','finance','compliance','dev_oncall','read_only')),
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
-- 2FA (обязательно для всех админов; v8.5 OPEN-И-13: при sso_provider != 'local'
|
||
-- локальный 2FA выключен и заменён на 2FA провайдера IDP)
|
||
totp_secret_enc TEXT, -- зашифровано Crypt::encryptString
|
||
totp_enabled_at TIMESTAMPTZ,
|
||
-- v8.5 (OPEN-И-13): SSO + JIT-provisioning + break-glass.
|
||
-- sso_provider — кто аутентифицировал (Yandex 360 OIDC по умолчанию;
|
||
-- 'local' — fallback для break-glass и dev). При is_break_glass=TRUE этот
|
||
-- аккаунт обходит SSO для аварийного входа при недоступности IDP, но force-2FA
|
||
-- через TOTP остаётся. Логика guard'ов — в SaasAdminAuthService (narrative §22.7).
|
||
sso_provider VARCHAR(32) NOT NULL DEFAULT 'yandex360'
|
||
CHECK (sso_provider IN ('yandex360','local')),
|
||
is_break_glass BOOLEAN NOT NULL DEFAULT FALSE,
|
||
-- IP allow-list (per-user, дополняет глобальный)
|
||
allowed_ips JSONB, -- ["10.0.0.0/8","1.2.3.4"]
|
||
-- Безопасность
|
||
password_changed_at TIMESTAMPTZ DEFAULT NOW(),
|
||
failed_login_count INT DEFAULT 0,
|
||
locked_until TIMESTAMPTZ,
|
||
-- Аудит
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
created_by BIGINT REFERENCES saas_admin_users(id),
|
||
last_login_at TIMESTAMPTZ,
|
||
deleted_at TIMESTAMPTZ -- soft delete
|
||
);
|
||
|
||
CREATE INDEX idx_saas_admin_users_active ON saas_admin_users(is_active) WHERE deleted_at IS NULL;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_admin_recovery_codes — recovery-коды 2FA для админов (НОВАЯ)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_admin_recovery_codes (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id) ON DELETE CASCADE,
|
||
code_hash VARCHAR(255) NOT NULL,
|
||
used_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_saas_admin_recovery_user ON saas_admin_recovery_codes(admin_user_id) WHERE used_at IS NULL;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_admin_sessions — сессии админов (НОВАЯ)
|
||
-- Ю-1: добавлены два поля для режима impersonation. В обычном режиме оба NULL.
|
||
-- В режиме impersonation оба заполнены, FK на impersonation_tokens (см. ниже).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_admin_sessions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id) ON DELETE CASCADE,
|
||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||
ip_address INET NOT NULL, -- проверяется на каждом запросе vs allow-list
|
||
user_agent TEXT,
|
||
last_active_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
-- Impersonation (Ю-1)
|
||
impersonating_tenant_id BIGINT, -- FK на tenants(id) добавлен ниже после CREATE TABLE tenants
|
||
impersonating_token_id BIGINT, -- FK добавлен ниже после CREATE TABLE impersonation_tokens
|
||
CONSTRAINT chk_admin_session_impersonation CHECK (
|
||
(impersonating_tenant_id IS NULL AND impersonating_token_id IS NULL)
|
||
OR (impersonating_tenant_id IS NOT NULL AND impersonating_token_id IS NOT NULL)
|
||
)
|
||
);
|
||
|
||
CREATE INDEX idx_saas_admin_sessions_user ON saas_admin_sessions(admin_user_id, last_active_at DESC);
|
||
-- expires_at NOT NULL → partial index по WHERE expires_at IS NOT NULL бесполезен; индекс по полю — для cron-очистки expired-сессий.
|
||
CREATE INDEX idx_saas_admin_sessions_expires ON saas_admin_sessions(expires_at);
|
||
CREATE INDEX idx_saas_admin_sessions_imp ON saas_admin_sessions(impersonating_tenant_id) WHERE impersonating_tenant_id IS NOT NULL;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- impersonation_tokens — одноразовые коды для режима "Войти как клиент" (Ю-1)
|
||
-- Workflow:
|
||
-- 1. Админ нажимает "Войти как…", указывает основание (>= 30 символов).
|
||
-- 2. Создаётся запись, на tenant.contact_email уходит письмо с 6-значным кодом.
|
||
-- 3. Срок жизни кода — 15 минут.
|
||
-- 4. Клиент передаёт код админу любым способом, админ вводит в админке.
|
||
-- 5. Создаётся saas_admin_sessions с impersonating_*, открывается клиентское
|
||
-- приложение с принудительным красным баннером.
|
||
-- 6. Сессия живёт 1 час, потом session_ended_at = NOW().
|
||
-- 7. После 5 неверных вводов кода — токен инвалидируется (failed_attempts).
|
||
-- 8. По завершении сессии — email клиенту со списком действий админа.
|
||
--
|
||
-- НЕ tenant-уровневая (saas-уровневая, как saas_admin_*). RLS не применяется
|
||
-- намеренно: токены создаются и используются только из админки SaaS под
|
||
-- crm_admin_user (BYPASSRLS). Tenant-приложение к этой таблице не обращается.
|
||
-- Изоляция между админами обеспечивается на уровне приложения (поле
|
||
-- requested_by) и audit log'а.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE impersonation_tokens (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL, -- FK + ON DELETE CASCADE на tenants(id) добавлены ниже после CREATE TABLE tenants
|
||
requested_by BIGINT NOT NULL REFERENCES saas_admin_users(id),
|
||
code_hash VARCHAR(255) NOT NULL, -- bcrypt 6-значного кода
|
||
reason TEXT NOT NULL, -- основание (мин. 30 символов)
|
||
sent_to_email VARCHAR(255) NOT NULL, -- snapshot tenant.contact_email на момент запроса
|
||
expires_at TIMESTAMPTZ NOT NULL, -- created_at + 15 минут
|
||
used_at TIMESTAMPTZ, -- момент успешного ввода
|
||
session_id BIGINT REFERENCES saas_admin_sessions(id),
|
||
session_ended_at TIMESTAMPTZ,
|
||
failed_attempts INT DEFAULT 0, -- защита от брутфорса
|
||
invalidated_at TIMESTAMPTZ, -- ручная инвалидация админом или клиентом
|
||
-- v8.5 (CTO-15 + Ю-9): two-person approval для тенантов с
|
||
-- pd_subject_request.processing_restricted=TRUE ИЛИ chargeback_unrecovered_rub > 0.
|
||
-- При создании токена SaasAdminAuthService проверяет флаги тенанта; если
|
||
-- хотя бы один TRUE — second_approver_id обязателен (роль 'compliance').
|
||
-- Полный flow guard'ов в narrative §22.7.X. NULL для обычных тенантов.
|
||
second_approver_id BIGINT REFERENCES saas_admin_users(id),
|
||
second_approval_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
CONSTRAINT chk_imp_reason_len CHECK (length(reason) >= 30)
|
||
);
|
||
|
||
CREATE INDEX idx_imp_tokens_active ON impersonation_tokens(tenant_id, expires_at)
|
||
WHERE used_at IS NULL AND failed_attempts < 5 AND invalidated_at IS NULL;
|
||
CREATE INDEX idx_imp_tokens_admin ON impersonation_tokens(requested_by, created_at DESC);
|
||
|
||
-- v8.11 (audit P0-02): RLS-isolation между тенантами для одноразовых impersonation-токенов
|
||
ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY;
|
||
CREATE POLICY tenant_isolation ON impersonation_tokens
|
||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
|
||
-- Forward FK на impersonation_tokens
|
||
ALTER TABLE saas_admin_sessions
|
||
ADD CONSTRAINT fk_admin_sessions_imp_token
|
||
FOREIGN KEY (impersonating_token_id) REFERENCES impersonation_tokens(id);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- system_settings — глобальные настройки SaaS (раздел 3.4)
|
||
-- updated_by — FK на saas_admin_users (РАСШИРЕНИЕ v8.1: в v8.0 без FK)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE system_settings (
|
||
key VARCHAR(100) PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
type VARCHAR(20) DEFAULT 'string' -- string, int, decimal, json, bool
|
||
CHECK (type IN ('string','int','decimal','json','bool')),
|
||
description TEXT,
|
||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_by BIGINT REFERENCES saas_admin_users(id) -- было BIGINT без FK в v8.0
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- Laravel-стандартные таблицы (DDL отсутствует в v8.0, реконструировано)
|
||
-- -----------------------------------------------------------------------------
|
||
|
||
-- Очередь Laravel — failed jobs
|
||
CREATE TABLE failed_jobs (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
uuid UUID UNIQUE NOT NULL,
|
||
connection TEXT NOT NULL,
|
||
queue TEXT NOT NULL,
|
||
payload TEXT NOT NULL,
|
||
exception TEXT NOT NULL,
|
||
failed_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
-- Сброс паролей (Laravel-стандарт, раздел 7.1 упоминает таблицу)
|
||
CREATE TABLE password_resets (
|
||
email VARCHAR(255) PRIMARY KEY,
|
||
token VARCHAR(255) NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_password_resets_token ON password_resets(token);
|
||
|
||
-- Подтверждение email (упомянуто в 4.1 шаг 2 и 7.1)
|
||
CREATE TABLE email_verifications (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users
|
||
email VARCHAR(255) NOT NULL,
|
||
token VARCHAR(255) UNIQUE NOT NULL,
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
verified_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_email_verifications_token ON email_verifications(token) WHERE verified_at IS NULL;
|
||
|
||
|
||
-- =============================================================================
|
||
-- 3. TENANTS (раздел 3.4)
|
||
-- FK на tariff_plans уже создан выше
|
||
-- =============================================================================
|
||
|
||
CREATE TABLE tenants (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
subdomain VARCHAR(63) UNIQUE NOT NULL, -- "client1"
|
||
organization_name VARCHAR(255) NOT NULL,
|
||
contact_email VARCHAR(255) NOT NULL,
|
||
status VARCHAR(20) DEFAULT 'active'
|
||
CHECK (status IN ('active','suspended','pending_email_confirm','deleted')),
|
||
webhook_token VARCHAR(64) UNIQUE NOT NULL,
|
||
webhook_token_rotated_at TIMESTAMPTZ,
|
||
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
|
||
locale VARCHAR(10) DEFAULT 'ru',
|
||
-- Биллинг
|
||
current_tariff_id BIGINT REFERENCES tariff_plans(id),
|
||
balance_rub DECIMAL(12,2) DEFAULT 0,
|
||
balance_leads INT DEFAULT 0,
|
||
is_trial BOOLEAN DEFAULT TRUE,
|
||
trial_leads_used INT DEFAULT 0,
|
||
-- Chargeback (Ю-3): непокрытая балансом часть внешнего возврата.
|
||
-- Если > 0 → tenants.status = 'suspended', клиент видит экран погашения в /billing.
|
||
chargeback_unrecovered_rub DECIMAL(12,2) DEFAULT 0
|
||
CHECK (chargeback_unrecovered_rub >= 0),
|
||
-- Активность
|
||
last_activity_at TIMESTAMPTZ,
|
||
last_webhook_at TIMESTAMPTZ,
|
||
-- Целевой объём лидов в день — сигнал для саппорта (Биз-16, партия 13.2.2).
|
||
-- НЕ лимит, НЕ биллинговая величина. Используется только для отображения
|
||
-- в админке SaaS (саппорту видно, сколько клиент хочет получать в день).
|
||
desired_daily_numbers INT
|
||
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0),
|
||
-- v8.19 (Plan 4): месячный счётчик доставленных лидов per-tenant.
|
||
-- Сбрасывается ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Europe/Moscow).
|
||
-- Используется PricingTierResolver на горячем пути RouteSupplierLeadJob
|
||
-- для O(1) lookup'а текущей ступени тарифа.
|
||
delivered_in_month INT NOT NULL DEFAULT 0
|
||
CHECK (delivered_in_month >= 0),
|
||
-- v8.5 (OPEN-И-19): hard-limit количества активных API-ключей.
|
||
-- Защита от DoS через создание тысяч ключей. Default 5 — комфортный
|
||
-- baseline; max 10 — для intg-партнёров. Hard limit, не soft warning.
|
||
api_key_limit INT NOT NULL DEFAULT 5
|
||
CHECK (api_key_limit BETWEEN 1 AND 10),
|
||
-- v8.5 (Биз-20): Telegram-бот для нотификаций менеджерам. Спринт 9.
|
||
-- Хранится в зашифрованном виде через Crypt::encryptString. Вне спринта 9
|
||
-- остаётся NULL и фича не активна (UI скрывает Telegram-настройки).
|
||
telegram_bot_token TEXT,
|
||
-- Plan 5 Task 3: per-tenant override лимитов тарифа.
|
||
-- Используется ProjectService::create() для проверки max_projects.
|
||
-- {"max_users":5,"max_projects":10,"api_rps":60}
|
||
limits JSONB NOT NULL DEFAULT '{}',
|
||
-- Метаданные
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5)
|
||
);
|
||
|
||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
|
||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||
|
||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||
-- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id).
|
||
-- Добавляем здесь, потому что tenants создаётся ниже по тексту схемы.
|
||
ALTER TABLE saas_admin_sessions
|
||
ADD CONSTRAINT fk_admin_sessions_imp_tenant
|
||
FOREIGN KEY (impersonating_tenant_id) REFERENCES tenants(id);
|
||
|
||
ALTER TABLE impersonation_tokens
|
||
ADD CONSTRAINT fk_impersonation_tokens_tenant
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- tenant_custom_domains (раздел 3.3, опционально, post-MVP)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE tenant_custom_domains (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
domain VARCHAR(255) UNIQUE NOT NULL,
|
||
ssl_status VARCHAR(20) DEFAULT 'pending'
|
||
CHECK (ssl_status IN ('pending','active','error')),
|
||
verified_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
|
||
-- =============================================================================
|
||
-- 4. TENANT-УРОВНЕВЫЕ БАЗОВЫЕ ТАБЛИЦЫ
|
||
-- =============================================================================
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- users (раздел 7.3)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE users (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
email VARCHAR(255) NOT NULL,
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
first_name VARCHAR(255),
|
||
last_name VARCHAR(255),
|
||
phone VARCHAR(20),
|
||
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
|
||
avatar_path VARCHAR(500),
|
||
-- 2FA
|
||
totp_secret TEXT, -- ШИФРУЕТСЯ Crypt::encrypt (encrypted ~256 chars > VARCHAR(255))
|
||
totp_enabled BOOLEAN DEFAULT FALSE,
|
||
-- Уведомления (раздел 18.5 v8.0 + CTO-4 решение).
|
||
-- Матрица 8 типов × до 3 каналов (in-app / push / email).
|
||
-- Звук — отдельный глобальный toggle, потому что играет именно в открытой вкладке.
|
||
notification_preferences JSONB NOT NULL DEFAULT '{
|
||
"new_lead": {"inapp": true, "push": true, "email": false},
|
||
"reminder": {"inapp": true, "push": true, "email": true},
|
||
"low_balance": {"email": true},
|
||
"zero_balance": {"email": true},
|
||
"topup_success": {"email": true},
|
||
"invoice_paid": {"email": true},
|
||
"new_device_login": {"email": true},
|
||
"marketing": {"email": false}
|
||
}'::jsonb,
|
||
sound_enabled BOOLEAN DEFAULT TRUE, -- общий toggle звука для in-app уведомлений
|
||
-- v8.5 (Биз-20): Telegram-канал нотификаций. Спринт 9. До спринта 9
|
||
-- остаётся NULL и UI скрывает Telegram-настройки. Подключение — через
|
||
-- бот /start: пользователь отправляет код, бот связывает chat_id с user_id.
|
||
telegram_user_id BIGINT, -- Telegram chat_id
|
||
-- Статус
|
||
email_verified_at TIMESTAMPTZ,
|
||
last_login_at TIMESTAMPTZ,
|
||
last_active_at TIMESTAMPTZ,
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
-- Аудит
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
deleted_at TIMESTAMPTZ, -- soft delete + анонимизация
|
||
UNIQUE (tenant_id, email),
|
||
-- CTO-4: базовая защита, что notification_preferences — JSON-объект.
|
||
-- Полная валидация структуры — на уровне приложения через Form Request.
|
||
CONSTRAINT chk_users_notif_prefs_object CHECK (jsonb_typeof(notification_preferences) = 'object')
|
||
);
|
||
|
||
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email) WHERE deleted_at IS NULL;
|
||
|
||
-- FK на email_verifications.user_id (forward reference)
|
||
ALTER TABLE email_verifications
|
||
ADD CONSTRAINT fk_email_verifications_user
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- projects (раздел 7.3, 9; расширено в v8.2 по аудиту партии 10)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Расширения v8.2 — 6 полей для соответствия модели оригинала (Прил. М §3.2):
|
||
-- • daily_limit_target — целевой лимит лидов в день, заданный клиентом
|
||
-- • effective_daily_limit_today — реальный лимит после автокоррекции по балансу
|
||
-- • effective_limit_calculated_at — когда последний раз пересчитывали
|
||
-- • region_mask + region_mode — фильтр по 8 федеральным округам РФ (партия 10.3 секция 6-7)
|
||
-- • delivery_days_mask — битмаска 7 дней приёма лидов (партия 10.3 секция 11)
|
||
--
|
||
-- Бизнес-правило автокоррекции (партия 10.6):
|
||
-- effective_daily_limit_today = MIN(daily_limit_target, FLOOR(balance / lead_cost))
|
||
-- Пересчитывает сервис EffectiveLimitCalculator (Laravel) по триггерам:
|
||
-- - cron limits:recalc в 00:00 МСК для всех активных проектов;
|
||
-- - после balance_transactions.commit;
|
||
-- - после списания за лид (если effective < target);
|
||
-- - при создании проекта; при смене тарифа.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE projects (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
name VARCHAR(255) NOT NULL, -- "чистое" имя без префиксов B1_/B2_/B3_
|
||
tag VARCHAR(255),
|
||
type VARCHAR(20) DEFAULT 'webhook'
|
||
CHECK (type IN ('webhook','manual','import')),
|
||
-- РАСШИРЕНИЕ v8.12: supplier integration (Plan 1/5 Task 1, spec §2.1)
|
||
signal_type VARCHAR(16), -- 'site' | 'call' | 'sms' | NULL (до выбора поставщика)
|
||
signal_identifier TEXT, -- URL/телефон для site/call; NULL для sms
|
||
sms_senders JSONB, -- массив sender-имён (для signal_type='sms')
|
||
sms_keyword TEXT, -- ключевое слово (опционально, signal_type='sms')
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
-- РАСШИРЕНИЕ v8.2: динамические лимиты (партия 10.6 аудита)
|
||
daily_limit_target INT NOT NULL DEFAULT 10, -- что хочет клиент (default 10 = паритет с оригиналом)
|
||
effective_daily_limit_today INT, -- что реально на сегодня (NULL = ещё не считалось)
|
||
-- РАСШИРЕНИЕ v8.12: счётчик доставленных в текущем месяце (для месячного лимита)
|
||
delivered_in_month INTEGER NOT NULL DEFAULT 0
|
||
CHECK (delivered_in_month >= 0),
|
||
-- РАСШИРЕНИЕ v8.18 (Plan 2/5): дневной счётчик доставленных лидов.
|
||
-- Сбрасывается cron'ом ResetDeliveredTodayCommand в 00:00 МСК.
|
||
-- Используется в LeadRouter для проверки квоты (delivered_today < effective_daily_limit).
|
||
delivered_today INTEGER NOT NULL DEFAULT 0
|
||
CHECK (delivered_today >= 0),
|
||
-- РАСШИРЕНИЕ v8.12: ссылки на supplier_projects (B1/B2/B3) — FK добавлены ниже после CREATE TABLE supplier_projects (v8.17)
|
||
supplier_b1_project_id BIGINT,
|
||
supplier_b2_project_id BIGINT,
|
||
supplier_b3_project_id BIGINT,
|
||
effective_limit_calculated_at TIMESTAMPTZ,
|
||
-- РАСШИРЕНИЕ v8.2: регионы и дни (партия 10.3 секции 6, 7, 11)
|
||
region_mask INT NOT NULL DEFAULT 255,
|
||
-- битмаска 8 ФО РФ: бит 1=Центральный, 2=Северо-Западный, 4=Южный,
|
||
-- 8=Северо-Кавказский, 16=Приволжский, 32=Уральский, 64=Сибирский,
|
||
-- 128=Дальневосточный. 255 = все 8 округов.
|
||
region_mode VARCHAR(10) NOT NULL DEFAULT 'include'
|
||
CHECK (region_mode IN ('include','exclude')),
|
||
-- 'include' = принимать только из выбранных, 'exclude' = принимать кроме выбранных
|
||
delivery_days_mask INT NOT NULL DEFAULT 127,
|
||
-- битмаска дней недели: бит 1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс.
|
||
-- 127 = все 7 дней (паритет с формой создания нового проекта в оригинале).
|
||
-- 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,
|
||
archived_at TIMESTAMPTZ NULL, -- v8.20 (Plan 5): soft archive flow (отличие от is_active=false который = pause)
|
||
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);
|
||
|
||
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 = не считалось.';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- supplier_projects — SaaS-level агрегатные проекты у поставщиков (v8.13, Plan 1/5 Task 2)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.2.
|
||
-- Один supplier_project отражает проект, фактически созданный/синхронизированный у поставщика
|
||
-- (B1/B2/B3). Несколько Лидерра-tenant'ов могут шарить один supplier_project (sharing-model
|
||
-- из §2.3 spec'а): для сайта/звонка — по domain/phone; для СМС — по (sender, keyword) на B2
|
||
-- или (sender) на B3.
|
||
--
|
||
-- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Доступ из tenant-приложения
|
||
-- закрыт через REVOKE ALL FROM crm_app_user (defense-in-depth, аналогично 6 saas-таблицам
|
||
-- из v8.5 OPEN-И-14). Сервис-слой синхронизации работает под elevated ролью.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE supplier_projects (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3
|
||
signal_type VARCHAR(16) NOT NULL, -- site / call / sms
|
||
unique_key TEXT NOT NULL, -- domain / phone / sender+keyword / sender
|
||
supplier_external_id VARCHAR(64), -- внутренний id у поставщика
|
||
current_limit INTEGER NOT NULL DEFAULT 0
|
||
CHECK (current_limit >= 0),
|
||
current_workdays JSONB, -- объединение workdays активных tenant'ов
|
||
current_regions JSONB, -- объединение regions активных tenant'ов
|
||
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||
last_synced_at TIMESTAMPTZ,
|
||
inactive_since TIMESTAMPTZ, -- момент когда последний tenant отвалился (TTL 180 дней)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
CONSTRAINT chk_supplier_projects_platform
|
||
CHECK (platform IN ('B1','B2','B3')),
|
||
CONSTRAINT chk_supplier_projects_signal_type
|
||
CHECK (signal_type IN ('site','call','sms')),
|
||
CONSTRAINT chk_supplier_projects_sync_status
|
||
CHECK (sync_status IN ('pending','ok','failed')),
|
||
-- B1 не поддерживает СМС (см. spec §2.2 — таблица platform×signal_type)
|
||
CONSTRAINT chk_supplier_projects_b1_not_for_sms
|
||
CHECK (NOT (platform = 'B1' AND signal_type = 'sms'))
|
||
);
|
||
|
||
CREATE UNIQUE INDEX supplier_projects_platform_unique_key_unique
|
||
ON supplier_projects(platform, unique_key);
|
||
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.17 (Plan 1/5 Task 2 fix): defense-in-depth — Project-уровень тоже запрещает B1+SMS.
|
||
-- supplier_projects уже имеет CHECK chk_supplier_projects_b1_not_for_sms; здесь дублируем
|
||
-- на projects, чтобы исключить логическую несостыковку «sms-проект привязан к B1-supplier».
|
||
ALTER TABLE projects
|
||
ADD CONSTRAINT chk_projects_b1_not_for_sms
|
||
CHECK (signal_type <> 'sms' OR supplier_b1_project_id IS NULL);
|
||
|
||
-- v8.13 (Plan 1/5 Task 2): defense-in-depth — REVOKE ALL FROM crm_app_user.
|
||
-- SaaS-уровневая таблица, tenant-приложению доступ запрещён даже теоретически.
|
||
-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) —
|
||
-- миграция оборачивает REVOKE в DO $$ EXISTS check.
|
||
--
|
||
-- REVOKE ALL ON supplier_projects FROM crm_app_user;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- pricing_tiers — SaaS-level конфигурация 7-ступенчатого объёмного тарифа (v8.14, Plan 1/5 Task 3)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.2.
|
||
-- Клиент Лидерры платит ступенчато: чем больше лидов получено в текущем месяце —
|
||
-- тем дешевле каждый следующий. Конфигурируется админом Лидерры; tenant'ы читают.
|
||
--
|
||
-- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Per-tenant override
|
||
-- out of scope для MVP (один тариф на всю Лидерру).
|
||
--
|
||
-- Цена хранится в копейках (integer) — избегаем floating-point округлений в money-расчётах
|
||
-- (1 руб = 100 копеек). UI converts at the edge (accessor в Eloquent-модели, Plan Task 7).
|
||
--
|
||
-- Поле leads_in_tier nullable: для последней ступени NULL = «всё свыше» (без верхней границы).
|
||
--
|
||
-- Доступ из tenant-приложения: SELECT only (через elevated service-role идут writes).
|
||
-- Conditional: dev runs as postgres superuser без crm_app_user — миграция оборачивает
|
||
-- REVOKE/GRANT в DO $$ EXISTS-check.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE pricing_tiers (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tier_no SMALLINT NOT NULL
|
||
CHECK (tier_no >= 0), -- unsigned-эквивалент: tier_no >= 0; диапазон 1..7 — отдельный CHECK ниже
|
||
leads_in_tier INTEGER -- NULL = «всё свыше» для последней ступени
|
||
CHECK (leads_in_tier IS NULL OR leads_in_tier >= 0),
|
||
price_per_lead_kopecks INTEGER NOT NULL
|
||
CHECK (price_per_lead_kopecks >= 0), -- копейки, integer (избегаем float)
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
effective_from DATE NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
CONSTRAINT chk_pricing_tiers_tier_no
|
||
CHECK (tier_no BETWEEN 1 AND 7)
|
||
);
|
||
|
||
CREATE UNIQUE INDEX pricing_tiers_tier_effective_unique
|
||
ON pricing_tiers(tier_no, effective_from);
|
||
CREATE INDEX pricing_tiers_is_active_effective_from_index
|
||
ON pricing_tiers(is_active, effective_from);
|
||
|
||
-- v8.14 (Plan 1/5 Task 3): SELECT-only для tenant-приложения.
|
||
-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) —
|
||
-- миграция оборачивает REVOKE/GRANT в DO $$ EXISTS check.
|
||
--
|
||
-- REVOKE ALL ON pricing_tiers FROM crm_app_user;
|
||
-- GRANT SELECT ON pricing_tiers TO crm_app_user;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- lead_charges — append-only ledger списаний за доставленный лид (v8.15, Plan 1/5 Task 4)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Tenant-scoped (RLS), append-only — каждая запись = факт списания с баланса клиента
|
||
-- по текущей ступени pricing_tiers при доставке лида в его проект.
|
||
-- Composite FK на партиционированную deals(id, received_at) — DEFERRABLE INITIALLY DEFERRED
|
||
-- для атомарного INSERT deal+charge в одной транзакции.
|
||
-- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE lead_charges (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
deal_id BIGINT NOT NULL,
|
||
deal_received_at TIMESTAMPTZ NOT NULL,
|
||
tier_no SMALLINT NOT NULL,
|
||
price_per_lead_kopecks INTEGER NOT NULL,
|
||
charge_source VARCHAR(8) NOT NULL DEFAULT 'rub'
|
||
CHECK (charge_source IN ('prepaid','rub')),
|
||
charged_at TIMESTAMPTZ NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT chk_lead_charges_prepaid_zero_price
|
||
CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0)
|
||
);
|
||
|
||
-- Composite FK на deals(id, received_at) добавляется в самом конце файла,
|
||
-- ПОСЛЕ создания партиционированной deals (section 5). DEFERRABLE INITIALLY DEFERRED —
|
||
-- обязательно для атомарного INSERT deal+charge в одной транзакции.
|
||
|
||
CREATE INDEX lead_charges_tenant_id_charged_at_index
|
||
ON lead_charges(tenant_id, charged_at);
|
||
CREATE INDEX lead_charges_deal_id_deal_received_at_index
|
||
ON lead_charges(deal_id, deal_received_at);
|
||
|
||
-- RLS: tenant_isolation. ENABLE + FORCE — даже владелец таблицы (postgres) подчиняется
|
||
-- политике, иначе superuser обходит RLS. POLICY с USING + WITH CHECK защищает все DML.
|
||
ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY;
|
||
CREATE POLICY tenant_isolation ON lead_charges
|
||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
|
||
-- v8.15 (Plan 1/5 Task 4): SELECT + INSERT для tenant-приложения (append-only ledger).
|
||
-- UPDATE/DELETE недопустимы — append-only гарантия для биллинга/аудита.
|
||
-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) —
|
||
-- миграция оборачивает GRANT в DO $$ EXISTS check.
|
||
--
|
||
-- GRANT SELECT, INSERT ON lead_charges TO crm_app_user;
|
||
-- GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO crm_app_user;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- supplier_sync_log — SaaS-level audit log AJAX-синхронизаций с поставщиком (v8.16, Plan 1/5 Task 5)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Append-only journal попыток rt-project-{save,update,delete} + session_refresh
|
||
-- к поставщику crm.bp-gr.ru. Используется для отладки, retry-логики и алертов.
|
||
-- НЕ tenant-scoped — события агрегатные на уровне SaaS (один supplier_project разделяется
|
||
-- между tenants, лог общий).
|
||
-- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE supplier_sync_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
|
||
action VARCHAR(32) NOT NULL,
|
||
request_payload JSONB,
|
||
response_body JSONB,
|
||
http_status SMALLINT,
|
||
error_message TEXT,
|
||
duration_ms INTEGER,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
|
||
CONSTRAINT chk_supplier_sync_log_action
|
||
CHECK (action IN ('create','update','delete','disable','session_refresh'))
|
||
);
|
||
|
||
CREATE INDEX supplier_sync_log_supplier_project_id_index ON supplier_sync_log(supplier_project_id);
|
||
CREATE INDEX supplier_sync_log_action_index ON supplier_sync_log(action);
|
||
CREATE INDEX supplier_sync_log_created_at_index ON supplier_sync_log(created_at);
|
||
|
||
-- v8.16 (Plan 1/5 Task 5): SaaS-level audit log; tenant-приложение не должно его читать/писать
|
||
-- напрямую. REVOKE ALL применяется в production через db/02_grants.sql; в dev — DO $$ EXISTS check.
|
||
--
|
||
-- REVOKE ALL ON supplier_sync_log FROM crm_app_user;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- supplier_csv_reconcile_log — журнал hourly CSV reconciliation (v8.19, Plan 4)
|
||
-- -----------------------------------------------------------------------------
|
||
-- SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log.
|
||
-- CsvReconcileJob записывает 1 строку на hourly run: started_at, окно,
|
||
-- метрики, drift_ratio. drift > 5% → email алерт; alert_email_sent_at timestamp.
|
||
-- Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE supplier_csv_reconcile_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
started_at TIMESTAMPTZ NOT NULL,
|
||
finished_at TIMESTAMPTZ,
|
||
window_start TIMESTAMPTZ NOT NULL,
|
||
window_end TIMESTAMPTZ NOT NULL,
|
||
total_csv_rows INTEGER,
|
||
matched_count INTEGER,
|
||
recovered_count INTEGER,
|
||
drift_ratio NUMERIC(5,4),
|
||
status VARCHAR(16) NOT NULL DEFAULT 'running'
|
||
CHECK (status IN ('running','ok','drift_alert','failed')),
|
||
error_message TEXT,
|
||
alert_email_sent_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX supplier_csv_reconcile_log_started_at_index
|
||
ON supplier_csv_reconcile_log(started_at DESC);
|
||
CREATE INDEX supplier_csv_reconcile_log_status_index
|
||
ON supplier_csv_reconcile_log(status)
|
||
WHERE status IN ('drift_alert','failed');
|
||
|
||
-- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser.
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- project_suppliers — m2m связь "проект ↔ поставщики" (НОВАЯ в v8.2)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Контекст: в карточке создания проекта в оригинале (партия 10.4) есть три
|
||
-- чекбокса B1/B2/B3 — все checked по умолчанию. В карточке редактирования
|
||
-- они readonly (партия 10.3). В нашем аналоге сохраняем UX, но разрешаем
|
||
-- редактирование (улучшение).
|
||
--
|
||
-- Per-supplier настройки (settings) хранят значения по схеме
|
||
-- suppliers.settings_schema. Например, для B2: {"sender_name": "MYCO", "keyword": "ЗАКАЗ"}
|
||
--
|
||
-- TENANT-уровневая через FK на projects (RLS наследуется через project.tenant_id).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE project_suppliers (
|
||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
|
||
settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- по schema suppliers.settings_schema
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
PRIMARY KEY (project_id, supplier_id)
|
||
);
|
||
|
||
CREATE INDEX idx_project_suppliers_supplier ON project_suppliers(supplier_id);
|
||
CREATE INDEX idx_project_suppliers_active
|
||
ON project_suppliers(project_id)
|
||
WHERE is_active = TRUE;
|
||
|
||
COMMENT ON TABLE project_suppliers IS
|
||
'M2M связь "проект ↔ поставщики". Один проект может принимать данные '
|
||
'от нескольких поставщиков. По умолчанию при создании проекта подключаются '
|
||
'все is_active=TRUE поставщики (паритет с формой создания в оригинале).';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- project_user_assignments — m2m "проект ↔ менеджеры" + skills (НОВАЯ v8.5, CTO-16)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Назначение: skill-based / regional routing для projects.assignment_strategy
|
||
-- IN ('round_robin','least_loaded') (Биз-17 Post-MVP). На MVP при assignment_
|
||
-- strategy='manual' таблица не используется — менеджер выбирается вручную в
|
||
-- карточке сделки. После Post-MVP cron lead-router-будет читать active members
|
||
-- проекта из этой таблицы и распределять входящих лидов.
|
||
--
|
||
-- skills — JSONB-массив строк (slug-и навыков): ['it_b2b','retail','sms_sender_NAME1'].
|
||
-- При assignment_strategy='round_robin' — игнорируется. При 'least_loaded' —
|
||
-- для статистики. Skill-based routing (intersection projects.required_skills
|
||
-- × users.skills) — Post-MVP-расширение.
|
||
--
|
||
-- TENANT-уровневая через JOIN на projects (RLS по tenant_id проекта).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE project_user_assignments (
|
||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
skills JSONB NOT NULL DEFAULT '[]'::jsonb, -- ['it_b2b','retail',...]
|
||
is_active BOOLEAN DEFAULT TRUE, -- временно отключить менеджера в проекте
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
PRIMARY KEY (project_id, user_id),
|
||
CONSTRAINT chk_pua_skills_array CHECK (jsonb_typeof(skills) = 'array')
|
||
);
|
||
|
||
CREATE INDEX idx_project_user_assignments_user
|
||
ON project_user_assignments(user_id)
|
||
WHERE is_active = TRUE;
|
||
|
||
COMMENT ON TABLE project_user_assignments IS
|
||
'M2M "проект ↔ менеджеры" с per-assignment skills для skill-based routing. '
|
||
'При projects.assignment_strategy=''manual'' (MVP) таблица не используется. '
|
||
'При round_robin/least_loaded (Post-MVP) — пул активных менеджеров проекта.';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- project_limit_adjustments — лог автокоррекций лимитов (НОВАЯ в v8.2)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Контекст: партия 10.7 зафиксировала в истории действий проекта системные
|
||
-- записи "Проект был запущен с лимитом N по причине нехватки баланса.
|
||
-- По факту M". Это лог автокоррекции, который у нас будет вести сервис
|
||
-- EffectiveLimitCalculator при каждом изменении effective_daily_limit_today.
|
||
--
|
||
-- Назначение:
|
||
-- - аналитика: сколько раз лимит срезался по балансу;
|
||
-- - compliance: следить, что коррекция работает корректно;
|
||
-- - расследование споров с клиентом ("почему лидов было меньше ожидаемого").
|
||
--
|
||
-- TENANT-уровневая (через tenant_id) → RLS-политика tenant_isolation.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE project_limit_adjustments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
target_limit INT NOT NULL, -- что хотел клиент
|
||
effective_limit INT NOT NULL, -- что получилось
|
||
adjustment_reason VARCHAR(50) NOT NULL,
|
||
balance_at_calc_rub DECIMAL(12,2), -- snapshot баланса на момент расчёта
|
||
lead_cost_at_calc_rub DECIMAL(10,2), -- snapshot стоимости лида (из тарифа)
|
||
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
CONSTRAINT chk_project_limit_adj_reason
|
||
CHECK (adjustment_reason IN (
|
||
'balance_low', -- скорректирован вниз из-за нехватки баланса
|
||
'balance_recovered', -- восстановлен до target после пополнения
|
||
'manual_override', -- админ вручную изменил
|
||
'tariff_change', -- сменился тариф → пересчёт
|
||
'daily_recalc', -- плановый пересчёт в 00:00 МСК
|
||
'project_created', -- первый расчёт для нового проекта
|
||
'target_changed' -- клиент изменил daily_limit_target
|
||
)),
|
||
CONSTRAINT chk_project_limit_adj_limits_nonneg
|
||
CHECK (target_limit >= 0 AND effective_limit >= 0),
|
||
CONSTRAINT chk_project_limit_adj_effective_le_target
|
||
CHECK (effective_limit <= target_limit OR adjustment_reason = 'manual_override')
|
||
);
|
||
|
||
CREATE INDEX idx_project_limit_adj_project_calc
|
||
ON project_limit_adjustments(project_id, calculated_at DESC);
|
||
CREATE INDEX idx_project_limit_adj_tenant_calc
|
||
ON project_limit_adjustments(tenant_id, calculated_at DESC);
|
||
CREATE INDEX idx_project_limit_adj_balance_low
|
||
ON project_limit_adjustments(tenant_id, calculated_at DESC)
|
||
WHERE adjustment_reason = 'balance_low';
|
||
|
||
COMMENT ON TABLE project_limit_adjustments IS
|
||
'Лог автоматических корректировок дневного лимита проекта. Заполняется '
|
||
'сервисом EffectiveLimitCalculator (Laravel) при каждом изменении '
|
||
'projects.effective_daily_limit_today.';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- tenant_status_overrides — переименование настраиваемых статусов (раздел 7.3, 8.5)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE tenant_status_overrides (
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
status_slug VARCHAR(50) NOT NULL REFERENCES lead_statuses(slug),
|
||
custom_name VARCHAR(100) NOT NULL,
|
||
PRIMARY KEY (tenant_id, status_slug)
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- Безопасность: recovery codes, sessions, api keys, auth log (раздел 18.4, 22.8)
|
||
-- -----------------------------------------------------------------------------
|
||
|
||
CREATE TABLE user_recovery_codes (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
code_hash VARCHAR(255) NOT NULL, -- bcrypt hash
|
||
used_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_recovery_user ON user_recovery_codes(user_id) WHERE used_at IS NULL;
|
||
|
||
|
||
CREATE TABLE user_sessions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||
ip_address INET,
|
||
user_agent TEXT,
|
||
geo_country VARCHAR(50),
|
||
geo_city VARCHAR(100),
|
||
last_active_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL
|
||
);
|
||
|
||
CREATE INDEX idx_sessions_user ON user_sessions(user_id, last_active_at DESC);
|
||
-- expires_at NOT NULL → partial index по WHERE expires_at IS NOT NULL бесполезен; индекс по полю — для cron-очистки expired-сессий.
|
||
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at);
|
||
|
||
|
||
CREATE TABLE api_keys (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||
name VARCHAR(255) NOT NULL,
|
||
key_hash VARCHAR(255) UNIQUE NOT NULL, -- bcrypt hash
|
||
key_prefix VARCHAR(10) NOT NULL, -- первые 8 символов для UI
|
||
scopes JSONB DEFAULT '["read"]', -- ["read","write","delete"]
|
||
last_used_at TIMESTAMPTZ,
|
||
last_used_ip INET,
|
||
-- v8.5 (OPEN-И-17): TTL обязательный, max 365 дней. Бессрочные ключи
|
||
-- запрещены — security-best-practice. Cron secrets:notify-expiring
|
||
-- уведомляет владельца за 30 дней до expiry. UI «продлить» ставит +365d
|
||
-- и пишет в audit. До v8.5 expires_at был NULL-able — миграция backfill'ит
|
||
-- всем существующим ключам NOW() + 365d при ALTER NOT NULL.
|
||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '365 days'),
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE is_active = TRUE;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- outbound_webhook_subscriptions — подписки тенанта на исходящие события (v8.4)
|
||
-- См. narrative §19.10 (Outbound webhook). Уровень 1 стратегии CRM-интеграций
|
||
-- (OPEN-И-2). Хеш secret + key_prefix — как в api_keys (раздел 19.3).
|
||
-- Список событий хранится в JSONB events с whitelist в приложении (deal.created,
|
||
-- deal.status_changed, deal.manager_changed, deal.commented, deal.tag_added,
|
||
-- deal.tag_removed, deal.deleted, deal.restored — синхронно с activity_log).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE outbound_webhook_subscriptions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id), -- кто создал
|
||
name VARCHAR(255) NOT NULL, -- "n8n CRM-копия", "Корпоративный data-pipeline"
|
||
target_url VARCHAR(2048) NOT NULL, -- только https:// (валидация в приложении)
|
||
secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash; оригинал показывается 1 раз
|
||
secret_prefix VARCHAR(10) NOT NULL, -- "whsec_a3f7…" для UI
|
||
events JSONB NOT NULL, -- ["deal.created","deal.status_changed",…]; DEFAULT '[]' нельзя — CHECK требует ≥1 элемент
|
||
custom_headers JSONB DEFAULT '{}', -- доп. headers получателю (например, X-Tenant-Auth)
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
paused_at TIMESTAMPTZ, -- если временно остановлена
|
||
last_delivery_at TIMESTAMPTZ, -- последняя успешная доставка
|
||
last_failure_at TIMESTAMPTZ, -- последняя неудачная доставка
|
||
consecutive_failures INT DEFAULT 0, -- сброс при success
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
-- Защита от misuse: не более 10 активных подписок на тенанта (см. §19.10.9).
|
||
-- Это правило проверяется в приложении (нет красивого SQL-варианта без trigger),
|
||
-- здесь — мягкая граница CHECK на массив events для самосогласованности.
|
||
CONSTRAINT chk_outbound_subs_events CHECK (jsonb_typeof(events) = 'array' AND jsonb_array_length(events) > 0)
|
||
);
|
||
|
||
CREATE INDEX idx_outbound_subs_tenant_active ON outbound_webhook_subscriptions(tenant_id, is_active) WHERE is_active = TRUE;
|
||
CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions(secret_prefix);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- outbound_webhook_deliveries — журнал попыток доставки (v8.4)
|
||
-- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток
|
||
-- от 30 секунд до 24 часов).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE outbound_webhook_deliveries (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
subscription_id BIGINT NOT NULL REFERENCES outbound_webhook_subscriptions(id) ON DELETE CASCADE,
|
||
delivery_uuid UUID NOT NULL, -- X-Liderra-Delivery, выдаётся 1 раз на событие
|
||
event VARCHAR(50) NOT NULL, -- deal.status_changed и т. п.
|
||
payload JSONB NOT NULL, -- тело отправляемого запроса
|
||
attempt_number SMALLINT NOT NULL DEFAULT 1
|
||
CHECK (attempt_number BETWEEN 1 AND 7),
|
||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||
CHECK (status IN ('pending','success','failed','permanently_failed')),
|
||
-- Результат HTTP-запроса (NULL пока pending)
|
||
http_status_code SMALLINT, -- 200..599 или NULL для transport-ошибок
|
||
response_body TEXT, -- первые 1 КБ тела ответа
|
||
response_time_ms INT, -- длительность запроса
|
||
error_message VARCHAR(500), -- DNS error / timeout / SSL error / …
|
||
-- Временные метки
|
||
scheduled_at TIMESTAMPTZ NOT NULL, -- когда должна выполниться попытка
|
||
started_at TIMESTAMPTZ, -- фактическое начало запроса
|
||
finished_at TIMESTAMPTZ, -- завершение (success/failed)
|
||
next_retry_at TIMESTAMPTZ, -- если failed и attempt < 7
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_outbound_deliveries_subscription ON outbound_webhook_deliveries(subscription_id, created_at DESC);
|
||
CREATE INDEX idx_outbound_deliveries_status_pending ON outbound_webhook_deliveries(status, scheduled_at) WHERE status = 'pending';
|
||
CREATE INDEX idx_outbound_deliveries_created ON outbound_webhook_deliveries(created_at DESC);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- auth_log — лог входов (раздел 22.8)
|
||
-- РАСШИРЕНИЕ v8.1: добавлены actor_type и saas_admin_user_id для объединения
|
||
-- логов входов клиентских пользователей и админов SaaS в одной таблице.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE auth_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
actor_type VARCHAR(20) NOT NULL DEFAULT 'tenant_user'
|
||
CHECK (actor_type IN ('tenant_user','saas_admin')),
|
||
tenant_id BIGINT REFERENCES tenants(id), -- NULL для админов SaaS
|
||
user_id BIGINT REFERENCES users(id), -- NULL если saas_admin
|
||
saas_admin_user_id BIGINT REFERENCES saas_admin_users(id), -- NULL если tenant_user
|
||
email VARCHAR(255), -- если actor не найден
|
||
event VARCHAR(50) NOT NULL, -- login_success, login_failed, password_reset, ...
|
||
ip_address INET,
|
||
user_agent TEXT,
|
||
failure_reason VARCHAR(100), -- 'invalid_password', 'invalid_2fa', ...
|
||
-- 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 DEFAULT NOW(),
|
||
-- Целостность: actor должен быть ровно одного типа
|
||
CONSTRAINT chk_auth_log_actor CHECK (
|
||
(actor_type = 'tenant_user' AND user_id IS NOT NULL AND saas_admin_user_id IS NULL)
|
||
OR (actor_type = 'saas_admin' AND saas_admin_user_id IS NOT NULL AND user_id IS NULL)
|
||
OR (actor_type = 'tenant_user' AND user_id IS NULL AND saas_admin_user_id IS NULL AND email IS NOT NULL)
|
||
-- Третий вариант: попытка входа с email, но user_id неизвестен (login_failed для неcуществующего email)
|
||
)
|
||
);
|
||
|
||
CREATE INDEX idx_auth_log_tenant_user ON auth_log(tenant_id, user_id, created_at DESC);
|
||
CREATE INDEX idx_auth_log_admin ON auth_log(saas_admin_user_id, created_at DESC) WHERE saas_admin_user_id IS NOT NULL;
|
||
CREATE INDEX idx_auth_log_ip_failed ON auth_log(ip_address, created_at DESC) WHERE event = 'login_failed';
|
||
CREATE INDEX idx_auth_log_email ON auth_log(email, created_at DESC);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- push_subscriptions — подписки браузеров на Web Push (раздел 17.5)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE push_subscriptions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
user_agent TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
last_used_at TIMESTAMPTZ,
|
||
UNIQUE (user_id, endpoint)
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- comment_templates — шаблоны быстрых комментариев (раздел 15.4)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE comment_templates (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT REFERENCES users(id), -- NULL = глобальный для тенанта
|
||
name VARCHAR(255) NOT NULL,
|
||
text TEXT NOT NULL,
|
||
sort_order INT DEFAULT 0,
|
||
usage_count INT DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE INDEX idx_templates_tenant_user ON comment_templates(tenant_id, user_id);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- deal_tags — теги для сделок (раздел 16.3)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE deal_tags (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
name VARCHAR(100) NOT NULL,
|
||
color_hex VARCHAR(7) NOT NULL,
|
||
description TEXT,
|
||
created_by BIGINT REFERENCES users(id),
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE (tenant_id, name)
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- import_log — журнал CSV-импорта (раздел 6.7, опциональный модуль)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE import_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||
filename VARCHAR(255) NOT NULL,
|
||
file_path VARCHAR(500),
|
||
rows_total INT DEFAULT 0,
|
||
rows_added INT DEFAULT 0,
|
||
rows_updated INT DEFAULT 0,
|
||
rows_skipped INT DEFAULT 0,
|
||
status VARCHAR(20) DEFAULT 'pending'
|
||
CHECK (status IN ('pending','processing','done','failed')),
|
||
error_message TEXT,
|
||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||
finished_at TIMESTAMPTZ
|
||
);
|
||
|
||
|
||
-- =============================================================================
|
||
-- 5. DEALS — партиционированная по received_at (раздел 7.3)
|
||
-- ВАЖНО: PRIMARY KEY обязательно включает ключ партиционирования (received_at)
|
||
-- =============================================================================
|
||
|
||
CREATE TABLE deals (
|
||
id BIGSERIAL,
|
||
tenant_id BIGINT NOT NULL,
|
||
source_crm_id BIGINT, -- vid из webhook (NULL для ручных)
|
||
project_id BIGINT NOT NULL,
|
||
phone VARCHAR(20) NOT NULL,
|
||
phones JSONB, -- дополнительные телефоны
|
||
status VARCHAR(50) NOT NULL DEFAULT 'new', -- slug из lead_statuses
|
||
contact_name VARCHAR(255),
|
||
comment TEXT,
|
||
-- v8.3: поля reminder_text и reminder_at удалены, заменены на таблицу reminders
|
||
-- (множественные напоминания на сделку, паритет с histories[].type='reminder'
|
||
-- оригинала — партия 12.2.5 аудита).
|
||
manager_id BIGINT, -- FK не ставится (партиционированная)
|
||
-- 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),
|
||
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)
|
||
) 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;
|
||
-- (Биз-19) lookup дублей master'а через duplicate_of_id для UI (показать
|
||
-- цепочку дублей) и для cleanup при удалении master'а.
|
||
CREATE INDEX ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL;
|
||
-- (OPEN-И-25) cron leads:escalate-stale — выбирает unclosed deals по assigned_at.
|
||
CREATE INDEX ON deals (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected');
|
||
-- v8.9: фильтр soft-deleted в DealController::index/show/transition. Partial index
|
||
-- по tenant_id, status WHERE deleted_at IS NULL — самый частый паттерн UI-запросов.
|
||
CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL;
|
||
|
||
-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
|
||
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
|
||
CREATE TABLE deals_2026_05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||
CREATE TABLE deals_2026_06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||
CREATE TABLE deals_2026_07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||
CREATE TABLE deals_2026_08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
|
||
CREATE TABLE deals_2026_09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
|
||
CREATE TABLE deals_2026_10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- 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
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE activity_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT REFERENCES users(id), -- NULL для системных событий
|
||
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
|
||
event VARCHAR(100) NOT NULL, -- deal.created, deal.status_changed, ...
|
||
old_value TEXT,
|
||
new_value TEXT,
|
||
context JSONB,
|
||
ip_address INET,
|
||
user_agent TEXT,
|
||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_activity_tenant_deal_created ON activity_log(tenant_id, deal_id, created_at DESC);
|
||
CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id, created_at DESC) WHERE user_id IS NOT NULL;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- reminders — напоминания по сделкам (раздел 17.5)
|
||
-- v8.3: расширено по итогам партии 12.2 аудита.
|
||
-- В оригинале реализовано через model.histories[].type='reminder' — массив
|
||
-- записей с полями {id, time, time_dt, note, from, to}, где from = создатель
|
||
-- (логин), to = null (зарезервировано на будущее под assignee).
|
||
-- На MVP паритет: created_by + опциональный assignee_id (по умолчанию NULL).
|
||
-- completed_at — наше расширение для аудита (в оригинале нет timestamp закрытия).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE reminders (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
|
||
text VARCHAR(255), -- = note в оригинале, лимит 255
|
||
remind_at TIMESTAMPTZ NOT NULL,
|
||
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
-- = histories[].from в оригинале
|
||
assignee_id BIGINT REFERENCES users(id), -- зарезервировано (= to, всегда NULL
|
||
-- в оригинале, наше расширение Post-MVP)
|
||
completed_at TIMESTAMPTZ, -- NULL = активное; not NULL = выполнено
|
||
-- (наше расширение, в оригинале нет)
|
||
is_sent BOOLEAN DEFAULT FALSE, -- для cron уведомлений
|
||
sent_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ
|
||
);
|
||
|
||
-- Индексы для основных flow:
|
||
-- 1) Cron уведомлений: «найти неотправленные напоминания, время которых наступило».
|
||
CREATE INDEX idx_reminders_due
|
||
ON reminders(remind_at)
|
||
WHERE is_sent = FALSE AND completed_at IS NULL;
|
||
|
||
-- 2) UI карточки сделки: «все напоминания этой сделки».
|
||
CREATE INDEX idx_reminders_deal
|
||
ON reminders(deal_id);
|
||
|
||
-- 3) Дашборд «Дела на сегодня / Просроченные / Предстоящие» (из оригинала
|
||
-- `?reminders=today|last|future|none` — партия 12.2.6).
|
||
CREATE INDEX idx_reminders_tenant_user_active
|
||
ON reminders(tenant_id, created_by, remind_at)
|
||
WHERE completed_at IS NULL;
|
||
|
||
-- 4) Аудит: все активные напоминания тенанта.
|
||
CREATE INDEX idx_reminders_tenant_active
|
||
ON reminders(tenant_id, remind_at)
|
||
WHERE completed_at IS NULL;
|
||
|
||
COMMENT ON TABLE reminders IS
|
||
'Напоминания на сделке (раздел 17.5). v8.3: множественные на сделку '
|
||
'(паритет с histories[].type=reminder оригинала, партия 12.2.5). '
|
||
'Дашборд задач — фильтры today/last/future/none через remind_at + completed_at.';
|
||
|
||
COMMENT ON COLUMN reminders.created_by IS
|
||
'Кто создал напоминание (= histories[].from в оригинале).';
|
||
|
||
COMMENT ON COLUMN reminders.assignee_id IS
|
||
'Кому назначено напоминание. На MVP — всегда NULL (паритет: оригинал имеет '
|
||
'поле to: null, не используется в UI). Поле зарезервировано для Post-MVP.';
|
||
|
||
COMMENT ON COLUMN reminders.completed_at IS
|
||
'Время выполнения/закрытия напоминания. Наше расширение сверх паритета '
|
||
'(в оригинале closed = удаление записи). NULL = активное.';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- in_app_notifications (раздел 18.5 v8.10) — bell-icon в UI.
|
||
-- На каждого user'а пишется row при триггере события (new_lead/reminder/...),
|
||
-- если notification_preferences[event].inapp=true. UI читает unread count
|
||
-- через GET /api/notifications?unread=1 и список последних 50.
|
||
-- РЕТЕНШН: 90 дней или 1000 записей на user (cleanup-job, отдельная задача).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE in_app_notifications (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
event VARCHAR(50) NOT NULL, -- new_lead|reminder|low_balance|zero_balance|topup_success|invoice_paid|new_device_login|marketing
|
||
title VARCHAR(255) NOT NULL,
|
||
body TEXT,
|
||
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
|
||
payload JSONB DEFAULT '{}'::jsonb, -- доп. поля для UI: {link, action_label, ...}
|
||
read_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
-- Главный index UI-флоу: «непрочитанные user'а в порядке свежести».
|
||
CREATE INDEX idx_in_app_notifications_user_unread
|
||
ON in_app_notifications(user_id, created_at DESC)
|
||
WHERE read_at IS NULL;
|
||
|
||
-- «Последние 50 уведомлений» (с прочитанными).
|
||
CREATE INDEX idx_in_app_notifications_user_recent
|
||
ON in_app_notifications(user_id, created_at DESC);
|
||
|
||
COMMENT ON TABLE in_app_notifications IS
|
||
'In-app уведомления (раздел 18.5, schema v8.10). Канал inapp в матрице '
|
||
'users.notification_preferences. INSERT при триггере события, UPDATE '
|
||
'read_at при клике. RLS: tenant isolation.';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- webhook_log — лог принятых webhook (раздел 5.7)
|
||
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE webhook_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
|
||
received_at TIMESTAMPTZ DEFAULT NOW(),
|
||
processed_at TIMESTAMPTZ,
|
||
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
|
||
error TEXT
|
||
);
|
||
|
||
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- failed_webhook_jobs — джобы webhook, упавшие после 3 ретраев (упомянуто в 7.1)
|
||
-- DDL отсутствует в v8.0, реконструировано
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE failed_webhook_jobs (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
|
||
webhook_log_id BIGINT REFERENCES webhook_log(id),
|
||
raw_payload JSONB NOT NULL,
|
||
exception TEXT NOT NULL,
|
||
retry_count INT DEFAULT 3,
|
||
failed_at TIMESTAMPTZ DEFAULT NOW(),
|
||
-- Возможность ручного retry из админки
|
||
retried_at TIMESTAMPTZ,
|
||
retried_by BIGINT REFERENCES saas_admin_users(id),
|
||
resolved_at TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC) WHERE resolved_at IS NULL;
|
||
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02)
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7)
|
||
-- РЕТЕНШН: бессрочно (опционально 12 месяцев)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE rejected_deals_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
webhook_log_id BIGINT REFERENCES webhook_log(id),
|
||
reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ...
|
||
payload JSONB,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);
|
||
CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03)
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- supplier_leads — SaaS-level raw-payload входящих webhook'ов (v8.18, Plan 2/5)
|
||
-- -----------------------------------------------------------------------------
|
||
-- Контекст: см. spec §5.1. Поставщик POST'ит на /api/webhook/supplier/{secret}
|
||
-- (общий URL, не per-tenant). На момент INSERT'а tenant ещё не определён —
|
||
-- routing происходит в RouteSupplierLeadJob через supplier_project + LeadRouter.
|
||
-- Поэтому таблица НЕ tenant-scoped, RLS НЕ применяется (как supplier_projects).
|
||
--
|
||
-- supplier_project_id заполняется job'ом ПОСЛЕ парсинга `project` из payload
|
||
-- (`B1_vashinvestor.ru` → platform=B1, signal_identifier=vashinvestor.ru).
|
||
-- ON DELETE SET NULL: при удалении supplier_project не каскадим — сохраняем
|
||
-- raw для аудита.
|
||
--
|
||
-- source: 'webhook' (основной канал §5.1) | 'csv_recovery' (резерв §5.2 —
|
||
-- Plan 4 CSV reconciliation). На Plan 2 поддерживаем только 'webhook';
|
||
-- enum extensibility — для Plan 4.
|
||
--
|
||
-- deals_created_count: сколько deal-копий было создано после routing'а
|
||
-- (sharing-model: 0..N). Заполняется job'ом в конце обработки. NULL до processed.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE supplier_leads (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
|
||
platform VARCHAR(4) NOT NULL,
|
||
raw_payload JSONB NOT NULL,
|
||
vid BIGINT NOT NULL,
|
||
phone VARCHAR(20) NOT NULL,
|
||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
|
||
processed_at TIMESTAMPTZ,
|
||
-- v8.19 (Plan 4 CSV reconcile): NULL для лидов из webhook (основной канал).
|
||
-- Заполняется CsvReconcileJob при восстановлении лида, пропущенного webhook'ом.
|
||
recovered_from_csv_at TIMESTAMPTZ,
|
||
deals_created_count INTEGER,
|
||
error TEXT,
|
||
|
||
CONSTRAINT chk_supplier_leads_platform
|
||
CHECK (platform IN ('B1','B2','B3')),
|
||
CONSTRAINT chk_supplier_leads_source
|
||
CHECK (source IN ('webhook','csv_recovery')),
|
||
CONSTRAINT chk_supplier_leads_deals_count_nonneg
|
||
CHECK (deals_created_count IS NULL OR deals_created_count >= 0)
|
||
);
|
||
|
||
CREATE INDEX idx_supplier_leads_received_at ON supplier_leads(received_at DESC);
|
||
CREATE INDEX idx_supplier_leads_supplier_project ON supplier_leads(supplier_project_id) WHERE supplier_project_id IS NOT NULL;
|
||
CREATE UNIQUE INDEX idx_supplier_leads_vid_unique ON supplier_leads(vid);
|
||
CREATE INDEX supplier_leads_recovered_from_csv_partial
|
||
ON supplier_leads(recovered_from_csv_at)
|
||
WHERE recovered_from_csv_at IS NOT NULL;
|
||
|
||
-- v8.18 (Plan 2/5): defense-in-depth — REVOKE ALL FROM crm_app_user.
|
||
-- SaaS-level таблица. tenant-приложение читать не должно (raw-payload — наша зона).
|
||
-- Conditional wrapper см. в supplier_projects (dev=postgres superuser без crm_app_user).
|
||
--
|
||
-- REVOKE ALL ON supplier_leads FROM crm_app_user;
|
||
|
||
|
||
-- =============================================================================
|
||
-- 7. БИЛЛИНГ (SAAS-уровень)
|
||
-- =============================================================================
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- payment_gateways — конфигурация шлюзов (раздел 20.4.2)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE payment_gateways (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(50) UNIQUE NOT NULL, -- 'yookassa', 'robokassa', 'tinkoff', ...
|
||
name VARCHAR(255) NOT NULL, -- "ЮKassa (ООО Ромашка)"
|
||
driver VARCHAR(50) NOT NULL, -- класс драйвера в коде
|
||
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
|
||
config TEXT NOT NULL, -- ШИФРОВАН Crypt::encrypt({"shop_id":"...","secret_key":"..."})
|
||
is_active BOOLEAN DEFAULT FALSE,
|
||
accepts_methods JSONB DEFAULT '[]', -- ["card","yoomoney","qiwi","sbp","bank_transfer"]
|
||
min_amount_rub DECIMAL(10,2) DEFAULT 100.00,
|
||
max_amount_rub DECIMAL(12,2),
|
||
sort_order INT DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- tariff_subscriptions — подписки тенантов на тарифы (раздел 20.2.3 + CTO-1, CTO-2)
|
||
--
|
||
-- Жизненный цикл (CTO-1, CTO-2):
|
||
-- • Смена тарифа → новая запись 'scheduled', started_at = ближайшие 00:00 МСК.
|
||
-- • Cron tariffs:apply-scheduled (каждые 5–10 минут):
|
||
-- - переводит scheduled → active, если started_at <= NOW();
|
||
-- - переводит active → superseded, если для тенанта появилась новая active;
|
||
-- - переводит active → expired, если expires_at <= NOW();
|
||
-- - при expired каскадно ставит tenants.status = 'suspended' (CTO-2).
|
||
-- • Все записи (включая superseded/expired/cancelled) хранятся БЕССРОЧНО — это
|
||
-- полная история смен тарифа, нужна бухгалтерии и аудиту.
|
||
--
|
||
-- Семантика статусов:
|
||
-- scheduled — будущая активация в 00:00 МСК (CTO-1: вариант Б)
|
||
-- active — текущий тариф тенанта
|
||
-- superseded — заменён новой подпиской (закрыт сменой тарифа)
|
||
-- expired — истёк по expires_at, тенант → suspended (CTO-2: вариант Б)
|
||
-- cancelled — отменён админом SaaS при блокировке тенанта (Биз-2: клиент сам не отменяет)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE tariff_subscriptions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
tariff_plan_id BIGINT NOT NULL REFERENCES tariff_plans(id),
|
||
started_at TIMESTAMPTZ NOT NULL, -- для scheduled — момент в будущем (00:00 МСК)
|
||
expires_at TIMESTAMPTZ, -- NULL = бессрочно (для per_lead)
|
||
status VARCHAR(20) DEFAULT 'active'
|
||
CHECK (status IN ('scheduled','active','superseded','expired','cancelled')),
|
||
cancelled_at TIMESTAMPTZ, -- момент cancelled (только для cancelled)
|
||
superseded_at TIMESTAMPTZ, -- момент superseded (когда новая active заменила эту)
|
||
custom_overrides JSONB, -- индивидуальные условия (price_per_lead, included_leads)
|
||
-- created_by: либо tenant_user_id, либо saas_admin_user_id — определяется контекстом
|
||
created_by_type VARCHAR(20) -- 'tenant_user' | 'saas_admin' | 'system'
|
||
CHECK (created_by_type IN ('tenant_user','saas_admin','system')),
|
||
created_by_id BIGINT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
-- CTO-1: гарантия "одна active подписка на тенанта" на уровне БД.
|
||
CREATE UNIQUE INDEX idx_subscriptions_one_active_per_tenant
|
||
ON tariff_subscriptions(tenant_id) WHERE status = 'active';
|
||
|
||
-- Аналогичная гарантия для scheduled — иначе можно случайно создать две будущие подписки.
|
||
CREATE UNIQUE INDEX idx_subscriptions_one_scheduled_per_tenant
|
||
ON tariff_subscriptions(tenant_id) WHERE status = 'scheduled';
|
||
|
||
CREATE INDEX idx_subscriptions_tenant_status ON tariff_subscriptions(tenant_id, status);
|
||
CREATE INDEX idx_subscriptions_apply_scheduled ON tariff_subscriptions(started_at) WHERE status = 'scheduled';
|
||
CREATE INDEX idx_subscriptions_apply_expired ON tariff_subscriptions(expires_at) WHERE status = 'active' AND expires_at IS NOT NULL;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_invoices — счета на оплату (раздел 20.10.1)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_invoices (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
|
||
invoice_number VARCHAR(50) NOT NULL, -- СЧ-2026-00123
|
||
-- Плательщик
|
||
payer_type VARCHAR(20) NOT NULL
|
||
CHECK (payer_type IN ('legal','individual')),
|
||
payer_name VARCHAR(500),
|
||
payer_inn VARCHAR(12),
|
||
payer_kpp VARCHAR(9),
|
||
payer_address TEXT,
|
||
payer_email VARCHAR(255),
|
||
-- Финансы
|
||
amount_net DECIMAL(12,2) NOT NULL,
|
||
vat_rate DECIMAL(5,2) DEFAULT 0,
|
||
vat_amount DECIMAL(12,2) DEFAULT 0,
|
||
amount_total DECIMAL(12,2) NOT NULL,
|
||
payment_purpose TEXT,
|
||
-- Связи
|
||
transaction_id BIGINT, -- FK добавлен ниже после saas_transactions
|
||
-- Файлы
|
||
pdf_path VARCHAR(500),
|
||
-- Статус
|
||
status VARCHAR(20) DEFAULT 'issued'
|
||
CHECK (status IN ('draft','issued','paid','overdue','cancelled')),
|
||
issued_at TIMESTAMPTZ DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL, -- по умолчанию +5 рабочих дней
|
||
paid_at TIMESTAMPTZ,
|
||
cancelled_at TIMESTAMPTZ,
|
||
UNIQUE (legal_entity_id, invoice_number)
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_invoice_items — позиции счёта (раздел 20.10.1)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_invoice_items (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
invoice_id BIGINT NOT NULL REFERENCES saas_invoices(id) ON DELETE CASCADE,
|
||
name VARCHAR(500) NOT NULL,
|
||
okpd2 VARCHAR(20),
|
||
quantity DECIMAL(10,3) DEFAULT 1,
|
||
unit VARCHAR(50) DEFAULT 'усл.',
|
||
price DECIMAL(12,2) NOT NULL,
|
||
amount_net DECIMAL(12,2) NOT NULL,
|
||
vat_rate DECIMAL(5,2),
|
||
vat_amount DECIMAL(12,2),
|
||
amount_total DECIMAL(12,2) NOT NULL
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_upd_documents — УПД (раздел 20.10.2)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_upd_documents (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
legal_entity_id BIGINT NOT NULL REFERENCES legal_entities(id),
|
||
upd_number VARCHAR(50) NOT NULL,
|
||
upd_function VARCHAR(10) DEFAULT 'СЧФ'
|
||
CHECK (upd_function IN ('СЧФ','ДОП')),
|
||
correction_for BIGINT REFERENCES saas_upd_documents(id), -- если корректировочный
|
||
-- Покупатель
|
||
buyer_type VARCHAR(20) NOT NULL
|
||
CHECK (buyer_type IN ('legal','individual')),
|
||
buyer_name VARCHAR(500),
|
||
buyer_inn VARCHAR(12),
|
||
buyer_kpp VARCHAR(9),
|
||
buyer_address TEXT,
|
||
-- Финансы
|
||
amount_net DECIMAL(12,2) NOT NULL,
|
||
vat_rate DECIMAL(5,2),
|
||
vat_amount DECIMAL(12,2),
|
||
amount_total DECIMAL(12,2) NOT NULL,
|
||
-- Связи
|
||
invoice_id BIGINT REFERENCES saas_invoices(id),
|
||
transaction_id BIGINT, -- FK добавлен ниже
|
||
-- Файлы
|
||
pdf_path VARCHAR(500),
|
||
-- Статус
|
||
status VARCHAR(20) DEFAULT 'issued'
|
||
CHECK (status IN ('draft','issued','signed','cancelled')),
|
||
issued_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE (legal_entity_id, upd_number)
|
||
);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- saas_transactions — транзакции SaaS-биллинга (раздел 20.5 + CTO-3, Ю-3)
|
||
--
|
||
-- Жизненный цикл (CTO-3):
|
||
-- • [*] → pending: POST /api/v1/billing/topup (карта/СБП/ЮMoney/QIWI).
|
||
-- • [*] → success: банк.перевод, админ подтверждает в админке (раздел 20.9).
|
||
-- • pending → success: webhook payment.succeeded ИЛИ polling-fallback от cron.
|
||
-- • pending → failed: webhook payment.canceled / cron-таймаут 30 мин / hard 24ч.
|
||
-- • success → refunded: через refund_requests.processed.
|
||
-- Источник refund_requests:
|
||
-- - admin_initiated (наш возврат, раздел 20.6);
|
||
-- - external_chargeback (Ю-3: webhook refund.succeeded без refund_requests
|
||
-- → refund_requests создаётся автоматически).
|
||
-- • failed → success ЗАПРЕЩЁН (CTO-3). Поздний webhook payment.succeeded по
|
||
-- failed-транзакции → алерт в админке для finance, ручной разбор.
|
||
--
|
||
-- Cron payments:cancel-stale (каждые 5–10 минут):
|
||
-- • status='pending' AND created_at < NOW() - INTERVAL '30 minutes':
|
||
-- - если gateway_payment_id IS NULL → failed, failure_reason='gateway_create_timeout';
|
||
-- - иначе driver.getPaymentStatus(): succeeded → success (polling-fallback);
|
||
-- canceled/failed → failed;
|
||
-- pending → пропустить;
|
||
-- unreachable → пропустить.
|
||
-- • Hard timeout 24 часа: status='pending' AND created_at < NOW() - INTERVAL '24 hours'
|
||
-- → failed, failure_reason='hard_timeout_24h' независимо от ответа шлюза.
|
||
--
|
||
-- Допустимые failure_reason (для фильтров в админке):
|
||
-- gateway_canceled, gateway_declined, gateway_create_timeout,
|
||
-- hard_timeout_24h, cron_polling_failed, gateway_unknown,
|
||
-- late_webhook_ignored (для логирования поздних webhook на failed транзакции).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE saas_transactions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
type VARCHAR(50) NOT NULL
|
||
CHECK (type IN ('topup','refund','manual_credit','manual_debit')),
|
||
amount_rub DECIMAL(12,2) NOT NULL,
|
||
balance_rub_after DECIMAL(12,2),
|
||
leads_credited INT DEFAULT 0,
|
||
-- Шлюз
|
||
gateway_id BIGINT REFERENCES payment_gateways(id),
|
||
gateway_code VARCHAR(50),
|
||
gateway_payment_id VARCHAR(255),
|
||
gateway_idempotence_key UUID,
|
||
-- Способ оплаты
|
||
payment_method VARCHAR(50), -- 'card','yoomoney','sbp','bank_transfer','qiwi','webmoney'
|
||
-- Юр. лицо получатель
|
||
legal_entity_id BIGINT REFERENCES legal_entities(id),
|
||
-- Документы
|
||
invoice_id BIGINT REFERENCES saas_invoices(id),
|
||
upd_id BIGINT REFERENCES saas_upd_documents(id),
|
||
-- Статус
|
||
status VARCHAR(20) DEFAULT 'pending'
|
||
CHECK (status IN ('pending','success','failed','refunded')),
|
||
description TEXT,
|
||
failure_reason VARCHAR(50), -- enum в комментарии выше
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
completed_at TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE INDEX idx_saas_tx_tenant_created ON saas_transactions(tenant_id, created_at DESC);
|
||
CREATE INDEX idx_saas_tx_status ON saas_transactions(status) WHERE status IN ('pending');
|
||
CREATE INDEX idx_saas_tx_gateway ON saas_transactions(gateway_code, gateway_payment_id);
|
||
CREATE UNIQUE INDEX idx_saas_tx_idempotence ON saas_transactions(gateway_idempotence_key) WHERE gateway_idempotence_key IS NOT NULL;
|
||
-- CTO-3: для cron payments:cancel-stale (каждые 5-10 минут)
|
||
CREATE INDEX idx_saas_tx_pending_stale ON saas_transactions(created_at) WHERE status = 'pending';
|
||
|
||
-- Forward FK на saas_transactions
|
||
ALTER TABLE saas_invoices ADD CONSTRAINT fk_saas_invoices_tx FOREIGN KEY (transaction_id) REFERENCES saas_transactions(id);
|
||
ALTER TABLE saas_upd_documents ADD CONSTRAINT fk_saas_upd_tx FOREIGN KEY (transaction_id) REFERENCES saas_transactions(id);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- refund_requests — запросы на возврат (раздел 20.6 + Ю-3)
|
||
-- РАСШИРЕНИЯ v8.1:
|
||
-- • admin_user_id получил FK на saas_admin_users.
|
||
-- • source — admin_initiated или external_chargeback (Ю-3). При chargeback
|
||
-- запись создаётся автоматически из webhook без участия админа.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE refund_requests (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
transaction_id BIGINT NOT NULL REFERENCES saas_transactions(id),
|
||
requested_amount_rub DECIMAL(12,2),
|
||
reason TEXT,
|
||
-- Источник возврата (Ю-3)
|
||
source VARCHAR(20) NOT NULL DEFAULT 'admin_initiated'
|
||
CHECK (source IN ('admin_initiated','external_chargeback')),
|
||
status VARCHAR(20) DEFAULT 'pending'
|
||
CHECK (status IN ('pending','approved','rejected','processed')),
|
||
-- admin_user_id NULL для source='external_chargeback' (автоматическое создание из webhook)
|
||
admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||
admin_decision TEXT,
|
||
gateway_refund_id VARCHAR(255),
|
||
correction_upd_id BIGINT REFERENCES saas_upd_documents(id),
|
||
requested_at TIMESTAMPTZ DEFAULT NOW(),
|
||
decided_at TIMESTAMPTZ,
|
||
processed_at TIMESTAMPTZ,
|
||
-- Целостность: для admin_initiated админ обязателен после approved/rejected
|
||
CONSTRAINT chk_refund_admin_decision CHECK (
|
||
(source = 'external_chargeback')
|
||
OR (status = 'pending')
|
||
OR (admin_user_id IS NOT NULL)
|
||
)
|
||
);
|
||
|
||
CREATE INDEX idx_refund_requests_pending ON refund_requests(status, requested_at) WHERE status = 'pending';
|
||
CREATE INDEX idx_refund_requests_chargeback ON refund_requests(tenant_id, requested_at DESC) WHERE source = 'external_chargeback';
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- balance_transactions — внутренний лид-биллинг (раздел 7.3, 21)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE balance_transactions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
type VARCHAR(50) NOT NULL
|
||
CHECK (type IN ('trial_bonus','topup','lead_charge','refund',
|
||
'manual_adjustment','historical_import',
|
||
'chargeback_writedown', -- Ю-3: списание непокрытой части chargeback в долг тенанта
|
||
'chargeback_repayment' -- Ю-3: погашение долга через /billing
|
||
)),
|
||
amount_rub DECIMAL(12,2) DEFAULT 0,
|
||
amount_leads INT DEFAULT 0,
|
||
balance_rub_after DECIMAL(12,2),
|
||
balance_leads_after INT,
|
||
description TEXT,
|
||
related_type VARCHAR(100), -- "App\\Models\\Deal" / "SaasTransaction" / etc
|
||
related_id BIGINT,
|
||
user_id BIGINT REFERENCES users(id), -- кто инициировал (NULL для системных)
|
||
-- Для manual_adjustment — кто из админов SaaS сделал
|
||
admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_balance_tenant_created ON balance_transactions(tenant_id, created_at DESC);
|
||
CREATE INDEX idx_balance_tenant_type ON balance_transactions(tenant_id, type);
|
||
|
||
|
||
-- =============================================================================
|
||
-- 8. ОТЧЁТЫ
|
||
-- =============================================================================
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- report_jobs — задания на генерацию отчётов (раздел 13.5)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE report_jobs (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||
type VARCHAR(50) NOT NULL, -- deals_export, projects_summary, conversion_funnel, ...
|
||
parameters JSONB,
|
||
status VARCHAR(20) DEFAULT 'pending'
|
||
CHECK (status IN ('pending','processing','done','failed')),
|
||
file_path VARCHAR(500), -- s3://bucket/path/file.xlsx
|
||
file_size BIGINT,
|
||
generation_seconds INT,
|
||
error_message TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
finished_at TIMESTAMPTZ,
|
||
expires_at TIMESTAMPTZ -- автоудаление файла
|
||
);
|
||
|
||
CREATE INDEX idx_report_jobs_tenant_user ON report_jobs(tenant_id, user_id, created_at DESC);
|
||
|
||
|
||
-- =============================================================================
|
||
-- 8.5. СЕБЕСТОИМОСТЬ И ПОСТАВЩИКИ (Ю-2)
|
||
-- =============================================================================
|
||
--
|
||
-- Реселлерская модель: мы покупаем лиды у crm.bp-gr.ru, перепродаём тенантам.
|
||
-- На MVP единая закупочная цена для всех лидов (system_settings.supplier_default_cost_rub).
|
||
-- На v2 предусмотрена эволюция к точным ценам по проектам/категориям.
|
||
-- =============================================================================
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- supplier_lead_costs — себестоимость каждого лида (Ю-2; в v8.2 supplier_id вместо supplier_code)
|
||
-- Партиционирована по received_at синхронно с deals.
|
||
-- В ProcessWebhookJob создаётся в той же транзакции, что и deals + balance_transactions.
|
||
-- -----------------------------------------------------------------------------
|
||
-- ИЗМЕНЕНИЯ v8.2: supplier_code (VARCHAR DEFAULT 'crm_bp_gr') заменён на
|
||
-- supplier_id (BIGINT FK → suppliers.id). cost_rub теперь snapshot из
|
||
-- suppliers.cost_rub конкретного поставщика (B1/B2/B3), а не из
|
||
-- system_settings.supplier_default_cost_rub.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE supplier_lead_costs (
|
||
id BIGSERIAL,
|
||
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
|
||
received_at TIMESTAMPTZ NOT NULL, -- ключ партиционирования (= deals.received_at)
|
||
supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
|
||
cost_rub DECIMAL(10,2) NOT NULL, -- snapshot suppliers.cost_rub на момент приёма
|
||
supplier_lead_id BIGINT, -- vid из webhook crm.bp-gr.ru
|
||
supplier_invoice_id BIGINT, -- FK добавлен ниже после supplier_invoices
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (id, received_at)
|
||
) PARTITION BY RANGE (received_at);
|
||
|
||
-- Индексы на родительской таблице наследуются партициями
|
||
CREATE INDEX ON supplier_lead_costs (deal_id, received_at);
|
||
CREATE INDEX ON supplier_lead_costs (supplier_invoice_id) WHERE supplier_invoice_id IS NOT NULL;
|
||
CREATE INDEX ON supplier_lead_costs (supplier_id, received_at DESC); -- v8.2: аналитика "лиды по поставщикам"
|
||
|
||
-- Партиции синхронно с deals (создаются cron на 2 месяца вперёд)
|
||
CREATE TABLE supplier_lead_costs_2026_05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||
CREATE TABLE supplier_lead_costs_2026_06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||
CREATE TABLE supplier_lead_costs_2026_07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||
CREATE TABLE supplier_lead_costs_2026_08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
|
||
CREATE TABLE supplier_lead_costs_2026_09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
|
||
CREATE TABLE supplier_lead_costs_2026_10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- supplier_invoices — счета от crm.bp-gr.ru к нам (Ю-2; в v8.2 supplier_id)
|
||
-- Заполняется finance вручную при получении счёта от поставщика.
|
||
-- Используется для:
|
||
-- - сверки: SUM(supplier_lead_costs.cost_rub) за период vs amount_rub счёта;
|
||
-- - выявления расхождений (отображается в discrepancy_rub);
|
||
-- - бухгалтерского следа;
|
||
-- - отчёта "расходы по поставщику".
|
||
-- ИЗМЕНЕНИЯ v8.2: supplier_code → supplier_id (FK на suppliers).
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE supplier_invoices (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
|
||
invoice_number VARCHAR(100),
|
||
period_from DATE NOT NULL,
|
||
period_to DATE NOT NULL,
|
||
leads_count INT, -- по счёту от поставщика
|
||
leads_count_actual INT, -- по нашим данным (заполняется при сверке)
|
||
amount_rub DECIMAL(12,2), -- по счёту
|
||
amount_rub_actual DECIMAL(12,2), -- по нашим данным
|
||
discrepancy_rub DECIMAL(12,2)
|
||
GENERATED ALWAYS AS (COALESCE(amount_rub,0) - COALESCE(amount_rub_actual,0)) STORED,
|
||
pdf_path VARCHAR(500),
|
||
paid_at TIMESTAMPTZ,
|
||
paid_by BIGINT REFERENCES saas_admin_users(id),
|
||
notes TEXT,
|
||
status VARCHAR(20) DEFAULT 'received'
|
||
CHECK (status IN ('received','reconciled','paid','disputed')),
|
||
reconciled_by BIGINT REFERENCES saas_admin_users(id),
|
||
reconciled_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
CONSTRAINT chk_supplier_invoices_period CHECK (period_to >= period_from)
|
||
);
|
||
|
||
CREATE INDEX idx_supplier_invoices_period ON supplier_invoices(period_from, period_to);
|
||
CREATE INDEX idx_supplier_invoices_unpaid ON supplier_invoices(status) WHERE status IN ('received','reconciled');
|
||
CREATE INDEX idx_supplier_invoices_supplier ON supplier_invoices(supplier_id); -- v8.2
|
||
|
||
-- Forward FK
|
||
ALTER TABLE supplier_lead_costs
|
||
ADD CONSTRAINT fk_supplier_lead_costs_invoice
|
||
FOREIGN KEY (supplier_invoice_id) REFERENCES supplier_invoices(id);
|
||
|
||
|
||
-- =============================================================================
|
||
-- 9. 152-ФЗ
|
||
-- =============================================================================
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- tenant_consents — согласия на обработку ПДн (раздел 22.9.1)
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE tenant_consents (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||
consent_type VARCHAR(50) NOT NULL, -- pd_processing, marketing, oferta_v1, ...
|
||
document_version VARCHAR(20), -- "v2.1"
|
||
given_at TIMESTAMPTZ DEFAULT NOW(),
|
||
ip_address INET,
|
||
user_agent TEXT,
|
||
revoked_at TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE INDEX idx_consents_tenant ON tenant_consents(tenant_id, consent_type);
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- pd_processing_log — журнал обработки ПДн (раздел 22.9.3)
|
||
-- РАСШИРЕНИЕ v8.1: разделение actor_user_id на два поля с FK
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE pd_processing_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant_id BIGINT REFERENCES tenants(id),
|
||
subject_type VARCHAR(50), -- 'user', 'lead'
|
||
subject_id BIGINT,
|
||
action VARCHAR(50), -- 'created', 'viewed', 'updated', 'deleted', 'exported'
|
||
purpose VARCHAR(255), -- 'lead_processing', 'analytics', 'support_request'
|
||
-- Актор: ровно один из двух
|
||
actor_tenant_user_id BIGINT REFERENCES users(id),
|
||
actor_admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||
ip_address INET,
|
||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
CONSTRAINT chk_pd_actor CHECK (
|
||
(actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL)
|
||
OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NOT NULL)
|
||
OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NULL) -- системное действие
|
||
)
|
||
);
|
||
|
||
CREATE INDEX idx_pd_log_tenant ON pd_processing_log(tenant_id, created_at DESC);
|
||
CREATE INDEX idx_pd_log_admin_actor ON pd_processing_log(actor_admin_user_id, created_at DESC) WHERE actor_admin_user_id IS NOT NULL;
|
||
|
||
|
||
-- -----------------------------------------------------------------------------
|
||
-- pd_subject_requests — обращения субъектов ПДн (НОВАЯ в v8.1, расширена в v8.2)
|
||
-- Срок ответа по 152-ФЗ — 30 дней
|
||
-- -----------------------------------------------------------------------------
|
||
-- РАСШИРЕНИЕ v8.2 (OPEN-Д-1, ст.21 ч.5 152-ФЗ):
|
||
-- processing_restricted BOOLEAN — субъект ПДн вправе требовать "прекращения
|
||
-- обработки" (не путать с удалением: данные сохраняются, но запрещаются любые
|
||
-- операции, кроме хранения). При TRUE сервисы при чтении/мутации ПДн
|
||
-- субъекта поднимают ProcessingRestrictedException (App\Services\Pd\ProcessingRestrictionGuard).
|
||
--
|
||
-- НЕ tenant-уровневая (saas-уровневая). RLS не применяется намеренно:
|
||
-- обращение от субъекта ПДн поступает ДО идентификации тенанта (субъект может
|
||
-- не знать, в каком тенанте лежат его данные; tenant_id — nullable, заполняется
|
||
-- compliance-админом по результатам поиска). Доступ — только из админки SaaS
|
||
-- под crm_admin_user (BYPASSRLS), tenant-приложение к этой таблице не
|
||
-- обращается. Связь с тенантом фиксируется через nullable tenant_id для
|
||
-- маршрутизации запросов на удаление/исправление.
|
||
-- -----------------------------------------------------------------------------
|
||
CREATE TABLE pd_subject_requests (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
received_at TIMESTAMPTZ DEFAULT NOW(),
|
||
subject_email VARCHAR(255),
|
||
subject_phone VARCHAR(20),
|
||
subject_full_name VARCHAR(255),
|
||
request_type VARCHAR(30) NOT NULL
|
||
CHECK (request_type IN ('access','rectification','deletion','objection')),
|
||
description TEXT,
|
||
status VARCHAR(20) DEFAULT 'received'
|
||
CHECK (status IN ('received','in_progress','completed','rejected')),
|
||
-- Связь с тенантом (если запрос относится к лиду в конкретном тенанте)
|
||
tenant_id BIGINT REFERENCES tenants(id),
|
||
-- Обработка
|
||
assigned_admin_id BIGINT REFERENCES saas_admin_users(id),
|
||
response_sent_at TIMESTAMPTZ,
|
||
response_text TEXT,
|
||
-- 30 дней по 152-ФЗ. 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. АДМИНКА SAAS — ЖУРНАЛ ДЕЙСТВИЙ (НОВАЯ)
|
||
-- saas_admin_users уже создана выше (нужна была для FK от других таблиц)
|
||
-- =============================================================================
|
||
|
||
CREATE TABLE saas_admin_audit_log (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id),
|
||
action VARCHAR(100) NOT NULL, -- 'tenant.suspend', 'refund.approve', 'system_settings.update', ...
|
||
target_type VARCHAR(50), -- 'tenant', 'saas_transaction', 'system_setting', ...
|
||
target_id BIGINT,
|
||
target_tenant_id BIGINT REFERENCES tenants(id), -- денормализация для быстрого фильтра
|
||
payload_before JSONB,
|
||
payload_after JSONB,
|
||
reason TEXT, -- обязательное основание для критичных действий
|
||
ip_address INET NOT NULL,
|
||
user_agent TEXT,
|
||
-- Four-eyes: для операций > порога нужно подтверждение второго админа
|
||
requires_approval BOOLEAN DEFAULT FALSE,
|
||
approved_by BIGINT REFERENCES saas_admin_users(id),
|
||
approved_at TIMESTAMPTZ,
|
||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_admin_audit_admin ON saas_admin_audit_log(admin_user_id, created_at DESC);
|
||
CREATE INDEX idx_admin_audit_tenant ON saas_admin_audit_log(target_tenant_id, created_at DESC) WHERE target_tenant_id IS NOT NULL;
|
||
CREATE INDEX idx_admin_audit_action ON saas_admin_audit_log(action, created_at DESC);
|
||
CREATE INDEX idx_admin_audit_pending ON saas_admin_audit_log(approved_at) WHERE requires_approval = TRUE AND approved_at IS NULL;
|
||
|
||
|
||
-- =============================================================================
|
||
-- 11. ЗАПОЛНЕНИЕ СПРАВОЧНИКОВ
|
||
-- =============================================================================
|
||
|
||
-- 14 статусов воронки (раздел 7.3, 8.1)
|
||
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
|
||
('new', 'Новые', TRUE, 1, '#3B82F6'),
|
||
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
|
||
('worked', 'Проработан', TRUE, 3, '#06B6D4'),
|
||
('base', 'База', FALSE, 4, '#64748B'),
|
||
('missed', 'Недозвон', FALSE, 5, '#F59E0B'),
|
||
('negotiations', 'Переговоры', FALSE, 6, '#EAB308'),
|
||
('waiting_payment', 'Ожидаем оплаты', FALSE, 7, '#A78BFA'),
|
||
('partnership', 'Партнерка', FALSE, 8, '#EC4899'),
|
||
('paid', 'Оплачено', TRUE, 9, '#10B981'),
|
||
('closed', 'Закрыто и не реализовано', TRUE, 10, '#6B7280'),
|
||
('test_drive', 'Тест драйв', FALSE,11, '#14B8A6'),
|
||
('hot', 'Горячий', FALSE,12, '#EF4444'),
|
||
('replacement', 'На замену', FALSE,13, '#F97316'),
|
||
('final_missed', 'Конечный недозвон', TRUE, 14, '#1F2937');
|
||
|
||
|
||
-- НОВОЕ в v8.2: каталог поставщиков B1/B2/B3
|
||
-- Раскрыты в партии 10.2 аудита crm.bp-gr.ru от 04.05.2026.
|
||
-- Прил. М §3.1 — обоснование модели.
|
||
-- Seed B1/B2/B3 — три суб-поставщика crm.bp-gr.ru, выявленные в партии 10.2 аудита.
|
||
-- v8.3: добавлены поля capabilities (channel, supports_*) — партия 13.3.5 аудита.
|
||
-- Цены 1.00 ₽ — placeholders, фактические значения берутся из договора с поставщиком
|
||
-- (ждём от заказчика по Б-1).
|
||
INSERT INTO suppliers (code, name, description, accepts_types, cost_rub, settings_schema,
|
||
channel, supports_sender_name, supports_keyword,
|
||
supports_csv_upload, supports_domains_list, sort_order)
|
||
VALUES
|
||
('b1', 'B1 — Сайты и Звонки',
|
||
'Основной поставщик для проектов типов «Сайты» и «Звонки». Принимает домены и телефонные номера через ingress-API.',
|
||
ARRAY['websites','calls'],
|
||
1.00,
|
||
'{"type":"object","properties":{},"additionalProperties":false}'::jsonb,
|
||
'sites', -- channel
|
||
FALSE, -- supports_sender_name
|
||
FALSE, -- supports_keyword
|
||
TRUE, -- supports_csv_upload (партия 13.3: загрузка CSV-доменов)
|
||
TRUE, -- supports_domains_list (textarea со списком доменов)
|
||
1),
|
||
('b2', 'B2 — СМС с ключевым словом',
|
||
'Поставщик СМС-лидов, работающий по связке «Наименование отправителя + Ключевое слово».',
|
||
ARRAY['sms'],
|
||
1.00,
|
||
'{"type":"object","required":["sender_name","keyword"],"properties":{"sender_name":{"type":"string","maxLength":11},"keyword":{"type":"string","maxLength":50}}}'::jsonb,
|
||
'sms', -- channel
|
||
TRUE, -- supports_sender_name
|
||
TRUE, -- supports_keyword (только B2 — партия 13.3.5)
|
||
FALSE, -- supports_csv_upload (для SMS не применимо)
|
||
FALSE, -- supports_domains_list (для SMS не применимо)
|
||
2),
|
||
('b3', 'B3 — СМС по наименованию',
|
||
'Поставщик СМС-лидов, работающий только по «Наименованию отправителя» (без ключевого слова).',
|
||
ARRAY['sms'],
|
||
1.00,
|
||
'{"type":"object","required":["sender_name"],"properties":{"sender_name":{"type":"string","maxLength":11}}}'::jsonb,
|
||
'sms', -- channel
|
||
TRUE, -- supports_sender_name
|
||
FALSE, -- supports_keyword (B3 не поддерживает — партия 13.3.5)
|
||
FALSE, -- supports_csv_upload
|
||
FALSE, -- supports_domains_list
|
||
3);
|
||
|
||
|
||
-- Глобальные настройки SaaS (раздел 3.4 + расширения v8.1 + v8.2 + v8.3)
|
||
INSERT INTO system_settings (key, value, type, description) VALUES
|
||
('schema_version', '8.3', 'string', 'Текущая версия схемы БД'),
|
||
('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'),
|
||
('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'),
|
||
('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'),
|
||
('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'),
|
||
('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'),
|
||
('webhook_hmac_required', 'false', 'bool', 'Обязательная HMAC-подпись X-Webhook-Signature: sha256=<hex(hmac(body,token))>. true → запросы без подписи 401. false → подпись опциональна, но если пришла — проверяется (backward-compat).'),
|
||
('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'),
|
||
('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'),
|
||
('password_min_length', '10', 'int', 'Минимальная длина пароля'),
|
||
('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'),
|
||
-- VAPID (Web Push, раздел 17.4)
|
||
('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'),
|
||
('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'),
|
||
('vapid_subject', 'mailto:admin@example.com', 'string', 'VAPID subject'),
|
||
-- Лимиты админки (АДМ §3, ДЕФ-3, ДЕФ-4)
|
||
('admin_support_topup_limit_rub_24h', '1000', 'decimal', 'Лимит ручного начисления для роли support за 24ч (₽)'),
|
||
('admin_support_topup_limit_leads_24h','10', 'int', 'Лимит ручного начисления для роли support за 24ч (лидов)'),
|
||
('admin_four_eyes_threshold_rub', '50000','decimal', 'Порог суммы, выше которого требуется подтверждение второго админа'),
|
||
-- CTO-3: таймауты pending-транзакций
|
||
('payments_pending_soft_timeout_min', '30', 'int', 'Soft-таймаут pending транзакции, после которого cron делает polling шлюза (минут)'),
|
||
('payments_pending_hard_timeout_hours','24', 'int', 'Hard-таймаут pending транзакции, после которого failed независимо от ответа шлюза (часов)'),
|
||
-- Ю-2: реселлерская модель (supplier_default_cost_rub оставлен как fallback для legacy-кода)
|
||
('supplier_default_cost_rub', '1.00', 'decimal', 'Закупочная цена одного лида (fallback). Новый код читает suppliers.cost_rub.'),
|
||
-- v8.2: динамические лимиты проектов (партия 10.6 аудита)
|
||
('limits_recalc_cron_enabled', 'true', 'bool', 'Включён ли cron limits:recalc для автокоррекции effective_daily_limit_today'),
|
||
('limits_recalc_minute_offset', '0', 'int', 'Смещение запуска cron limits:recalc от 00:00 МСК (минут). 0 = ровно в полночь.'),
|
||
('limits_balance_low_log_enabled', 'true', 'bool', 'Логировать ли в project_limit_adjustments каждое изменение лимита по balance_low'),
|
||
-- v8.3: cron projects:purge-deleted (Биз-14, партия 13.2.3 аудита)
|
||
-- Оригинал хранит soft-deleted проекты ≥3 месяцев, точное TTL не определено.
|
||
-- У нас дефолт 6 месяцев + cron отключён (включается админом SaaS вручную после
|
||
-- согласования с юристом по 152-ФЗ ст.5 п.7 «минимизация хранения»).
|
||
('projects_purge_deleted_enabled', 'false', 'bool', 'Включён ли cron физического удаления soft-deleted проектов после TTL'),
|
||
('projects_purge_deleted_ttl_days', '180', 'int', 'TTL для физического удаления soft-deleted проектов (дней). 180 = 6 месяцев.'),
|
||
('projects_purge_deleted_cron', '0 4 * * *', 'string', 'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)'),
|
||
-- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
|
||
('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
|
||
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.');
|
||
|
||
|
||
-- 4 стартовых тарифа-заглушки (Биз-1: вариант Б).
|
||
-- Структура зафиксирована (code, billing_model — контракт с кодом).
|
||
-- Цены 1.00 ₽ — заглушки, заказчик заменит через админку до публичного запуска.
|
||
INSERT INTO tariff_plans (code, name, description, billing_model,
|
||
price_per_lead, price_monthly, included_leads,
|
||
limits, features, trial_bonus_leads,
|
||
is_active, is_public, sort_order)
|
||
VALUES
|
||
('start', 'Старт', 'Базовый функционал. Платите только за лиды.',
|
||
'per_lead', 1.00, NULL, NULL,
|
||
'{"max_users":1,"max_projects":3,"api_rps":60}'::jsonb,
|
||
'["webhook","basic_analytics"]'::jsonb,
|
||
50, TRUE, TRUE, 1),
|
||
('basic', 'Базовый', 'Для небольших отделов. Абонплата + сверхлимитные лиды.',
|
||
'hybrid', 1.00, 1.00, 100,
|
||
'{"max_users":3,"max_projects":10,"api_rps":60}'::jsonb,
|
||
'["webhook","kanban","basic_analytics"]'::jsonb,
|
||
50, TRUE, TRUE, 2),
|
||
('pro', 'Про', 'Расширенная аналитика, API, Kanban.',
|
||
'hybrid', 1.00, 1.00, 300,
|
||
'{"max_users":10,"max_projects":50,"api_rps":120}'::jsonb,
|
||
'["webhook","kanban","advanced_analytics","api","2fa"]'::jsonb,
|
||
50, TRUE, TRUE, 3),
|
||
('enterprise', 'Корпоративный', 'Индивидуальные условия, кастомный домен, SLA.',
|
||
'custom', NULL, NULL, NULL,
|
||
'{}'::jsonb,
|
||
'["webhook","kanban","advanced_analytics","api","2fa","custom_domain"]'::jsonb,
|
||
50, TRUE, FALSE, 4);
|
||
|
||
|
||
-- =============================================================================
|
||
-- 12. ROW-LEVEL SECURITY (CTO-5: ВКЛЮЧЁН НА MVP)
|
||
-- =============================================================================
|
||
--
|
||
-- Перед каждым запросом приложение делает SET LOCAL app.current_tenant_id = X
|
||
-- в той же транзакции. Подробнее — в спецификации (раздел 22.6 v8.1).
|
||
--
|
||
-- Роли БД (см. раздел 13 ниже):
|
||
-- crm_app_user — обычная роль, на неё действуют политики;
|
||
-- crm_admin_user — BYPASSRLS, для админки SaaS (legitimate cross-tenant);
|
||
-- crm_migrator — BYPASSRLS, для миграций.
|
||
--
|
||
-- Для очередей: базовый класс TenantAwareJob в начале handle() устанавливает
|
||
-- app.current_tenant_id из своих параметров. Обязательно для всех job-классов,
|
||
-- работающих с tenant-данными.
|
||
-- =============================================================================
|
||
|
||
-- Шаблон политики (применяется ко всем tenant-таблицам):
|
||
-- USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
|
||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE tenant_status_overrides ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE tenant_custom_domains ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE user_recovery_codes ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE email_verifications ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE comment_templates ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE deal_tags ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE import_log ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже)
|
||
ALTER TABLE saas_upd_documents ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE saas_transactions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE refund_requests ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE balance_transactions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE report_jobs ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE tenant_consents ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE pd_processing_log ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE project_suppliers ENABLE ROW LEVEL SECURITY; -- v8.2
|
||
ALTER TABLE project_limit_adjustments ENABLE ROW LEVEL SECURITY; -- v8.2 (Прил. М §3.2)
|
||
-- auth_log: специальный случай, политика ниже (для tenant_user строк)
|
||
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
|
||
-- v8.4: outbound webhook (раздел 19.10)
|
||
ALTER TABLE outbound_webhook_subscriptions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE outbound_webhook_deliveries ENABLE ROW LEVEL SECURITY;
|
||
-- v8.4 hotfix: deal_tag_pivot — link между deals (partitioned, RLS есть) и deal_tags (RLS через tenant_id).
|
||
-- В самой pivot нет tenant_id, поэтому фильтр через JOIN на deal_tags (tag_id).
|
||
ALTER TABLE deal_tag_pivot ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE project_user_assignments ENABLE ROW LEVEL SECURITY; -- v8.5 (CTO-16)
|
||
ALTER TABLE webhook_dedup_keys ENABLE ROW LEVEL SECURITY; -- v8.6 (CTO-17)
|
||
ALTER TABLE in_app_notifications ENABLE ROW LEVEL SECURITY; -- v8.10 (P0 этап 2)
|
||
|
||
-- Базовая политика для таблиц с прямым tenant_id
|
||
CREATE POLICY tenant_isolation ON users USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON projects USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON deals USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON tenant_status_overrides USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON tenant_custom_domains USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON api_keys USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON push_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON comment_templates USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON saas_transactions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON refund_requests USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON balance_transactions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON report_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON tenant_consents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON pd_processing_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
CREATE POLICY tenant_isolation ON project_limit_adjustments USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.2
|
||
CREATE POLICY tenant_isolation ON outbound_webhook_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.4
|
||
CREATE POLICY tenant_isolation ON outbound_webhook_deliveries USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.4
|
||
CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.10
|
||
|
||
-- v8.2: project_suppliers — фильтр через JOIN на projects (tenant_id у проекта, не у связи)
|
||
CREATE POLICY tenant_isolation ON project_suppliers USING (
|
||
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
|
||
-- Для user_*-таблиц фильтр через JOIN на users
|
||
CREATE POLICY tenant_isolation ON user_recovery_codes USING (
|
||
user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
CREATE POLICY tenant_isolation ON user_sessions USING (
|
||
user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
CREATE POLICY tenant_isolation ON email_verifications USING (
|
||
user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
|
||
-- saas_invoice_items: фильтр через invoice_id
|
||
-- v8.5 (OPEN-И-14): WITH CHECK защищает INSERT/UPDATE — нельзя вставить
|
||
-- строку invoice_item ссылающуюся на чужой invoice. До v8.5 защита только
|
||
-- на USING (SELECT/UPDATE filter), INSERT мог пройти если invoice_id — чужой.
|
||
CREATE POLICY tenant_isolation ON saas_invoice_items USING (
|
||
invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
)
|
||
WITH CHECK (
|
||
invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
|
||
-- v8.4 hotfix: deal_tag_pivot — фильтр через tag_id → deal_tags(tenant_id)
|
||
-- v8.5 (OPEN-И-14): добавлено WITH CHECK — нельзя пометить deal чужим тегом.
|
||
CREATE POLICY tenant_isolation ON deal_tag_pivot USING (
|
||
tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
)
|
||
WITH CHECK (
|
||
tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
|
||
-- v8.5 (CTO-16): project_user_assignments — фильтр через project_id → projects(tenant_id).
|
||
-- WITH CHECK на INSERT/UPDATE — нельзя назначить менеджера в чужой проект.
|
||
CREATE POLICY tenant_isolation ON project_user_assignments USING (
|
||
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
)
|
||
WITH CHECK (
|
||
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
|
||
);
|
||
|
||
-- v8.6 (CTO-17): webhook_dedup_keys — tenant-уровневая по tenant_id напрямую.
|
||
-- WITH CHECK на INSERT/UPDATE — нельзя зарегистрировать дедуп-ключ в чужом тенанте
|
||
-- (defense-in-depth поверх tenant_id NOT NULL + FK на deals).
|
||
CREATE POLICY tenant_isolation ON webhook_dedup_keys USING (
|
||
tenant_id = current_setting('app.current_tenant_id')::bigint
|
||
)
|
||
WITH CHECK (
|
||
tenant_id = current_setting('app.current_tenant_id')::bigint
|
||
);
|
||
|
||
-- auth_log: tenant_user и saas_admin строки разные. Здесь — только tenant_user.
|
||
CREATE POLICY tenant_isolation ON auth_log USING (
|
||
actor_type = 'tenant_user'
|
||
AND tenant_id = current_setting('app.current_tenant_id')::bigint
|
||
);
|
||
|
||
-- Таблицы supplier_lead_costs, supplier_invoices, suppliers — НЕ tenant-уровневые
|
||
-- (это наши данные у поставщика), доступны только из админки (под ролью
|
||
-- crm_admin_user с BYPASSRLS). Для основного приложения недоступны.
|
||
-- Исключение: project_suppliers — tenant-уровневая через JOIN на projects (см. RLS-политику выше).
|
||
|
||
|
||
-- =============================================================================
|
||
-- 13. РОЛИ БД (CTO-5)
|
||
-- =============================================================================
|
||
--
|
||
-- Создаются вручную в production-окружении после первого деплоя схемы.
|
||
-- На dev можно использовать суперпользователя.
|
||
--
|
||
-- ВАЖНО: пароли НЕ в этом файле. Заполнить в production через secret manager.
|
||
|
||
-- CREATE ROLE crm_app_user LOGIN PASSWORD '<from-secrets>';
|
||
-- CREATE ROLE crm_admin_user LOGIN PASSWORD '<from-secrets>' BYPASSRLS;
|
||
-- CREATE ROLE crm_migrator LOGIN PASSWORD '<from-secrets>' BYPASSRLS CREATEDB;
|
||
|
||
-- GRANT crm_app_user-у:
|
||
-- USAGE на схему public;
|
||
-- SELECT, INSERT, UPDATE, DELETE на все tenant-таблицы;
|
||
-- SELECT, INSERT, UPDATE на tenants (для onboarding/blocking);
|
||
-- USAGE на все sequences;
|
||
-- запрещено: TRUNCATE, DROP, alteration схемы.
|
||
--
|
||
-- GRANT crm_admin_user-у:
|
||
-- все права crm_app_user, плюс
|
||
-- SELECT, INSERT, UPDATE на saas_admin_*, supplier_*, system_settings, tariff_plans,
|
||
-- legal_entities, payment_gateways;
|
||
-- нет DELETE на финансовых таблицах (только soft markers).
|
||
|
||
-- v8.5 (OPEN-И-15 + OPEN-И-23): новая роль crm_audit_writer.
|
||
-- INSERT-only на 5 audit-таблиц. UPDATE/DELETE заблокированы триггерами
|
||
-- (см. секцию «14. АУДИТ APPEND-ONLY + HASH CHAIN»). Application пишет
|
||
-- в audit-таблицы под этой ролью (через temporary SET ROLE), что обеспечивает
|
||
-- невозможность fraud-удаления записей даже от super_admin.
|
||
--
|
||
-- CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
|
||
--
|
||
-- GRANT crm_audit_writer-у:
|
||
-- USAGE на схему public;
|
||
-- INSERT (только) на auth_log, activity_log, pd_processing_log,
|
||
-- saas_admin_audit_log, balance_transactions;
|
||
-- USAGE на sequences соответствующих таблиц;
|
||
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.
|
||
|
||
-- v8.5 (OPEN-И-14): defense-in-depth — REVOKE ALL на 6 saas-таблицах
|
||
-- от crm_app_user. К этим таблицам tenant-приложение доступа НЕ должно
|
||
-- иметь даже теоретически (RLS + REVOKE = 2 барьера).
|
||
--
|
||
-- REVOKE ALL ON saas_admin_users FROM crm_app_user;
|
||
-- REVOKE ALL ON saas_admin_sessions FROM crm_app_user;
|
||
-- REVOKE ALL ON saas_admin_audit_log FROM crm_app_user;
|
||
-- REVOKE ALL ON incidents_log FROM crm_app_user;
|
||
-- REVOKE ALL ON pd_subject_requests FROM crm_app_user;
|
||
-- REVOKE ALL ON impersonation_tokens FROM crm_app_user;
|
||
|
||
|
||
-- =============================================================================
|
||
-- 14. АУДИТ APPEND-ONLY + HASH CHAIN (v8.5, OPEN-И-15)
|
||
-- =============================================================================
|
||
--
|
||
-- Назначение: обеспечить юридическую доказательность audit-журналов.
|
||
-- • Триггеры BEFORE UPDATE/DELETE → RAISE EXCEPTION делают записи неизменяемыми.
|
||
-- • Триггер BEFORE INSERT заполняет log_hash = sha256(prev_hash || NEW::text).
|
||
-- Любое удаление средней строки или вставка фейка → разрыв цепочки при
|
||
-- пересчёте (cron audit:verify-chain в Прил. И).
|
||
-- • Роль crm_audit_writer (INSERT-only) дополняет триггеры: даже если
|
||
-- админ выполнит ALTER TABLE … DISABLE TRIGGER, у него под crm_audit_writer
|
||
-- не будет UPDATE/DELETE прав — пройти оба слоя одновременно невозможно
|
||
-- без `super_admin` PostgreSQL (которого у админов SaaS нет в production).
|
||
--
|
||
-- Затронутые таблицы (5):
|
||
-- - auth_log
|
||
-- - activity_log
|
||
-- - pd_processing_log
|
||
-- - saas_admin_audit_log
|
||
-- - balance_transactions
|
||
|
||
CREATE OR REPLACE FUNCTION audit_chain_hash() RETURNS TRIGGER AS $$
|
||
DECLARE
|
||
prev_hash BYTEA;
|
||
BEGIN
|
||
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
|
||
-- TG_TABLE_NAME — имя таблицы, через которое триггер сработал; используем
|
||
-- format/EXECUTE для полиморфности.
|
||
EXECUTE format(
|
||
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
|
||
TG_TABLE_NAME
|
||
) INTO prev_hash;
|
||
|
||
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL — берём
|
||
-- пустую байтовую строку (первая запись цепочки).
|
||
NEW.log_hash := digest(
|
||
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
|
||
'sha256'
|
||
);
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
COMMENT ON FUNCTION audit_chain_hash() IS
|
||
'v8.5 (OPEN-И-15): SHA-256 hash chain для audit-таблиц. Заполняет '
|
||
'NEW.log_hash перед INSERT. Цепочка: digest(prev_hash || NEW::text, ''sha256''). '
|
||
'При попытке UPDATE/DELETE — отдельный триггер RAISE EXCEPTION.';
|
||
|
||
CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
|
||
USING ERRCODE = 'check_violation';
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
COMMENT ON FUNCTION audit_block_mutation() IS
|
||
'v8.5 (OPEN-И-15): запрещает UPDATE/DELETE на audit-таблицах. '
|
||
'Совместно с REVOKE на роли — два слоя защиты от tampering.';
|
||
|
||
-- 5 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
|
||
|
||
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_pd_log
|
||
BEFORE INSERT ON pd_processing_log
|
||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||
CREATE TRIGGER trg_audit_block_mut_pd_log
|
||
BEFORE UPDATE OR DELETE ON pd_processing_log
|
||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||
|
||
CREATE TRIGGER trg_audit_chain_hash_saas_admin_audit
|
||
BEFORE INSERT ON saas_admin_audit_log
|
||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||
CREATE TRIGGER trg_audit_block_mut_saas_admin_audit
|
||
BEFORE UPDATE OR DELETE ON saas_admin_audit_log
|
||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||
|
||
CREATE TRIGGER trg_audit_chain_hash_balance_tx
|
||
BEFORE INSERT ON balance_transactions
|
||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||
CREATE TRIGGER trg_audit_block_mut_balance_tx
|
||
BEFORE UPDATE OR DELETE ON balance_transactions
|
||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||
|
||
|
||
-- =============================================================================
|
||
-- 15. ТРИГГЕР ЭКСПОРТА В pd_processing_log (v8.5, OPEN-И-20)
|
||
-- =============================================================================
|
||
--
|
||
-- При создании записи в report_jobs (выгрузка лидов в файл) автоматически
|
||
-- пишем в pd_processing_log action='exported' для соответствия 152-ФЗ ст.18
|
||
-- ч.2 (фиксация всех операций обработки ПДн). До v8.5 такая запись делалась
|
||
-- из app-кода — риск пропуска при ошибке программиста.
|
||
|
||
CREATE OR REPLACE FUNCTION report_jobs_log_export() RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
INSERT INTO pd_processing_log (
|
||
tenant_id, subject_type, subject_id, action, purpose,
|
||
actor_tenant_user_id, ip_address
|
||
) VALUES (
|
||
NEW.tenant_id,
|
||
'lead', -- bulk-выгрузка лидов
|
||
NULL, -- subject_id NULL = bulk
|
||
'exported',
|
||
'report_job_' || NEW.id,
|
||
NEW.user_id,
|
||
NULL -- IP уже в saas_admin_audit_log/auth_log если нужно
|
||
);
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER trg_report_jobs_export_log
|
||
AFTER INSERT ON report_jobs
|
||
FOR EACH ROW EXECUTE FUNCTION report_jobs_log_export();
|
||
|
||
COMMENT ON FUNCTION report_jobs_log_export() IS
|
||
'v8.5 (OPEN-И-20): автоматически пишет в pd_processing_log при создании '
|
||
'report_jobs. Закрывает риск пропуска audit-записи на стороне приложения.';
|
||
|
||
|
||
-- =============================================================================
|
||
-- 16. ТРИГГЕР РАСЧЁТА LEAD_SCORE (v8.5, Биз-22)
|
||
-- =============================================================================
|
||
--
|
||
-- Простая модель без ML: lead_score = supplier.quality_score *
|
||
-- LEAST(time_in_form_seconds / 60.0, 99.99 / supplier.quality_score).
|
||
-- Если time_in_form_seconds NULL — lead_score NULL.
|
||
-- Поставщик вычисляется через project → project_suppliers → supplier (в MVP
|
||
-- предполагается ровно один активный поставщик на проект; если их несколько
|
||
-- — берётся первый по sort_order). Полная модель — Post-MVP.
|
||
|
||
CREATE OR REPLACE FUNCTION calc_lead_score() RETURNS TRIGGER AS $$
|
||
DECLARE
|
||
quality NUMERIC(3,2);
|
||
BEGIN
|
||
IF NEW.time_in_form_seconds IS NULL THEN
|
||
NEW.lead_score := NULL;
|
||
RETURN NEW;
|
||
END IF;
|
||
|
||
SELECT s.quality_score INTO quality
|
||
FROM project_suppliers ps
|
||
JOIN suppliers s ON s.id = ps.supplier_id
|
||
WHERE ps.project_id = NEW.project_id
|
||
AND ps.is_active = TRUE
|
||
AND s.is_active = TRUE
|
||
ORDER BY s.sort_order ASC, s.id ASC
|
||
LIMIT 1;
|
||
|
||
IF quality IS NULL THEN
|
||
NEW.lead_score := NULL;
|
||
ELSE
|
||
NEW.lead_score := LEAST(
|
||
quality * (NEW.time_in_form_seconds::numeric / 60.0),
|
||
99.99
|
||
);
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER trg_deals_calc_lead_score
|
||
BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals
|
||
FOR EACH ROW EXECUTE FUNCTION calc_lead_score();
|
||
|
||
COMMENT ON FUNCTION calc_lead_score() IS
|
||
'v8.5 (Биз-22): простой scoring lead_score = supplier.quality_score * '
|
||
'(time_in_form_seconds / 60), clamped в [0, 99.99]. Без ML.';
|
||
|
||
|
||
-- v8.6 (CTO-17): trigger для pd_subject_requests.deadline_at — замена
|
||
-- `GENERATED ALWAYS AS (received_at + INTERVAL '30 days') STORED`, отклонённого
|
||
-- PostgreSQL 16 как не-immutable (`+ INTERVAL` зависит от timezone/DST).
|
||
CREATE OR REPLACE FUNCTION set_pd_subject_request_deadline() RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
NEW.deadline_at := COALESCE(NEW.received_at, NOW()) + INTERVAL '30 days';
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER trg_pd_subject_requests_deadline
|
||
BEFORE INSERT OR UPDATE OF received_at ON pd_subject_requests
|
||
FOR EACH ROW EXECUTE FUNCTION set_pd_subject_request_deadline();
|
||
|
||
COMMENT ON FUNCTION set_pd_subject_request_deadline() IS
|
||
'v8.6 (CTO-17): расчёт deadline_at = received_at + 30 дней (152-ФЗ). '
|
||
'Заменяет GENERATED STORED column, отклонённый PG 16 как не-immutable.';
|
||
|
||
|
||
-- =============================================================================
|
||
-- 17. POST-MVP DDL ЗАКОММЕНТИРОВАН (v8.5, OPEN-И-26)
|
||
-- =============================================================================
|
||
--
|
||
-- Биз-12 (Post-MVP): телефонная интеграция и call-recording.
|
||
-- DDL ниже — задел для ретрофита, чтобы при добавлении функции в Post-MVP
|
||
-- не пришлось проектировать с нуля. ПДн-маркировка выставлена сразу:
|
||
-- recording_path хранит ссылки на S3 (сами записи лежат в S3+KMS), их
|
||
-- доступ — через signed URL TTL 1 ч с обязательным INSERT в pd_processing_log.
|
||
--
|
||
-- CREATE TABLE call_recordings (
|
||
-- id BIGSERIAL PRIMARY KEY,
|
||
-- tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
-- deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
|
||
-- call_started_at TIMESTAMPTZ NOT NULL,
|
||
-- duration_sec INT NOT NULL CHECK (duration_sec >= 0),
|
||
-- direction VARCHAR(10) NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||
-- recording_path VARCHAR(500), -- s3://bucket/recordings/...
|
||
-- transcript TEXT, -- результат STT (Yandex SpeechKit)
|
||
-- created_at TIMESTAMPTZ DEFAULT NOW()
|
||
-- );
|
||
-- CREATE INDEX ON call_recordings (tenant_id, deal_id, call_started_at DESC);
|
||
-- ALTER TABLE call_recordings ENABLE ROW LEVEL SECURITY;
|
||
-- CREATE POLICY tenant_isolation ON call_recordings
|
||
-- USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||
|
||
|
||
-- =============================================================================
|
||
-- ОТЛОЖЕННЫЕ FK НА ПАРТИЦИОНИРОВАННЫЕ ТАБЛИЦЫ
|
||
-- =============================================================================
|
||
-- Composite FK на partitioned deals(id, received_at) для lead_charges (Plan 1/5 Task 4, v8.15).
|
||
-- Размещён в конце файла, ПОСЛЕ section 5 (deals), чтобы при `migrate:fresh` PostgreSQL
|
||
-- видел deals при ADD CONSTRAINT. DEFERRABLE INITIALLY DEFERRED обязателен для
|
||
-- атомарного INSERT deal+charge в одной транзакции.
|
||
|
||
ALTER TABLE lead_charges
|
||
ADD CONSTRAINT lead_charges_deals_fk
|
||
FOREIGN KEY (deal_id, deal_received_at)
|
||
REFERENCES deals(id, received_at)
|
||
ON DELETE CASCADE
|
||
DEFERRABLE INITIALLY DEFERRED;
|
||
|
||
|
||
-- =============================================================================
|
||
-- КОНЕЦ schema.sql
|
||
-- =============================================================================
|