Files
portal/app/app/Services/LeadRegionResolver.php
T
Дмитрий f94552d452 WIP чекпойнт: lead-region/supplier бэкенд + фронт-редизайн + Pint + тесты
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:17:12 +03:00

177 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\SupplierLead;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataPhoneResponse;
use App\Services\Dto\RegionResolution;
use App\Support\DaDataRegionMap;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Оркестратор резолва региона лида: DaData → Россвязь → tag-fallback (spec §3.3, §3.4).
*
* Каскад решений по qc:
* qc 0/3 + region не-ambiguous и маппится → source=dadata;
* qc 0/3 + region ambiguous/null/не-маппится → Россвязь (оператор от DaData сохраняем, §3.4.1);
* qc 1 / таймаут / 5xx / бюджет исчерпан → Россвязь;
* qc 2/7 → tag (Россвязь бессмысленна).
* Если ничего не дало код → source=tag (или unknown при пустом теге).
*
* Кэш по sha256(phone) (без сырого номера в ключе/значении, §7.3). Persistent-idempotency
* по supplier_leads.resolved_subject_code (защита от двойной оплаты DaData на retry, §3.11).
* Feature-flag services.dadata.enabled=false → сразу tag (текущее поведение, §6.5).
*/
class LeadRegionResolver
{
public function __construct(
private readonly DaDataPhoneClient $dadataClient,
private readonly DaDataBudgetGuard $budgetGuard,
private readonly RossvyazPrefixLookup $rossvyazLookup,
private readonly RegionTagResolver $tagResolver,
private readonly CacheRepository $cache,
) {}
public function resolve(SupplierLead $lead): RegionResolution
{
// Feature-flag: резолвер выключен → текущее tag-поведение.
if (! (bool) config('services.dadata.enabled', false)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
// Persistent-idempotency: уже резолвили на предыдущем try → без DaData.
if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
return RegionResolution::fromSupplierLead($lead);
}
$phone = (string) $lead->phone;
if (! preg_match('/^7\d{10}$/', $phone)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
$cacheKey = $this->cacheKey($phone);
$cached = $this->cache->get($cacheKey);
if ($cached instanceof RegionResolution) {
return $cached->withCacheHit(true);
}
$resolution = $this->doResolve($lead, $phone);
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
$this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays));
return $resolution;
}
private function doResolve(SupplierLead $lead, string $phone): RegionResolution
{
$start = microtime(true);
$provider = null;
$qc = null;
$masked = null;
// 1. DaData (под дневным бюджетом).
if ($this->budgetGuard->canSpend()) {
try {
$dadata = $this->dadataClient->cleanPhone($phone);
$this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
$qc = $dadata->qc;
$provider = $dadata->provider;
$masked = $this->maskResponse($dadata);
if (in_array($dadata->qc, [0, 3], true)) {
$region = (string) ($dadata->region ?? '');
if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) {
$code = DaDataRegionMap::toSubjectCode($region);
if ($code !== null) {
return RegionResolution::make(
$code, 'dadata',
operator: $provider, qc: $qc,
dadataMasked: $masked, durationMs: $this->ms($start),
);
}
// qc=0/3, но регион не маппится → страховка Россвязью.
}
// ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем).
} elseif ($dadata->qc === 2 || $dadata->qc === 7) {
// Мусор / иностранец → Россвязь не поможет, сразу tag.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
// qc=1 → Россвязь.
} catch (DaDataException) {
// Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем.
}
}
// 2. Россвязь.
$rossvyaz = $this->rossvyazLookup->find($phone);
if ($rossvyaz !== null) {
$code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region);
if ($code !== null) {
return RegionResolution::make(
$code, 'rossvyaz',
operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP)
qc: $qc, dadataMasked: $masked,
durationMs: $this->ms($start), rossvyazMatched: true,
);
}
}
// 3. Tag-fallback.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution
{
$tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : '');
$tagCode = $this->tagResolver->resolve($tag);
return RegionResolution::make(
$tagCode,
$tagCode !== null ? 'tag' : 'unknown',
operator: $provider,
qc: $qc,
dadataMasked: $masked,
durationMs: $start !== null ? $this->ms($start) : null,
);
}
private function cacheKey(string $phone): string
{
return 'phone-region:'.hash('sha256', $phone);
}
private function ms(float $start): int
{
return (int) round((microtime(true) - $start) * 1000);
}
/**
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
*/
private function maskResponse(DaDataPhoneResponse $response): array
{
$raw = $response->raw;
if (isset($raw['phone']) && is_string($raw['phone'])) {
$raw['phone'] = $this->maskPhone($raw['phone']);
}
return $raw;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}