fix(supplier): в офлайн-режиме не дёргать поставщика при создании проекта
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled

Прод-инцидент 14.07.2026. В batch-режиме портал при создании проекта слал поставщику
«каркас» с limit=0 и без регионов. Кабинет такой запрос ОТБИВАЕТ ВСЕГДА — снято живьём
с боевого 14.07:

    POST /admin/visit/rt-project-save
    {"status":"Error","message":"Введите limit!"}

Дальше портал считал отказ поломкой: дёргал запасной путь через браузер, тот тоже падал,
и проект уезжал в ручную очередь. Итог на бою: 114 неразобранных записей и 2 ложных
high-инцидента «похоже, кабинет поставщика упал» (08.07 и 14.07). Кабинет при этом жив —
проверено запросом с боевого: отдаёт 140 проектов, сессия рабочая.

Лиды и деньги при этом НЕ терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob
(18:00 МСК) — уже с посчитанными лимитами и регионами. Так доехали 19/19 (07.07), 25/26
(08.07), 1/1 (10.07); «недоехавший» проект №20 у поставщика на деле есть (3 строки,
включены, лимит 1+1+1 = заказ клиента) — пусты лишь поля-ссылки в карточке.

Что сделано: handleBatch больше не ходит к поставщику при создании — слать нечего, дневной
лимит считается на cut-off, а не в момент создания. Идемпотентная привязка уже существующих
строк сохранена. Слать limit>0, чтобы кабинет «принял», НЕЛЬЗЯ: у каркаса нет регионов, и
включённая строка потянет лиды со всей страны за деньги клиента.

Тесты: batch-путь переписан под новое правило (поставщик не зовётся, ручная очередь пуста);
разбор проекта на площадки (site/call → B1+B2+B3, sms+keyword → B2+B3, sms → B3) вынесен в
прямые проверки SupplierProjectGrouping — раньше он проверялся через вызовы createProject.

Прогон: 2453/2458 (единственное падение — ExampleTest/Vite manifest, окружение свежего
worktree, к правке отношения не имеет), phpstan 0, pint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 10:44:44 +03:00
parent 71c096af69
commit 7fa811b431
4 changed files with 136 additions and 142 deletions
+17 -36
View File
@@ -431,43 +431,24 @@ class SyncSupplierProjectJob implements ShouldQueue
continue;
}
$dto = new SupplierProjectDto(
platform: $platform,
signalType: (string) $project->signal_type,
uniqueKey: $uniqueKey,
limit: 0,
workdays: $workdays,
regions: [],
regionsReverse: false,
status: 'active',
// 🔴 К поставщику при создании НЕ идём (прод-инцидент 14.07.2026).
//
// Раньше здесь слался «каркас» с limit=0 и без регионов. Кабинет такой
// запрос ОТБИВАЕТ ВСЕГДА: {"status":"Error","message":"Введите limit!"}
// (снято с боевого 14.07). Дальше портал считал отказ поломкой, дёргал
// запасной путь через браузер, тот тоже падал, и проект уезжал в ручную
// очередь — 114 записей мусора и 2 ложных инцидента «кабинет упал».
//
// Настоящие строки создаёт ночной SyncSupplierProjectsJob (18:00) — уже с
// посчитанными лимитами и регионами; так доехали 19/19 (07.07), 25/26
// (08.07), 1/1 (10.07). Слать раньше просто нечего: дневной лимит на завтра
// считается на cut-off, а не в момент создания проекта.
//
// Слать сюда limit>0, чтобы кабинет «принял», НЕЛЬЗЯ: у каркаса нет регионов,
// и включённая строка потянет лиды со всей страны за деньги клиента.
Log::info(
"SyncSupplierProjectJob: project {$project->id} {$platform} — batch, создание отложено до вечернего прогона (18:00 МСК)"
);
try {
$externalId = $channel instanceof FailoverProjectChannel
? $channel->createProjectForLiderra($project, $dto)
: $channel->createProject($dto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
continue;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
continue;
}
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => $project->signal_type,
'unique_key' => $uniqueKey,
'supplier_external_id' => (string) $externalId,
'current_limit' => 0,
'current_workdays' => $workdays,
'current_regions' => null,
'sync_status' => 'ok',
]);
$project->{$column} = $sp->id;
}
$project->save();
+2 -2
View File
@@ -2409,7 +2409,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 6
count: 2
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
-
@@ -3003,7 +3003,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 6
count: 7
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
-
@@ -7,27 +7,73 @@ use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierProjectGrouping;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
// DatabaseTransactions — per-test isolation.
// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS);
// SharesSupplierPdo — SyncSupplierProjectJob пишет через pgsql_supplier (BYPASSRLS);
// без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
* Mock SupplierProjectChannel НЕ instanceof FailoverProjectChannel job идёт
* по ветке createProject() (без эскалации) это и тестируем здесь.
* Failover-эскалация покрыта FailoverProjectChannelTest.
*/
function dispatchJobSync(SyncSupplierProjectJob $job): void
{
$job->handle(app(SupplierProjectChannel::class));
}
it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', function () {
/**
* Прод-инцидент 14.07.2026 (см. SyncSupplierProjectJobTest в tests/Feature/Supplier):
* batch-режим больше НЕ ходит к поставщику при создании кабинет всё равно отбивает
* каркас с limit=0 («Введите limit!»), а строки создаёт вечерний прогон 18:00 МСК.
*
* Раньше этот файл проверял разбор проекта на площадки ЧЕРЕЗ вызовы createProject.
* Сам разбор (site/call B1+B2+B3, sms+keyword B2+B3, sms без keyword B3) никуда
* не делся проверяем его напрямую у SupplierProjectGrouping, без похода к поставщику.
*/
it('site project resolves to B1+B2+B3', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B1', 'B2', 'B3']);
});
it('call project resolves to B1+B2+B3', function () {
$project = Project::factory()->create([
'signal_type' => 'call',
'signal_identifier' => '79161234567',
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B1', 'B2', 'B3']);
});
it('sms project with keyword resolves to B2+B3 only (no B1)', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => 'ипотека',
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B2', 'B3']);
});
it('sms project without keyword resolves to B3 only', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => null,
]);
expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B3']);
});
it('batch create: supplier is never called and nothing lands in the manual queue', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
@@ -36,100 +82,24 @@ it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', fu
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(700001, 700002, 700003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
// FK ведёт на local supplier_projects.id, не на portal external_id.
expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('700001');
});
it('call project: creates B1+B2+B3 with phone signal_identifier', function () {
$project = Project::factory()->create([
'signal_type' => 'call',
'signal_identifier' => '79161234567',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(3)
->andReturn(800001, 800002, 800003);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
expect($project->fresh()->supplier_b2_project_id)->not->toBeNull();
expect($project->fresh()->supplier_b3_project_id)->not->toBeNull();
});
it('sms project with keyword: creates B2+B3 only (no B1)', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => 'ипотека',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(2)
->andReturn(900001, 900002);
$mock->shouldNotReceive('createProject');
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
expect(SupplierProject::where('unique_key', 'okna.ru')->count())->toBe(0);
expect(DB::table('supplier_manual_sync_queue')->where('project_id', $project->id)->count())->toBe(0);
});
it('sms project without keyword: creates B3 only', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => null,
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->once()
->andReturn(910001);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b2_project_id)->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('channel exception: re-throws for queue retry', function () {
it('idempotency: pre-existing supplier_project row is linked, channel still not called', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')
->andThrow(new RuntimeException('timeout'));
});
expect(fn () => dispatchJobSync(new SyncSupplierProjectJob($project->id)))
->toThrow(RuntimeException::class);
});
it('idempotency: pre-existing supplier_project row is reused, channel not called for it', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
// B2 уже существует локально (например, от прошлого частичного запуска).
// B2 уже существует локально (например, создан вечерним прогоном).
$spB2 = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
@@ -137,17 +107,16 @@ it('idempotency: pre-existing supplier_project row is reused, channel not called
'sync_status' => 'failed',
]);
// Channel дёргается только для B1 и B3 — B2 берётся из существующей строки.
$this->mock(SupplierProjectChannel::class, function ($mock) {
$mock->shouldReceive('createProject')->times(2)
->andReturn(700001, 700003);
$mock->shouldNotReceive('createProject');
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
// Существующая строка привязана...
expect($project->supplier_b2_project_id)->toBe($spB2->id);
expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed');
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
// ...а недостающие B1/B3 остаются на вечерний прогон.
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b3_project_id)->toBeNull();
});
@@ -394,9 +394,23 @@ it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-st
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
// ---------------------------------------------------------------------------
it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, no project_supplier_links pivot)', function (): void {
// batch is already set in beforeEach — no change needed
/**
* Прод-инцидент 14.07.2026: «каркас» batch-режима кабинет ОТБИВАЕТ ВСЕГДА.
*
* Живой ответ поставщика на create с limit=0 (снято с боевого 14.07):
* {"status":"Error","message":"Введите limit!"}
*
* Дальше портал считал это поломкой: пробовал запасной путь через браузер, тот тоже
* падал, проект уезжал в ручную очередь (114 записей), а сторож поднимал ложную тревогу
* «кабинет поставщика упал» (2 high-инцидента). При этом сами проекты доезжали до
* поставщика вечером: ночной SyncSupplierProjectsJob (18:00) создаёт их с НАСТОЯЩИМИ
* лимитами и регионами 19/19 (07.07), 25/26 (08.07), 1/1 (10.07) доехали именно так.
*
* Правило: в batch-режиме к поставщику при создании НЕ ходим слать нечего (лимит ещё
* не посчитан), кабинет всё равно откажет. Создание забота вечернего прогона.
* Идемпотентная привязка уже существующих строк остаётся (см. тест ниже).
*/
it('batch mode does NOT call the supplier on create — the nightly job creates rows with real limits', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
@@ -409,23 +423,53 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n
]);
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldReceive('createProject')->times(3)->andReturn(200001, 200002, 200003);
$mock->shouldNotReceive('createProject');
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// Ни строк у поставщика, ни мусора в ручной очереди — просто ждём вечернего прогона.
expect(SupplierProject::where('unique_key', 'batch-test.ru')->count())->toBe(0);
expect(DB::table('supplier_manual_sync_queue')->where('project_id', $project->id)->count())->toBe(0);
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
});
it('batch mode still links supplier_projects rows that already exist (idempotency, no supplier call)', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'already-there.ru',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [82],
'delivery_days_mask' => 127,
]);
foreach (['B1' => '900001', 'B2' => '900002', 'B3' => '900003'] as $platform => $externalId) {
SupplierProject::create([
'platform' => $platform,
'signal_type' => 'site',
'unique_key' => 'already-there.ru',
'supplier_external_id' => $externalId,
'current_limit' => 4,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
]);
}
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldNotReceive('createProject');
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$project->refresh();
// Batch: the old FK columns are set
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
// Batch: каркас → limit=0
$sp = SupplierProject::find($project->supplier_b1_project_id);
expect($sp->current_limit)->toBe(0);
// Batch: no pivot rows (nightly job fills them)
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('900001');
});
// ---------------------------------------------------------------------------