Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a028af4bc8 |
@@ -15,6 +15,7 @@ use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\CompetitorCardEnricher;
|
||||
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
|
||||
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
|
||||
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
|
||||
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
|
||||
@@ -33,6 +34,7 @@ use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
|
||||
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
|
||||
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
|
||||
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
|
||||
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
|
||||
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
|
||||
@@ -48,6 +50,7 @@ use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Services\Autopodbor\RunProgressChannel;
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@@ -94,10 +97,20 @@ class AutopodborServiceProvider extends ServiceProvider
|
||||
directoryFetcher: new XfetchDirectoryFetcher($pages),
|
||||
);
|
||||
|
||||
// Сторож валидности телефонов: слой 1 Россвязь (всегда, бесплатно) + слой 2
|
||||
// ДаДата за флагом (платно, снимает подтверждённый мусор среди прошедших реестр).
|
||||
// Общий на оба пути шага 2 (обычный и глубокий через SiteOpener).
|
||||
$validityGate = new PhoneValidityGate(
|
||||
new RossvyazPrefixLookup,
|
||||
config('autopodbor.phone_dadata_enabled')
|
||||
? $app->make(DaDataValidityLayer::class)
|
||||
: null,
|
||||
);
|
||||
|
||||
$deep = config('autopodbor.deep_study')
|
||||
? new DeepStudyCollector(
|
||||
xfetch: $pages,
|
||||
opener: new SiteOpener($siteFetcher),
|
||||
opener: new SiteOpener($siteFetcher, validityGate: $validityGate),
|
||||
reverse: config('autopodbor.phone_reverse')
|
||||
? $this->buildPhoneReverse($pages, $reader)
|
||||
: null,
|
||||
@@ -115,6 +128,7 @@ class AutopodborServiceProvider extends ServiceProvider
|
||||
return new RealCompetitorAgent(
|
||||
$fetcher,
|
||||
new FakeCompetitorAgent,
|
||||
validityGate: $validityGate,
|
||||
liveFind: $liveFind,
|
||||
deep: $deep,
|
||||
progress: $app->make(RunProgressChannel::class),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сторож валидности телефонов, пойманных при «вскрытии» кода страницы конкурента.
|
||||
*
|
||||
* Экстрактор ({@see HtmlPhoneScanner}) ловит любую последовательность из 11 цифр,
|
||||
* начинающуюся на 7/8 и сгруппированную «по-телефонному» — но НЕ проверяет, что это
|
||||
* реальный российский номер. Со страниц-агрегаторов (отзовики, каталоги) так пролезает
|
||||
* мусор: ID/коды/чужие блоки цифр с несуществующими кодами города (227, 303, 453, 613, 761…).
|
||||
* Этот гейт отсекает такой мусор ДО показа клиенту (spec §12.4-подбор).
|
||||
*
|
||||
* Слой 1 (здесь, бесплатно): реестр нумерации Россвязи (`phone_ranges`, 453k диапазонов на
|
||||
* проде). Полный номер 7XXXXXXXXXX, которого нет ни в одном диапазоне → выкидываем молча.
|
||||
* Слой 2 (ДаДата, платно, за флагом) — {@see DaDataValidityLayer}, добивает сомнительные.
|
||||
*
|
||||
* Защита: если реестр пуст (dev/тесты или сбой загрузки) — НЕ режем ничего (fail-open),
|
||||
* чтобы незагруженный справочник не выкосил все номера. Короткие/локальные номера
|
||||
* (kind=uncertain, код города не определён) не трогаем — их нельзя сверить с реестром.
|
||||
*/
|
||||
final class PhoneValidityGate
|
||||
{
|
||||
private ?bool $registryHasData = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly RossvyazPrefixLookup $rossvyaz = new RossvyazPrefixLookup,
|
||||
private readonly ?PhoneValidityLayer $dadata = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
|
||||
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
|
||||
*/
|
||||
public function filter(array $candidates): array
|
||||
{
|
||||
// Реестр не загружен → fail-open: не режем ничего.
|
||||
if (! $this->registryHasData()) {
|
||||
return array_values($candidates);
|
||||
}
|
||||
|
||||
return array_values(array_filter($candidates, fn (array $c): bool => $this->keep((string) $c['number'])));
|
||||
}
|
||||
|
||||
private function keep(string $number): bool
|
||||
{
|
||||
// Сверяем только полные российские номера. Короткие/локальные (7-значные и т.п.)
|
||||
// нельзя проверить по реестру — не теряем.
|
||||
if (preg_match('/^7\d{10}$/', $number) !== 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Слой 1 — Россвязь (авторитетный полный план нумерации): номера нет ни в одном
|
||||
// диапазоне → это не настоящий российский номер → мусор, выкидываем молча (бесплатно).
|
||||
if ($this->rossvyaz->find($number) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Слой 2 — ДаДата (платно, за флагом): перепроверяет уже прошедший реестр номер и
|
||||
// снимает подтверждённый мусор/иностранца (qc 2/7). Платим только за реальных выживших.
|
||||
if ($this->dadata !== null) {
|
||||
return $this->dadata->isValid($number);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function registryHasData(): bool
|
||||
{
|
||||
if ($this->registryHasData !== null) {
|
||||
return $this->registryHasData;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->registryHasData = DB::connection(RossvyazPrefixLookup::CONNECTION)
|
||||
->table('phone_ranges')
|
||||
->limit(1)
|
||||
->exists();
|
||||
} catch (\Throwable) {
|
||||
// Сбой БД/соединения при проверке реестра → fail-open (не фильтруем ничего).
|
||||
// Иначе внешний catch в SiteOpener::open() принял бы это за «нет номеров».
|
||||
return $this->registryHasData = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
|
||||
|
||||
/**
|
||||
* Второй слой проверки валидности телефона (после реестра Россвязи).
|
||||
* Реализация — {@see DaDataValidityLayer} (платно, за флагом).
|
||||
*/
|
||||
interface PhoneValidityLayer
|
||||
{
|
||||
/** true — номер годен (оставляем); false — мусор/иностранец (выкидываем). */
|
||||
public function isValid(string $number): bool;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Phone;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
|
||||
use App\Services\DaData\DaDataBudgetGuard;
|
||||
use App\Services\DaData\DaDataException;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
|
||||
/**
|
||||
* Второй слой валидности телефона (после реестра Россвязи) через DaData clean/phone.
|
||||
* Платный, поэтому подключается только за флагом (`autopodbor.phone_dadata_enabled`) и
|
||||
* под дневным бюджетом {@see DaDataBudgetGuard} — тот же кошелёк, что резолв региона лида.
|
||||
*
|
||||
* Снимает ТОЛЬКО подтверждённый мусор: qc 2 (не телефон) и qc 7 (иностранный номер).
|
||||
* Всё остальное (qc 0/3/1, исчерпанный бюджет, сбой сети) — оставляет: номер уже прошёл
|
||||
* авторитетный реестр Россвязи, наказывать его за нашу неуверенность/сбой нельзя.
|
||||
*
|
||||
* Вердикт кэшируется по sha256(номер) — не платим дважды за один номер между прогонами.
|
||||
* В кэше/ключе только хэш, без сырого номера (та же ПДн-конвенция, что LeadRegionResolver §7.3).
|
||||
*/
|
||||
final class DaDataValidityLayer implements PhoneValidityLayer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DaDataPhoneClient $client,
|
||||
private readonly DaDataBudgetGuard $budget,
|
||||
private readonly CacheRepository $cache,
|
||||
) {}
|
||||
|
||||
public function isValid(string $number): bool
|
||||
{
|
||||
$key = 'autopodbor:phone-valid:'.hash('sha256', $number);
|
||||
|
||||
$cached = $this->cache->get($key);
|
||||
if (is_bool($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Бюджет исчерпан — не тратим и не режем (номер уже прошёл реестр).
|
||||
if (! $this->budget->canSpend()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->cleanPhone($number);
|
||||
$this->budget->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
|
||||
} catch (DaDataException) {
|
||||
return true; // сбой ДаДаты — не роняем и не режем
|
||||
}
|
||||
|
||||
$valid = ! in_array($response->qc, [2, 7], true);
|
||||
|
||||
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
|
||||
$this->cache->put($key, $valid, now()->addDays($ttlDays));
|
||||
|
||||
return $valid;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
use App\Services\Autopodbor\Agent\Study\CompetitorFingerprint;
|
||||
@@ -26,6 +27,7 @@ final class RealCompetitorAgent implements CompetitorAgent
|
||||
private Fetcher $fetcher,
|
||||
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
|
||||
private CandidateBuilder $builder = new CandidateBuilder,
|
||||
private PhoneValidityGate $validityGate = new PhoneValidityGate,
|
||||
private SourceAggregator $aggregator = new SourceAggregator,
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
|
||||
@@ -108,6 +110,8 @@ final class RealCompetitorAgent implements CompetitorAgent
|
||||
// конкурента (только если на странице нет полных номеров; см. RegionAreaCode).
|
||||
$areaCode = RegionAreaCode::forSubject($r->regionCode);
|
||||
$candidates = $this->builder->build($sites, $cards, $areaCode);
|
||||
// Отсекаем мусор: пойманные «номера» с несуществующими кодами (не в реестре Россвязи).
|
||||
$candidates = $this->validityGate->filter($candidates);
|
||||
$collected = $this->aggregator->aggregate($candidates);
|
||||
|
||||
// 3. Маппинг в существующий контракт
|
||||
|
||||
@@ -5,17 +5,20 @@ declare(strict_types=1);
|
||||
namespace App\Services\Autopodbor\Agent\Study;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
|
||||
/**
|
||||
* Вскрытие одного сайта фирмы: Fetcher (curl+Playwright) → CandidateBuilder (kind номеров) →
|
||||
* список кандидатов, флаг коллтрекинга, HTML в нижнем регистре и «код»-цифры (для якорей).
|
||||
* сторож валидности (отсев мусора по реестру Россвязи) → список кандидатов, флаг коллтрекинга,
|
||||
* HTML в нижнем регистре и «код»-цифры (для якорей).
|
||||
*/
|
||||
final class SiteOpener
|
||||
{
|
||||
public function __construct(
|
||||
private Fetcher $fetcher,
|
||||
private CandidateBuilder $builder = new CandidateBuilder,
|
||||
private PhoneValidityGate $validityGate = new PhoneValidityGate,
|
||||
) {}
|
||||
|
||||
public function open(string $domain, ?string $areaCode): OpenedSite
|
||||
@@ -23,7 +26,8 @@ final class SiteOpener
|
||||
$url = 'https://'.$this->idn($domain).'/';
|
||||
try {
|
||||
$site = $this->fetcher->site($url);
|
||||
$cands = $this->builder->build([$site], [], $areaCode);
|
||||
// Отсекаем мусор (номера с несуществующими кодами) ДО якорей/разворота по телефонам.
|
||||
$cands = $this->validityGate->filter($this->builder->build([$site], [], $areaCode));
|
||||
$codeDigits = [];
|
||||
foreach ($cands as $c) {
|
||||
if ($c['kind'] === 'code' || $c['kind'] === 'contacts') {
|
||||
|
||||
@@ -122,4 +122,20 @@ return [
|
||||
*/
|
||||
'render_cache' => env('AUTOPODBOR_RENDER_CACHE', false),
|
||||
'render_cache_ttl_days' => (int) env('AUTOPODBOR_RENDER_CACHE_TTL_DAYS', 5),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Второй слой отсева телефонов через ДаДату (после реестра Россвязи)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Пойманные при «вскрытии» кода страницы номера всегда чистятся реестром Россвязи
|
||||
| (бесплатно): номера с несуществующим кодом → выкидываются. Этот флаг добавляет
|
||||
| ВТОРОЙ, ПЛАТНЫЙ слой — ДаДата clean/phone (~0,6 ₽/номер) перепроверяет уже прошедшие
|
||||
| реестр номера и снимает подтверждённый мусор/иностранцев (qc 2/7). Платим только за
|
||||
| реальных выживших (мусор отсекается бесплатно ДО ДаДаты), под дневным бюджетом
|
||||
| services.dadata.daily_cap_rub, с кэшем вердикта. По умолчанию ВЫКЛ — включается
|
||||
| осознанно (тратит деньги). Требует services.dadata.api_key/secret.
|
||||
|
|
||||
*/
|
||||
'phone_dadata_enabled' => env('AUTOPODBOR_PHONE_DADATA_ENABLED', false),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Засеваем один диапазон Россвязи → реестр становится непустым.
|
||||
* По умолчанию: def_code=921, абоненты 5550000..5559999 (МегаФон, СПб).
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function seedGateRange(array $overrides = []): void
|
||||
{
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/test',
|
||||
'checksum_sha256' => str_repeat('b', 64),
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('phone_ranges')->insert(array_merge([
|
||||
'def_code' => 921,
|
||||
'from_num' => 5550000,
|
||||
'to_num' => 5559999,
|
||||
'operator' => 'МегаФон',
|
||||
'region' => 'Санкт-Петербург',
|
||||
'subject_code' => 83,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}
|
||||
*/
|
||||
function gateCandidate(string $number, array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'number' => $number,
|
||||
'kind' => 'text',
|
||||
'label' => 'в тексте сайта — проверьте',
|
||||
'url' => 'https://review-group.ru/',
|
||||
'office' => null,
|
||||
'tracker' => false,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('выкидывает номер, чей код города отсутствует в реестре Россвязи', function (): void {
|
||||
seedGateRange(); // реестр непустой (есть 921)
|
||||
|
||||
// 76132306130 — код «613», такого в реестре нет (мусор со страницы-агрегатора).
|
||||
$out = app(PhoneValidityGate::class)->filter([gateCandidate('76132306130')]);
|
||||
|
||||
expect($out)->toBe([]);
|
||||
});
|
||||
|
||||
it('оставляет номер, который есть в реестре Россвязи', function (): void {
|
||||
seedGateRange(); // def_code=921, 5550000..5559999
|
||||
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('79215555123', ['kind' => 'code', 'label' => 'в коде сайта']),
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(1)
|
||||
->and($out[0]['number'])->toBe('79215555123');
|
||||
});
|
||||
|
||||
it('фильтрует только мусор, реальные соседи остаются', function (): void {
|
||||
seedGateRange();
|
||||
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('76132306130'), // мусор (код 613)
|
||||
gateCandidate('79215555123', ['kind' => 'code']), // реальный (921)
|
||||
gateCandidate('72279347817'), // мусор (код 227 — вообще <300, запрещён реестром)
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(1)
|
||||
->and($out[0]['number'])->toBe('79215555123');
|
||||
});
|
||||
|
||||
it('при пустом реестре не режет ничего (fail-open)', function (): void {
|
||||
// seedGateRange НЕ вызван → phone_ranges пуст (как на dev). Фильтр не должен
|
||||
// выкосить всё из-за незагруженного реестра.
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('76132306130'),
|
||||
gateCandidate('79215555123'),
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('никогда не трогает короткие номера «требует проверки» (их нельзя сверить)', function (): void {
|
||||
seedGateRange();
|
||||
|
||||
// 2713333 — локальный 7-значный, код города не определён (kind=uncertain).
|
||||
// Сверить с реестром нельзя → не теряем.
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('2713333', ['kind' => 'uncertain']),
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(1)
|
||||
->and($out[0]['number'])->toBe('2713333');
|
||||
});
|
||||
|
||||
it('второй слой (ДаДата) снимает мусор, прошедший реестр', function (): void {
|
||||
seedGateRange(); // 79215555123 есть в реестре
|
||||
|
||||
$dropAll = new class implements PhoneValidityLayer
|
||||
{
|
||||
public function isValid(string $number): bool
|
||||
{
|
||||
return false; // ДаДата сказала «мусор»
|
||||
}
|
||||
};
|
||||
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $dropAll);
|
||||
|
||||
$out = $gate->filter([gateCandidate('79215555123', ['kind' => 'code'])]);
|
||||
|
||||
expect($out)->toBe([]);
|
||||
});
|
||||
|
||||
it('второй слой НЕ вызывается для номеров вне реестра (мусор отсёк слой 1 бесплатно)', function (): void {
|
||||
seedGateRange();
|
||||
|
||||
$spy = new class implements PhoneValidityLayer
|
||||
{
|
||||
public bool $called = false;
|
||||
|
||||
public function isValid(string $number): bool
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $spy);
|
||||
|
||||
// 76132306130 — код 613, вне реестра → слой 1 выкидывает бесплатно, ДаДата не дёргается.
|
||||
$out = $gate->filter([gateCandidate('76132306130')]);
|
||||
|
||||
expect($out)->toBe([])
|
||||
->and($spy->called)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
use App\Services\Autopodbor\Agent\Study\SiteOpener;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function seedOpenerRange(): void
|
||||
{
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/test',
|
||||
'checksum_sha256' => str_repeat('c', 64),
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('phone_ranges')->insert([
|
||||
'def_code' => 391,
|
||||
'from_num' => 2000000,
|
||||
'to_num' => 2999999,
|
||||
'operator' => 'Ростелеком',
|
||||
'region' => 'Красноярский край',
|
||||
'subject_code' => 24,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fetcher, отдающий фиксированный HTML. */
|
||||
function openerFetcher(string $html): Fetcher
|
||||
{
|
||||
return new class($html) implements Fetcher
|
||||
{
|
||||
public function __construct(private string $html) {}
|
||||
|
||||
public function site(string $url): FetchedSite
|
||||
{
|
||||
return new FetchedSite($url, $this->html);
|
||||
}
|
||||
|
||||
public function directory(string $url): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('глубокий путь: мусорный номер (код вне реестра) не попадает в numbers', function (): void {
|
||||
seedOpenerRange(); // есть только 391 2000000..2999999
|
||||
|
||||
// Настоящий 73912920000 (391) + мусор 76132306130 (код 613, вне реестра).
|
||||
$html = '<a href="tel:+73912920000">звонок</a>'
|
||||
.' <span>+7 613 230 61 30</span>';
|
||||
|
||||
$r = (new SiteOpener(openerFetcher($html)))->open('kraslombard24.ru', '391');
|
||||
|
||||
$numbers = collect($r->numbers)->pluck('number');
|
||||
expect($numbers)->toContain('73912920000')
|
||||
->and($numbers)->not->toContain('76132306130');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function (): void {
|
||||
config(['services.dadata.daily_cap_rub' => 10000]); // бюджет открыт
|
||||
});
|
||||
|
||||
it('qc=2 (мусор) → номер невалиден (выкидываем)', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 2]], 200)]);
|
||||
|
||||
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('qc=7 (иностранец) → номер невалиден', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 7]], 200)]);
|
||||
|
||||
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('qc=0 (годный) → валиден', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
|
||||
|
||||
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('qc=1 (неоднозначный) → оставляем (не режем по сомнению)', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 1]], 200)]);
|
||||
|
||||
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('бюджет исчерпан → не тратим и не режем, ДаДату не дёргаем', function (): void {
|
||||
config(['services.dadata.daily_cap_rub' => 0]); // касса закрыта
|
||||
Http::fake();
|
||||
|
||||
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('сбой ДаДаты (сеть) → не роняем и не режем', function (): void {
|
||||
Http::fake(fn () => throw new ConnectionException('timeout'));
|
||||
|
||||
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
|
||||
});
|
||||
Reference in New Issue
Block a user