feat(db): Plan 4 Task 1 — schema delta v8.18 → v8.19 + models/seeder

Schema delta (4 правки в db/schema.sql):
- tenants + delivered_in_month INT NOT NULL DEFAULT 0 CHECK (>=0) — month
  counter для PricingTierResolver O(1) lookup на горячем пути.
- lead_charges + charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')
  + ALTER ADD CONSTRAINT chk_lead_charges_prepaid_zero_price (prepaid → price=0).
- supplier_leads + recovered_from_csv_at TIMESTAMPTZ + partial index
  WHERE recovered_from_csv_at IS NOT NULL.
- Новая таблица supplier_csv_reconcile_log (SaaS-level, без RLS) + 2 индекса
  (started_at DESC, partial status WHERE status IN ('drift_alert','failed')).

Метрики: 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS (без изменений).

Сопутствующие правки:
- db/CHANGELOG_schema.md — v8.19 entry (18 записей).
- db/02_grants.sql — GRANT SELECT,INSERT,UPDATE on supplier_csv_reconcile_log
  + GRANT USAGE,SELECT on sequence для crm_supplier_worker.
- app/Models/Tenant — fillable+casts: delivered_in_month integer.
- app/Models/LeadCharge — fillable+casts: charge_source string.
- app/Models/SupplierLead — fillable+casts: recovered_from_csv_at datetime.
- LeadChargeFactory — defaults charge_source='rub' + prepaid() state.
- PricingTierSeeder (новый) — 7 ступеней дефолтного тарифа (placeholder,
  Plan 4 Открытый вопрос #1: 100/200/400/800/1500/3000/∞ leads at
  50000/45000/40000/35000/30000/27000/25000 копеек).
- DatabaseSeeder — call PricingTierSeeder; убран broken Laravel scaffold
  User::factory(['name' => ...]) (наша схема first_name/last_name).

Tests (Plan 4 surface):
- SchemaDeltaTest.php — 5 it() блоков: delivered_in_month CHECK, charge_source
  CHECK на prepaid+zero-price, recovered_from_csv_at колонка, reconcile_log
  таблица+status CHECK, migrate:fresh idempotency (skip in parallel).
- pest --filter=SchemaDeltaTest: 5/5 PASS (9 assertions, 2076 ms).
- pest --filter='Tenant|LeadCharge|SupplierLead|Plan4': 111/111 PASS.

CI gates:
- composer pint: passed.
- composer stan: passed (0 errors above baseline — @phpstan-ignore-next-line
  на $this->markTestSkipped в Pest closure rebound context).

Verify:
- migrate:fresh --seed на DB_DATABASE=liderra_testing: 0 errors, 754 ms.
- PricingTier::count() = 7.

Концерны (НЕ блокируют Task 1):
- pest --parallel: 617/622 PASS + 4 skipped + 1 flaky FAIL — flaky test
  колеблется между ProjectExtensionsTest::supplierB1_B2_B3_relations
  (SupplierProjectFactory race: closure пикает signal_type=sms до override
  platform=B1 → CHECK chk_supplier_projects_b1_not_for_sms violation)
  и NewLeadNotificationTest::webhook_дубль_Биз_19 (известный microsecond
  precision quirk в anti-spam exclusion, memory feedback_environment.md).
  Оба теста существуют на main (HEAD 0802f7c = plan4-billing branch HEAD
  до этого commit'а), Plan 4 их не трогает. Фиксы — вне Task 1 scope.

Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §2.
Plan: docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md Task 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-11 08:38:38 +03:00
parent 0802f7cf4c
commit a907fea031
10 changed files with 208 additions and 11 deletions
+2
View File
@@ -37,6 +37,7 @@ class LeadCharge extends Model
'deal_id',
'deal_received_at',
'tier_no',
'charge_source',
'price_per_lead_kopecks',
'charged_at',
'created_at',
@@ -46,6 +47,7 @@ class LeadCharge extends Model
{
return [
'tier_no' => 'integer',
'charge_source' => 'string',
'price_per_lead_kopecks' => 'integer',
'deal_received_at' => 'datetime',
'charged_at' => 'datetime',
+2
View File
@@ -38,6 +38,7 @@ class SupplierLead extends Model
'received_at',
'source',
'processed_at',
'recovered_from_csv_at',
'deals_created_count',
'error',
];
@@ -48,6 +49,7 @@ class SupplierLead extends Model
'raw_payload' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
'recovered_from_csv_at' => 'datetime',
'vid' => 'integer',
'deals_created_count' => 'integer',
];
+2
View File
@@ -42,6 +42,7 @@ class Tenant extends Model
'last_activity_at',
'last_webhook_at',
'desired_daily_numbers',
'delivered_in_month',
'api_key_limit',
];
@@ -54,6 +55,7 @@ class Tenant extends Model
'balance_leads' => 'integer',
'trial_leads_used' => 'integer',
'desired_daily_numbers' => 'integer',
'delivered_in_month' => 'integer',
'api_key_limit' => 'integer',
'webhook_token_rotated_at' => 'datetime',
'last_activity_at' => 'datetime',
@@ -26,8 +26,20 @@ class LeadChargeFactory extends Factory
'deal_received_at' => now(),
'tier_no' => fake()->numberBetween(1, 7),
'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000),
'charge_source' => 'rub',
'charged_at' => now(),
'created_at' => now(),
];
}
/**
* State для prepaid-списания (price=0).
*/
public function prepaid(): self
{
return $this->state(fn () => [
'charge_source' => 'prepaid',
'price_per_lead_kopecks' => 0,
]);
}
}
+6 -7
View File
@@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -12,14 +11,14 @@ class DatabaseSeeder extends Seeder
/**
* Seed the application's database.
*
* Note: the Laravel scaffold default User::factory() seed was removed
* наша схема использует first_name/last_name (а не "name"), и заранее
* не было сценария, где этот seed реально вызывался. PricingTierSeeder
* (Plan 4) единственный текущий seed для dev/testing.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->call(PricingTierSeeder::class);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\PricingTier;
use Illuminate\Database\Seeder;
class PricingTierSeeder extends Seeder
{
/**
* 7 ступеней дефолтного тарифа (Plan 4 spec §2.3 placeholder, ожидает
* подтверждения заказчика. Открытый вопрос #1).
*/
public function run(): void
{
$tiers = [
['tier_no' => 1, 'leads_in_tier' => 100, 'price_per_lead_kopecks' => 50000],
['tier_no' => 2, 'leads_in_tier' => 200, 'price_per_lead_kopecks' => 45000],
['tier_no' => 3, 'leads_in_tier' => 400, 'price_per_lead_kopecks' => 40000],
['tier_no' => 4, 'leads_in_tier' => 800, 'price_per_lead_kopecks' => 35000],
['tier_no' => 5, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 30000],
['tier_no' => 6, 'leads_in_tier' => 3000, 'price_per_lead_kopecks' => 27000],
['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25000],
];
foreach ($tiers as $tier) {
PricingTier::updateOrCreate(
['tier_no' => $tier['tier_no'], 'effective_from' => '1970-01-01'],
array_merge($tier, ['is_active' => true, 'effective_from' => '1970-01-01']),
);
}
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Tenant;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// NOTE: \Tests\TestCase auto-binds via tests/Pest.php (->in('Feature')); explicit
// uses(\Tests\TestCase::class) conflicts ("already uses the test case").
it('tenants table has delivered_in_month column with CHECK >= 0', function () {
expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();
DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
expect(fn () => DB::statement(
'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '.
"VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
))->toThrow(QueryException::class);
});
it('lead_charges table has charge_source column with CHECK on prepaid=zero-price', function () {
expect(Schema::hasColumn('lead_charges', 'charge_source'))->toBeTrue();
$tenant = Tenant::factory()->create();
$deal = Deal::factory()->create(['tenant_id' => $tenant->id]);
expect(fn () => DB::table('lead_charges')->insert([
'tenant_id' => $tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'prepaid',
'charged_at' => now(),
'created_at' => now(),
]))->toThrow(QueryException::class);
});
it('supplier_leads table has recovered_from_csv_at column', function () {
expect(Schema::hasColumn('supplier_leads', 'recovered_from_csv_at'))->toBeTrue();
});
it('supplier_csv_reconcile_log table exists with required columns and status CHECK', function () {
expect(Schema::hasTable('supplier_csv_reconcile_log'))->toBeTrue();
expect(Schema::hasColumns('supplier_csv_reconcile_log', [
'id', 'started_at', 'finished_at', 'window_start', 'window_end',
'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio',
'status', 'error_message', 'alert_email_sent_at', 'created_at',
]))->toBeTrue();
expect(fn () => DB::table('supplier_csv_reconcile_log')->insert([
'started_at' => now(),
'window_start' => now()->subDay(),
'window_end' => now(),
'status' => 'unknown_status',
]))->toThrow(QueryException::class);
});
it('migrate:fresh is idempotent — re-run produces same metrics', function () {
// В parallel-режиме DROP CASCADE на главной БД конфликтует с воркерами;
// serial-проверку выполняем через `pest tests/.../SchemaDeltaTest.php` (Step 11).
if (getenv('LARAVEL_PARALLEL_TESTING')) {
// @phpstan-ignore-next-line method.notFound (Pest rebinds $this to TestCase at runtime)
$this->markTestSkipped('Idempotency check is serial-only (avoids cross-worker DROP CASCADE deadlock).');
}
$beforeTables = count(DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public'"));
Artisan::call('migrate:fresh', ['--database' => 'pgsql', '--force' => true]);
$afterTables = count(DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public'"));
expect($afterTables)->toBe($beforeTables);
});
+8
View File
@@ -132,3 +132,11 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_supplier_worker;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO crm_supplier_worker;
-- =============================================================================
-- v8.19 (Plan 4): supplier_csv_reconcile_log — SaaS-уровневый журнал CSV recon.
-- Используется CsvReconcileJob под crm_supplier_worker (BYPASSRLS).
-- =============================================================================
GRANT SELECT, INSERT, UPDATE ON TABLE supplier_csv_reconcile_log TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE supplier_csv_reconcile_log_id_seq TO crm_supplier_worker;
+16 -2
View File
@@ -1,11 +1,25 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит семнадцать записей в обратном хронологическом порядке (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.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.18, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.19, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.19 (2026-05-11) — Plan 4 Billing + CSV Reconcile + Admin
**Изменения:**
- `tenants` + колонка `delivered_in_month INTEGER NOT NULL DEFAULT 0 CHECK >= 0` (per-tenant счётчик для tier-lookup).
- `lead_charges` + колонка `charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')` + CHECK `chk_lead_charges_prepaid_zero_price` (prepaid → price=0).
- `supplier_leads` + колонка `recovered_from_csv_at TIMESTAMPTZ` + partial index.
- Новая таблица `supplier_csv_reconcile_log` (SaaS-level, без RLS) + 2 индекса.
- 0 RLS-политик изменено.
**Метрики:** 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS-политик (без изменений).
**Spec:** [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](../docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md).
- **v8.18 (10.05.2026)** — Plan 2/5 Task 1: подготовка слоя данных для supplier-webhook + sharing routing (spec §5–§6). Новая таблица `supplier_leads` (SaaS-level, без RLS) — raw-payload входящих webhook'ов от поставщика, FK на `supplier_projects(id) ON DELETE SET NULL`, 3 CHECK (platform enum / source enum / deals_count nonneg), 3 индекса (idx_received_at DESC + idx_supplier_project partial + UNIQUE на vid для idempotency). Новая колонка `projects.delivered_today INTEGER NOT NULL DEFAULT 0 CHECK (>=0)` — дневной счётчик для проверки квоты, сбрасывается cron'ом в 00:00 МСК. 2 строки в `system_settings`: `supplier_webhook_secret` (string, placeholder `__SET_ON_DEPLOY__`) — platform-wide секрет в URL; `supplier_ip_allowlist` (json, default `[]`) — IP/CIDR поставщика. REVOKE: `supplier_leads` defense-in-depth (закомментирован, conditional wrapper аналогично `supplier_projects`). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §5–§6. Метрики: 60 → **61 базовая таблица** (+1) / 111 → **114 индексов** (+3) / **39 RLS-политик** (без изменений — supplier_leads SaaS-level) / функции/триггеры без изменений.
- **v8.17 (10.05.2026 поздний вечер)** — Plan 1/5 Task 2 fix (закрытие code-review BLOCKER#1 + WARNING#3): добавлены 3 FK constraints `projects.supplier_b{1,2,3}_project_id → supplier_projects(id) ON DELETE SET NULL` (заведены в v8.12 как placeholder BIGINT — FK был обещан в комментарии, но не добавлен в Task 2 commit). +3 partial индекса (idx_projects_supplier_b{1,2,3}_project_id WHERE NOT NULL) для FK lookup performance. +1 CHECK `chk_projects_b1_not_for_sms` (defense-in-depth: дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне — `signal_type <> 'sms' OR supplier_b1_project_id IS NULL`). Метрики: 60 базовых таблиц (без изменений) / 111 индексов (+3) / 39 RLS-политик (без изменений) / функции/триггеры без изменений.
- **v8.16 (10.05.2026)** — Plan 1/5 Task 5: создание `supplier_sync_log` SaaS-level append-only audit log для AJAX-синхронизаций с поставщиком crm.bp-gr.ru. Колонки: id, supplier_project_id (nullable BIGINT, FK на supplier_projects ON DELETE SET NULL — лог переживает удаление supplier-проекта для audit-trail), action (VARCHAR(32)), request_payload (jsonb), response_body (jsonb), http_status (smallint), error_message (text), duration_ms (uint), created_at. 1 CHECK (`chk_supplier_sync_log_action` — action IN create/update/delete/disable/session_refresh). 3 индекса: btree на supplier_project_id (drill-down по проекту), btree на action (фильтрация по типу события), btree на created_at (timeline-запросы для алертов). **НЕ tenant-scoped** — события агрегатные на уровне SaaS. REVOKE ALL FROM crm_app_user (миграция оборачивает в DO $$ EXISTS-check). Используется для retry-логики, отладки rt-project-* AJAX и алертов менеджеру при failed sync. Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §4.3. Метрики: 60 базовых таблиц (+1) / 108 индексов (+3) / RLS/функции/триггеры без изменений.
+55 -2
View File
@@ -1,7 +1,8 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
-- Метрики: 61 базовая таблица + 12 партиций / 114 индексов / 39 RLS-политик / 5 функций / 13 триггеров
-- Версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
-- Метрики: 62 базовые таблицы + 12 партиций / 117 индексов / 39 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK projects.supplier_b{1,2,3}_project_id → supplier_projects (ON DELETE SET NULL) + 3 partial index + CHECK chk_projects_b1_not_for_sms (defense-in-depth дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне). Закрывает code-review BLOCKER#1 + WARNING#3 от 10.05.2026 поздний вечер)
-- Базовая версия: v8.16 (10.05.2026 — Plan 1/5 Task 5: supplier_sync_log SaaS-level audit log AJAX-синхронизаций с поставщиком + 1 CHECK (action enum) + 3 индекса + nullable FK на supplier_projects (ON DELETE SET NULL) + REVOKE ALL для crm_app_user)
-- Базовая версия: v8.15 (10.05.2026 — Plan 1/5 Task 4: lead_charges append-only ledger списаний за доставленный лид + composite DEFERRABLE FK на deals + RLS tenant_isolation + 2 индекса + GRANT SELECT,INSERT для crm_app_user)
@@ -642,6 +643,12 @@ CREATE TABLE tenants (
-- в админке SaaS (саппорту видно, сколько клиент хочет получать в день).
desired_daily_numbers INT
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0),
-- v8.19 (Plan 4): месячный счётчик доставленных лидов per-tenant.
-- Сбрасывается ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Europe/Moscow).
-- Используется PricingTierResolver на горячем пути RouteSupplierLeadJob
-- для O(1) lookup'а текущей ступени тарифа.
delivered_in_month INT NOT NULL DEFAULT 0
CHECK (delivered_in_month >= 0),
-- v8.5 (OPEN-И-19): hard-limit количества активных API-ключей.
-- Защита от DoS через создание тысяч ключей. Default 5 — комфортный
-- baseline; max 10 — для intg-партнёров. Hard limit, не soft warning.
@@ -1005,10 +1012,16 @@ CREATE TABLE lead_charges (
deal_received_at TIMESTAMPTZ NOT NULL,
tier_no SMALLINT NOT NULL,
price_per_lead_kopecks INTEGER NOT NULL,
charge_source VARCHAR(8) NOT NULL DEFAULT 'rub'
CHECK (charge_source IN ('prepaid','rub')),
charged_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE lead_charges
ADD CONSTRAINT chk_lead_charges_prepaid_zero_price
CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0);
-- Composite FK на deals(id, received_at) добавляется в самом конце файла,
-- ПОСЛЕ создания партиционированной deals (section 5). DEFERRABLE INITIALLY DEFERRED —
-- обязательно для атомарного INSERT deal+charge в одной транзакции.
@@ -1069,6 +1082,40 @@ CREATE INDEX supplier_sync_log_created_at_index ON supplier_sync_log(created_at)
-- REVOKE ALL ON supplier_sync_log FROM crm_app_user;
-- -----------------------------------------------------------------------------
-- supplier_csv_reconcile_log — журнал hourly CSV reconciliation (v8.19, Plan 4)
-- -----------------------------------------------------------------------------
-- SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log.
-- CsvReconcileJob записывает 1 строку на hourly run: started_at, окно,
-- метрики, drift_ratio. drift > 5% → email алерт; alert_email_sent_at timestamp.
-- Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_csv_reconcile_log (
id BIGSERIAL PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
window_start TIMESTAMPTZ NOT NULL,
window_end TIMESTAMPTZ NOT NULL,
total_csv_rows INTEGER,
matched_count INTEGER,
recovered_count INTEGER,
drift_ratio NUMERIC(5,4),
status VARCHAR(16) NOT NULL DEFAULT 'running'
CHECK (status IN ('running','ok','drift_alert','failed')),
error_message TEXT,
alert_email_sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX supplier_csv_reconcile_log_started_at_index
ON supplier_csv_reconcile_log(started_at DESC);
CREATE INDEX supplier_csv_reconcile_log_status_index
ON supplier_csv_reconcile_log(status)
WHERE status IN ('drift_alert','failed');
-- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser.
-- -----------------------------------------------------------------------------
-- project_suppliers — m2m связь "проект ↔ поставщики" (НОВАЯ в v8.2)
-- -----------------------------------------------------------------------------
@@ -1824,6 +1871,9 @@ CREATE TABLE supplier_leads (
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
processed_at TIMESTAMPTZ,
-- v8.19 (Plan 4 CSV reconcile): NULL для лидов из webhook (основной канал).
-- Заполняется CsvReconcileJob при восстановлении лида, пропущенного webhook'ом.
recovered_from_csv_at TIMESTAMPTZ,
deals_created_count INTEGER,
error TEXT,
@@ -1838,6 +1888,9 @@ CREATE TABLE supplier_leads (
CREATE INDEX idx_supplier_leads_received_at ON supplier_leads(received_at DESC);
CREATE INDEX idx_supplier_leads_supplier_project ON supplier_leads(supplier_project_id) WHERE supplier_project_id IS NOT NULL;
CREATE UNIQUE INDEX idx_supplier_leads_vid_unique ON supplier_leads(vid);
CREATE INDEX supplier_leads_recovered_from_csv_partial
ON supplier_leads(recovered_from_csv_at)
WHERE recovered_from_csv_at IS NOT NULL;
-- v8.18 (Plan 2/5): defense-in-depth — REVOKE ALL FROM crm_app_user.
-- SaaS-level таблица. tenant-приложение читать не должно (raw-payload — наша зона).