Files
portal/app/tests/Feature/Supplier/CsvReconcileJobTest.php
T
Дмитрий 91c5a119eb
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
fix(supplier): CSV-reconcile сверяет журнал ОТДАННОГО (по vid), не пул
Причина фантомных сделок kdv1 (09.07.2026): CsvReconcileJob брал отчёт «Запрос
номеров» = ПУЛ собранных номеров (phones_cnt), а не ОТДАННОЕ (crms_cnt), и лепил
из пула сделки клиенту + выедал дневной лимит. Пример: пул 157, отдано 15,
у клиента 15 фантомов, а 15 реально отданных выбило лимитом.

Фикс: SupplierPortalClient::fetchDeliveredLeads() читает журнал «Мои сделки»
(index-visit, по vid) — только реально отгруженное. CsvReconcileJob сверяет по vid,
добор несёт настоящий vid (idx_supplier_leads_vid_unique -> идемпотентно с webhook).
Пул не трогается -> фантомы невозможны, лимит не забивается мусором.

Проверено: 12 новых тестов + 294 supplier зелёные, Pint/Larastan чисто. Выкачено и
проверено на проде (reconcile читает отданное, 0 фантомов за прогон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:05:52 +03:00

382 lines
15 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\Supplier\SupplierCsvParser;
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');
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');
});
/**
* Мокает журнал ОТДАННОГО (Мои сделки): 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);
$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(SupplierCsvParser::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);
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);
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((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 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;
});
});