Files
portal/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
T
Дмитрий 9eff9aaafd fix(supplier): recreate deleted donor + fill legacy FK in online sync
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:21:42 +03:00

350 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'sess123',
'csrf' => 'csrf123',
'refreshed_at' => now()->toIso8601String(),
], now()->addHours(6));
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
config(['services.supplier.alert_email' => 'ops@liderra.test']);
// Default to batch mode so existing Plan5 tests are unaffected
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
});
afterEach(function (): void {
Cache::store('redis')->forget('supplier:session');
Carbon::setTestNow();
});
// ---------------------------------------------------------------------------
// Online mode: single-group supplier_projects + pivot
// ---------------------------------------------------------------------------
it('online mode creates single-group supplier_projects with full regions + pivot', function (): void {
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
'is_active' => true,
'daily_limit_target' => 12,
'regions' => [82],
'delivery_days_mask' => 127,
]);
// saveProjectMultiFlag → rt-project-save + listProjects → 3 ids
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '1001', 'src' => 'rt', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
['id' => '1002', 'src' => 'bl', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
['id' => '1003', 'src' => 'mt', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
]],
200,
),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// 3 supplier_projects: subject_code=null (single group), platforms B1/B2/B3
expect(SupplierProject::where('unique_key', 'okna.ru')->whereNull('subject_code')->count())->toBe(3);
// pivot: 3 links for this project
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79135191264',
'is_active' => true,
'daily_limit_target' => 15,
'regions' => [],
'delivery_days_mask' => 31, // Пн-Пт
]);
$capturedWorkdays = null;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedWorkdays) {
$body = $request->data();
if (isset($body['workdays'])) {
$capturedWorkdays = $body['workdays'];
}
return Http::response(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '2001', 'src' => 'rt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
['id' => '2002', 'src' => 'bl', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
['id' => '2003', 'src' => 'mt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'],
]],
200,
),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// 1) supplier_projects записаны с реальными буднями, не all-7.
$sps = SupplierProject::where('unique_key', '79135191264')->get();
expect($sps)->toHaveCount(3);
foreach ($sps as $sp) {
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
}
// 2) HTTP payload к порталу содержал ["1","2","3","4","5"], не ["1".."7"].
expect($capturedWorkdays)->toBe(['1', '2', '3', '4', '5']);
});
it('online mode update-path: existing supplier_projects.current_workdays is refreshed (not just regions/limit)', function (): void {
// Regression: forceFill ранее не включал current_workdays — после первого create со
// старым хардкод-[1..7] последующий ресинк не подтягивал реальные дни.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79991234567',
'is_active' => true,
'daily_limit_target' => 9,
'regions' => [],
'delivery_days_mask' => 31, // Пн-Пт
]);
// Pre-seed existing supplier_projects со старыми (хардкод-)workdays.
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::create([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79991234567',
'subject_code' => null,
'supplier_external_id' => '99'.$platform,
'current_limit' => 6,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$sps = SupplierProject::where('unique_key', '79991234567')->get();
expect($sps)->toHaveCount(3);
foreach ($sps as $sp) {
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
expect($sp->current_limit)->toBe(9);
}
});
it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_projects + 3 pivot links', function (): void {
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'allrf.example.com',
'is_active' => true,
'daily_limit_target' => 6,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'],
200,
),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
['projects' => [
['id' => '500', 'src' => 'rt', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
['id' => '501', 'src' => 'bl', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
['id' => '502', 'src' => 'mt', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
]],
200,
),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$sps = SupplierProject::where('unique_key', 'allrf.example.com')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
it('online mode re-creates donor on portal when its external_id no longer exists there', function (): void {
// Regression: если донора удалили на портале, в нашей БД остаются supplier_projects
// с мёртвыми external_id. Раньше джоб шёл по update-ветке → updateProject мёртвого id
// портал молча принимает (no-op) → донор не пересоздаётся. Фикс: проверять, жив ли
// external_id на портале (listProjects), и пересоздавать недостающих in-place
// (НЕ удаляя записи — на них могут висеть лиды/списания).
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79990001122',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [],
'delivery_days_mask' => 31,
]);
// Pre-seed supplier_projects, чьи external_id указывают на удалённых с портала доноров.
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::create([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79990001122',
'subject_code' => null,
'supplier_external_id' => 'DEAD'.$platform,
'current_limit' => 10,
'current_workdays' => [1, 2, 3, 4, 5],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$loadCalls = 0;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '7003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
$loadCalls++;
// Первый load = проверка существования → донор удалён (пусто).
if ($loadCalls === 1) {
return Http::response(['projects' => []], 200);
}
// Последующие load (внутри saveProjectMultiFlag) = свежесозданные доноры.
return Http::response(['projects' => [
['id' => '7001', 'src' => 'rt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
['id' => '7002', 'src' => 'bl', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
['id' => '7003', 'src' => 'mt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
]], 200);
},
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// external_id переписаны на свежесозданных доноров (не DEAD*), записи не удалены.
$sps = SupplierProject::where('unique_key', '79990001122')->orderBy('platform')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['7001', '7002', '7003']);
});
it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-status is not stuck pending', function (): void {
// Regression: online mode writes the link to the pivot, but ProjectResource/aggregateSyncStatus
// read the legacy FK columns (supplierB1/B2/B3). They stayed NULL in online → "Sync pending"
// forever even though the stack is synced. Online must populate them too.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'uisync.example.com',
'is_active' => true,
'daily_limit_target' => 5,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '9003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '9001', 'src' => 'rt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
['id' => '9002', 'src' => 'bl', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
['id' => '9003', 'src' => 'mt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
]], 200),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$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();
expect($project->aggregateSyncStatus())->toBe('ok');
});
// ---------------------------------------------------------------------------
// 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
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'batch-test.ru',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [82],
'delivery_days_mask' => 127,
]);
$this->mock(SupplierProjectChannel::class, function ($mock): void {
$mock->shouldReceive('createProject')->times(3)->andReturn(200001, 200002, 200003);
});
(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);
});