f94552d452
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>
177 lines
7.2 KiB
PHP
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);
|
|
}
|
|
}
|