Files
portal/app/tests/Feature/Supplier/CsvReconcileJobTest.php
T
Дмитрий 77711835a0
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Accessibility (Pa11y live) / a11y (push) Has been cancelled
fix(поставщик): добор забирает ТЕГ из журнала — региону возвращена опора
Продолжение разбора 31.07.2026. У пяти сегодняшних добранных сделок (223, 225-228)
пусты регион и город. Проверил три источника: ДаData по этим номерам молчит
(qc=0, виртуальные операторы МиАТел/МТТ/Скартел/ВымпелКом), Россвязь не совпала,
тег поставщика у них — «РФ», то есть вся страна. Восстановить их регион НЕЧЕМ,
задним числом не чиню и не выдумываю.

Но причина на будущее устранима: в журнале отданного ЕСТЬ колонка «Тег», и там
бывает настоящий регион (у одного из семи сегодняшних — «Свердловская область»).
Мы её просто не читали, поэтому у добранного лида в карточке было только
vid+phone+project, и когда ДаData молчит, резолверу нечем подстраховаться —
RegionTagResolver работает именно по тегу.

- parseDeliveredRows: тег берётся из ячейки сразу после td.crm-domain-column.
  Разметка списана с ЖИВОГО кабинета, не придумана. Прочерк/пусто → tag=null,
  поведение как раньше (парсер этого журнала уже дважды ломал прод — 09.07 и 16.07,
  поэтому только добавление поля, ни одна существующая ветка не тронута).
- CsvReconcileJob: тег кладётся в raw_payload добранного лида.

Тесты (20/20 в файле, 233/233 поставщик+вебхук): разбор живой разметки, «РФ» и
прочерк, тег доезжает в карточку и — главное — с тегом резолвер даёт регион
(source=tag), а стоит тег вырезать, тот же лид снова «регион неизвестен».
Larastan 0, Pint чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:28:35 +03:00

591 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Mail\CsvDriftAlertMail;
use App\Mail\TenantBusinessDriftAlertMail;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\LeadRegionResolver;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function putSupplierSession(): void
{
Cache::store('redis')->put(
'supplier:session',
['phpsessid' => 'test', 'csrf' => 'test'],
now()->addHour(),
);
}
beforeEach(function (): void {
Mail::fake();
Bus::fake([RouteSupplierLeadJob::class]);
app()->bind(RefreshSupplierSessionJob::class, fn () => new class
{
public function handle(): void
{
putSupplierSession();
}
});
Cache::store('redis')->forget('supplier:csv_reconcile');
Cache::store('redis')->forget('supplier:csv_reconcile:pending');
putSupplierSession();
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
config(['services.supplier.alert_email' => 'ops@liderra.ru']);
});
afterEach(function (): void {
Cache::store('redis')->forget('supplier:csv_reconcile');
Cache::store('redis')->forget('supplier:csv_reconcile:pending');
});
/**
* Мокает журнал ОТДАННОГО (Мои сделки): SupplierPortalClient::fetchDeliveredLeads
* возвращает лиды, ключ = vid (как реальный метод).
*
* @param list<array{vid:int, phone:string, project:string}> $leads
*/
function fakeDelivered(array $leads): void
{
$byVid = [];
foreach ($leads as $l) {
$byVid[(int) $l['vid']] = $l;
}
$mock = Mockery::mock(SupplierPortalClient::class);
$mock->shouldReceive('fetchDeliveredLeads')->andReturn($byVid);
app()->instance(SupplierPortalClient::class, $mock);
}
function fakeDeliveredThrows(Throwable $e): void
{
$mock = Mockery::mock(SupplierPortalClient::class);
/* @phpstan-ignore-next-line method.notFound (Mockery andThrow — union-type false positive, как в др. supplier-тестах) */
$mock->shouldReceive('fetchDeliveredLeads')->andThrow($e);
app()->instance(SupplierPortalClient::class, $mock);
}
/** Принятый вебхуком лид (source=webhook, реальный vid). */
function webhookLead(int $vid, string $phone, string $project = 'B1_a.com', ?Carbon $at = null): void
{
SupplierLead::create([
'supplier_project_id' => null,
'platform' => str_starts_with($project, 'B') ? substr($project, 0, 2) : 'B1',
'phone' => $phone,
'vid' => $vid,
'raw_payload' => ['project' => $project, 'phone' => $phone, 'vid' => $vid],
'received_at' => $at ?? now()->subHour(),
'source' => 'webhook',
]);
}
function runCsvReconcile(): void
{
app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(Mailer::class),
);
}
// ---------------------------------------------------------------------------
// Сверка по vid против журнала ОТДАННОГО (Мои сделки) — Путь 2, переработка 09.07.2026.
// Раньше сверялись с пулом «Запрос номеров» (phones_cnt) → фантомы + выедание лимита.
// ---------------------------------------------------------------------------
it('all delivered vids already received via webhook — recovered 0, status ok', function (): void {
$delivered = [];
for ($i = 0; $i < 10; $i++) {
$vid = 800000 + $i;
$phone = "7999000000{$i}";
webhookLead($vid, $phone);
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
}
fakeDelivered($delivered);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->total_csv_rows)->toBe(10);
expect((int) $log->matched_count)->toBe(10);
expect((int) $log->recovered_count)->toBe(0);
Mail::assertNotSent(CsvDriftAlertMail::class);
Bus::assertNothingDispatched();
});
it('recovers a delivered lead missing from webhook — with the REAL vid (not null)', function (): void {
$delivered = [];
for ($i = 0; $i < 9; $i++) {
$vid = 810000 + $i;
$phone = "7999111000{$i}";
webhookLead($vid, $phone);
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
}
// 10-й отдан поставщиком, но вебхук его потерял.
$missingVid = 810099;
$missingPhone = '79991119999';
$delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com'];
fakeDelivered($delivered);
// Отсрочка 31.07.2026: первый прогон только кладёт недостачу в карантин, добор — со
// второго, когда стало ясно, что вебхук её не несёт (а не просто опаздывает).
runCsvReconcile();
Carbon::setTestNow(now()->addMinutes(16));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('drift_alert');
expect((float) $log->drift_ratio)->toBeGreaterThan(0.05);
expect((int) $log->recovered_count)->toBe(1);
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
expect($recovered)->not->toBeNull();
// КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: recovery несёт НАСТОЯЩИЙ vid (раньше был null) — точная личность лида.
expect((int) $recovered->vid)->toBe($missingVid);
expect($recovered->phone)->toBe($missingPhone);
expect($recovered->recovered_from_csv_at)->not->toBeNull();
Mail::assertSent(CsvDriftAlertMail::class, 1);
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
});
it('reconciles ONLY the delivered ledger — pool numbers (roistat identity) are structurally impossible to recover', function (): void {
// Поставщик реально ОТДАЛ 3 лида (vid+phone), вебхук их принял.
// В старом баге ПУЛ по этому проекту содержал десятки ДРУГИХ телефонов (пул 157 vs отдано 15),
// и старый reconcile лепил из них фантомы. Новый источник = только отданное (по vid) → лишнему
// взяться неоткуда.
$delivered = [];
foreach ([[900001, '79000000001'], [900002, '79000000002'], [900003, '79000000003']] as [$vid, $phone]) {
webhookLead($vid, $phone, 'B2_74950001122');
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B2_74950001122'];
}
fakeDelivered($delivered);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->recovered_count)->toBe(0);
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
Bus::assertNothingDispatched();
});
it('delivered vid already exists (any time) — matched, not re-recovered, no duplicate', function (): void {
// Лид принят вебхуком 3 дня назад (вне 2-дневного окна reconcile). В журнале отданного он ещё
// виден. Глобальная проверка vid должна засчитать его как matched, а не пытаться вставить дубль.
$vid = 920000;
$phone = '79200000000';
webhookLead($vid, $phone, 'B1_a.com', now()->subDays(3));
fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]);
runCsvReconcile();
expect(SupplierLead::where('vid', $vid)->count())->toBe(1); // без дубля
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->matched_count)->toBe(1);
});
it('unparseable project in delivered ledger — skipped, counted, excluded from drift', function (): void {
// 5 нормальных отданных (webhook принял) + 5 отданных с мусорным project (extractPlatform=null).
$delivered = [];
for ($i = 0; $i < 5; $i++) {
$vid = 930000 + $i;
$phone = "7993000000{$i}";
webhookLead($vid, $phone);
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
}
$junk = ['???', '!@#', '%%%', '$$$', '***'];
foreach ($junk as $j => $bad) {
$delivered[] = ['vid' => 931000 + $j, 'phone' => "7993100000{$j}", 'project' => $bad];
}
fakeDelivered($delivered);
// Мусорные строки — тоже недостача, поэтому проходят через отсрочку: считаются
// unparseable только на прогоне, где реально дошло до разбора project.
runCsvReconcile();
Carbon::setTestNow(now()->addMinutes(16));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(10);
expect((int) $log->matched_count)->toBe(5);
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->unparseable_count)->toBe(5);
expect((int) $log->pending_count)->toBe(0);
expect((float) $log->drift_ratio)->toBe(0.0); // только junk, реального missing нет
expect($log->status)->toBe('ok');
});
it('empty delivered ledger — status=ok, drift=0', function (): void {
fakeDelivered([]);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->total_csv_rows)->toBe(0);
expect((int) $log->recovered_count)->toBe(0);
});
it('overlap lock held — job skips, no log row', function (): void {
$countBefore = DB::table('supplier_csv_reconcile_log')->count();
$lock = Cache::store('redis')->lock('supplier:csv_reconcile', 600);
$lock->get();
try {
runCsvReconcile();
} finally {
$lock->release();
}
expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
});
it('SupplierTransientException from delivered fetch — status=failed, rethrown', function (): void {
fakeDeliveredThrows(new SupplierTransientException('Supplier server error 500'));
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('failed');
expect($log->error_message)->toContain('500');
});
// ---------------------------------------------------------------------------
// fetchDeliveredLeads — парсинг HTML «Мои сделки» + пагинация (реальный клиент, Http::fake).
// ---------------------------------------------------------------------------
/**
* Строит HTML таблицы «Мои сделки»: строка = checkbox value(vid) + B{n}_<proj> + телефон.
*
* @param list<array{vid:int, phone:string, project:string}> $leads
*/
function deliveredHtml(array $leads): string
{
$rows = '';
foreach ($leads as $l) {
$rows .= '<tr class="users-table__item">'
.'<td><input type="checkbox" name="visit-checbox" value="'.$l['vid'].'"></td>'
.'<td>Открыть '.$l['project'].' РФ'.$l['phone'].' -</td>'
.'</tr>';
}
return '<table><tbody>'.$rows.'</tbody></table>';
}
it('fetchDeliveredLeads parses vid+phone+project from Мои сделки HTML', function (): void {
$html = deliveredHtml([
['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com'],
['vid' => 1718932472, 'phone' => '79001112244', 'project' => 'B2_74950001122'],
]);
Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]);
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
expect($result)->toHaveCount(2);
expect($result[1718932476])->toMatchArray(['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com']);
expect($result[1718932472]['project'])->toBe('B2_74950001122');
});
it('fetchDeliveredLeads picks the real caller phone, not the phone-shaped B2/B3 project number', function (): void {
// B2/B3 (Билайн/МТС) проекты названы номером-ловушкой (7\d{10}), который в строке «Мои
// сделки» идёт РАНЬШЕ телефона звонившего. Прод-инцидент 16.07.2026 (tenant 7): парсер
// хватал номер-ловушку как телефон → сделки с номером проекта вместо звонившего.
$html = deliveredHtml([
['vid' => 5001, 'phone' => '79008887766', 'project' => 'B2_74950009988'],
['vid' => 5002, 'phone' => '79007776655', 'project' => 'B3_74990001122'],
]);
Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]);
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
expect($result[5001]['phone'])->toBe('79008887766');
expect($result[5001]['project'])->toBe('B2_74950009988');
expect($result[5002]['phone'])->toBe('79007776655');
});
it('fetchDeliveredLeads paginates until a page returns < 50 rows', function (): void {
$page1 = [];
for ($i = 0; $i < 50; $i++) {
$page1[] = ['vid' => 1000 + $i, 'phone' => '790000'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com'];
}
$page2 = [];
for ($i = 0; $i < 3; $i++) {
$page2[] = ['vid' => 2000 + $i, 'phone' => '790010'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com'];
}
Http::fake([
'crm.bp-gr.ru/admin/visit/index-visit*' => Http::sequence()
->push(deliveredHtml($page1), 200)
->push(deliveredHtml($page2), 200),
]);
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
expect($result)->toHaveCount(53); // 50 + 3, остановились на неполной странице
});
// ---------------------------------------------------------------------------
// R-05 business-drift (spec §4.4.4) — второй проход по project_routing_snapshots.
// Ортогонален webhook-loss drift: тот же лид может быть не доставлен вовсе.
// ---------------------------------------------------------------------------
function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void
{
$tenant = Tenant::find($tenantId) ?? Tenant::factory()->create();
$project = Project::factory()
->for($tenant)
->asCallSignal('7977'.Str::random(7))
->create([
'is_active' => true,
'daily_limit_target' => max($expected, 1),
]);
DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->insert([
'snapshot_date' => $date,
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => max($expected, 1),
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => $project->signal_identifier,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => $expected,
'delivered_count' => $delivered,
'created_at' => now(),
]);
}
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
$tenant = Tenant::factory()->create();
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
fakeDelivered([]);
runCsvReconcile();
Mail::assertSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
return $mail->tenantId === $tenant->id
&& $mail->expected === 10
&& $mail->delivered === 2
&& $mail->shortfallRatio >= 0.79
&& $mail->shortfallRatio <= 0.81;
});
});
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
$tenant = Tenant::factory()->create();
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
fakeDelivered([]);
runCsvReconcile();
Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
return $mail->tenantId === $tenant->id;
});
});
// ---------------------------------------------------------------------------
// Отсрочка добора (31.07.2026): вебхук поставщика приходит на 2-8 минут ПОЗЖЕ,
// чем строка появляется в журнале отданного. Сверка каждые 30 мин попадала в это
// окно и «восстанавливала» лид, который уже был в пути → ложная тревога «потеряно N»
// + бедная карточка (в журнале нет tag/time/phones). Инцидент 31.07: 7 добранных,
// вебхук по тем же семи пришёл через 2,5 минуты и получил «уже есть».
// Правило: недостача добирается только если висит дольше GRACE_MINUTES.
// ---------------------------------------------------------------------------
it('first sighting of a missing vid is quarantined — not recovered, no alert', function (): void {
$vid = 930001;
$phone = '79300000001';
fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]);
runCsvReconcile();
expect(SupplierLead::where('vid', $vid)->count())->toBe(0);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->pending_count)->toBe(1);
expect($log->status)->toBe('ok');
Mail::assertNotSent(CsvDriftAlertMail::class);
Bus::assertNothingDispatched();
});
it('late webhook arrives during quarantine — nothing is recovered, no phantom, no alert', function (): void {
// Точный сценарий инцидента 31.07.2026: в 05:30 сверка увидела 7 номеров, которых у нас
// нет; вебхук по ним пришёл в 05:32-05:33. Со старым поведением портал уже завёл их сам
// (бедные карточки, ложная тревога «потеряно 7»). С отсрочкой — ждём и не трогаем.
$vid = 940001;
$phone = '79400000001';
fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]);
runCsvReconcile(); // 05:30 — увидели недостачу, в карантин
webhookLead($vid, $phone, 'B1_a.com'); // 05:33 — вебхук донёс, как в жизни
Carbon::setTestNow(now()->addMinutes(16));
runCsvReconcile(); // 06:00 — недостачи больше нет
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
expect(SupplierLead::where('vid', $vid)->count())->toBe(1);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->pending_count)->toBe(0);
expect((int) $log->matched_count)->toBe(1);
expect($log->status)->toBe('ok');
Mail::assertNotSent(CsvDriftAlertMail::class);
Bus::assertNothingDispatched();
});
it('alert counts only overdue as lost and reports in-flight separately', function (): void {
// Один номер висит с прошлого прогона (реальная потеря), второй увиден только сейчас (в пути).
$lostVid = 950001;
$lostPhone = '79500000001';
fakeDelivered([['vid' => $lostVid, 'phone' => $lostPhone, 'project' => 'B1_a.com']]);
runCsvReconcile();
Carbon::setTestNow(now()->addMinutes(16));
$freshVid = 950002;
$freshPhone = '79500000002';
fakeDelivered([
['vid' => $lostVid, 'phone' => $lostPhone, 'project' => 'B1_a.com'],
['vid' => $freshVid, 'phone' => $freshPhone, 'project' => 'B1_a.com'],
]);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->recovered_count)->toBe(1);
expect((int) $log->pending_count)->toBe(1);
Mail::assertSent(CsvDriftAlertMail::class, function (CsvDriftAlertMail $mail) {
return $mail->missingCount === 1 // потерей считаем только просроченный
&& $mail->pendingCount === 1 // «в пути» показываем отдельно
&& $mail->recoveredCount === 1;
});
expect(SupplierLead::where('vid', $freshVid)->count())->toBe(0);
});
it('drift alert email renders and separates loss from in-flight', function (): void {
// Смоук шаблона: битый blade иначе всплыл бы только на бою, письмом-пустышкой.
$html = (new CsvDriftAlertMail(
reconcileLogId: 1417,
totalCsvRows: 21,
missingCount: 7,
pendingCount: 3,
recoveredCount: 7,
driftRatio: 0.3333,
windowStart: Carbon::parse('2026-07-30 00:00:00'),
windowEnd: Carbon::parse('2026-07-31 02:30:00'),
))->render();
expect($html)->toContain('Ещё в пути');
expect($html)->toContain('считаем потерей');
expect($html)->toContain('1417');
expect($html)->not->toContain('Пропущено webhook');
});
/**
* Разметка строки журнала «Мои сделки» — СПИСАНА С ЖИВОГО КАБИНЕТА (31.07.2026),
* а не придумана: проект лежит в td.crm-domain-column, следом отдельная ячейка с ТЕГОМ
* (регион или «РФ»), затем td.users__td_phones с телефоном звонившего.
*
* @param list<array{vid:int, phone:string, project:string, tag:string}> $leads
*/
function deliveredHtmlCabinet(array $leads): string
{
$rows = '';
foreach ($leads as $l) {
$vid = $l['vid'];
$rows .= '<tr class="users-table__item" data-id="'.$vid.'">'
.'<td><input type="checkbox" name="visit-checbox" value="'.$vid.'"></td>'
.'<td class="crm-domain-column"> <a href="/admin/visit/view?id='.$vid.'">'.$l['project'].'</a> </td>'
.'<td><a href="/admin/visit/view?id='.$vid.'">'.$l['tag'].'</a></td>'
.'<td class="users__td_phones" style="position: relative;"><div class="visr-phone">'
.'<a class=" " href="tel: " data-info="">'.$l['phone'].'</a></div></td>'
.'<td class="users__td_user"></td>'
.'</tr>';
}
return '<table><tbody>'.$rows.'</tbody></table>';
}
it('fetchDeliveredLeads picks up the region tag from the cabinet row', function (): void {
$html = deliveredHtmlCabinet([
['vid' => 6001, 'phone' => '79990000001', 'project' => 'B1_79089202427', 'tag' => 'Свердловская область'],
['vid' => 6002, 'phone' => '79990000002', 'project' => 'B1_79537885612', 'tag' => 'РФ'],
['vid' => 6003, 'phone' => '79990000003', 'project' => 'B1_a.com', 'tag' => '-'],
]);
Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]);
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
expect($result[6001]['tag'])->toBe('Свердловская область');
expect($result[6002]['tag'])->toBe('РФ');
expect($result[6003]['tag'])->toBeNull(); // прочерк — это не тег
// телефон и проект по-прежнему берутся верно
expect($result[6001]['phone'])->toBe('79990000001');
expect($result[6001]['project'])->toBe('B1_79089202427');
});
it('recovered lead carries the supplier tag — region has a chance when DaData is silent', function (): void {
// Повод (31.07.2026): у добранного лида в карточке было только vid+phone+project, поэтому
// резолверу нечем было подстраховаться, когда ДаData не знает номер. Тег поставщика —
// ровно эта подстраховка (RegionTagResolver), и он в журнале ЕСТЬ, мы его выбрасывали.
$vid = 960001;
fakeDelivered([['vid' => $vid, 'phone' => '79600000001', 'project' => 'B1_a.com', 'tag' => 'Красноярский край']]);
runCsvReconcile();
Carbon::setTestNow(now()->addMinutes(16));
runCsvReconcile();
$lead = SupplierLead::where('vid', $vid)->first();
expect($lead)->not->toBeNull();
expect($lead->raw_payload['tag'])->toBe('Красноярский край');
});
it('tag from the journal actually yields a region when DaData is silent', function (): void {
// Смысл правки — не «положить строчку в карточку», а вернуть региону опору.
// ДаData выключена (как будто не знает номер) → резолвер обязан взять регион из тега.
config(['services.dadata.enabled' => false]);
$vid = 970001;
fakeDelivered([['vid' => $vid, 'phone' => '79700000001', 'project' => 'B1_a.com', 'tag' => 'Красноярский край']]);
runCsvReconcile();
Carbon::setTestNow(now()->addMinutes(16));
runCsvReconcile();
$lead = SupplierLead::where('vid', $vid)->first();
$resolution = app(LeadRegionResolver::class)->resolve($lead);
expect($resolution->source)->toBe('tag');
expect($resolution->subjectCode)->not->toBeNull();
// Контроль вырезанием: без тега тот же лид даёт «регион неизвестен».
$lead->raw_payload = ['project' => 'B1_a.com', 'phone' => '79700000001', 'vid' => $vid];
$lead->save();
$without = app(LeadRegionResolver::class)->resolve($lead->fresh());
expect($without->source)->toBe('unknown');
expect($without->subjectCode)->toBeNull();
});