Files
portal/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
T
Дмитрий e6beff6aeb fix(supplier): делить лимит между B1/B2/B3, а не дублировать (×N переплата)
Портал поставщика НЕ делит лимит по площадкам сам (Plan 3 R6 «verified 15→5»
оказался ложным — проверено вживую 2026-05-21 через listProjects): каждый
B-проект честно набирает до своего лимита, поэтому одинаковый лимит на B1/B2/B3
= заказ ×N (звонки/сайт ×3, sms+keyword ×2) → переплата поставщику.

Восстановлен per-platform split (был удалён в R6):
- SupplierQuotaAllocator::distributeForPlatform(order, platforms) —
  largest-remainder, Σ долей == заказу (18→6/6/6, 10→4/3/3, 5→3/2).
- SyncSupplierProjectJob (online) + SyncSupplierProjectsJob (ночной):
  create / dead-donor / missing / update — по одной save на площадку с её долей.
  Online делит daily_limit_target; ночной делит групповой computeOrder.

Сторона выдачи клиенту не затронута (RouteSupplierLeadJob по-прежнему режет по
лимиту клиента). Утечка была только на стороне заказа у поставщика.

Tests: allocator 27/27, online job 9/9, nightly job 12/12, broad supplier
suite green. 2 SupplierPortalClient PlaywrightBridge-теста падают только в
worktree-окружении (нет node-модуля playwright) — pre-existing, доказано stash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:50:06 +03:00

462 lines
22 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 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']);
$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']);
$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(),
]);
}
// 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']);
$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('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']);
});