feat(supplier): group recompute + pause + source-change + root auto-link

Закрывает замечания заказчика (22.05.2026) по проектам/поставщику. Все 4 куска
имеют общий корень: online-синхронизация одного проекта работала с данными ЭТОГО
проекта, а не пересчитывала всю «группу» (проекты разных tenant'ов с одним
identifier) — отсюда переплата ×3 при изменении лимита, затирание регионов/дней
группы, неотправленная пауза, и осиротевшие проекты при смене источника.

1. Групповой пересчёт в SyncSupplierProjectJob::handleOnline (#1 при изменении,
   #2 дни, #3 регионы, C2/C3): union regions, computeOrder eligible,
   distributeForPlatform — те же расчёты, что в ночном syncGroup. Online и
   ночной теперь дают идентичный supplier-state, расхождение устранено.

2. Пауза #10:
   - ProjectController::toggleActive — диспатчит SyncSupplierProjectJob;
   - ProjectService::bulkPauseResume — диспатчит sync per project;
   - DTO status вычисляется из groupActive (paused когда группа без активных);
   - sp.inactive_since пишется при пересинке (для UI/DTO консистентности).

3. Смена источника #8/#9 в ProjectService::update:
   - до update снимается старый buildUniqueKeyAgnostic;
   - если изменился — отвязываем старые supplier_projects от этого project
     (pivot + legacy FK), DeleteSupplierProjectJob удаляет их у поставщика
     при отсутствии других потребителей, либо пересинкает агрегат.

4. Перенос auto-link корня из feat/root-domain-auto-link: новый
   App\Support\SupplierIdentifier::extractRootDomain + блоки auto-link в
   обоих джобах (online + nightly).

Тесты: TDD на каждый кусок. SyncSupplierProjectJobTest +2 (group recompute,
pause). ProjectUpdateDedupTest +1 (source detach + cleanup dispatch).
ProjectsActionsTest +2 (toggle + bulk pause dispatches).

Регрессия: 186/186 passed (Project/Plan5/Projects + Supplier), 502 assertions.

Деплой: дельтой на боевой (база = root-domain ветка; на боевом джобы СТАРЕЕ
main, deliver через копию изменённых файлов + config:cache + restart queue).

План: docs/superpowers/plans/2026-05-22-замечания-проекты-чеклист.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-22 16:52:30 +03:00
parent 4d37402bc7
commit 1be2d62f9e
10 changed files with 645 additions and 15 deletions
+84 -2
View File
@@ -7,7 +7,9 @@ namespace App\Services\Project;
use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierProjectGrouping;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\DB;
@@ -54,8 +56,22 @@ class ProjectService
$this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
}
// #8/#9: capture the OLD source identifier BEFORE update so we can detach the
// project from supplier_projects keyed by the old source (otherwise they orphan).
$identifierFieldsTouched = array_key_exists('signal_identifier', $data)
|| array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data);
$oldAgnostic = $identifierFieldsTouched ? SupplierProjectGrouping::buildUniqueKeyAgnostic($project) : null;
$project->update($data);
if ($oldAgnostic !== null) {
$newAgnostic = SupplierProjectGrouping::buildUniqueKeyAgnostic($project->fresh());
if ($oldAgnostic !== $newAgnostic) {
$this->detachOldSourceSupplierProjects($project, $oldAgnostic);
}
}
if ($needsResync) {
SyncSupplierProjectJob::dispatch($project->id);
}
@@ -63,6 +79,54 @@ class ProjectService
return $project->fresh();
}
/**
* #8/#9: при смене источника отвязать старые supplier_projects этого проекта (по
* старому ключу) и запустить их чистку. DeleteSupplierProjectJob удалит их у
* поставщика, если других потребителей не осталось; иначе пересинк агрегата.
*/
private function detachOldSourceSupplierProjects(Project $project, string $oldAgnostic): void
{
$oldSpIds = SupplierProject::where('unique_key', $oldAgnostic)
->where('signal_type', $project->signal_type)
->pluck('id')
->all();
if ($oldSpIds === []) {
return;
}
// Linked to THIS project via pivot — those are the ones we're detaching.
$linkedIds = DB::table('project_supplier_links')
->where('project_id', $project->id)
->whereIn('supplier_project_id', $oldSpIds)
->pluck('supplier_project_id')
->map(fn ($v) => (int) $v)
->all();
if ($linkedIds === []) {
return;
}
DB::table('project_supplier_links')
->where('project_id', $project->id)
->whereIn('supplier_project_id', $linkedIds)
->delete();
// Clear legacy FKs that point at old sps (they no longer belong to this project).
$dirty = false;
foreach (['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id'] as $col) {
if (in_array((int) $project->{$col}, $linkedIds, true)) {
$project->{$col} = null;
$dirty = true;
}
}
if ($dirty) {
$project->save();
}
DeleteSupplierProjectJob::dispatch($linkedIds);
}
public function delete(Project $project): void
{
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
@@ -127,8 +191,8 @@ class ProjectService
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
return match ($action) {
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
'pause' => $this->bulkPauseResume($query, false),
'resume' => $this->bulkPauseResume($query, true),
'delete' => $this->bulkDelete($query),
'update_regions' => $this->bulkUpdateRegions($query, $payload),
'update_days' => $this->bulkUpdateDays($query, $payload),
@@ -136,6 +200,24 @@ class ProjectService
};
}
/**
* Pause/resume + supplier sync per affected project (#10).
*
* Without the dispatch, pause never reached the supplier (status stayed active).
* The job's group recompute then pushes status=paused when no active project of
* the group remains, or rebalances the order when some siblings are still active.
*/
private function bulkPauseResume($query, bool $isActive): array
{
$ids = (clone $query)->pluck('id')->all();
$updated = $query->update(['is_active' => $isActive]);
foreach ($ids as $id) {
SyncSupplierProjectJob::dispatch((int) $id);
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkSimpleUpdate($query, array $update): array
{
$updated = $query->update($update);