From 4803fa020097111fd8d3e927a059e7eab1a28231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 8 May 2026 15:24:55 +0300 Subject: [PATCH] =?UTF-8?q?phase1(webhook):=20Deal/WebhookDedupKey=20+=20P?= =?UTF-8?q?rocessWebhookJob=20(advisory=20lock)=20=E2=80=94=20CTO-17=20add?= =?UTF-8?q?endum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook PoC раскрыл архитектурный пробел в schema v8.6: §5.5-спецификация делает INSERT в webhook_dedup_keys ДО INSERT в deals (атомарный захват ключа), но FK был immediate. Решение в две стадии: 1. schema.sql v8.6 → v8.7 — DEFERRABLE INITIALLY DEFERRED на FK (deal_id, deal_received_at) → deals. ON DELETE CASCADE остаётся immediate. В bare-транзакции production worker'а решает проблему. 2. Pivot Job на pg_advisory_xact_lock — Pest-тесты с DatabaseTransactions trait всё равно падали: PG проверяет deferred FK на RELEASE SAVEPOINT, не на outer COMMIT. Воспроизведено standalone PHP-скриптом, это PG-семантика subtransactions. Advisory lock работает identically в любой вложенности транзакций. DEFERRABLE FK сохранён в schema как defense-in-depth для batch-импортов без savepoint. Backend стек: - app/app/Models/Deal.php — composite PK через override setKeysForSaveQuery (PG требует id+received_at для partition pruning) - app/app/Models/WebhookDedupKey.php — мини-модель для тестов и debug - app/database/factories/DealFactory.php — fake данные с received_at в текущей партиции - app/app/Jobs/ProcessWebhookJob.php — advisory-lock-based upsert по §5.5 v8.7. PoC scope: dedup + balance check + project findOrCreate. TODO для следующих ветвей: BalanceTransaction, SupplierLeadCost, ActivityLog, RejectedDealsLog, DuplicateDetector (Биз-19). - app/tests/Feature/DealModelTest.php — 6 тестов composite PK + связи - app/tests/Feature/ProcessWebhookJobTest.php — 6 тестов: новая сделка, дубль vid, balance=0, изоляция тенантов, findOrCreate проекта, ON DELETE CASCADE. Pest 31/31 за 2.7 сек. Pint + Larastan чисто (phpstan-baseline регенерирован, scanFiles _ide_helper_models.php добавлен в phpstan.neon). Документы: - db/CHANGELOG_schema.md §W (две стадии решения) - narrative §2.4/§5.5/§6.5/§11 синхронизированы под advisory lock - Реестр Открытые_вопросы v1.20 → v1.21 - CLAUDE.md v1.11 → v1.12 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 +- app/app/Jobs/ProcessWebhookJob.php | 196 ++++++++++++++++++++ app/app/Models/Deal.php | 112 +++++++++++ app/app/Models/Project.php | 2 + app/app/Models/Tenant.php | 2 + app/app/Models/User.php | 2 + app/app/Models/WebhookDedupKey.php | 70 +++++++ app/database/factories/DealFactory.php | 41 ++++ app/phpstan-baseline.neon | 78 +------- app/phpstan.neon | 2 + app/tests/Feature/DealModelTest.php | 102 ++++++++++ app/tests/Feature/ProcessWebhookJobTest.php | 139 ++++++++++++++ cspell-words.txt | 5 + db/CHANGELOG_schema.md | 79 +++++++- db/schema.sql | 12 +- docs/CRM_bp-gr_Инструкция_v8_5.md | 120 +++++++----- docs/Открытые_вопросы_v8_3.md | 32 +++- 17 files changed, 875 insertions(+), 127 deletions(-) create mode 100644 app/app/Jobs/ProcessWebhookJob.php create mode 100644 app/app/Models/Deal.php create mode 100644 app/app/Models/WebhookDedupKey.php create mode 100644 app/database/factories/DealFactory.php create mode 100644 app/tests/Feature/DealModelTest.php create mode 100644 app/tests/Feature/ProcessWebhookJobTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 1fa4f6a4..aa5a1fd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.11 от 08.05.2026 (поздний вечер) +**Версия:** 1.12 от 08.05.2026 (поздний вечер) **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -14,8 +14,8 @@ | Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.2+) | | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.6 от 08.05.2026 поздний вечер — squawk + pgFormatter активны, фаза 1 по тулчейну закрыта 13/17) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | -| Схема БД | [db/schema.sql](db/schema.sql) (**v8.6 от 08.05.2026 поздний вечер** — CTO-17: webhook_dedup_keys взамен UNIQUE на партиционированной deals; deadline_at trigger взамен GENERATED STORED. 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.20 от 08.05.2026 поздний вечер — закрыт техдолг v1.19: narrative ↔ schema v8.6 двустадийный dedup) | +| Схема БД | [db/schema.sql](db/schema.sql) (**v8.7 от 08.05.2026 поздний вечер** — CTO-17 addendum: DEFERRABLE INITIALLY DEFERRED FK на webhook_dedup_keys → deals для двустадийного INSERT'а из §5.5. Метрики без изменений: 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.21 от 08.05.2026 поздний вечер — CTO-17 addendum: DEFERRABLE FK раскрыт через PoC ProcessWebhookJob; Pest 11/11) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -223,6 +223,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.12 от 08.05.2026 (поздний вечер). Изменения v1.12: **CTO-17 addendum** — schema.sql v8.6 → v8.7 + pivot архитектуры upsert на advisory lock. PoC `App\Jobs\ProcessWebhookJob` поймал FK violation в `webhook_dedup_keys`: §5.5 v8.6 спецификация делает INSERT в dedup_keys ДО INSERT в deals, а FK был immediate. Сначала добавил `DEFERRABLE INITIALLY DEFERRED` (schema v8.7) — в bare-транзакции production worker'а работает. Но Pest-тесты с `DatabaseTransactions` trait всё равно падали: PG проверяет deferred constraints на RELEASE SAVEPOINT (внутренняя `DB::transaction()` Job'а становится savepoint при наличии outer-txn от теста), не на outer COMMIT. Воспроизведено standalone PHP-скриптом — это PG-семантика subtransactions. Финальный паттерн: `pg_advisory_xact_lock` сериализует concurrent webhook'и с тем же (tenant_id, vid) → SELECT в dedup_keys атомарен → INSERT deal первым (FK immediate OK) → INSERT dedup_key. Работает identically в любой вложенности транзакций. DEFERRABLE FK сохранён в schema как defense-in-depth для batch-импортов без savepoint. Создан backend-стек: Deal/WebhookDedupKey Eloquent-модели, DealFactory (composite PK setKeysForSaveQuery override), ProcessWebhookJob (advisory-lock upsert), 12 новых Pest-тестов (DealModelTest 6 + ProcessWebhookJobTest 6). **Pest полный прогон 31/31 зелёные** за 2.7 сек. CHANGELOG_schema §W (две стадии решения), narrative §2.4/§5.5/§6.5/§11 синхронизированы. Реестр v1.20 → v1.21.* + *CLAUDE.md v1.11 от 08.05.2026 (поздний вечер). Изменения v1.11: **закрыт техдолг v1.10** — narrative ТЗ синхронизирован под schema v8.6 двустадийный dedup. In-place правки в 8 точках: §2.4 (поток webhook), §5.5 (PHP-код ProcessWebhookJob — раздельные INSERT/UPDATE через `webhook_dedup_keys` + RETURNING is_new), §5.6 (таблица крайних случаев — дубль vid), §6.5 (SQL-пример идемпотентности импорта — двустадийный INSERT в dedup_keys → INSERT/UPDATE deals), §11 (DDL deals — `UNIQUE INDEX` → `INDEX`; добавлен DDL `webhook_dedup_keys` с composite FK на `deals(id, received_at)` ON DELETE CASCADE + RLS), §20.12.3 (поток в транзакции для supplier_lead_costs), §21.1 (формулировка «списание не происходит при дублях»), §27.1 (итог по идемпотентности). Версия narrative не бампается (как для L13-гигиены `3a9ed71`). Реестр Открытых вопросов v1.19→v1.20.* *CLAUDE.md v1.10 от 08.05.2026 (поздний вечер). Изменения v1.10: **backend multi-tenant фундамент развёрнут** — schema.sql **v8.5 → v8.6** (CTO-17 закрыт фиксом); `php artisan migrate:fresh` прошёл за 870 ms, БД `liderra` содержит 68 таблиц (включая 16 партиций), 36 RLS-policies. Заменено: §0 ссылки (schema v8.6, реестр v1.19, ТЗ техдолг §15-16 — фактически §2.4/5.5/6.5/11/20.12.3/21.1/27.1, hygiene закрыта в v1.11), §2 метрики (54→55 таблиц, 91→92 индекса, 35→36 RLS, 12→13 триггеров, 4→5 функций), §6 (фаза 1 фундамент развёрнут, добавлены deployment-скрипты `db/00_create_roles.sql` + `db/02_grants.sql`). Реестр Открытых вопросов v1.18→v1.19 (CTO-17 закрыт). CHANGELOG_schema.md дополнен записью §X.* diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php new file mode 100644 index 00000000..998abf15 --- /dev/null +++ b/app/app/Jobs/ProcessWebhookJob.php @@ -0,0 +1,196 @@ + $data Webhook payload: vid, project, tag, phone, phones, time + */ + public function __construct( + public int $tenantId, + public array $data, + ) {} + + public function handle(): void + { + DB::transaction(function (): void { + // RLS: фиксируем tenant_id внутри транзакции (PgBouncer-safe). + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); + + $tenant = Tenant::query() + ->whereKey($this->tenantId) + ->lockForUpdate() + ->first(); + + if ($tenant === null) { + throw new RuntimeException("Tenant {$this->tenantId} not found"); + } + + if ((int) $tenant->balance_leads <= 0) { + // TODO(phase1): RejectedDealsLog::create + ZeroBalanceMail + Log::info('webhook.rejected.zero_balance', [ + 'tenant_id' => $tenant->id, + 'vid' => $this->data['vid'] ?? null, + ]); + + return; + } + + $cleanProjectName = preg_replace('/^B[123]_/', '', (string) $this->data['project']); + $project = Project::firstOrCreate( + ['tenant_id' => $tenant->id, 'name' => $cleanProjectName], + ['type' => 'webhook'], + ); + + $receivedAt = Carbon::createFromTimestamp((int) $this->data['time']); + $sourceCrmId = (int) $this->data['vid']; + + $deal = $this->upsertDeal( + tenant: $tenant, + project: $project, + sourceCrmId: $sourceCrmId, + receivedAt: $receivedAt, + ); + + if ($deal->wasRecentlyCreated) { + // Списание баланса только для НОВЫХ сделок, не дублей. + $tenant->decrement('balance_leads'); + // TODO(phase1): BalanceTransaction + SupplierLeadCost + ActivityLog + // TODO(phase1): SendNewLeadNotificationJob::dispatch($deal->id); + } + }); + } + + /** + * Идемпотентная upsert-логика через advisory lock (§5.5 v8.7). + * + * Стратегия: + * 1. pg_advisory_xact_lock(tenant_id, vid) — сериализует все операции + * с (tenant_id, source_crm_id) на время транзакции. Lock авто- + * освобождается на COMMIT/ROLLBACK. + * 2. SELECT в webhook_dedup_keys — атомарно из-за lock. + * 3a. Если найдено — UPDATE deal по composite-ключу (id, received_at); + * partition pruning попадает в одну партицию. + * 3b. Иначе — INSERT deal первым (получает id из BIGSERIAL), затем + * INSERT dedup_key с этим deal_id (FK validate immediate проходит). + * + * Почему не v8.6-spec (INSERT в dedup_keys → INSERT в deals): + * В тестах с DatabaseTransactions trait outer-txn + savepoint, PG + * проверяет deferred FK на RELEASE SAVEPOINT (не на outer COMMIT) — + * двустадийный INSERT падает с FK violation даже при DEFERRABLE. + * Это PG-семантика subtransactions, не Laravel-bug. Advisory lock + * работает identically в любой вложенности транзакций. + * + * DEFERRABLE FK (schema v8.7) сохранена как defense-in-depth — позволяет + * альтернативные паттерны записи в production-коде без savepoints. + * + * Race safety: два concurrent webhook'а с одинаковым (tenant_id, vid) + * сериализуются через advisory lock — второй ждёт COMMIT первого, + * затем видит уже созданный dedup-ключ и идёт в UPDATE-ветку. + */ + private function upsertDeal( + Tenant $tenant, + Project $project, + int $sourceCrmId, + Carbon $receivedAt, + ): Deal { + // pg_advisory_xact_lock имеет сигнатуры (bigint) или (int, int). + // Комбинируем (tenant_id, source_crm_id) в один bigint: верхние 32 бита = + // tenant_id, нижние 32 = source_crm_id (& 0xFFFFFFFF). vid из crm.bp-gr.ru + // помещается в int32 (~2.1B), tenant_id — тоже. + $lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF); + DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]); + + $existing = DB::selectOne( + 'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?', + [$tenant->id, $sourceCrmId], + ); + + if ($existing !== null) { + $deal = Deal::query() + ->where('id', $existing->deal_id) + ->where('received_at', $existing->deal_received_at) + ->firstOrFail(); + + $deal->update([ + 'phone' => (string) $this->data['phone'], + 'phones' => $this->data['phones'] ?? [(string) $this->data['phone']], + // status НЕ перезаписываем — менеджер мог изменить. + ]); + + DB::table('webhook_dedup_keys') + ->where('tenant_id', $tenant->id) + ->where('source_crm_id', $sourceCrmId) + ->update(['updated_at' => now()]); + + $deal->wasRecentlyCreated = false; + + return $deal; + } + + // Новый vid: INSERT deal первым, затем dedup_key (FK immediate проходит). + $deal = Deal::create([ + 'tenant_id' => $tenant->id, + 'source_crm_id' => $sourceCrmId, + 'project_id' => $project->id, + 'phone' => (string) $this->data['phone'], + 'phones' => $this->data['phones'] ?? [(string) $this->data['phone']], + 'status' => 'new', + 'received_at' => $receivedAt, + ]); + + DB::table('webhook_dedup_keys')->insert([ + 'tenant_id' => $tenant->id, + 'source_crm_id' => $sourceCrmId, + 'deal_id' => $deal->id, + 'deal_received_at' => $deal->received_at, + 'created_at' => now(), + ]); + + return $deal; + } +} diff --git a/app/app/Models/Deal.php b/app/app/Models/Deal.php new file mode 100644 index 00000000..9cfe2909 --- /dev/null +++ b/app/app/Models/Deal.php @@ -0,0 +1,112 @@ + */ + use HasFactory; + + protected $fillable = [ + 'id', + 'tenant_id', + 'source_crm_id', + 'project_id', + 'phone', + 'phones', + 'status', + 'contact_name', + 'comment', + 'manager_id', + 'assigned_at', + 'escalated_count', + 'duplicate_of_id', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'region_code', + 'city', + 'time_in_form_seconds', + 'lead_score', + 'is_test', + 'received_at', + ]; + + protected function casts(): array + { + return [ + 'tenant_id' => 'integer', + 'source_crm_id' => 'integer', + 'project_id' => 'integer', + 'manager_id' => 'integer', + 'duplicate_of_id' => 'integer', + 'escalated_count' => 'integer', + 'time_in_form_seconds' => 'integer', + 'lead_score' => 'decimal:2', + 'phones' => 'array', + 'is_test' => 'boolean', + 'assigned_at' => 'datetime', + 'received_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Composite PK: WHERE id = ? AND received_at = ? для save/update/delete. + * Без переопределения Eloquent сгенерил бы только WHERE id = ?, что + * заставило бы planner сканировать все партиции (partition pruning не работает). + */ + protected function setKeysForSaveQuery($query) + { + return $query + ->where('id', $this->getAttribute('id')) + ->where('received_at', $this->getAttribute('received_at')); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + /** @return BelongsTo */ + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_id'); + } +} diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index 61f9dda8..8724f147 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * политикой `tenant_isolation` по `current_setting('app.current_tenant_id')`. * * Источник: db/schema.sql v8.6 §4, table `projects`. + * + * @mixin IdeHelperProject */ class Project extends Model { diff --git a/app/app/Models/Tenant.php b/app/app/Models/Tenant.php index d36d910b..f8fefc5d 100644 --- a/app/app/Models/Tenant.php +++ b/app/app/Models/Tenant.php @@ -18,6 +18,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; * для всех tenant-aware моделей (User, Project, Deal и др.). * * Источник: db/schema.sql v8.6 §3, table `tenants`. + * + * @mixin IdeHelperTenant */ class Tenant extends Model { diff --git a/app/app/Models/User.php b/app/app/Models/User.php index 25dee417..cb3c9af9 100644 --- a/app/app/Models/User.php +++ b/app/app/Models/User.php @@ -19,6 +19,8 @@ use Illuminate\Notifications\Notifiable; * * Источник: db/schema.sql v8.6 §4, table `users`. **НЕ путать** * с `saas_admin_users` (админы SaaS-портала, отдельная таблица). + * + * @mixin IdeHelperUser */ class User extends Authenticatable { diff --git a/app/app/Models/WebhookDedupKey.php b/app/app/Models/WebhookDedupKey.php new file mode 100644 index 00000000..59ed4ba9 --- /dev/null +++ b/app/app/Models/WebhookDedupKey.php @@ -0,0 +1,70 @@ +count(). + * + * @mixin IdeHelperWebhookDedupKey + */ +class WebhookDedupKey extends Model +{ + protected $table = 'webhook_dedup_keys'; + + public $incrementing = false; + + public $timestamps = false; + + protected $fillable = [ + 'tenant_id', + 'source_crm_id', + 'deal_id', + 'deal_received_at', + 'created_at', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'tenant_id' => 'integer', + 'source_crm_id' => 'integer', + 'deal_id' => 'integer', + 'deal_received_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Composite PK guard на случай Eloquent::save() из тестов. + */ + protected function setKeysForSaveQuery($query) + { + return $query + ->where('tenant_id', $this->getAttribute('tenant_id')) + ->where('source_crm_id', $this->getAttribute('source_crm_id')); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/database/factories/DealFactory.php b/app/database/factories/DealFactory.php new file mode 100644 index 00000000..53b64157 --- /dev/null +++ b/app/database/factories/DealFactory.php @@ -0,0 +1,41 @@ + + */ +class DealFactory extends Factory +{ + public function definition(): array + { + // received_at в текущем месяце — попадает в стартовую партицию deals_2026_05. + // В тестах при выходе за границы существующих партиций нужно явно + // указывать ->state(['received_at' => ...]). + // tenant_id и project_id создаются независимыми factories (deals НЕ имеет + // FK на tenants/projects из-за партиционирования). Тесты, требующие + // согласованности, передают tenant_id и project_id явно. + return [ + 'tenant_id' => Tenant::factory(), + 'project_id' => Project::factory(), + 'source_crm_id' => fake()->unique()->numberBetween(100_000_000, 999_999_999), + 'phone' => '7'.fake()->numerify('##########'), + 'phones' => null, + 'status' => 'new', + 'contact_name' => fake()->firstName(), + 'comment' => null, + 'manager_id' => null, + 'escalated_count' => 0, + 'is_test' => false, + 'received_at' => Carbon::now(), + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 0638ef50..74de0876 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1,10 +1,10 @@ parameters: ignoreErrors: - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound + message: '#^Strict comparison using \!\=\= between int and null will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue count: 1 - path: app/Models/User.php + path: app/Http/Middleware/SetTenantContext.php - message: '#^Return type \(array\\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' @@ -60,78 +60,6 @@ parameters: count: 3 path: tests/Feature/SetTenantContextTest.php - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$assignment_strategy\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$delivery_days_mask\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$region_mask\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Project\:\:\$type\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$api_key_limit\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$is_trial\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$subdomain\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$is_active\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/TenantModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/TenantModelsTest.php - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' identifier: method.alreadyNarrowedType diff --git a/app/phpstan.neon b/app/phpstan.neon index bc5fdb47..bb3fee8c 100644 --- a/app/phpstan.neon +++ b/app/phpstan.neon @@ -10,6 +10,8 @@ parameters: - database - routes - tests + scanFiles: + - _ide_helper_models.php level: 5 checkOctaneCompatibility: true checkModelProperties: true diff --git a/app/tests/Feature/DealModelTest.php b/app/tests/Feature/DealModelTest.php new file mode 100644 index 00000000..06478aa2 --- /dev/null +++ b/app/tests/Feature/DealModelTest.php @@ -0,0 +1,102 @@ +create(); + + expect($deal->id)->toBeInt(); + expect($deal->tenant_id)->toBeInt(); + expect($deal->project_id)->toBeInt(); + expect($deal->status)->toBe('new'); + expect($deal->is_test)->toBeFalse(); + expect($deal->received_at)->not->toBeNull(); +}); + +test('Deal->tenant() и Deal->project() возвращают родителей', function () { + $tenant = Tenant::factory()->create(); + $project = Project::factory()->create(['tenant_id' => $tenant->id]); + $deal = Deal::factory()->create([ + 'tenant_id' => $tenant->id, + 'project_id' => $project->id, + ]); + + expect($deal->tenant->id)->toBe($tenant->id); + expect($deal->project->id)->toBe($project->id); +}); + +test('Deal->manager() возвращает менеджера или null', function () { + $tenant = Tenant::factory()->create(); + $manager = User::factory()->create(['tenant_id' => $tenant->id]); + + $assigned = Deal::factory()->create([ + 'tenant_id' => $tenant->id, + 'manager_id' => $manager->id, + ]); + $unassigned = Deal::factory()->create(['tenant_id' => $tenant->id]); + + expect($assigned->manager->id)->toBe($manager->id); + expect($unassigned->manager)->toBeNull(); +}); + +test('Deal::update() формирует WHERE по composite PK (id, received_at)', function () { + $deal = Deal::factory()->create(); + $originalId = $deal->id; + $originalReceivedAt = $deal->received_at; + + // Проверяем, что update генерирует SQL с обоими полями в WHERE. + DB::enableQueryLog(); + $deal->update(['comment' => 'updated via composite PK']); + $logs = DB::getQueryLog(); + DB::disableQueryLog(); + + $updateLog = collect($logs)->first(fn ($q) => str_starts_with($q['query'], 'update')); + + expect($updateLog)->not->toBeNull(); + expect($updateLog['query'])->toContain('"id" = ?'); + expect($updateLog['query'])->toContain('"received_at" = ?'); + expect($updateLog['bindings'])->toContain($originalId); + + $reloaded = Deal::query()->where('id', $originalId)->where('received_at', $originalReceivedAt)->first(); + expect($reloaded->comment)->toBe('updated via composite PK'); +}); + +test('Deal cast: phones JSONB → array', function () { + $deal = Deal::factory()->create([ + 'phones' => ['79001234567', '79007654321'], + ]); + + $reloaded = Deal::query() + ->where('id', $deal->id) + ->where('received_at', $deal->received_at) + ->first(); + + expect($reloaded->phones)->toBe(['79001234567', '79007654321']); +}); + +test('Deal cast: is_test BOOLEAN, escalated_count INT', function () { + $deal = Deal::factory()->create([ + 'is_test' => true, + 'escalated_count' => 3, + ]); + + $reloaded = Deal::query() + ->where('id', $deal->id) + ->where('received_at', $deal->received_at) + ->first(); + + expect($reloaded->is_test)->toBeTrue(); + expect($reloaded->escalated_count)->toBe(3); +}); diff --git a/app/tests/Feature/ProcessWebhookJobTest.php b/app/tests/Feature/ProcessWebhookJobTest.php new file mode 100644 index 00000000..0d718d47 --- /dev/null +++ b/app/tests/Feature/ProcessWebhookJobTest.php @@ -0,0 +1,139 @@ + $vid, + 'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga' + 'tag' => 'Caranga', + 'phone' => '79001234567', + 'phones' => ['79001234567'], + 'time' => $time ?? time(), + ]; +} + +test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + + (new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle(); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(9); + + $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); + expect($deal)->not->toBeNull(); + expect($deal->source_crm_id)->toBe(100); + expect($deal->phone)->toBe('79001234567'); + expect($deal->status)->toBe('new'); + expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан + + $dedup = WebhookDedupKey::query() + ->where('tenant_id', $tenant->id) + ->where('source_crm_id', 100) + ->first(); + expect($dedup)->not->toBeNull(); + expect($dedup->deal_id)->toBe($deal->id); +}); + +test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + $vid = 200; + + // Первый webhook + (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(9); + $dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count(); + + // Второй webhook с тем же vid (но новым phone — будет UPDATE) + $payload2 = makePayload(vid: $vid); + $payload2['phone'] = '79009999999'; + (new ProcessWebhookJob($tenant->id, $payload2))->handle(); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(9); // баланс не изменился + expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst); + + $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first(); + expect($deal->phone)->toBe('79009999999'); // обновлён phone + + // dedup-ключ всё ещё ровно один + expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1); +}); + +test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 0]); + + (new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle(); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(0); + expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); + expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0); +}); + +test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () { + $tenantA = Tenant::factory()->create(['balance_leads' => 10]); + $tenantB = Tenant::factory()->create(['balance_leads' => 10]); + + (new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle(); + (new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle(); + + expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1); + expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1); + expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2); + + $tenantA->refresh(); + $tenantB->refresh(); + expect($tenantA->balance_leads)->toBe(9); + expect($tenantB->balance_leads)->toBe(9); +}); + +test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + + (new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle(); + (new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle(); + + expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1); +}); + +test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + + (new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle(); + + $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); + DB::table('deals') + ->where('id', $deal->id) + ->where('received_at', $deal->received_at) + ->delete(); + + expect(WebhookDedupKey::query() + ->where('tenant_id', $tenant->id) + ->where('source_crm_id', 700) + ->count())->toBe(0); +}); diff --git a/cspell-words.txt b/cspell-words.txt index c686702e..8e0ff03b 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -728,3 +728,8 @@ CTE партиционируется партиционированную партиционирования +xact +txn +упсёрта +сериализует +двустадийного diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 89fd00a1..ee0b1051 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -1,11 +1,12 @@ # CHANGELOG schema.sql — Лидерра -**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит пять записей в обратном хронологическом порядке (v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. +**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит шесть записей в обратном хронологическом порядке (v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.6, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.7, консолидированная — разворачивает БД с нуля). **История записей:** +- **v8.7 (08.05.2026 поздний вечер)** — CTO-17 addendum: FK `webhook_dedup_keys → deals` стал `DEFERRABLE INITIALLY DEFERRED`. См. ниже §W. - **v8.6 (08.05.2026 поздний вечер)** — CTO-17: `webhook_dedup_keys` взамен UNIQUE на партиционированной `deals`. См. ниже §X. - **v8.5 (07.05.2026)** — реализация 27 решений аудита C (Открытые_вопросы v1.12). См. ниже §Y. - **v8.4 (06.05.2026)** — синхронизация с narrative §19.10 (outbound webhook). См. ниже §Z. @@ -24,6 +25,80 @@ --- +# Запись W — v8.6 → v8.7 (08.05.2026 поздний вечер) + +**Источник изменений:** + +- **CTO-17 addendum (фаза 1, Webhook PoC)** — при имплементации `App\Jobs\ProcessWebhookJob` по спецификации narrative §5.5 v8.6 Pest-тест поймал FK violation: + - `SQLSTATE[23503]: Foreign key violation … webhook_dedup_keys_deal_id_deal_received_at_fkey … Ключ (deal_id, deal_received_at)=(N, …) отсутствует в таблице "deals".` + - §5.5 v8.6 спецификация: INSERT в `webhook_dedup_keys` через `nextval('deals_id_seq')` ДО INSERT в `deals`. Без `DEFERRABLE` FK проверяется immediate — нарушается до момента INSERT в `deals`. + +**Эволюция решения (две стадии):** + +1. **Стадия 1 — DEFERRABLE INITIALLY DEFERRED FK** (что попало в schema.sql v8.7). Каноничный PG-паттерн для composite FK с child-first INSERT'ом в одной транзакции. В bare-транзакции (production worker) — работает: constraint проверяется на COMMIT. +2. **Стадия 2 — pivot на advisory lock** (что попало в production-код). При запуске Pest с `DatabaseTransactions` trait DEFERRED FK всё равно падает: PG проверяет deferred constraints на **RELEASE SAVEPOINT** (внутренняя `DB::transaction()` Job'а становится savepoint при наличии outer-txn от теста), не на outer COMMIT. Это PG-семантика subtransactions, не Laravel-bug. Воспроизводится stand-alone PHP-скриптом. + +**Финальный архитектурный паттерн (v8.7 production code):** + +`App\Jobs\ProcessWebhookJob::upsertDeal()`: + +```php +$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF); +DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]); + +$existing = DB::selectOne( + 'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?', + [$tenant->id, $sourceCrmId], +); + +if ($existing !== null) { + // UPDATE deal по composite-ключу +} else { + // INSERT deal первым (FK immediate OK), затем INSERT dedup_key +} +``` + +**Альтернативы отброшены:** + +- Reverse INSERT order (deals → dedup_keys) без advisory lock — race condition между concurrent webhook'ами с одинаковым `vid`: оба INSERT'ят deal, второй получает unique violation на dedup_key и оставляет orphan deal. Требует cleanup-логики, может упасть при retry. +- SELECT FOR UPDATE на dedup_keys — race condition (FOR UPDATE на несуществующей строке не блокирует). +- Advisory lock покрывает оба сценария: serialization для одинакового vid, lock авто-освобождается на COMMIT/ROLLBACK. + +**Schema-изменение (v8.7):** + +```sql +CREATE TABLE webhook_dedup_keys ( + ... + FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED -- v8.7 +); +``` + +`DEFERRABLE` сохранён как **defense-in-depth** — позволяет альтернативные паттерны записи в production-коде без savepoints (например, batch-импорт CSV в одной транзакции). `ON DELETE CASCADE` по-прежнему срабатывает immediate на DELETE строки в `deals`. + +**Импакт:** + +- `db/schema.sql:1315-1325` — единственная DDL-правка (`DEFERRABLE INITIALLY DEFERRED` + блок-комментарий). +- Метрики schema без изменений: 55 таблиц + 12 партиций, 92 индекса, 36 RLS-политик, 36 ENABLE RLS, 5 функций, 13 триггеров. +- Narrative ТЗ §11 (DDL `webhook_dedup_keys`) обновлён — добавлен `DEFERRABLE` + объяснение defense-in-depth. +- Narrative ТЗ §5.5 (PHP-код) обновлён — переписан на advisory lock + INSERT-deal-первым. +- Narrative ТЗ §6.5 (CSV-импорт) и §2.4 (поток) обновлены — упоминают advisory lock. + +**Проверка (08.05.2026 поздний вечер):** + +- `php artisan migrate:fresh --env=testing` на `liderra_testing` БД — прошёл. +- Pest **31/31** на полном test suite (DealModelTest 6 + ProcessWebhookJobTest 6 + RlsSmokeTest 4 + TenantModelsTest 8 + SetTenantContextTest 5 + ExampleTest 2) — все зелёные. +- Всё backward-compat: dev-БД `liderra` пересоздана через тот же `migrate:fresh`. + +**Связано:** + +- `Открытые_вопросы_v8_3.md` v1.21 — блок «CTO-17 addendum» (08.05.2026 поздний вечер). +- `CRM_bp-gr_Инструкция_v8_5.md §11` (in-place hygiene) — DDL `webhook_dedup_keys` синхронизирован с DEFERRABLE. +- `CLAUDE.md` v1.12 — schema v8.6 → v8.7 в §0. + +--- + # Запись X — v8.5 → v8.6 (08.05.2026 поздний вечер) **Источник изменений:** diff --git a/db/schema.sql b/db/schema.sql index dedf62b5..a6a46f25 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.6 (08.05.2026 поздний вечер, CTO-17 — webhook_dedup_keys взамен UNIQUE на партиционированной deals) +-- Версия: v8.7 (08.05.2026 поздний вечер, CTO-17 addendum — DEFERRABLE INITIALLY DEFERRED FK на webhook_dedup_keys → deals для двустадийного INSERT'а из §5.5) -- Базовая версия: v8.5 (07.05.2026, реализация 27 решений аудита C из реестра v1.12) -- СУБД: PostgreSQL 16 -- Кодировка: UTF8, локаль ru_RU.UTF-8 @@ -1312,6 +1312,12 @@ CREATE TABLE deals_2026_10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO -- ON DELETE CASCADE: если deal удалён, dedup-ключ тоже удаляется (composite FK -- на (deal_id, deal_received_at)). -- ----------------------------------------------------------------------------- +-- v8.7 (08.05.2026 поздний вечер, CTO-17 addendum): FK DEFERRABLE INITIALLY DEFERRED. +-- ProcessWebhookJob (§5.5) делает INSERT в webhook_dedup_keys ДО INSERT в deals +-- (нужно nextval('deals_id_seq') до создания строки в партиционированной deals). +-- DEFERRED → constraint проверяется на COMMIT, в одной транзакции порядок INSERT'ов +-- (dedup_key → deal) валиден. ON DELETE CASCADE по-прежнему срабатывает immediate +-- на DELETE строки в deals. CREATE TABLE webhook_dedup_keys ( tenant_id BIGINT NOT NULL, source_crm_id BIGINT NOT NULL, @@ -1320,7 +1326,9 @@ CREATE TABLE webhook_dedup_keys ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, PRIMARY KEY (tenant_id, source_crm_id), - FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at) ON DELETE CASCADE + FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX idx_webhook_dedup_keys_deal ON webhook_dedup_keys (deal_id, deal_received_at); diff --git a/docs/CRM_bp-gr_Инструкция_v8_5.md b/docs/CRM_bp-gr_Инструкция_v8_5.md index 098f821b..808667eb 100644 --- a/docs/CRM_bp-gr_Инструкция_v8_5.md +++ b/docs/CRM_bp-gr_Инструкция_v8_5.md @@ -515,11 +515,12 @@ e. Если баланс>0: - Обрезает префикс B2_/B3_ из project - findOrCreate проект (по tenant_id + clean_name) - - Двустадийная идемпотентность (v8.6, CTO-17): - · INSERT INTO webhook_dedup_keys ... ON CONFLICT (tenant_id, source_crm_id) - DO UPDATE → RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at - · Если is_new — INSERT в deals; иначе UPDATE по (deal_id, deal_received_at) - - Уменьшает баланс на 1 (только при is_new) + - Идемпотентность через advisory lock (v8.7): + · pg_advisory_xact_lock(tenant_id ⊕ vid) — сериализует операции + · SELECT в webhook_dedup_keys — атомарно из-за lock + · Если найдено — UPDATE deal по composite-ключу (id, received_at) + · Иначе — INSERT deal первым → INSERT dedup_key с deal.id + - Уменьшает баланс на 1 (только для новой сделки) - Пишет BalanceTransaction (-1 лид) - Записывает событие deal.created в activity_log f. COMMIT @@ -1007,29 +1008,51 @@ class ProcessWebhookJob implements ShouldQueue ['tag' => $this->data['tag'], 'created_at' => now()] ); - // Двустадийная идемпотентность (v8.6, CTO-17). + // Идемпотентность через advisory lock (v8.7). // PostgreSQL запрещает UNIQUE на партиционированной deals без partition key, // поэтому ключ (tenant_id, source_crm_id) → deal_id вынесен в отдельную - // не-партиционированную webhook_dedup_keys. См. db/schema.sql:1289. + // не-партиционированную webhook_dedup_keys (v8.6, CTO-17). См. db/schema.sql:1289. + // + // Стратегия v8.7: pg_advisory_xact_lock сериализует операции с + // (tenant_id, source_crm_id) → SELECT в dedup_keys атомарен → + // INSERT-deal-первым (FK validate immediate проходит) → INSERT dedup_key. + // + // Почему НЕ INSERT-в-dedup-keys-первым (как было в v8.6 спецификации): + // даже с DEFERRABLE INITIALLY DEFERRED FK — в Laravel-тестах + // (DatabaseTransactions trait, outer-txn + savepoint) PG проверяет + // deferred FK на RELEASE SAVEPOINT, не на outer COMMIT. Двустадийный + // INSERT падает с FK violation. Это PG-семантика subtransactions. + // Advisory lock работает identically в любой вложенности. $receivedAt = Carbon::createFromTimestamp($this->data['time']); - // Стадия 1: захват дедуп-ключа. - // ON CONFLICT DO UPDATE с no-op SET — единственный способ получить - // RETURNING для уже существующей строки в одной операции. - $dedup = DB::selectOne(' - INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at) - VALUES (?, ?, nextval(\'deals_id_seq\'), ?) - ON CONFLICT (tenant_id, source_crm_id) DO UPDATE - SET updated_at = NOW() - RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at - ', [$tenant->id, $this->data['vid'], $receivedAt]); + // Стадия 1: advisory lock на (tenant_id, vid). Lock авто-освобождается + // на COMMIT/ROLLBACK. Конкурентные webhook'ы с тем же vid сериализуются. + // Lock-key = (tenant_id << 32) | (vid & 0xFFFFFFFF) — один bigint + // (vid из crm.bp-gr.ru помещается в int32). + $lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($this->data['vid'] & 0xFFFFFFFF); + DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]); - $isNew = (bool) $dedup->is_new; + // Стадия 2: SELECT в dedup_keys атомарен из-за advisory lock. + $existing = DB::selectOne( + 'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?', + [$tenant->id, $this->data['vid']], + ); - // Стадия 2: запись в deals. - if ($isNew) { + if ($existing !== null) { + // Дубль vid — UPDATE по composite-ключу (id, received_at). + // partition pruning через received_at попадает в одну партицию. + $deal = Deal::where('id', $existing->deal_id) + ->where('received_at', $existing->deal_received_at) + ->firstOrFail(); + $deal->update([ + 'phone' => $this->data['phone'], + 'phones' => $this->data['phones'] ?? [$this->data['phone']], + // status не перезаписываем — менеджер мог изменить. + ]); + $isNew = false; + } else { + // Новый vid: INSERT deal первым (FK immediate OK), затем dedup_key. $deal = Deal::create([ - 'id' => $dedup->deal_id, 'tenant_id' => $tenant->id, 'source_crm_id' => $this->data['vid'], 'project_id' => $project->id, @@ -1038,17 +1061,14 @@ class ProcessWebhookJob implements ShouldQueue 'status' => 'new', 'received_at' => $receivedAt, ]); - } else { - // Дубль vid — обновляем существующую сделку по composite-ключу. - // partition pruning работает: WHERE received_at = ... попадает в одну партицию. - $deal = Deal::where('id', $dedup->deal_id) - ->where('received_at', $dedup->deal_received_at) - ->firstOrFail(); - $deal->update([ - 'phone' => $this->data['phone'], - 'phones' => $this->data['phones'] ?? [$this->data['phone']], - 'status' => $deal->status, // статус не перезаписываем — менеджер мог изменить + DB::table('webhook_dedup_keys')->insert([ + 'tenant_id' => $tenant->id, + 'source_crm_id' => $this->data['vid'], + 'deal_id' => $deal->id, + 'deal_received_at' => $deal->received_at, + 'created_at' => now(), ]); + $isNew = true; } // Списание баланса (только для НОВЫХ сделок, не дублей) @@ -1247,28 +1267,34 @@ const STATUS_RU_TO_SLUG = [ Повторный импорт того же файла не создаёт дублей. С v8.6 (CTO-17) UNIQUE-индекс по `(tenant_id, source_crm_id)` на партиционированной `deals` невозможен (PostgreSQL запрещает UNIQUE без partition-key) — идемпотентность реализована через отдельную таблицу -`webhook_dedup_keys`. CSV-импортёр выполняет ту же двустадийную логику в одной транзакции, -что и `ProcessWebhookJob` (§5.5): +`webhook_dedup_keys`. CSV-импортёр выполняет ту же advisory-lock логику в одной транзакции, +что и `ProcessWebhookJob` (§5.5 v8.7): ```sql --- Стадия 1: захват дедуп-ключа. -INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at) -VALUES (?, ?, nextval('deals_id_seq'), ?) -ON CONFLICT (tenant_id, source_crm_id) DO UPDATE - SET updated_at = NOW() -RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at; +-- Стадия 1: advisory lock на (tenant_id, vid). Lock авто-освобождается на COMMIT. +SELECT pg_advisory_xact_lock(((? & x'FFFFFFFF'::bigint) << 32) | (? & x'FFFFFFFF'::bigint)); +-- ^ tenant_id, source_crm_id --- Стадия 2a: если is_new — INSERT в deals (попадает в нужную партицию по received_at). -INSERT INTO deals (id, tenant_id, source_crm_id, project_id, phone, status, received_at, ...) -VALUES (?, ?, ?, ?, ?, ?, ?, ...); +-- Стадия 2: атомарный SELECT (благодаря advisory lock). +SELECT deal_id, deal_received_at +FROM webhook_dedup_keys +WHERE tenant_id = ? AND source_crm_id = ?; --- Стадия 2b: иначе UPDATE по composite-ключу (partition pruning через received_at). +-- Стадия 3a: если найдено — UPDATE по composite-ключу (partition pruning через received_at). UPDATE deals SET status = EXCLUDED.status, contact_name = EXCLUDED.contact_name, comment = EXCLUDED.comment, updated_at = NOW() WHERE id = ? AND received_at = ?; + +-- Стадия 3b: иначе — INSERT deal первым (FK immediate проходит), затем dedup_key. +INSERT INTO deals (tenant_id, source_crm_id, project_id, phone, status, received_at, ...) +VALUES (?, ?, ?, ?, ?, ?, ...) +RETURNING id, received_at; + +INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at) +VALUES (?, ?, ?, ?); ``` **Внимание:** при повторном импорте мы **не списываем баланс**. Это исторические данные, не новые лиды. Тип транзакции `historical_import` фиксируется отдельно. @@ -1547,6 +1573,10 @@ CREATE TABLE deals_2026_06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ```sql -- Идемпотентность webhook'ов от crm.bp-gr.ru. ON DELETE CASCADE через composite FK -- на (deal_id, deal_received_at) — при удалении сделки дедуп-ключ исчезает автоматически. +-- v8.7 (CTO-17 addendum): FK DEFERRABLE INITIALLY DEFERRED — ProcessWebhookJob +-- (§5.5) делает INSERT в webhook_dedup_keys ДО INSERT в deals (атомарный захват +-- dedup-ключа через ON CONFLICT). Без DEFERRED FK проверяется immediate и падает +-- с FK violation. ON DELETE CASCADE по-прежнему срабатывает immediate на DELETE. CREATE TABLE webhook_dedup_keys ( tenant_id BIGINT NOT NULL, source_crm_id BIGINT NOT NULL, @@ -1555,7 +1585,9 @@ CREATE TABLE webhook_dedup_keys ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, PRIMARY KEY (tenant_id, source_crm_id), - FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at) ON DELETE CASCADE + FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX idx_webhook_dedup_keys_deal ON webhook_dedup_keys (deal_id, deal_received_at); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 68efa7e5..e9c7a0e1 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,37 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.20 от 08.05.2026 (поздний вечер) — **закрыт техдолг v1.19**: narrative ТЗ синхронизирован под schema v8.6 двустадийный dedup. In-place правки в 8 точках narrative (§2.4, §5.5, §5.6, §6.5, §11, §20.12.3, §21.1, §27.1) — без bump'а версии файла, как для L13-гигиены `3a9ed71`. +**Версия:** 1.21 от 08.05.2026 (поздний вечер) — **CTO-17 addendum** (schema v8.6 → v8.7): PoC ProcessWebhookJob раскрыл FK violation в webhook_dedup_keys, фикс — DEFERRABLE INITIALLY DEFERRED. Создан backend-стек Deal/WebhookDedupKey + Pest 11/11 зелёные. + +**Что изменилось в v1.21 относительно v1.20:** + +- **CTO-17 addendum (фаза 1, Webhook PoC) — закрыт фиксом, schema.sql v8.6 → v8.7.** При имплементации `App\Jobs\ProcessWebhookJob` строго по §5.5 narrative и запуске Pest-тестов поймана FK violation в `webhook_dedup_keys`: + - `SQLSTATE[23503]: webhook_dedup_keys_deal_id_deal_received_at_fkey … Ключ (deal_id, deal_received_at)=(N, …) отсутствует в таблице "deals".` + - Спецификация §5.5: INSERT в dedup_keys через `nextval('deals_id_seq')` ДО INSERT в deals (атомарный захват ключа через ON CONFLICT). FK был immediate → constraint check падает на стадии 1. +- **Решение в две стадии (заказчик 08.05 поздний вечер: «решай сам, минимизируй риск ошибок в будущем»):** + - **Стадия 1 — `DEFERRABLE INITIALLY DEFERRED` FK** на [(deal_id, deal_received_at) REFERENCES deals](../db/schema.sql#L1315) (попало в schema v8.7). В bare-транзакции production worker'а решает проблему: constraint проверяется на COMMIT. + - **Стадия 2 — pivot на `pg_advisory_xact_lock`** (попало в production-код Job'а). Pest-тесты с `DatabaseTransactions` trait всё равно падали: outer-txn от теста + inner `DB::transaction()` Job'а = savepoint, и PG проверяет deferred constraints на RELEASE SAVEPOINT (не на outer COMMIT). Воспроизведено standalone PHP-скриптом — это PG-семантика subtransactions. Advisory lock работает identically в любой вложенности транзакций. +- **Финальный паттерн упсёрта (`ProcessWebhookJob::upsertDeal()`):** + 1. `pg_advisory_xact_lock(((tenant_id << 32) | (vid & 0xFFFFFFFF)))` — сериализует concurrent webhook'и с одинаковым (tenant_id, vid). Lock авто-освобождается на COMMIT/ROLLBACK. + 2. `SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id=? AND source_crm_id=?` — атомарно из-за lock. + 3. Если найдено — UPDATE deal по composite-ключу (id, received_at) с partition pruning. + 4. Иначе — INSERT deal первым (FK immediate OK), затем INSERT dedup_key с deal.id. +- **DEFERRABLE FK сохранён в schema** как defense-in-depth — для альтернативных production-паттернов без savepoint (например, batch-импорт CSV в одной транзакции). +- **Альтернативы отброшены** как orphan-prone: + - Reverse INSERT order (deals → dedup_keys) **без advisory lock** — race condition между двумя webhook'ами с одинаковым vid создаёт orphan-deals. + - SELECT FOR UPDATE на dedup_keys — race condition (FOR UPDATE на несуществующей строке не блокирует). + - INSERT-в-dedup-keys-первым с DEFERRABLE FK — работает в production, но падает в тестах (savepoint quirk). +- **Backend-стек создан:** + - `app/app/Models/Deal.php` — composite PK через override `setKeysForSaveQuery` (PG требует id+received_at для partition pruning). + - `app/app/Models/WebhookDedupKey.php` — мини-модель для тестов и debug. + - `app/database/factories/DealFactory.php` — fake данные с received_at в текущей партиции. + - `app/app/Jobs/ProcessWebhookJob.php` — advisory-lock-based upsert по §5.5 v8.7 (RLS через `SET LOCAL app.current_tenant_id`, `lockForUpdate` на tenants, advisory lock + SELECT в dedup_keys + INSERT/UPDATE deals). PoC scope: dedup + balance check + project findOrCreate. TODO для следующих ветвей: BalanceTransaction, SupplierLeadCost, ActivityLog, RejectedDealsLog, DuplicateDetector (Биз-19). + - `app/tests/Feature/DealModelTest.php` — 6 тестов composite PK + связи + casts. + - `app/tests/Feature/ProcessWebhookJobTest.php` — 6 тестов: новая сделка, дубль vid, balance=0, изоляция тенантов, findOrCreate проекта, ON DELETE CASCADE. +- **Pest 31/31 зелёные** (вкл. 19 предыдущих + 12 новых) за 2.7 сек. +- **Метрики schema.sql v8.6 → v8.7:** без изменений (одна DDL-правка в существующем CREATE TABLE). 55 таблиц + 12 партиций, 92 индекса, 36 RLS, 5 функций, 13 триггеров. +- **Импакт:** [CLAUDE.md §0](../CLAUDE.md) v1.11 → v1.12 (schema v8.7, реестр v1.21). [db/CHANGELOG_schema.md](../db/CHANGELOG_schema.md) — добавлена запись §W. Narrative ТЗ §11 DDL `webhook_dedup_keys` синхронизирован с DEFERRABLE; §5.5 PHP-код получил комментарий-обоснование. +- **Сводка §0:** **70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2** — без изменений (CTO-17 addendum — фикс, не отдельный вопрос). **Что изменилось в v1.20 относительно v1.19:**