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


-- =============================================================================
-- 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,
    [REDACTED]      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 $$;


-- -----------------------------------------------------------------------------
-- 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 $$;


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

