77711835a0
Продолжение разбора 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>
591 lines
27 KiB
PHP
591 lines
27 KiB
PHP
<?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();
|
||
});
|