91c5a119eb
Причина фантомных сделок 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>
382 lines
15 KiB
PHP
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;
|
|
});
|
|
});
|