d98fc3c834
Доказывает 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>
138 lines
6.1 KiB
PHP
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, 'у удалённого проекта сделка не создаётся');
|
|
});
|