Files
portal/app/tests/Feature/LeadRouter/LeadFlowChangedDeletedTest.php
T
Дмитрий d98fc3c834 test/router: сквозной поток лида для изменённого и удалённого проекта (вопрос владельца)
Доказывает end-to-end (лид → RouteSupplierLeadJob → сделка):
- изменён источник, проект ЖИВ: лид по СТАРОМУ источнику доезжает до сделки (слепок
  сегодня помнит старый источник, INNER JOIN projects проходит);
- удалён проект: лид по его источнику НЕ падает в сироту и не роняет раздачу (INNER JOIN
  projects ON id=snap.project_id отсекает удалённый проект, сделка не создаётся).
2/2 зелёные. Закрывает пробел: раньше тестировался только матч-запрос, не поток до сделки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:21:55 +03:00

138 lines
6.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
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, SharesSupplierPdo::class);
/**
* Сквозная проверка потока лида (лид → сделка) для ИЗМЕНЁННОГО и УДАЛЁННОГО проекта.
* Вопрос владельца: «как идёт поток лидов по удалённым/изменённым проектам?»
*
* Инвариант матча: queryCandidates делает INNER JOIN projects ON projects.id = snap.project_id
* И snap.snapshot_date = сегодня. Отсюда:
* - изменён источник (проект ЖИВ): слепок сегодня помнит старый источник → лид доезжает до сделки;
* - удалён проект (строки projects нет): INNER JOIN отсекает → лид НЕ падает в сироту, без краша.
*/
beforeEach(function (): void {
// До 21:00 МСК → activeSnapshotDate = сегодня.
Carbon::setTestNow(Carbon::parse('2026-06-25 14:00:00', 'Europe/Moscow'));
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Новый матч по слепку ВКЛ — иначе хвост по старому источнику теряется (старый pivot-путь).
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
);
});
afterEach(fn () => Carbon::setTestNow());
function flowFixture(): array
{
$tenant = Tenant::factory()->create(['balance_rub' => '10000.00', 'delivered_in_month' => 0]);
$sp = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'sms', 'unique_key' => 'Caranga']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['Caranga'],
'sms_keyword' => null,
'supplier_b3_project_id' => $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($project, $sp);
// Слепок на СЕГОДНЯ с источником Caranga (фиксирует завтрашний заказ = сегодняшнюю раздачу).
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => Carbon::today('Europe/Moscow')->toDateString(),
'project_id' => $project->id,
'tenant_id' => $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(),
]);
return ['tenant' => $tenant, 'sp' => $sp, 'project' => $project];
}
function routeFlowLead(SupplierProject $sp, string $phone): SupplierLead
{
$lead = SupplierLead::factory()->create([
'platform' => 'B3',
'phone' => $phone,
'vid' => 700001,
'supplier_project_id' => $sp->id,
'raw_payload' => ['vid' => 700001, 'project' => 'B3_Caranga', 'phone' => $phone, 'time' => now()->getTimestamp()],
'received_at' => now(),
'source' => 'webhook',
'processed_at' => null,
]);
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
return $lead;
}
it('ИЗМЕНЁН источник: лид по СТАРОМУ источнику доезжает до сделки (проект жив)', function (): void {
$fix = flowFixture();
// Клиент сменил live-источник Caranga → NewSender (слепок сегодня всё ещё Caranga).
DB::table('projects')->where('id', $fix['project']->id)->update(['sms_senders' => json_encode(['NewSender'])]);
routeFlowLead($fix['sp'], '79161230001');
DB::statement("SET LOCAL app.current_tenant_id = '{$fix['tenant']->id}'");
expect(Deal::where('project_id', $fix['project']->id)->where('phone', '79161230001')->count())
->toBe(1, 'хвост по старому источнику должен создать сделку у живого проекта');
});
it('УДАЛЁН проект: лид по его источнику НЕ падает в сироту и не роняет раздачу', function (): void {
$fix = flowFixture();
// Жёсткое удаление проекта (как ProjectService::delete после grace) — слепок переживает (нет FK).
DB::table('projects')->where('id', $fix['project']->id)->delete();
// Раздача не должна бросить исключение (INNER JOIN отсекает удалённый проект).
routeFlowLead($fix['sp'], '79161230002');
// Дошли сюда без исключения = раздача не упала. Сделок нет — лид обработан без сироты.
expect(Deal::where('phone', '79161230002')->count())->toBe(0, 'у удалённого проекта сделка не создаётся');
});