feat(billing-v2): supplier_lead_deliveries lock table (Spec B)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-23 20:22:27 +03:00
parent e1cc540d74
commit bc8afbc362
6 changed files with 129 additions and 4 deletions
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Замок «поставка клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
*
* @property int $supplier_lead_id
* @property int $tenant_id
* @property int|null $deal_id
* @property string $created_at
*/
class SupplierLeadDelivery extends Model
{
public $incrementing = false;
public $timestamps = false;
protected $primaryKey = null;
protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
}
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Idempotency guard: skip if table already exists (e.g. loaded via schema.sql).
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['supplier_lead_deliveries']) !== null) {
return;
}
$sql = file_get_contents(base_path('../db/migrations/2026_05_23_200_supplier_lead_deliveries.sql'));
if ($sql === false) {
throw new RuntimeException('Migration SQL file not found.');
}
DB::unprepared($sql);
}
public function down(): void
{
DB::unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
}
};
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
$cols = collect(DB::select(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
))->pluck('column_name')->all();
expect($cols)->toContain('supplier_lead_id')
->toContain('tenant_id')
->toContain('deal_id')
->toContain('created_at');
$pk = collect(DB::select(
"SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
))->pluck('attname')->sort()->values()->all();
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
$rls = DB::selectOne(
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
);
expect($rls->relrowsecurity)->toBeTrue();
});
+7 -2
View File
@@ -1,8 +1,13 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать девять записей в обратном хронологическом порядке (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.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.32, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.33, консолидированная — разворачивает БД с нуля).
## v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей (Phase 1)
- **+таблица `supplier_lead_deliveries`** (PK `supplier_lead_id`+`tenant_id`, FK на `supplier_leads` ON DELETE CASCADE, `deal_id` без FK — `deals` партиционирована, RLS `tenant_isolation`). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути (`RouteSupplierLeadJob`). INSERT-логика будет добавлена в следующем коммите.
- Метрики: +1 таблица, +1 RLS-политика. (Сверять с header `db/schema.sql`.)
**История записей:**
@@ -0,0 +1,18 @@
-- =============================================================================
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
-- разные поставки с одним телефоном остаются отдельными платными лидами.
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
-- при гонках / перезапусках задачи / CSV-восстановлении.
-- =============================================================================
CREATE TABLE supplier_lead_deliveries (
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (supplier_lead_id, tenant_id)
);
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
+21 -2
View File
@@ -1,12 +1,12 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.32 (23.05.2026 — balance_transactions.type +'migration' для Billing v2 Spec A конвертации balance_leads → balance_rub)
-- Версия: v8.33 (23.05.2026 — Billing v2 Spec B: +supplier_lead_deliveries замок поставка↔клиент)
-- Базовая версия: 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()))
-- Метрики: 74 базовые таблицы (65 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 41 RLS-политика / 5 функций / 15 триггеров
-- Метрики: 75 базовые таблицы (66 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 42 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 лимитов)
@@ -2046,6 +2046,25 @@ CREATE INDEX supplier_leads_recovered_from_csv_partial
--
-- REVOKE ALL ON supplier_leads FROM crm_app_user;
-- =============================================================================
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
-- разные поставки с одним телефоном остаются отдельными платными лидами.
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
-- при гонках / перезапусках задачи / CSV-восстановлении.
-- =============================================================================
CREATE TABLE supplier_lead_deliveries (
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (supplier_lead_id, tenant_id)
);
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
-- =============================================================================
-- 7. БИЛЛИНГ (SAAS-уровень)