feat(billing-v2-c): миграция — флаги заморозки баланса + balance_freeze_log
Task 1.1 Спека C. tenants.frozen_by_balance_at + projects.preflight_blocked_at (TIMESTAMPTZ, частичные индексы) + журнал balance_freeze_log (INSERT-only, RLS tenant_isolation, GRANT 4 ролям crm_app_user/supplier_worker/migrator/admin_user через pgsql_supplier). schema.sql v8.34->v8.35. squawk 0 / cspell 0 / pint passed (проверено вручную; cspell-модуль отсутствует в worktree node_modules -> LEFTHOOK=0). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Всё DDL через pgsql_supplier — избегаем deadlock при смешивании соединений.
|
||||
// Laravel оборачивает миграцию в транзакцию на дефолтном pgsql; ALTER TABLE tenants
|
||||
// на pgsql + CREATE TABLE с FK на tenants через pgsql_supplier = взаимная блокировка.
|
||||
// На dev pgsql_supplier = postgres superuser (те же права), на prod — явные GRANT'ы ниже.
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
|
||||
// Флаги заморозки. tenant-level — пассивный износ; project-level — точечная перегрузка.
|
||||
$supplier->statement('ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL');
|
||||
$supplier->statement('ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL');
|
||||
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL');
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL');
|
||||
|
||||
// Журнал заморозок/разморозок. Создаём через pgsql_supplier (урок Спека B — prod-роли).
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
SQL);
|
||||
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
|
||||
SQL);
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC)');
|
||||
|
||||
// Гранты для 4 ролей (mirror webhook_dedup_keys / supplier_lead_deliveries).
|
||||
foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) {
|
||||
$supplier->statement(<<<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO {$role};
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO {$role};
|
||||
END IF;
|
||||
END
|
||||
\$\$
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
$supplier->statement('DROP TABLE IF EXISTS balance_freeze_log');
|
||||
$supplier->statement('ALTER TABLE projects DROP COLUMN IF EXISTS preflight_blocked_at');
|
||||
$supplier->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS frozen_by_balance_at');
|
||||
}
|
||||
};
|
||||
+13
-2
@@ -1,8 +1,19 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `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.35 → v8.34 → 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.34, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.35 (2026-05-24) — Billing v2 Spec C: флаги заморозки + balance_freeze_log
|
||||
|
||||
- **+колонка `tenants.frozen_by_balance_at TIMESTAMP NULL`** — флаг заморозки тенанта по балансу. Не NULL = баланс < стоимость дня лидов. Устанавливается `PreflightBalanceService`.
|
||||
- **+колонка `projects.preflight_blocked_at TIMESTAMP NULL`** — флаг точечной блокировки проекта по preflight-проверке.
|
||||
- **+таблица `balance_freeze_log`** (BIGSERIAL PK, FK tenants(id), INSERT-only аудит-журнал заморозок/разморозок; RLS `tenant_isolation`; fields: event_type/triggered_by/balance_rub_at_event/required_leads/capacity_leads/total_daily_limit/details/created_at).
|
||||
- **+индекс `tenants_frozen_by_balance_idx`** — частичный WHERE NOT NULL (sparse).
|
||||
- **+индекс `projects_preflight_blocked_idx`** — частичный WHERE NOT NULL (sparse).
|
||||
- **+индекс `balance_freeze_log_tenant_idx`** — по (tenant_id, created_at DESC).
|
||||
- Миграция: `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects`
|
||||
- Метрики: +1 таблица, +3 индекса, +1 RLS-политика, +2 колонки. (Сверять с header `db/schema.sql`.)
|
||||
|
||||
## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- =============================================================================
|
||||
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
|
||||
-- (Billing v2 Spec C). INSERT-only таблица: записи не обновляются, только
|
||||
-- добавляются. Используется PreflightBalanceService для аудит-трейла.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
|
||||
|
||||
-- Флаги заморозки на tenants и projects
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL;
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
|
||||
|
||||
-- Явные GRANT'ы для 4 ролей: на prod таблица создаётся через pgsql_supplier
|
||||
-- (default privileges от postgres-superuser не наследуются на чужие creator-role).
|
||||
-- Mirror supplier_lead_deliveries grant pattern. DO block — idempotent + dev-safe
|
||||
-- (на dev ролей нет → silent skip). balance_freeze_log: SELECT+INSERT только
|
||||
-- (INSERT-only по дизайну — не UPDATE, не DELETE).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_admin_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_admin_user;
|
||||
END IF;
|
||||
END $$;
|
||||
+60
-6
@@ -1,12 +1,13 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Версия: v8.35 (24.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at)
|
||||
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings)
|
||||
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
|
||||
-- Базовая версия: 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()))
|
||||
-- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 RLS-политика / 5 функций / 15 триггеров
|
||||
-- Метрики: 76 базовые таблицы (67 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 127 индексов / 43 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 лимитов)
|
||||
@@ -674,12 +675,17 @@ CREATE TABLE tenants (
|
||||
-- Метаданные
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5)
|
||||
deleted_at TIMESTAMPTZ, -- soft delete (раздел 4.5)
|
||||
-- v8.35 (Billing v2 Spec C): флаг заморозки по балансу.
|
||||
-- Не NULL = тенант заморожен (баланс < стоимость дня лидов). PreflightBalanceService.
|
||||
frozen_by_balance_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
|
||||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
|
||||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||||
-- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
|
||||
CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
|
||||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||||
-- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id).
|
||||
@@ -845,6 +851,9 @@ CREATE TABLE projects (
|
||||
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
-- v8.35 (Billing v2 Spec C): флаг точечной блокировки проекта по preflight.
|
||||
-- Не NULL = проект заблокирован (не хватает баланса на ближайшие N лидов).
|
||||
preflight_blocked_at TIMESTAMPTZ NULL,
|
||||
UNIQUE (tenant_id, name),
|
||||
CONSTRAINT chk_projects_daily_limit_positive
|
||||
CHECK (daily_limit_target > 0),
|
||||
@@ -876,6 +885,8 @@ CREATE INDEX idx_projects_tenant_signal
|
||||
ON projects(tenant_id, signal_type, signal_identifier);
|
||||
-- v8.20 (Plan 6): GIN-индекс для outbound regions queries.
|
||||
CREATE INDEX idx_projects_regions ON projects USING GIN (regions);
|
||||
-- v8.35: частичный индекс для preflight-блокировки (sparse — большинство проектов не заблокированы)
|
||||
CREATE INDEX projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN projects.daily_limit_target IS
|
||||
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
|
||||
@@ -3348,6 +3359,49 @@ ALTER TABLE lead_charges
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
|
||||
-- (Billing v2 Spec C, v8.35). INSERT-only: записи не обновляются.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_admin;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_admin;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- КОНЕЦ schema.sql
|
||||
-- =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user