Files
portal/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
T
Дмитрий b28f858419 fix(supplier): метка канала B1_/B2_/B3_ не стирается при обновлении заказа
Прод-инцидент 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>
2026-07-12 19:14:22 +03:00

709 lines
30 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\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);
});