Files
portal/db/schema_modules.sql
T
Дмитрий bffca85399 docs+fix: канон схемы догнал миграции — 39 таблиц описаны, сверка считает обе стороны
Канон знал 82 таблицы и 995 столбцов, а база — 121 и 1470: не описаны были
рекламный модуль, телеграм-рассылки и весь отдел продаж.

Главное, что выяснилось по дороге: db/schema.sql не документ, а исполняемый
файл — миграция 0001_01_01_000000_load_initial_schema заливает его целиком
первым шагом сборки. Перенести туда DDL модулей нельзя: те же таблицы создают
дельта-миграции, их 77 и 49 из них без стражей. Сборка с нуля падала дважды.
Поэтому канон разбит на два файла: schema.sql исполняется, новый
schema_modules.sql только описывает.

Проверено не глазами:
- сборка с нуля 148 из 148 DONE;
- столбцы 1470 против 1470, ноль расхождений по имени, типу, длине и NULL;
- все четыре счётчика расхождений инструмента — ноль;
- schema.sql отдельно исполняется без единой ошибки, ограничения 495=495,
  политики 64=64;
- статанализ 0.

Инструмент сверки усилен: считал только одну сторону и потому не видел
переименования jivo_chat_id в chat_id — старое имя жило в каноне месяц.
Теперь обе стороны, канон из нескольких файлов, понимание RENAME/DROP COLUMN
и DROP TABLE. Починен разбор переносов строки внутри определения столбца.

Сторож SchemaDeltaTest дополнен: файл модулей обязан быть на месте и описывать
39 таблиц, а тело schema.sql их не содержать. Принят вырезанием.

Найдено и НЕ исправлено, нужно решение владельца: задвоенный указатель на
deals и пять политик без NULLIF в миграциях. Оба помечены в тексте канона.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:53:45 +03:00

1304 lines
76 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =============================================================================
-- schema_modules.sql — КАНОН МОДУЛЕЙ, дописанных поверх schema.sql миграциями
-- Версия: v1.0 (02.08.2026)
-- =============================================================================
--
-- 🔴 ЭТОТ ФАЙЛ НЕ ИСПОЛНЯЕТСЯ. Он только описывает.
--
-- Почему отдельный файл, а не тело schema.sql. `db/schema.sql` — не просто
-- документ: миграция `0001_01_01_000000_load_initial_schema.php` заливает его
-- ЦЕЛИКОМ как первый шаг сборки, а все остальные миграции ложатся поверх.
-- Значит всё, что перенесено в тело schema.sql, обязано не столкнуться с
-- дельта-миграцией, которая создаёт то же самое. Замер 02.08.2026: эти таблицы
-- трогают 77 миграций, 49 из них без стражей (`hasTable` / `hasColumn` /
-- `IF NOT EXISTS`). Свести их в тело — большая отдельная работа с риском сломать
-- сборку с нуля, и это решение владельца, а не побочный шаг сверки канона.
--
-- Пока такого решения нет, канон живёт в двух файлах:
-- • db/schema.sql — исходная схема, ИСПОЛНЯЕТСЯ первой миграцией;
-- • db/schema_modules.sql — всё, что дописали миграции после неё (этот файл).
-- Вместе они описывают базу полностью — это проверено сверкой:
-- node tools/sverka-kanona-shemy.mjs db/schema.sql db/schema_modules.sql stolbcy.txt
--
-- Порядок операторов внутри файла — исполнимый (родитель раньше ребёнка), чтобы
-- при будущем сведении его можно было взять как есть. Но сам по себе он никуда
-- не заливается: заливка привела бы к столкновению с дельта-миграциями.
--
-- Что описано: разделы 18–21 (реклама клиента, телеграм-рассылки клиента, отдел
-- продаж, служебные) + поздние столбцы уже описанных в schema.sql таблиц.
-- Журнал: db/CHANGELOG_schema.md, запись v9.33.
-- =============================================================================
-- =============================================================================
-- 18. РЕКЛАМНЫЙ МОДУЛЬ КЛИЕНТА (ad_*) — v8.86, canon-sync
-- =============================================================================
-- Клиент из кабинета заказывает рекламу на свою аудиторию: собирается сегмент из
-- телефонов его лидов, на него запускается кампания в Яндекс.Директе, деньги
-- списываются с отдельного рекламного кошелька (НЕ с баланса за лиды).
-- Выкачено на бой 25.07.2026, см. память project-reklama-modul-vykat-2026-07-25.
--
-- 🪤 Времена здесь — TIMESTAMP(0) БЕЗ пояса (умолчание Laravel timestamps()), а не
-- TIMESTAMPTZ, как в старших разделах. Столбец без пояса молча отбрасывает смещение
-- из строки: фикстуру и запись всегда приводить к UTC (App\Support\MskBoundary).
-- Исключение — ad_campaigns.revived_at: он с поясом.
--
-- 🔴 RLS: все таблицы с tenant_id — ENABLE + FORCE + политика tenant_isolation.
-- Служебная роль crm_supplier_worker ходит мимо RLS (BYPASSRLS), но ей всё равно
-- нужен GRANT: пропущенный grant на счётчик = «permission denied» на бою.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- ad_campaigns — рекламная кампания клиента
-- -----------------------------------------------------------------------------
-- mode: auto (портал сам ведёт) | manual. channel: пока только yandex.
-- Пара budget_rub/paid_impressions — что клиент купил; yandex_cost_rub/
-- delivered_impressions — что реально израсходовано у площадки; разница и есть
-- наша маржа (ad_settings.ad_margin_percent).
-- 🪤 snapshot_from/snapshot_to — КАЛЕНДАРНЫЕ даты (DATE), не мгновения: их
-- сравнивают с днём, а не с отметкой времени, переводить в пояс нельзя.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaigns (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel VARCHAR(16) NOT NULL DEFAULT 'yandex',
name VARCHAR(255) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'draft',
mode VARCHAR(10) NOT NULL DEFAULT 'auto',
-- Аудитория
audience_days SMALLINT NOT NULL DEFAULT 10,
use_uploaded_list BOOLEAN NOT NULL DEFAULT FALSE,
snapshot_from DATE,
snapshot_to DATE,
-- Деньги и объём
weekly_budget_rub NUMERIC(14,2),
daily_budget_rub NUMERIC(14,2),
budget_rub NUMERIC(14,2),
click_bid_rub NUMERIC(14,2),
client_cpm_rub NUMERIC(8,2),
frequency SMALLINT,
frequency_period_days SMALLINT,
run_days SMALLINT,
shows_until DATE,
estimated_impressions BIGINT,
paid_impressions BIGINT,
delivered_impressions BIGINT NOT NULL DEFAULT 0,
yandex_cost_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
charged_client_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
-- Идентификаторы на стороне Яндекса
yandex_segment_id BIGINT,
yandex_retargeting_list_id BIGINT,
yandex_campaign_id BIGINT,
yandex_ad_group_id BIGINT,
yandex_creative_id BIGINT,
yandex_ad_id BIGINT,
landing_url VARCHAR(1024),
moderation_reason VARCHAR(255),
banners_approved_at TIMESTAMP(0),
launched_at TIMESTAMP(0),
revived_at TIMESTAMPTZ(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
-- Составной ключ для FK дочерних: ребёнок обязан лежать в том же тенанте.
CONSTRAINT uq_ad_campaign_tenant UNIQUE (id, tenant_id)
);
CREATE INDEX ad_campaigns_tenant_id_status_index ON ad_campaigns (tenant_id, status);
ALTER TABLE ad_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaigns FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaigns
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_banners — картинки кампании, по одной на размерный слот
-- -----------------------------------------------------------------------------
-- included = участвует в показе. Уникальность по слоту (тенант+кампания+ШхВ):
-- на один размер — одна картинка.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_banners (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES ad_campaigns(id) ON DELETE CASCADE,
width SMALLINT NOT NULL,
height SMALLINT NOT NULL,
path VARCHAR(255) NOT NULL,
bytes INTEGER NOT NULL,
included BOOLEAN NOT NULL DEFAULT TRUE,
yandex_creative_id BIGINT,
yandex_ad_id BIGINT,
moderation_status VARCHAR(16) NOT NULL DEFAULT 'draft',
moderation_reason VARCHAR(255),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX ad_campaign_banners_tenant_id_campaign_id_index ON ad_campaign_banners (tenant_id, campaign_id);
CREATE UNIQUE INDEX uq_ad_campaign_banner_slot ON ad_campaign_banners (tenant_id, campaign_id, width, height);
ALTER TABLE ad_campaign_banners ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_banners FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_banners
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_ads — текстовые объявления кампании
-- -----------------------------------------------------------------------------
-- Длины title/title2/text — предельные у Яндекса (56/45/96 знаков).
-- moderation_status: draft | sent | accepted | rejected.
-- 🔑 «Создать» ≠ «запустить»: объявление, созданное без ads.moderate, остаётся
-- черновиком и не показывается (урок 30.07.2026).
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_ads (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES ad_campaigns(id) ON DELETE CASCADE,
title VARCHAR(56) NOT NULL,
title2 VARCHAR(45),
text VARCHAR(96) NOT NULL,
href VARCHAR(1024) NOT NULL,
image_normal_hash VARCHAR(255),
image_wide_hash VARCHAR(255),
yandex_ad_id BIGINT,
moderation_status VARCHAR(16) NOT NULL DEFAULT 'draft',
moderation_reason VARCHAR(255),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX ad_campaign_ads_tenant_id_campaign_id_index ON ad_campaign_ads (tenant_id, campaign_id);
ALTER TABLE ad_campaign_ads ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_ads FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_ads
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_messages — переписка клиента с модератором по кампании
-- -----------------------------------------------------------------------------
-- author: client | admin. banner_id — если реплика про конкретную картинку.
-- FK на кампанию СОСТАВНОЙ (campaign_id, tenant_id): реплика не может уехать
-- в чужой тенант. Свой составной ключ (id, campaign_id) нужен ad_creative_jobs.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_messages (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
banner_id BIGINT REFERENCES ad_campaign_banners(id) ON DELETE SET NULL,
author VARCHAR(16) NOT NULL,
body TEXT NOT NULL,
file_path VARCHAR(512),
file_name VARCHAR(255),
file_size INTEGER,
file_mime VARCHAR(128),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
CONSTRAINT uq_ad_campaign_message_campaign UNIQUE (id, campaign_id),
CONSTRAINT fk_ad_campaign_message_campaign FOREIGN KEY (campaign_id, tenant_id)
REFERENCES ad_campaigns (id, tenant_id) ON DELETE CASCADE
);
CREATE INDEX ad_campaign_messages_tenant_id_campaign_id_id_index
ON ad_campaign_messages (tenant_id, campaign_id, id);
ALTER TABLE ad_campaign_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_messages FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_messages
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_creative_jobs — очередь работ робота в кабинете Яндекса
-- -----------------------------------------------------------------------------
-- kind: upload (залить картинку) | inspect (посмотреть, что с модерацией) и т. п.
-- uq_creative_job_single_taken — частичный уникальный индекс по status='taken':
-- в работе одновременно может быть только ОДНА задача (робот один).
-- FK на кампанию и на реплику — составные, оба с защитой от ухода в чужой тенант.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_creative_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
message_id BIGINT,
kind VARCHAR(16) NOT NULL DEFAULT 'upload',
status VARCHAR(16) NOT NULL DEFAULT 'queued',
attempts SMALLINT NOT NULL DEFAULT 0,
snapshot_before JSONB,
failure_reason VARCHAR(1024),
yandex_ad_id BIGINT,
taken_at TIMESTAMP(0),
finished_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
CONSTRAINT fk_creative_job_campaign_tenant FOREIGN KEY (campaign_id, tenant_id)
REFERENCES ad_campaigns (id, tenant_id) ON DELETE CASCADE,
CONSTRAINT fk_creative_job_message_campaign FOREIGN KEY (message_id, campaign_id)
REFERENCES ad_campaign_messages (id, campaign_id) ON DELETE SET NULL (message_id)
);
CREATE INDEX ad_creative_jobs_status_index ON ad_creative_jobs (status);
CREATE INDEX ad_creative_jobs_tenant_id_campaign_id_index ON ad_creative_jobs (tenant_id, campaign_id);
CREATE INDEX idx_creative_job_inspect ON ad_creative_jobs (campaign_id, kind, yandex_ad_id);
CREATE UNIQUE INDEX uq_creative_job_single_taken ON ad_creative_jobs (status) WHERE status = 'taken';
ALTER TABLE ad_creative_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_creative_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_creative_jobs
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_phones — телефоны аудитории кампании (слепок на момент запуска)
-- -----------------------------------------------------------------------------
-- ПДн: только номер, без имён. expires_at — когда номер выпадает из сегмента.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_phones (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES ad_campaigns(id) ON DELETE CASCADE,
phone VARCHAR(11) NOT NULL,
expires_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, campaign_id, phone)
);
CREATE INDEX ad_campaign_phones_tenant_id_campaign_id_index ON ad_campaign_phones (tenant_id, campaign_id);
ALTER TABLE ad_campaign_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_phones FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_phones
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_wallets / ad_wallet_holds / ad_wallet_transactions — рекламный кошелёк
-- -----------------------------------------------------------------------------
-- Отдельный от баланса за лиды кошелёк: пополняется счётом с
-- credit_target='advertising'. frozen_rub — сумма живых удержаний (holds) под
-- запущенные кампании; списание превращает удержание в проводку.
-- external_key + tenant_id уникальны — защита от двойного зачисления одного
-- и того же внешнего платежа.
-- 🪤 ad_wallet_transactions.created_at — TIMESTAMP(0) БЕЗ пояса: строка со
-- смещением «+03:00» ляжет туда молча не тем моментом (обжигались 02.08.2026).
-- -----------------------------------------------------------------------------
CREATE TABLE ad_wallets (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
balance_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
frozen_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
ALTER TABLE ad_wallets ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_wallets FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_wallets
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE TABLE ad_wallet_holds (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel VARCHAR(32) NOT NULL,
source_type VARCHAR(255) NOT NULL, -- класс модели-источника (кампания)
source_id BIGINT NOT NULL,
amount_rub NUMERIC(14,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, channel, source_type, source_id)
);
CREATE INDEX ad_wallet_holds_tenant_id_status_index ON ad_wallet_holds (tenant_id, status);
ALTER TABLE ad_wallet_holds ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_wallet_holds FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_wallet_holds
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE TABLE ad_wallet_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(32) NOT NULL,
amount_rub NUMERIC(14,2) NOT NULL,
balance_rub_after NUMERIC(14,2) NOT NULL,
channel VARCHAR(32),
related_type VARCHAR(255),
related_id BIGINT,
description VARCHAR(255),
external_key VARCHAR(255),
created_at TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (tenant_id, external_key)
);
CREATE INDEX ad_wallet_transactions_tenant_id_created_at_index ON ad_wallet_transactions (tenant_id, created_at);
CREATE INDEX ad_wallet_transactions_related_type_related_id_index ON ad_wallet_transactions (related_type, related_id);
ALTER TABLE ad_wallet_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_wallet_transactions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_wallet_transactions
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_settings — одна строка настроек рекламного модуля (SaaS-level, без RLS)
-- -----------------------------------------------------------------------------
-- client_cpm_rub — цена тысячи показов для клиента; ad_margin_percent и
-- markup_percent — наша наценка над расходом у площадки.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_settings (
id BIGSERIAL PRIMARY KEY,
markup_percent NUMERIC(5,2) NOT NULL DEFAULT 30.00,
client_cpm_rub NUMERIC(8,2) NOT NULL DEFAULT 120.00,
ad_margin_percent NUMERIC(5,2) NOT NULL DEFAULT 40,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
-- -----------------------------------------------------------------------------
-- external_service_balances — светофор остатков на внешних сервисах (SaaS-level)
-- -----------------------------------------------------------------------------
-- Одна строка на сервис (service_key — PK): Яндекс, МТС, смс-провайдер и т. п.
-- light: green | amber | red | grey (grey = не смогли спросить). Заполняет джоб
-- под crm_supplier_worker, читает админка. Без RLS и tenant_id — это про нас, не
-- про клиентов.
-- -----------------------------------------------------------------------------
CREATE TABLE external_service_balances (
service_key VARCHAR(32) PRIMARY KEY,
balance_amount NUMERIC(14,2),
currency VARCHAR(8) NOT NULL DEFAULT 'RUB',
daily_spend_estimate NUMERIC(14,2),
days_left INTEGER,
light VARCHAR(8) NOT NULL DEFAULT 'green'
CHECK (light IN ('green','amber','red','grey')),
ok BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Права рекламного модуля. Роли могут отсутствовать (dev под postgres) — проверяем.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_campaign_ads TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_banners TO crm_app_user;
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_phones TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_creative_jobs TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_wallets TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_wallet_holds TO crm_app_user;
GRANT SELECT, INSERT ON ad_wallet_transactions TO crm_app_user;
GRANT SELECT ON ad_settings TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE
ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_banners_id_seq,
ad_campaign_messages_id_seq, ad_campaign_phones_id_seq, ad_creative_jobs_id_seq,
ad_wallets_id_seq, ad_wallet_holds_id_seq, ad_wallet_transactions_id_seq
TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT ON ad_campaigns TO crm_admin_user;
-- Робот дописывает номер креатива в кампанию — точечное право на один столбец.
GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user;
GRANT SELECT ON ad_campaign_ads TO crm_admin_user;
GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_admin_user;
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_admin_user;
GRANT SELECT ON ad_campaign_phones TO crm_admin_user;
GRANT SELECT, UPDATE ON ad_creative_jobs TO crm_admin_user;
GRANT SELECT ON ad_wallets TO crm_admin_user;
GRANT SELECT ON ad_wallet_holds TO crm_admin_user;
GRANT SELECT ON ad_wallet_transactions TO crm_admin_user;
GRANT SELECT, UPDATE ON ad_settings TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_banners_id_seq,
ad_campaign_messages_id_seq, ad_campaign_phones_id_seq, ad_creative_jobs_id_seq,
ad_wallets_id_seq, ad_wallet_holds_id_seq, ad_wallet_transactions_id_seq,
ad_settings_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, UPDATE ON ad_campaigns TO crm_supplier_worker;
GRANT SELECT, UPDATE ON ad_campaign_ads TO crm_supplier_worker;
GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_supplier_worker;
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_supplier_worker;
GRANT SELECT, INSERT, UPDATE ON external_service_balances TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_banners_id_seq,
ad_campaign_messages_id_seq, ad_campaign_phones_id_seq, ad_creative_jobs_id_seq,
ad_wallets_id_seq, ad_wallet_holds_id_seq, ad_wallet_transactions_id_seq,
ad_settings_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 19. ТЕЛЕГРАМ-РАССЫЛКИ КЛИЕНТА (client_tg_*) — v8.86, canon-sync
-- =============================================================================
-- Клиент заказывает рассылку в Телеграме через рекламный кабинет МТС: собирается
-- аудитория из его лидов, робот заводит кампанию, деньги берутся из рекламного
-- кошелька (раздел 18). Модуль в ветке, на бой НЕ выкачен — ждёт, пока канал МТС
-- на боевом начнёт отправлять (без этого рассылки создаются и оплачиваются, а до
-- людей не доходят). Времена — TIMESTAMP(0) без пояса, как в разделе 18.
--
-- 🪤 У админской роли на кампаниях должен быть не только SELECT: канал ПИШЕТ
-- в client_tg_campaigns. На dev под postgres это невидимо, на бою — отказ.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- client_tg_campaigns — рассылка клиента
-- -----------------------------------------------------------------------------
-- audience_kind: deals (по своим лидам) | uploaded (загруженный список).
-- planned_count — сколько собрали, matched_count — сколько подтвердил МТС.
-- moderator_file_path — файл, который отдаём модератору площадки.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_campaigns (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
status_reason VARCHAR(500),
ad_headline VARCHAR(40),
ad_text VARCHAR(1000) NOT NULL,
ad_link VARCHAR(500),
media_path VARCHAR(500),
moderator_file_path VARCHAR(500),
ord_category VARCHAR(200) NOT NULL DEFAULT 'Размещение рекламы',
budget_cap_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
audience_kind VARCHAR(16) NOT NULL DEFAULT 'deals',
audience_params JSONB,
planned_count INTEGER NOT NULL DEFAULT 0,
matched_count INTEGER,
estimated_cost_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
actual_cost_rub NUMERIC(14,2),
mts_campaign_id VARCHAR(32),
created_by BIGINT,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX client_tg_campaigns_tenant_id_created_at_index ON client_tg_campaigns (tenant_id, created_at);
ALTER TABLE client_tg_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_campaigns FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_campaigns
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_campaign_phones — телефоны аудитории рассылки (слепок)
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_campaign_phones (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
phone VARCHAR(11) NOT NULL,
expires_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX client_tg_campaign_phones_tenant_id_campaign_id_index
ON client_tg_campaign_phones (tenant_id, campaign_id);
ALTER TABLE client_tg_campaign_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_campaign_phones FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_campaign_phones
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_robot_jobs — очередь работ робота в кабинете МТС
-- -----------------------------------------------------------------------------
-- Портал не ходит в кабинет сам: кладёт задание, робот ОПРАШИВАЕТ очередь
-- (связка переделана на опрос 31.07.2026 — робот за забором, стучаться некуда).
-- mode — что делать (создать / залить список / снять отчёт), payload/result — JSONB.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_robot_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES client_tg_campaigns(id) ON DELETE CASCADE,
mode VARCHAR(16) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
attempts SMALLINT NOT NULL DEFAULT 0,
result JSONB,
failure_reason VARCHAR(1024),
taken_at TIMESTAMP(0),
finished_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX client_tg_robot_jobs_status_index ON client_tg_robot_jobs (status);
CREATE INDEX client_tg_robot_jobs_tenant_id_campaign_id_index ON client_tg_robot_jobs (tenant_id, campaign_id);
ALTER TABLE client_tg_robot_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_robot_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_robot_jobs
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_contacts / client_tg_optouts — своя книжка номеров и отказы
-- -----------------------------------------------------------------------------
-- Отказ (optout) — номер, которому больше не пишем. Уникальность в пределах тенанта.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_contacts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
phone VARCHAR(11) NOT NULL,
name TEXT,
operator TEXT,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, phone)
);
ALTER TABLE client_tg_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_contacts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_contacts
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE TABLE client_tg_optouts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
phone VARCHAR(11) NOT NULL,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, phone)
);
ALTER TABLE client_tg_optouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_optouts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_optouts
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_auto_rule — одно правило автозапуска на тенанта
-- -----------------------------------------------------------------------------
-- Когда включено, портал сам собирает пачку и запускает рассылку, как только
-- накопилось batch_threshold номеров. daily_limit_rub/spent_today_rub/spent_date —
-- дневной потолок расхода; spent_date хранит КАЛЕНДАРНЫЙ день (DATE), сравнение
-- ведётся по московским суткам.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_auto_rule (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
ad_text VARCHAR(1000) NOT NULL,
ad_link VARCHAR(500),
ord_category VARCHAR(200) NOT NULL DEFAULT 'Размещение рекламы',
budget_cap_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
daily_limit_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
spent_today_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
spent_date DATE,
batch_threshold INTEGER,
updated_by BIGINT,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
ALTER TABLE client_tg_auto_rule ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_auto_rule FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_auto_rule
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_tariffs — лесенка цен за сообщение (SaaS-level, без RLS)
-- -----------------------------------------------------------------------------
-- min_qty — от какого объёма действует цена; строка с наибольшим подходящим
-- min_qty и даёт цену.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_tariffs (
id BIGSERIAL PRIMARY KEY,
min_qty INTEGER NOT NULL UNIQUE,
price_rub NUMERIC(6,2) NOT NULL,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON client_tg_campaigns TO crm_app_user;
GRANT SELECT, INSERT, DELETE ON client_tg_campaign_phones TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON client_tg_robot_jobs TO crm_app_user;
GRANT SELECT, INSERT, DELETE ON client_tg_contacts TO crm_app_user;
GRANT SELECT, INSERT, DELETE ON client_tg_optouts TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON client_tg_auto_rule TO crm_app_user;
GRANT SELECT ON client_tg_tariffs TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE client_tg_robot_jobs_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
-- 🔴 UPDATE на кампаниях обязателен: админский канал в них ПИШЕТ.
GRANT SELECT, UPDATE ON client_tg_campaigns TO crm_admin_user;
GRANT SELECT, UPDATE ON client_tg_robot_jobs TO crm_admin_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON client_tg_tariffs TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
client_tg_campaigns_id_seq, client_tg_campaign_phones_id_seq,
client_tg_robot_jobs_id_seq, client_tg_contacts_id_seq,
client_tg_optouts_id_seq, client_tg_auto_rule_id_seq,
client_tg_tariffs_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT ON client_tg_campaigns TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
client_tg_campaigns_id_seq, client_tg_campaign_phones_id_seq,
client_tg_robot_jobs_id_seq, client_tg_contacts_id_seq,
client_tg_optouts_id_seq, client_tg_auto_rule_id_seq,
client_tg_tariffs_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 20. ОТДЕЛ ПРОДАЖ (sales_*) — v8.86, canon-sync
-- =============================================================================
-- Отдельный портал для НАШИХ продавцов (не для клиентов): своя воронка «фирма →
-- переговоры → зарегистрировался → платит», свои тарифы и выплаты, своя рассылка
-- и «прогрев» будущих клиентов рекламой до звонка.
--
-- 🔴 Все таблицы SaaS-уровня: БЕЗ RLS и без tenant_id (даже там, где tenant_id
-- есть — это ссылка на клиента, а не разрез доступа). Ходят только служебные роли
-- crm_admin_user и crm_supplier_worker; клиентская crm_app_user сюда не допущена.
-- Времена — TIMESTAMPTZ (в отличие от разделов 18–19).
-- =============================================================================
-- -----------------------------------------------------------------------------
-- sales_tariffs / sales_users — продавцы и их вознаграждение
-- -----------------------------------------------------------------------------
-- kind тарифа: daily_salary (оклад за день) | topup_step (процент со ступени
-- пополнения). role: manager | head — «head» видит весь отдел.
-- 🪤 Ролей ровно две; 'admin' в проверках падает на sales_users_role_check.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_tariffs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
kind VARCHAR(20) NOT NULL CHECK (kind IN ('daily_salary','topup_step')),
params JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE sales_users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(10) NOT NULL DEFAULT 'manager'
CHECK (role IN ('manager','head')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
base_salary_rub NUMERIC(12,2) NOT NULL DEFAULT 0,
current_tariff_id BIGINT REFERENCES sales_tariffs(id),
created_by BIGINT REFERENCES sales_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
-- -----------------------------------------------------------------------------
-- sales_prospects — карточка будущего клиента в воронке отдела продаж
-- -----------------------------------------------------------------------------
-- stage — 12 стадий воронки; source: search (нашли поиском) | manager (завёл сам).
-- test_channel/test_target — куда отправляли пробные лиды, kp_channel/kp_target —
-- куда выслали коммерческое предложение (стадии manual_testing и kp_sent, v9.28).
-- prev_stage помнит, откуда пришли, чтобы вернуть карточку назад одним нажатием.
-- linked_tenant_id — связь с зарегистрировавшимся клиентом.
-- Уникальность (продавец, ИНН) — частичная: без ИНН фирму можно завести дважды.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_prospects (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
assigned_by BIGINT REFERENCES sales_users(id),
stage VARCHAR(16) NOT NULL DEFAULT 'new'
CHECK (stage IN ('new','in_work','negotiation','manual_testing',
'registered','testing','topped_up','user',
'kp_sent','rejected','no_answer','trash')),
prev_stage VARCHAR(16),
source VARCHAR(16) NOT NULL DEFAULT 'search'
CHECK (source IN ('search','manager')),
firm_name VARCHAR(500) NOT NULL,
legal_name VARCHAR(500),
city VARCHAR(255),
phone VARCHAR(64),
site VARCHAR(500),
inn VARCHAR(20),
rating_label VARCHAR(32),
contacts JSONB NOT NULL DEFAULT '[]'::jsonb,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
next_call_at TIMESTAMPTZ,
reason TEXT,
notes TEXT,
registered_email VARCHAR(255),
linked_tenant_id BIGINT REFERENCES tenants(id),
test_channel VARCHAR(8) CHECK (test_channel IS NULL OR test_channel IN ('phone','site')),
test_target VARCHAR(500),
kp_channel VARCHAR(16) CHECK (kp_channel IS NULL OR kp_channel IN ('email','whatsapp','telegram','max','other')),
kp_target VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prospect_user ON sales_prospects (sales_user_id);
CREATE INDEX idx_prospect_stage ON sales_prospects (stage);
CREATE INDEX idx_prospect_source ON sales_prospects (source);
CREATE UNIQUE INDEX uq_prospect_user_inn ON sales_prospects (sales_user_id, inn) WHERE inn IS NOT NULL;
-- -----------------------------------------------------------------------------
-- sales_prospect_moves / sales_prospect_notes — история карточки
-- -----------------------------------------------------------------------------
-- moves — каждый переход между стадиями (from_stage NULL = карточка только заведена).
-- notes.kind: note (запись продавца) | stage (автоматическая пометка о переходе).
-- -----------------------------------------------------------------------------
CREATE TABLE sales_prospect_moves (
id BIGSERIAL PRIMARY KEY,
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE,
sales_user_id BIGINT REFERENCES sales_users(id),
from_stage VARCHAR(16),
to_stage VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prospect_move_card ON sales_prospect_moves (prospect_id, created_at DESC);
CREATE INDEX idx_prospect_move_when ON sales_prospect_moves (created_at);
CREATE TABLE sales_prospect_notes (
id BIGSERIAL PRIMARY KEY,
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE,
sales_user_id BIGINT REFERENCES sales_users(id),
kind VARCHAR(16) NOT NULL DEFAULT 'note' CHECK (kind IN ('note','stage')),
title VARCHAR(500),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prospect_note_card ON sales_prospect_notes (prospect_id, created_at DESC);
-- -----------------------------------------------------------------------------
-- sales_client_assignments — за каким продавцом закреплён клиент
-- -----------------------------------------------------------------------------
-- Клиент закреплён ровно за одним продавцом (tenant_id уникален). tariff_kind/
-- tariff_params — слепок условий на момент закрепления: изменение тарифа задним
-- числом не переписывает уже начисленное.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_client_assignments (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id),
tariff_id BIGINT REFERENCES sales_tariffs(id),
tariff_kind VARCHAR(20),
tariff_params JSONB NOT NULL DEFAULT '{}'::jsonb,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sca_sales_user ON sales_client_assignments (sales_user_id);
-- -----------------------------------------------------------------------------
-- sales_attachment_requests — заявка продавца «этот клиент мой»
-- -----------------------------------------------------------------------------
-- login_input — что продавец назвал (почта или логин). status: pending |
-- approved | rejected | not_found (такого клиента у нас нет).
-- -----------------------------------------------------------------------------
CREATE TABLE sales_attachment_requests (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
login_input VARCHAR(255) NOT NULL,
tenant_id BIGINT REFERENCES tenants(id),
status VARCHAR(12) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','not_found')),
comment TEXT,
decided_by BIGINT REFERENCES sales_users(id),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sar_status ON sales_attachment_requests (status);
-- -----------------------------------------------------------------------------
-- sales_payouts — выплаты продавцам, только дописывание
-- -----------------------------------------------------------------------------
-- 🔴 Строку нельзя ни изменить, ни удалить — стережёт триггер: деньги, однажды
-- записанные, задним числом не переписываются. Ошиблись — новая строка с минусом.
-- 🪤 paid_on — КАЛЕНДАРНАЯ дата (DATE), а не мгновение: в пояс не переводить.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_payouts (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
amount_rub NUMERIC(12,2) NOT NULL CHECK (amount_rub > 0),
paid_on DATE NOT NULL,
comment TEXT,
created_by BIGINT NOT NULL REFERENCES sales_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payout_user ON sales_payouts (sales_user_id);
CREATE OR REPLACE FUNCTION sales_payouts_no_mutate() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'sales_payouts is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sales_payouts_no_mutate
BEFORE UPDATE OR DELETE ON sales_payouts
FOR EACH ROW EXECUTE FUNCTION sales_payouts_no_mutate();
-- -----------------------------------------------------------------------------
-- sales_sms_senders / sales_sms_campaigns / sales_sms_messages / sales_sms_optouts
-- -----------------------------------------------------------------------------
-- Смс-рассылка отдела продаж. tenant_id здесь NULL = рассылка наша собственная,
-- не от имени клиента; поэтому уникальность считается через COALESCE(tenant_id, 0).
-- 🪤 sales_sms_messages БЕЗ created_at/updated_at (timestamps=false у модели):
-- время события живёт в sent_at/delivered_at.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_sms_senders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
name VARCHAR(11) NOT NULL, -- имя отправителя у оператора
provider_key VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','pending','active','rejected')),
monthly_fee_kopecks INTEGER,
submitted_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
rejected_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uniq_sms_sender ON sales_sms_senders (COALESCE(tenant_id, 0::bigint), name, provider_key);
CREATE TABLE sales_sms_campaigns (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
sender_id BIGINT REFERENCES sales_sms_senders(id),
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','queued','sending','done','failed','canceled')),
created_by BIGINT NOT NULL,
planned_count INTEGER NOT NULL DEFAULT 0,
sent_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
skipped_count INTEGER NOT NULL DEFAULT 0,
estimated_cost_kopecks INTEGER NOT NULL DEFAULT 0,
actual_cost_kopecks INTEGER NOT NULL DEFAULT 0,
balance_transaction_id BIGINT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE sales_sms_messages (
id BIGSERIAL PRIMARY KEY,
campaign_id BIGINT NOT NULL REFERENCES sales_sms_campaigns(id) ON DELETE CASCADE,
phone_id BIGINT,
phone VARCHAR(11) NOT NULL,
operator VARCHAR(30),
phone_type VARCHAR(12),
provider_key VARCHAR(20),
status VARCHAR(24) NOT NULL DEFAULT 'pending',
segments SMALLINT NOT NULL DEFAULT 1,
cost_kopecks INTEGER NOT NULL DEFAULT 0,
provider_message_id VARCHAR(64),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
error TEXT
);
CREATE INDEX idx_sms_messages_campaign ON sales_sms_messages (campaign_id, status);
CREATE UNIQUE INDEX uniq_sms_message_per_campaign ON sales_sms_messages (campaign_id, phone);
CREATE TABLE sales_sms_optouts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
phone VARCHAR(11) NOT NULL,
reason VARCHAR(30) NOT NULL DEFAULT 'manual'
CHECK (reason IN ('manual','reply_stop','complaint','operator')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uniq_sms_optout ON sales_sms_optouts (COALESCE(tenant_id, 0::bigint), phone);
-- -----------------------------------------------------------------------------
-- sales_ad_audience_* — «прогрев»: фирму видят в рекламе ещё до звонка
-- -----------------------------------------------------------------------------
-- Найденные фирмы попадают в аудиторию рекламных площадок (Яндекс, ВК, МТС) и
-- некоторое время видят нашу рекламу; продавец звонит уже «тёплому».
-- firms — сама фирма, phones — её номера в аудитории, firm_channels — на каких
-- площадках она сейчас, warming_episodes — летопись «когда и где грели»,
-- platforms — настройки площадок, state — единственная строка общего рубильника.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_ad_audience_firms (
id BIGSERIAL PRIMARY KEY,
firm_inn VARCHAR(20),
firm_name VARCHAR(500) NOT NULL,
legal_name VARCHAR(500),
city VARCHAR(255),
rubric VARCHAR(255),
site VARCHAR(500),
phone VARCHAR(64),
rating_label VARCHAR(32),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
contacts JSONB NOT NULL DEFAULT '[]'::jsonb,
prospect_id BIGINT, -- карточка в воронке, если уже заведена
-- Старый одиночный признак канала (channels) оставлен для совместимости,
-- живые флаги — ch_yandex/ch_vk/ch_mts плюс строки firm_channels.
channels VARCHAR(8) NOT NULL DEFAULT 'yandex'
CHECK (channels IN ('yandex','vk','both')),
ch_yandex BOOLEAN NOT NULL DEFAULT FALSE,
ch_vk BOOLEAN NOT NULL DEFAULT FALSE,
ch_mts BOOLEAN NOT NULL DEFAULT FALSE,
stage_seen VARCHAR(16),
stage_changed_at TIMESTAMPTZ,
warmup_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ready_at TIMESTAMPTZ,
assigned_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
stop_reason VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_ad_firm_inn ON sales_ad_audience_firms (firm_inn) WHERE firm_inn IS NOT NULL;
CREATE INDEX idx_ad_firm_channels ON sales_ad_audience_firms (channels);
CREATE INDEX idx_ad_firm_prospect ON sales_ad_audience_firms (prospect_id) WHERE prospect_id IS NOT NULL;
CREATE INDEX idx_ad_firm_ch_yandex ON sales_ad_audience_firms (ch_yandex) WHERE ch_yandex;
CREATE INDEX idx_ad_firm_ch_vk ON sales_ad_audience_firms (ch_vk) WHERE ch_vk;
CREATE INDEX idx_ad_firm_ch_mts ON sales_ad_audience_firms (ch_mts) WHERE ch_mts;
CREATE TABLE sales_ad_audience_firm_channels (
id BIGSERIAL PRIMARY KEY,
firm_id BIGINT NOT NULL REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
channel VARCHAR(8) NOT NULL CONSTRAINT chk_afc_channel
CHECK (channel IN ('yandex','vk','mts','sms')),
status VARCHAR(12) NOT NULL DEFAULT 'loaded' CONSTRAINT chk_afc_status
CHECK (status IN ('loaded','warming')),
mode VARCHAR(8) CONSTRAINT chk_afc_mode
CHECK (mode IS NULL OR mode IN ('funnel','flat')),
flat_days INTEGER CONSTRAINT chk_afc_flat_days
CHECK (flat_days IS NULL OR (flat_days >= 1 AND flat_days <= 365)),
warming_started_at TIMESTAMPTZ,
warmed_times INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_afc_firm_channel ON sales_ad_audience_firm_channels (firm_id, channel);
CREATE INDEX idx_afc_warming ON sales_ad_audience_firm_channels (channel) WHERE status = 'warming';
CREATE TABLE sales_ad_audience_phones (
id BIGSERIAL PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE,
firm_id BIGINT REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
firm_inn VARCHAR(20),
firm_name VARCHAR(500),
city VARCHAR(255),
rubric VARCHAR(255),
operator VARCHAR(30),
phone_type VARCHAR(12),
state VARCHAR(16) NOT NULL DEFAULT 'active' CONSTRAINT chk_ad_phone_state
CHECK (state IN ('active','paused','stopped')),
resume_at TIMESTAMPTZ,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
synced_at TIMESTAMPTZ,
removed_at TIMESTAMPTZ
);
CREATE INDEX idx_ad_audience_live ON sales_ad_audience_phones (expires_at) WHERE removed_at IS NULL;
CREATE INDEX idx_ad_phone_state ON sales_ad_audience_phones (state) WHERE removed_at IS NULL;
-- Одна строка на запуск прогрева фирмы на канале. ended_at NULL = греется прямо
-- сейчас; для смс started_at = ended_at (мгновенное событие).
CREATE TABLE sales_ad_audience_warming_episodes (
id BIGSERIAL PRIMARY KEY,
firm_id BIGINT NOT NULL REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
channel VARCHAR(8) NOT NULL CONSTRAINT chk_awe_channel
CHECK (channel IN ('yandex','vk','mts','sms')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_awe_firm_channel ON sales_ad_audience_warming_episodes (firm_id, channel);
CREATE INDEX idx_awe_open ON sales_ad_audience_warming_episodes (firm_id, channel) WHERE ended_at IS NULL;
-- Настройки площадки: сколько дней держать фирму в аудитории на каждой стадии
-- воронки. Все «дни» ограничены 1..365 — защита от опечатки в админке.
CREATE TABLE sales_ad_audience_platforms (
platform VARCHAR(8) PRIMARY KEY CONSTRAINT chk_ad_platform_code
CHECK (platform IN ('yandex','vk','mts')),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
min_phones INTEGER NOT NULL,
external_id BIGINT,
status VARCHAR(16) NOT NULL DEFAULT 'ok' CONSTRAINT chk_ad_platform_status
CHECK (status IN ('ok','no_access','waiting_volume','working')),
last_synced_at TIMESTAMPTZ,
last_error TEXT,
warmup_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_warmup_days CHECK (warmup_days BETWEEN 1 AND 365),
new_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_new_days CHECK (new_days BETWEEN 1 AND 365),
in_work_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_adp_in_work_days CHECK (in_work_days BETWEEN 1 AND 365),
negotiation_overdue_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_negotiation_overdue_days CHECK (negotiation_overdue_days BETWEEN 1 AND 365),
negotiation_far_threshold_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_adp_negotiation_far_threshold_days CHECK (negotiation_far_threshold_days BETWEEN 1 AND 365),
negotiation_far_head_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_negotiation_far_head_days CHECK (negotiation_far_head_days BETWEEN 1 AND 365),
negotiation_far_lead_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_negotiation_far_lead_days CHECK (negotiation_far_lead_days BETWEEN 1 AND 365),
no_answer_days INTEGER NOT NULL DEFAULT 7 CONSTRAINT chk_adp_no_answer_days CHECK (no_answer_days BETWEEN 1 AND 365),
rejected_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_rejected_days CHECK (rejected_days BETWEEN 1 AND 365),
registered_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_adp_registered_days CHECK (registered_days BETWEEN 1 AND 365),
testing_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_adp_testing_days CHECK (testing_days BETWEEN 1 AND 365),
days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_adp_days CHECK (days BETWEEN 1 AND 365),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Общий рубильник прогрева. Строка ровно одна: id BOOLEAN с проверкой CHECK (id) —
-- вторую строку положить физически некуда.
CREATE TABLE sales_ad_audience_state (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
days INTEGER NOT NULL DEFAULT 30 CHECK (days BETWEEN 1 AND 365),
yandex_segment_id BIGINT,
last_synced_at TIMESTAMPTZ,
last_error TEXT,
vk_list_id BIGINT,
vk_last_synced_at TIMESTAMPTZ,
vk_last_error TEXT,
vk_status VARCHAR(16) NOT NULL DEFAULT 'no_access' CONSTRAINT chk_ad_vk_status
CHECK (vk_status IN ('no_access','waiting_volume','working')),
warmup_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_warmup_days CHECK (warmup_days BETWEEN 1 AND 365),
new_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_new_days CHECK (new_days BETWEEN 1 AND 365),
in_work_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_ad_in_work_days CHECK (in_work_days BETWEEN 1 AND 365),
negotiation_overdue_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_negotiation_overdue_days CHECK (negotiation_overdue_days BETWEEN 1 AND 365),
negotiation_far_threshold_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_ad_negotiation_far_threshold_days CHECK (negotiation_far_threshold_days BETWEEN 1 AND 365),
negotiation_far_head_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_negotiation_far_head_days CHECK (negotiation_far_head_days BETWEEN 1 AND 365),
negotiation_far_lead_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_negotiation_far_lead_days CHECK (negotiation_far_lead_days BETWEEN 1 AND 365),
no_answer_days INTEGER NOT NULL DEFAULT 7 CONSTRAINT chk_ad_no_answer_days CHECK (no_answer_days BETWEEN 1 AND 365),
rejected_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_rejected_days CHECK (rejected_days BETWEEN 1 AND 365),
registered_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_ad_registered_days CHECK (registered_days BETWEEN 1 AND 365),
testing_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_ad_testing_days CHECK (testing_days BETWEEN 1 AND 365),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Права отдела продаж: клиентской роли здесь нет вовсе.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE ON
sales_tariffs, sales_users, sales_prospects, sales_client_assignments,
sales_attachment_requests, sales_sms_senders, sales_sms_campaigns,
sales_sms_messages, sales_ad_audience_phones, sales_ad_audience_platforms,
sales_ad_audience_state
TO crm_admin_user;
GRANT SELECT, INSERT ON sales_payouts, sales_prospect_moves, sales_prospect_notes TO crm_admin_user;
GRANT SELECT, INSERT, DELETE ON sales_sms_optouts TO crm_admin_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON
sales_ad_audience_firms, sales_ad_audience_firm_channels,
sales_ad_audience_warming_episodes
TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
sales_tariffs_id_seq, sales_users_id_seq, sales_prospects_id_seq,
sales_prospect_moves_id_seq, sales_prospect_notes_id_seq,
sales_client_assignments_id_seq, sales_attachment_requests_id_seq,
sales_payouts_id_seq, sales_sms_senders_id_seq, sales_sms_campaigns_id_seq,
sales_sms_messages_id_seq, sales_sms_optouts_id_seq,
sales_ad_audience_firms_id_seq, sales_ad_audience_firm_channels_id_seq,
sales_ad_audience_phones_id_seq, sales_ad_audience_warming_episodes_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE ON
sales_tariffs, sales_users, sales_prospects, sales_client_assignments,
sales_attachment_requests, sales_sms_senders, sales_sms_campaigns,
sales_sms_messages, sales_ad_audience_phones, sales_ad_audience_platforms,
sales_ad_audience_state
TO crm_supplier_worker;
GRANT SELECT, INSERT ON sales_payouts, sales_prospect_moves, sales_prospect_notes TO crm_supplier_worker;
GRANT SELECT, INSERT, DELETE ON sales_sms_optouts TO crm_supplier_worker;
GRANT SELECT, INSERT, UPDATE, DELETE ON
sales_ad_audience_firms, sales_ad_audience_firm_channels,
sales_ad_audience_warming_episodes
TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
sales_tariffs_id_seq, sales_users_id_seq, sales_prospects_id_seq,
sales_prospect_moves_id_seq, sales_prospect_notes_id_seq,
sales_client_assignments_id_seq, sales_attachment_requests_id_seq,
sales_payouts_id_seq, sales_sms_senders_id_seq, sales_sms_campaigns_id_seq,
sales_sms_messages_id_seq, sales_sms_optouts_id_seq,
sales_ad_audience_firms_id_seq, sales_ad_audience_firm_channels_id_seq,
sales_ad_audience_phones_id_seq, sales_ad_audience_warming_episodes_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 21. СЛУЖЕБНЫЕ ТАБЛИЦЫ БЕЗ РАЗДЕЛА — v8.86, canon-sync
-- =============================================================================
-- -----------------------------------------------------------------------------
-- personal_access_tokens — токены Laravel Sanctum
-- -----------------------------------------------------------------------------
-- Ставит сам Sanctum; в каноне нужна, иначе `migrate:fresh` со схемы даёт
-- «отношение не существует» при входе по токену. Владелец токена — любая модель
-- (tokenable_type/tokenable_id), поэтому внешнего ключа тут нет.
-- -----------------------------------------------------------------------------
CREATE TABLE personal_access_tokens (
id BIGSERIAL PRIMARY KEY,
tokenable_type VARCHAR(255) NOT NULL,
tokenable_id BIGINT NOT NULL,
name TEXT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
abilities TEXT,
last_used_at TIMESTAMP(0),
expires_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX personal_access_tokens_tokenable_type_tokenable_id_index
ON personal_access_tokens (tokenable_type, tokenable_id);
CREATE INDEX personal_access_tokens_expires_at_index ON personal_access_tokens (expires_at);
-- -----------------------------------------------------------------------------
-- supplier_order_checks — сверка заказанного у поставщика с тем, что стоит в ЛК
-- -----------------------------------------------------------------------------
-- intended_rows — сколько строк мы намеревались выставить, live_rows — сколько
-- реально стоит в кабинете поставщика. status: ok | mismatch | unable_to_verify
-- (в кабинет не попали — это НЕ расхождение, а «не смогли посмотреть»).
-- SaaS-level, без RLS; пишет crm_supplier_worker через соединение pgsql_supplier.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_order_checks (
id BIGSERIAL PRIMARY KEY,
checked_at TIMESTAMPTZ NOT NULL,
sync_run_id BIGINT,
intended_rows INTEGER NOT NULL DEFAULT 0,
live_rows INTEGER NOT NULL DEFAULT 0,
mismatch_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL
CHECK (status IN ('ok','mismatch','unable_to_verify')),
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_supplier_order_checks_checked ON supplier_order_checks (checked_at DESC);
CREATE INDEX idx_supplier_order_checks_status ON supplier_order_checks (status);
-- -----------------------------------------------------------------------------
-- supplier_webhook_log — журнал стуков поставщика в наш вебхук
-- -----------------------------------------------------------------------------
-- Заведён 31.07.2026: журнала не было вовсе, и 70 отказов «404» за десять дней
-- никто не видел. Секрет не хранится — только отпечаток первых знаков
-- (secret_fingerprint), чтобы отличить «пришли не с тем ключом» от «пришли не туда».
-- ПДн нет: телефон не пишем, только номер лида у поставщика.
-- SaaS-level, без RLS; соединение pgsql_supplier.
-- 🪤 Запись идёт ВТОРЫМ соединением — обычный откат теста её не достаёт
-- (лечится трейтом SharesSupplierPdo).
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_webhook_log (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(32) NOT NULL,
secret_fingerprint VARCHAR(8),
ip_address VARCHAR(45),
target_host VARCHAR(255),
supplier_lead_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_supplier_webhook_log_created ON supplier_webhook_log (created_at DESC);
CREATE INDEX idx_supplier_webhook_log_status ON supplier_webhook_log (status, created_at DESC);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON personal_access_tokens TO crm_admin_user;
GRANT SELECT ON supplier_webhook_log TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
personal_access_tokens_id_seq, supplier_order_checks_id_seq,
supplier_webhook_log_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON personal_access_tokens TO crm_supplier_worker;
GRANT SELECT, INSERT ON supplier_order_checks TO crm_supplier_worker;
GRANT SELECT, INSERT, DELETE ON supplier_webhook_log TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
personal_access_tokens_id_seq, supplier_order_checks_id_seq,
supplier_webhook_log_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 22. ПОЗДНИЕ ПРАВКИ ТАБЛИЦ, ОПИСАННЫХ В schema.sql
-- =============================================================================
-- Столбцы и переименования, которые миграции дописали к таблицам из тела
-- schema.sql. В само тело их перенести НЕЛЬЗЯ: соответствующие миграции сделаны
-- без стражей и на сборке с нуля столкнутся сами с собой. Каждая строка ниже
-- помечена своей миграцией.
--
-- 🪤 Здесь только те правки, что в schema.sql не влезли. Остальные поздние
-- столбцы (supplier_csv_reconcile_log.pending_count, saas_invoices.credit_target,
-- saas_transactions.credit_target) в теле schema.sql УЖЕ есть — их миграции
-- написаны через `ADD COLUMN IF NOT EXISTS` и столкновения не дают.
-- -----------------------------------------------------------------------------
-- Автоподбор: пачка прогонов, запущенных одним нажатием. Миграция 2026_07_08_120000
-- (Blueprint без стража — потому и не в теле schema.sql).
ALTER TABLE autopodbor_runs ADD COLUMN batch_id UUID;
CREATE INDEX autopodbor_runs_tenant_batch_idx ON autopodbor_runs (tenant_id, batch_id);
CREATE INDEX autopodbor_runs_status_kind_idx ON autopodbor_runs (status, kind);
-- Свой чат вместо Jivo (спека 2026-07-13-own-chat-widget-design §5).
-- Миграция 2026_07_13_100000_chat_widget_columns: номер разговора теперь наш,
-- добавлены «откуда пришёл», «кто клиент» (если вошёл) и адрес.
-- Переименование сделано без стража — потому и не в теле schema.sql.
-- На проде таблицы не было (бот там не стоял) — переименование безопасно.
ALTER TABLE bot_dialogs RENAME COLUMN jivo_chat_id TO chat_id;
ALTER TABLE bot_dialogs ADD COLUMN source VARCHAR(16) NOT NULL DEFAULT 'portal'; -- portal | landing
ALTER TABLE bot_dialogs ADD COLUMN user_id BIGINT; -- клиент, если вошёл (без FK: диалоги переживают удаление)
ALTER TABLE bot_dialogs ADD COLUMN ip VARCHAR(45);
-- Опрос окошка: «дай реплики этого разговора после id N».
CREATE INDEX idx_bot_dialogs_chat_seq ON bot_dialogs (chat_id, id);
-- NB: указатель idx_bot_dialogs_chat в schema.sql заведён по старому имени столбца;
-- после переименования PostgreSQL сам считает его индексом по chat_id.
-- =============================================================================
-- КОНЕЦ schema_modules.sql
-- =============================================================================