b7379e7a03
Под флагом routing_match_by_snapshot=ВКЛ: CSV-recovered лид + догоняющий webhook одного физлида (sms-источник Caranga) сходятся в ОДИН Deal/charge. Доказывает шов §9b — новый матч по слепку не разводит project_id, merge срабатывает, баланс не списан дважды. Эпик 2 Task 2.7. baseline +4 записи (Pest higher-order $this-noise, как CsvWebhookRaceTest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.9 KiB
PHP
169 lines
6.9 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 Carbon\Carbon;
|
||
use Database\Seeders\PricingTierSeeder;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
/**
|
||
* Task 2.7 — страховка от двойного списания вокруг смены источника.
|
||
*
|
||
* Шов §9b «двойное списание»: переделка матча на слепок (Task 2.2/2.6) могла бы
|
||
* развести project_id у CSV-recovered лида и догоняющего webhook одного физического
|
||
* лида. Если бы они попали в РАЗНЫЕ проекты — merge по (tenant, phone, project_id)
|
||
* не сработал бы → второй Deal → второе списание.
|
||
*
|
||
* Тест гоняется с ФЛАГОМ ВКЛ (routing_match_by_snapshot=true), чтобы доказать:
|
||
* новый матч по слепку оставляет project_id сходящимся, merge срабатывает,
|
||
* списание ровно одно. Это страховка — она зелёная и ДО, и ПОСЛЕ переделки.
|
||
* Если позеленела до и покраснела после — матч развёл project_id (см. план §9b).
|
||
*/
|
||
beforeEach(function (): void {
|
||
$this->seed(PricingTierSeeder::class);
|
||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||
|
||
// Новый матч по слепку ВКЛ — тестируем риск §9b именно на нём.
|
||
DB::table('system_settings')->updateOrInsert(
|
||
['key' => 'routing_match_by_snapshot'],
|
||
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
|
||
);
|
||
|
||
// sms supplier_project — самая рискованная ветка матча (sms_senders[0]+keyword).
|
||
$this->sp = SupplierProject::factory()->create([
|
||
'platform' => 'B3',
|
||
'signal_type' => 'sms',
|
||
'unique_key' => 'Caranga',
|
||
]);
|
||
|
||
$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' => 'sms',
|
||
'signal_identifier' => null,
|
||
'sms_senders' => ['Caranga'],
|
||
'sms_keyword' => null,
|
||
'supplier_b3_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);
|
||
|
||
// Слепки на сегодня и завтра — несут sms-источник Caranga.
|
||
insertSmsSnapshotFor($this->project, Carbon::today('Europe/Moscow')->toDateString());
|
||
insertSmsSnapshotFor($this->project, Carbon::tomorrow('Europe/Moscow')->toDateString());
|
||
});
|
||
|
||
function insertSmsSnapshotFor(Project $project, string $date): void
|
||
{
|
||
DB::table('project_routing_snapshots')->insert([
|
||
'snapshot_date' => $date,
|
||
'project_id' => $project->id,
|
||
'tenant_id' => $project->tenant_id,
|
||
'daily_limit' => 100,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => '{}',
|
||
'signal_type' => 'sms',
|
||
'signal_identifier' => null,
|
||
'sms_senders' => json_encode(['Caranga']),
|
||
'sms_keyword' => null,
|
||
'expected_volume' => 100,
|
||
'delivered_count' => 0,
|
||
'created_at' => now(),
|
||
]);
|
||
}
|
||
|
||
function runNoDoubleChargeJob(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),
|
||
);
|
||
}
|
||
|
||
it('не списывает дважды когда CSV-recovery и webhook одного лида приходят вокруг смены источника', function (): void {
|
||
$phone = '79161234567';
|
||
|
||
// ── CSV-recovered лид (vid=null) → создаёт Deal ──
|
||
$csvLead = SupplierLead::factory()->create([
|
||
'platform' => 'B3',
|
||
'phone' => $phone,
|
||
'vid' => null,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'project' => 'B3_Caranga',
|
||
'phone' => $phone,
|
||
'time' => now()->subHour()->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subHour(),
|
||
'recovered_from_csv_at' => now()->subHour(),
|
||
'source' => 'csv_recovery',
|
||
'processed_at' => null,
|
||
]);
|
||
runNoDoubleChargeJob($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(LeadCharge::where('deal_id', $csvDeal->id)->count())->toBe(1);
|
||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||
|
||
// ── webhook того же физического лида (vid=int) приходит позже ──
|
||
$webhookLead = SupplierLead::factory()->create([
|
||
'platform' => 'B3',
|
||
'phone' => $phone,
|
||
'vid' => 555,
|
||
'supplier_project_id' => $this->sp->id,
|
||
'raw_payload' => [
|
||
'vid' => 555,
|
||
'project' => 'B3_Caranga',
|
||
'phone' => $phone,
|
||
'time' => now()->subMinutes(15)->getTimestamp(),
|
||
],
|
||
'received_at' => now()->subMinutes(15),
|
||
'source' => 'webhook',
|
||
'processed_at' => null,
|
||
]);
|
||
runNoDoubleChargeJob($webhookLead->id);
|
||
|
||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||
|
||
// ── ОДНА сделка, ОДНО списание, баланс не списан дважды ──
|
||
$deals = Deal::where('phone', $phone)->get();
|
||
expect($deals)->toHaveCount(1, '§9b: webhook после CSV merge'.'ится в один deal — project_id сошёлся под матчем по слепку');
|
||
expect((int) $deals->first()->source_crm_id)->toBe(555);
|
||
expect(LeadCharge::where('deal_id', $csvDeal->id)->count())->toBe(1, '§9b: второго списания нет');
|
||
expect((string) $this->tenant->fresh()->balance_rub)->toBe($balanceAfterCsv, '§9b: баланс не списан второй раз');
|
||
});
|