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>
140 lines
5.7 KiB
PHP
140 lines
5.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\ProcessWebhookJob;
|
|
use App\Models\Deal;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use App\Models\WebhookDedupKey;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
|
|
*
|
|
* Проверяет ключевую архитектурную инвариант: один и тот же vid должен
|
|
* обновлять существующую сделку (а не создавать дубль), и баланс должен
|
|
* списываться ровно один раз. См. narrative ТЗ §5.5.
|
|
*
|
|
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
|
|
* trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
|
|
* корректно обрабатывает nested через savepoints.
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
function makePayload(int $vid = 432176649, ?int $time = null): array
|
|
{
|
|
return [
|
|
'vid' => $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);
|
|
});
|