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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user