create(['balance_leads' => 100, 'balance_rub' => '1000.00']); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => $dailyLimit, 'delivered_today' => $deliveredToday, 'delivery_days_mask' => 127, 'signal_type' => $sp->signal_type, 'signal_identifier' => $sp->unique_key, ]); linkProjectToSupplier($project, $sp); createRoutingSnapshotFromProject( $project, signalType: $sp->signal_type, signalIdentifier: $sp->unique_key, dailyLimit: $dailyLimit, regions: $regions, ); return $project; } function b1Supplier(string $key = 'ex.ru'): SupplierProject { return SupplierProject::query()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key, 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', ]); } it('step 1: exact region match wins, others excluded', function (): void { $sp = b1Supplier(); $spb = makeCascadeProject($sp, regions: '{83}'); // Питер $msk = makeCascadeProject($sp, regions: '{82}'); // Москва $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); expect($matched->pluck('id')->all())->toBe([$msk->id]) ->and($matched->first()->routing_step)->toBe(1); }); it('step 2: falls to all-RF when no exact match', function (): void { $sp = b1Supplier('s2.ru'); $allRu = makeCascadeProject($sp, regions: '{}'); // вся РФ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); expect($matched->pluck('id')->all())->toBe([$allRu->id]) ->and($matched->first()->routing_step)->toBe(2); }); it('step 3: fallback channel when nobody subscribed to region and no all-RF', function (): void { $sp = b1Supplier('s3.ru'); $spb = makeCascadeProject($sp, regions: '{83}'); // только Питер подписан // resolvedSubjectCode=82 (Москва): точных нет, «вся РФ» нет → запасной канал. $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); expect($matched->pluck('id')->all())->toBe([$spb->id]) ->and($matched->first()->routing_step)->toBe(3); }); it('exact + all-RF combine up to cap=3, exact taking priority', function (): void { $sp = b1Supplier('s4.ru'); $e1 = makeCascadeProject($sp, regions: '{82}'); $e2 = makeCascadeProject($sp, regions: '{82}'); $r1 = makeCascadeProject($sp, regions: '{}'); $r2 = makeCascadeProject($sp, regions: '{}'); $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); // Всего 3 (cap). Оба точных (step 1) обязаны быть; добор — ровно 1 «вся РФ» (step 2). expect($matched)->toHaveCount(3); $byStep = $matched->groupBy(fn ($p) => $p->routing_step); expect($byStep->get(1)->pluck('id')->sort()->values()->all())->toBe(collect([$e1->id, $e2->id])->sort()->values()->all()) ->and($byStep->get(2))->toHaveCount(1); expect(in_array($byStep->get(2)->first()->id, [$r1->id, $r2->id], true))->toBeTrue(); }); it('null resolvedSubjectCode skips exact, uses all-RF', function (): void { $sp = b1Supplier('s5.ru'); $allRu = makeCascadeProject($sp, regions: '{}'); $exact = makeCascadeProject($sp, regions: '{82}'); // Резолвер не сработал → шаг 1 пропускается; матчит только «вся РФ». $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: null); expect($matched->pluck('id')->all())->toBe([$allRu->id]) ->and($matched->first()->routing_step)->toBe(2); }); it('cascade works for DIRECT supplier_project path too', function (): void { $sp = SupplierProject::query()->create([ 'platform' => 'DIRECT', 'signal_type' => 'site', 'unique_key' => 'cashmotor.ru', 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', ]); $msk = makeCascadeProject($sp, regions: '{82}'); $spb = makeCascadeProject($sp, regions: '{83}'); $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); expect($matched->pluck('id')->all())->toBe([$msk->id]) ->and($matched->first()->routing_step)->toBe(1); }); it('backward compat: no second arg behaves as all-RF/any (existing call shape)', function (): void { $sp = b1Supplier('s7.ru'); $allRu = makeCascadeProject($sp, regions: '{}'); // Старая сигнатура (без 2-го аргумента) — дефолт null → шаг 2 all-RF матчит '{}'. $matched = seededRouter()->matchEligibleProjects($sp); expect($matched->pluck('id')->all())->toBe([$allRu->id]); }); it('variant В: weighted pick — small client never starved, big client wins more often', function (): void { $sp = b1Supplier('fair.ru'); // 5 клиентов на Москву, разный остаток лимита. $a = makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); // остаток 100 $b = makeCascadeProject($sp, regions: '{82}', dailyLimit: 50); $c = makeCascadeProject($sp, regions: '{82}', dailyLimit: 30); $d = makeCascadeProject($sp, regions: '{82}', dailyLimit: 20); $e = makeCascadeProject($sp, regions: '{82}', dailyLimit: 10); // остаток 10 — самый маленький $wins = []; $seedCount = 120; for ($seed = 0; $seed < $seedCount; $seed++) { $matched = seededRouter($seed)->matchEligibleProjects($sp, resolvedSubjectCode: 82); expect($matched)->toHaveCount(3); // лид всегда раздаётся ровно троим foreach ($matched as $p) { $wins[$p->id] = ($wins[$p->id] ?? 0) + 1; } } // (1) Мелкого не отрезаем: за 120 розыгрышей хотя бы раз получил лид. expect($wins[$e->id] ?? 0)->toBeGreaterThan(0); // (2) Вес уважается: крупный клиент выигрывает строго чаще мелкого. expect($wins[$a->id] ?? 0)->toBeGreaterThan($wins[$e->id] ?? 0); }); it('variant В: deterministic — same seed yields same recipients', function (): void { $sp = b1Supplier('det.ru'); makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); makeCascadeProject($sp, regions: '{82}', dailyLimit: 50); makeCascadeProject($sp, regions: '{82}', dailyLimit: 30); makeCascadeProject($sp, regions: '{82}', dailyLimit: 20); $first = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all(); $second = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all(); expect($first)->toBe($second)->and($first)->toHaveCount(3); });