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