Files
portal/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
T
Дмитрий 88a284cc91 feat(supplier): R-18 — fixed target_date in online sync (21:00 МСК cut-off)
Extracted SyncSupplierProjectJob::targetWeekdayForNow() — slepok cut-off boundary
is 21:00 МСК, matching supplier's snapshot fix-point. Before fix Carbon::tomorrow
flipped at midnight, mis-aligning portal sync (Thu 23:59 МСК pointed to Fri while
post-21:00 portion of day N belongs to slepok dated N+1 effective day N+2).

  hour <  21 МСК → target = today + 1 day
  hour >= 21 МСК → target = today + 2 days

3 pure unit tests (Mon 20:00→Tue, Mon 22:00→Wed discriminator, Tue 00:01→Wed
no-midnight-flicker) confirm new logic. Baseline regression verified — 8 pre-
existing Pest failures on Windows-native PG env are NOT caused by this change.
Stage 4 §4.4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:59:23 +03:00

691 lines
35 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\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierPortalClient;
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 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']);
// 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));
$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);
}
});
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']);
// 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));
$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']);
// 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));
$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(),
]);
}
// 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),
]);
$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);
// 9 split across B1/B2/B3 = 3/3/3 (Σ == 9 = project limit, not 9 on each = 27).
expect($sps->sum('current_limit'))->toBe(9);
foreach ($sps as $sp) {
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
expect($sp->current_limit)->toBe(3);
}
});
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']);
// 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));
$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);
});
// ---------------------------------------------------------------------------
// Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC
// ---------------------------------------------------------------------------
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);
});
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']);
// 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));
$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,
]);
$this->mock(SupplierPortalClient::class, function ($mock): void {
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
if ($dto->platform === 'B3') {
throw new RuntimeException('transient: connection reset by peer');
}
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)))
->toThrow(RuntimeException::class);
// 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']);
// 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));
$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,
]);
$this->mock(SupplierPortalClient::class, function ($mock): void {
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
if ($dto->platform === 'B3') {
throw new WindowDeferredException('portal window closed');
}
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);
});
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']);
});
// ---------------------------------------------------------------------------
// 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)
});