diff --git a/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php b/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php new file mode 100644 index 00000000..39025585 --- /dev/null +++ b/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php @@ -0,0 +1,184 @@ +delete()` + * = hard delete. Audit-trail сохраняется через supplier_sync_log (FK ON DELETE SET NULL). + * + * NOTE про connection: Job's $connection — queue connection, не DB. Используем + * Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning). + * + * Spec: + * - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §3.3, §4.4 + * - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4 + */ +class CleanupInactiveSupplierProjectsJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public const DB_CONNECTION = 'pgsql_supplier'; + + private const INACTIVE_TTL_DAYS = 180; + + public function handle(?SupplierPortalClient $client = null): void + { + $client ??= app(SupplierPortalClient::class); + + // Подзапрос — DISTINCT id'шники supplier_projects, на которые ссылается + // хотя бы один Лидерра-project с is_active=true через любой из трёх FK. + $activeIdsSubquery = <<<'SQL' + SELECT DISTINCT id FROM ( + SELECT supplier_b1_project_id AS id FROM projects + WHERE is_active = true AND supplier_b1_project_id IS NOT NULL + UNION + SELECT supplier_b2_project_id FROM projects + WHERE is_active = true AND supplier_b2_project_id IS NOT NULL + UNION + SELECT supplier_b3_project_id FROM projects + WHERE is_active = true AND supplier_b3_project_id IS NOT NULL + ) AS active_supplier_ids + SQL; + + // Phase A — re-activate (СНАЧАЛА для safety: до Phase C, чтобы недавно + // вернувшийся supplier_project не был удалён в Phase C). + $reactivated = DB::connection(self::DB_CONNECTION)->update(<< $reactivated]); + + // Phase B — mark newly orphaned (нет ни одного active Лидерра-project). + $marked = DB::connection(self::DB_CONNECTION)->update(<< $marked]); + + // Phase C — delete supplier_projects, inactive > 180 days. + $deleted = 0; + $cutoff = now()->subDays(self::INACTIVE_TTL_DAYS); + + SupplierProject::on(self::DB_CONNECTION) + ->where('inactive_since', '<', $cutoff) + ->cursor() + ->each(function (SupplierProject $sp) use ($client, &$deleted): void { + $this->deleteOne($sp, $client, $deleted); + }); + + Log::info('supplier.cleanup.phase_c_deleted', ['count' => $deleted]); + } + + private function deleteOne(SupplierProject $sp, SupplierPortalClient $client, int &$deleted): void + { + try { + if ($sp->supplier_external_id !== null) { + $client->deleteProject((int) $sp->supplier_external_id); + } + // Audit row ДО локального delete: FK ON DELETE SET NULL занулит + // supplier_project_id у этого лога при последующем delete'е, но + // строка останется в журнале (audit-trail durability). + // request_payload сохраняет id/external_id/key для post-delete-трассировки. + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'delete', + 'http_status' => 200, + 'request_payload' => $this->auditPayload($sp), + 'created_at' => now(), + ]); + $sp->delete(); + $deleted++; + } catch (SupplierClientException $e) { + if ($e->httpStatus === 404) { + // 'Already deleted' on supplier side — trust + local delete. + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'delete', + 'http_status' => 404, + 'error_message' => 'supplier returned 404 (already deleted)', + 'request_payload' => $this->auditPayload($sp), + 'created_at' => now(), + ]); + $sp->delete(); + $deleted++; + + return; + } + + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'delete', + 'http_status' => $e->httpStatus, + 'error_message' => substr($e->getMessage(), 0, 500), + 'request_payload' => $this->auditPayload($sp), + 'created_at' => now(), + ]); + report($e); + } catch (SupplierTransientException $e) { + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'delete', + 'http_status' => $e->httpStatus, + 'error_message' => substr($e->getMessage(), 0, 500), + 'request_payload' => $this->auditPayload($sp), + 'created_at' => now(), + ]); + } + } + + /** + * Денормализованный snapshot supplier_project для audit-trail durability. + * FK ON DELETE SET NULL зануляет supplier_project_id, но request_payload + * сохраняет идентификаторы для post-delete-трассировки в журнале. + * + * @return array + */ + private function auditPayload(SupplierProject $sp): array + { + return [ + 'supplier_project_id' => $sp->id, + 'supplier_external_id' => $sp->supplier_external_id, + 'platform' => $sp->platform, + 'signal_type' => $sp->signal_type, + 'unique_key' => $sp->unique_key, + ]; + } +} diff --git a/app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php new file mode 100644 index 00000000..e70d9fba --- /dev/null +++ b/app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php @@ -0,0 +1,160 @@ +put('supplier:session', [ + 'phpsessid' => 'sess', + 'csrf' => 'csrf', + 'refreshed_at' => now()->toIso8601String(), + ], now()->addHours(6)); + + config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); +}); + +afterEach(function (): void { + Cache::store('redis')->forget('supplier:session'); + Carbon::setTestNow(); +}); + +test('phase A re-activates supplier_project when active liderra project still references it', function (): void { + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'reactivate.example.com', + 'inactive_since' => now()->subDays(30), + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'reactivate.example.com', + 'supplier_b1_project_id' => $sp->id, + ]); + + (new CleanupInactiveSupplierProjectsJob)->handle(); + + expect($sp->fresh()->inactive_since)->toBeNull(); +}); + +test('phase B marks inactive_since=NOW for newly orphaned supplier_project', function (): void { + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'orphan.example.com', + 'inactive_since' => null, + ]); + + Carbon::setTestNow(Carbon::parse('2026-05-12 02:00:00')); + (new CleanupInactiveSupplierProjectsJob)->handle(); + + expect($sp->fresh()->inactive_since)->not->toBeNull(); +}); + +test('phase C deletes supplier_project after 180 days inactive and writes audit row', function (): void { + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'old.example.com', + 'supplier_external_id' => '999', + 'inactive_since' => now()->subDays(181), + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200), + ]); + + (new CleanupInactiveSupplierProjectsJob)->handle(); + + expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->toBeNull(); + // FK ON DELETE SET NULL зануляет supplier_project_id у лога после delete'а + // supplier_project — трассировка через request_payload (JSONB snapshot). + expect( + SupplierSyncLog::on('pgsql_supplier') + ->where('action', 'delete') + ->where('http_status', 200) + ->whereRaw("request_payload->>'supplier_project_id' = ?", [(string) $sp->id]) + ->exists() + )->toBeTrue(); +}); + +test('phase A runs before phase C (safety ordering): returned-active is reactivated, not deleted', function (): void { + $tenant = Tenant::factory()->create(); + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'edge.example.com', + 'supplier_external_id' => '888', + 'inactive_since' => now()->subDays(185), + ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => 'edge.example.com', + 'supplier_b1_project_id' => $sp->id, + ]); + + Http::fake(); + (new CleanupInactiveSupplierProjectsJob)->handle(); + + expect($sp->fresh()?->inactive_since)->toBeNull(); + expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->not->toBeNull(); + Http::assertNothingSent(); +}); + +test('handles 404 from supplier as already-deleted: local delete + audit row with 404 status', function (): void { + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'ghost.example.com', + 'supplier_external_id' => '777', + 'inactive_since' => now()->subDays(181), + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/rt-project-delete' => Http::response('not found', 404), + ]); + + (new CleanupInactiveSupplierProjectsJob)->handle(); + + expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->toBeNull(); + expect( + SupplierSyncLog::on('pgsql_supplier') + ->where('action', 'delete') + ->where('http_status', 404) + ->whereRaw("request_payload->>'supplier_project_id' = ?", [(string) $sp->id]) + ->exists() + )->toBeTrue(); +}); + +test('does not delete supplier_project marked inactive less than 180 days ago', function (): void { + $sp = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'recent.example.com', + 'supplier_external_id' => '555', + 'inactive_since' => now()->subDays(100), + ]); + + Http::fake(); + (new CleanupInactiveSupplierProjectsJob)->handle(); + + expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->not->toBeNull(); + Http::assertNothingSent(); +});