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: баланс не списан второй раз');
|
|||
|
|
});
|