4188fcbc36
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.
180 lines
8.2 KiB
PHP
180 lines
8.2 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|