Files
portal/app/tests/Feature/Supplier/SupplierRekeyOrphansCommandTest.php
T
Дмитрий 33b3ac06f2 feat(supplier): R-17 migration — supplier:rekey-orphans artisan command
One-time cleanup of orphan SMS supplier_projects rows created by the now-removed
buildUniqueKey divergence (B3 used sender alone; B2 sender+keyword).

Logic per orphan (sms unique_key without '+', owning project has sms_keyword):
  - no sibling at sender+keyword for same tenant → UPDATE row's unique_key
  - has sibling → dispatch DeleteSupplierProjectJob (cleans up at portal +
    cascades pivot deletion + local row removal)

Discovers orphans via pivot project_supplier_links join (primary path post-Plan-1
pivot rollout). --dry-run flag previews without mutation.

Usage on prod after Stage 4 deploy:
  ssh ubuntu@liderra.ru 'cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans --dry-run'
  # review output
  ssh ubuntu@liderra.ru 'cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans'

3 Pest tests: no-sibling UPDATE path, sibling DELETE-dispatch path, dry-run no-op.
Stage 4 §4.4.1 migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:18:38 +03:00

167 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ---------------------------------------------------------------------------
// Stage 4 / Task 4.2 — R-17 migration (spec §4.4.1): one-time artisan command
// to clean up orphan supplier_projects rows created by the now-removed
// buildUniqueKey divergence.
//
// Before R-17 fix: SMS projects with keyword produced two diverging unique_keys:
// B2 row: unique_key='sender+keyword'
// B3 row: unique_key='sender' (no keyword) — ORPHAN after unification
//
// After fix all platforms use unique_key='sender+keyword'. Existing orphans
// (B3 rows keyed under sender alone) need migration:
// - no sibling at 'sender+keyword' for same tenant → UPDATE row's unique_key
// - has sibling → mark for deletion (dispatch DeleteSupplierProjectJob, which
// also removes the donor from supplier portal + cascades pivot cleanup)
// ---------------------------------------------------------------------------
it('R-17 migrate: orphan SMS row with no sibling → UPDATE unique_key to sender+keyword', function (): void {
$sender = '7913'.fake()->numerify('#######');
$keyword = 'KW'.Str::random(5);
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->asSmsSignal([$sender], $keyword)->create([
'is_active' => true,
'daily_limit_target' => 5,
]);
// Pre-existing orphan: B3 supplier_project keyed under sender alone (legacy buildUniqueKey).
$orphanId88001 = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
'platform' => 'B3',
'signal_type' => 'sms',
'unique_key' => $sender, // orphan key (no '+keyword')
'subject_code' => null,
'supplier_external_id' => '88001',
'current_limit' => 5,
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
'current_regions' => null,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $orphanId88001,
'platform' => 'B3',
]);
$exitCode = $this->artisan('supplier:rekey-orphans')->run();
expect($exitCode)->toBe(0);
// Orphan now has unified key.
$sp = SupplierProject::on('pgsql_supplier')->where('supplier_external_id', '88001')->first();
expect($sp)->not->toBeNull();
expect($sp->unique_key)->toBe($sender.'+'.$keyword);
});
it('R-17 migrate: orphan SMS row WITH sibling at sender+keyword → dispatch DeleteSupplierProjectJob for orphan', function (): void {
Queue::fake();
$sender = '7923'.fake()->numerify('#######');
$keyword = 'KW'.Str::random(5);
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->asSmsSignal([$sender], $keyword)->create([
'is_active' => true,
'daily_limit_target' => 5,
]);
// Sibling B2 row at unified key.
$siblingId = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
'platform' => 'B2',
'signal_type' => 'sms',
'unique_key' => $sender.'+'.$keyword,
'subject_code' => null,
'supplier_external_id' => '88002',
'current_limit' => 5,
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
'current_regions' => null,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $siblingId,
'platform' => 'B2',
]);
// Orphan B3 row under sender alone.
$orphanId = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
'platform' => 'B3',
'signal_type' => 'sms',
'unique_key' => $sender, // orphan
'subject_code' => null,
'supplier_external_id' => '88003',
'current_limit' => 5,
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
'current_regions' => null,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $orphanId,
'platform' => 'B3',
]);
$exitCode = $this->artisan('supplier:rekey-orphans')->run();
expect($exitCode)->toBe(0);
Queue::assertPushed(DeleteSupplierProjectJob::class, function ($job) use ($orphanId) {
return in_array($orphanId, $job->supplierProjectIds, true);
});
});
it('R-17 migrate: --dry-run reports orphans without modifying anything', function (): void {
Queue::fake();
$sender = '7933'.fake()->numerify('#######');
$keyword = 'KW'.Str::random(5);
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->asSmsSignal([$sender], $keyword)->create([
'is_active' => true,
'daily_limit_target' => 5,
]);
$dryOrphanId = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
'platform' => 'B3',
'signal_type' => 'sms',
'unique_key' => $sender, // orphan
'subject_code' => null,
'supplier_external_id' => '88004',
'current_limit' => 5,
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
'current_regions' => null,
'sync_status' => 'ok',
'last_synced_at' => now(),
]);
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $dryOrphanId,
'platform' => 'B3',
]);
$exitCode = $this->artisan('supplier:rekey-orphans', ['--dry-run' => true])->run();
expect($exitCode)->toBe(0);
// Unchanged.
$sp = SupplierProject::on('pgsql_supplier')->where('supplier_external_id', '88004')->first();
expect($sp->unique_key)->toBe($sender);
Queue::assertNothingPushed();
});