2026-05-20 12:34:27 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\SyncSupplierProjectJob;
|
|
|
|
|
|
use App\Models\Project;
|
|
|
|
|
|
use App\Models\SupplierProject;
|
|
|
|
|
|
use App\Models\Tenant;
|
2026-05-23 15:32:06 +03:00
|
|
|
|
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
2026-05-20 12:34:27 +03:00
|
|
|
|
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
2026-05-23 15:32:06 +03:00
|
|
|
|
use App\Services\Supplier\SupplierPortalClient;
|
2026-05-20 12:34:27 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-20 16:46:27 +03:00
|
|
|
|
// Online mode: single-group supplier_projects + pivot
|
2026-05-20 12:34:27 +03:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-20 16:46:27 +03:00
|
|
|
|
it('online mode creates single-group supplier_projects with full regions + pivot', function (): void {
|
2026-05-20 12:34:27 +03:00
|
|
|
|
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));
|
|
|
|
|
|
|
2026-05-20 16:46:27 +03:00
|
|
|
|
// 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);
|
2026-05-20 12:34:27 +03:00
|
|
|
|
|
|
|
|
|
|
// pivot: 3 links for this project
|
|
|
|
|
|
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-22 03:47:23 +03:00
|
|
|
|
it('online create DIVIDES the limit across B1/B2/B3 so supplier total == project limit (not ×3)', function (): void {
|
|
|
|
|
|
// Money-loss regression (owner-reported 2026-05-21, verified live): the limit was
|
|
|
|
|
|
// replicated full to all 3 platforms (18 → 18/18/18 = supplier could deliver up to 54).
|
|
|
|
|
|
// The portal does NOT divide — each B-project honours its own limit independently.
|
|
|
|
|
|
// Fix: split the limit so Σ per-platform == project limit (18 → 6/6/6).
|
|
|
|
|
|
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
2026-05-28 19:59:23 +03:00
|
|
|
|
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
|
|
|
|
|
Cache::store('redis')->put('supplier:session', [
|
|
|
|
|
|
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
|
|
|
|
|
], now()->addHours(6));
|
2026-05-22 03:47:23 +03:00
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
|
|
|
|
|
$project = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $tenant->id,
|
|
|
|
|
|
'signal_type' => 'call',
|
|
|
|
|
|
'signal_identifier' => '79991110000',
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
'daily_limit_target' => 18,
|
|
|
|
|
|
'regions' => [],
|
|
|
|
|
|
'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$capturedLimits = [];
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedLimits) {
|
|
|
|
|
|
$body = $request->data();
|
|
|
|
|
|
$capturedLimits[] = $body['limit'] ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
return Http::response(['status' => 'OK', 'message' => '', 'id' => '3000'], 200);
|
|
|
|
|
|
},
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
|
|
|
|
|
['id' => '3001', 'src' => 'rt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
|
|
|
|
|
['id' => '3002', 'src' => 'bl', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
|
|
|
|
|
['id' => '3003', 'src' => 'mt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
|
|
|
|
|
]], 200),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
|
|
|
|
|
|
|
|
|
|
|
$sps = SupplierProject::where('unique_key', '79991110000')->get();
|
|
|
|
|
|
expect($sps)->toHaveCount(3);
|
|
|
|
|
|
// Σ per-platform limits == the project limit — the loss-prevention invariant.
|
|
|
|
|
|
expect($sps->sum('current_limit'))->toBe(18);
|
|
|
|
|
|
foreach ($sps as $sp) {
|
|
|
|
|
|
expect($sp->current_limit)->toBe(6); // 18 / 3 platforms
|
|
|
|
|
|
}
|
|
|
|
|
|
// Every limit pushed to the portal is the divided share, never the full 18.
|
|
|
|
|
|
$sent = array_values(array_filter($capturedLimits, fn ($l) => $l !== null));
|
|
|
|
|
|
expect($sent)->not->toBeEmpty();
|
|
|
|
|
|
foreach ($sent as $l) {
|
|
|
|
|
|
expect((int) $l)->toBe(6);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 17:33:46 +03:00
|
|
|
|
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']);
|
2026-05-28 19:59:23 +03:00
|
|
|
|
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
|
|
|
|
|
Cache::store('redis')->put('supplier:session', [
|
|
|
|
|
|
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
|
|
|
|
|
], now()->addHours(6));
|
2026-05-20 17:33:46 +03:00
|
|
|
|
|
|
|
|
|
|
$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']);
|
2026-05-28 19:59:23 +03:00
|
|
|
|
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
|
|
|
|
|
Cache::store('redis')->put('supplier:session', [
|
|
|
|
|
|
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
|
|
|
|
|
], now()->addHours(6));
|
2026-05-20 17:33:46 +03:00
|
|
|
|
|
|
|
|
|
|
$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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 03:47:23 +03:00
|
|
|
|
// listProjects (dead-donor liveness check) must see the seeded donors as alive,
|
|
|
|
|
|
// so the update path runs without recreating (and without hitting the real portal).
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
|
|
|
|
|
['id' => '99B1', 'src' => 'rt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
|
|
|
|
|
['id' => '99B2', 'src' => 'bl', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
|
|
|
|
|
['id' => '99B3', 'src' => 'mt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
|
|
|
|
|
]], 200),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-20 17:33:46 +03:00
|
|
|
|
$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);
|
2026-05-22 03:47:23 +03:00
|
|
|
|
// 9 split across B1/B2/B3 = 3/3/3 (Σ == 9 = project limit, not 9 on each = 27).
|
|
|
|
|
|
expect($sps->sum('current_limit'))->toBe(9);
|
2026-05-20 17:33:46 +03:00
|
|
|
|
foreach ($sps as $sp) {
|
|
|
|
|
|
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
|
2026-05-22 03:47:23 +03:00
|
|
|
|
expect($sp->current_limit)->toBe(3);
|
2026-05-20 17:33:46 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 12:34:27 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 10:59:37 +03:00
|
|
|
|
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']);
|
2026-05-28 19:59:23 +03:00
|
|
|
|
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
|
|
|
|
|
Cache::store('redis')->put('supplier:session', [
|
|
|
|
|
|
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
|
|
|
|
|
], now()->addHours(6));
|
2026-05-21 10:59:37 +03:00
|
|
|
|
|
|
|
|
|
|
$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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-20 12:34:27 +03:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
2026-05-21 15:02:40 +03:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-22 16:52:30 +03:00
|
|
|
|
it('online sync recomputes the WHOLE group: editing one project keeps siblings (union regions + group order, no overwrite)', function (): void {
|
|
|
|
|
|
// Multi-client regression (owner-reported 2026-05-22, verified live): when several
|
|
|
|
|
|
// tenants share one source (identifier), an online edit of ONE project overwrote the
|
|
|
|
|
|
// shared supplier_projects with that single project's regions/limit, wiping the others
|
|
|
|
|
|
// until the nightly batch. Online must recompute the WHOLE group like the nightly job:
|
|
|
|
|
|
// union regions, computeOrder across the group, divided per platform.
|
|
|
|
|
|
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
|
|
|
|
|
|
|
|
|
|
|
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
|
|
|
|
|
|
$t2 = Tenant::factory()->create(['balance_leads' => 100]);
|
|
|
|
|
|
$common = '79991112233';
|
|
|
|
|
|
|
|
|
|
|
|
$p1 = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $t1->id, 'signal_type' => 'call', 'signal_identifier' => $common,
|
|
|
|
|
|
'is_active' => true, 'daily_limit_target' => 10, 'regions' => [82], 'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
$p2 = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $t2->id, 'signal_type' => 'call', 'signal_identifier' => $common,
|
|
|
|
|
|
'is_active' => true, 'daily_limit_target' => 20, 'regions' => [77], 'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// First sync p1 — creates the group's 3 supplier_projects. Both projects already exist
|
|
|
|
|
|
// and share the identifier, so the GROUP has 2 regions [77,82] → union → tag 'РФ'.
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4100'], 200),
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
|
|
|
|
|
['id' => '4101', 'src' => 'rt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common],
|
|
|
|
|
|
['id' => '4102', 'src' => 'bl', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common],
|
|
|
|
|
|
['id' => '4103', 'src' => 'mt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common],
|
|
|
|
|
|
]], 200),
|
|
|
|
|
|
]);
|
|
|
|
|
|
(new SyncSupplierProjectJob($p1->id))->handle(app(SupplierProjectChannel::class));
|
|
|
|
|
|
|
|
|
|
|
|
// Edit-sync p2. Buggy code overwrites group to p2-only ([77], limit 20 full).
|
|
|
|
|
|
// Expected: union regions [77,82], order = computeOrder([10,20]) = max(20, ceil(30/3)) = 20, divided 7/7/6.
|
|
|
|
|
|
$this->mock(SupplierProjectChannel::class, function ($mock): void {
|
|
|
|
|
|
$mock->shouldReceive('updateProject')->andReturn(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
(new SyncSupplierProjectJob($p2->id))->handle(app(SupplierProjectChannel::class));
|
|
|
|
|
|
|
|
|
|
|
|
$sps = SupplierProject::where('unique_key', $common)->get();
|
|
|
|
|
|
expect($sps)->toHaveCount(3);
|
|
|
|
|
|
// Group order, divided so Σ == 20 (NOT 60 from ×3, NOT 20 on each).
|
|
|
|
|
|
expect($sps->sum('current_limit'))->toBe(20);
|
|
|
|
|
|
// Union regions across the whole group — both projects' regions, not just p2's.
|
|
|
|
|
|
$regions = $sps->first()->current_regions;
|
|
|
|
|
|
sort($regions);
|
|
|
|
|
|
expect($regions)->toBe([77, 82]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('online pause: when the group has no active project left, supplier receives status=paused', function (): void {
|
|
|
|
|
|
// Pause regression (#10, owner-reported 2026-05-22): pausing a project never told the
|
|
|
|
|
|
// supplier — DTO status was hardcoded 'active'. Now the group recompute sets status
|
|
|
|
|
|
// 'paused' when no active project remains, and the update is pushed with that status.
|
|
|
|
|
|
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
|
|
|
|
|
$common = '79993334444';
|
|
|
|
|
|
// Project already paused (the action that triggers this sync).
|
|
|
|
|
|
$project = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => $common,
|
|
|
|
|
|
'is_active' => false, 'daily_limit_target' => 6, 'regions' => [], 'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
// Pre-seed the already-synced supplier_projects.
|
|
|
|
|
|
foreach (['B1' => 'PA1', 'B2' => 'PA2', 'B3' => 'PA3'] as $platform => $ext) {
|
|
|
|
|
|
SupplierProject::create([
|
|
|
|
|
|
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common,
|
|
|
|
|
|
'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2,
|
|
|
|
|
|
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
|
|
|
|
|
|
'last_synced_at' => now()->subDay(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
|
|
|
|
|
['id' => 'PA1', 'src' => 'rt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common],
|
|
|
|
|
|
['id' => 'PA2', 'src' => 'bl', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common],
|
|
|
|
|
|
['id' => 'PA3', 'src' => 'mt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common],
|
|
|
|
|
|
]], 200),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$capturedStatuses = [];
|
|
|
|
|
|
$this->mock(SupplierProjectChannel::class, function ($mock) use (&$capturedStatuses): void {
|
|
|
|
|
|
$mock->shouldReceive('updateProject')->andReturnUsing(function ($id, $dto) use (&$capturedStatuses) {
|
|
|
|
|
|
$capturedStatuses[] = $dto->status;
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
|
|
|
|
|
|
|
|
|
|
|
// All 3 platform updates carried status=paused (supplier project is stopped).
|
|
|
|
|
|
expect($capturedStatuses)->toHaveCount(3);
|
|
|
|
|
|
foreach ($capturedStatuses as $st) {
|
|
|
|
|
|
expect($st)->toBe('paused');
|
|
|
|
|
|
}
|
|
|
|
|
|
// Local rows mark inactive_since so UI/DTO reflect the pause.
|
|
|
|
|
|
expect(SupplierProject::where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-23 10:21:10 +03:00
|
|
|
|
it('online create: transient failure on one platform throws so the job retries (partial set not left silently)', function (): void {
|
|
|
|
|
|
// Atomicity gap (owner-approved fix 2026-05-23): the 3 platforms are created by 3
|
|
|
|
|
|
// sequential supplier calls. If one fails transiently, the other 2 are created and the
|
|
|
|
|
|
// 3rd is silently skipped → group under-orders ~1/3 until the next sync. Fix: when a
|
|
|
|
|
|
// platform is skipped for a TRANSIENT reason (not escalation/window-defer), throw so the
|
|
|
|
|
|
// Laravel retry (backoff) re-runs and partial-set recovery fills the missing platform.
|
|
|
|
|
|
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
2026-05-28 19:59:23 +03:00
|
|
|
|
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
|
|
|
|
|
Cache::store('redis')->put('supplier:session', [
|
|
|
|
|
|
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
|
|
|
|
|
], now()->addHours(6));
|
2026-05-23 10:21:10 +03:00
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
|
|
|
|
|
$project = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $tenant->id,
|
|
|
|
|
|
'signal_type' => 'call',
|
|
|
|
|
|
'signal_identifier' => '70000009999',
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
'daily_limit_target' => 9,
|
|
|
|
|
|
'regions' => [],
|
|
|
|
|
|
'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-23 15:32:06 +03:00
|
|
|
|
$this->mock(SupplierPortalClient::class, function ($mock): void {
|
2026-05-23 10:21:10 +03:00
|
|
|
|
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
|
|
|
|
|
|
if ($dto->platform === 'B3') {
|
2026-05-23 15:32:06 +03:00
|
|
|
|
throw new RuntimeException('transient: connection reset by peer');
|
2026-05-23 10:21:10 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [$dto->platform => ($dto->platform === 'B1' ? 6001 : 6002)];
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Transient miss on B3 → job must throw (so Laravel retries).
|
|
|
|
|
|
expect(fn () => (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)))
|
2026-05-23 15:32:06 +03:00
|
|
|
|
->toThrow(RuntimeException::class);
|
2026-05-23 10:21:10 +03:00
|
|
|
|
|
|
|
|
|
|
// Progress is preserved: B1 + B2 are created so the retry only fills B3.
|
|
|
|
|
|
expect(SupplierProject::where('unique_key', '70000009999')->count())->toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('online create: escalation/window-defer of one platform does NOT throw (legitimate skip, no retry)', function (): void {
|
|
|
|
|
|
// Escalation (manual queue) and window-defer (portal after 18:00) are legitimate skips
|
|
|
|
|
|
// with their own recovery (manual queue / nightly batch). Retrying would not help and
|
|
|
|
|
|
// would only spam failed_jobs — so they must NOT trigger the retry throw.
|
|
|
|
|
|
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
2026-05-28 19:59:23 +03:00
|
|
|
|
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
|
|
|
|
|
Cache::store('redis')->put('supplier:session', [
|
|
|
|
|
|
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
|
|
|
|
|
], now()->addHours(6));
|
2026-05-23 10:21:10 +03:00
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
|
|
|
|
|
$project = Project::factory()->create([
|
|
|
|
|
|
'tenant_id' => $tenant->id,
|
|
|
|
|
|
'signal_type' => 'call',
|
|
|
|
|
|
'signal_identifier' => '70000008888',
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
'daily_limit_target' => 9,
|
|
|
|
|
|
'regions' => [],
|
|
|
|
|
|
'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-23 15:32:06 +03:00
|
|
|
|
$this->mock(SupplierPortalClient::class, function ($mock): void {
|
2026-05-23 10:21:10 +03:00
|
|
|
|
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
|
|
|
|
|
|
if ($dto->platform === 'B3') {
|
2026-05-23 15:32:06 +03:00
|
|
|
|
throw new WindowDeferredException('portal window closed');
|
2026-05-23 10:21:10 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [$dto->platform => ($dto->platform === 'B1' ? 7001 : 7002)];
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Legitimate skip on B3 → job completes without throwing.
|
|
|
|
|
|
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
|
|
|
|
|
|
|
|
|
|
|
// B1 + B2 created; B3 left for manual queue / nightly batch (no exception).
|
|
|
|
|
|
expect(SupplierProject::where('unique_key', '70000008888')->count())->toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-21 15:02:40 +03:00
|
|
|
|
it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', function (): void {
|
|
|
|
|
|
// Regression: job ran on the default RLS-enforced connection. On a real queue worker
|
|
|
|
|
|
// (role crm_app_user, no SetTenantContext middleware → no app.current_tenant_id GUC)
|
|
|
|
|
|
// the very first Project::find() dies with SQLSTATE 42704 before any supplier contact,
|
|
|
|
|
|
// so the supplier project is never created and the UI sticks on "Sync pending".
|
|
|
|
|
|
// Every sibling supplier job (SyncSupplierProjectsJob/DeleteSupplierProjectJob/…) uses
|
|
|
|
|
|
// pgsql_supplier; this one must too. On dev (postgres superuser) RLS is bypassed, so we
|
|
|
|
|
|
// assert the *connection* the queries run on rather than RLS enforcement.
|
|
|
|
|
|
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' => 'conn-test.example.com',
|
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
'daily_limit_target' => 10,
|
|
|
|
|
|
'regions' => [],
|
|
|
|
|
|
'delivery_days_mask' => 127,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
|
|
|
|
|
|
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
|
|
|
|
|
['id' => '8001', 'src' => 'rt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
|
|
|
|
|
['id' => '8002', 'src' => 'bl', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
|
|
|
|
|
['id' => '8003', 'src' => 'mt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
|
|
|
|
|
|
]], 200),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Listen only during the job run (factory queries above are already done).
|
|
|
|
|
|
$projectConnections = [];
|
|
|
|
|
|
DB::listen(function ($query) use (&$projectConnections): void {
|
|
|
|
|
|
// '"projects"' (quoted table) does NOT match '"supplier_projects"' or
|
|
|
|
|
|
// '"project_supplier_links"', so this captures only the projects table.
|
|
|
|
|
|
if (str_contains($query->sql, '"projects"')) {
|
|
|
|
|
|
$projectConnections[] = $query->connectionName;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
|
|
|
|
|
|
|
|
|
|
|
expect($projectConnections)->not->toBeEmpty();
|
|
|
|
|
|
expect(array_values(array_unique($projectConnections)))->toBe(['pgsql_supplier']);
|
|
|
|
|
|
});
|
2026-05-28 19:59:23 +03:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Stage 4 / Task 4.3 — R-18 (spec §4.4.2): fixed target_date in online sync.
|
|
|
|
|
|
// Before fix: Carbon::tomorrow('Europe/Moscow')->isoWeekday() flipped target at
|
|
|
|
|
|
// midnight (Thu 23:59 МСК → Fri; Fri 00:01 МСК → Sat). After fix: 21:00 МСК is
|
|
|
|
|
|
// the slepok cut-off boundary, matching supplier's snapshot fix-point.
|
|
|
|
|
|
// hour < 21 МСК → target = today + 1 day
|
|
|
|
|
|
// hour >= 21 МСК → target = today + 2 days
|
|
|
|
|
|
// 2026-05-25 = Mon (ISO 1), 2026-05-26 = Tue (ISO 2), 2026-05-27 = Wed (ISO 3).
|
|
|
|
|
|
// Pure unit test via SyncSupplierProjectJob::targetWeekdayForNow() — bypasses
|
|
|
|
|
|
// factory/DB quirks of full sync downstream-effect assertions.
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
it('R-18 targetWeekdayForNow: hour < 21 МСК → target = today + 1 day (Mon 20:00 МСК → Tue ISO 2)', function (): void {
|
|
|
|
|
|
Carbon::setTestNow(Carbon::parse('2026-05-25 20:00:00', 'Europe/Moscow'));
|
|
|
|
|
|
expect(SyncSupplierProjectJob::targetWeekdayForNow())->toBe(2); // Tue (ISO 2)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('R-18 targetWeekdayForNow: hour >= 21 МСК → target = today + 2 days (Mon 22:00 МСК → Wed ISO 3)', function (): void {
|
|
|
|
|
|
// Discriminator: OLD code (Carbon::tomorrow) gives Tue (2); NEW code gives Wed (3).
|
|
|
|
|
|
Carbon::setTestNow(Carbon::parse('2026-05-25 22:00:00', 'Europe/Moscow'));
|
|
|
|
|
|
expect(SyncSupplierProjectJob::targetWeekdayForNow())->toBe(3); // Wed (ISO 3)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('R-18 targetWeekdayForNow: no midnight flicker — Mon 22:00 and Tue 00:01 point to same Wed', function (): void {
|
|
|
|
|
|
// OLD: Mon 22:00 → tomorrow=Tue (ISO 2); Tue 00:01 → tomorrow=Wed (ISO 3) — FLIPS at midnight.
|
|
|
|
|
|
// NEW: Mon 22:00 → addDays(2)=Wed (ISO 3); Tue 00:01 → addDay=Wed (ISO 3) — CONSISTENT.
|
|
|
|
|
|
Carbon::setTestNow(Carbon::parse('2026-05-26 00:01:00', 'Europe/Moscow'));
|
|
|
|
|
|
expect(SyncSupplierProjectJob::targetWeekdayForNow())->toBe(3); // Wed (ISO 3)
|
|
|
|
|
|
});
|