Files
portal/app/tests/Feature/Supplier/CsvReconcileJobTest.php
T
Дмитрий 6bf0ebfd1d feat(supplier): LedgerService + CsvReconcileJob recognise DIRECT platform
LedgerService::resolveSupplierId returns suppliers.code='direct' row for
DIRECT-platform supplier_projects (and for parsed-from-payload non-B
projects). CsvReconcileJob::extractPlatform now classifies most non-empty,
non-junk project strings as DIRECT (instead of dumping them into
unparseable_count) — this allows CSV recovery to also create DIRECT
supplier_leads, mirroring the webhook path.

CsvReconcileJobTest junk-rows fixtures updated: previously used callback
phone-number-as-project (79135551234) and URL-like strings as 'junk', but
those are now valid DIRECT identifiers. Replaced with truly junk strings
matching only outside-whitelist symbols (e.g. '???', '!@#').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:59:08 +03:00

341 lines
12 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\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
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 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');
});
/**
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
*
* @param array<int, array{project: string, phone: string}> $rows
*/
function csvBody(array $rows): string
{
$out = "Name;Tag;Phone\n";
foreach ($rows as $r) {
$out .= "{$r['project']};tag;{$r['phone']}\n";
}
return $out;
}
/**
* Мокает весь async-флоу отчёта (реальные endpoint'ы — discovery T3 2026-05-19):
* POST /admin/report/save-report → "OK"
* GET /admin/report/load-reports → array [{id, title, status:"1", ...}] (id извлекается по title-match)
* GET /admin/report/getfile?id=N → raw CSV
*
* Title включает фактически использованные dateFrom/dateTo — захватываем их из save-report body
* и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся.
*/
function fakeReportFlow(string $csv): void
{
$captured = ['from' => '', 'to' => ''];
Http::fake([
'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) {
$body = $r->data();
$captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? '');
$captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? '');
return Http::response('OK', 200);
},
'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) {
$title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']);
return Http::response([
['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'],
], 200);
},
'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
]);
}
function runCsvReconcile(): void
{
app(CsvReconcileJob::class)->handle(
app(SupplierPortalClient::class),
app(SupplierCsvParser::class),
app(Mailer::class),
);
}
it('no missing leads — status=ok, no recovery, no alert', function (): void {
for ($i = 0; $i < 10; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => "7999000000{$i}",
'vid' => 800000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 10; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
}
fakeReportFlow(csvBody($rows));
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::assertNothingSent();
Bus::assertNothingDispatched();
});
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
for ($i = 0; $i < 9; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => "7999111000{$i}",
'vid' => 810000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 10; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
}
fakeReportFlow(csvBody($rows));
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();
expect($recovered->vid)->toBeNull();
expect($recovered->recovered_from_csv_at)->not->toBeNull();
Mail::assertSent(CsvDriftAlertMail::class, 1);
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
});
it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
for ($i = 0; $i < 99; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 820000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->recovered_count)->toBe(1);
Mail::assertNothingSent();
});
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79995550000',
'vid' => 830000,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
fakeReportFlow(csvBody([
['project' => 'B1_a.com', 'phone' => '79995550000'],
['project' => 'B2_b.com', 'phone' => '79995550000'],
]));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->matched_count)->toBe(1);
expect((int) $log->recovered_count)->toBe(1);
});
it('empty CSV — status=ok, drift=0', function (): void {
fakeReportFlow("Name;Tag;Phone\n");
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->total_csv_rows)->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 — status=failed, error recorded, rethrown', function (): void {
Http::fake(['crm.bp-gr.ru/*' => Http::response('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');
});
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
// 100 нормальных webhook-лидов.
for ($i = 0; $i < 100; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 840000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
// CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null).
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
$rows = [];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
foreach ($junkProjects as $j => $junk) {
$rows[] = ['project' => $junk, 'phone' => '7999500000'.$j];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(110);
expect((int) $log->matched_count)->toBe(100);
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->unparseable_count)->toBe(10);
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
expect((float) $log->drift_ratio)->toBe(0.0);
expect($log->status)->toBe('ok');
Mail::assertNothingSent();
});
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
for ($i = 0; $i < 95; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 850000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 95; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
foreach ($junkProjects as $j => $junk) {
$rows[] = ['project' => $junk, 'phone' => '7999600000'.$j];
}
for ($k = 0; $k < 3; $k++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(103);
expect((int) $log->matched_count)->toBe(95);
expect((int) $log->recovered_count)->toBe(3);
expect((int) $log->unparseable_count)->toBe(5);
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
expect($log->status)->toBe('ok');
});