4803fa0200
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>
71 lines
2.1 KiB
PHP
71 lines
2.1 KiB
PHP
<?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);
|
||
}
|
||
}
|