b28f858419
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.
Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
- итоговая проверка выводила площадку из префикса имени и переставала узнавать
строку -> ложное missing 11.07 и 12.07;
- лид от такой строки приходил с project без метки -> webhook не мог определить
канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.
Что сделано:
- SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
три строки, общего префикса у них нет.
- VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
- Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.
Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.
Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.
Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
709 lines
30 KiB
PHP
709 lines
30 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Exceptions\Supplier\SupplierAuthException;
|
||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||
use App\Jobs\Supplier\VerifySupplierOrderJob;
|
||
use App\Mail\SupplierCriticalAlertMail;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\SupplierSyncLog;
|
||
use App\Models\Tenant;
|
||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\Bus;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
beforeEach(function (): void {
|
||
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
|
||
|
||
Cache::store('redis')->put('supplier:session', [
|
||
'phpsessid' => 'sess',
|
||
'csrf' => 'csrf',
|
||
'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']);
|
||
});
|
||
|
||
afterEach(function (): void {
|
||
Cache::store('redis')->forget('supplier:session');
|
||
Carbon::setTestNow();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multi-region grouping (merged into single group)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Project regions=[82,83] site → 1 group (merged regions) → tag='РФ' →
|
||
* 1 multi-flag save → 3 supplier_projects (platforms B1/B2/B3)
|
||
* subject_code=null, current_regions=[82,83]; pivot — 3 links for the project.
|
||
*/
|
||
test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 supplier_projects + 3 pivot links', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
/** @var Project $project */
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'persubject.example.com',
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [82, 83],
|
||
]);
|
||
insertSnapshotForTomorrow($project, regions: '{82,83}');
|
||
|
||
// One save (merged regions=[82,83] → tag='РФ') + one listProjects
|
||
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' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// 3 supplier_projects (not 6): all regions merged into one group
|
||
$sps = SupplierProject::on('pgsql_supplier')
|
||
->where('unique_key', 'persubject.example.com')
|
||
->where('signal_type', 'site')
|
||
->get();
|
||
|
||
expect($sps)->toHaveCount(3);
|
||
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||
|
||
// subject_code=null (no per-subject split)
|
||
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
|
||
|
||
// regions merged: [82, 83] — sorted ascending, stored on each SP
|
||
expect($sps->firstWhere('platform', 'B1')->current_regions)->toBe([82, 83]);
|
||
|
||
// pivot: 3 links (not 6)
|
||
$pivotCount = DB::table('project_supplier_links')
|
||
->where('project_id', $project->id)
|
||
->count();
|
||
expect($pivotCount)->toBe(3);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// All-RF pool
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
/** @var Project $project */
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'rf-pool.example.com',
|
||
'daily_limit_target' => 6,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
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' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||
['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||
['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sps = SupplierProject::on('pgsql_supplier')
|
||
->where('unique_key', 'rf-pool.example.com')
|
||
->where('signal_type', 'site')
|
||
->get();
|
||
|
||
expect($sps)->toHaveCount(3);
|
||
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
|
||
expect($sps->pluck('current_regions')->first())->toBe([]);
|
||
|
||
// pivot
|
||
$pivotCount = DB::table('project_supplier_links')
|
||
->where('project_id', $project->id)
|
||
->count();
|
||
expect($pivotCount)->toBe(3);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Order: 2 projects on one (source × subject) → computeOrder
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$project1 = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'order-test.example.com',
|
||
'daily_limit_target' => 10,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project1);
|
||
|
||
$project2 = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'order-test.example.com',
|
||
'daily_limit_target' => 20,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project2);
|
||
|
||
// saveProjectMultiFlag called once (both projects share same group)
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'],
|
||
200,
|
||
),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||
['projects' => [
|
||
['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||
['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||
['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 (the GROUP order), then split
|
||
// across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend).
|
||
$sps = SupplierProject::on('pgsql_supplier')
|
||
->where('unique_key', 'order-test.example.com')
|
||
->get();
|
||
|
||
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
|
||
expect($sps)->toHaveCount(3);
|
||
expect($sps->sum('current_limit'))->toBe(20);
|
||
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7);
|
||
});
|
||
|
||
test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void {
|
||
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
|
||
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
|
||
$tenant = Tenant::factory()->create();
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79135161263',
|
||
'daily_limit_target' => 18,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||
['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||
['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||
['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||
]], 200),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// Assert only THIS group's rows (the nightly job syncs every active project in the DB).
|
||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get();
|
||
expect($sps)->toHaveCount(3);
|
||
expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54)
|
||
expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all())
|
||
->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SMS platforms
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'sms',
|
||
'signal_identifier' => null,
|
||
'sms_senders' => ['79001234567'],
|
||
'sms_keyword' => 'KVARTIRA',
|
||
'daily_limit_target' => 5,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'],
|
||
200,
|
||
),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||
['projects' => [
|
||
['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
|
||
['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sps = SupplierProject::on('pgsql_supplier')
|
||
->where('signal_type', 'sms')
|
||
->get();
|
||
|
||
// sms+keyword → B2+B3 only
|
||
expect($sps)->toHaveCount(2);
|
||
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
|
||
expect($sps->where('platform', 'B1')->count())->toBe(0);
|
||
});
|
||
|
||
test('sms without keyword → platform B3 only (1 supplier_project)', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'sms',
|
||
'signal_identifier' => null,
|
||
'sms_senders' => ['79009876543'],
|
||
'sms_keyword' => null,
|
||
'daily_limit_target' => 5,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'],
|
||
200,
|
||
),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||
['projects' => [
|
||
['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sps = SupplierProject::on('pgsql_supplier')
|
||
->where('signal_type', 'sms')
|
||
->get();
|
||
|
||
expect($sps)->toHaveCount(1);
|
||
expect($sps->first()->platform)->toBe('B3');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'idempotent.example.com',
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
// First run: create
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
|
||
200,
|
||
),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||
['projects' => [
|
||
['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||
['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||
['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
expect(SupplierProject::on('pgsql_supplier')
|
||
->where('unique_key', 'idempotent.example.com')
|
||
->count())->toBe(3);
|
||
|
||
// Second run: no changes → updateProject calls (rt-project-save with id != 0)
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// Still 3 (no duplicates)
|
||
expect(SupplierProject::on('pgsql_supplier')
|
||
->where('unique_key', 'idempotent.example.com')
|
||
->count())->toBe(3);
|
||
|
||
// updateProject sends id != 0
|
||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save')
|
||
&& (int) ($r['id'] ?? 0) !== 0);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Orthogonal: time budget, auth, abort-50, sync_log
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||
// Итоговая проверка (VerifySupplierOrderJob) диспатчится в finally и сама шлёт HTTP
|
||
// при реальном прогоне — вне области этого теста (тут проверяем только САМ робот).
|
||
Bus::fake([VerifySupplierOrderJob::class]);
|
||
|
||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'time-budget.example.com',
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
Http::fake();
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
Http::assertNothingSent();
|
||
});
|
||
|
||
test('sticky auth error throws and sends critical alert email', function (): void {
|
||
Mail::fake();
|
||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'auth-fail.example.com',
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
|
||
]);
|
||
|
||
expect(fn () => (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)))
|
||
->toThrow(SupplierAuthException::class);
|
||
|
||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||
return $mail->alertType === 'sticky_auth';
|
||
});
|
||
});
|
||
|
||
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
|
||
Mail::fake();
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
for ($i = 1; $i <= 60; $i++) {
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => "host{$i}.abort.com",
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
}
|
||
|
||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||
return $mail->alertType === 'mass_transient';
|
||
});
|
||
});
|
||
|
||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'audit-log.example.com',
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||
200,
|
||
),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||
['projects' => [
|
||
['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||
['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||
['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||
]],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// 3 supplier_projects created → 3 log rows (one per platform)
|
||
$sp = SupplierProject::on('pgsql_supplier')
|
||
->where('unique_key', 'audit-log.example.com')
|
||
->where('platform', 'B1')
|
||
->first();
|
||
|
||
expect($sp)->not->toBeNull();
|
||
|
||
$log = SupplierSyncLog::on('pgsql_supplier')
|
||
->where('supplier_project_id', $sp->id)
|
||
->first();
|
||
|
||
expect($log)->not->toBeNull()
|
||
->and($log->action)->toBe('create')
|
||
->and($log->http_status)->toBe(200)
|
||
->and($log->error_message)->toBeNull();
|
||
});
|
||
|
||
test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void {
|
||
// Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale
|
||
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
|
||
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
|
||
$tenant = Tenant::factory()->create();
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79993334455',
|
||
'daily_limit_target' => 10,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [],
|
||
]);
|
||
insertSnapshotForTomorrow($project);
|
||
|
||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||
'platform' => $platform,
|
||
'signal_type' => 'call',
|
||
'unique_key' => '79993334455',
|
||
'subject_code' => null,
|
||
'supplier_external_id' => 'GONE'.$platform,
|
||
'current_limit' => 10,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||
'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' => '8003'], 200),
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
|
||
$loadCalls++;
|
||
if ($loadCalls === 1) {
|
||
return Http::response(['projects' => []], 200);
|
||
}
|
||
|
||
return Http::response(['projects' => [
|
||
['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||
['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||
['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||
]], 200);
|
||
},
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get();
|
||
expect($sps)->toHaveCount(3);
|
||
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Deactivation (18:00): turn OFF orders whose source has no active project left.
|
||
// Money-critical (owner-reported 2026-07-08, verified on prod live DB): prod runs
|
||
// in BATCH mode. The supplier fixes tomorrow's delivery by its 21:00 snapshot, so the
|
||
// ONLY window to STOP an order is the 18:00 batch sync — before that snapshot. This
|
||
// nightly job only processed ACTIVE projects and never turned OFF a source whose
|
||
// projects are all now paused → the paused order stayed active at the supplier → next
|
||
// day leads (and charges) kept coming. The 18:00 job must push status=paused for such
|
||
// orphaned orders before 21:00.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('nightly 18:00: a source with no active project left is turned OFF (status=paused) at the supplier', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$common = '79997778899';
|
||
|
||
// Project is PAUSED → excluded from tomorrow's snapshot (SnapshotProjectRoutingJob
|
||
// filters is_active=true). Deliberately NO insertSnapshotForTomorrow — nothing active
|
||
// references this source tonight, so its supplier order is orphaned.
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id, 'is_active' => false, 'signal_type' => 'call',
|
||
'signal_identifier' => $common, 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [],
|
||
]);
|
||
|
||
// Pre-seed the supplier orders created earlier when the project was live (numeric
|
||
// external ids so updateProject's (int) cast keeps id != 0).
|
||
foreach (['B1' => '90001', 'B2' => '90002', 'B3' => '90003'] as $platform => $ext) {
|
||
SupplierProject::on('pgsql_supplier')->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(),
|
||
]);
|
||
}
|
||
|
||
$savePayloads = [];
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
|
||
$savePayloads[] = $request->data();
|
||
|
||
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
|
||
},
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// The 3 orphaned orders were turned OFF: update (id != 0) with status=false.
|
||
$offCalls = array_values(array_filter(
|
||
$savePayloads,
|
||
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
|
||
));
|
||
expect($offCalls)->toHaveCount(3);
|
||
// Non-zero limit kept (portal rejects limit=0 even when paused).
|
||
foreach ($offCalls as $p) {
|
||
expect((int) $p['limit'])->toBeGreaterThan(0);
|
||
}
|
||
// Local rows marked inactive_since so cleanup/UI reflect the pause.
|
||
expect(SupplierProject::on('pgsql_supplier')->where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
|
||
});
|
||
|
||
test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in DB while its snapshot tail still routes', function (): void {
|
||
// Composition of the new deactivation pass with delete-defer (DeleteSupplierProjectTailGuardTest):
|
||
// when a project is deleted but its donor is deferred (so already-ordered tail leads still
|
||
// route via the snapshot), the 18:00 pass must still turn the order OFF at the supplier
|
||
// (stop FUTURE ordering) — WITHOUT deleting the local donor (the tail still needs it).
|
||
$tenant = Tenant::factory()->create();
|
||
$common = '79996665544';
|
||
|
||
// Donor exists (created when the project was live). Project already hard-deleted → no pivot
|
||
// links, and NOT in tomorrow's active snapshot.
|
||
$sp = SupplierProject::on('pgsql_supplier')->create([
|
||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => $common,
|
||
'subject_code' => null, 'supplier_external_id' => '95001', 'current_limit' => 3,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
|
||
'last_synced_at' => now()->subDay(),
|
||
]);
|
||
// Tail snapshot for the source (fake project_id → not an active project, mirrors the
|
||
// delete tail-guard fixture). LeadRouter still needs the donor for tail matching.
|
||
DB::table('project_routing_snapshots')->insert([
|
||
'snapshot_date' => Carbon::tomorrow('Europe/Moscow')->toDateString(),
|
||
'project_id' => 987654, 'tenant_id' => $tenant->id, 'daily_limit' => 5,
|
||
'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call',
|
||
'signal_identifier' => $common, 'sms_senders' => null, 'sms_keyword' => null,
|
||
'expected_volume' => 5, 'delivered_count' => 0, 'created_at' => now(),
|
||
]);
|
||
|
||
$savePayloads = [];
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
|
||
$savePayloads[] = $request->data();
|
||
|
||
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
|
||
},
|
||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
// Order turned OFF at the supplier (update id != 0, status=false) — stops future ordering.
|
||
$off = array_values(array_filter(
|
||
$savePayloads,
|
||
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
|
||
));
|
||
expect($off)->toHaveCount(1);
|
||
// The local donor record is KEPT (not deleted) so the snapshot tail still routes; marked inactive.
|
||
$fresh = SupplierProject::on('pgsql_supplier')->find($sp->id);
|
||
expect($fresh)->not->toBeNull();
|
||
expect($fresh->inactive_since)->not->toBeNull();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Итоговая проверка: робот после прогона диспатчит VerifySupplierOrderJob
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('handle() dispatches VerifySupplierOrderJob after run (finally block)', function (): void {
|
||
Bus::fake([VerifySupplierOrderJob::class]);
|
||
|
||
// Минимальный сценарий: нет ни одного eligible-проекта (как в тесте time-budget) —
|
||
// групп нет, но finally всё равно должен отработать и запустить итоговую проверку.
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
Bus::assertDispatched(VerifySupplierOrderJob::class);
|
||
});
|