diff --git a/app/database/migrations/2026_05_10_000003_create_pricing_tiers_table.php b/app/database/migrations/2026_05_10_000003_create_pricing_tiers_table.php new file mode 100644 index 00000000..79bade8e --- /dev/null +++ b/app/database/migrations/2026_05_10_000003_create_pricing_tiers_table.php @@ -0,0 +1,60 @@ +bigIncrements('id'); + $table->unsignedSmallInteger('tier_no'); + $table->unsignedInteger('leads_in_tier')->nullable(); + $table->unsignedInteger('price_per_lead_kopecks'); + $table->boolean('is_active')->default(true); + $table->date('effective_from'); + $table->timestamps(); + + $table->unique(['tier_no', 'effective_from'], 'pricing_tiers_tier_effective_unique'); + $table->index(['is_active', 'effective_from'], 'pricing_tiers_is_active_effective_from_index'); + }); + + DB::statement(' + ALTER TABLE pricing_tiers + ADD CONSTRAINT chk_pricing_tiers_tier_no + CHECK (tier_no BETWEEN 1 AND 7) + '); + + // SaaS-level table — конфигурируется админом Лидерры, читается всеми tenant'ами. + // App-user has SELECT only (read-only); writes — через elevated service-role. + // Conditional: dev runs as postgres superuser without crm_app_user role; prod creates role + // via db/00_create_roles.sql before running migrations. + DB::statement(" + DO \$\$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + REVOKE ALL ON TABLE pricing_tiers FROM crm_app_user; + END IF; + END \$\$ + "); + + DB::statement(" + DO \$\$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT ON TABLE pricing_tiers TO crm_app_user; + END IF; + END \$\$ + "); + } + + public function down(): void + { + Schema::dropIfExists('pricing_tiers'); + } +}; diff --git a/app/tests/Feature/Integration/PricingTiersTest.php b/app/tests/Feature/Integration/PricingTiersTest.php new file mode 100644 index 00000000..29df1d25 --- /dev/null +++ b/app/tests/Feature/Integration/PricingTiersTest.php @@ -0,0 +1,63 @@ +toBeTrue(); + + foreach ([ + 'id', + 'tier_no', + 'leads_in_tier', + 'price_per_lead_kopecks', + 'is_active', + 'effective_from', + 'created_at', + 'updated_at', + ] as $col) { + expect(Schema::hasColumn('pricing_tiers', $col))->toBeTrue("column {$col} missing"); + } +}); + +test('pricing_tiers tier_no constrained to 1..7', function () { + $check = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'pricing_tiers' AND c.conname = 'chk_pricing_tiers_tier_no'" + ); + + expect($check)->not->toBeNull(); + expect($check->def) + ->toContain('1') + ->toContain('7'); +}); + +test('pricing_tiers has unique on (tier_no, effective_from)', function () { + $idx = DB::selectOne( + "SELECT indexdef + FROM pg_indexes + WHERE tablename = 'pricing_tiers' + AND indexname = 'pricing_tiers_tier_effective_unique'" + ); + + expect($idx)->not->toBeNull(); + expect($idx->indexdef) + ->toContain('UNIQUE') + ->toContain('tier_no') + ->toContain('effective_from'); +}); + +test('pricing_tiers price stored in kopecks (integer, not float)', function () { + $col = DB::selectOne( + "SELECT data_type + FROM information_schema.columns + WHERE table_name = 'pricing_tiers' AND column_name = 'price_per_lead_kopecks'" + ); + + expect($col)->not->toBeNull(); + expect($col->data_type)->toBe('integer'); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 84e31b47..90306fc8 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -1,11 +1,12 @@ # CHANGELOG schema.sql — Лидерра -**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двенадцать записей в обратном хронологическом порядке (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.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.13, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.14, консолидированная — разворачивает БД с нуля). **История записей:** +- **v8.14 (10.05.2026)** — Plan 1/5 Task 3: создание `pricing_tiers` SaaS-level таблицы для конфигурации 7-ступенчатого объёмного тарифа (volume billing). Колонки: id, tier_no (smallint 1..7), leads_in_tier (uint nullable; NULL = «всё свыше» для последней ступени), price_per_lead_kopecks (uint, копейки integer — избегаем floating-point округлений в money-расчётах; 1 руб = 100 коп.), is_active (default true), effective_from (date), timestamps. 1 CHECK constraint (`chk_pricing_tiers_tier_no` — `tier_no BETWEEN 1 AND 7`). 2 индекса: UNIQUE на (tier_no, effective_from), btree на (is_active, effective_from). **НЕ tenant-scoped** — конфигурация админом Лидерры; RLS НЕ применяется. Per-tenant override out of scope для MVP (один тариф на всю Лидерру). SELECT-only для tenant-приложения: REVOKE ALL FROM crm_app_user + GRANT SELECT TO crm_app_user (миграция оборачивает оба в DO $$ EXISTS-check для совместимости с dev без роли). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §7.2. Метрики: 58 базовых таблиц (+1) / 103 индекса (+2) / RLS/функции/триггеры без изменений. - **v8.13 (10.05.2026)** — Plan 1/5 Task 2: создание `supplier_projects` SaaS-level агрегатной таблицы для проектов у поставщиков B1/B2/B3. Колонки: id, platform (B1/B2/B3), signal_type (site/call/sms), unique_key (TEXT — domain/phone/sender+keyword/sender), supplier_external_id, current_limit (uint, default 0), current_workdays (jsonb), current_regions (jsonb), sync_status (pending/ok/failed), last_synced_at, inactive_since, timestamps. 4 CHECK constraints (`chk_supplier_projects_platform`, `chk_supplier_projects_signal_type`, `chk_supplier_projects_sync_status`, `chk_supplier_projects_b1_not_for_sms` — B1 не поддерживает СМС). 3 индекса: UNIQUE на (platform, unique_key), btree на sync_status, btree на inactive_since. **НЕ tenant-scoped** — sharing-model между Лидерра-tenant'ами; RLS НЕ применяется. Defense-in-depth: REVOKE ALL FROM crm_app_user (миграция оборачивает в DO $$ EXISTS-check для совместимости с dev без роли). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §2.2. Метрики: 57 базовых таблиц (+1) / 101 индекс (+3) / RLS/функции/триггеры без изменений. - **v8.12 (10.05.2026)** — Plan 1/5 Task 1: расширение `projects` для supplier integration. +signal_type (enum site/call/sms), +signal_identifier (text), +sms_senders (jsonb array), +sms_keyword (nullable text), +delivered_in_month (uint), +supplier_b{1,2,3}_project_id (nullable BIGINT placeholder, FK добавятся в Task 2 после создания supplier_projects). 3 CHECK constraints (signal_type enum; sms_senders required for sms; signal_identifier required for site/call) + 1 composite index `idx_projects_tenant_signal(tenant_id, signal_type, signal_identifier)`. Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §2.1. - **v8.11 (09.05.2026)** — hygiene-фиксы аудита 2026-05-09: P0-02 RLS на `impersonation_tokens` + O-perf-02/03 индексы FK-колонок `webhook_log_id` на `failed_webhook_jobs` и `rejected_deals_log`. См. ниже §S. diff --git a/db/schema.sql b/db/schema.sql index fc0bf8aa..acbfc2d6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,8 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.13 (10.05.2026 — Plan 1/5 Task 2: supplier_projects SaaS-level агрегатная таблица для проектов у поставщиков B1/B2/B3 + 4 CHECK + 3 индекса + REVOKE ALL FROM crm_app_user) --- Метрики: 57 базовых таблиц + 12 партиций / 101 индекс / 38 RLS-политик / 5 функций / 13 триггеров +-- Версия: v8.14 (10.05.2026 — Plan 1/5 Task 3: pricing_tiers SaaS-level конфигурация 7-ступенчатого объёмного тарифа в копейках + 1 CHECK (tier_no 1..7) + 2 индекса + REVOKE ALL + GRANT SELECT для crm_app_user) +-- Метрики: 58 базовых таблиц + 12 партиций / 103 индекса / 38 RLS-политик / 5 функций / 13 триггеров +-- Базовая версия: v8.13 (10.05.2026 — Plan 1/5 Task 2: supplier_projects SaaS-level агрегатная таблица для проектов у поставщиков B1/B2/B3 + 4 CHECK + 3 индекса + REVOKE ALL FROM crm_app_user) -- Базовая версия: v8.12 (10.05.2026 — расширение projects для supplier integration: signal_type/identifier/sms_senders/sms_keyword/delivered_in_month/supplier_b{1,2,3}_project_id + 3 CHECK + 1 composite index) -- Базовая версия: v8.11 (09.05.2026 — hygiene-фиксы аудита: P0-02 RLS на impersonation_tokens + O-perf-02/03 индексы FK-колонок webhook_log_id) -- Базовая версия: v8.10 (09.05.2026 — in_app_notifications для bell-icon UI; 2 индекса (unread + recent); RLS tenant isolation) @@ -906,6 +907,55 @@ CREATE INDEX supplier_projects_inactive_since_index -- REVOKE ALL ON supplier_projects FROM crm_app_user; +-- ----------------------------------------------------------------------------- +-- pricing_tiers — SaaS-level конфигурация 7-ступенчатого объёмного тарифа (v8.14, Plan 1/5 Task 3) +-- ----------------------------------------------------------------------------- +-- Контекст: см. spec docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.2. +-- Клиент Лидерры платит ступенчато: чем больше лидов получено в текущем месяце — +-- тем дешевле каждый следующий. Конфигурируется админом Лидерры; tenant'ы читают. +-- +-- НЕ tenant-scoped — таблица SaaS-уровня, RLS НЕ применяется. Per-tenant override +-- out of scope для MVP (один тариф на всю Лидерру). +-- +-- Цена хранится в копейках (integer) — избегаем floating-point округлений в money-расчётах +-- (1 руб = 100 копеек). UI converts at the edge (accessor в Eloquent-модели, Plan Task 7). +-- +-- Поле leads_in_tier nullable: для последней ступени NULL = «всё свыше» (без верхней границы). +-- +-- Доступ из tenant-приложения: SELECT only (через elevated service-role идут writes). +-- Conditional: dev runs as postgres superuser без crm_app_user — миграция оборачивает +-- REVOKE/GRANT в DO $$ EXISTS-check. +-- ----------------------------------------------------------------------------- +CREATE TABLE pricing_tiers ( + id BIGSERIAL PRIMARY KEY, + tier_no SMALLINT NOT NULL + CHECK (tier_no >= 0), -- unsigned-эквивалент: tier_no >= 0; диапазон 1..7 — отдельный CHECK ниже + leads_in_tier INTEGER -- NULL = «всё свыше» для последней ступени + CHECK (leads_in_tier IS NULL OR leads_in_tier >= 0), + price_per_lead_kopecks INTEGER NOT NULL + CHECK (price_per_lead_kopecks >= 0), -- копейки, integer (избегаем float) + is_active BOOLEAN NOT NULL DEFAULT TRUE, + effective_from DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_pricing_tiers_tier_no + CHECK (tier_no BETWEEN 1 AND 7) +); + +CREATE UNIQUE INDEX pricing_tiers_tier_effective_unique + ON pricing_tiers(tier_no, effective_from); +CREATE INDEX pricing_tiers_is_active_effective_from_index + ON pricing_tiers(is_active, effective_from); + +-- v8.14 (Plan 1/5 Task 3): SELECT-only для tenant-приложения. +-- Применяется в production через db/02_grants.sql; в dev (postgres superuser без crm_app_user) — +-- миграция оборачивает REVOKE/GRANT в DO $$ EXISTS check. +-- +-- REVOKE ALL ON pricing_tiers FROM crm_app_user; +-- GRANT SELECT ON pricing_tiers TO crm_app_user; + + -- ----------------------------------------------------------------------------- -- project_suppliers — m2m связь "проект ↔ поставщики" (НОВАЯ в v8.2) -- -----------------------------------------------------------------------------