33b3ac06f2
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>
167 lines
6.3 KiB
PHP
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();
|
|
});
|