b28f858419
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.
Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
- итоговая проверка выводила площадку из префикса имени и переставала узнавать
строку -> ложное missing 11.07 и 12.07;
- лид от такой строки приходил с project без метки -> webhook не мог определить
канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.
Что сделано:
- SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
три строки, общего префикса у них нет.
- VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
- Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.
Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.
Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.
Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
11 KiB
PHP
215 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\Supplier\VerifySupplierOrderJob;
|
||
use App\Mail\SupplierOrderMismatchMail;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\Tenant;
|
||
use App\Services\Supplier\SupplierPortalClient;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
beforeEach(function (): void {
|
||
Carbon::setTestNow(Carbon::parse('2026-07-09 18:04:00', 'Europe/Moscow'));
|
||
});
|
||
|
||
afterEach(function (): void {
|
||
Carbon::setTestNow();
|
||
});
|
||
|
||
/**
|
||
* Активный call-проект daily_limit_target=3 → задуманный заказ order=max(3, ceil(3/3))=3,
|
||
* поделённый на B1/B2/B3 (largest-remainder) → по 1 на каждую площадку.
|
||
* Снимок на завтра ОБЯЗАТЕЛЕН — collectEligibleProjects() читает project_routing_snapshots,
|
||
* не live projects (Task 2.9 slepok-инвариант).
|
||
*/
|
||
function seedActiveCallProject(string $identifier, int $limit): Project
|
||
{
|
||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||
$project = Project::factory()->for($tenant)->create([
|
||
'is_active' => true,
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => $identifier,
|
||
'daily_limit_target' => $limit,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project, dailyLimit: $limit, deliveryDaysMask: 127);
|
||
|
||
return $project;
|
||
}
|
||
|
||
/**
|
||
* Наша supplier_project, которую мы уже выключили (inactive_since IS NOT NULL) —
|
||
* SupplierOrderVerifier::diff должен поймать kind=should_be_off, если поставщик
|
||
* до сих пор показывает её включённой (status=1). supplier_external_id — признак
|
||
* «наша строка» в normalizeLive() (баг 1: tag НЕ годится — робот шлёт регион/«РФ»).
|
||
*/
|
||
function seedPausedSupplierProject(string $identifier, string $platform, string $signalType, string $externalId): SupplierProject
|
||
{
|
||
return SupplierProject::factory()->create([
|
||
'platform' => $platform,
|
||
'signal_type' => $signalType,
|
||
'unique_key' => $identifier,
|
||
'supplier_external_id' => $externalId,
|
||
'inactive_since' => now(),
|
||
]);
|
||
}
|
||
|
||
it('нет расхождений → письма нет, статус ok', function (): void {
|
||
Mail::fake();
|
||
seedActiveCallProject('79135397707', 3);
|
||
|
||
SupplierProject::factory()->create([
|
||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||
'supplier_external_id' => '1001', 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
SupplierProject::factory()->create([
|
||
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||
'supplier_external_id' => '1002', 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
SupplierProject::factory()->create([
|
||
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||
'supplier_external_id' => '1003', 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
|
||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||
$m->shouldReceive('listProjects')->andReturn([
|
||
['id' => '1001', 'src' => 'rt', 'name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
['id' => '1002', 'src' => 'bl', 'name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
['id' => '1003', 'src' => 'mt', 'name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
]);
|
||
});
|
||
|
||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||
|
||
Mail::assertNothingQueued();
|
||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||
expect($row)->not->toBeNull();
|
||
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
|
||
});
|
||
|
||
it('пауза не дошла (should_be_off) → письмо + статус mismatch на попытке 2', function (): void {
|
||
Mail::fake();
|
||
seedPausedSupplierProject('automoney.ru', 'B3', 'site', '2001');
|
||
|
||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||
$m->shouldReceive('listProjects')->andReturn([
|
||
['id' => '2001', 'src' => 'mt', 'name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
]);
|
||
});
|
||
|
||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||
|
||
Mail::assertQueued(SupplierOrderMismatchMail::class);
|
||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||
expect($row)->not->toBeNull();
|
||
expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(1);
|
||
});
|
||
|
||
/*
|
||
* Прод-инцидент 11–12.07.2026: письма «missing» по строкам, которые в кабинете ЕСТЬ,
|
||
* включены и с верным лимитом. Причина — normalizeLive() выводил площадку из ПРЕФИКСА
|
||
* ИМЕНИ (B1_/B2_/B3_), а кабинет ставит этот префикс только при СОЗДАНИИ строки: наш
|
||
* ежедневный updateProject слал имя без префикса и затирал его. Строка без префикса
|
||
* выпадала из live → ложное «нет заказа у поставщика».
|
||
*
|
||
* Площадку надо брать из служебного поля src (rt→B1, bl→B2, mt→B3) — как уже делает
|
||
* saveProjectMultiFlag. Тогда сверка не зависит от имени вообще.
|
||
*/
|
||
it('строки с затёртым префиксом имени опознаются по src → НЕ ложная тревога (прод 11–12.07)', function (): void {
|
||
Mail::fake();
|
||
seedActiveCallProject('79135397707', 3);
|
||
|
||
foreach (['B1' => '4001', 'B2' => '4002', 'B3' => '4003'] as $platform => $ext) {
|
||
SupplierProject::factory()->create([
|
||
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||
'supplier_external_id' => $ext, 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
}
|
||
|
||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||
// Имя — голый ключ, БЕЗ префикса (ровно то, что отдаёт боевой кабинет 12.07).
|
||
$m->shouldReceive('listProjects')->andReturn([
|
||
['id' => '4001', 'src' => 'rt', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
['id' => '4002', 'src' => 'bl', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
['id' => '4003', 'src' => 'mt', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
]);
|
||
});
|
||
|
||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||
|
||
Mail::assertNothingQueued();
|
||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||
expect($row)->not->toBeNull();
|
||
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
|
||
});
|
||
|
||
it('настоящее «нет заказа» по-прежнему ловится: строки в кабинете нет вовсе', function (): void {
|
||
Mail::fake();
|
||
seedActiveCallProject('79135397707', 3);
|
||
|
||
foreach (['B1' => '5001', 'B2' => '5002', 'B3' => '5003'] as $platform => $ext) {
|
||
SupplierProject::factory()->create([
|
||
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||
'supplier_external_id' => $ext, 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
}
|
||
|
||
// Кабинет отдал только B1 (rt) — B2/B3 реально отсутствуют.
|
||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||
$m->shouldReceive('listProjects')->andReturn([
|
||
['id' => '5001', 'src' => 'rt', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
]);
|
||
});
|
||
|
||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||
|
||
Mail::assertQueued(SupplierOrderMismatchMail::class);
|
||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||
expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(2);
|
||
});
|
||
|
||
it('ключ активен по формуле, но inactive_since завис → НЕ ложная тревога (баг 2)', function (): void {
|
||
Mail::fake();
|
||
seedActiveCallProject('79999999999', 3);
|
||
|
||
// B1 «завис» с inactive_since, хотя группа сейчас активна по формуле —
|
||
// должен быть исключён из shouldBeOff, а не дать ложный should_be_off.
|
||
SupplierProject::factory()->create([
|
||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79999999999',
|
||
'supplier_external_id' => '3001', 'current_limit' => 1, 'inactive_since' => now(),
|
||
]);
|
||
SupplierProject::factory()->create([
|
||
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79999999999',
|
||
'supplier_external_id' => '3002', 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
SupplierProject::factory()->create([
|
||
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79999999999',
|
||
'supplier_external_id' => '3003', 'current_limit' => 1, 'inactive_since' => null,
|
||
]);
|
||
|
||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||
$m->shouldReceive('listProjects')->andReturn([
|
||
['id' => '3001', 'src' => 'rt', 'name' => 'B1_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
['id' => '3002', 'src' => 'bl', 'name' => 'B2_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
['id' => '3003', 'src' => 'mt', 'name' => 'B3_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||
]);
|
||
});
|
||
|
||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||
|
||
Mail::assertNothingQueued();
|
||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||
expect($row)->not->toBeNull();
|
||
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
|
||
});
|