diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 00421efd..b56428d1 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -9,6 +9,7 @@ use App\Http\Requests\BulkProjectActionRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; +use App\Jobs\SyncSupplierProjectJob; use App\Models\Project; use App\Services\Project\ProjectService; use Illuminate\Http\JsonResponse; @@ -132,6 +133,10 @@ class ProjectController extends Controller $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $project->update(['is_active' => $request->boolean('is_active')]); + // #10: pause/resume must reach the supplier. The job's group recompute pushes + // status=paused when no active project of the group remains (resume → active). + SyncSupplierProjectJob::dispatch($project->id); + return response()->json(['data' => new ProjectResource($project->fresh())]); } diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index 294686d2..24189a32 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -20,6 +20,7 @@ use App\Services\Supplier\SupplierPortalClient; use App\Services\Supplier\SupplierProjectGrouping; use App\Services\Supplier\SupplierQuotaAllocator; use App\Support\RussianRegions; +use App\Support\SupplierIdentifier; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -427,6 +428,31 @@ class SyncSupplierProjectsJob implements ShouldQueue ]); } } + + // Auto-link to root domain (spec 2026-05-22-root-domain-auto-link-design §4.2). + // groupProjects шарят identifier — root один на всю группу. + $firstProject = $groupProjects[0] ?? null; + if ($firstProject !== null && $firstProject->signal_type === 'site') { + $rootIdentifier = SupplierIdentifier::extractRootDomain( + (string) $firstProject->signal_identifier + ); + if ($rootIdentifier !== null) { + $rootSps = SupplierProject::on(self::DB_CONNECTION) + ->where('unique_key', $rootIdentifier) + ->where('signal_type', 'site') + ->get(); + foreach ($groupProjects as $lp) { + foreach ($rootSps as $rootSp) { + DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([ + 'project_id' => $lp->id, + 'supplier_project_id' => $rootSp->id, + 'platform' => $rootSp->platform, + 'subject_code' => null, + ]); + } + } + } + } } /** diff --git a/app/app/Jobs/SyncSupplierProjectJob.php b/app/app/Jobs/SyncSupplierProjectJob.php index 9a926d6e..b23cd14c 100644 --- a/app/app/Jobs/SyncSupplierProjectJob.php +++ b/app/app/Jobs/SyncSupplierProjectJob.php @@ -16,6 +16,8 @@ use App\Services\Supplier\SupplierPortalClient; use App\Services\Supplier\SupplierProjectGrouping; use App\Services\Supplier\SupplierQuotaAllocator; use App\Support\RussianRegions; +use App\Support\SupplierIdentifier; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -107,20 +109,64 @@ class SyncSupplierProjectJob implements ShouldQueue $identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]); - // Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name). - // Pass all project regions as a single group — no per-subject split. - $allRegions = array_map('intval', (array) ($project->regions ?? [])); + // GROUP recompute (multi-client): an online edit of ONE project must recompute the + // WHOLE group sharing this identifier — otherwise it overwrites siblings' regions/ + // limit/days until the nightly batch. Mirrors SyncSupplierProjectsJob::syncGroup so + // online and nightly produce identical supplier state. + $agnostic = SupplierProjectGrouping::buildUniqueKeyAgnostic($project); + $groupProjects = Project::on(self::DB_CONNECTION) + ->where('is_active', true) + ->where('signal_type', (string) $project->signal_type) + ->get() + ->filter(fn (Project $gp) => SupplierProjectGrouping::buildUniqueKeyAgnostic($gp) === $agnostic) + ->values(); + + // status: paused when the whole group has no active project (the pause was the last one). + $groupActive = $groupProjects->isNotEmpty(); + $status = $groupActive ? 'active' : 'paused'; + + // eligible tomorrow → order/workdays (mirror nightly's eligibility window). + $targetWeekday = Carbon::tomorrow('Europe/Moscow')->isoWeekday(); + $eligible = $groupProjects->filter( + fn (Project $gp) => ((int) $gp->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0 + )->values(); + + $order = SupplierQuotaAllocator::computeOrder( + $eligible->map(fn (Project $gp) => (int) $gp->daily_limit_target)->all() + ); + + // union regions across the group (any project "all-RF" → whole group all-RF). + $hasAllRussia = false; + $merged = []; + foreach ($groupProjects as $gp) { + $r = array_map('intval', (array) ($gp->regions ?? [])); + if ($r === []) { + $hasAllRussia = true; + $merged = []; + break; + } + $merged = array_values(array_unique(array_merge($merged, $r))); + } + $allRegions = $hasAllRussia ? [] : $merged; + sort($allRegions); + + // union workdays of eligible projects (fallback to this project's mask if group empty). + $wd = []; + foreach ($eligible as $gp) { + foreach ($this->workdaysFromMask((int) $gp->delivery_days_mask) as $d) { + $wd[$d] = $d; + } + } + sort($wd); + $workdays = $wd !== [] ? $wd : $this->workdaysFromMask((int) $project->delivery_days_mask); + // count=0 → all-Russia; count=1 → named region; count>1 → merged → 'РФ' $tag = count($allRegions) === 1 ? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0]) : 'РФ'; - $workdays = $this->workdaysFromMask((int) $project->delivery_days_mask); - - // Split the limit across the platforms so Σ per-platform limits == project limit. - // The portal does NOT divide (verified live 2026-05-21) — replicating the full limit - // to B1/B2/B3 = order ×N (overspend). See SupplierQuotaAllocator::distributeForPlatform. - $shares = SupplierQuotaAllocator::distributeForPlatform((int) $project->daily_limit_target, $platforms); + // Split the GROUP order across platforms so Σ per-platform == order (no ×N overspend). + $shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms); // Idempotency: find existing by identifier regardless of subject_code (any previous run). $existingSps = SupplierProject::on(self::DB_CONNECTION) @@ -129,6 +175,11 @@ class SyncSupplierProjectJob implements ShouldQueue ->whereIn('platform', $platforms) ->get(); + // Fully-paused group with nothing yet at supplier — nothing to create. + if ($existingSps->isEmpty() && ! $groupActive) { + return; + } + if ($existingSps->isEmpty()) { // Create path: one save PER platform with that platform's divided share // (single-flag save → exactly one rt-project, reliable id via listProjects match). @@ -224,7 +275,7 @@ class SyncSupplierProjectJob implements ShouldQueue workdays: $workdays, regions: $allRegions, regionsReverse: false, - status: 'active', + status: $status, tag: $tag, platforms: [$sp->platform], ); @@ -235,6 +286,7 @@ class SyncSupplierProjectJob implements ShouldQueue 'current_regions' => $allRegions, 'sync_status' => 'ok', 'last_synced_at' => now(), + 'inactive_since' => $groupActive ? null : now(), ])->save(); } } @@ -249,6 +301,30 @@ class SyncSupplierProjectJob implements ShouldQueue ]); } + // Auto-link to root domain (spec 2026-05-22-root-domain-auto-link-design §4.2). + // Когда identifier — субдомен (krasnoyarsk.carmoney.ru), доп. линкуем проект к + // supplier_projects корневого домена (carmoney.ru), если такие есть. Закрывает + // класс «поставщик шлёт корень — подписчики на субдомены не получают». + if ($project->signal_type === 'site') { + $rootIdentifier = SupplierIdentifier::extractRootDomain( + (string) $project->signal_identifier + ); + if ($rootIdentifier !== null) { + $rootSps = SupplierProject::on(self::DB_CONNECTION) + ->where('unique_key', $rootIdentifier) + ->where('signal_type', 'site') + ->get(); + foreach ($rootSps as $rootSp) { + DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([ + 'project_id' => $project->id, + 'supplier_project_id' => $rootSp->id, + 'platform' => $rootSp->platform, + '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. diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index d83354b9..c93d5cfb 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -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); diff --git a/app/app/Support/SupplierIdentifier.php b/app/app/Support/SupplierIdentifier.php new file mode 100644 index 00000000..c68bf5b2 --- /dev/null +++ b/app/app/Support/SupplierIdentifier.php @@ -0,0 +1,39 @@ +fresh()->is_active)->toBeFalse(); }); +it('toggle-active dispatches supplier sync so pause/resume reaches the supplier (#10)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}/toggle-active", ['is_active' => false]) + ->assertOk(); + + Queue::assertPushed(SyncSupplierProjectJob::class, fn ($job) => $job->projectId === $project->id); +}); + +it('bulk pause dispatches supplier sync for each affected project (#10)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $p1 = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + $p2 = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + + $this->actingAs($user)->postJson('/api/projects/bulk', [ + 'action' => 'pause', 'ids' => [$p1->id, $p2->id], + ])->assertOk()->assertJsonPath('updated', 2); + + Queue::assertPushed(SyncSupplierProjectJob::class, fn ($job) => in_array($job->projectId, [$p1->id, $p2->id], true)); +}); + it('bulk pause sets is_active=false on multiple', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); diff --git a/app/tests/Feature/Project/ProjectUpdateDedupTest.php b/app/tests/Feature/Project/ProjectUpdateDedupTest.php index 017b252a..8599e8f1 100644 --- a/app/tests/Feature/Project/ProjectUpdateDedupTest.php +++ b/app/tests/Feature/Project/ProjectUpdateDedupTest.php @@ -2,11 +2,18 @@ declare(strict_types=1); +use App\Jobs\Supplier\DeleteSupplierProjectJob; +use App\Jobs\SyncSupplierProjectJob; +use App\Models\SupplierProject; use App\Models\Tenant; use App\Services\Project\ProjectService; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +uses(DatabaseTransactions::class); + beforeEach(fn () => Queue::fake()); it('blocks update that collides source with another project of same tenant', function () { @@ -26,3 +33,58 @@ it('allows update keeping same source on the same project', function () { $updated = $svc->update($a, ['signal_identifier' => '79991110000', 'daily_limit_target' => 7]); expect($updated->daily_limit_target)->toBe(7); }); + +it('changing source detaches old supplier_projects and dispatches their cleanup (#8)', function () { + // #8/#9 regression: changing signal_identifier left the old supplier_projects orphan + // (still linked via pivot, still alive at the supplier) — that is the "two projects + // instead of one" the owner reported. Fix: detach old key's sps from this project + + // dispatch DeleteSupplierProjectJob (cleans portal only if no other consumer remains). + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $svc = app(ProjectService::class); + $p = $svc->create($tenant, [ + 'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '79991110000', + 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31, + ]); + + // Simulate the old source already synced — 3 sps linked via pivot + legacy FKs. + $oldIds = []; + foreach (['B1' => 'OLD1', 'B2' => 'OLD2', 'B3' => 'OLD3'] as $platform => $ext) { + $sp = SupplierProject::create([ + 'platform' => $platform, 'signal_type' => 'call', 'unique_key' => '79991110000', + 'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2, + 'current_workdays' => [1, 2, 3, 4, 5], 'current_regions' => [], 'sync_status' => 'ok', + 'last_synced_at' => now(), + ]); + DB::table('project_supplier_links')->insert([ + 'project_id' => $p->id, 'supplier_project_id' => $sp->id, 'platform' => $platform, 'subject_code' => null, + ]); + $oldIds[] = $sp->id; + $col = 'supplier_'.strtolower($platform).'_project_id'; + $p->{$col} = $sp->id; + } + $p->save(); + + // Change source — should detach old sps, dispatch their cleanup, and dispatch the new-source sync. + $svc->update($p, ['signal_identifier' => '79993330000']); + + // Old sps no longer linked to this project via pivot. + $stillLinked = DB::table('project_supplier_links') + ->where('project_id', $p->id) + ->whereIn('supplier_project_id', $oldIds) + ->count(); + expect($stillLinked)->toBe(0); + + // Legacy FK columns pointing at old sps are cleared. + $fresh = $p->fresh(); + expect($fresh->supplier_b1_project_id)->toBeNull(); + expect($fresh->supplier_b2_project_id)->toBeNull(); + expect($fresh->supplier_b3_project_id)->toBeNull(); + + // Cleanup of OLD sps dispatched (delete-or-resync per remaining-consumers rule). + Queue::assertPushed(DeleteSupplierProjectJob::class, function ($job) use ($oldIds) { + return ! array_diff($oldIds, $job->supplierProjectIds); + }); + + // The new source still gets a sync dispatch (existing needsResync path). + Queue::assertPushed(SyncSupplierProjectJob::class, fn ($job) => $job->projectId === $p->id); +}); diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php index 07e206cf..8de8be4f 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php @@ -414,6 +414,108 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n // Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC // --------------------------------------------------------------------------- +it('online sync recomputes the WHOLE group: editing one project keeps siblings (union regions + group order, no overwrite)', function (): void { + // Multi-client regression (owner-reported 2026-05-22, verified live): when several + // tenants share one source (identifier), an online edit of ONE project overwrote the + // shared supplier_projects with that single project's regions/limit, wiping the others + // until the nightly batch. Online must recompute the WHOLE group like the nightly job: + // union regions, computeOrder across the group, divided per platform. + DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']); + + $t1 = Tenant::factory()->create(['balance_leads' => 100]); + $t2 = Tenant::factory()->create(['balance_leads' => 100]); + $common = '79991112233'; + + $p1 = Project::factory()->create([ + 'tenant_id' => $t1->id, 'signal_type' => 'call', 'signal_identifier' => $common, + 'is_active' => true, 'daily_limit_target' => 10, 'regions' => [82], 'delivery_days_mask' => 127, + ]); + $p2 = Project::factory()->create([ + 'tenant_id' => $t2->id, 'signal_type' => 'call', 'signal_identifier' => $common, + 'is_active' => true, 'daily_limit_target' => 20, 'regions' => [77], 'delivery_days_mask' => 127, + ]); + + // First sync p1 — creates the group's 3 supplier_projects. Both projects already exist + // and share the identifier, so the GROUP has 2 regions [77,82] → union → tag 'РФ'. + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4100'], 200), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [ + ['id' => '4101', 'src' => 'rt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common], + ['id' => '4102', 'src' => 'bl', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common], + ['id' => '4103', 'src' => 'mt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common], + ]], 200), + ]); + (new SyncSupplierProjectJob($p1->id))->handle(app(SupplierProjectChannel::class)); + + // Edit-sync p2. Buggy code overwrites group to p2-only ([77], limit 20 full). + // Expected: union regions [77,82], order = computeOrder([10,20]) = max(20, ceil(30/3)) = 20, divided 7/7/6. + $this->mock(SupplierProjectChannel::class, function ($mock): void { + $mock->shouldReceive('updateProject')->andReturn(true); + }); + + (new SyncSupplierProjectJob($p2->id))->handle(app(SupplierProjectChannel::class)); + + $sps = SupplierProject::where('unique_key', $common)->get(); + expect($sps)->toHaveCount(3); + // Group order, divided so Σ == 20 (NOT 60 from ×3, NOT 20 on each). + expect($sps->sum('current_limit'))->toBe(20); + // Union regions across the whole group — both projects' regions, not just p2's. + $regions = $sps->first()->current_regions; + sort($regions); + expect($regions)->toBe([77, 82]); +}); + +it('online pause: when the group has no active project left, supplier receives status=paused', function (): void { + // Pause regression (#10, owner-reported 2026-05-22): pausing a project never told the + // supplier — DTO status was hardcoded 'active'. Now the group recompute sets status + // 'paused' when no active project remains, and the update is pushed with that status. + DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']); + + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $common = '79993334444'; + // Project already paused (the action that triggers this sync). + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => $common, + 'is_active' => false, 'daily_limit_target' => 6, 'regions' => [], 'delivery_days_mask' => 127, + ]); + // Pre-seed the already-synced supplier_projects. + foreach (['B1' => 'PA1', 'B2' => 'PA2', 'B3' => 'PA3'] as $platform => $ext) { + SupplierProject::create([ + 'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common, + 'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok', + 'last_synced_at' => now()->subDay(), + ]); + } + + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [ + ['id' => 'PA1', 'src' => 'rt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common], + ['id' => 'PA2', 'src' => 'bl', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common], + ['id' => 'PA3', 'src' => 'mt', 'name' => $common, 'tag' => 'РФ', 'type' => 'calls', 'content' => $common], + ]], 200), + ]); + + $capturedStatuses = []; + $this->mock(SupplierProjectChannel::class, function ($mock) use (&$capturedStatuses): void { + $mock->shouldReceive('updateProject')->andReturnUsing(function ($id, $dto) use (&$capturedStatuses) { + $capturedStatuses[] = $dto->status; + + return true; + }); + }); + + (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); + + // All 3 platform updates carried status=paused (supplier project is stopped). + expect($capturedStatuses)->toHaveCount(3); + foreach ($capturedStatuses as $st) { + expect($st)->toBe('paused'); + } + // Local rows mark inactive_since so UI/DTO reflect the pause. + expect(SupplierProject::where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3); +}); + it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', function (): void { // Regression: job ran on the default RLS-enforced connection. On a real queue worker // (role crm_app_user, no SetTenantContext middleware → no app.current_tenant_id GUC) diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index fd4bb9ff..26a8465d 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-22T11:27:52.849Z +Last updated: 2026-05-22T11:33:46.118Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,12 +8,12 @@ Last updated: 2026-05-22T11:27:52.849Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ⚠️ | 41 episode(s) this month · Stop-hook + post-commit OK · 16 missed activation(s) — see /brain-retro | +| C5 Observer-coverage | ⚠️ | 42 episode(s) this month · Stop-hook + post-commit OK · 16 missed activation(s) — see /brain-retro | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 15 chains in sync | ## Метрики (информационные, не алерты) -- Observer evidence: 41 episodes this month, 0 observer_error markers, 5 PII matches before filter +- Observer evidence: 42 episodes this month, 0 observer_error markers, 5 PII matches before filter - Legacy v1 episodes (not in factor analysis): 5 - Last /brain-retro: 3 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 16. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). diff --git a/docs/superpowers/plans/2026-05-22-замечания-проекты-чеклист.md b/docs/superpowers/plans/2026-05-22-замечания-проекты-чеклист.md new file mode 100644 index 00000000..e63c574d --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-замечания-проекты-чеклист.md @@ -0,0 +1,214 @@ +# Чек-лист: замечания по проектам (боевой liderra.ru) — 22.05.2026 + +> **Процесс заказчика для КАЖДОГО пункта (обязателен):** +> +> 1. **Тест на практике** — воспроизвести на боевом сайте + у поставщика. +> 2. **Чистка №1** — удалить тестовые данные в Лидерре И у поставщика. +> 3. **Фикс** — исправить код (TDD: сначала падающий Pest/Vitest-тест, потом код). +> 4. **Ре-тест** — снова проверить на практике, что починилось. +> 5. **Чистка №2** — снова удалить тестовые данные в Лидерре И у поставщика. +> 6. **Закрыто** — только когда всё выше сделано и проверено. + +**Среда:** боевой `liderra.ru` (SSH `ubuntu@111.88.246.137`), боевой поставщик `crm.bp-gr.ru`. +**Важно:** код на боевом ВПЕРЕДИ origin/main (ветки `feat/test-deploy`, `feat/root-domain-auto-link`). Каждый баг проверять на РЕАЛЬНОМ боевом, не только в репо. + +--- + +## ⚠️ Соглашения о безопасности тестирования на боевом + +Поставщик — общий и реальный. Тест создаёт реальные проекты у поставщика → риск реальных заказов лидов и списаний. Поэтому: + +- **Фиктивные источники только.** Несуществующие, но валидные по формату: домены `qa-liderra-test-NN.ru`, телефоны `7999000NNNN`, SMS-отправитель `QATESTNN`. Никогда не использовать источник реального клиента. +- **Минимальный лимит** (1 лид/день) — даже если заказ уйдёт, объём копеечный. +- **Создал → проверил → сразу почистил.** Не оставлять тестовые проекты «на ночь» (чтобы ночной батч не заказал). +- **Снимок «до».** Перед каждым тестом снять список проектов у поставщика (`listProjects()`), чтобы точно знать, что удалять после. +- **Тестовые клиенты** изолированы (отдельные tenant'ы `qa-test-1..5`), в конце удаляются полностью. +- **Деньги.** Тестовым клиентам не пополнять баланс реально; если списание произойдёт — зафиксировать и откатить. + +**Инструменты на боевом (по SSH):** + +- Наблюдать поставщика: `php artisan tinker` → `app(\App\Services\Supplier\SupplierPortalClient::class)->listProjects()`. +- Чистить у поставщика: `\App\Jobs\Supplier\DeleteSupplierProjectJob::dispatch([...])` (sync-выполнить через `queue:work --once` или `Bus::dispatchSync`). +- Чистить в Лидерре: удалить `projects`/`supplier_projects`/`project_supplier_links` тестовых клиентов; удалить тестовых tenant+users. +- БД боевого: PostgreSQL `liderra`, supplier-операции под ролью `crm_supplier_worker` (BYPASSRLS), connection `pgsql_supplier`. + +--- + +## ФАЗА 0 — Подготовка (один раз, до пунктов) + +- [ ] **0.1 Снять реальное состояние боевого кода.** По SSH сверить `git hash-object` боевых файлов с origin/main и ветками: `SyncSupplierProjectJob.php`, `SyncSupplierProjectsJob.php`, `SupplierQuotaAllocator.php`, `ProjectController.php`, `ProjectService.php`, `ProjectsView.vue`, `ProjectDetailsDrawer.vue`. Зафиксировать в файле «боевая база» (что реально развёрнуто). Узнать `SupplierExportMode` боевого (online/batch). +- [ ] **0.2 Снять снимок поставщика «до всего».** `listProjects()` → сохранить весь список (id, name, limit, status) в `docs/audit/supplier-snapshot-2026-05-22-before.json`. Это эталон чистоты. +- [ ] **0.3 Завести 5 тестовых клиентов** на боевом (`qa-test-1..5`): tenant + 1 user каждому, лимит проектов ≥ 5. Зафиксировать их tenant_id. +- [ ] **0.4 Проверить процедуру чистки на «пустышке».** Создать 1 тестовый проект с фиктивным источником → убедиться, что он появился у поставщика → удалить его и в Лидерре, и у поставщика → `listProjects()` совпал со снимком 0.2. Только после этого процедура чистки считается рабочей. + +--- + +## ГРУППА 1 — Баги синхронизации с поставщиком (один клиент) + +### П1 — Приостановка/возобновление не доходит до поставщика (замечание #10) + +**Подозрение (по коду):** `toggleActive` ([ProjectController.php:129](app/app/Http/Controllers/Api/ProjectController.php#L129)) и bulk pause/resume ([ProjectService.php:130-131](app/app/Services/Project/ProjectService.php#L130-L131)) не диспатчат sync; `needsResync` ([ProjectService.php:38-43](app/app/Services/Project/ProjectService.php#L38-L43)) не включает `is_active`; DTO всегда `status: 'active'`; ночной джоб берёт только `is_active=true`. + +- [ ] **Тест:** создать тест-проект, дождаться синка, увидеть его активным у поставщика → нажать «Приостановить» → проверить `listProjects()`: остался ли «зелёным/активным». +- [ ] **Чистка №1.** +- [ ] **Фикс:** пауза должна сообщать поставщику (status/обнуление лимита); `is_active` → в needsResync + toggleActive/bulk диспатчат sync; для общего источника — пересчёт без paused-доли (см. П7). +- [ ] **Ре-тест:** пауза → у поставщика остановлен/обнулён; возобновление → снова активен. +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П2 — Смена источника создаёт новый проект вместо изменения + дубли (замечания #9, #8) + +**Подозрение (по коду):** `unique_key` строится из источника ([SupplierProjectGrouping.php:30-45](app/app/Services/Supplier/SupplierProjectGrouping.php#L30-L45)); смена источника → новый ключ → `existingSps` пусто ([SyncSupplierProjectJob.php:126-132](app/app/Jobs/SyncSupplierProjectJob.php#L126-L132)) → create нового; старый supplier-проект и pivot не чистятся. + +- [ ] **Тест A (изменение):** создать проект, источник `qa-liderra-test-01.ru`, синк → запомнить supplier-id. Сменить источник на `qa-liderra-test-02.ru` → `listProjects()`: сколько проектов (ожидаем баг = 2). +- [ ] **Тест B (история #8):** по SSH поднять историю реального инцидента с источником `79135191264` (`activity_logs`/`saas_admin_audit_log`/`supplier_sync_logs`) — как создавался и менялся, откуда 2 проекта. +- [ ] **Чистка №1.** +- [ ] **Фикс:** при смене источника — найти supplier-проекты по pivot (а не по новому ключу), обновить/переименовать у поставщика ИЛИ удалить старые + создать новые; почистить осиротевшие. Учесть шаринг (старый источник мог быть у другого клиента — тогда только отвязать). +- [ ] **Ре-тест:** смена источника → у поставщика РОВНО один проект с новым источником, старый убран/переотвязан. +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П3 — Смена дня поставки не доходит до поставщика (замечание #2) + +**Подозрение:** `delivery_days_mask` в needsResync есть; проверить, реально ли `updateProject`/`saveProjectMultiFlag` шлёт workdays и в каком режиме (online/batch); batch только создаёт каркас без update. + +- [ ] **Тест (убрать день):** создать проект со всеми днями, синк → у поставщика дни. Убрать один день → `listProjects()`/деталь проекта у поставщика: изменились ли дни. +- [ ] **Тест (добавить день):** добавить ранее убранный день → проверить у поставщика. +- [ ] **Чистка №1.** +- [ ] **Фикс:** обеспечить передачу workdays поставщику при изменении (в актуальном режиме боевого). +- [ ] **Ре-тест:** убрать день → у поставщика убрался; добавить → добавился. +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П4 — Деление лимита B1/B2/B3 (замечание #1) + +**Подозрение:** `distributeForPlatform` уже на боевом (`e6beff6`), re-split форсом выполнен, НО «уже-`ok` проекты со старыми ×N батч сам не перечинивает» (ПИЛОТ §2). Возможно остались проекты с дублированным лимитом. + +- [ ] **Тест:** найти на боевом проекты, у которых сумма лимитов B1+B2+B3 ≠ заказу (старый ×N). Создать новый тест-проект с лимитом, напр. 30 → проверить у поставщика: 10/10/10 (правильно) или 30/30/30 (баг). +- [ ] **Чистка №1** (для тест-проекта). +- [ ] **Фикс:** если новые делятся правильно — добить force-resync «уже-ok» реальных проектов с ×N (без удаления, in-place); если новые делятся неправильно — починить деление. +- [ ] **Ре-тест:** новый проект делится корректно; реальные ×N перечинены (Σ = заказу). +- [ ] **Чистка №2** (тест-данные; реальные проекты остаются исправленными). +- [ ] **Закрыто.** + +### П5 — Убрать один регион из двух → слетают все (замечание #3) + +**Подозрение:** drawer шлёт `regions` целиком ([ProjectDetailsDrawer.vue:78-83](app/resources/js/components/projects/ProjectDetailsDrawer.vue#L78-L83)); если пусто → трактуется как «вся РФ». Проверить, что реально уходит при удалении одного из двух регионов и как поставщик это видит. + +- [ ] **Тест:** проект с 2 регионами (напр. Москва+Питер), синк → у поставщика 2 региона/tag. Убрать Питер → проверить: остался ли только Питер убран (Москва есть) или слетели оба → «вся РФ». +- [ ] **Чистка №1.** +- [ ] **Фикс:** убирание одного региона оставляет остальные и у нас, и у поставщика. +- [ ] **Ре-тест:** убрать один из двух → второй остаётся. +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +--- + +## ГРУППА 2 — Сценарии 5+ клиентов одновременно (общий источник у поставщика) + +> Базовый сетап для всех: ≥3 тест-клиента с ОДНИМ источником `qa-liderra-test-shared.ru`, разные регионы/лимиты/дни. Синхронизировать → у поставщика ОДИН проект (заказ = сумма по формуле). Снять снимок-эталон. После каждого пункта — чистка обоих сторон. + +### П6 — Один из группы меняет регион/лимит/дни днём → затирает остальных (сценарии C2, C3) + +**Подозрение:** online-синк одного проекта пишет регионы/лимит/дни ТОЛЬКО этого проекта ([SyncSupplierProjectJob.php:112,118,123](app/app/Jobs/SyncSupplierProjectJob.php#L112)), а ночной — union группы. Дневная правка одного клиента стирает данные остальных. + +- [ ] **Тест:** 5 клиентов на общем источнике (регионы A,B,C,D,E; лимиты разные). Клиент 1 меняет свой регион среди дня → проверить у поставщика: остался union(A..E) или стал только регион клиента 1. +- [ ] **Чистка №1.** +- [ ] **Фикс:** online-синк одного проекта должен пересчитывать union/заказ по ВСЕЙ группе (как ночной), а не по одному проекту. +- [ ] **Ре-тест:** правка одного клиента → у поставщика union всех сохранён, заказ = сумме. +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П7 — Один из группы ставит паузу → заказ не уменьшается на его долю (сценарий C4) + +- [ ] **Тест:** 5 клиентов на общем источнике, заказ = X. Клиент 3 ставит паузу → проверить у поставщика: заказ уменьшился на долю клиента 3 или остался X. +- [ ] **Чистка №1.** +- [ ] **Фикс:** пауза участника группы → пересчёт заказа без него (зависит от П1). +- [ ] **Ре-тест:** пауза → заказ пересчитан; возобновление → вернулся. +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П8 — Один из группы удаляет проект → не трогать общий supplier-проект (сценарий C5) + +- [ ] **Тест:** 5 клиентов на общем источнике. Клиент 2 удаляет свой проект → проверить: общий supplier-проект жив (4 ещё работают), заказ пересчитан без клиента 2, лиды/списания остальных целы. +- [ ] **Чистка №1.** +- [ ] **Фикс (если нужно):** удаление участника группы не удаляет общий supplier-проект, только отвязывает + пересчитывает заказ. +- [ ] **Ре-тест.** +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П9 — Двое одновременно сохраняют общий источник → гонка (сценарий C6) + +- [ ] **Тест:** клиент 1 и клиент 2 (общий источник) почти одновременно жмут «Сохранить» с разными изменениями → проверить: оба изменения учтены или последний затёр первого / дубль у поставщика. +- [ ] **Чистка №1.** +- [ ] **Фикс (если нужно):** блокировка/идемпотентность на уровне группы (advisory-lock по identifier). +- [ ] **Ре-тест.** +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П10 — Двое одновременно создают один новый источник → два проекта вместо одного (сценарий C7, родственно #8) + +- [ ] **Тест:** клиент 4 и клиент 5 одновременно создают проект с одним новым источником `qa-liderra-test-race.ru` → проверить `listProjects()`: один supplier-проект или два. +- [ ] **Чистка №1.** +- [ ] **Фикс (если нужно):** идемпотентное создание (advisory-lock/уникальный ключ на identifier). +- [ ] **Ре-тест.** +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +### П11 — Смена источника на занятый другим клиентом → слияние групп (сценарий C8) + +- [ ] **Тест:** клиент 1 (источник X) меняет источник на Y, который уже у клиента 2 → проверить: группы слились в одну у поставщика (заказ = сумма) или создался дубль. +- [ ] **Чистка №1.** +- [ ] **Фикс (если нужно):** смена источника на существующий → присоединение к группе (зависит от П2). +- [ ] **Ре-тест.** +- [ ] **Чистка №2.** +- [ ] **Закрыто.** + +--- + +## ГРУППА 3 — Внешний вид и удобство страницы «Проекты» + +> UI чистить не нужно (нет данных у поставщика). Цикл: проверил визуально на боевом → фикс → перепроверил. + +### П12 — После Сохранить/Приостановить панель и галочка не исчезают (замечание #4) + +**Подозрение:** `onDrawerSaved` делает только fetch без снятия выбора ([ProjectsView.vue:147-149](app/resources/js/views/ProjectsView.vue#L147-L149)); `onPause` в drawer не закрывает панель ([ProjectDetailsDrawer.vue:58-61](app/resources/js/components/projects/ProjectDetailsDrawer.vue#L58-L61)). + +- [ ] **Тест:** выбрать проект (галочка) → открылась правая панель. Нажать «Сохранить» → панель и галочка остаются? То же для «Приостановить», «Удалить», «Отмена». +- [ ] **Фикс:** после любого из 4 действий — закрыть панель + снять галочку (clearSelection). +- [ ] **Ре-тест:** все 4 кнопки закрывают панель и снимают галочку. +- [ ] **Закрыто.** + +### П13 — Отступ от тёмных границ как в канбане (замечание #5) + +- [ ] **Тест:** сравнить отступ страницы «Проекты» с канбаном на боевом. +- [ ] **Фикс:** выровнять отступы (CSS `.projects-view`). +- [ ] **Ре-тест:** визуально совпадает. +- [ ] **Закрыто.** + +### П14 — Селектор «показывать по 20/50/100/200» (замечание #6) + +**Текущее:** бэкенд режет `per_page` до max 100 ([ProjectController.php:70](app/app/Http/Controllers/Api/ProjectController.php#L70)); фронт без селектора (есть только пагинация на боевом). + +- [ ] **Тест:** проверить, есть ли выбор количества на боевом (нет). +- [ ] **Фикс:** селектор 20/50/100/200 (как на «Сделках»); поднять серверный лимит до 200. +- [ ] **Ре-тест:** выбор работает, список перезагружается. +- [ ] **Закрыто.** + +### П15 — Сортировка по лидам + фильтры регион/дни + дефолт-сортировка (замечание #7) + +**Текущее:** бэкенд сортирует жёстко `created_at desc` ([ProjectController.php:71](app/app/Http/Controllers/Api/ProjectController.php#L71)); фильтров регион/дни нет. + +- [ ] **Тест:** убедиться, что сортировки/фильтров нет. +- [ ] **Фикс:** сортировка по кол-ву лидов; фильтр по региону; фильтр по дням; дефолт-сортировка по доставленным лидам за текущий день (`delivered_today`). +- [ ] **Ре-тест:** при заходе сразу сортировка по лидам за сегодня; фильтры/сортировки работают. +- [ ] **Закрыто.** + +--- + +## ФАЗА ЗАВЕРШЕНИЯ + +- [ ] **Финальная чистка:** удалить всех 5 тест-клиентов и все их проекты (Лидерра + поставщик). `listProjects()` == снимку 0.2. +- [ ] **Снимок поставщика «после»** — сверить с «до»: ни одного лишнего проекта. +- [ ] **Деплой фиксов** на боевой (копированием, по процедуре ПИЛОТ §2: фронт — локальный build, бэк — config:cache + restart queue + reload fpm). +- [ ] **Обновить ПИЛОТ.md** («обнови пилот» от заказчика).