1ba25e6b4e
Закрыты 4 TODO в Webhook PoC. Job теперь полностью реализует §5.5
narrative ТЗ за исключением DuplicateDetector (Биз-19) и
SendNewLeadNotificationJob (Биз-20) — отдельные ветви.
5 новых Eloquent-моделей:
- app/app/Models/BalanceTransaction.php — списание lead_charge -1,
type-константы (TYPE_LEAD_CHARGE и т.д.)
- app/app/Models/ActivityLog.php — event=deal.created с
context.source=webhook, event-константы
- app/app/Models/RejectedDealsLog.php — zero_balance ветка вместо
Log::info (payload сохраняется для возможного восстановления)
- app/app/Models/SupplierLeadCost.php — composite PK (id, received_at),
snapshot cost_rub из suppliers, supplier_id resolves через
project_suppliers m2m (первый активный по sort_order)
- app/app/Models/Supplier.php — минимальная для FK target
Job-структура реструктурирована: handle() оркестрирует, делегирует в
logRejection() / chargeNewLead() / resolveSupplierId() / upsertDeal().
Все INSERT'ы в одной DB::transaction — атомарность Ю-2 (deal +
balance_transaction + supplier_lead_cost появляются вместе).
Graceful skip SupplierLeadCost если у проекта нет активного supplier
через project_suppliers + Log::warning. TODO для production: SystemSetting
fallback.
6 новых Pest-тестов в ProcessWebhookJobTest:
- BalanceTransaction lead_charge -1 для новой сделки
- Дубль vid НЕ создаёт BalanceTransaction
- ActivityLog event=deal.created с context.source=webhook
- RejectedDealsLog reason=zero_balance при balance_leads=0
- SupplierLeadCost snapshot cost_rub (helper seedSupplierForProject)
- SupplierLeadCost graceful skip без активного supplier
Pest 37/37 зелёные за 3.9 сек. Pint + Larastan чисто (ide-helper:models
регенерирован для 5 новых моделей).
CLAUDE.md v1.12 → v1.13. Реестр Открытые_вопросы v1.21 → v1.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
PHP
271 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\ProcessWebhookJob;
|
||
use App\Models\ActivityLog;
|
||
use App\Models\BalanceTransaction;
|
||
use App\Models\Deal;
|
||
use App\Models\Project;
|
||
use App\Models\RejectedDealsLog;
|
||
use App\Models\SupplierLeadCost;
|
||
use App\Models\Tenant;
|
||
use App\Models\WebhookDedupKey;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Тесты 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(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Создаёт активного поставщика и привязывает его к проекту через project_suppliers.
|
||
* Используется в тестах SupplierLeadCost-ветки.
|
||
*/
|
||
function seedSupplierForProject(Project $project, float $costRub = 50.00): int
|
||
{
|
||
$supplierId = (int) DB::table('suppliers')->insertGetId([
|
||
'code' => 'b1-test-'.Str::lower(Str::random(6)),
|
||
'name' => 'B1 Test',
|
||
'accepts_types' => '{websites,calls}',
|
||
'cost_rub' => $costRub,
|
||
'channel' => 'sites',
|
||
'quality_score' => 1.00,
|
||
'is_active' => true,
|
||
'sort_order' => 0,
|
||
]);
|
||
|
||
DB::table('project_suppliers')->insert([
|
||
'project_id' => $project->id,
|
||
'supplier_id' => $supplierId,
|
||
'is_active' => true,
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
return $supplierId;
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () {
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle();
|
||
|
||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||
$tx = BalanceTransaction::query()
|
||
->where('tenant_id', $tenant->id)
|
||
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
|
||
->first();
|
||
|
||
expect($tx)->not->toBeNull();
|
||
expect($tx->amount_leads)->toBe(-1);
|
||
expect($tx->balance_leads_after)->toBe(9);
|
||
expect($tx->related_type)->toBe(Deal::class);
|
||
expect($tx->related_id)->toBe($deal->id);
|
||
});
|
||
|
||
test('дубль vid НЕ создаёт BalanceTransaction', function () {
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||
$vid = 801;
|
||
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||
|
||
expect(BalanceTransaction::query()
|
||
->where('tenant_id', $tenant->id)
|
||
->count())->toBe(1);
|
||
});
|
||
|
||
test('новая сделка создаёт ActivityLog event=deal.created', function () {
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle();
|
||
|
||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||
$log = ActivityLog::query()
|
||
->where('tenant_id', $tenant->id)
|
||
->where('deal_id', $deal->id)
|
||
->first();
|
||
|
||
expect($log)->not->toBeNull();
|
||
expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED);
|
||
expect($log->user_id)->toBeNull();
|
||
expect($log->context)->toBe(['source' => 'webhook']);
|
||
});
|
||
|
||
test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () {
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle();
|
||
|
||
$rejected = RejectedDealsLog::query()
|
||
->where('tenant_id', $tenant->id)
|
||
->first();
|
||
|
||
expect($rejected)->not->toBeNull();
|
||
expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE);
|
||
expect($rejected->payload['vid'])->toBe(803);
|
||
});
|
||
|
||
test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () {
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'name' => 'Caranga', // совпадает с обрезанным project из payload
|
||
]);
|
||
$supplierId = seedSupplierForProject($project, costRub: 75.50);
|
||
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle();
|
||
|
||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||
$cost = SupplierLeadCost::query()
|
||
->where('deal_id', $deal->id)
|
||
->where('received_at', $deal->received_at)
|
||
->first();
|
||
|
||
expect($cost)->not->toBeNull();
|
||
expect($cost->supplier_id)->toBe($supplierId);
|
||
expect((string) $cost->cost_rub)->toBe('75.50');
|
||
expect($cost->supplier_lead_id)->toBe(804);
|
||
});
|
||
|
||
test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () {
|
||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||
|
||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle();
|
||
|
||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||
expect(SupplierLeadCost::query()
|
||
->where('deal_id', $deal->id)
|
||
->count())->toBe(0);
|
||
|
||
// Сделка всё равно создаётся, баланс списан, ActivityLog есть.
|
||
expect($deal)->not->toBeNull();
|
||
$tenant->refresh();
|
||
expect($tenant->balance_leads)->toBe(9);
|
||
});
|