feat(db): create pricing_tiers table (7-step volume billing, kopecks integer)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('pricing_tiers', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('pricing_tiers table exists with required columns', function () {
|
||||
expect(Schema::hasTable('pricing_tiers'))->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');
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+52
-2
@@ -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)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user