2026-08-02 12:53:45 +03:00
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
-- 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),
|
2026-08-08 21:50:26 +03:00
|
|
|
|
stop_requested_at TIMESTAMPTZ,
|
2026-08-02 12:53:45 +03:00
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 11:01:19 +03:00
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
-- 23. МОДУЛЬ ОБЗВОНА (obzvon_*) — задача З-1.1, запись журнала v9.68
|
|
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
-- Голосовой робот сам звонит по списку номеров, разговаривает с человеком и
|
|
|
|
|
|
-- переводит на менеджера клиента. Клиент за это платит. Здесь — место, куда
|
|
|
|
|
|
-- робот кладёт результат: без него нечего списывать, нечего показать клиенту и
|
|
|
|
|
|
-- нечем ответить через год на вопрос «за что вы взяли деньги в августе».
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Мест ТРИ, а не одно:
|
|
|
|
|
|
-- • obzvon_calls — одна попытка набора (по номеру их бывает до 12, Р53);
|
|
|
|
|
|
-- • obzvon_number_results — единственный итог на всю работу с номером (Т84);
|
|
|
|
|
|
-- • расшифровка — прямо в строке звонка (obzvon_calls.transcript).
|
|
|
|
|
|
-- Отдельного хранилища под расшифровку НЕТ: Р50 сняло бессрочную обезличенную
|
|
|
|
|
|
-- расшифровку, а то, что живёт три месяца, обезличивать незачем.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Имя своё, а не call_recordings из закомментированного чертежа раздела 17
|
|
|
|
|
|
-- db/schema.sql: чертёж был про телефонию вообще и не знает ни статуса обзвона,
|
|
|
|
|
|
-- ни двух сроков хранения со следом удаления, ни «почему звонили», ни двух плеч,
|
|
|
|
|
|
-- ни попыток с итогом. Занять чужое имя наполовину хуже, чем завести своё.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🪤 Времена здесь — TIMESTAMPTZ (как в теле schema.sql и в lead_charges), а НЕ
|
|
|
|
|
|
-- TIMESTAMP(0) без пояса, как в разделах 18–19. Звонок происходит в конкретный
|
|
|
|
|
|
-- миг, и пояс у него терять нельзя.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Миграция: app/database/migrations/2026_08_05_100000_create_obzvon_calls.php.
|
|
|
|
|
|
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.1.
|
|
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- obzvon_number_results — ЕДИНСТВЕННЫЙ итог по номеру (Т84)
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- Именно он стоит в карточке сделки (Т65), а не последняя попытка: двенадцать
|
|
|
|
|
|
-- недозвонов обязаны дать ОДИН итог «не дозвонились», а не двенадцать строк.
|
|
|
|
|
|
-- Замок, который это держит, — UNIQUE (tenant_id, campaign_id, phone).
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Это НЕ статус сделки: у сделки своя воронка (14 значений), у обзвона своя.
|
|
|
|
|
|
-- 🟡 Раскладка семи исходов Т21 на «про попытку» и «про номер» владельцем ещё не
|
|
|
|
|
|
-- сделана (план §З-2.4, осталось открытым) — список ниже это восемь статусов
|
|
|
|
|
|
-- обзвона Т65 и лежит в одном месте, чтобы переиграть его стоило минуту.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- campaign_id БЕЗ FK: таблицы кампаний обзвона ещё нет, её строит соседняя задача.
|
|
|
|
|
|
-- deal_id БЕЗ прямого FK на столбец — составной FK на партиционированную
|
|
|
|
|
|
-- deals(id, received_at) ниже.
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
CREATE TABLE obzvon_number_results (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
|
|
|
|
campaign_id BIGINT NOT NULL,
|
|
|
|
|
|
phone VARCHAR(20) NOT NULL,
|
|
|
|
|
|
deal_id BIGINT,
|
|
|
|
|
|
deal_received_at TIMESTAMPTZ,
|
|
|
|
|
|
outcome VARCHAR(24),
|
|
|
|
|
|
outcome_at TIMESTAMPTZ,
|
|
|
|
|
|
callback_at TIMESTAMPTZ,
|
|
|
|
|
|
attempts_count SMALLINT NOT NULL DEFAULT 0,
|
|
|
|
|
|
last_call_at TIMESTAMPTZ,
|
|
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
CONSTRAINT obzvon_number_results_one_per_phone UNIQUE (tenant_id, campaign_id, phone),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_number_results_attempts CHECK (attempts_count >= 0),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_number_results_deal_pair
|
|
|
|
|
|
CHECK ((deal_id IS NULL) = (deal_received_at IS NULL)),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_number_results_outcome
|
|
|
|
|
|
CHECK (outcome IS NULL OR outcome IN (
|
|
|
|
|
|
'transferred', 'transfer_failed', 'callback', 'email_sent',
|
|
|
|
|
|
'not_target', 'refused', 'no_answer', 'aggression'))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- obzvon_calls — ОДНА попытка набора
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- 🔴 Два плеча врозь (Т78, Р44): leg1_* — робот с человеком, leg2_* — человек с
|
|
|
|
|
|
-- менеджером клиента после перевода, у второго свой признак «состоялось». Плату
|
|
|
|
|
|
-- за второе плечо владелец отложил (развилка 22), но УЧЁТ отложить нельзя:
|
|
|
|
|
|
-- пересчитать задним числом то, чего не записали, невозможно.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Цена ЗАПОМНЕНА в строке вместе со снимком тарифа (Т57): price_kopecks —
|
|
|
|
|
|
-- сколько взяли, tariff_answer_kopecks / tariff_minute_kopecks — по какому тарифу
|
|
|
|
|
|
-- посчитали. Смена тарифа завтра НЕ перепишет вчерашний счёт, потому что счёт
|
|
|
|
|
|
-- ничего не спрашивает у тарифа. Образец — lead_charges.tier_no +
|
|
|
|
|
|
-- price_per_lead_kopecks. Деньги целыми копейками, не дробными числами.
|
|
|
|
|
|
-- billable = FALSE — единственное исключение тарифа (Т87, Р49): дозвон на
|
|
|
|
|
|
-- закрытый номер считается, но строки в счёте не даёт.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Строку НИКОГДА не удаляют целиком: вместе с ней уходят деньги. Чистка идёт
|
|
|
|
|
|
-- тремя ступенями ПО ТОЙ ЖЕ строке (Т88): месяц — стирается звук (Т70), три
|
|
|
|
|
|
-- месяца — расшифровка (Т71), дальше живут сухие итоги. Отсюда два срока и два
|
|
|
|
|
|
-- следа удаления: без них карточка не отличит «стёрто по сроку» от «не
|
|
|
|
|
|
-- записалось вовсе» и покажет сломанный проигрыватель (Т73). Прав на DELETE не
|
|
|
|
|
|
-- выдано ни одной роли — намеренно.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- external_call_id + частичный UNIQUE — против задвоенного отчёта робота: повтор
|
|
|
|
|
|
-- по тому же звонку база не примет, а задвоенный отчёт это задвоенные деньги (Т62).
|
|
|
|
|
|
-- launched_by_user_id / launched_at — «почему звонили»: кто и когда поставил
|
|
|
|
|
|
-- кампанию в работу (Т68).
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
CREATE TABLE obzvon_calls (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
|
|
|
|
number_result_id BIGINT REFERENCES obzvon_number_results(id) ON DELETE SET NULL,
|
|
|
|
|
|
campaign_id BIGINT,
|
|
|
|
|
|
script_id BIGINT,
|
|
|
|
|
|
launched_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
|
|
|
|
|
launched_at TIMESTAMPTZ,
|
|
|
|
|
|
deal_id BIGINT,
|
|
|
|
|
|
deal_received_at TIMESTAMPTZ,
|
|
|
|
|
|
phone VARCHAR(20) NOT NULL,
|
|
|
|
|
|
from_number VARCHAR(20),
|
|
|
|
|
|
attempt_no SMALLINT NOT NULL DEFAULT 1,
|
|
|
|
|
|
direction VARCHAR(10) NOT NULL DEFAULT 'outbound',
|
|
|
|
|
|
external_call_id VARCHAR(100),
|
|
|
|
|
|
started_at TIMESTAMPTZ NOT NULL,
|
|
|
|
|
|
ended_at TIMESTAMPTZ,
|
|
|
|
|
|
answered BOOLEAN NOT NULL DEFAULT FALSE,
|
|
|
|
|
|
leg1_duration_sec INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
leg2_connected BOOLEAN NOT NULL DEFAULT FALSE,
|
|
|
|
|
|
leg2_started_at TIMESTAMPTZ,
|
|
|
|
|
|
leg2_duration_sec INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
attempt_outcome VARCHAR(16),
|
|
|
|
|
|
recording_path VARCHAR(500),
|
|
|
|
|
|
recording_expires_at TIMESTAMPTZ,
|
|
|
|
|
|
recording_deleted_at TIMESTAMPTZ,
|
|
|
|
|
|
transcript TEXT,
|
|
|
|
|
|
transcript_expires_at TIMESTAMPTZ,
|
|
|
|
|
|
transcript_deleted_at TIMESTAMPTZ,
|
|
|
|
|
|
billable BOOLEAN NOT NULL DEFAULT TRUE,
|
|
|
|
|
|
price_kopecks INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
billed_minutes SMALLINT NOT NULL DEFAULT 0,
|
|
|
|
|
|
tariff_answer_kopecks INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
tariff_minute_kopecks INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
billing_note VARCHAR(64),
|
|
|
|
|
|
charged_at TIMESTAMPTZ,
|
|
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_direction CHECK (direction IN ('inbound', 'outbound')),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_attempt_no CHECK (attempt_no >= 1),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_outcome
|
|
|
|
|
|
CHECK (attempt_outcome IS NULL OR attempt_outcome IN (
|
|
|
|
|
|
'answered', 'no_answer', 'busy', 'declined')),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_deal_pair
|
|
|
|
|
|
CHECK ((deal_id IS NULL) = (deal_received_at IS NULL)),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_leg1_nonneg CHECK (leg1_duration_sec >= 0),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_leg2_nonneg CHECK (leg2_duration_sec >= 0),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_leg2_silent
|
|
|
|
|
|
CHECK (leg2_connected OR (leg2_duration_sec = 0 AND leg2_started_at IS NULL)),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_money_nonneg
|
|
|
|
|
|
CHECK (price_kopecks >= 0 AND billed_minutes >= 0
|
|
|
|
|
|
AND tariff_answer_kopecks >= 0 AND tariff_minute_kopecks >= 0),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_calls_free_is_zero CHECK (billable OR price_kopecks = 0)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
-- Составные FK на партиционированную deals(id, received_at) — как у lead_charges.
|
|
|
|
|
|
-- DEFERRABLE INITIALLY DEFERRED: сделку и звонок кладут в одной транзакции.
|
|
|
|
|
|
-- ON DELETE CASCADE отвечает на проверку «сделку удалили — звонки не висят».
|
|
|
|
|
|
ALTER TABLE obzvon_number_results
|
|
|
|
|
|
ADD CONSTRAINT obzvon_number_results_deals_fk
|
|
|
|
|
|
FOREIGN KEY (deal_id, deal_received_at)
|
|
|
|
|
|
REFERENCES deals(id, received_at)
|
|
|
|
|
|
ON DELETE CASCADE
|
|
|
|
|
|
DEFERRABLE INITIALLY DEFERRED;
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE obzvon_calls
|
|
|
|
|
|
ADD CONSTRAINT obzvon_calls_deals_fk
|
|
|
|
|
|
FOREIGN KEY (deal_id, deal_received_at)
|
|
|
|
|
|
REFERENCES deals(id, received_at)
|
|
|
|
|
|
ON DELETE CASCADE
|
|
|
|
|
|
DEFERRABLE INITIALLY DEFERRED;
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX obzvon_number_results_deal_idx
|
|
|
|
|
|
ON obzvon_number_results (deal_id, deal_received_at);
|
|
|
|
|
|
CREATE INDEX obzvon_number_results_tenant_outcome_idx
|
|
|
|
|
|
ON obzvon_number_results (tenant_id, outcome);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX obzvon_calls_tenant_started_idx
|
|
|
|
|
|
ON obzvon_calls (tenant_id, started_at DESC);
|
|
|
|
|
|
CREATE INDEX obzvon_calls_deal_idx
|
|
|
|
|
|
ON obzvon_calls (deal_id, deal_received_at);
|
|
|
|
|
|
CREATE INDEX obzvon_calls_number_result_idx
|
|
|
|
|
|
ON obzvon_calls (number_result_id);
|
|
|
|
|
|
CREATE INDEX obzvon_calls_tenant_campaign_phone_idx
|
|
|
|
|
|
ON obzvon_calls (tenant_id, campaign_id, phone);
|
|
|
|
|
|
CREATE UNIQUE INDEX obzvon_calls_external_id_uniq
|
|
|
|
|
|
ON obzvon_calls (tenant_id, external_call_id)
|
|
|
|
|
|
WHERE external_call_id IS NOT NULL;
|
|
|
|
|
|
-- Указатели под чистку по срокам (Т88): джоб ищет просроченные, а не перебирает всё.
|
|
|
|
|
|
CREATE INDEX obzvon_calls_recording_cleanup_idx
|
|
|
|
|
|
ON obzvon_calls (recording_expires_at)
|
|
|
|
|
|
WHERE recording_path IS NOT NULL AND recording_deleted_at IS NULL;
|
|
|
|
|
|
CREATE INDEX obzvon_calls_transcript_cleanup_idx
|
|
|
|
|
|
ON obzvon_calls (transcript_expires_at)
|
|
|
|
|
|
WHERE transcript_deleted_at IS NULL;
|
|
|
|
|
|
|
|
|
|
|
|
-- 🔴 RLS: ENABLE + FORCE (даже владелец таблицы подчиняется политике) + USING и
|
|
|
|
|
|
-- WITH CHECK — иначе клиент А сможет подложить строку клиенту Б.
|
|
|
|
|
|
-- 🪤 Служебным ролям этого МАЛО: после накатки обязателен перезапуск
|
|
|
|
|
|
-- db/03_service_bypass_policies.sql, иначе служебная роль молча правит НОЛЬ строк.
|
|
|
|
|
|
ALTER TABLE obzvon_number_results ENABLE ROW LEVEL SECURITY;
|
|
|
|
|
|
ALTER TABLE obzvon_number_results FORCE ROW LEVEL SECURITY;
|
|
|
|
|
|
CREATE POLICY tenant_isolation ON obzvon_number_results
|
|
|
|
|
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
|
|
|
|
|
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE obzvon_calls ENABLE ROW LEVEL SECURITY;
|
|
|
|
|
|
ALTER TABLE obzvon_calls FORCE ROW LEVEL SECURITY;
|
|
|
|
|
|
CREATE POLICY tenant_isolation ON obzvon_calls
|
|
|
|
|
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
|
|
|
|
|
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
|
|
|
|
|
|
|
|
|
|
|
|
-- Права: SELECT + INSERT + UPDATE. DELETE не выдан НИКОМУ намеренно — вместе со
|
|
|
|
|
|
-- строкой звонка уходят деньги, а чистка по срокам только обнуляет столбцы.
|
|
|
|
|
|
DO $$
|
|
|
|
|
|
BEGIN
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_app_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_app_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_supplier_worker;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_supplier_worker;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_admin_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_admin_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_migrator;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_migrator;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
|
|
|
|
|
|
GRANT SELECT ON obzvon_calls, obzvon_number_results TO crm_readonly;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
END $$;
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 13:42:34 +03:00
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- obzvon_tariffs — ДВЕ ЦИФРЫ ТАРИФА, которые правит админ (задача З-1.3, Т52/Т56)
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- Правило цены звонка живёт в коде (App\Services\Obzvon\ObzvonTarifikator) и
|
|
|
|
|
|
-- больше нигде. Здесь — только сами два числа, которые в него приходят снаружи:
|
|
|
|
|
|
-- за снятую трубку и за начатую минуту. Без этой таблицы поправить цену можно
|
|
|
|
|
|
-- было бы только руками программиста и новой сборкой.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Обе цифры назначены владельцем ВСЛЕПУЮ: сколько нам самим стоит звонок, ни
|
|
|
|
|
|
-- разу не измерено. 2 ₽ и 10 ₽ — не расчёт, а первое приближение. Записано прямо
|
|
|
|
|
|
-- рядом с числами намеренно: через месяц никто не должен принять их за
|
|
|
|
|
|
-- обоснованные — и менять их будут наверняка.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Строка ОДНА на весь портал: замок CHECK (id = 1). Две строки означали бы два
|
|
|
|
|
|
-- разных ответа на вопрос «сколько стоит звонок», и какой попадёт в счёт — как
|
|
|
|
|
|
-- повезёт. RLS нет по той же причине, что у client_sms_settings и
|
|
|
|
|
|
-- client_tg_tariffs: прайс единый для всех клиентов, таблица не клиентская.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 Деньги — целые копейки: те же единицы, что у obzvon_calls.tariff_answer_kopecks
|
|
|
|
|
|
-- и tariff_minute_kopecks. Смена тарифа НЕ пересчитывает вчерашние звонки (Т57) —
|
|
|
|
|
|
-- цена и снимок тарифа лежат в самой строке звонка; здесь только «сколько будет
|
|
|
|
|
|
-- стоить СЛЕДУЮЩИЙ».
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Правки тарифа оставляют след в saas_admin_audit_log, action 'obzvon_tariff.update'
|
|
|
|
|
|
-- (кто, когда, с чего на что) — своей таблицы истории у тарифа нет намеренно.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Миграция: app/database/migrations/2026_08_05_110000_create_obzvon_tariffs.php.
|
|
|
|
|
|
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.3.
|
|
|
|
|
|
|
|
|
|
|
|
CREATE TABLE obzvon_tariffs (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
answer_kopecks INTEGER NOT NULL DEFAULT 200,
|
|
|
|
|
|
minute_kopecks INTEGER NOT NULL DEFAULT 1000,
|
|
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
CONSTRAINT obzvon_tariffs_singleton CHECK (id = 1),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_tariffs_nonneg
|
|
|
|
|
|
CHECK (answer_kopecks >= 0 AND minute_kopecks >= 0)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
-- Умолчание владельца: 2 ₽ за снятую трубку и 10 ₽ за начатую минуту.
|
|
|
|
|
|
-- 🔴 Обе цифры назначены ВСЛЕПУЮ — себестоимость звонка не измерена ни разу.
|
|
|
|
|
|
INSERT INTO obzvon_tariffs (id, answer_kopecks, minute_kopecks) VALUES (1, 200, 1000);
|
|
|
|
|
|
|
|
|
|
|
|
-- Явный id не двигает счётчик, а без этого следующая вставка попробовала бы
|
|
|
|
|
|
-- занять тот же id и упала бы «дубликатом» вместо честного «строка тут одна».
|
|
|
|
|
|
SELECT setval('obzvon_tariffs_id_seq', 1, true);
|
|
|
|
|
|
|
|
|
|
|
|
-- Права: править тариф может только админ-роль. Рантайм-роли и служебная роль
|
|
|
|
|
|
-- джобов читают — им нужна цена следующего звонка, но менять её они не вправе.
|
|
|
|
|
|
-- DELETE не выдан НИКОМУ: без строки тарифа звонок считать не по чему.
|
|
|
|
|
|
DO $$
|
|
|
|
|
|
BEGIN
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
|
|
|
|
|
GRANT SELECT ON obzvon_tariffs TO crm_app_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
|
|
|
|
|
GRANT SELECT ON obzvon_tariffs TO crm_supplier_worker;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_tariffs TO crm_admin_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_tariffs_id_seq TO crm_admin_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_tariffs TO crm_migrator;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_tariffs_id_seq TO crm_migrator;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
|
|
|
|
|
|
GRANT SELECT ON obzvon_tariffs TO crm_readonly;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
END $$;
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 20:35:21 +03:00
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
-- 24. МАТЕРИАЛЫ КЛИЕНТА ДЛЯ ОБУЧЕНИЯ РОБОТА — задача З-2.3, запись журнала v9.70
|
|
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
-- Клиент отдаёт нам записи СВОИХ холодных разговоров, чтобы робот говорил как
|
|
|
|
|
|
-- надо. 🔴 Это голоса живых людей, которые об этом не знают и согласия нам не
|
|
|
|
|
|
-- давали. Отсюда всё устройство таблицы ниже.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Миграция: app/database/migrations/2026_08_05_120000_create_obzvon_materialy_klienta.php.
|
|
|
|
|
|
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-2.3.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 ДВЕ ДАТЫ ИСТЕЧЕНИЯ, А НЕ ОДНА — решение владельца Р82 от 05.08.2026:
|
|
|
|
|
|
-- • звук чужой записи — 30 дней;
|
|
|
|
|
|
-- • расшифровка чужой записи — шесть месяцев.
|
|
|
|
|
|
-- Обе NOT NULL. Строка без даты истечения — это голос человека, которого не
|
|
|
|
|
|
-- найдёт ни одна чистка и который пролежит у нас вечно. Сами сроки живут в
|
|
|
|
|
|
-- system_settings (`obzvon_materialy_srok_zvuka_dney`,
|
|
|
|
|
|
-- `obzvon_materialy_srok_rasshifrovki_mesyacev`), а не в коде.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 ДВА СЛЕДА УДАЛЕНИЯ ВРОЗЬ. Команда чистки из З-2.2 обязана уметь стереть
|
|
|
|
|
|
-- звук, НЕ тронув текст (пять месяцев текст живёт без звука). А карточка обязана
|
|
|
|
|
|
-- отличать «стёрто по сроку» от «не загружали вовсе» и говорить это словами
|
|
|
|
|
|
-- (Т93), а не показывать сломанный проигрыватель.
|
|
|
|
|
|
-- Два замка держат это честным:
|
|
|
|
|
|
-- chk_obzvon_materialy_zvuk_styort — есть след удаления звука ⇒ пути нет;
|
|
|
|
|
|
-- chk_obzvon_materialy_tekst_styort — есть след удаления текста ⇒ текста нет.
|
|
|
|
|
|
-- Без них строка могла бы утверждать «стёрто», продолжая ссылаться на живой файл.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- ⚠️ СТИРАЕТ НЕ ЭТА ЗАДАЧА, а З-2.2, и она НЕ построена. Здесь материалы только
|
|
|
|
|
|
-- принимаются, изолируются и получают две даты истечения.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🪤 От какой даты считать полгода, владельцем НЕ задано (от загрузки к нам или
|
|
|
|
|
|
-- от даты самого разговора). До ответа считаем от загрузки — единственной даты,
|
|
|
|
|
|
-- которую мы знаем точно. Столбец `recorded_at` заведён заранее и заполняется,
|
|
|
|
|
|
-- если клиент дату разговора сообщил: в день ответа владельца будет по чему
|
|
|
|
|
|
-- пересчитать.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- DELETE не выдан НИКОМУ намеренно: строка остаётся носителем надписи Т93. Уходит
|
|
|
|
|
|
-- она только вместе с клиентом — по ON DELETE CASCADE на tenants.
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
CREATE TABLE obzvon_materialy_klienta (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
|
|
|
|
uploaded_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
|
|
|
|
|
disk VARCHAR(32) NOT NULL DEFAULT 'obzvon_materialy',
|
|
|
|
|
|
file_path VARCHAR(500),
|
|
|
|
|
|
original_name VARCHAR(255) NOT NULL,
|
|
|
|
|
|
size_bytes BIGINT NOT NULL,
|
|
|
|
|
|
mime_type VARCHAR(100) NOT NULL,
|
|
|
|
|
|
recorded_at TIMESTAMPTZ,
|
|
|
|
|
|
transcript TEXT,
|
|
|
|
|
|
recording_expires_at TIMESTAMPTZ NOT NULL,
|
|
|
|
|
|
recording_deleted_at TIMESTAMPTZ,
|
|
|
|
|
|
transcript_expires_at TIMESTAMPTZ NOT NULL,
|
|
|
|
|
|
transcript_deleted_at TIMESTAMPTZ,
|
|
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_materialy_size CHECK (size_bytes > 0),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_materialy_zvuk_styort
|
|
|
|
|
|
CHECK (recording_deleted_at IS NULL OR file_path IS NULL),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_materialy_tekst_styort
|
|
|
|
|
|
CHECK (transcript_deleted_at IS NULL OR transcript IS NULL)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
-- Один файл — одна строка. Две строки на один файл означали бы, что чистка по
|
|
|
|
|
|
-- сроку одной строки вслепую стирает звук у второй.
|
|
|
|
|
|
CREATE INDEX obzvon_materialy_klienta_tenant_idx
|
|
|
|
|
|
ON obzvon_materialy_klienta (tenant_id, created_at DESC);
|
|
|
|
|
|
CREATE UNIQUE INDEX obzvon_materialy_klienta_file_uniq
|
|
|
|
|
|
ON obzvon_materialy_klienta (disk, file_path)
|
|
|
|
|
|
WHERE file_path IS NOT NULL;
|
|
|
|
|
|
-- Указатели под чистку по срокам: команда З-2.2 ищет просроченные, а не
|
|
|
|
|
|
-- перебирает всё подряд.
|
|
|
|
|
|
CREATE INDEX obzvon_materialy_klienta_zvuk_cleanup_idx
|
|
|
|
|
|
ON obzvon_materialy_klienta (recording_expires_at)
|
|
|
|
|
|
WHERE file_path IS NOT NULL AND recording_deleted_at IS NULL;
|
|
|
|
|
|
CREATE INDEX obzvon_materialy_klienta_tekst_cleanup_idx
|
|
|
|
|
|
ON obzvon_materialy_klienta (transcript_expires_at)
|
|
|
|
|
|
WHERE transcript_deleted_at IS NULL;
|
|
|
|
|
|
|
|
|
|
|
|
-- 🔴 RLS: ENABLE + FORCE + USING и WITH CHECK. Без WITH CHECK клиент А смог бы
|
|
|
|
|
|
-- подложить свою запись клиенту Б. Это Т6: клиент А не видит и не может открыть
|
|
|
|
|
|
-- материалы клиента Б.
|
|
|
|
|
|
-- 🪤 Служебным ролям этого МАЛО: после накатки обязателен перезапуск
|
|
|
|
|
|
-- db/03_service_bypass_policies.sql, иначе служебная роль молча правит НОЛЬ
|
|
|
|
|
|
-- строк — и чистка из З-2.2 отчитается «стёрто», ничего не стерев.
|
|
|
|
|
|
ALTER TABLE obzvon_materialy_klienta ENABLE ROW LEVEL SECURITY;
|
|
|
|
|
|
ALTER TABLE obzvon_materialy_klienta FORCE ROW LEVEL SECURITY;
|
|
|
|
|
|
CREATE POLICY tenant_isolation ON obzvon_materialy_klienta
|
|
|
|
|
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
|
|
|
|
|
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
|
|
|
|
|
|
|
|
|
|
|
|
-- Права: SELECT + INSERT + UPDATE. DELETE не выдан НИКОМУ — строка остаётся
|
|
|
|
|
|
-- носителем надписи Т93, чистка только обнуляет столбцы.
|
|
|
|
|
|
DO $$
|
|
|
|
|
|
BEGIN
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_app_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_app_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_supplier_worker;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_supplier_worker;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_admin_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_admin_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_migrator;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_migrator;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
|
|
|
|
|
|
GRANT SELECT ON obzvon_materialy_klienta TO crm_readonly;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
END $$;
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-08 22:43:45 +03:00
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- obzvon_campaigns — САМА КАМПАНИЯ ОБЗВОНА (задача З-4.1)
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
-- До неё места под кампанию обзвона не было ВОВСЕ: у СМС есть
|
|
|
|
|
|
-- client_sms_campaigns, у телеграма client_tg_campaigns, у обзвона — ничего.
|
|
|
|
|
|
-- Задание запуска ZapustitObzvonBazyJob принимало номер кампании, obzvon_calls и
|
|
|
|
|
|
-- obzvon_number_results носили campaign_id — а самой кампании не существовало.
|
|
|
|
|
|
-- Оговорка выше («campaign_id БЕЗ FK: таблицы кампаний обзвона ещё нет, её строит
|
|
|
|
|
|
-- соседняя задача») — про эту самую таблицу.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 ОТДЕЛЬНОЙ ТАБЛИЦЫ НОМЕРОВ КАМПАНИИ НЕТ, И ЭТО РЕШЕНИЕ. У СМС она есть
|
|
|
|
|
|
-- (client_sms_campaign_phones), у обзвона не заводим: obzvon_number_results уже
|
|
|
|
|
|
-- носит замок UNIQUE (tenant_id, campaign_id, phone), то есть уже является списком
|
|
|
|
|
|
-- «какие номера у этой кампании». Вторая таблица с тем же ключом — два списка
|
|
|
|
|
|
-- одного и того же, которые однажды разъедутся, и тогда портал не сможет ответить,
|
|
|
|
|
|
-- какой из них правда. «Осталось набрать» читается как outcome IS NULL.
|
|
|
|
|
|
-- 🪤 Чего этому списку не хватает: у obzvon_number_results НЕТ места под пояс
|
|
|
|
|
|
-- получателя, а окно часов без пояса работать не может. У СМС ровно под это
|
|
|
|
|
|
-- заведён снимок. Дыра следующего круга, названа вслух.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- 🔴 script_id — БЕЗ внешнего ключа: каталога скриптов (З-4.3) ещё не построили.
|
|
|
|
|
|
-- 🔴 Окно часов клиента — ТРИ столбца, а не два: часы менеджера названы в ЕГО
|
|
|
|
|
|
-- часах, а получателя мы будим в ЕГО. Без смещения «14:00–18:00» москвича было бы
|
|
|
|
|
|
-- прочитано как «по Владивостоку» — робот перевёл бы звонок на спящего человека, а
|
|
|
|
|
|
-- на экране всё бы сошлось (Р115).
|
|
|
|
|
|
-- 🔴 Предел линий ТРИ (Т34) стоит замком в базе, а не только проверкой на входе.
|
|
|
|
|
|
-- 🔴 Деньги здесь РУБЛЯМИ (не копейками, как в obzvon_calls): это числа
|
|
|
|
|
|
-- кошелькового уровня — ровно те же рубли, какими отвечает заморозка и какими
|
|
|
|
|
|
-- живёт ad_wallets.balance_rub. Цена ОДНОГО звонка по-прежнему в копейках.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Миграция: app/database/migrations/2026_08_08_100000_create_obzvon_campaigns.php.
|
|
|
|
|
|
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-4.1.
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
|
|
CREATE TABLE obzvon_campaigns (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
|
|
|
|
title VARCHAR(200) NOT NULL,
|
|
|
|
|
|
source VARCHAR(16) NOT NULL DEFAULT 'deals',
|
|
|
|
|
|
audience_days INTEGER,
|
|
|
|
|
|
audience_statuses JSONB,
|
|
|
|
|
|
script_id BIGINT,
|
|
|
|
|
|
lines_count SMALLINT NOT NULL DEFAULT 1,
|
|
|
|
|
|
manager_phone VARCHAR(20),
|
|
|
|
|
|
window_from_hour SMALLINT,
|
|
|
|
|
|
window_to_hour SMALLINT,
|
|
|
|
|
|
window_offset_minutes INTEGER,
|
|
|
|
|
|
status VARCHAR(16) NOT NULL DEFAULT 'draft',
|
|
|
|
|
|
idempotency_key VARCHAR(64),
|
|
|
|
|
|
planned_count INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
estimated_cost_rub NUMERIC(14,2) NOT NULL DEFAULT 0,
|
|
|
|
|
|
actual_cost_rub NUMERIC(14,2),
|
|
|
|
|
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
|
|
|
|
|
cancel_requested_at TIMESTAMPTZ,
|
|
|
|
|
|
stop_reason VARCHAR(16),
|
|
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_source
|
|
|
|
|
|
CHECK (source IN ('deals', 'file', 'manual')),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_status
|
|
|
|
|
|
CHECK (status IN ('draft', 'queued', 'running', 'waiting_window',
|
|
|
|
|
|
'done', 'failed', 'cancelled')),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_stop_reason
|
|
|
|
|
|
CHECK (stop_reason IS NULL OR stop_reason IN ('client', 'no_funds', 'watchdog')),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_lines
|
|
|
|
|
|
CHECK (lines_count BETWEEN 1 AND 3),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_days
|
|
|
|
|
|
CHECK (audience_days IS NULL OR audience_days >= 1),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_planned_nonneg
|
|
|
|
|
|
CHECK (planned_count >= 0),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_money_nonneg
|
|
|
|
|
|
CHECK (estimated_cost_rub >= 0
|
|
|
|
|
|
AND (actual_cost_rub IS NULL OR actual_cost_rub >= 0)),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_window_together
|
|
|
|
|
|
CHECK ((window_from_hour IS NULL) = (window_to_hour IS NULL)
|
|
|
|
|
|
AND (window_from_hour IS NULL) = (window_offset_minutes IS NULL)),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_window_sane
|
|
|
|
|
|
CHECK (window_from_hour IS NULL
|
|
|
|
|
|
OR (window_from_hour >= 0 AND window_to_hour <= 24
|
|
|
|
|
|
AND window_from_hour < window_to_hour)),
|
|
|
|
|
|
CONSTRAINT chk_obzvon_campaigns_window_offset_sane
|
|
|
|
|
|
CHECK (window_offset_minutes IS NULL
|
|
|
|
|
|
OR (window_offset_minutes BETWEEN -720 AND 840))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX obzvon_campaigns_tenant_created_idx
|
|
|
|
|
|
ON obzvon_campaigns (tenant_id, created_at DESC);
|
|
|
|
|
|
CREATE INDEX obzvon_campaigns_tenant_status_idx
|
|
|
|
|
|
ON obzvon_campaigns (tenant_id, status);
|
|
|
|
|
|
-- Замок от двойного нажатия: тот же ключ заказа у того же клиента = тот же заказ.
|
|
|
|
|
|
-- У обзвона цена двойного нажатия выше, чем у СМС: это второй звонок ЖИВОМУ
|
|
|
|
|
|
-- человеку и вторая заморозка денег.
|
|
|
|
|
|
CREATE UNIQUE INDEX obzvon_campaigns_idempotency_uniq
|
|
|
|
|
|
ON obzvon_campaigns (tenant_id, idempotency_key)
|
|
|
|
|
|
WHERE idempotency_key IS NOT NULL;
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE obzvon_campaigns ENABLE ROW LEVEL SECURITY;
|
|
|
|
|
|
ALTER TABLE obzvon_campaigns FORCE ROW LEVEL SECURITY;
|
|
|
|
|
|
CREATE POLICY tenant_isolation ON obzvon_campaigns
|
|
|
|
|
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
|
|
|
|
|
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
|
|
|
|
|
|
|
|
|
|
|
|
DO $$
|
|
|
|
|
|
BEGIN
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_campaigns TO crm_app_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_campaigns_id_seq TO crm_app_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_campaigns TO crm_supplier_worker;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_campaigns_id_seq TO crm_supplier_worker;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_campaigns TO crm_admin_user;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_campaigns_id_seq TO crm_admin_user;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
|
|
|
|
|
GRANT SELECT, INSERT, UPDATE ON obzvon_campaigns TO crm_migrator;
|
|
|
|
|
|
GRANT USAGE, SELECT ON SEQUENCE obzvon_campaigns_id_seq TO crm_migrator;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
|
|
|
|
|
|
GRANT SELECT ON obzvon_campaigns TO crm_readonly;
|
|
|
|
|
|
END IF;
|
|
|
|
|
|
END $$;
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 12:53:45 +03:00
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
-- КОНЕЦ schema_modules.sql
|
|
|
|
|
|
-- =============================================================================
|
|
|
|
|
|
|