diff --git a/app/app/Services/DaData/DaDataBudgetGuard.php b/app/app/Services/DaData/DaDataBudgetGuard.php new file mode 100644 index 00000000..8d66ef1d --- /dev/null +++ b/app/app/Services/DaData/DaDataBudgetGuard.php @@ -0,0 +1,47 @@ +`. + * `Cache::increment` на redis-сторе атомарен (INCRBY) — корректно при параллельных + * RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые. + * + * При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3). + */ +class DaDataBudgetGuard +{ + public function canSpend(): bool + { + $capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100; + + return $this->spentTodayKopecks() < $capKopecks; + } + + public function recordSpend(int $kopecks): void + { + if ($kopecks <= 0) { + return; + } + + $key = $this->dailyKey(); + Cache::add($key, 0, now()->addDays(2)); + Cache::increment($key, $kopecks); + } + + public function spentTodayKopecks(): int + { + return (int) Cache::get($this->dailyKey(), 0); + } + + private function dailyKey(): string + { + return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d'); + } +} diff --git a/app/app/Services/DaData/DaDataException.php b/app/app/Services/DaData/DaDataException.php new file mode 100644 index 00000000..b67eb35f --- /dev/null +++ b/app/app/Services/DaData/DaDataException.php @@ -0,0 +1,13 @@ + ; X-Secret: ; body [""] + * + * Retry — только на сетевые ошибки и 5xx (4xx → сразу DaDataException, без retry). + * Сеть/таймаут после исчерпания retry → DaDataTimeoutException; 5xx → DaDataException. + * Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory). + */ +class DaDataPhoneClient +{ + private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone'; + + public function __construct( + private readonly HttpFactory $http, + ) {} + + public function cleanPhone(string $phone): DaDataPhoneResponse + { + $cfg = (array) config('services.dadata'); + $timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000)); + $attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1); + $apiKey = (string) ($cfg['api_key'] ?? ''); + $secret = (string) ($cfg['secret'] ?? ''); + + $lastException = null; + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + try { + $response = $this->http + ->asJson() + ->acceptJson() + ->timeout($timeoutSec) + ->withHeaders([ + 'Authorization' => 'Token '.$apiKey, + 'X-Secret' => $secret, + ]) + ->post(self::URL, [$phone]); + } catch (ConnectionException $e) { + $lastException = new DaDataTimeoutException( + 'DaData connection failed: '.$e->getMessage(), 0, $e, + ); + + continue; // сеть → retry + } + + if ($response->serverError()) { + $lastException = new DaDataException('DaData 5xx: HTTP '.$response->status()); + + continue; // 5xx → retry + } + + if (! $response->successful()) { + // 4xx — клиентская ошибка, retry бессмыслен. + throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body()); + } + + return $this->parse($response->json()); + } + + throw $lastException ?? new DaDataException('DaData failed without a response'); + } + + /** + * @param mixed $body декодированный JSON (ожидается массив с одним объектом) + */ + private function parse($body): DaDataPhoneResponse + { + $row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : []; + + return new DaDataPhoneResponse( + qc: isset($row['qc']) ? (int) $row['qc'] : null, + qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null, + type: isset($row['type']) ? (string) $row['type'] : null, + phone: isset($row['phone']) ? (string) $row['phone'] : null, + provider: isset($row['provider']) ? (string) $row['provider'] : null, + region: isset($row['region']) ? (string) $row['region'] : null, + city: isset($row['city']) ? (string) $row['city'] : null, + timezone: isset($row['timezone']) ? (string) $row['timezone'] : null, + raw: $row, + ); + } +} diff --git a/app/app/Services/DaData/DaDataPhoneResponse.php b/app/app/Services/DaData/DaDataPhoneResponse.php new file mode 100644 index 00000000..3135c095 --- /dev/null +++ b/app/app/Services/DaData/DaDataPhoneResponse.php @@ -0,0 +1,26 @@ + $raw полный сырой объект ответа (для маскированного лога) + */ + public function __construct( + public readonly ?int $qc, + public readonly ?int $qcConflict, + public readonly ?string $type, + public readonly ?string $phone, + public readonly ?string $provider, + public readonly ?string $region, + public readonly ?string $city, + public readonly ?string $timezone, + public readonly array $raw, + ) {} +} diff --git a/app/app/Services/DaData/DaDataQualityCode.php b/app/app/Services/DaData/DaDataQualityCode.php new file mode 100644 index 00000000..7cc7ae39 --- /dev/null +++ b/app/app/Services/DaData/DaDataQualityCode.php @@ -0,0 +1,27 @@ + + */ + public const AMBIGUOUS_REGIONS = [ + 'Санкт-Петербург и область', + 'Москва и область', + ]; + + /** + * Ручные переопределения для имён DaData, не совпадающих с RussianRegions. + * На старте пуст — заполняется по findings со staging-smoke. + * + * @var array + */ + public const OVERRIDES = []; + + public static function toSubjectCode(string $name): ?int + { + $name = trim($name); + if ($name === '') { + return null; + } + + return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null; + } + + public static function isAmbiguous(string $name): bool + { + return in_array(trim($name), self::AMBIGUOUS_REGIONS, true); + } +} diff --git a/app/config/services.php b/app/config/services.php index 20f1e7e9..1f046757 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -42,4 +42,17 @@ return [ 'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'), ], + // DaData phone cleaner — резолв региона лида по телефону (lead region resolution). + // Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки. + 'dadata' => [ + 'api_key' => env('DADATA_API_KEY'), + 'secret' => env('DADATA_SECRET'), + 'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000), + 'retries' => (int) env('DADATA_RETRIES', 1), + 'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000), + 'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу + 'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL), + 'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30), + ], + ]; diff --git a/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php b/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php new file mode 100644 index 00000000..e1939d56 --- /dev/null +++ b/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php @@ -0,0 +1,42 @@ + 10]); // 1000 копеек + $guard = app(DaDataBudgetGuard::class); + + expect($guard->canSpend())->toBeTrue(); + + $guard->recordSpend(500); + + expect($guard->canSpend())->toBeTrue() + ->and($guard->spentTodayKopecks())->toBe(500); +}); + +it('blocks spend once the daily cap is reached', function (): void { + config(['services.dadata.daily_cap_rub' => 1]); // 100 копеек + $guard = app(DaDataBudgetGuard::class); + + $guard->recordSpend(100); + + expect($guard->canSpend())->toBeFalse(); +}); + +it('accumulates spend across multiple calls', function (): void { + config(['services.dadata.daily_cap_rub' => 100]); + $guard = app(DaDataBudgetGuard::class); + + $guard->recordSpend(30); + $guard->recordSpend(70); + + expect($guard->spentTodayKopecks())->toBe(100); +}); + +it('starts at zero spend for a fresh day', function (): void { + $guard = app(DaDataBudgetGuard::class); + + expect($guard->spentTodayKopecks())->toBe(0); +}); diff --git a/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php b/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php new file mode 100644 index 00000000..262196d3 --- /dev/null +++ b/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php @@ -0,0 +1,80 @@ + Http::response([[ + 'qc' => 0, 'qc_conflict' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34', + 'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', 'city' => null, 'timezone' => 'UTC+3', + ]], 200)]); + + $resp = app(DaDataPhoneClient::class)->cleanPhone('79215551234'); + + expect($resp->qc)->toBe(0) + ->and($resp->provider)->toBe('МегаФон') + ->and($resp->region)->toBe('Санкт-Петербург и область') + ->and($resp->type)->toBe('Мобильный') + ->and($resp->raw)->toBeArray(); +}); + +it('parses qc=3 multiple response', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 3, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', + ]], 200)]); + + expect(app(DaDataPhoneClient::class)->cleanPhone('79991234567')->qc)->toBe(3); +}); + +it('sends Token auth, X-Secret header and json-array body', function (): void { + config(['services.dadata.api_key' => 'KEY', 'services.dadata.secret' => 'SEC']); + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); + + app(DaDataPhoneClient::class)->cleanPhone('79161234567'); + + Http::assertSent(function ($request): bool { + return $request->url() === 'https://cleaner.dadata.ru/api/v1/clean/phone' + && $request->hasHeader('Authorization', 'Token KEY') + && $request->hasHeader('X-Secret', 'SEC') + && $request->body() === '["79161234567"]'; + }); +}); + +it('throws DaDataTimeoutException on connection error', function (): void { + Http::fake(fn () => throw new ConnectionException('timeout')); + + expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234')) + ->toThrow(DaDataTimeoutException::class); +}); + +it('throws DaDataException on persistent 5xx', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response('upstream error', 500)]); + + expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234')) + ->toThrow(DaDataException::class); +}); + +it('retries once on 5xx then succeeds', function (): void { + Http::fakeSequence('cleaner.dadata.ru/*') + ->push('upstream error', 500) + ->push([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200); + + $resp = app(DaDataPhoneClient::class)->cleanPhone('79161234567'); + + expect($resp->qc)->toBe(0); + Http::assertSentCount(2); +}); + +it('does not retry on 4xx client error', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response('bad request', 400)]); + + expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79161234567')) + ->toThrow(DaDataException::class); + + Http::assertSentCount(1); +}); diff --git a/app/tests/Unit/Support/DaDataRegionMapTest.php b/app/tests/Unit/Support/DaDataRegionMapTest.php new file mode 100644 index 00000000..3fec0588 --- /dev/null +++ b/app/tests/Unit/Support/DaDataRegionMapTest.php @@ -0,0 +1,35 @@ +toBe(82) + ->and(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56) + ->and(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83) + ->and(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53); +}); + +it('trims surrounding whitespace before mapping', function (): void { + expect(DaDataRegionMap::toSubjectCode(' Москва '))->toBe(82); +}); + +it('flags ambiguous agglomeration strings', function (): void { + expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue() + ->and(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue() + ->and(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse() + ->and(DaDataRegionMap::isAmbiguous('Санкт-Петербург'))->toBeFalse(); +}); + +it('returns null for unmappable region', function (): void { + expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull() + ->and(DaDataRegionMap::toSubjectCode(''))->toBeNull(); +}); + +it('resolves all 89 RussianRegions names', function (): void { + foreach (RussianRegions::CODE_TO_NAME as $code => $name) { + expect(DaDataRegionMap::toSubjectCode($name))->toBe($code); + } +});