c975e16a14
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
11 KiB
PHP
263 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Random\Randomizer;
|
||
|
||
/**
|
||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
|
||
* каскадной маршрутизацией по региону (lead region resolution §3.9).
|
||
*
|
||
* Eligibility — структурно через snapshot `project_routing_snapshots` за активную
|
||
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
|
||
* с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации берутся из
|
||
* snapshot; из live `projects` — только `delivered_today` (остаток лимита),
|
||
* из `tenants` — `balance_rub` + `frozen_by_balance_at` (live auto-pause).
|
||
*
|
||
* Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
|
||
* 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
|
||
* 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
|
||
* 3) запасной канал (без фильтра региона) — только если первые две пусты;
|
||
* сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
|
||
* Каждый Project помечается атрибутом `routing_step` (1/2/3).
|
||
*
|
||
* Отбор внутри фазы при кандидатах > cap — **взвешенный жребий по остатку лимита**
|
||
* (вариант D1=В): шанс ∝ остатку, но у каждого кандидата шанс > 0 (вес ≥ 1) —
|
||
* маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
|
||
* Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
|
||
*
|
||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||
*
|
||
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
|
||
* + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
|
||
*/
|
||
class LeadRouter
|
||
{
|
||
public function __construct(
|
||
private readonly Randomizer $randomizer = new Randomizer,
|
||
) {}
|
||
|
||
/**
|
||
* Возвращает ≤ cap проектов (по одному на tenant), отобранных каскадом
|
||
* по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
|
||
*
|
||
* @return Collection<int, Project>
|
||
*/
|
||
public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
|
||
{
|
||
$activeDate = $this->activeSnapshotDate();
|
||
$cap = LeadDistributor::CAP;
|
||
|
||
// Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
|
||
$exact = $resolvedSubjectCode !== null
|
||
? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
|
||
: collect();
|
||
$selected = $this->weightedPick($exact, $cap);
|
||
$this->tagStep($selected, 1);
|
||
|
||
if ($selected->count() >= $cap) {
|
||
return $selected->take($cap)->values();
|
||
}
|
||
|
||
// Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
|
||
$allRu = $this->queryCandidates(
|
||
$activeDate, $supplierProject, 'all_ru', null,
|
||
$selected->pluck('tenant_id')->all(),
|
||
);
|
||
$pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
|
||
$this->tagStep($pickedRu, 2);
|
||
$combined = $selected->concat($pickedRu);
|
||
|
||
if ($combined->isNotEmpty()) {
|
||
return $combined->take($cap)->values();
|
||
}
|
||
|
||
// Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
|
||
$fallback = $this->weightedPick(
|
||
$this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
|
||
$cap,
|
||
);
|
||
$this->tagStep($fallback, 3);
|
||
|
||
$this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);
|
||
|
||
return $fallback->take($cap)->values();
|
||
}
|
||
|
||
/**
|
||
* Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
|
||
* regionFilter ∈ exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
|
||
* упорядоченных по остатку лимита DESC, created_at, id; жребий — поверх в PHP.
|
||
*
|
||
* @param list<int> $excludeTenantIds
|
||
* @return Collection<int, Project>
|
||
*/
|
||
private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
|
||
{
|
||
$bindings = [$activeDate];
|
||
|
||
if ($sp->platform === 'DIRECT') {
|
||
// DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier.
|
||
$sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
|
||
$bindings[] = $sp->signal_type;
|
||
$bindings[] = $sp->unique_key;
|
||
} else {
|
||
$sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
|
||
WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
|
||
$bindings[] = $sp->id;
|
||
}
|
||
|
||
$regionWhere = '';
|
||
if ($regionFilter === 'exact') {
|
||
$regionWhere = 'AND ?::int = ANY(snap.regions)';
|
||
$bindings[] = $code;
|
||
} elseif ($regionFilter === 'all_ru') {
|
||
$regionWhere = "AND snap.regions = '{}'::int[]";
|
||
}
|
||
|
||
$excludeWhere = '';
|
||
if ($excludeTenantIds !== []) {
|
||
$placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
|
||
$excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
|
||
foreach ($excludeTenantIds as $tid) {
|
||
$bindings[] = $tid;
|
||
}
|
||
}
|
||
|
||
$sql = <<<SQL
|
||
SELECT DISTINCT ON (snap.tenant_id)
|
||
projects.*,
|
||
snap.daily_limit AS snapshot_daily_limit,
|
||
snap.regions AS snapshot_regions
|
||
FROM project_routing_snapshots snap
|
||
INNER JOIN projects ON projects.id = snap.project_id
|
||
WHERE snap.snapshot_date = ?::date
|
||
AND $sourceWhere
|
||
AND projects.delivered_today < snap.daily_limit
|
||
AND EXISTS (
|
||
SELECT 1 FROM tenants
|
||
WHERE tenants.id = snap.tenant_id
|
||
AND tenants.balance_rub > 0
|
||
AND tenants.frozen_by_balance_at IS NULL
|
||
)
|
||
$regionWhere
|
||
$excludeWhere
|
||
ORDER BY snap.tenant_id,
|
||
(snap.daily_limit - projects.delivered_today) DESC,
|
||
projects.created_at,
|
||
projects.id
|
||
SQL;
|
||
|
||
return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
|
||
}
|
||
|
||
/**
|
||
* Взвешенный жребий без возврата (вариант D1=В): отбирает ≤ $n кандидатов,
|
||
* вероятность ∝ остатку лимита, вес ≥ 1 у каждого (мелкие не отрезаются).
|
||
* При кандидатах ≤ $n — возвращает всех в исходном SQL-порядке (детерминизм).
|
||
*
|
||
* @param Collection<int, Project> $candidates
|
||
* @return Collection<int, Project>
|
||
*/
|
||
private function weightedPick(Collection $candidates, int $n): Collection
|
||
{
|
||
if ($n <= 0) {
|
||
return collect();
|
||
}
|
||
|
||
$pool = $candidates->values()->all();
|
||
if (count($pool) <= $n) {
|
||
return collect($pool);
|
||
}
|
||
|
||
$picked = [];
|
||
for ($i = 0; $i < $n && $pool !== []; $i++) {
|
||
$total = 0;
|
||
foreach ($pool as $p) {
|
||
$total += $this->weightOf($p);
|
||
}
|
||
|
||
$roll = $this->randomizer->getInt(1, $total);
|
||
$acc = 0;
|
||
$winner = 0;
|
||
foreach ($pool as $idx => $p) {
|
||
$acc += $this->weightOf($p);
|
||
if ($roll <= $acc) {
|
||
$winner = $idx;
|
||
break;
|
||
}
|
||
}
|
||
|
||
$picked[] = $pool[$winner];
|
||
array_splice($pool, $winner, 1);
|
||
}
|
||
|
||
return collect($picked);
|
||
}
|
||
|
||
private function weightOf(Project $project): int
|
||
{
|
||
$remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;
|
||
|
||
return max(1, $remaining);
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, Project> $projects
|
||
*/
|
||
private function tagStep(Collection $projects, int $step): void
|
||
{
|
||
foreach ($projects as $project) {
|
||
$project->setAttribute('routing_step', $step);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Активная дата слепка: до 21:00 МСК — сегодня, с 21:00 МСК — завтра (§4.2.3).
|
||
*/
|
||
private function activeSnapshotDate(): string
|
||
{
|
||
$msk = Carbon::now('Europe/Moscow');
|
||
|
||
return $msk->hour >= 21
|
||
? $msk->copy()->addDay()->toDateString()
|
||
: $msk->toDateString();
|
||
}
|
||
|
||
/**
|
||
* Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
|
||
* snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
|
||
* результат при наличии snapshot'ов — не алерт.
|
||
*
|
||
* @param array<int, mixed> $rows
|
||
*/
|
||
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
|
||
{
|
||
if ($rows !== []) {
|
||
return;
|
||
}
|
||
|
||
$snapshotEmpty = DB::connection('pgsql_supplier')
|
||
->table('project_routing_snapshots')
|
||
->where('snapshot_date', $activeDate)
|
||
->doesntExist();
|
||
|
||
if ($snapshotEmpty) {
|
||
Log::error('lead_router.no_snapshot_for_active_date', [
|
||
'active_date' => $activeDate,
|
||
'supplier_project_id' => $supplierProject->id,
|
||
'platform' => $supplierProject->platform,
|
||
]);
|
||
}
|
||
}
|
||
}
|