diff --git a/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php b/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php index 5d89ff04..09c11958 100644 --- a/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php +++ b/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php @@ -24,7 +24,7 @@ return new class extends Migration DB::unprepared(<<<'SQL' -- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым) - CREATE TABLE phone_ranges_imports ( + CREATE TABLE IF NOT EXISTS phone_ranges_imports ( id BIGSERIAL PRIMARY KEY, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), source_url TEXT NOT NULL, @@ -40,7 +40,7 @@ return new class extends Migration 'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).'; -- 2. phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS — публичные данные) - CREATE TABLE phone_ranges ( + CREATE TABLE IF NOT EXISTS phone_ranges ( id BIGSERIAL PRIMARY KEY, def_code SMALLINT NOT NULL, from_num BIGINT NOT NULL, @@ -55,14 +55,14 @@ return new class extends Migration CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89), CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num) ); - CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num); + CREATE INDEX IF NOT EXISTS idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num); COMMENT ON TABLE phone_ranges IS 'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.'; GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker; -- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log) - CREATE TABLE lead_region_resolution_log ( + CREATE TABLE IF NOT EXISTS lead_region_resolution_log ( id BIGSERIAL, supplier_lead_id BIGINT NOT NULL, received_at TIMESTAMPTZ NOT NULL, @@ -89,8 +89,8 @@ return new class extends Migration PRIMARY KEY (id, received_at) ) PARTITION BY RANGE (received_at); - CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id); - CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at); + CREATE INDEX IF NOT EXISTS idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id); + CREATE INDEX IF NOT EXISTS idx_lrrl_source ON lead_region_resolution_log (region_source, received_at); COMMENT ON TABLE lead_region_resolution_log IS 'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).'; @@ -98,26 +98,26 @@ return new class extends Migration GRANT SELECT ON lead_region_resolution_log TO crm_app_user; -- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2). - CREATE TABLE lead_region_resolution_log_y2026_m05 + CREATE TABLE IF NOT EXISTS lead_region_resolution_log_y2026_m05 PARTITION OF lead_region_resolution_log FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); - CREATE TABLE lead_region_resolution_log_y2026_m06 + CREATE TABLE IF NOT EXISTS lead_region_resolution_log_y2026_m06 PARTITION OF lead_region_resolution_log FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); -- 4. supplier_leads: +4 колонки (denormalized display + persistent idempotency для retry). ALTER TABLE supplier_leads - ADD COLUMN resolved_subject_code SMALLINT + ADD COLUMN IF NOT EXISTS resolved_subject_code SMALLINT CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89), - ADD COLUMN region_source TEXT + ADD COLUMN IF NOT EXISTS region_source TEXT CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), - ADD COLUMN dadata_qc SMALLINT, - ADD COLUMN phone_operator TEXT; + ADD COLUMN IF NOT EXISTS dadata_qc SMALLINT, + ADD COLUMN IF NOT EXISTS phone_operator TEXT; -- 5. deals: +2 колонки (UI-карточка + флаг подмены региона). ALTER TABLE deals - ADD COLUMN phone_operator TEXT, - ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE; + ADD COLUMN IF NOT EXISTS phone_operator TEXT, + ADD COLUMN IF NOT EXISTS region_substituted BOOLEAN NOT NULL DEFAULT FALSE; SQL); // Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней). diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 76f1d33d..69d6ae54 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,35 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.41, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.46, консолидированная — разворачивает БД с нуля). + +## v8.46 (2026-06-19) — Sync дрейфа: слой lead-region в schema.sql + +Добавлен слой lead-region, отсутствовавший в `schema.sql` (на проде живёт с 31.05.2026, +DDL держался только в дельта-миграции `2026_05_31_100000` — см. v8.40, где это сделано +осознанно «как v8.39»). Реконсиляция дрейфа: теперь объекты есть в теле канонической +схемы, а миграция guard-нута (`IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`) и под +`migrate:fresh` (schema.sql уже содержит объекты) становится no-op. + +Источник: миграция `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`. + +**Добавлено в schema.sql:** + +- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (regular, без RLS). +- **`phone_ranges`** — реестр диапазонов нумерации Россвязи (regular, без RLS) + + индекс `idx_phone_ranges_lookup` + `GRANT SELECT` для `crm_app_user`/`crm_supplier_worker`. +- **`lead_region_resolution_log`** — партиционированный (`PARTITION BY RANGE (received_at)`, + composite PK `(id, received_at)`) аудит резолва региона лида + индексы `idx_lrrl_lead_id`, + `idx_lrrl_source` + 2 стартовые партиции `_y2026_m05` / `_y2026_m06` + + `GRANT SELECT, INSERT` для `crm_supplier_worker`, `GRANT SELECT` для `crm_app_user`. +- **`supplier_leads` +4 колонки:** `resolved_subject_code` (CHECK 1..89), `region_source` + (CHECK enum dadata/rossvyaz/tag/unknown), `dadata_qc`, `phone_operator`. +- **`deals` +2 колонки:** `phone_operator`, `region_substituted` (NOT NULL DEFAULT FALSE). + NB: обе отсутствовали в теле schema.sql (не только `phone_operator`). + +**Счётчики:** таблиц `72 → 75` (regular `64 → 66`, partitioned parents `8 → 9`) / +партиций `12 → 14` / индексов `117 → 120`. RLS-политики / функции / триггеры — без +изменений (lead-region — SaaS-level, без RLS). ## v8.45 (2026-06-19) — G3: удалена таблица reminders diff --git a/db/schema.sql b/db/schema.sql index d1269771..1efaefbd 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,7 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.45 (19.06.2026 — G3: DROP таблицы reminders (фича «Напоминания» снята с продукта). Удалены: таблица reminders, 4 индекса (idx_reminders_due/deal/tenant_user_active/tenant_active), RLS-политика tenant_isolation, COMMENT'ы; ключ "reminder" убран из DEFAULT users.notification_preferences. Счётчики: таблиц 73→72 (regular 65→64) / индексов 121→117 / RLS-политик 40→39. Миграция 2026_06_19_130000_drop_reminders_table) +-- Версия: v8.46 (19.06.2026 — Sync дрейфа lead-region: слой миграции 2026_05_31_100000 (live на проде с 31.05) добавлен в тело schema.sql, отсутствовал ранее (v8.40 держал DDL только в дельта-миграции). Добавлены таблицы phone_ranges_imports, phone_ranges (+idx_phone_ranges_lookup, GRANT) — regular; lead_region_resolution_log — partitioned-parent (PARTITION BY received_at, +idx_lrrl_lead_id/idx_lrrl_source, +2 стартовые партиции m05/m06, GRANT). +4 колонки supplier_leads (resolved_subject_code/region_source/dadata_qc/phone_operator), +2 колонки deals (phone_operator/region_substituted). Счётчики: таблиц 72→75 (regular 64→66, partitioned parents 8→9) / партиций 12→14 / индексов 117→120. Миграция 2026_05_31_100000 (теперь guard-нута под migrate:fresh)) +-- Базовая версия: v8.45 (19.06.2026 — G3: DROP таблицы reminders (фича «Напоминания» снята с продукта). Удалены: таблица reminders, 4 индекса (idx_reminders_due/deal/tenant_user_active/tenant_active), RLS-политика tenant_isolation, COMMENT'ы; ключ "reminder" убран из DEFAULT users.notification_preferences. Счётчики: таблиц 73→72 (regular 65→64) / индексов 121→117 / RLS-политик 40→39. Миграция 2026_06_19_130000_drop_reminders_table) -- Базовая версия: v8.44 (19.06.2026 — G2-B: users.notification_preferences DEFAULT new_lead.email false→true (почтовый дайджест новых сделок по умолчанию; существующие пользователи дотягиваются UPDATE false→true, только живые). Структурно без изменений — правка дефолтного значения; счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_19_120000, спека docs/superpowers/specs/2026-06-19-g2b-new-lead-email-default-on-design.md) -- Базовая версия: v8.43 (18.06.2026 — G1/SP2 реквизиты клиента: tenant_requisites (1:1 tenants), RLS tenant isolation, лёгкие поля (тип лица/контакт/телефон) гейтят первый проект; тяжёлые (ИНН/КПП/ОГРН/юр.адрес/банк) nullable. Миграция 2026_06_18_140000, спека docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md) -- Базовая версия: v8.42 (18.06.2026 — G1/SP1 самозапись клиента: email_verifications +code_hash +failed_attempts (6-значный код подтверждения почты, bcrypt, TTL 15м, 5 попыток) + tenants.status расширен VARCHAR(20)→(30) (латентный дефект: CHECK допускал 21-символьное 'pending_email_confirm', колонка была 20). Счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_18_120000, спека docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md) @@ -17,7 +18,7 @@ -- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns) -- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure) -- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) --- Метрики: 72 базовые таблицы (64 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 117 индексов / 39 RLS-политик / 5 функций / 15 триггеров +-- Метрики: 75 базовых таблиц (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 6 audit + lead_region_resolution_log) + 14 партиций / 120 индексов / 39 RLS-политик / 5 функций / 15 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) -- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) @@ -1711,6 +1712,10 @@ CREATE TABLE deals ( -- enrichment-сервиса). Используется для filter в §10.3 + аналитики §12. region_code VARCHAR(8), subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно + -- v8.40 (lead region resolution): оператор связи (UI-карточка) + флаг подмены + -- региона каскадом DaData→Россвязь→tag (миграция 2026_05_31_100000). + phone_operator TEXT, + region_substituted BOOLEAN NOT NULL DEFAULT FALSE, city VARCHAR(100), -- v8.5 (Биз-22): простая lead scoring модель без ML. -- time_in_form_seconds — сколько секунд физлицо заполняло форму @@ -1996,6 +2001,14 @@ CREATE TABLE supplier_leads ( recovered_from_csv_at TIMESTAMPTZ, deals_created_count INTEGER, error TEXT, + -- v8.40 (lead region resolution): денормализованный display + persistent + -- idempotency для retry резолва региона лида (миграция 2026_05_31_100000). + resolved_subject_code SMALLINT + CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89), + region_source TEXT + CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), + dadata_qc SMALLINT, + phone_operator TEXT, CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3','DIRECT')), @@ -2018,6 +2031,96 @@ CREATE INDEX supplier_leads_recovered_from_csv_partial -- -- REVOKE ALL ON supplier_leads FROM crm_app_user; +-- ============================================================================= +-- Lead-region (телефон → регион) — реестр Россвязи + аудит резолва +-- v8.40 (31.05.2026), миграция 2026_05_31_100000. SaaS-level (без RLS): +-- phone_ranges/phone_ranges_imports — публичные данные; lead_region_resolution_log +-- — партиционированный аудит (паттерн activity_log). Порядок: imports → phone_ranges +-- (FK на imports) → lead_region_resolution_log. +-- ============================================================================= + +-- phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаётся первым) +CREATE TABLE phone_ranges_imports ( + id BIGSERIAL PRIMARY KEY, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source_url TEXT NOT NULL, + rows_inserted INTEGER NOT NULL DEFAULT 0, + rows_updated INTEGER NOT NULL DEFAULT 0, + checksum_sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'in_progress' + CHECK (status IN ('in_progress','completed','failed','rolled_back')), + error TEXT, + completed_at TIMESTAMPTZ +); +COMMENT ON TABLE phone_ranges_imports IS + 'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).'; + +-- phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS — публичные данные) +CREATE TABLE phone_ranges ( + id BIGSERIAL PRIMARY KEY, + def_code SMALLINT NOT NULL, + from_num BIGINT NOT NULL, + to_num BIGINT NOT NULL, + operator TEXT NOT NULL, + region TEXT NOT NULL, + region_normalized TEXT, + subject_code SMALLINT, + imported_at TIMESTAMPTZ NOT NULL, + import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id), + CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999), + CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89), + CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num) +); +CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num); +COMMENT ON TABLE phone_ranges IS + 'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.'; + +GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker; + +-- lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log) +CREATE TABLE lead_region_resolution_log ( + id BIGSERIAL, + supplier_lead_id BIGINT NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + phone_masked TEXT NOT NULL, + subject_code_resolved SMALLINT, + subject_code_from_tag SMALLINT, + region_source TEXT NOT NULL + CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), + dadata_qc SMALLINT, + dadata_provider TEXT, + dadata_type TEXT, + dadata_response_masked JSONB, + rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE, + actual_subject_code SMALLINT + CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89), + substituted_subject_code SMALLINT + CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89), + routing_step SMALLINT + CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3), + phone_operator TEXT, + cache_hit BOOLEAN NOT NULL DEFAULT FALSE, + duration_ms INTEGER, + resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (id, received_at) +) PARTITION BY RANGE (received_at); +CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id); +CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at); +COMMENT ON TABLE lead_region_resolution_log IS + 'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).'; + +GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker; +GRANT SELECT ON lead_region_resolution_log TO crm_app_user; + +-- Стартовые партиции (далее их подхватывает partitions:create-months / MonthlyPartitionManager). +CREATE TABLE lead_region_resolution_log_y2026_m05 + PARTITION OF lead_region_resolution_log + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +CREATE TABLE lead_region_resolution_log_y2026_m06 + PARTITION OF lead_region_resolution_log + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); + + -- ============================================================================= -- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз» -- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —