Files
portal/app/app/Models/WebhookDedupKey.php
T
Дмитрий 4803fa0200 phase1(webhook): Deal/WebhookDedupKey + ProcessWebhookJob (advisory lock) — CTO-17 addendum
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) <noreply@anthropic.com>
2026-05-08 15:24:55 +03:00

71 lines
2.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Идемпотентность webhook'ов от crm.bp-gr.ru — отдельная не-партиционированная
* таблица с PRIMARY KEY (tenant_id, source_crm_id).
*
* v8.6 (CTO-17): архитектурная замена UNIQUE INDEX на партиционированной deals
* (PostgreSQL запрещает UNIQUE без partition key). См. db/schema.sql:1289 и
* §5.5 narrative ТЗ — двустадийная dedup-логика.
*
* Composite PK (tenant_id, source_crm_id). $incrementing=false, $primaryKey
* не задан — работаем через QueryBuilder. Eloquent-CRUD не используется
* (записи создаёт ProcessWebhookJob через DB::selectOne ON CONFLICT).
*
* Полезна для тестов и debug-запросов: WebhookDedupKey::where(...)->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<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}