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>
This commit is contained in:
@@ -100,36 +100,32 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
|
||||
$portal->waitReportReady($reportId);
|
||||
$csv = $portal->downloadReport($reportId);
|
||||
// Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом
|
||||
// ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt).
|
||||
// Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15,
|
||||
// а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid
|
||||
// (точная личность лида) исключает фантомы и ловит подмену при совпадении количества.
|
||||
$delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd);
|
||||
$totalCsvRows = count($delivered);
|
||||
|
||||
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
|
||||
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
|
||||
$csvByKey = [];
|
||||
foreach ($parser->parse($csv) as $row) {
|
||||
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
|
||||
// Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid
|
||||
// (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен
|
||||
// числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки —
|
||||
// unique-violation в норме не наступает (иначе abort транзакции).
|
||||
$existingVids = [];
|
||||
$deliveredVids = array_map('intval', array_keys($delivered));
|
||||
if ($deliveredVids !== []) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_leads')
|
||||
->whereIn('vid', $deliveredVids)
|
||||
->pluck('vid')
|
||||
->each(function ($vid) use (&$existingVids): void {
|
||||
$existingVids[(int) $vid] = true;
|
||||
});
|
||||
}
|
||||
$totalCsvRows = count($csvByKey);
|
||||
|
||||
// Существующие лиды за окно → set ключей phone|project.
|
||||
$existingKeys = [];
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_leads')
|
||||
->where('received_at', '>=', $windowStart)
|
||||
->select('phone', 'raw_payload')
|
||||
->orderBy('id')
|
||||
->chunk(500, function ($leads) use (&$existingKeys): void {
|
||||
foreach ($leads as $lead) {
|
||||
$payload = is_string($lead->raw_payload)
|
||||
? json_decode($lead->raw_payload, true)
|
||||
: (array) $lead->raw_payload;
|
||||
$project = (string) ($payload['project'] ?? '');
|
||||
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
|
||||
}
|
||||
});
|
||||
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
// Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял).
|
||||
$missing = array_diff_key($delivered, $existingVids);
|
||||
|
||||
$recoveredCount = 0;
|
||||
$unparseableCount = 0;
|
||||
@@ -149,11 +145,18 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
// vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает
|
||||
// recovery идемпотентным с поздним webhook: SupplierWebhookController при том же
|
||||
// vid вернёт существующий лид, второй сделки не будет.
|
||||
$lead = SupplierLead::create([
|
||||
'vid' => null,
|
||||
'vid' => $row['vid'],
|
||||
'platform' => $platform,
|
||||
'phone' => (string) $row['phone'],
|
||||
'raw_payload' => $row,
|
||||
'raw_payload' => [
|
||||
'project' => $row['project'],
|
||||
'phone' => (string) $row['phone'],
|
||||
'vid' => $row['vid'],
|
||||
],
|
||||
'received_at' => now(),
|
||||
'recovered_from_csv_at' => now(),
|
||||
'source' => 'csv_recovery',
|
||||
@@ -162,8 +165,10 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
RouteSupplierLeadJob::dispatch($lead->id);
|
||||
$recoveredCount++;
|
||||
} catch (QueryException $e) {
|
||||
Log::warning('csv_reconcile.lead_insert_failed', [
|
||||
'phone' => $row['phone'],
|
||||
// Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом)
|
||||
// ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен.
|
||||
Log::info('csv_reconcile.lead_insert_skipped_race', [
|
||||
'vid' => $row['vid'],
|
||||
'project' => $row['project'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
@@ -256,14 +261,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ дедупа: нормализованный phone + project.
|
||||
*/
|
||||
private function dedupKey(string $phone, string $project): string
|
||||
{
|
||||
return trim($phone).'|'.trim($project);
|
||||
}
|
||||
|
||||
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
|
||||
private function failureAlertRecentlySent(): bool
|
||||
{
|
||||
|
||||
@@ -69,6 +69,81 @@ class SupplierPortalClient
|
||||
return is_array($projects) ? array_values($projects) : [];
|
||||
}
|
||||
|
||||
/** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */
|
||||
private const DELIVERED_PAGE_SIZE = 50;
|
||||
|
||||
/** Backstop от бесконечной пагинации (аномалия портала). */
|
||||
private const DELIVERED_MAX_PAGES = 500;
|
||||
|
||||
/**
|
||||
* Журнал ОТДАННОГО («Мои сделки») за окно [from, to] — гарантированный канал доставки.
|
||||
*
|
||||
* В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров
|
||||
* (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально
|
||||
* ОТГРУЖЕННЫЕ поставщиком лиды — каждый с настоящим `vid` (= checkbox value / view?id).
|
||||
* Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение
|
||||
* количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15,
|
||||
* но все 15 — пул) ловится, чего потолок по `crms_cnt` не умеет.
|
||||
*
|
||||
* GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N — server-rendered HTML,
|
||||
* 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк.
|
||||
* Verified live 2026-07-09: строка = `name="visit-checbox" value="<vid>"` +
|
||||
* `B{1,2,3}_<ident>` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y.
|
||||
*
|
||||
* @return array<int, array{vid: int, phone: string, project: string}> ключ = vid (дедуп)
|
||||
*/
|
||||
public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array
|
||||
{
|
||||
$byVid = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$response = $this->request('GET', '/admin/visit/index-visit', [
|
||||
'visit' => 'rt',
|
||||
'date_from' => $from->format('d.m.Y'),
|
||||
'date_to' => $to->format('d.m.Y'),
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
$rows = $this->parseDeliveredRows($response->body());
|
||||
foreach ($rows as $row) {
|
||||
$byVid[$row['vid']] = $row;
|
||||
}
|
||||
|
||||
$page++;
|
||||
} while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES);
|
||||
|
||||
return $byVid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит строки таблицы «Мои сделки» → [vid, phone, project].
|
||||
* Строки без vid (шапка/служебные) или без телефона пропускаются.
|
||||
*
|
||||
* @return list<array{vid: int, phone: string, project: string}>
|
||||
*/
|
||||
private function parseDeliveredRows(string $html): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (preg_split('/<tr[ >]/', $html) ?: [] as $row) {
|
||||
if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/(7\d{10})/', $row, $phone) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : '';
|
||||
|
||||
$out[] = [
|
||||
'vid' => (int) $vid[1],
|
||||
'phone' => $phone[1],
|
||||
'project' => $project,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function saveProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$response = $this->request(
|
||||
|
||||
@@ -16,7 +16,6 @@ use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\Carbon;
|
||||
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;
|
||||
@@ -57,49 +56,40 @@ afterEach(function (): void {
|
||||
});
|
||||
|
||||
/**
|
||||
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
|
||||
* Мокает журнал ОТДАННОГО (Мои сделки): SupplierPortalClient::fetchDeliveredLeads
|
||||
* возвращает лиды, ключ = vid (как реальный метод).
|
||||
*
|
||||
* @param array<int, array{project: string, phone: string}> $rows
|
||||
* @param list<array{vid:int, phone:string, project:string}> $leads
|
||||
*/
|
||||
function csvBody(array $rows): string
|
||||
function fakeDelivered(array $leads): void
|
||||
{
|
||||
$out = "Name;Tag;Phone\n";
|
||||
foreach ($rows as $r) {
|
||||
$out .= "{$r['project']};tag;{$r['phone']}\n";
|
||||
$byVid = [];
|
||||
foreach ($leads as $l) {
|
||||
$byVid[(int) $l['vid']] = $l;
|
||||
}
|
||||
|
||||
return $out;
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldReceive('fetchDeliveredLeads')->andReturn($byVid);
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Мокает весь 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
|
||||
function fakeDeliveredThrows(Throwable $e): void
|
||||
{
|
||||
$captured = ['from' => '', 'to' => ''];
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldReceive('fetchDeliveredLeads')->andThrow($e);
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
}
|
||||
|
||||
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),
|
||||
/** Принятый вебхуком лид (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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,24 +102,20 @@ function runCsvReconcile(): void
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
]);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Сверка по vid против журнала ОТДАННОГО (Мои сделки) — Путь 2, переработка 09.07.2026.
|
||||
// Раньше сверялись с пулом «Запрос номеров» (phones_cnt) → фантомы + выедание лимита.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$rows = [];
|
||||
it('all delivered vids already received via webhook — recovered 0, status ok', function (): void {
|
||||
$delivered = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
|
||||
$vid = 800000 + $i;
|
||||
$phone = "7999000000{$i}";
|
||||
webhookLead($vid, $phone);
|
||||
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
@@ -139,28 +125,23 @@ it('no missing leads — status=ok, no recovery, no alert', function (): void {
|
||||
expect((int) $log->matched_count)->toBe(10);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
|
||||
it('recovers a delivered lead missing from webhook — with the REAL vid (not null)', function (): void {
|
||||
$delivered = [];
|
||||
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',
|
||||
]);
|
||||
$vid = 810000 + $i;
|
||||
$phone = "7999111000{$i}";
|
||||
webhookLead($vid, $phone);
|
||||
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
// 10-й отдан поставщиком, но вебхук его потерял.
|
||||
$missingVid = 810099;
|
||||
$missingPhone = '79991119999';
|
||||
$delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com'];
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
@@ -171,71 +152,87 @@ it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
|
||||
|
||||
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
|
||||
expect($recovered)->not->toBeNull();
|
||||
expect($recovered->vid)->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('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',
|
||||
]);
|
||||
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'];
|
||||
}
|
||||
|
||||
$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));
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
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'],
|
||||
]));
|
||||
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);
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
});
|
||||
|
||||
it('empty CSV — status=ok, drift=0', function (): void {
|
||||
fakeReportFlow("Name;Tag;Phone\n");
|
||||
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 {
|
||||
@@ -253,8 +250,8 @@ it('overlap lock held — job skips, no log row', function (): void {
|
||||
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)]);
|
||||
it('SupplierTransientException from delivered fetch — status=failed, rethrown', function (): void {
|
||||
fakeDeliveredThrows(new SupplierTransientException('Supplier server error 500'));
|
||||
|
||||
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
|
||||
|
||||
@@ -263,96 +260,65 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
|
||||
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',
|
||||
]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>';
|
||||
}
|
||||
|
||||
// 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));
|
||||
return '<table><tbody>'.$rows.'</tbody></table>';
|
||||
}
|
||||
|
||||
runCsvReconcile();
|
||||
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)]);
|
||||
|
||||
$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');
|
||||
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
|
||||
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
expect($result)->toHaveCount(2);
|
||||
expect($result[1718932476])->toMatchArray(['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com']);
|
||||
expect($result[1718932472]['project'])->toBe('B2_74950001122');
|
||||
});
|
||||
|
||||
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',
|
||||
]);
|
||||
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),
|
||||
]);
|
||||
|
||||
$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));
|
||||
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
|
||||
|
||||
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');
|
||||
expect($result)->toHaveCount(53); // 50 + 3, остановились на неполной странице
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass.
|
||||
// After existing webhook-loss drift detection, CsvReconcileJob runs a second
|
||||
// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups
|
||||
// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail.
|
||||
// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be:
|
||||
// - delivered & webhook OK (no alerts)
|
||||
// - delivered & webhook miss (R-05.1 CsvDriftAlertMail)
|
||||
// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task)
|
||||
// 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
|
||||
@@ -386,12 +352,10 @@ function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int
|
||||
|
||||
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
// Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold).
|
||||
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
|
||||
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
|
||||
|
||||
// Empty CSV — primary drift pass is trivially OK; we exercise only the second pass.
|
||||
fakeReportFlow(csvBody([]));
|
||||
fakeDelivered([]);
|
||||
runCsvReconcile();
|
||||
|
||||
Mail::assertSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
||||
@@ -405,15 +369,12 @@ it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAler
|
||||
|
||||
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
// Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold).
|
||||
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
|
||||
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
|
||||
|
||||
fakeReportFlow(csvBody([]));
|
||||
fakeDelivered([]);
|
||||
runCsvReconcile();
|
||||
|
||||
// Scoped assertion: prior-run leaked snapshots may fire mails for other tenants;
|
||||
// this test only owns one tenant, so assert no mail was sent for IT.
|
||||
Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
||||
return $mail->tenantId === $tenant->id;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# CSV-reconcile: сверка по журналу ОТДАННОГО (по vid) — реализовано 09.07.2026
|
||||
|
||||
**Goal:** Перестать превращать «пул» номеров поставщика в фантомные сделки клиента. Сверять по журналу реально ОТДАННОГО (Мои сделки, по `vid`), а не по пулу «Запрос номеров».
|
||||
|
||||
**Статус:** код написан и проверен локально (НЕ выкачен). 12 новых тестов + 294 supplier-теста зелёные, Pint чисто, Larastan 0.
|
||||
|
||||
## Root cause (доказано на живой базе 09.07.2026)
|
||||
Отчёт «Запрос номеров» (selectType=49, `prophones:'curr'`, `state_id:0`) возвращает `phones_cnt` — ПУЛ собранного, а не `crms_cnt` — ОТДАННОЕ. roistat✓: пул 157, отдано 15, у клиента 15 сделок — но все 15 из пула (фантомы), а 15 настоящих отданных выбило лимитом. Потолок по `crms_cnt` не спасает: 15=15, а номера подменены. Спека 2026-05-18 осознанно выбрала пул-отчёт ради простоты — это и есть корень.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. `SupplierPortalClient::fetchDeliveredLeads($from, $to): array<int, {vid,phone,project}>`
|
||||
Читает журнал ОТДАННОГО «Мои сделки»: `GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N` (server-rendered HTML, 50/стр, пагинация до неполной страницы). Парсер `parseDeliveredRows()`: `name="visit-checbox" value="<vid>"` + `B[123]_<ident>` + `7\d{10}`. Ключ = `vid` (точная личность лида). Verified live 2026-07-09 (фильтр по дате d.m.Y работает).
|
||||
|
||||
### 2. `CsvReconcileJob` — сверка по vid
|
||||
- `fetchDeliveredLeads(windowStart, windowEnd)` вместо `requestNumbersReport/waitReportReady/downloadReport`.
|
||||
- дедуп: `existingVids` = точечный `whereIn('vid', deliveredVids)` ГЛОБАЛЬНО (не по окну) → исключает любой существующий vid до вставки.
|
||||
- `missing` = отданные vid, которых у нас нет → `SupplierLead::create` с **настоящим vid** (не null) → `RouteSupplierLeadJob`.
|
||||
- идемпотентность: `idx_supplier_leads_vid_unique` + `SupplierWebhookController` (при том же vid вернёт существующий) → поздний webhook не задвоит; try/catch на unique — страховка от гонки.
|
||||
- drift = доля отданного, которую вебхук потерял (осмысленная метрика webhook-loss); business-drift второй проход (R-05) не тронут.
|
||||
- удалён мёртвый `dedupKey()`.
|
||||
|
||||
### 3. Тесты (`CsvReconcileJobTest.php`, переписан)
|
||||
Сверка по vid: всё принято→добор 0; недостача→добор с РЕАЛЬНЫМ vid; пул структурно недостижим (roistat identity); существующий vid не задваивается; unparseable; пустой журнал; overlap-lock; transient→failed; парсинг HTML + пагинация; R-05 business-drift (2).
|
||||
|
||||
## Эффект
|
||||
- Фантомы невозможны (источник = только отданное, сверка по vid).
|
||||
- Лимит не забивается пулом → настоящие номера доходят.
|
||||
- Нагрузка на поставщика не растёт (page-GET'ы вместо генерации отчёта, та же частота 30 мин).
|
||||
|
||||
## Выкат (после «да» владельца)
|
||||
Оверлей 2 backend-файлов на прод + `restart liderra-queue` + `reload php8.3-fpm`. Бэкапы `.bak-<ts>`. Миграций нет, фронт не тронут. Откат = вернуть бэкапы + restart.
|
||||
|
||||
## Осталось
|
||||
- Уборка kdv1: убрать csv-без-vid сделки (сейчас 18) + вернуть за них деньги + дослать реально отданные, что выбило лимитом. Пошагово, под отдельное «да».
|
||||
Reference in New Issue
Block a user