fix(supplier): recreate deleted donor + fill legacy FK in online sync
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -281,6 +281,52 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// External-deletion recovery: донор мог быть удалён на портале → external_id
|
||||
// в нашей БД мёртв, updateProject его молча no-op'ит. Сверяемся со списком живых
|
||||
// проектов портала и пересоздаём недостающих in-place (НЕ удаляя записи — на них
|
||||
// могут висеть лиды/списания). Throws пропагируют в outer handle() catch
|
||||
// (SupplierAuth/Transient/Client) — failover-counter semantics сохраняется.
|
||||
$livePortalIds = collect($this->client->listProjects())
|
||||
->map(fn ($p) => (string) ($p['id'] ?? ''))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$deadSps = $existingSps->filter(
|
||||
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
|
||||
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
|
||||
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
|
||||
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
|
||||
|
||||
@@ -164,6 +164,57 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// External-deletion recovery: донор мог быть удалён на портале (вручную или
|
||||
// прошлым hard-delete). Тогда external_id в нашей БД мёртв, а updateProject
|
||||
// такого id портал молча принимает (no-op) — донор не пересоздаётся. Поэтому
|
||||
// сверяемся со списком живых проектов портала и пересоздаём недостающих
|
||||
// in-place (НЕ удаляя записи — на supplier_project могут висеть лиды/списания).
|
||||
$livePortalIds = collect($client->listProjects())
|
||||
->map(fn ($p) => (string) ($p['id'] ?? ''))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$deadSps = $existingSps->filter(
|
||||
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
|
||||
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
|
||||
$recreatedIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
|
||||
$recreatedIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
|
||||
$recreatedIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partial-set recovery: если предыдущий run создал не все platforms.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
@@ -253,6 +304,15 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Mirror the link into the legacy FK columns (supplier_b{1,2,3}_project_id) so the
|
||||
// UI sync-status (ProjectResource → aggregateSyncStatus, which reads supplierB1/B2/B3)
|
||||
// reflects the synced stack in online mode too — online primarily uses the pivot.
|
||||
foreach ($existingSps as $sp) {
|
||||
$column = 'supplier_'.strtolower((string) $sp->platform).'_project_id';
|
||||
$project->{$column} = $sp->id;
|
||||
}
|
||||
$project->save();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -213,6 +213,103 @@ it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_proje
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online mode re-creates donor on portal when its external_id no longer exists there', function (): void {
|
||||
// Regression: если донора удалили на портале, в нашей БД остаются supplier_projects
|
||||
// с мёртвыми external_id. Раньше джоб шёл по update-ветке → updateProject мёртвого id
|
||||
// портал молча принимает (no-op) → донор не пересоздаётся. Фикс: проверять, жив ли
|
||||
// external_id на портале (listProjects), и пересоздавать недостающих in-place
|
||||
// (НЕ удаляя записи — на них могут висеть лиды/списания).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79990001122',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 31,
|
||||
]);
|
||||
|
||||
// Pre-seed supplier_projects, чьи external_id указывают на удалённых с портала доноров.
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79990001122',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => 'DEAD'.$platform,
|
||||
'current_limit' => 10,
|
||||
'current_workdays' => [1, 2, 3, 4, 5],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$loadCalls = 0;
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '7003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
|
||||
$loadCalls++;
|
||||
// Первый load = проверка существования → донор удалён (пусто).
|
||||
if ($loadCalls === 1) {
|
||||
return Http::response(['projects' => []], 200);
|
||||
}
|
||||
|
||||
// Последующие load (внутри saveProjectMultiFlag) = свежесозданные доноры.
|
||||
return Http::response(['projects' => [
|
||||
['id' => '7001', 'src' => 'rt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
|
||||
['id' => '7002', 'src' => 'bl', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
|
||||
['id' => '7003', 'src' => 'mt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
|
||||
]], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
// external_id переписаны на свежесозданных доноров (не DEAD*), записи не удалены.
|
||||
$sps = SupplierProject::where('unique_key', '79990001122')->orderBy('platform')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('supplier_external_id')->all())->toBe(['7001', '7002', '7003']);
|
||||
});
|
||||
|
||||
it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-status is not stuck pending', function (): void {
|
||||
// Regression: online mode writes the link to the pivot, but ProjectResource/aggregateSyncStatus
|
||||
// read the legacy FK columns (supplierB1/B2/B3). They stayed NULL in online → "Sync pending"
|
||||
// forever even though the stack is synced. Online must populate them too.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'uisync.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '9003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '9001', 'src' => 'rt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
|
||||
['id' => '9002', 'src' => 'bl', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
|
||||
['id' => '9003', 'src' => 'mt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$project->refresh();
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b2_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
expect($project->aggregateSyncStatus())->toBe('ok');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -480,3 +480,57 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
->and($log->http_status)->toBe(200)
|
||||
->and($log->error_message)->toBeNull();
|
||||
});
|
||||
|
||||
test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void {
|
||||
// Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale
|
||||
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
|
||||
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79993334455',
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||||
'platform' => $platform,
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79993334455',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => 'GONE'.$platform,
|
||||
'current_limit' => 10,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$loadCalls = 0;
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
|
||||
$loadCalls++;
|
||||
if ($loadCalls === 1) {
|
||||
return Http::response(['projects' => []], 200);
|
||||
}
|
||||
|
||||
return Http::response(['projects' => [
|
||||
['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||||
['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||||
['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
|
||||
]], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user