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
@@ -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())]);
}
@@ -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,
]);
}
}
}
}
}
/**
+86 -10
View File
@@ -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.
+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);
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилиты для работы с identifier'ами поставщика (supplier_projects.unique_key).
*
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
*/
class SupplierIdentifier
{
/**
* Извлекает корневой домен из identifier'а проекта.
*
* Правило: если identifier выглядит как домен с ≥3 сегментами через точку
* вернуть последние 2 сегмента. Иначе (уже корень, телефон, sms-ключ) null.
*
* Public-suffix-list (.co.uk и т.п.) НЕ поддерживается у проекта только
* .ru/.рф/.com, для которых правило «2 последних сегмента» корректно.
*/
public static function extractRootDomain(string $identifier): ?string
{
$trimmed = trim($identifier);
if ($trimmed === '') {
return null;
}
if (! str_contains($trimmed, '.')) {
return null;
}
$parts = explode('.', $trimmed);
if (count($parts) < 3) {
return null;
}
return implode('.', array_slice($parts, -2));
}
}
@@ -59,6 +59,30 @@ it('toggle-active flips is_active flag', function () {
expect($project->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]);
@@ -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);
});
@@ -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)
+3 -3
View File
@@ -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).
@@ -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([<supplier_project_id>...])` (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** («обнови пилот» от заказчика).