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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 — наша зона).
|
||||
|
||||
Reference in New Issue
Block a user