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>
103 lines
3.4 KiB
PHP
103 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Deal;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
/**
|
|
* Smoke-тесты Deal Eloquent-модели: composite PK (id, received_at),
|
|
* factory, связи. Под superuser (без RLS — отдельно в RlsSmokeTest).
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
test('DealFactory создаёт сделку с дефолтным status=new', function () {
|
|
$deal = Deal::factory()->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);
|
|
});
|