feat(supplier): Plan 3 Task 7 — CleanupInactiveSupplierProjectsJob (Phase A→B→C)

Daily 02:00 МСК cron, 3 фазы со строгим порядком:
- Phase A: re-activate supplier_projects где появился active liderra
  (СНАЧАЛА — safety: Phase C не удалит недавно вернувшихся)
- Phase B: mark inactive_since=NOW() для newly orphaned
- Phase C: для inactive_since < NOW() - 180d → rt-project-delete + local delete
  + 404 от поставщика → trust 'already deleted' + локальный delete

180-day TTL paritet со spec §3.3. Audit в supplier_sync_log на каждый delete.

SupplierProject не имеет SoftDeletes → используется hard delete; audit-trail
durability через JSONB request_payload snapshot (FK ON DELETE SET NULL зануляет
supplier_project_id, но строка лога остаётся).

+6 тестов (Phase A reactivation / Phase B mark / Phase C delete + audit /
critical ordering safety / 404 trust / < 180d boundary). 19/19 Feature/Supplier PASS.
This commit is contained in:
Дмитрий
2026-05-11 02:41:46 +03:00
parent dedaae5aaa
commit c6859859a3
2 changed files with 344 additions and 0 deletions
@@ -0,0 +1,184 @@
<?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);
// Подзапрос — 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(<<<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,
];
}
}
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
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']);
});
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();
});