e8782c47b3
Reproduces 37 duplicate deals observed on prod 2026-05-25 for tenant client1.
After Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector, the race
between CsvReconcileJob (creates SupplierLead vid=null) and later webhook
retry (vid=int) results in two separate Deals because supplier_lead_deliveries
locks on supplier_lead_id (which differs between csv-recovery and webhook),
not on (phone, project_id).
Failing now — implementation comes in next commit.
292 lines
13 KiB
PHP
292 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\RouteSupplierLeadJob;
|
||
use App\Models\Deal;
|
||
use App\Models\LeadCharge;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierLead;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\Tenant;
|
||
use App\Services\Billing\LedgerService;
|
||
use App\Services\LeadDistributor;
|
||
use App\Services\LeadRouter;
|
||
use App\Services\NotificationService;
|
||
use App\Services\RegionTagResolver;
|
||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||
use Database\Seeders\PricingTierSeeder;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
/**
|
||
* Phase 2 — webhook ↔ CSV-recovered idempotency.
|
||
*
|
||
* Сценарий (наблюдался на prod 2026-05-25, 37 дублей tenant client1):
|
||
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
|
||
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
|
||
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
|
||
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
|
||
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
|
||
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
|
||
* → биллинг списывает второй раз.
|
||
*
|
||
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
|
||
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
|
||
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
|
||
*/
|
||
|
||
beforeEach(function (): void {
|
||
$this->seed(PricingTierSeeder::class);
|
||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||
|
||
// Shared supplier_project для всех тестов (B1, site, domain race-csv.ru).
|
||
$this->sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'race-csv.ru',
|
||
]);
|
||
|
||
$this->tenant = Tenant::factory()->create([
|
||
'balance_rub' => '10000.00',
|
||
'delivered_in_month' => 0,
|
||
]);
|
||
|
||
$this->project = Project::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'race-csv.ru',
|
||
'supplier_b1_project_id' => $this->sp->id,
|
||
'is_active' => true,
|
||
'daily_limit_target' => 100,
|
||
'effective_daily_limit_today' => 100,
|
||
'delivered_today' => 0,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
]);
|
||
|
||
linkProjectToSupplier($this->project, $this->sp);
|
||
});
|
||
|
||
/**
|
||
* Dispatch helper — mirrors runRouteJob() / dispatchJob() from other test files.
|
||
*/
|
||
function runRaceJob(int $supplierLeadId): void
|
||
{
|
||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||
app(LeadRouter::class),
|
||
app(SupplierProjectResolver::class),
|
||
app(NotificationService::class),
|
||
app(LedgerService::class),
|
||
app(LeadDistributor::class),
|
||
app(RegionTagResolver::class),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 1 — Main bug reproduction: CSV-recovery followed by webhook retry
|
||
// ДОЛЖЕН дать 1 deal + 1 charge (сейчас даёт 2+2 → FAILING).
|
||
// ---------------------------------------------------------------------------
|
||
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function (): void {
|
||
$phone = '79991000001';
|
||
|
||
// ── Step 1: CSV-recovered SupplierLead (vid=null, source='csv_recovery') ──
|
||
// Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика,
|
||
// но настоящего webhook_log'а нет → вид неизвестен (vid=null).
|
||
$csvLead = SupplierLead::factory()->create([
|
||
'platform' => 'B1',
|
||
'phone' => $phone,
|
||
'vid' => null,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'project' => 'B1_race-csv.ru',
|
||
'phone' => $phone,
|
||
'time' => now()->subHour()->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subHour(),
|
||
'recovered_from_csv_at' => now()->subHour(),
|
||
'source' => 'csv_recovery',
|
||
'processed_at' => null,
|
||
]);
|
||
|
||
// RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL.
|
||
runRaceJob($csvLead->id);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||
$csvDeal = Deal::where('phone', $phone)->first();
|
||
expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal');
|
||
expect($csvDeal->source_crm_id)->toBeNull('CSV-recovered deal должен иметь source_crm_id=NULL');
|
||
|
||
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||
expect($chargesAfterCsv)->toBe(1, 'После CSV-recovery должен быть ровно 1 LeadCharge');
|
||
|
||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||
|
||
// ── Step 2: поставщик ретраит webhook 15 мин спустя с настоящим vid ──
|
||
// Это то, что создаёт дубль на проде: новый SupplierLead с vid != null,
|
||
// phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal.
|
||
$webhookLead = SupplierLead::factory()->create([
|
||
'platform' => 'B1',
|
||
'phone' => $phone,
|
||
'vid' => 1672819986,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'vid' => 1672819986,
|
||
'project' => 'B1_race-csv.ru',
|
||
'phone' => $phone,
|
||
'time' => now()->subMinutes(15)->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subMinutes(15),
|
||
'source' => 'webhook',
|
||
'processed_at' => null,
|
||
]);
|
||
|
||
runRaceJob($webhookLead->id);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||
|
||
// ── Assertions ──
|
||
|
||
// Assertion 1: по-прежнему ОДИН deal, но source_crm_id теперь заполнен.
|
||
$deals = Deal::where('phone', $phone)->get();
|
||
expect($deals)->toHaveCount(1, 'Phase 2: webhook после CSV-recovery должен ОБНОВИТЬ существующий deal, а не создать второй');
|
||
expect($deals->first()->source_crm_id)->toBe(1672819986, 'source_crm_id должен быть обновлён от webhook vid');
|
||
|
||
// Assertion 2: НЕТ второго LeadCharge — биллинг не списывается дважды.
|
||
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||
expect($chargesAfterWebhook)->toBe(1, 'Phase 2: второй LeadCharge создан не должен быть');
|
||
|
||
// Assertion 3: баланс НЕ списан второй раз.
|
||
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
|
||
expect($balanceAfterWebhook)->toBe($balanceAfterCsv, 'Phase 2: баланс после webhook не должен уменьшиться');
|
||
|
||
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
|
||
// привязанных к ОДНОМУ deal_id.
|
||
$deliveries = DB::table('supplier_lead_deliveries')
|
||
->where('deal_id', $csvDeal->id)
|
||
->get();
|
||
expect($deliveries)->toHaveCount(2, 'Оба SupplierLead (csv + webhook) должны быть в supplier_lead_deliveries');
|
||
$deliveredLeadIds = $deliveries->pluck('supplier_lead_id')->sort()->values()->all();
|
||
expect($deliveredLeadIds)->toContain($csvLead->id);
|
||
expect($deliveredLeadIds)->toContain($webhookLead->id);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design).
|
||
// Наш Phase 2 fix НЕ должен блокировать это.
|
||
// ---------------------------------------------------------------------------
|
||
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function (): void {
|
||
$phone = '79991000002';
|
||
|
||
// Первый webhook, vid=100.
|
||
$lead1 = SupplierLead::factory()->create([
|
||
'platform' => 'B1',
|
||
'phone' => $phone,
|
||
'vid' => 100,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'vid' => 100,
|
||
'project' => 'B1_race-csv.ru',
|
||
'phone' => $phone,
|
||
'time' => now()->subHour()->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subHour(),
|
||
'source' => 'webhook',
|
||
'processed_at' => null,
|
||
]);
|
||
runRaceJob($lead1->id);
|
||
|
||
// Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект).
|
||
$lead2 = SupplierLead::factory()->create([
|
||
'platform' => 'B1',
|
||
'phone' => $phone,
|
||
'vid' => 200,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'vid' => 200,
|
||
'project' => 'B1_race-csv.ru',
|
||
'phone' => $phone,
|
||
'time' => now()->subMinutes(30)->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subMinutes(30),
|
||
'source' => 'webhook',
|
||
'processed_at' => null,
|
||
]);
|
||
runRaceJob($lead2->id);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||
|
||
// Spec B: оба webhook'а имеют source_crm_id != null.
|
||
// Условие merge (source_crm_id IS NULL) не срабатывает → два deal,
|
||
// два LeadCharge. Spec B Phase 1 (commit ccfecd5e) за повторы поставщика берём.
|
||
$deals = Deal::where('phone', $phone)->get();
|
||
expect($deals)->toHaveCount(2, 'Два webhook с разными vid должны создавать два deal (Spec B)');
|
||
$sourceCrmIds = $deals->pluck('source_crm_id')->sort()->values()->all();
|
||
expect($sourceCrmIds)->toContain(100);
|
||
expect($sourceCrmIds)->toContain(200);
|
||
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 3 — Boundary: CSV-recovered deal старше 24h НЕ мержится с новым webhook.
|
||
// Окно merge — 24h. Старый лид не считается «активным» duplicate.
|
||
// ---------------------------------------------------------------------------
|
||
it('csv-recovered deal older than 24h is NOT merged with new webhook', function (): void {
|
||
$phone = '79991000003';
|
||
|
||
// CSV-recovered SupplierLead, обработанный 2 дня назад.
|
||
$csvLead = SupplierLead::factory()->create([
|
||
'platform' => 'B1',
|
||
'phone' => $phone,
|
||
'vid' => null,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'project' => 'B1_race-csv.ru',
|
||
'phone' => $phone,
|
||
'time' => now()->subDays(2)->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subDays(2),
|
||
'recovered_from_csv_at' => now()->subDays(2),
|
||
'source' => 'csv_recovery',
|
||
'processed_at' => null,
|
||
]);
|
||
runRaceJob($csvLead->id);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||
$csvDeal = Deal::where('phone', $phone)->first();
|
||
expect($csvDeal)->not->toBeNull('CSV-recovered deal должен существовать');
|
||
|
||
// Сбросим processed_at у tenant-level проекта: delivered_today накопился,
|
||
// нужно сбросить счётчик чтобы второй deal тоже прошёл лимит.
|
||
$this->project->update(['delivered_today' => 0]);
|
||
|
||
// Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится.
|
||
$webhookLead = SupplierLead::factory()->create([
|
||
'platform' => 'B1',
|
||
'phone' => $phone,
|
||
'vid' => 999,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'vid' => 999,
|
||
'project' => 'B1_race-csv.ru',
|
||
'phone' => $phone,
|
||
'time' => now()->getTimestamp(),
|
||
],
|
||
'received_at' => now(),
|
||
'source' => 'webhook',
|
||
'processed_at' => null,
|
||
]);
|
||
runRaceJob($webhookLead->id);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||
|
||
// Два deal: старый CSV-recovered (2 дня назад) + новый от webhook.
|
||
// Merge НЕ происходит — CSV-recovered вне 24h окна.
|
||
$deals = Deal::where('phone', $phone)->get();
|
||
expect($deals)->toHaveCount(2, 'CSV-recovered deal старше 24h — merge не происходит, создаётся новый deal от webhook');
|
||
});
|