diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index e4fbda8f..af425a12 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -134,6 +134,10 @@ class SyncSupplierProjectsJob implements ShouldQueue $groups[$key]['projects'][] = $project; } + // Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе. + /** @var array $activeKeys */ + $activeKeys = array_fill_keys(array_keys($groups), true); + // 3. Sync each group try { foreach ($groups as $group) { @@ -192,6 +196,13 @@ class SyncSupplierProjectsJob implements ShouldQueue continue; } } + + // 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим + // заказы источников, у которых на сегодняшний слепок не осталось ни одного + // активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth). + if (! $aborted) { + $this->deactivateOrphanedOrders($activeKeys); + } } finally { $this->recordRunSummary( startedAt: $startedAt, @@ -205,6 +216,103 @@ class SyncSupplierProjectsJob implements ShouldQueue } } + /** + * Deactivation (18:00, money-critical — owner-reported 2026-07-08, verified on prod live DB): + * гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни + * одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00, + * поэтому единственное окно остановить заказ — этот 18:00-прогон ДО снимка. Без этого + * осиротевший заказ оставался включённым (галочка вкл, лимит > 0) → поставщик лил лиды и + * списывал за остановленный клиентом проект. + * + * Активность — из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active: + * слепок-инвариант — проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает + * поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет. + * + * Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей + * (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с + * ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since + * ТОЛЬКО после успешной отправки (иначе — ретрай следующим прогоном). Реактивацию при + * возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active). + * + * @param array $activeKeys ключи «signal_type|identifier» активных групп + */ + private function deactivateOrphanedOrders(array $activeKeys): int + { + $paused = 0; + + // get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле; + // набор ограничен (активные + ещё-не-погашенные осиротевшие заказы). + $candidates = SupplierProject::on(self::DB_CONNECTION) + ->whereNotNull('supplier_external_id') + ->whereNull('inactive_since') + ->orderBy('id') + ->get(); + + foreach ($candidates as $sp) { + if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) { + continue; // источник ещё активен в сегодняшнем слепке — не трогаем + } + if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) { + break; // время вышло — недогашенное подберёт следующий прогон + } + + $regions = array_map('intval', (array) ($sp->current_regions ?? [])); + $workdays = $sp->current_workdays !== null && $sp->current_workdays !== [] + ? array_map('intval', (array) $sp->current_workdays) + : [1, 2, 3, 4, 5, 6, 7]; + $tag = count($regions) === 1 + ? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0]) + : 'РФ'; + + $dto = new SupplierProjectDto( + platform: $sp->platform, + signalType: (string) $sp->signal_type, + uniqueKey: (string) $sp->unique_key, + limit: max(1, (int) $sp->current_limit), + workdays: $workdays, + regions: $regions, + regionsReverse: false, + status: 'paused', + tag: $tag, + platforms: [$sp->platform], + ); + + try { + $this->channel->updateProject((int) $sp->supplier_external_id, $dto); + } catch (Throwable $e) { + // Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном. + Log::warning('supplier.sync.deactivate_failed', [ + 'unique_key' => (string) $sp->unique_key, + 'platform' => $sp->platform, + 'error' => $e->getMessage(), + ]); + + continue; + } + + $sp->forceFill([ + 'sync_status' => 'ok', + 'last_synced_at' => now(), + 'inactive_since' => now(), + ])->save(); + + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'update', + 'http_status' => 200, + 'created_at' => now(), + ]); + + $paused++; + } + + if ($paused > 0) { + Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]); + } + + return $paused; + } + /** * Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки. * Пишется и при раннем abort (time-budget / mass-fail / auth) — через finally. diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php index 30aa2631..6b91533d 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -577,3 +577,113 @@ test('nightly: re-creates donor on portal when its external_id no longer exists 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(); +});