Files
portal/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php
T
Дмитрий 4188fcbc36 fix(supplier): R-16 — cleanup uses pivot, not legacy FK
CleanupInactiveSupplierProjectsJob Phase A/B/C subquery determined
active supplier_projects through legacy supplier_b{1,2,3}_project_id FKs,
which are NULL for Plan 3+ projects (using project_supplier_links pivot).
After 180d TTL these supplier_projects would be deleted from supplier,
breaking real lead flow. Subquery now uses pivot.
2026-05-27 04:18:04 +03:00

180 lines
8.2 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);
namespace App\Jobs\Supplier;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Daily 02:00 МСК cron-job: чистит supplier_projects, на которые больше не
* ссылаются активные Лидерра-projects, с 180-дневным TTL и safety ordering.
*
* Алгоритм (3 фазы со строгим порядком Phase A → B → C):
* Phase A (re-activate, СНАЧАЛА для safety): если supplier_project имеет
* inactive_since IS NOT NULL и при этом существует active Лидерра-project
* с FK на него → сбрасываем inactive_since = NULL. Phase A до Phase C —
* чтобы недавно вернувшийся supplier_project не был удалён.
* Phase B (mark): если supplier_project имеет inactive_since IS NULL и на него
* нет ни одного active Лидерра-project через supplier_b{1,2,3}_project_id →
* помечаем inactive_since = NOW().
* Phase C (delete): для supplier_project с inactive_since < NOW() - 180 days
* → DELETE на supplier через rt-project-delete + локальный DELETE +
* audit row в supplier_sync_log. 404 от поставщика = trust 'already deleted'
* + локальный DELETE + audit row с http_status=404.
*
* Active liderra subquery: DISTINCT id supplier_projects, на которые ссылается
* хотя бы один Project WHERE is_active=true через любой из трёх FK supplier_b{1,2,3}_project_id.
*
* SupplierProject НЕ имеет SoftDeletes (см. app/Models/SupplierProject.php) — `->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);
// Источник истинности активности — `project_supplier_links` pivot (Plan 3+).
// Legacy FK `supplier_b{1,2,3}_project_id` оставлены для read-compat,
// но не определяют активность.
$activeIdsSubquery = <<<'SQL'
SELECT DISTINCT psl.supplier_project_id AS id
FROM project_supplier_links psl
INNER JOIN projects p ON p.id = psl.project_id
WHERE p.is_active = true
SQL;
// Phase A — re-activate (СНАЧАЛА для safety: до Phase C, чтобы недавно
// вернувшийся supplier_project не был удалён в Phase C).
$reactivated = DB::connection(self::DB_CONNECTION)->update(<<<SQL
UPDATE supplier_projects
SET inactive_since = NULL
WHERE inactive_since IS NOT NULL
AND id IN ({$activeIdsSubquery})
SQL);
Log::info('supplier.cleanup.phase_a_reactivated', ['count' => $reactivated]);
// Phase B — mark newly orphaned (нет ни одного active Лидерра-project).
$marked = DB::connection(self::DB_CONNECTION)->update(<<<SQL
UPDATE supplier_projects
SET inactive_since = NOW()
WHERE inactive_since IS NULL
AND id NOT IN ({$activeIdsSubquery})
SQL);
Log::info('supplier.cleanup.phase_b_marked', ['count' => $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<string, mixed>
*/
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,
];
}
}