2f55632792
Оба job'а инжектят SupplierProjectChannel (DI → FailoverProjectChannel) вместо прямого SupplierPortalClient. Catch TierEscalatedException + WindowDeferredException — эскалация/перенос пропускают элемент, не валят job. SyncSupplierProjectJob (singular): handle переписан — find-or-create local supplier_projects row, portal-create через channel. ОТКЛОНЕНИЕ ОТ plan Step 8.1: план писал channel-результат (portal external_id) прямо в projects.supplier_b*_ project_id, но эта колонка — FK на supplier_projects.id (local), не portal id. Сохранена семантика ensureSupplierProject — job создаёт local row с supplier_external_id и пишет в FK local id. ensureSupplierProject удалён из SupplierPortalClient (был единственный consumer — этот job). SyncSupplierProjectsJob (plural): handle/syncOne принимают channel; create → createProjectForLiderra, update → updateProjectForLiderra (context-project из liderraProjects->first() для project_id в очереди яруса 3). Tests: singular переписан под SupplierProjectChannel mock (6 tests, incl. idempotency reuse); plural — handle(AjaxProjectChannel) для non-failover ветки (Http::fake-контракт сохранён). Larastan отложен на T12 (worktree quirk — гонится в основной копии). Регрессия Pest 966/963/0 / 3 skipped. Spec §5. Task 8 of 12. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
430 lines
14 KiB
PHP
430 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Exceptions\Supplier\SupplierAuthException;
|
||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||
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\Database\Eloquent\Collection;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\Bus;
|
||
use Illuminate\Support\Facades\Cache;
|
||
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();
|
||
});
|
||
|
||
test('creates supplier_project at supplier when supplier_external_id is null', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'create-flow.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'create-flow.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sp->refresh();
|
||
expect($sp->supplier_external_id)->toBe('555')
|
||
->and($sp->sync_status)->toBe('ok')
|
||
->and($sp->current_limit)->toBe(3);
|
||
|
||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save'));
|
||
});
|
||
|
||
test('updates when diff detected', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'update-flow.example.com',
|
||
'supplier_external_id' => '12345',
|
||
'current_limit' => 1,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'update-flow.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 30,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sp->refresh();
|
||
expect($sp->current_limit)->toBe(10)
|
||
->and($sp->sync_status)->toBe('ok');
|
||
|
||
// Update теперь идёт на тот же endpoint что и save (verified 2026-05-19 — Task 1 recon),
|
||
// с id:N в body вместо id:0.
|
||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && $r['id'] === 12345);
|
||
});
|
||
|
||
test('skips when no diff between current and computed allocation', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'no-diff.example.com',
|
||
'supplier_external_id' => '999',
|
||
'current_limit' => 9,
|
||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||
'current_regions' => [],
|
||
'sync_status' => 'ok',
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'no-diff.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 27,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fake();
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
Http::assertNothingSent();
|
||
});
|
||
|
||
test('isolates failure: one bad supplier_project does not stop others', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$bad = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'bad.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
$good = SupplierProject::factory()->create([
|
||
'platform' => 'B2',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'good.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'bad.example.com',
|
||
'supplier_b1_project_id' => $bad->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'good.example.com',
|
||
'supplier_b2_project_id' => $good->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save')
|
||
->push('bad request', 422)
|
||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
expect(
|
||
SupplierSyncLog::on('pgsql_supplier')
|
||
->where('supplier_project_id', $bad->id)
|
||
->whereNotNull('error_message')
|
||
->exists()
|
||
)->toBeTrue();
|
||
|
||
expect($good->fresh()->supplier_external_id)->toBe('777');
|
||
});
|
||
|
||
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
|
||
Mail::fake();
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
for ($i = 1; $i <= 60; $i++) {
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => "host{$i}.example.com",
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => "host{$i}.example.com",
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
}
|
||
|
||
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();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'audit-log.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'audit-log.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$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('respects time budget by stopping at 20:55 МСК', function (): void {
|
||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'time-budget.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'time-budget.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fake();
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
Http::assertNothingSent();
|
||
});
|
||
|
||
test('passes regions directly to allocator without bitmask conversion', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'regions' => [82, 83],
|
||
'region_mask' => 255,
|
||
]);
|
||
|
||
$job = new SyncSupplierProjectsJob;
|
||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||
|
||
expect($adapted->first()->regions)->toBe([82, 83]);
|
||
});
|
||
|
||
test('passes empty array to allocator when project has regions=[]', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'regions' => [],
|
||
'region_mask' => 255,
|
||
]);
|
||
|
||
$job = new SyncSupplierProjectsJob;
|
||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||
|
||
expect($adapted->first()->regions)->toBe([]);
|
||
});
|
||
|
||
test('sticky auth error throws and sends critical alert email', function (): void {
|
||
Mail::fake();
|
||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'auth-fail.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'auth-fail.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
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('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'regions-flow.example.com',
|
||
'supplier_external_id' => null,
|
||
'current_limit' => 0,
|
||
'current_workdays' => [],
|
||
'current_regions' => [],
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'regions-flow.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
'daily_limit_target' => 9,
|
||
'delivery_days_mask' => 127,
|
||
'regions' => [82, 83],
|
||
'region_mask' => 255,
|
||
'region_mode' => 'include',
|
||
]);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||
|
||
$sp->refresh();
|
||
expect($sp->current_regions)->toBe([82, 83])
|
||
->and($sp->supplier_external_id)->toBe('556');
|
||
});
|