fix(supplier): в офлайн-режиме не дёргать поставщика при создании проекта
Прод-инцидент 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:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user