Compare commits

..

28 Commits

Author SHA1 Message Date
Дмитрий 6e36c2455d docs(plan): регистрация — подтверждение email кодом + обязательный телефон
12 задач TDD: PhoneNormalizer, Mailable+шаблон, register/start|verify|resend,
удаление старого register, фронт (утилита телефона, API, store, двухшаговый
RegisterView, тесты), полная регрессия + живая проверка SMTP (blocked на пароль).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:24:47 +03:00
Дмитрий 4c2f4da664 docs(spec): регистрация — подтверждение email кодом + обязательный телефон
Дизайн: 6-значный код на email до создания аккаунта (pending в сессии,
паттерн 2FA), обязательный телефон по маске +7 (XXX) XXX-XX-XX (только
сбор, без SMS), реальная доставка через Яндекс SMTP. Без правок схемы БД.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:10:36 +03:00
Дмитрий 1df353ae51 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:02:40 +03:00
Дмитрий 47cf202226 chore(gitleaks): ignore Nuclei docs curl-auth-user false-positive (05437ba)
Cross-branch FP: gitleaks-full-history сканит все refs; чужой коммит a8-infosec
Nuclei docs -u http://... ловится curl-auth-user (не аутентификация).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:34:08 +03:00
Дмитрий 888ead3264 docs(etalon): тест-сервер YC + 3 канала поставщика настроены
§5 факт о тест-сервере (доступ, демо-логины, info@lkomega.ru), §6 нить
каналов миграции, §1 git push feat/test-deploy. +2 слова cspell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:32:31 +03:00
Дмитрий dcc1040f73 docs(deploy): test-server runbook — supplier migration channels section
3 канала проверены вживую на тест-сервере (webhook/CSV reconcile/export),
+предпосылки (Node20+Playwright+Chromium под /var/www/.cache, PlaywrightBridge 180s),
secret/allowlist/supplier-portal/HTTPS TODO. +2 слова в cspell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:28:09 +03:00
Дмитрий b873c53aad fix(supplier): PlaywrightBridge timeout 75->180 for weak-VM Chromium cold-start
На 2 vCPU/2GB YC VM холодный старт Chromium в refresh-session ~65s wall-clock,
не укладывался в прежние 75s (60s Node + 15s buffer). Поднято до 180s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:27:11 +03:00
Дмитрий bf4ed65d0e docs(deploy): runbook — crm_app_user + auth-friendly RLS, isolation verified, 4 test clients
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:05:08 +03:00
Дмитрий 3b2096b4cb docs(deploy): test-server runbook (access, services, update, HTTPS, teardown)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:42:25 +03:00
Дмитрий 2f4cf433cd docs(etalon): bump после supplier dead-donor/UI-бейдж fix + баннер 18:00 (83613b4+68f42ad)
§1 git: HEAD origin/main 68f42ad, ветка дрейфнула на feat/test-deploy, push через cherry-pick worktree. §6: +нить supplier-синк fix. cspell +3 слова.
2026-05-21 11:28:06 +03:00
Дмитрий 5fef4647c1 feat(projects): информационный баннер о сроке изменений до 18:00 МСК
Закрывается крестиком, закрытие запоминается в localStorage. Чисто фронтенд (информация, без блокировок, без бэкенда). +3 Vitest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:17:04 +03:00
Дмитрий 815f0a2dcd docs(deploy): test-deploy Yandex Cloud spec + plan (single VM, nginx/php/pg/redis, real RLS roles)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:02:17 +03:00
Дмитрий e6752b5e4c feat(deploy): temporary SAAS_ADMIN_TEST_BYPASS flag for test server (off by default)
Allows SaaS-admin area in non-local/testing envs only when SAAS_ADMIN_TEST_BYPASS=true.
Default false -> production unaffected. Remove after Yandex 360 SSO (Б-1 + DO-4).
TDD: tests/Feature/Middleware/EnsureSaasAdminTest.php (2 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:00:16 +03:00
Дмитрий 1220bddf3e fix(supplier): recreate deleted donor + fill legacy FK in online sync
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 10:59:37 +03:00
Дмитрий 8c350572df docs(etalon): bump после фичи удаление-вместо-архива + дедуп + человеческие ошибки (22e81cc)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:56:08 +03:00
Дмитрий 22e81cc896 chore(gitleaks): allowlist Nuclei docs false-positive (curl-auth-user)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:50:44 +03:00
Дмитрий 3bbd7787d8 feat(projects-ui): replace archive with delete, drop archived filter
- Remove archived_at from Project interface; rename store.archive → store.del
- BulkActionsBar: archive button → delete (testid, icon, confirm text)
- ProjectCard: archive menu item → delete (emit + icon)
- ProjectDetailsDrawer: confirm text + store.del call
- ProjectsView: @delete binding, remove 'Архивные' status filter entry
- vuetify.ts: add mdi-delete → Trash2 mapping
- All specs/stories updated: archived_at removed, archive → del renamed
- New test: del() calls DELETE /api/projects/{id}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:37:26 +03:00
Дмитрий 07d73870ba refactor(projects): remove archive feature, drop archived_at column (schema v8.27) 2026-05-21 08:24:25 +03:00
Дмитрий 7408bc4232 feat(projects): hard delete with deals-guard, replace archive
- ProjectService: add delete() with DB-level deals check (bypasses SoftDeletes
  scope via DB::table), captures supplier pivot IDs before cascade, dispatches
  DeleteSupplierProjectJob; add bulkDelete() private method; replace archive
  match arm with delete; remove archive() method
- ProjectController: destroy() calls delete() not archive(); update docblocks
- BulkProjectActionRequest: replace 'archive' with 'delete' in Rule::in for action
- Tests: ProjectDeleteTest (2 new TDD tests), ProjectsActionsTest updated
  (destroy → hard delete, 409-already-archived → 422-has-deals, bulk archive → bulk delete)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:08:33 +03:00
Дмитрий 9d68fc0ad6 feat(supplier): delete/re-sync donor on project delete respecting sharing
DeleteSupplierProjectJob: если после удаления Лидерра-проекта у донора
(supplier_project) не осталось других потребителей (pivot
project_supplier_links) — удаляет его у поставщика и локально;
если потребители есть — НЕ удаляет, диспатчит SyncSupplierProjectsJob.
2 Pest-теста (no-consumers / remaining-consumers) GREEN.
phpstan-baseline: +once() Mockery chain (аналог andThrow baseline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 07:50:11 +03:00
Дмитрий e2fb20ef05 feat(projects): source+name dedup on update 2026-05-21 07:35:11 +03:00
Дмитрий 5427cdc740 feat(projects): source+name dedup with human messages on create 2026-05-21 07:01:46 +03:00
Дмитрий f3250ce178 feat(errors): global QueryException handler returns human message 2026-05-21 06:42:38 +03:00
Дмитрий 472ea8c75c docs(plan): project delete + source dedup + human errors implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:31:45 +03:00
Дмитрий b053796182 docs(spec): project delete (vs archive) + source dedup + human errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:27:59 +03:00
Дмитрий 3b6992d8e9 Merge remote-tracking branch 'origin/main' into feat/project-migration-redesign
# Conflicts:
#	docs/observer/STATUS.md
#	docs/observer/episodes-2026-05.jsonl
2026-05-21 06:20:38 +03:00
Дмитрий 233f9984fc chore(observer): backfill chain_ref on live May episodes (working branch) 2026-05-21 06:19:51 +03:00
Дмитрий 4f5cf263f6 docs(observer): chain attribution L1-L13 spec + plan + brain-retro #2
Brain-retro #2 (весь май) → кандидат: атрибуция canonical chains L1-L13.
Spec + 9-task TDD plan (chain_ref в primary_rationale, C6 sync-контролёр,
ретрофилл). Исполнение разблокировано — epic observer-instrument-expansion
влит в main. +cspell словарь.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:42:41 +03:00
59 changed files with 4862 additions and 183 deletions
+7
View File
@@ -0,0 +1,7 @@
# gitleaks false-positive allowlist (fingerprints).
# Format: one fingerprint per line. `gitleaks detect --report-format json` outputs them.
# Nuclei docs `-u http://...` — nuclei's -u flag is "target URL", not curl basic-auth.
# Rule `curl-auth-user` matches the pattern but it's not authentication.
f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth-user:27
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
@@ -74,7 +74,6 @@ class DashboardController extends Controller
// --- active projects ---
$activeProjects = DB::table('projects')
->where('tenant_id', $tenantId)
->whereNull('archived_at')
->where('is_active', true)
->count();
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
@@ -52,16 +52,12 @@ class ProjectController extends Controller
// Фильтр по статусу жизненного цикла
$status = $request->query('status');
if ($status === 'archived') {
$query->archived();
} elseif ($status === 'active') {
$query->active()->where('is_active', true);
if ($status === 'active') {
$query->where('is_active', true);
} elseif ($status === 'paused') {
$query->active()->where('is_active', false);
} else {
// По умолчанию: все не архивированные (active + paused)
$query->active();
$query->where('is_active', false);
}
// default → no extra filter
// Поиск по name и signal_identifier
if ($search = $request->query('search')) {
@@ -111,11 +107,11 @@ class ProjectController extends Controller
return response()->json(['data' => new ProjectResource($project)]);
}
/** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */
/** DELETE /api/projects/{id} — hard delete (guard по сделкам: 422 если есть сделки) */
public function destroy(Request $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$this->projects->archive($project);
$this->projects->delete($project);
return response()->json(null, 204);
}
@@ -139,7 +135,7 @@ class ProjectController extends Controller
return response()->json(['data' => new ProjectResource($project->fresh())]);
}
/** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
public function bulk(BulkProjectActionRequest $request): JsonResponse
{
$tenantId = $request->user()->tenant_id;
+9 -3
View File
@@ -29,10 +29,16 @@ class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
if (app()->environment('local', 'testing')) {
return $next($request);
}
return $next($request);
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
if (config('app.saas_admin_test_bypass') === true) {
return $next($request);
}
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
}
@@ -20,7 +20,7 @@ class BulkProjectActionRequest extends FormRequest
$rules = [
'action' => ['required', Rule::in([
'pause', 'resume', 'archive',
'pause', 'resume', 'delete',
'update_regions', 'update_days', 'update_limit',
])],
'ids' => ['nullable', 'array', 'max:500'],
@@ -28,7 +28,7 @@ class BulkProjectActionRequest extends FormRequest
'scope' => ['nullable', 'array'],
'scope.filter' => ['nullable', 'array'],
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused'])],
'scope.filter.search' => ['nullable', 'string', 'max:255'],
];
@@ -13,9 +13,6 @@ class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
/** @var Project $project */
$project = $this->resource;
return [
'id' => $this->id,
'name' => $this->name,
@@ -28,7 +25,6 @@ class ProjectResource extends JsonResource
'delivered_today' => $this->delivered_today,
'delivered_in_month' => $this->delivered_in_month,
'is_active' => $this->is_active,
'archived_at' => $project->archived_at?->toIso8601String(),
'region_mask' => $this->region_mask,
'region_mode' => $this->region_mode,
'regions' => $this->regions,
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Удаление/пере-синк доноров у поставщика после удаления Лидерра-проекта.
*
* Для каждого supplier_project S (донора), к которому был привязан удалённый проект:
* - остались другие потребители (project_supplier_links) донор нужен другим клиентам:
* НЕ удаляем у поставщика, пере-синкаем агрегат (SyncSupplierProjectsJob).
* - потребителей не осталось удаляем у поставщика (deleteProject) + локальную запись S.
*
* Spec: docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md §Решение 2.
*/
class DeleteSupplierProjectJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public const string DB_CONNECTION = 'pgsql_supplier';
/** @param array<int,int> $supplierProjectIds */
public function __construct(public array $supplierProjectIds) {}
public function handle(SupplierPortalClient $client): void
{
$needsResync = false;
foreach ($this->supplierProjectIds as $id) {
$sp = SupplierProject::on(self::DB_CONNECTION)->find($id);
if ($sp === null) {
continue;
}
$remaining = DB::connection(self::DB_CONNECTION)
->table('project_supplier_links')
->where('supplier_project_id', $id)
->count();
if ($remaining > 0) {
$needsResync = true;
continue;
}
if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
try {
$client->deleteProject((int) $sp->supplier_external_id);
} catch (Throwable $e) {
Log::warning('supplier.delete_donor_failed', [
'supplier_project_id' => $id, 'error' => $e->getMessage(),
]);
throw $e; // retry the job
}
}
$sp->delete();
}
if ($needsResync) {
SyncSupplierProjectsJob::dispatch();
}
}
}
@@ -37,7 +37,7 @@ use Throwable;
* (расписание перенесено 20:30 18:00, см. routes/console.php).
*
* Алгоритм (план 3 Task 5 переработан: one-group-per-identifier):
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
* 1. Загрузить активные Лидерра-projects (is_active=true).
* 2. Сгруппировать по (signal_type, identifier) БЕЗ subject_code:
* - identifier = buildUniqueKeyAgnostic() (site/call signal_identifier; sms+keyword sender+keyword; sms sender).
* - platforms = resolvePlatforms() (site/call B1+B2+B3; sms+keyword B2+B3; sms B3).
@@ -86,7 +86,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
/** @var Collection<int, Project> $projects */
$projects = Project::on(self::DB_CONNECTION)
->where('is_active', true)
->whereNull('archived_at')
->orderBy('id')
->get();
@@ -282,6 +281,52 @@ class SyncSupplierProjectsJob implements ShouldQueue
$existingSps->push($sp);
}
} else {
// External-deletion recovery: донор мог быть удалён на портале → external_id
// в нашей БД мёртв, updateProject его молча no-op'ит. Сверяемся со списком живых
// проектов портала и пересоздаём недостающих in-place (НЕ удаляя записи — на них
// могут висеть лиды/списания). Throws пропагируют в outer handle() catch
// (SupplierAuth/Transient/Client) — failover-counter semantics сохраняется.
$livePortalIds = collect($this->client->listProjects())
->map(fn ($p) => (string) ($p['id'] ?? ''))
->filter()
->all();
$deadSps = $existingSps->filter(
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
);
if ($deadSps->isNotEmpty()) {
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
$recreateDto = new SupplierProjectDto(
platform: $deadPlatforms[0],
signalType: $signalType,
uniqueKey: $identifier,
limit: $order,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $deadPlatforms,
);
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
foreach ($deadSps as $sp) {
$newId = $recreatedIdMap[$sp->platform] ?? null;
if ($newId !== null) {
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'create',
'http_status' => 200,
'created_at' => now(),
]);
}
}
}
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
+79 -7
View File
@@ -60,11 +60,23 @@ class SyncSupplierProjectJob implements ShouldQueue
/** @var array<int, int> */
public array $backoff = [15, 60, 300];
/**
* BYPASSRLS-роль crm_supplier_worker для всех DB-операций (как у всех supplier-flow
* джобов: SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob/).
*
* Джоб запускается из очереди, где SetTenantContext-прослойка не отрабатывает и
* app.current_tenant_id GUC не установлен. Под обычной ролью crm_app_user первый же
* SELECT по projects падает 42704 (unrecognized configuration parameter
* "app.current_tenant_id"). На dev не всплывало там DB_USERNAME=postgres (superuser,
* RLS обходится). Plan 3 Task 3 learning.
*/
public const DB_CONNECTION = 'pgsql_supplier';
public function __construct(public int $projectId) {}
public function handle(SupplierProjectChannel $channel): void
{
$project = Project::find($this->projectId);
$project = Project::on(self::DB_CONNECTION)->find($this->projectId);
if ($project === null) {
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
@@ -105,7 +117,7 @@ class SyncSupplierProjectJob implements ShouldQueue
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
$existingSps = SupplierProject::query()
$existingSps = SupplierProject::on(self::DB_CONNECTION)
->where('unique_key', $identifier)
->where('signal_type', (string) $project->signal_type)
->whereIn('platform', $platforms)
@@ -148,7 +160,7 @@ class SyncSupplierProjectJob implements ShouldQueue
continue;
}
$sp = SupplierProject::create([
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => (string) $project->signal_type,
'unique_key' => $identifier,
@@ -164,6 +176,57 @@ class SyncSupplierProjectJob implements ShouldQueue
$existingSps->push($sp);
}
} else {
// External-deletion recovery: донор мог быть удалён на портале (вручную или
// прошлым hard-delete). Тогда external_id в нашей БД мёртв, а updateProject
// такого id портал молча принимает (no-op) — донор не пересоздаётся. Поэтому
// сверяемся со списком живых проектов портала и пересоздаём недостающих
// in-place (НЕ удаляя записи — на supplier_project могут висеть лиды/списания).
$livePortalIds = collect($client->listProjects())
->map(fn ($p) => (string) ($p['id'] ?? ''))
->filter()
->all();
$deadSps = $existingSps->filter(
fn (SupplierProject $sp) => $sp->supplier_external_id !== null
&& ! in_array((string) $sp->supplier_external_id, $livePortalIds, true)
);
if ($deadSps->isNotEmpty()) {
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
$recreateDto = new SupplierProjectDto(
platform: $deadPlatforms[0],
signalType: (string) $project->signal_type,
uniqueKey: $identifier,
limit: (int) $project->daily_limit_target,
workdays: $workdays,
regions: $allRegions,
regionsReverse: false,
status: 'active',
tag: $tag,
platforms: $deadPlatforms,
);
try {
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
$recreatedIdMap = [];
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
$recreatedIdMap = [];
} catch (\Throwable $e) {
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
$recreatedIdMap = [];
}
foreach ($deadSps as $sp) {
$newId = $recreatedIdMap[$sp->platform] ?? null;
if ($newId !== null) {
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
}
}
}
// Partial-set recovery: если предыдущий run создал не все platforms.
$existingPlatforms = $existingSps->pluck('platform')->all();
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
@@ -200,7 +263,7 @@ class SyncSupplierProjectJob implements ShouldQueue
if ($externalId === null) {
continue;
}
$sp = SupplierProject::create([
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => (string) $project->signal_type,
'unique_key' => $identifier,
@@ -246,13 +309,22 @@ class SyncSupplierProjectJob implements ShouldQueue
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
foreach ($existingSps as $sp) {
DB::table('project_supplier_links')->insertOrIgnore([
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->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.
foreach ($existingSps as $sp) {
$column = 'supplier_'.strtolower((string) $sp->platform).'_project_id';
$project->{$column} = $sp->id;
}
$project->save();
}
// -------------------------------------------------------------------------
@@ -269,7 +341,7 @@ class SyncSupplierProjectJob implements ShouldQueue
$column = 'supplier_'.strtolower($platform).'_project_id';
// Idempotency: local supplier_projects-запись уже есть?
$existing = SupplierProject::query()
$existing = SupplierProject::on(self::DB_CONNECTION)
->where('platform', $platform)
->where('signal_type', $project->signal_type)
->where('unique_key', $uniqueKey)
@@ -306,7 +378,7 @@ class SyncSupplierProjectJob implements ShouldQueue
continue;
}
$sp = SupplierProject::query()->create([
$sp = SupplierProject::on(self::DB_CONNECTION)->create([
'platform' => $platform,
'signal_type' => $project->signal_type,
'unique_key' => $uniqueKey,
-31
View File
@@ -40,8 +40,6 @@ class Project extends Model
'tag',
'type',
'is_active',
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
'archived_at',
'daily_limit_target',
'effective_daily_limit_today',
'effective_limit_calculated_at',
@@ -87,8 +85,6 @@ class Project extends Model
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
'delivered_today' => 'integer',
// Plan 5 Task 1 (schema v8.20): soft archive.
'archived_at' => 'datetime',
];
}
@@ -151,33 +147,6 @@ class Project extends Model
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
}
/**
* Не архивированные проекты (archived_at IS NULL).
*
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
* проекты сюда попадают это разные lifecycle-состояния. Если нужны только
* «работающие» (не архив И не на паузе) комбинируйте:
* ->active()->where('is_active', true).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
/**
* Архивированные проекты (archived_at IS NOT NULL).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
/**
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
*
+112 -14
View File
@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace App\Services\Project;
use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\DB;
class ProjectService
{
@@ -19,7 +21,6 @@ class ProjectService
$data['tenant_id'], $data['signal_type'],
$data['delivered_today'], $data['delivered_in_month'],
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
$data['archived_at'],
);
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
@@ -41,6 +42,18 @@ class ProjectService
|| array_key_exists('daily_limit_target', $data)
|| array_key_exists('delivery_days_mask', $data);
if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
$this->assertSourceUnique($project->tenant_id, array_merge([
'signal_type' => $project->signal_type,
'signal_identifier' => $project->signal_identifier,
'sms_senders' => $project->sms_senders,
'sms_keyword' => $project->sms_keyword,
], $data), exceptId: $project->id);
}
if (array_key_exists('name', $data)) {
$this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
}
$project->update($data);
if ($needsResync) {
@@ -50,17 +63,26 @@ class ProjectService
return $project->fresh();
}
public function archive(Project $project): void
public function delete(Project $project): void
{
if ($project->archived_at !== null) {
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
if ($hasDeals) {
throw new HttpResponseException(response()->json([
'message' => 'Project уже архивирован.',
], 409));
'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
], 422));
}
// Капчим доноров ДО удаления — pivot уйдёт каскадом.
$supplierProjectIds = DB::table('project_supplier_links')
->where('project_id', $project->id)
->pluck('supplier_project_id')
->all();
$project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.
if ($supplierProjectIds !== []) {
DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
}
$project->update([
'is_active' => false,
'archived_at' => now(),
]);
}
public function triggerSync(Project $project): void
@@ -83,9 +105,8 @@ class ProjectService
}
if (! empty($filter['status'])) {
match ($filter['status']) {
'active' => $query->where('is_active', true)->whereNull('archived_at'),
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
'archived' => $query->whereNotNull('archived_at'),
'active' => $query->where('is_active', true),
'paused' => $query->where('is_active', false),
default => null,
};
}
@@ -108,7 +129,7 @@ class ProjectService
return match ($action) {
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
'delete' => $this->bulkDelete($query),
'update_regions' => $this->bulkUpdateRegions($query, $payload),
'update_days' => $this->bulkUpdateDays($query, $payload),
'update_limit' => $this->bulkUpdateLimit($query, $payload),
@@ -122,6 +143,29 @@ class ProjectService
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkDelete($query): array
{
$projects = (clone $query)->get(['id']);
$deleted = 0;
$skipped = [];
foreach ($projects as $p) {
$model = Project::find($p->id);
if ($model === null) {
continue;
}
try {
$this->delete($model);
$deleted++;
} catch (HttpResponseException) {
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
}
}
return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
}
/**
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
*
@@ -213,10 +257,60 @@ class ProjectService
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
}
private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
{
$q = Project::where('tenant_id', $tenantId)->where('name', $name);
if ($exceptId !== null) {
$q->where('id', '!=', $exceptId);
}
if ($q->exists()) {
throw new HttpResponseException(response()->json([
'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
], 422));
}
}
/** @param array<string,mixed> $data */
private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
{
$signalType = $data['signal_type'] ?? null;
$q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
if ($exceptId !== null) {
$q->where('id', '!=', $exceptId);
}
if (in_array($signalType, ['call', 'site'], true)) {
$identifier = (string) ($data['signal_identifier'] ?? '');
if ($identifier === '') {
return;
}
$q->where('signal_identifier', $identifier);
} elseif ($signalType === 'sms') {
$senders = (array) ($data['sms_senders'] ?? []);
$norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
if ($norm === []) {
return;
}
$keyword = $data['sms_keyword'] ?? null;
$q->where('sms_keyword', $keyword)
->whereJsonContains('sms_senders', $norm)
->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
} else {
return;
}
$existing = $q->first();
if ($existing !== null) {
throw new HttpResponseException(response()->json([
'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
], 422));
}
}
public function create(Tenant $tenant, array $data): Project
{
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
$current = Project::where('tenant_id', $tenant->id)->active()->count();
$current = Project::where('tenant_id', $tenant->id)->count();
if ($current >= $limit) {
throw new HttpResponseException(response()->json([
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
@@ -230,6 +324,10 @@ class ProjectService
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
$data['region_mask'] = 255;
$data['region_mode'] = 'include';
$this->assertNameUnique($tenant->id, (string) $data['name']);
$this->assertSourceUnique($tenant->id, $data);
$project = Project::create($data);
SyncSupplierProjectJob::dispatch($project->id);
@@ -8,7 +8,7 @@ use App\Exceptions\Supplier\SupplierAuthException;
class PlaywrightBridge
{
private const TIMEOUT_SECONDS = 75; // 60s Node timeout + 15s safety buffer
private const TIMEOUT_SECONDS = 180; // 60s Node timeout + запас на холодный старт Chromium на маломощных VM (тест-сервер YC 2vCPU/2GB: ~65s wall-clock на refresh-session). До 21.05.2026 было 75с — упиралось на тест-сервере.
private const SCRIPT_RELATIVE_PATH = 'playwright/refresh-session.js';
+17 -1
View File
@@ -2,9 +2,12 @@
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\SetTenantContext;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -30,5 +33,18 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->render(function (QueryException $e, Request $request) {
Log::error('db.query_exception', [
'message' => $e->getMessage(),
'sql' => $e->getSql(),
'path' => $request->path(),
]);
if ($request->expectsJson()) {
return response()->json([
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
], 422);
}
return null; // default render for non-JSON
});
})->create();
+7
View File
@@ -28,6 +28,13 @@ return [
'env' => env('APP_ENV', 'production'),
/*
| ВРЕМЕННО (тест-деплой): пропуск гейта SaaS-admin зоны вне local/testing.
| По умолчанию false прод не затронут. Включается только на тест-сервере
| (SAAS_ADMIN_TEST_BYPASS=true). Убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
*/
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS archived_at');
}
public function down(): void
{
DB::statement('ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL');
}
};
+114
View File
@@ -258,6 +258,90 @@ parameters:
count: 1
path: app/Services/Supplier/SupplierProjectGrouping.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -1572,6 +1656,12 @@ parameters:
count: 14
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/QueryExceptionRenderTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1848,6 +1938,12 @@ parameters:
count: 2
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -2015,3 +2111,21 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 6
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
-
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Project/ProjectCreateDedupTest.php
@@ -29,11 +29,11 @@
<v-btn
color="error"
prepend-icon="mdi-archive"
data-testid="bulk-archive"
@click="confirmAndRun('archive')"
prepend-icon="mdi-delete"
data-testid="bulk-delete"
@click="confirmAndRun('delete')"
>
Архивировать
Удалить
</v-btn>
<v-spacer />
@@ -92,11 +92,10 @@ const skipToastText = ref('');
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
archive:
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
delete: 'Удалить выбранные проекты? Действие необратимо. Проекты со сделками будут пропущены.',
};
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
if (!window.confirm(messages[action])) return;
await runBulk({ action });
}
@@ -9,7 +9,6 @@ const base = {
daily_limit_target: 50,
delivered_today: 32,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
};
@@ -48,9 +48,9 @@
<template #prepend><v-icon>mdi-refresh</v-icon></template>
<v-list-item-title>Синхронизировать</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('archive', project)">
<template #prepend><v-icon>mdi-archive</v-icon></template>
<v-list-item-title>Архивировать</v-list-item-title>
<v-list-item @click="$emit('delete', project)">
<template #prepend><v-icon>mdi-delete</v-icon></template>
<v-list-item-title>Удалить</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
@@ -97,7 +97,7 @@ defineEmits<{
edit: [project: Project];
'toggle-active': [project: Project];
'sync-now': [project: Project];
archive: [project: Project];
delete: [project: Project];
}>();
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
@@ -63,10 +63,10 @@ async function onPause(): Promise<void> {
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm(
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
);
if (!ok) return;
await store.archive(props.project.id);
await store.del(props.project.id);
emit('close');
}
+1
View File
@@ -168,6 +168,7 @@ const lucideMap: Record<string, Component> = {
'mdi-content-save-outline': Save,
'mdi-credit-card-outline': CreditCard,
'mdi-currency-rub': RussianRuble,
'mdi-delete': Trash2,
'mdi-delete-outline': Trash2,
'mdi-dots-vertical': MoreVertical,
'mdi-download': Download,
+4 -5
View File
@@ -13,7 +13,6 @@ export interface Project {
delivered_today: number;
delivered_in_month?: number;
is_active: boolean;
archived_at: string | null;
region_mask?: number;
region_mode?: string;
regions?: number[]; // Plan 6 — subject codes 1..89; пустой массив = вся РФ
@@ -65,7 +64,7 @@ export const useProjectsStore = defineStore('projects', () => {
return data.data;
}
async function archive(id: number) {
async function del(id: number) {
await axios.delete(`/api/projects/${id}`);
await fetch();
}
@@ -94,7 +93,7 @@ export const useProjectsStore = defineStore('projects', () => {
selectedIds.value.clear();
}
async function bulkAction(action: 'pause' | 'resume' | 'archive') {
async function bulkAction(action: 'pause' | 'resume' | 'delete') {
const ids = Array.from(selectedIds.value);
if (!ids.length) return;
await axios.post('/api/projects/bulk', { action, ids });
@@ -103,7 +102,7 @@ export const useProjectsStore = defineStore('projects', () => {
}
interface BulkPayload {
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
action: 'pause' | 'resume' | 'delete' | 'update_regions' | 'update_days' | 'update_limit';
add?: number;
remove?: number;
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
@@ -200,7 +199,7 @@ export const useProjectsStore = defineStore('projects', () => {
fetch,
create,
update,
archive,
del,
syncNow,
toggleActive,
toggleSelect,
+35 -2
View File
@@ -5,6 +5,31 @@
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
</div>
<v-alert
v-if="showCutoffBanner"
data-testid="cutoff-banner"
type="info"
variant="tonal"
border="start"
class="mb-4"
>
<div class="d-flex justify-space-between align-start gap-2">
<span>
Важно: изменения по проектам (добавление, удаление, лимиты, рабочие дни, регионы)
вносите <strong>до 18:00 МСК</strong>. Изменения после 18:00 применяются при следующей
синхронизации на следующий день.
</span>
<v-btn
data-testid="cutoff-banner-close"
icon="mdi-close"
size="x-small"
variant="text"
aria-label="Скрыть уведомление"
@click="dismissCutoffBanner"
/>
</div>
</v-alert>
<div class="d-flex gap-3 mb-4">
<v-select
v-model="store.filters.signal_type"
@@ -71,7 +96,7 @@
@edit="openEdit"
@toggle-active="store.toggleActive"
@sync-now="(p: Project) => store.syncNow(p.id)"
@archive="(p: Project) => store.archive(p.id)"
@delete="(p: Project) => store.del(p.id)"
/>
</div>
@@ -101,6 +126,15 @@ const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 18:00 МСК).
// Закрытие запоминается, чтобы не показывать повторно.
const CUTOFF_BANNER_KEY = 'projects.cutoffBannerDismissed';
const showCutoffBanner = ref(localStorage.getItem(CUTOFF_BANNER_KEY) !== '1');
function dismissCutoffBanner(): void {
showCutoffBanner.value = false;
localStorage.setItem(CUTOFF_BANNER_KEY, '1');
}
const singleSelectedProject = computed<Project | null>(() => {
if (store.selectedIds.size !== 1) return null;
const [id] = store.selectedIds;
@@ -123,7 +157,6 @@ const typeFilters = [
const statusFilters = [
{ title: 'Активные', value: 'active' },
{ title: 'На паузе', value: 'paused' },
{ title: 'Архивные', value: 'archived' },
];
let searchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -25,7 +25,6 @@ const sampleProject = {
daily_limit_target: 50,
delivered_today: 12,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
region_mask: 0,
region_mode: 'include' as const,
+4 -5
View File
@@ -94,12 +94,11 @@ it('conversion = доля статуса won в окне', function () {
->assertJsonPath('conversion.value', 25);
});
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
it('active_projects считает is_active=true + limit из limits', function () {
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('active_projects.active', 2)
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use function Pest\Laravel\get;
// Гейт SaaS-admin зоны (middleware EnsureSaasAdmin). Вне local/testing зона
// закрыта (503), кроме случая включённого временного флага тест-деплоя.
it('blocks saas-admin area outside local/testing without bypass flag', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => false]);
get('/api/admin/tenants')->assertStatus(503);
});
it('allows saas-admin area when test bypass flag is enabled', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => true]);
expect(get('/api/admin/tenants')->status())->not->toBe(503);
});
@@ -8,10 +8,13 @@ use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
// DatabaseTransactions — per-test isolation.
uses(DatabaseTransactions::class);
// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS);
// без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
@@ -6,28 +6,34 @@ use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
beforeEach(fn () => Queue::fake());
it('destroy archives project (sets archived_at, is_active=false)', function () {
it('destroy hard-deletes a project with no deals', 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)->deleteJson("/api/projects/{$project->id}")->assertNoContent();
$project->refresh();
expect($project->is_active)->toBeFalse();
expect($project->archived_at)->not->toBeNull();
expect(Project::find($project->id))->toBeNull();
});
it('destroy returns 409 if already archived', function () {
it('destroy returns 422 if project has deals', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
DB::table('deals')->insert([
'tenant_id' => $tenant->id, 'project_id' => $project->id,
'phone' => '79990001100', 'status' => 'new',
'received_at' => now(), 'created_at' => now(),
]);
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(409);
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(422);
expect(Project::find($project->id))->not->toBeNull();
});
it('sync re-dispatches SyncSupplierProjectJob', function () {
@@ -81,16 +87,16 @@ it('bulk filters out cross-tenant ids silently', function () {
expect($pB->fresh()->is_active)->toBeTrue();
});
it('bulk archive sets archived_at on multiple', function () {
it('bulk delete removes project with no deals', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$p1 = Project::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user)->postJson('/api/projects/bulk', [
'action' => 'archive', 'ids' => [$p1->id],
])->assertOk();
'action' => 'delete', 'ids' => [$p1->id],
])->assertOk()->assertJsonPath('updated', 1);
expect($p1->fresh()->archived_at)->not->toBeNull();
expect(Project::find($p1->id))->toBeNull();
});
it('bulk rejects > 500 ids', function () {
@@ -16,7 +16,7 @@ it('returns paginated list of active projects for current tenant', function () {
$response->assertOk();
$response->assertJsonStructure([
'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target',
'delivered_today', 'is_active', 'archived_at', 'sync_status']],
'delivered_today', 'is_active', 'sync_status']],
'meta' => ['current_page', 'per_page', 'total'],
]);
expect($response->json('meta.total'))->toBe(3);
@@ -45,23 +45,24 @@ it('isolates projects per tenant (RLS)', function () {
expect($response->json('meta.total'))->toBe(2);
});
it('excludes archived projects by default', function () {
it('returns all projects by default (archive feature removed in v8.27)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
Project::factory()->create(['tenant_id' => $tenant->id]);
$response = $this->actingAs($user)->getJson('/api/projects');
expect($response->json('meta.total'))->toBe(1);
expect($response->json('meta.total'))->toBe(2);
});
it('returns archived when status=archived requested', function () {
it('status=active returns only is_active=true projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$response = $this->actingAs($user)->getJson('/api/projects?status=archived');
$response = $this->actingAs($user)->getJson('/api/projects?status=active');
expect($response->json('meta.total'))->toBe(1);
});
@@ -140,19 +141,18 @@ it('search is case-insensitive for Cyrillic substrings', function () {
expect($partial->json('meta.total'))->toBe(1);
});
it('show returns 200 for archived project (read access preserved)', function () {
it('show returns 200 for any project by id', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'archived_at' => now(),
'signal_type' => 'site',
'signal_identifier' => 'archived.ru',
'signal_identifier' => 'myproject.ru',
]);
$response = $this->actingAs($user)->getJson("/api/projects/{$project->id}");
$response->assertOk();
expect($response->json('data.id'))->toBe($project->id);
expect($response->json('data.archived_at'))->not->toBeNull();
expect($response->json('data'))->not->toHaveKey('archived_at');
});
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\Project;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Schema;
@@ -11,15 +10,6 @@ use Illuminate\Support\Facades\Schema;
// DatabaseTransactions — изоляция; also ensures DB connection is bootstrapped.
uses(DatabaseTransactions::class);
it('projects table has archived_at column nullable timestamp', function () {
expect(Schema::hasColumn('projects', 'archived_at'))->toBeTrue();
$type = Schema::getColumnType('projects', 'archived_at');
// PostgreSQL TIMESTAMPTZ → Doctrine/Laravel reports 'timestamptz' (not 'timestamp').
expect($type)->toBe('timestamptz');
});
it('Project model has archived_at in fillable and casts it to datetime', function () {
$project = new Project;
expect(in_array('archived_at', $project->getFillable(), true))->toBeTrue();
expect($project->getCasts()['archived_at'] ?? null)->toBe('datetime');
it('projects table does NOT have archived_at column (feature removed in v8.27)', function () {
expect(Schema::hasColumn('projects', 'archived_at'))->toBeFalse();
});
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Queue::fake();
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
});
function makeCall(array $over = []): array
{
return array_merge([
'name' => 'Проект A', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
], $over);
}
it('blocks duplicate source within tenant with human message', function () {
app(ProjectService::class)->create($this->tenant, makeCall());
expect(fn () => app(ProjectService::class)
->create($this->tenant, makeCall(['name' => 'Проект B'])))
->toThrow(HttpResponseException::class);
});
it('allows same source for a different tenant (sharing)', function () {
$other = Tenant::factory()->create(['balance_leads' => 100]);
app(ProjectService::class)->create($this->tenant, makeCall());
$p = app(ProjectService::class)->create($other, makeCall(['name' => 'Проект B']));
expect($p)->toBeInstanceOf(Project::class);
});
it('blocks duplicate name within tenant with human message (not SQL)', function () {
app(ProjectService::class)->create($this->tenant, makeCall());
try {
app(ProjectService::class)
->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000']));
$this->fail('expected HttpResponseException');
} catch (HttpResponseException $e) {
$body = $e->getResponse()->getData(true);
expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE');
}
});
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
beforeEach(fn () => Queue::fake());
it('hard-deletes an empty project', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = app(ProjectService::class)->create($tenant, [
'name' => 'Empty', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
]);
app(ProjectService::class)->delete($project);
expect(Project::find($project->id))->toBeNull();
});
it('blocks delete when project has deals', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = app(ProjectService::class)->create($tenant, [
'name' => 'WithDeals', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
]);
DB::table('deals')->insert([
'tenant_id' => $tenant->id, 'project_id' => $project->id, 'phone' => '79990001122',
'status' => 'new', 'received_at' => now(), 'created_at' => now(),
]);
expect(fn () => app(ProjectService::class)->delete($project))
->toThrow(HttpResponseException::class);
expect(Project::find($project->id))->not->toBeNull();
});
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Project\ProjectService;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Queue;
beforeEach(fn () => Queue::fake());
it('blocks update that collides source with another project of same tenant', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$svc = app(ProjectService::class);
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
$b = $svc->create($tenant, ['name' => 'B', 'signal_type' => 'call', 'signal_identifier' => '79992220000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
expect(fn () => $svc->update($b, ['signal_identifier' => '79991110000']))
->toThrow(HttpResponseException::class);
});
it('allows update keeping same source on the same project', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$svc = app(ProjectService::class);
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
$updated = $svc->update($a, ['signal_identifier' => '79991110000', 'daily_limit_target' => 7]);
expect($updated->daily_limit_target)->toBe(7);
});
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Route;
it('renders QueryException as human JSON message, not SQLSTATE', function () {
Route::get('/_test/boom-query', function () {
throw new QueryException('pgsql', 'SELECT 1', [], new Exception('SQLSTATE[23505] duplicate key'));
});
$res = $this->getJson('/_test/boom-query');
$res->assertStatus(422);
expect($res->json('message'))->not->toContain('SQLSTATE');
expect($res->json('message'))->toContain('Не удалось');
});
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('deletes donor at supplier when no consumers remain', function (): void {
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000',
'supplier_external_id' => '555', 'current_limit' => 1,
]);
$mock = Mockery::mock(SupplierPortalClient::class);
$mock->shouldReceive('deleteProject')->once()->with(555);
app()->instance(SupplierPortalClient::class, $mock);
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
expect(SupplierProject::find($sp->id))->toBeNull();
});
it('does NOT delete donor at supplier when other consumers remain; re-syncs', function (): void {
Bus::fake([SyncSupplierProjectsJob::class]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$sp = SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110001',
'supplier_external_id' => '556', 'current_limit' => 1,
]);
$other = Project::factory()->create(['tenant_id' => $tenant->id]);
DB::table('project_supplier_links')->insert([
'project_id' => $other->id,
'supplier_project_id' => $sp->id,
'platform' => 'B1',
'subject_code' => null,
]);
$mock = Mockery::mock(SupplierPortalClient::class);
$mock->shouldNotReceive('deleteProject');
app()->instance(SupplierPortalClient::class, $mock);
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
expect(SupplierProject::find($sp->id))->not->toBeNull();
Bus::assertDispatched(SyncSupplierProjectsJob::class);
});
@@ -21,6 +21,7 @@ declare(strict_types=1);
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
@@ -46,6 +47,14 @@ test('RouteSupplierLeadJob declares DB_CONNECTION = pgsql_supplier (Plan 3 Task
expect(RouteSupplierLeadJob::DB_CONNECTION)->toBe('pgsql_supplier');
});
test('SyncSupplierProjectJob declares DB_CONNECTION = pgsql_supplier (queue worker has no tenant GUC)', function (): void {
// Дублирует RouteSupplierLeadJob: создание/правка проекта тоже запускается из очереди,
// где SetTenantContext-прослойка не отработала. Под обычной ролью crm_app_user
// SELECT по projects падает 42704 (unrecognized configuration parameter
// "app.current_tenant_id"). Все DB-операции джоба обязаны идти через pgsql_supplier (BYPASSRLS).
expect(SyncSupplierProjectJob::DB_CONNECTION)->toBe('pgsql_supplier');
});
test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void {
// Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
// отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false).
@@ -213,6 +213,103 @@ it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_proje
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
});
it('online mode re-creates donor on portal when its external_id no longer exists there', function (): void {
// Regression: если донора удалили на портале, в нашей БД остаются supplier_projects
// с мёртвыми external_id. Раньше джоб шёл по update-ветке → updateProject мёртвого id
// портал молча принимает (no-op) → донор не пересоздаётся. Фикс: проверять, жив ли
// external_id на портале (listProjects), и пересоздавать недостающих in-place
// (НЕ удаляя записи — на них могут висеть лиды/списания).
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'call',
'signal_identifier' => '79990001122',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [],
'delivery_days_mask' => 31,
]);
// Pre-seed supplier_projects, чьи external_id указывают на удалённых с портала доноров.
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::create([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79990001122',
'subject_code' => null,
'supplier_external_id' => 'DEAD'.$platform,
'current_limit' => 10,
'current_workdays' => [1, 2, 3, 4, 5],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$loadCalls = 0;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '7003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
$loadCalls++;
// Первый load = проверка существования → донор удалён (пусто).
if ($loadCalls === 1) {
return Http::response(['projects' => []], 200);
}
// Последующие load (внутри saveProjectMultiFlag) = свежесозданные доноры.
return Http::response(['projects' => [
['id' => '7001', 'src' => 'rt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
['id' => '7002', 'src' => 'bl', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
['id' => '7003', 'src' => 'mt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'],
]], 200);
},
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
// external_id переписаны на свежесозданных доноров (не DEAD*), записи не удалены.
$sps = SupplierProject::where('unique_key', '79990001122')->orderBy('platform')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['7001', '7002', '7003']);
});
it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-status is not stuck pending', function (): void {
// Regression: online mode writes the link to the pivot, but ProjectResource/aggregateSyncStatus
// read the legacy FK columns (supplierB1/B2/B3). They stayed NULL in online → "Sync pending"
// forever even though the stack is synced. Online must populate them too.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'uisync.example.com',
'is_active' => true,
'daily_limit_target' => 5,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '9003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '9001', 'src' => 'rt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
['id' => '9002', 'src' => 'bl', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
['id' => '9003', 'src' => 'mt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'],
]], 200),
]);
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
$project->refresh();
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
expect($project->aggregateSyncStatus())->toBe('ok');
});
// ---------------------------------------------------------------------------
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
// ---------------------------------------------------------------------------
@@ -250,3 +347,53 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n
// Batch: no pivot rows (nightly job fills them)
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
});
// ---------------------------------------------------------------------------
// Connection: must use pgsql_supplier (BYPASSRLS) — queue worker has no tenant GUC
// ---------------------------------------------------------------------------
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)
// the very first Project::find() dies with SQLSTATE 42704 before any supplier contact,
// so the supplier project is never created and the UI sticks on "Sync pending".
// Every sibling supplier job (SyncSupplierProjectsJob/DeleteSupplierProjectJob/…) uses
// pgsql_supplier; this one must too. On dev (postgres superuser) RLS is bypassed, so we
// assert the *connection* the queries run on rather than RLS enforcement.
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'conn-test.example.com',
'is_active' => true,
'daily_limit_target' => 10,
'regions' => [],
'delivery_days_mask' => 127,
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
['id' => '8001', 'src' => 'rt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
['id' => '8002', 'src' => 'bl', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
['id' => '8003', 'src' => 'mt', 'name' => 'conn-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'conn-test.example.com'],
]], 200),
]);
// Listen only during the job run (factory queries above are already done).
$projectConnections = [];
DB::listen(function ($query) use (&$projectConnections): void {
// '"projects"' (quoted table) does NOT match '"supplier_projects"' or
// '"project_supplier_links"', so this captures only the projects table.
if (str_contains($query->sql, '"projects"')) {
$projectConnections[] = $query->connectionName;
}
});
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
expect($projectConnections)->not->toBeEmpty();
expect(array_values(array_unique($projectConnections)))->toBe(['pgsql_supplier']);
});
@@ -57,7 +57,6 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'persubject.example.com',
'daily_limit_target' => 9,
@@ -116,7 +115,6 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'rf-pool.example.com',
'daily_limit_target' => 6,
@@ -167,7 +165,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'order-test.example.com',
'daily_limit_target' => 10,
@@ -178,7 +175,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'order-test.example.com',
'daily_limit_target' => 20,
@@ -229,7 +225,6 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['79001234567'],
@@ -271,7 +266,6 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['79009876543'],
@@ -314,7 +308,6 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'idempotent.example.com',
'daily_limit_target' => 9,
@@ -375,7 +368,6 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'time-budget.example.com',
'daily_limit_target' => 9,
@@ -397,7 +389,6 @@ test('sticky auth error throws and sends critical alert email', function (): voi
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'auth-fail.example.com',
'daily_limit_target' => 9,
@@ -425,7 +416,6 @@ test('aborts after 50 consecutive transient failures and sends alert', function
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => "host{$i}.abort.com",
'daily_limit_target' => 9,
@@ -449,7 +439,6 @@ test('writes supplier_sync_log row for each successful action', function (): voi
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'audit-log.example.com',
'daily_limit_target' => 9,
@@ -491,3 +480,57 @@ test('writes supplier_sync_log row for each successful action', function (): voi
->and($log->http_status)->toBe(200)
->and($log->error_message)->toBeNull();
});
test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void {
// Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79993334455',
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'regions' => [],
]);
foreach (['B1', 'B2', 'B3'] as $platform) {
SupplierProject::on('pgsql_supplier')->forceCreate([
'platform' => $platform,
'signal_type' => 'call',
'unique_key' => '79993334455',
'subject_code' => null,
'supplier_external_id' => 'GONE'.$platform,
'current_limit' => 10,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => [],
'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$loadCalls = 0;
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200),
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) {
$loadCalls++;
if ($loadCalls === 1) {
return Http::response(['projects' => []], 200);
}
return Http::response(['projects' => [
['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'],
]], 200);
},
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get();
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});
+2 -2
View File
@@ -169,7 +169,7 @@ describe('BulkActionsBar — extended', () => {
expect((wrapper.vm as unknown as { regionsOpen: boolean }).regionsOpen).toBe(true);
});
it('keeps existing pause/resume/archive buttons', async () => {
it('keeps existing pause/resume/delete buttons', async () => {
setActivePinia(createPinia());
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
@@ -184,6 +184,6 @@ describe('BulkActionsBar — extended', () => {
});
expect(wrapper.find('[data-testid="bulk-pause"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-resume"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-archive"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-delete"]').exists()).toBe(true);
});
});
@@ -23,7 +23,6 @@ const sampleProject = {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
region_mask: 0,
region_mode: 'include',
-1
View File
@@ -12,7 +12,6 @@ const baseProject = {
daily_limit_target: 50,
delivered_today: 32,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
};
@@ -11,7 +11,6 @@ const sample = {
is_active: true,
daily_limit_target: 50,
delivered_today: 12,
archived_at: null,
sync_status: 'ok' as const,
};
@@ -21,7 +21,6 @@ const sampleProject: Project = {
daily_limit_target: 30,
delivered_today: 0,
is_active: true,
archived_at: null,
region_mask: 0,
region_mode: 'include',
regions: [],
@@ -152,10 +151,10 @@ describe('ProjectDetailsDrawer', () => {
expect(wrapper.get('[data-testid="pdd-pause"]').text()).toContain('Приостановить');
});
it('Delete: confirm=true → archive + close emit', async () => {
it('Delete: confirm=true → del + close emit', async () => {
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
const store = useProjectsStore();
const spy = vi.spyOn(store, 'archive').mockResolvedValueOnce(undefined);
const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined);
vi.stubGlobal('confirm', () => true);
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
@@ -166,10 +165,10 @@ describe('ProjectDetailsDrawer', () => {
vi.unstubAllGlobals();
});
it('Delete: confirm=false → no archive, no close', async () => {
it('Delete: confirm=false → no del, no close', async () => {
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
const store = useProjectsStore();
const spy = vi.spyOn(store, 'archive').mockResolvedValueOnce(undefined);
const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined);
vi.stubGlobal('confirm', () => false);
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
+33 -5
View File
@@ -52,7 +52,6 @@ describe('ProjectsView', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
sync_status: 'ok',
},
],
@@ -82,7 +81,6 @@ describe('ProjectsView', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
sync_status: 'ok',
},
{
@@ -93,7 +91,6 @@ describe('ProjectsView', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
sync_status: 'ok',
},
],
@@ -127,7 +124,6 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
};
const projectB = {
@@ -138,7 +134,6 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
};
@@ -244,3 +239,36 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
});
describe('ProjectsView 18:00 cutoff banner', () => {
beforeEach(() => {
localStorage.clear();
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } },
});
});
it('shows the cutoff banner with the 18:00 deadline by default', async () => {
const wrapper = factory();
await flushPromises();
const banner = wrapper.find('[data-testid="cutoff-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('18:00');
});
it('hides the banner after the close button and remembers it in localStorage', async () => {
const wrapper = factory();
await flushPromises();
await wrapper.find('[data-testid="cutoff-banner-close"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
expect(localStorage.getItem('projects.cutoffBannerDismissed')).toBe('1');
});
it('stays hidden on next mount when previously dismissed', async () => {
localStorage.setItem('projects.cutoffBannerDismissed', '1');
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
});
});
@@ -20,7 +20,6 @@ const mountView = async () => {
daily_limit_target: 100,
delivered_today: 0,
is_active: true,
archived_at: null,
region_mask: 1,
region_mode: 'include',
delivery_days_mask: 31,
@@ -34,7 +33,6 @@ const mountView = async () => {
daily_limit_target: 100,
delivered_today: 0,
is_active: true,
archived_at: null,
region_mask: 1,
region_mode: 'include',
delivery_days_mask: 31,
+8 -2
View File
@@ -66,6 +66,14 @@ describe('projectsStore (no polling)', () => {
expect(store.selectedIds.has(1)).toBe(false);
});
it('del() calls DELETE /api/projects/{id}', async () => {
const store = useProjectsStore();
vi.spyOn(axios, 'delete').mockResolvedValue({ data: null });
vi.spyOn(axios, 'get').mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
await store.del(7);
expect(axios.delete).toHaveBeenCalledWith('/api/projects/7');
});
it('bulkAction sends array of ids and clears selection', async () => {
(axios.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { updated: 2 } });
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -104,7 +112,6 @@ describe('projectsStore (polling)', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
region_mask: 0,
region_mode: 'include',
delivery_days_mask: 127,
@@ -133,7 +140,6 @@ describe('projectsStore (polling)', () => {
daily_limit_target: 10,
delivered_today: 0,
is_active: true,
archived_at: null,
region_mask: 0,
region_mode: 'include',
delivery_days_mask: 127,
+29
View File
@@ -1,6 +1,17 @@
# Глоссарий проекта Лидерра
# Формат: одно слово на строке. Кириллица в нижнем регистре.
# Test-deploy Yandex Cloud (2026-05-21)
hba
htpasswd
lsb
nslookup
scp
хостить
tos
прода
ребута
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
iconify
@@ -1579,3 +1590,21 @@ lemed
побочек
диффы
ретрофилл
# project delete / dedup / errors spec (2026-05-21)
шеринг
шеринга
констрейнт
дропается
батч
ретраит
шеринге
# Supplier dead-donor fix + баннер 18:00 (2026-05-21)
дрейфнувшей
дропа
коммитах
доустановлены
дочерпывание
creds
незавершёнку
+4
View File
@@ -6,6 +6,10 @@
**История записей:**
## v8.27 — 2026-05-21 — DROP COLUMN projects.archived_at
- DROP COLUMN `projects.archived_at` — фича «архив» полностью убрана и заменена настоящим удалением с защитой по сделкам (`ProjectService::delete()`). Миграция `2026_05_21_000000_drop_projects_archived_at.php`.
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)
`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
+1 -2
View File
@@ -1,6 +1,6 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.26 (20.05.2026 — project-migration-redesign Plans 1-3: supplier_projects.subject_code (per-субъект экспорт) + project_supplier_links (M:N pivot projects↔supplier_projects) + deals.subject_code + CHECK chk_deals_subject_code + seed system_settings.supplier_export_mode)
-- Версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
-- Метрики: 65 базовые таблицы (63 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 123 индекса / 40 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
@@ -840,7 +840,6 @@ CREATE TABLE projects (
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ NULL, -- v8.20 (Plan 5): soft archive flow (отличие от is_active=false который = pause)
UNIQUE (tenant_id, name),
CONSTRAINT chk_projects_daily_limit_positive
CHECK (daily_limit_target > 0),
+130
View File
@@ -0,0 +1,130 @@
# Лидерра — тест-сервер (Yandex Cloud) — runbook
**Создан:** 2026-05-21. Тестовое окружение для ручной проверки (заказчик + Claude). Не продакшен.
Спека: `docs/superpowers/specs/2026-05-21-test-deploy-yandex-cloud-design.md`.
План: `docs/superpowers/plans/2026-05-21-test-deploy-yandex-cloud.md`.
## Доступ
- **URL (HTTP, временно):** `http://111.88.246.137` — статический IP YC.
- **HTTPS / домен:** добавляется после покупки домена (см. «Включить HTTPS»).
- **Дверь сайта (HTTP Basic Auth):** логин `liderra` — пароль в `/home/ubuntu/liderra-secrets.txt` на сервере (ключ `basic_auth`).
- **Демо-вход в портал:** `admin@demo.local` / `password` (tenant `demo`, 3 проекта, демо-сделки).
- **SSH:** `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137` (ключ на dev-машине; пароль входа отключён).
- **YC:** облако `cloud-sasha261185`, каталог `default`, VM `liderra-test` (ru-central1-a, 2vCPU/2GB/20%), SG `liderra-test-sg` (22/80/443).
## Состав
- Ubuntu 24.04: nginx (Basic Auth, webhook `/api/webhook/*` без auth) → PHP-FPM 8.3 → Laravel.
- PostgreSQL 16 (БД `liderra`), Redis (sessions+cache+queue, predis).
- Код в `/var/www/liderra/app`; фронтенд `public/build` (собирается на dev, заливается scp).
- Службы: `liderra-queue.service` (queue worker, systemd, enabled) + cron `/etc/cron.d/liderra-scheduler` (schedule:run). Все автозапускаются после ребута.
## Важные отклонения от прод-дизайна (на решение позже)
- **DB-роль приложения = `crm_app_user` (RLS включена)** — изоляция бизнес-данных между клиентами
**работает** (deals/projects/billing/… строгие политики). Чтобы вход работал под строгой ролью,
RLS-политики на таблицах `users` + `auth_log` сделаны «дружелюбными ко входу»: пропускают запрос,
когда tenant-контекст ещё не установлен (auth/login), и фильтруют по тенанту после. Это server-only
правка политик (не в schema.sql); для прода — кандидат в нормативную схему.
- **Админка SaaS `/admin/*` под `crm_app_user` НЕ работает** (нет доступа к saas-таблицам — REVOKE).
Для теста «от лица клиентов» не нужна. Понадобится — переключать admin-запросы на `crm_admin_user`
(connection-switch в middleware `EnsureSaasAdmin`) — отдельная доработка.
- **`SAAS_ADMIN_TEST_BYPASS=true`** — временный флаг (для будущей админки). Убрать после Yandex SSO (Б-1).
- **Почта** = `log` (письма в файл). **APP_DEBUG=false**, **APP_ENV=production**.
- Установлены dev-зависимости (faker нужен для сидов).
## Тестовые клиенты
| Логин | Пароль | Компания |
|---|---|---|
| `admin@demo.local` | `password` | Demo (3 проекта + демо-сделки) |
| `client1@liderra.test` | `password` | Компания 1 (2 проекта) |
| `client2@liderra.test` | `password` | Компания 2 (2 проекта) |
| `client3@liderra.test` | `password` | Компания 3 (2 проекта) |
| `client4@liderra.test` | `password` | Компания 4 (2 проекта) |
Изоляция проверена вживую: каждый видит только свои проекты (HTTP-логин + `/api/projects`).
## Каналы миграции с поставщиком (настроены 2026-05-21)
Все 3 канала с `crm.bp-gr.ru` подняты и проверены вживую на тест-сервере.
### Предпосылки (доустановлены сверх базового деплоя — в исходном runbook их не было)
- **Node.js 20** (NodeSource) + **Playwright** (`app/playwright/node_modules`, `npm install`) + **Chromium**
в `/var/www/.cache/ms-playwright/` (HOME у `www-data` = `/var/www`; ставить через
`sudo HOME=/var/www .../playwright install chromium` затем `chown -R www-data:www-data /var/www/.cache`,
иначе artisan от www-data не находит браузер). Без них логин к поставщику (Yii2-форма, JS) не работает
→ CSV-сверка и экспорт мертвы (`PlaywrightBridge exit code 127: node: not found`).
- `PlaywrightBridge::TIMEOUT_SECONDS` поднят **75 → 180** (`app/app/Services/Supplier/PlaywrightBridge.php`):
на 2 ГБ VM холодный старт Chromium ~65 c, в 75 не укладывался. Бэкап `*.bak.20260521`.
- `.env`: `SUPPLIER_LOGIN` / `SUPPLIER_PASSWORD` (те же, что на dev). Бэкап `.env.bak.20260521-*`.
- `system_settings.supplier_webhook_secret` — 48-hex (DemoSeeder ставит короткий → guard `<32` → webhook молча 404).
Копия в `/home/ubuntu/liderra-secrets.txt`.
- `system_settings.supplier_ip_allowlist` = `["0.0.0.0/0"]` — на `APP_ENV=production` пустой массив fail-closed (404 всем).
**TODO: сузить** до IP поставщика (в логе видели `92.53.65.242`).
### Канал 1 — приём webhook'а (вход, основной)
- POST `http://111.88.246.137/api/webhook/supplier/<secret>` (nginx `^~ /api/webhook/` без Basic Auth).
- Проверено: правильный secret → 202, дубль `vid` → 200 `already_processed`, битый secret → 404.
### Канал 2 — CSV-дочерпывание (вход, резерв)
- `CsvReconcileJob`, scheduler каждые 30 мин (cron `schedule:run` ежеминутно). Прогон вживую: 185 строк, status `ok`, drift 0.
- Ручной запуск: `sudo -u www-data php artisan tinker --execute='App\Jobs\Supplier\CsvReconcileJob::dispatchSync()'`.
### Канал 3 — экспорт проектов (выход)
- `SupplierProjectChannel::createProject` / `SupplierPortalClient::deleteProject`. Проверено: create+delete
тестового проекта (`external_id=12764235`), сверка `listProjects` — следов у поставщика нет.
### Supplier-портал
- `crm.bp-gr.ru → /admin/user/api`: «Апи ссылка» = `http://111.88.246.137/api/webhook/supplier/<secret>`,
«Апи протокол» = HTTP, «Апи статус» = Активный. Поставщик HTTP-URL принимает.
- ⚠️ Поле URL **одно** → после переключения на тест-сервер dev-машина живых лидов **не получает**.
- Сессия логина: Redis DB 1, ключ `liderra-database-liderra-cache-supplier:session` (TTL 6h, refresh-крон/`supplier:session:refresh`).
### Сделать позже
- Привязать `client1..4` к реальным каналам поставщика через pivot `project_supplier_links` (иначе лиды = ghost без сделок).
- HTTPS после покупки домена → URL у поставщика на https.
- Сузить `supplier_ip_allowlist`.
## Обновить версию
На dev-машине:
```powershell
npm --prefix app run build
git -C <repo> archive --format=tar HEAD app db -o $env:TEMP\liderra.tar
scp -i ~/.ssh/liderra_deploy $env:TEMP\liderra.tar ubuntu@111.88.246.137:/tmp/
scp -i ~/.ssh/liderra_deploy -r app\public\build ubuntu@111.88.246.137:/tmp/build
```
На сервере:
```bash
tar -xf /tmp/liderra.tar -C /var/www/liderra
rm -rf /var/www/liderra/app/public/build && cp -r /tmp/build /var/www/liderra/app/public/build
bash /var/www/liderra/redeploy.sh
```
## Включить HTTPS (после покупки домена)
1. DNS: A-запись `test.<домен>` (и/или `demo.<домен>` для subdomain-tenant) → `111.88.246.137`.
2. На сервере: в `/etc/nginx/sites-available/liderra` заменить `server_name _;` на домен, `nginx -t && systemctl reload nginx`.
3. `sudo certbot --nginx -d test.<домен> --non-interactive --agree-tos -m <email> --redirect`.
4. В `.env` обновить `APP_URL=https://test.<домен>`, затем `php artisan optimize`.
## Остановить / удалить (прекратить оплату)
- Остановить VM: `yc compute instance stop liderra-test` (диск/IP сохраняются, мелкая плата).
- Удалить совсем: `yc compute instance delete liderra-test` + `yc vpc address delete <id>`.
## После теста — обязательно
- **Отозвать OAuth-токен Yandex Cloud** (Яндекс ID → Безопасность → сторонние приложения).
- При переходе к прод-конфигу: убрать `SAAS_ADMIN_TEST_BYPASS`, вернуть `crm_app_user` (после auth-rework).
+3 -3
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-21T01:53:48.034Z
Last updated: 2026-05-21T08:42:35.722Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -8,12 +8,12 @@ Last updated: 2026-05-21T01:53:48.034Z
| 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 | ⚠️ | 16 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) |
| C5 Observer-coverage | | 71 episode(s) this month · Stop-hook + post-commit OK |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 16 episodes this month, 0 observer_error markers, 0 PII matches before filter
- Observer evidence: 71 episodes this month, 0 observer_error markers, 52 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 5
- Last /brain-retro: 2 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
File diff suppressed because one or more lines are too long
@@ -0,0 +1,746 @@
# Project delete + source dedup + human errors — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Заменить архивацию проектов настоящим удалением (с защитой по сделкам и корректной обработкой шеринга у поставщика), добавить дедуп источника внутри клиента и заменить сырые SQL-ошибки человеческими сообщениями.
**Architecture:** Бэкенд — вся логика в `ProjectService` (create/update/delete) + новый `DeleteSupplierProjectJob` для удаления/пере-синка донора у поставщика с учётом шеринга + глобальный handler `QueryException`. Архивация (`archived_at`) убирается полностью (миграция-снос колонки). Фронтенд — «Архивировать»→«Удалить», убрать фильтр «Архивные».
**Tech Stack:** PHP 8.3 / Laravel 13, Pest 4; Vue 3 + Vuetify 3 + Pinia, Vitest; PostgreSQL 16.
**Спека:** `docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md`
**Команды (из `app/`):** `C:/tools/php83/php.exe artisan test --filter=<name>`, `composer pint`, `composer stan`, `npm run test:vue`.
**Ключевые факты (разведка):**
- `Project` — БЕЗ SoftDeletes → `$project->delete()` = hard delete. У `projects` нет `deleted_at` (только `archived_at` custom + `is_active`).
- `Deal`С SoftDeletes (`deals.deleted_at`). Guard считает ВСЕ сделки через `DB::table('deals')` (минует scope).
- `deals.project_id` без FK; cascade на `projects(id)` только у служебных таблиц.
- `supplier_projects.supplier_external_id` VARCHAR(64) — id донора у поставщика (числовой; cast к int для `deleteProject(int)`).
- `project_supplier_links` — pivot (project_id, supplier_project_id), ON DELETE CASCADE на оба.
- Источник: `signal_identifier` (call/site) либо `sms_senders[]`+`sms_keyword` (sms).
---
## Task 1: Дедуп источника + имени в ProjectService::create()
**Files:**
- Modify: `app/app/Services/Project/ProjectService.php` (метод `create`, +helper `assertSourceUnique`)
- Test: `app/tests/Feature/Project/ProjectCreateDedupTest.php` (create)
- [ ] **Step 1: Написать падающие тесты**
```php
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
beforeEach(function () {
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
});
function makeCall(array $over = []): array {
return array_merge([
'name' => 'Проект A', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
], $over);
}
it('blocks duplicate source within tenant with human message', function () {
app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
expect(fn () => app(\App\Services\Project\ProjectService::class)
->create($this->tenant, makeCall(['name' => 'Проект B'])))
->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
});
it('allows same source for a different tenant (sharing)', function () {
$other = Tenant::factory()->create(['balance_leads' => 100]);
app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
$p = app(\App\Services\Project\ProjectService::class)->create($other, makeCall(['name' => 'Проект B']));
expect($p)->toBeInstanceOf(Project::class);
});
it('blocks duplicate name within tenant with human message (not SQL)', function () {
app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
try {
app(\App\Services\Project\ProjectService::class)
->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000']));
$this->fail('expected HttpResponseException');
} catch (\Illuminate\Http\Exceptions\HttpResponseException $e) {
$body = $e->getResponse()->getData(true);
expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE');
}
});
```
- [ ] **Step 2: Запустить — убедиться что падают**
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectCreateDedupTest`
Expected: FAIL (дедупа нет — второй create проходит / бьёт DB-констрейнт).
- [ ] **Step 3: Реализация в `ProjectService::create()`**
В начало `create()` (после получения `$tenant`, до `Project::create`) добавить вызовы и helper'ы:
```php
// перед Project::create($data):
$this->assertNameUnique($tenant->id, (string) $data['name']);
$this->assertSourceUnique($tenant->id, $data);
```
Добавить методы в класс:
```php
private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
{
$q = Project::where('tenant_id', $tenantId)->where('name', $name);
if ($exceptId !== null) {
$q->where('id', '!=', $exceptId);
}
if ($q->exists()) {
throw new HttpResponseException(response()->json([
'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
], 422));
}
}
/** @param array<string,mixed> $data */
private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
{
$signalType = $data['signal_type'] ?? null;
$q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
if ($exceptId !== null) {
$q->where('id', '!=', $exceptId);
}
if (in_array($signalType, ['call', 'site'], true)) {
$identifier = (string) ($data['signal_identifier'] ?? '');
if ($identifier === '') {
return;
}
$q->where('signal_identifier', $identifier);
} elseif ($signalType === 'sms') {
$senders = (array) ($data['sms_senders'] ?? []);
$norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
if ($norm === []) {
return;
}
// sms-источник идентичен, если совпадают набор отправителей и ключевое слово.
$keyword = $data['sms_keyword'] ?? null;
$q->where('sms_keyword', $keyword)
->whereJsonContains('sms_senders', $norm)
->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
} else {
return;
}
$existing = $q->first();
if ($existing !== null) {
throw new HttpResponseException(response()->json([
'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
], 422));
}
}
```
Убедиться, что в шапке есть `use Illuminate\Http\Exceptions\HttpResponseException;` (уже есть).
- [ ] **Step 4: Запустить — PASS**
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectCreateDedupTest`
Expected: PASS (3 теста).
- [ ] **Step 5: Commit**
```bash
git add -- app/app/Services/Project/ProjectService.php app/tests/Feature/Project/ProjectCreateDedupTest.php
git commit -m "feat(projects): source+name dedup with human messages on create"
```
---
## Task 2: Дедуп источника при смене источника (update)
**Files:**
- Modify: `app/app/Services/Project/ProjectService.php` (метод `update`)
- Test: `app/tests/Feature/Project/ProjectUpdateDedupTest.php`
- [ ] **Step 1: Падающий тест**
```php
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
it('blocks update that collides source with another project of same tenant', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$svc = app(\App\Services\Project\ProjectService::class);
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
$b = $svc->create($tenant, ['name' => 'B', 'signal_type' => 'call', 'signal_identifier' => '79992220000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
expect(fn () => $svc->update($b, ['signal_identifier' => '79991110000']))
->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
});
it('allows update keeping same source on the same project', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$svc = app(\App\Services\Project\ProjectService::class);
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
$updated = $svc->update($a, ['signal_identifier' => '79991110000', 'daily_limit_target' => 7]);
expect($updated->daily_limit_target)->toBe(7);
});
```
- [ ] **Step 2: Запустить — FAIL**
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectUpdateDedupTest`
Expected: FAIL (первый кейс не бросает).
- [ ] **Step 3: Реализация в `update()`**
После блока immutable-unset и до `$project->update($data)` добавить:
```php
if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
$this->assertSourceUnique($project->tenant_id, array_merge([
'signal_type' => $project->signal_type,
'signal_identifier' => $project->signal_identifier,
'sms_senders' => $project->sms_senders,
'sms_keyword' => $project->sms_keyword,
], $data), exceptId: $project->id);
}
if (array_key_exists('name', $data)) {
$this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
}
```
- [ ] **Step 4: Запустить — PASS**
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectUpdateDedupTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add -- app/app/Services/Project/ProjectService.php app/tests/Feature/Project/ProjectUpdateDedupTest.php
git commit -m "feat(projects): source+name dedup on update"
```
---
## Task 3: Глобальный handler QueryException (никакого SQL в UI)
**Files:**
- Modify: `app/bootstrap/app.php` (`withExceptions`)
- Test: `app/tests/Feature/Project/QueryExceptionRenderTest.php`
- [ ] **Step 1: Падающий тест** (бьём прямой DB-констрейнт мимо app-проверок — два проекта с одинаковым именем через прямой insert невозможно из API после Task 1, поэтому тестируем рендер handler'а на искусственном маршруте)
```php
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
it('renders QueryException as human JSON message, not SQLSTATE', function () {
Route::get('/_test/boom-query', function () {
throw new \Illuminate\Database\QueryException('pgsql', 'SELECT 1', [], new \Exception('SQLSTATE[23505] duplicate key'));
});
$res = $this->getJson('/_test/boom-query');
$res->assertStatus(422);
expect($res->json('message'))->not->toContain('SQLSTATE');
expect($res->json('message'))->toContain('Не удалось');
});
```
- [ ] **Step 2: Запустить — FAIL**
Run: `C:/tools/php83/php.exe artisan test --filter=QueryExceptionRenderTest`
Expected: FAIL (по умолчанию 500 + SQL-текст в debug).
- [ ] **Step 3: Реализация в `bootstrap/app.php`**
Заменить тело `->withExceptions(function (Exceptions $exceptions): void { // });` на:
```php
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (\Illuminate\Database\QueryException $e, \Illuminate\Http\Request $request) {
\Illuminate\Support\Facades\Log::error('db.query_exception', [
'message' => $e->getMessage(),
'sql' => $e->getSql(),
'path' => $request->path(),
]);
if ($request->expectsJson()) {
return response()->json([
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
], 422);
}
return null; // дефолтный рендер для не-JSON
});
})
```
- [ ] **Step 4: Запустить — PASS**
Run: `C:/tools/php83/php.exe artisan test --filter=QueryExceptionRenderTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add -- app/bootstrap/app.php app/tests/Feature/Project/QueryExceptionRenderTest.php
git commit -m "feat(errors): global QueryException handler returns human message"
```
---
## Task 4: ProjectService::delete() с guard по сделкам (+ снос archive())
**Files:**
- Modify: `app/app/Services/Project/ProjectService.php` (+`delete()`, `archive()`, bulk `archive``delete`)
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (`destroy``delete`)
- Modify: `app/app/Http/Requests/BulkProjectActionRequest.php` (`archive``delete`)
- Test: `app/tests/Feature/Project/ProjectDeleteTest.php`
- [ ] **Step 1: Падающие тесты**
```php
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
it('hard-deletes an empty project', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = app(\App\Services\Project\ProjectService::class)->create($tenant, [
'name' => 'Empty', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
]);
app(\App\Services\Project\ProjectService::class)->delete($project);
expect(Project::find($project->id))->toBeNull();
});
it('blocks delete when project has deals', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = app(\App\Services\Project\ProjectService::class)->create($tenant, [
'name' => 'WithDeals', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
]);
DB::table('deals')->insert([
'tenant_id' => $tenant->id, 'project_id' => $project->id, 'phone' => '79990001122',
'status' => 'new', 'received_at' => now(), 'created_at' => now(),
]);
expect(fn () => app(\App\Services\Project\ProjectService::class)->delete($project))
->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
expect(Project::find($project->id))->not->toBeNull();
});
```
- [ ] **Step 2: Запустить — FAIL**
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectDeleteTest`
Expected: FAIL (метода `delete()` нет).
- [ ] **Step 3: Реализация**
В `ProjectService` добавить `delete()` и удалить `archive()`:
```php
public function delete(Project $project): void
{
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
if ($hasDeals) {
throw new HttpResponseException(response()->json([
'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
], 422));
}
// Доноров фиксируем ДО удаления — pivot уйдёт каскадом.
$supplierProjectIds = DB::table('project_supplier_links')
->where('project_id', $project->id)
->pluck('supplier_project_id')
->all();
$project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.
if ($supplierProjectIds !== []) {
\App\Jobs\Supplier\DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
}
}
```
Добавить `use Illuminate\Support\Facades\DB;` в шапку. Удалить метод `archive()`. В `bulkAction()` строку `'archive' => ...` заменить на:
```php
'delete' => $this->bulkDelete($query),
```
Добавить `bulkDelete` (guard per-project, не роняет батч):
```php
private function bulkDelete($query): array
{
$projects = (clone $query)->get(['id']);
$deleted = 0; $skipped = [];
foreach ($projects as $p) {
$model = Project::find($p->id);
if ($model === null) { continue; }
try {
$this->delete($model);
$deleted++;
} catch (HttpResponseException) {
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
}
}
return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
}
```
В `update()` убрать из unset строку `$data['archived_at'],` (колонка уходит в Task 6). В `resolveBulkScope()` ветку match `'archived' => ...` удалить; `'active'`/`'paused'` оставить без `whereNull('archived_at')` (см. Task 6).
В `ProjectController::destroy()` заменить `$this->projects->archive($project);` на `$this->projects->delete($project);` и docblock «soft-archive» → «hard delete (guard по сделкам)».
В `BulkProjectActionRequest`: в `Rule::in([...])` для action `'archive'``'delete'`; убрать `'archived'` из `Rule::in(['active','paused','archived'])`.
- [ ] **Step 4: Запустить — PASS** + регрессия bulk
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectDeleteTest`
Then: `C:/tools/php83/php.exe artisan test --filter=Project`
Expected: PASS; падений по `archive` нет (если есть старые тесты на archive — обновить на delete в этом же шаге).
- [ ] **Step 5: Commit**
```bash
git add -- app/app/Services/Project/ProjectService.php app/app/Http/Controllers/Api/ProjectController.php app/app/Http/Requests/BulkProjectActionRequest.php app/tests/Feature/Project/ProjectDeleteTest.php
git commit -m "feat(projects): hard delete with deals-guard, replace archive"
```
---
## Task 5: DeleteSupplierProjectJob — удаление/пере-синк донора с учётом шеринга
**Files:**
- Create: `app/app/Jobs/Supplier/DeleteSupplierProjectJob.php`
- Test: `app/tests/Feature/Supplier/DeleteSupplierProjectJobTest.php`
- [ ] **Step 1: Падающие тесты** (mock `SupplierPortalClient`)
```php
<?php
declare(strict_types=1);
use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
it('deletes donor at supplier when no consumers remain', function () {
$sp = SupplierProject::create(['platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000', 'supplier_external_id' => '555', 'current_limit' => 1]);
$mock = Mockery::mock(SupplierPortalClient::class);
$mock->shouldReceive('deleteProject')->once()->with(555);
app()->instance(SupplierPortalClient::class, $mock);
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
expect(SupplierProject::find($sp->id))->toBeNull();
});
it('does NOT delete donor at supplier when other consumers remain; re-syncs', function () {
Bus::fake([SyncSupplierProjectsJob::class]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$sp = SupplierProject::create(['platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000', 'supplier_external_id' => '555', 'current_limit' => 1]);
$other = Project::factory()->create(['tenant_id' => $tenant->id]);
DB::table('project_supplier_links')->insert(['project_id' => $other->id, 'supplier_project_id' => $sp->id, 'subject_code' => null]);
$mock = Mockery::mock(SupplierPortalClient::class);
$mock->shouldNotReceive('deleteProject');
app()->instance(SupplierPortalClient::class, $mock);
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
expect(SupplierProject::find($sp->id))->not->toBeNull();
Bus::assertDispatched(SyncSupplierProjectsJob::class);
});
```
- [ ] **Step 2: Запустить — FAIL**
Run: `C:/tools/php83/php.exe artisan test --filter=DeleteSupplierProjectJobTest`
Expected: FAIL (класса нет).
- [ ] **Step 3: Реализация джоба**
```php
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Удаление/пере-синк доноров у поставщика после удаления Лидерра-проекта.
*
* Для каждого supplier_project S (донора), к которому был привязан удалённый проект:
* - остались другие потребители (project_supplier_links) → донор нужен другим клиентам:
* НЕ удаляем у поставщика, пере-синкаем агрегат (SyncSupplierProjectsJob).
* - потребителей не осталось → удаляем у поставщика (deleteProject) + локальную запись S.
*
* Spec: docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md §Решение 2.
*/
class DeleteSupplierProjectJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public const DB_CONNECTION = 'pgsql_supplier';
/** @param array<int,int> $supplierProjectIds */
public function __construct(public array $supplierProjectIds) {}
public function handle(SupplierPortalClient $client): void
{
$needsResync = false;
foreach ($this->supplierProjectIds as $id) {
$sp = SupplierProject::on(self::DB_CONNECTION)->find($id);
if ($sp === null) {
continue;
}
$remaining = DB::connection(self::DB_CONNECTION)
->table('project_supplier_links')
->where('supplier_project_id', $id)
->count();
if ($remaining > 0) {
$needsResync = true;
continue;
}
if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
try {
$client->deleteProject((int) $sp->supplier_external_id);
} catch (\Throwable $e) {
Log::warning('supplier.delete_donor_failed', ['supplier_project_id' => $id, 'error' => $e->getMessage()]);
throw $e; // ретрай джоба
}
}
$sp->delete();
}
if ($needsResync) {
SyncSupplierProjectsJob::dispatch();
}
}
}
```
- [ ] **Step 4: Запустить — PASS**
Run: `C:/tools/php83/php.exe artisan test --filter=DeleteSupplierProjectJobTest`
Expected: PASS (2 теста).
- [ ] **Step 5: Commit**
```bash
git add -- app/app/Jobs/Supplier/DeleteSupplierProjectJob.php app/tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
git commit -m "feat(supplier): delete/re-sync donor on project delete respecting sharing"
```
---
## Task 6: Снос archived_at — модель/ресурс/синк/дашборд/контроллер + миграция
**Files:**
- Modify: `app/app/Models/Project.php` (scopeArchived, scopeActive, fillable+cast `archived_at`)
- Modify: `app/app/Http/Resources/ProjectResource.php` (`archived_at`)
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (index `status=archived`/`active()`)
- Modify: `app/app/Http/Controllers/Api/DashboardController.php` (`whereNull('archived_at')`)
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (`whereNull('archived_at')`)
- Create: `app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php`
- Modify: `db/schema.sql` (убрать строку `archived_at` из projects + header v8.27) + `db/CHANGELOG_schema.md`
- Test: запуск всей backend-регрессии
- [ ] **Step 1: Миграция**
```php
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS archived_at');
}
public function down(): void
{
DB::statement('ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL');
}
};
```
- [ ] **Step 2: Чистка кода**
- `Project.php`: удалить `'archived_at'` из `$fillable` и из casts; удалить методы `scopeArchived` и `scopeActive` (scope `scopeActiveOnDay` — НЕ трогать, это про день недели). После удаления заменить всех вызывающих `->active()` на чистый query:
- `ProjectService::create()` лимит-проверка: `Project::where('tenant_id', $tenant->id)->active()->count()``Project::where('tenant_id', $tenant->id)->count()` (после сноса архива «активные» = все проекты тенанта).
- `ProjectController::index()` — см. ниже.
Проверить `grep -rn "->active(" app/app` после правок (должны остаться только `scopeActiveOnDay`/`PricingTier`/`SupplierProject`).
- `ProjectResource.php`: удалить строку `'archived_at' => ...`.
- `ProjectController::index()`: удалить ветку `if ($status === 'archived')`; для `active`/`paused`/default убрать вызовы `->active()`/`->archived()` (фильтрация только по `is_active`).
- `DashboardController.php:77`: убрать `->whereNull('archived_at')`.
- `SyncSupplierProjectsJob.php:89`: убрать `->whereNull('archived_at')`.
- [ ] **Step 3: schema.sql + CHANGELOG**
В `db/schema.sql` удалить строку `archived_at TIMESTAMPTZ NULL,` из `CREATE TABLE projects`; обновить header-комментарий → v8.27 (drop projects.archived_at). В `db/CHANGELOG_schema.md` добавить запись v8.27.
- [ ] **Step 4: Прогнать миграцию на dev + регрессия**
Run:
```
C:/tools/php83/php.exe artisan migrate
C:/tools/php83/php.exe artisan test --filter=Project
composer stan
```
Expected: миграция OK; тесты зелёные; Larastan 0 (или обновить baseline, если всплыло legacy).
- [ ] **Step 5: Commit**
```bash
git add -- app/app/Models/Project.php app/app/Http/Resources/ProjectResource.php app/app/Http/Controllers/Api/ProjectController.php app/app/Http/Controllers/Api/DashboardController.php app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php db/schema.sql db/CHANGELOG_schema.md
git commit -m "refactor(projects): remove archive feature, drop archived_at column (schema v8.27)"
```
---
## Task 7: Фронтенд — «Архивировать» → «Удалить»
**Files:**
- Modify: `app/resources/js/stores/projectsStore.ts` (`archive``del`, bulk type, `archived_at` из интерфейса)
- Modify: `app/resources/js/components/projects/BulkActionsBar.vue`
- Modify: `app/resources/js/components/projects/ProjectCard.vue`
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
- Modify: `app/resources/js/views/ProjectsView.vue` (фильтр «Архивные», `@archive``@delete`)
- Test: `app/resources/js/stores/projectsStore.spec.ts` (или существующий) + затронутые spec'и
- [ ] **Step 1: Падающий тест стора**
В spec проверить, что метод удаления дёргает `DELETE /api/projects/{id}`:
```ts
it('delete() calls DELETE /api/projects/{id}', async () => {
const store = useProjectsStore();
vi.spyOn(axios, 'delete').mockResolvedValue({ data: null });
vi.spyOn(axios, 'get').mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
await store.del(7);
expect(axios.delete).toHaveBeenCalledWith('/api/projects/7');
});
```
- [ ] **Step 2: Запустить — FAIL**
Run: `npm --prefix app run test:vue -- projectsStore`
Expected: FAIL (`del` не существует).
- [ ] **Step 3: Реализация фронта**
- `projectsStore.ts`: переименовать `archive``del` (метод + return); в `bulkAction`/`BulkPayload` тип `'archive'``'delete'`; убрать `archived_at` из `interface Project`.
- `BulkActionsBar.vue`: кнопка «Архивировать»→«Удалить» (`data-testid="bulk-delete"`, иконка `mdi-delete`→Lucide `Trash2` через IconSet), confirm-текст про удаление; `confirmAndRun('delete')`; тип union `'pause'|'resume'|'delete'`.
- `ProjectCard.vue`: пункт меню «Архивировать»→«Удалить» (иконка `mdi-delete`), `$emit('delete', project)`; emit-тип `delete: [project: Project]`.
- `ProjectDetailsDrawer.vue`: «Архивировать проект?»→«Удалить проект? Действие необратимо.»; `store.del(props.project.id)`.
- `ProjectsView.vue`: `@archive``@delete="(p) => store.del(p.id)"`; убрать `{ title: 'Архивные', value: 'archived' }` из фильтра статусов.
NB: иконки — через существующий IconSet mapping в `plugins/vuetify.ts` (`mdi-delete``Trash2`); если маппинга нет — добавить.
- [ ] **Step 4: Запустить — PASS + тип-чек + сборка**
Run:
```
npm --prefix app run test:vue
npm --prefix app run type-check
npm --prefix app run build
```
Expected: тесты зелёные (обновить spec'и, где был `archive`/`archived`); type-check 0; build OK.
- [ ] **Step 5: Commit**
```bash
git add -- app/resources/js/stores/projectsStore.ts app/resources/js/components/projects/BulkActionsBar.vue app/resources/js/components/projects/ProjectCard.vue app/resources/js/components/projects/ProjectDetailsDrawer.vue app/resources/js/views/ProjectsView.vue
git commit -m "feat(projects-ui): replace archive with delete, drop archived filter"
```
---
## Task 8: Живая проверка всех 4 задач + чистка тестовых данных
Среда: portal `serve` :8000 + `queue:work` (запустить, если не идёт). Демо tenant 1 (`admin@demo.local`/`password`).
- [ ] **Шаг 1 (задача 4 — человеческие ошибки):** создать проект с именем существующего → ожидать понятное 422-сообщение (не SQL). Создать проект с именем-дублем через UI/`tinker` POST → проверить тело ответа.
- [ ] **Шаг 2 (задача 3 — дедуп источника):** в рамках tenant 1 создать 2-й проект на тот же `signal_identifier` → ожидать «У вас уже есть проект с этим источником…».
- [ ] **Шаг 3 (задача 2 — шеринг):** под другим тенантом создать проект с тем же источником → проходит (демонстрация «ок»).
- [ ] **Шаг 4 (задача 1 — удаление):** удалить пустой проект → исчез; удалить проект со сделкой → блок с сообщением, проект жив. Если есть привязка к донору и других потребителей нет → проверить, что `DeleteSupplierProjectJob` удалил донора у поставщика (или пере-синкнул при наличии других).
- [ ] **Шаг 5 (чистка):** удалить все тестовые проекты/сделки/доноров, созданные в шагах 1–4; вернуть БД к чистому демо; зафиксировать в отчёте, что изменилось.
(Verify-skill: каждый шаг — реальный рантайм, capture результата; затем cleanup.)
---
## Финальная регрессия (после всех задач)
Run (из корня/`app/`):
```
C:/tools/php83/php.exe artisan test
npm --prefix app run test:vue
npm --prefix app run type-check
npm --prefix app run build
composer pint && composer stan
```
Все зелёные → готово к push (`git push origin <ветка>:main`) по решению заказчика.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,631 @@
# Тестовый деплой портала Лидерра в Yandex Cloud — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task (inline — план содержит интерактивные шаги заказчика: создание VM, DNS, deploy-key). Steps use checkbox (`- [ ]`) syntax.
**Goal:** Поднять рабочую копию портала в интернете на одной Linux-VM в Yandex Cloud по адресу `https://<поддомен>` с HTTPS, доступом только для заказчика+Claude, для ручного теста.
**Architecture:** Одна Ubuntu 24.04 VM: nginx (HTTPS + Basic Auth) → PHP-FPM 8.3 → портал (Laravel 13 + собранный Vue) → PostgreSQL 16 + Redis 7 на той же машине; queue worker + scheduler как systemd-службы. Фронтенд собирается на dev-машине и заливается. Настоящие роли БД (RLS включён). Спека: `docs/superpowers/specs/2026-05-21-test-deploy-yandex-cloud-design.md`.
**Tech Stack:** Yandex Cloud Compute, Ubuntu 24.04 LTS, nginx, PHP 8.3-FPM, PostgreSQL 16, Redis 7, Certbot/Let's Encrypt, systemd, OpenSSH.
**Условные обозначения:** 🧑 = шаг заказчика (веб-интерфейс/решение), 🤖 = шаг Claude (Bash/SSH). Плейсхолдеры: `<SERVER_IP>`, `<DOMAIN>` (например `test.example.ru`), `<BASIC_USER>`/`<BASIC_PASS>` (дверь сайта) — заполняются по ходу.
---
## Фаза 0 — Подготовка на dev-машине (🤖, до создания сервера)
### Task 0.1: Проверить SSH-клиент и сгенерировать ключ деплоя
**Files:** `~/.ssh/liderra_deploy`, `~/.ssh/liderra_deploy.pub` (на dev-машине)
- [ ] **Step 1: Проверить наличие OpenSSH**
Run: `ssh -V; ssh-keygen --help 2>&1 | Select-Object -First 1`
Expected: версия OpenSSH (например `OpenSSH_for_Windows_9.x`). Если нет — поставить «OpenSSH Client» через Settings → Optional Features.
- [ ] **Step 2: Сгенерировать ключ-пару (без пароля, ed25519)**
Run (PowerShell):
```powershell
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\liderra_deploy" -C "liderra-test-deploy" -N '""'
```
Expected: созданы `liderra_deploy` (приватный) и `liderra_deploy.pub` (публичный).
- [ ] **Step 3: Показать публичный ключ заказчику**
Run: `Get-Content "$env:USERPROFILE\.ssh\liderra_deploy.pub"`
Expected: строка `ssh-ed25519 AAAA... liderra-test-deploy`. Отдать заказчику для вставки при создании VM (Task 1.2).
### Task 0.2: Код-правка — временный флаг доступа к админке (TDD)
**Files:**
- Modify: `app/config/app.php` (добавить ключ `saas_admin_test_bypass`)
- Modify: `app/app/Http/Middleware/EnsureSaasAdmin.php`
- Test: `app/tests/Feature/Middleware/EnsureSaasAdminTest.php` (создать или дополнить)
- [ ] **Step 1: Написать падающий тест**
Создать `app/tests/Feature/Middleware/EnsureSaasAdminTest.php`:
```php
<?php
declare(strict_types=1);
use function Pest\Laravel\get;
it('blocks admin area in production by default', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => false]);
// любой admin-маршрут под EnsureSaasAdmin; подставить реальный из routes
$response = get('/api/admin/tenants');
expect($response->status())->toBe(503);
});
it('allows admin area in production when test bypass flag is on', function () {
app()->detectEnvironment(fn () => 'production');
config(['app.saas_admin_test_bypass' => true]);
$response = get('/api/admin/tenants');
expect($response->status())->not->toBe(503);
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app; C:\tools\php83\php.exe artisan test --filter=EnsureSaasAdmin`
Expected: второй тест FAIL (сейчас middleware всегда 503 вне local/testing).
- [ ] **Step 3: Добавить ключ конфига**
В `app/config/app.php` добавить (рядом с другими ключами):
```php
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
```
- [ ] **Step 4: Поправить middleware**
В `app/app/Http/Middleware/EnsureSaasAdmin.php` заменить тело `handle`:
```php
public function handle(Request $request, Closure $next): Response
{
if (app()->environment('local', 'testing')) {
return $next($request);
}
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
if (config('app.saas_admin_test_bypass') === true) {
return $next($request);
}
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
```
- [ ] **Step 5: Запустить тест — зелёный**
Run: `cd app; C:\tools\php83\php.exe artisan test --filter=EnsureSaasAdmin`
Expected: оба PASS.
- [ ] **Step 6: Линт + commit**
Run: `cd app; composer pint; composer stan`
Expected: 0 ошибок.
```bash
git add app/config/app.php app/app/Http/Middleware/EnsureSaasAdmin.php app/tests/Feature/Middleware/EnsureSaasAdminTest.php
git commit -m "feat(deploy): temporary SAAS_ADMIN_TEST_BYPASS flag for test server (off by default)"
```
> NB: маршрут `/api/admin/tenants` в тесте — подставить реальный admin-маршрут из `app/routes/`. Уточнить на Step 1 (grep по `EnsureSaasAdmin`).
### Task 0.3: Собрать фронтенд для прода
- [ ] **Step 1: Прод-сборка**
Run: `npm --prefix app run build`
Expected: создан `app/public/build/` с манифестом и ассетами, ошибок нет.
- [ ] **Step 2: Зафиксировать факт сборки**
Сборка не коммитится (build в .gitignore) — будет залита на сервер в Task 3.3 через scp. Проверить: `Test-Path app/public/build/manifest.json` → True.
---
## Фаза 1 — Создание сервера (🧑 заказчик в консоли YC, по инструкции Claude)
### Task 1.1: Зарезервировать статический публичный IP
- [ ] **Step 1:** YC Console → Virtual Private Cloud → IP-адреса → «Зарезервировать адрес» → зона `ru-central1-a`.
- [ ] **Step 2:** Записать выданный IP → это `<SERVER_IP>` (нужен для DNS; статический, чтобы адрес не менялся при перезагрузке).
### Task 1.2: Создать виртуальную машину
- [ ] **Step 1:** Compute Cloud → «Создать ВМ».
- [ ] **Step 2:** Параметры:
- Имя: `liderra-test`; зона `ru-central1-a`.
- Образ: **Ubuntu 24.04 LTS**.
- vCPU 2, RAM 2 ГБ, **гарантированная доля vCPU 20%** (дёшево; сборки идут на dev-машине).
- Диск: SSD 20 ГБ.
- Публичный адрес: выбрать **зарезервированный** из Task 1.1.
- Доступ: логин `deploy`; SSH-ключ — вставить публичный ключ из Task 0.1 Step 3.
- [ ] **Step 3:** Создать. Дождаться статуса RUNNING.
### Task 1.3: Открыть порты (группа безопасности)
- [ ] **Step 1:** VPC → Группы безопасности → группа сети ВМ → правила входящего трафика.
- [ ] **Step 2:** Разрешить TCP **22, 80, 443** (источник `0.0.0.0/0`; 22 можно сузить до IP заказчика/dev — но для простоты теста оставить открытым).
- [ ] **Step 3:** Сообщить Claude `<SERVER_IP>` → переходим к Фазе 2.
---
## Фаза 2 — Базовая настройка сервера (🤖 по SSH)
### Task 2.1: Первое подключение
- [ ] **Step 1: Подключиться**
Run: `ssh -i "$env:USERPROFILE\.ssh\liderra_deploy" -o StrictHostKeyChecking=accept-new deploy@<SERVER_IP> "echo OK; lsb_release -d"`
Expected: `OK` + `Ubuntu 24.04`.
- [ ] **Step 2: Обновить пакеты**
Run: `ssh ... deploy@<SERVER_IP> "sudo apt-get update && sudo apt-get -y upgrade"`
Expected: завершается без ошибок.
### Task 2.2: Установить стек
- [ ] **Step 1: Установить пакеты**
Run одной командой по SSH:
```bash
sudo apt-get install -y nginx \
php8.3-fpm php8.3-cli php8.3-pgsql php8.3-redis php8.3-mbstring \
php8.3-xml php8.3-curl php8.3-bcmath php8.3-zip php8.3-gd php8.3-intl \
postgresql postgresql-contrib redis-server git unzip certbot python3-certbot-nginx \
apache2-utils
```
Expected: установлено без ошибок (`apache2-utils` даёт `htpasswd`).
- [ ] **Step 2: Установить Composer**
```bash
php -r "copy('https://getcomposer.org/installer','/tmp/ci.php');" \
&& sudo php /tmp/ci.php --install-dir=/usr/local/bin --filename=composer
```
Run: `ssh ... "composer --version; php -v | head -1"`
Expected: Composer 2.x; PHP 8.3.
- [ ] **Step 3: Проверить службы**
Run: `ssh ... "systemctl is-active nginx php8.3-fpm postgresql redis-server"`
Expected: `active` × 4.
---
## Фаза 3 — База, код, конфиг (🤖 по SSH)
> **Порядок исполнения внутри фазы:** 3.2 (код на сервере — db/-скрипты приезжают с репо) → 3.1 (БД и роли) → 3.3 (фронтенд) → 3.4 (.env) → 3.5 (схема через migrate + grants + seed). Здесь нумерация по смыслу, но db-скрипты есть только после clone.
>
> **DB-роли (из `db/00_create_roles.sql` v1.1 + `app/config/database.php`):** пароли передаются psql через `-v` (НЕ `ALTER ROLE`). Схема грузится миграцией `load_initial_schema` (она делает `DB::unprepared(schema.sql)`) под ролью `crm_migrator` (BYPASSRLS+CREATEDB). Гранты — `db/02_grants.sql`. Рантайм — `crm_app_user` (RLS). Supplier-джобы — `crm_supplier_worker` (BYPASSRLS) через connection `pgsql_supplier`. Connection `pgsql_migrator` в конфиге НЕТ → для миграций временно подменяем `DB_USERNAME` на `crm_migrator` (default-connection `pgsql`), потом возвращаем на `crm_app_user`.
### Task 3.1: Создать БД и роли
**Files (на сервере):** `db/00_create_roles.sql` (после clone в 3.2).
- [ ] **Step 1: Сгенерировать пароли ролей (на dev или сервере)**
Run: `ssh ... "for r in app admin migrator audit supplier; do echo \$r=\$(openssl rand -hex 16); done"`
Expected: 5 строк вида `app=...`. Сохранить как `<APP_DB_PASS>` / `<ADMIN_DB_PASS>` / `<MIGRATOR_DB_PASS>` / `<AUDIT_DB_PASS>` / `<WORKER_DB_PASS>` (в безопасное место, не в git).
- [ ] **Step 2: Создать БД**
```bash
ssh ... "sudo -u postgres createdb liderra"
```
Expected: без ошибок.
- [ ] **Step 3: Создать роли с паролями (через -v)**
```bash
ssh ... "sudo -u postgres psql -d liderra \
-v crm_app_password='<APP_DB_PASS>' \
-v crm_admin_password='<ADMIN_DB_PASS>' \
-v crm_migrator_password='<MIGRATOR_DB_PASS>' \
-v crm_audit_writer_password='<AUDIT_DB_PASS>' \
-v crm_supplier_worker_password='<WORKER_DB_PASS>' \
-f /var/www/liderra/db/00_create_roles.sql"
```
Run: `ssh ... "sudo -u postgres psql -d liderra -c '\du' | grep -E 'crm_(app|migrator|supplier)'"`
Expected: 5 ролей созданы (`crm_app_user`, `crm_admin_user`, `crm_migrator`, `crm_audit_writer`, `crm_supplier_worker`).
- [ ] **Step 4: Разрешить TCP-вход ролям (pg_hba)**
> Роли ходят через 127.0.0.1 (scram). Убедиться, что `pg_hba.conf` имеет строку `host all all 127.0.0.1/32 scram-sha-256` (на Ubuntu по умолчанию есть). Если нет — добавить и `sudo systemctl reload postgresql`.
Run: `ssh ... "sudo grep -E '127.0.0.1/32' /etc/postgresql/16/main/pg_hba.conf"`
Expected: строка с `scram-sha-256` (или `md5`).
### Task 3.2: Выложить код (deploy-key + clone)
- [ ] **Step 1: Сгенерировать deploy-key на сервере**
```bash
ssh ... "ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N '' -C 'liderra-server'; cat ~/.ssh/github_deploy.pub"
```
Expected: публичный ключ сервера.
- [ ] **Step 2 (🧑): Добавить ключ в GitHub**
Заказчик: GitHub → репо `CoralMinister/lidpotok` → Settings → Deploy keys → Add → вставить ключ (read-only, без write).
- [ ] **Step 3: Настроить SSH для GitHub + clone**
```bash
ssh ... 'cat >> ~/.ssh/config <<EOF
Host github.com
IdentityFile ~/.ssh/github_deploy
StrictHostKeyChecking accept-new
EOF
sudo mkdir -p /var/www && sudo chown deploy:deploy /var/www
git clone git@github.com:CoralMinister/lidpotok.git /var/www/liderra
cd /var/www/liderra && git checkout main && git log -1 --oneline'
```
Expected: репозиторий склонирован, HEAD на нужном коммите (с флагом из Task 0.2 — убедиться, что коммит влит в `main`; иначе `git checkout <ветка>`).
- [ ] **Step 4: composer install**
```bash
ssh ... "cd /var/www/liderra/app && composer install --no-dev --optimize-autoloader --no-interaction"
```
Expected: зависимости установлены, 0 ошибок.
### Task 3.3: Залить собранный фронтенд
- [ ] **Step 1: Скопировать build на сервер**
Run (с dev-машины):
```powershell
scp -i "$env:USERPROFILE\.ssh\liderra_deploy" -r app/public/build deploy@<SERVER_IP>:/var/www/liderra/app/public/
```
Expected: `manifest.json` + ассеты на сервере.
### Task 3.4: Production .env
- [ ] **Step 1: Создать .env на сервере**
```bash
ssh ... 'cat > /var/www/liderra/app/.env <<EOF
APP_NAME=Liderra
APP_ENV=production
APP_DEBUG=false
APP_URL=https://<DOMAIN>
APP_LOCALE=ru
APP_FALLBACK_LOCALE=ru
APP_TIMEZONE=Europe/Moscow
LOG_CHANNEL=stack
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=liderra
DB_USERNAME=crm_app_user
DB_PASSWORD=<APP_DB_PASS>
DB_SUPPLIER_USERNAME=crm_supplier_worker
DB_SUPPLIER_PASSWORD=<WORKER_DB_PASS>
SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@<DOMAIN>"
MAIL_FROM_NAME=Liderra
SAAS_ADMIN_TEST_BYPASS=true
AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets
EOF'
```
- [ ] **Step 2: APP_KEY**
```bash
ssh ... "cd /var/www/liderra/app && php artisan key:generate --force && php artisan about | head -20"
```
Expected: ключ сгенерирован; `Environment: production`, `Debug Mode: OFF`.
### Task 3.5: Схема (migrate), гранты, демо-данные, кэши
> Схему и сиды грузим под BYPASSRLS-ролью `crm_migrator`, потом возвращаем рантайм на `crm_app_user`. Подмена — временно правим `DB_USERNAME`/`DB_PASSWORD` в `.env` (это значения для default-connection `pgsql`, через которую идёт migrate/seed).
- [ ] **Step 1: Временно переключить .env на crm_migrator**
```bash
ssh ... "cd /var/www/liderra/app && \
sed -i 's/^DB_USERNAME=.*/DB_USERNAME=crm_migrator/; s/^DB_PASSWORD=.*/DB_PASSWORD=<MIGRATOR_DB_PASS>/' .env && \
grep -E '^DB_(USERNAME|PASSWORD)=' .env"
```
Expected: `DB_USERNAME=crm_migrator`.
- [ ] **Step 2: Накатить схему (миграция load_initial_schema грузит schema.sql)**
```bash
ssh ... "cd /var/www/liderra/app && php artisan migrate --force"
```
Run: `ssh ... "sudo -u postgres psql -d liderra -c '\dt' | tail -3"`
Expected: миграция `load_initial_schema` отработала; десятки таблиц (схема v8.27).
- [ ] **Step 3: Создать партиции (как на dev — ручной cron вместо pg_partman)**
```bash
ssh ... "cd /var/www/liderra/app && php artisan partitions:create-months"
```
Expected: партиции созданы (команда из ЭТАЛОН/project_phase1_strategy; если имя иное — `php artisan list | grep partition`).
- [ ] **Step 4: Применить гранты**
```bash
ssh ... "sudo -u postgres psql -d liderra -f /var/www/liderra/db/02_grants.sql"
```
Expected: гранты применены без ошибок (запуск под postgres-суперюзером — владелец/superuser, см. 00_create_roles doc вариант с crm_admin_user тоже подходит).
- [ ] **Step 5: Демо-данные (под crm_migrator, BYPASSRLS — cross-tenant сид проходит)**
```bash
# залить нужные демо-скрипты на сервер
scp -i "$env:USERPROFILE\.ssh\liderra_deploy" app/storage/_demo_5users.php app/storage/_demo_split_tenants.php deploy@<SERVER_IP>:/var/www/liderra/app/storage/
ssh ... "cd /var/www/liderra/app && php artisan db:seed --force && php artisan tinker storage/_demo_5users.php && php artisan tinker storage/_demo_split_tenants.php"
```
Expected: 5 компаний + учётки `admin@demo.local` / `manager1..4@demo.local` (пароль `password`).
> NB: точный набор демо-скриптов сверить с ЭТАЛОН §4 (там же команда восстановления). Залить только нужные `_demo_*.php`.
- [ ] **Step 6: Вернуть рантайм-роль crm_app_user**
```bash
ssh ... "cd /var/www/liderra/app && \
sed -i 's/^DB_USERNAME=.*/DB_USERNAME=crm_app_user/; s/^DB_PASSWORD=.*/DB_PASSWORD=<APP_DB_PASS>/' .env && \
grep -E '^DB_USERNAME=' .env"
```
Expected: `DB_USERNAME=crm_app_user` (RLS будет enforce'иться в рантайме).
- [ ] **Step 7: Права и кэши**
```bash
ssh ... 'cd /var/www/liderra/app \
&& sudo chown -R deploy:www-data storage bootstrap/cache \
&& sudo chmod -R 775 storage bootstrap/cache \
&& php artisan config:cache && php artisan route:cache && php artisan view:cache'
```
Expected: кэши собраны, прав хватает.
---
## Фаза 4 — Веб, HTTPS, дверь (🤖 + 🧑 DNS)
### Task 4.1: DNS A-запись (🧑)
- [ ] **Step 1:** В панели домена создать запись `A` для `<DOMAIN>``<SERVER_IP>`.
- [ ] **Step 2 (🤖): Проверить распространение**
Run: `ssh ... "getent hosts <DOMAIN> || nslookup <DOMAIN>"`
Expected: резолвится в `<SERVER_IP>` (может занять до 30–60 мин).
### Task 4.2: nginx vhost (HTTP)
- [ ] **Step 1: Конфиг сайта**
```bash
ssh ... 'sudo tee /etc/nginx/sites-available/liderra <<EOF
server {
listen 80;
server_name <DOMAIN>;
root /var/www/liderra/app/public;
index index.php;
# дверь на весь сайт (Basic Auth), кроме webhook поставщика
location / {
auth_basic "Liderra test";
auth_basic_user_file /etc/nginx/.htpasswd;
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ^~ /api/webhook/ {
auth_basic off;
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/liderra /etc/nginx/sites-enabled/liderra
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx'
```
Expected: `nginx -t` syntax ok; reload без ошибок.
> NB: точный префикс webhook (`/api/webhook/`) сверить с `app/routes/api.php` (grep `webhook`). Если иной — поправить `location ^~`.
- [ ] **Step 2: Создать пароль двери**
```bash
ssh ... "sudo htpasswd -bc /etc/nginx/.htpasswd <BASIC_USER> <BASIC_PASS>"
```
Expected: `.htpasswd` создан.
- [ ] **Step 3: Проверка по HTTP**
Run: `ssh ... "curl -s -o /dev/null -w '%{http_code}' -u <BASIC_USER>:<BASIC_PASS> http://<DOMAIN>/"`
Expected: `200` (или `302` на /login). Без креда → `401`.
### Task 4.3: HTTPS (Let's Encrypt)
- [ ] **Step 1: Выпустить сертификат**
```bash
ssh ... "sudo certbot --nginx -d <DOMAIN> --non-interactive --agree-tos -m <EMAIL> --redirect"
```
Expected: сертификат выпущен, nginx переписан на 443 + редирект с 80.
- [ ] **Step 2: Проверить HTTPS + авто-продление**
Run: `ssh ... "curl -sI -u <BASIC_USER>:<BASIC_PASS> https://<DOMAIN>/ | head -1; sudo certbot renew --dry-run 2>&1 | tail -1"`
Expected: `HTTP/2 200|302`; dry-run `Congratulations` / success.
---
## Фаза 5 — Фоновые службы (🤖)
### Task 5.1: queue worker как systemd-служба
- [ ] **Step 1: Юнит**
```bash
ssh ... 'sudo tee /etc/systemd/system/liderra-queue.service <<EOF
[Unit]
Description=Liderra queue worker
After=redis-server.service postgresql.service
[Service]
User=deploy
Restart=always
WorkingDirectory=/var/www/liderra/app
ExecStart=/usr/bin/php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now liderra-queue'
```
Run: `ssh ... "systemctl is-active liderra-queue"`
Expected: `active`.
### Task 5.2: scheduler (cron)
- [ ] **Step 1: Cron-запись**
```bash
ssh ... '( crontab -l 2>/dev/null; echo "* * * * * cd /var/www/liderra/app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1" ) | crontab -'
```
Run: `ssh ... "crontab -l | grep schedule:run"`
Expected: строка присутствует.
---
## Фаза 6 — Приёмка и сопровождение (🤖)
### Task 6.1: Проверка критериев готовности (DoD)
- [ ] **Step 1: HTTPS + замочек**
Открыть `https://<DOMAIN>` в браузере (с логином двери) → валидный сертификат, портал грузится.
- [ ] **Step 2: Дверь работает**
Run: `ssh ... "curl -s -o /dev/null -w '%{http_code}' https://<DOMAIN>/"``401` (без креда).
- [ ] **Step 3: Вход + данные**
В браузере: `admin@demo.local` / `password` → видно 4 демо-проекта.
- [ ] **Step 4: Изоляция компаний (RLS)**
Войти `manager1@demo.local` / `password` → видна только своя компания (чужих проектов нет). Если падает SQL — зафиксировать, чинить (риск из спеки §5.4).
- [ ] **Step 5: Админка**
Открыть `/admin/...` под админом → не 503 (флаг bypass работает).
- [ ] **Step 6: Службы переживают перезагрузку**
```bash
ssh ... "sudo reboot" # подождать ~40с
ssh ... "systemctl is-active nginx php8.3-fpm postgresql redis-server liderra-queue"
```
Expected: все `active`; сайт снова открывается.
### Task 6.2: Скрипт обновления + инструкция
**Files:** `/var/www/liderra/deploy.sh` (на сервере), `docs/deploy/test-server-runbook.md` (в репо)
- [ ] **Step 1: deploy.sh**
```bash
ssh ... 'cat > /var/www/liderra/deploy.sh <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/liderra
git pull
cd app
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo systemctl restart php8.3-fpm liderra-queue
echo "Deployed: \$(git -C /var/www/liderra log -1 --oneline)"
EOF
chmod +x /var/www/liderra/deploy.sh'
```
> Фронтенд при обновлении: пересобрать на dev (`npm --prefix app run build`) и `scp` build на сервер ПЕРЕД запуском deploy.sh.
- [ ] **Step 2: Runbook**
Создать `docs/deploy/test-server-runbook.md`: адрес, доступы (где лежат пароли), команда обновления, как остановить/удалить VM (прекратить оплату), напоминание убрать `SAAS_ADMIN_TEST_BYPASS` при переходе к настоящему SSO.
- [ ] **Step 3: Commit runbook**
```bash
git add docs/deploy/test-server-runbook.md
git commit -m "docs(deploy): test-server runbook"
```
---
## Открытые вопросы (заполнить при исполнении)
- `<DOMAIN>` и панель управления доменом — от заказчика.
- Точный admin-маршрут для теста (Task 0.2) и префикс webhook (Task 4.2) — grep по коду.
- Точные seed-шаги демо-учёток (Task 3.5) — по ЭТАЛОН §4.
- Пароли БД-ролей (`<APP_DB_PASS>`, `<ADMIN_DB_PASS>`, `<MIGRATOR_DB_PASS>`, `<AUDIT_DB_PASS>`, `<WORKER_DB_PASS>`) + дверь сайта (`<BASIC_PASS>`) — сгенерировать (Task 3.1 Step 1), сохранить в безопасном месте (не в git; занести в runbook-ссылку на хранилище).
- `pg_hba.conf` путь зависит от версии PG (`/etc/postgresql/16/main/`) — сверить на сервере.
@@ -0,0 +1,149 @@
# Удаление проектов вместо архива + дедуп источника + человеческие ошибки
**Дата:** 2026-05-21
**Статус:** утверждён (заказчик «ок делай»)
**Ветка исполнения:** worktree от `feat/project-migration-redesign` (origin/main)
## Контекст и проблема
Заказчик при создании проекта получил сырой `SQLSTATE[23505] Unique violation ...
projects_tenant_id_name_key` в UI. Разбор вскрыл 4 связанных задачи:
1. **Архивация бессмысленна** — если клиенту проект не нужен, хранить его незачем.
Сейчас «удаление» (`DELETE /api/projects/{id}`) = мягкая архивация
(`ProjectService::archive``archived_at`, `is_active=false`). Нужно настоящее удаление.
2. **Шеринг между клиентами** — два разных клиента могут завести проекты с одинаковыми
параметрами/источником. Это корректно (модель шеринга: один донор раздаётся ≤3 клиентам
через `LeadRouter`). Менять не нужно — подтвердить.
3. **Дедуп источника внутри клиента отсутствует** — один клиент может завести 2 проекта на
один источник (номер/домен/SMS-отправитель). Нужен запрет.
4. **Утечка SQL в UI**`Project::create()` бьётся о DB-констрейнт без перехвата → сырой
`SQLSTATE` рендерится пользователю. Нужны человеческие сообщения.
## Ключевые факты кодовой базы (разведка)
- `deals.project_id`**без FK** (таблица партиционирована, partition-wise FK не
поддерживается). Hard-delete проекта НЕ каскадит и НЕ блокируется сделками → они «повиснут».
Поэтому удаление проекта со сделками опасно → блокируем (см. Решение 1).
- `ON DELETE CASCADE` на `projects(id)` имеют только служебные таблицы:
`project_supplier_links`, `supplier_manual_sync_queue`, `project_suppliers`,
`project_user_assignments`, `project_limit_adjustments`. Их каскад при удалении — норма.
- Уникальность `projects` на DB-уровне: `projects_tenant_id_name_key` = `(tenant_id, name)`.
Дедупа по источнику на DB-уровне нет.
- Источник проекта: `signal_identifier` (call=телефон `7\d{10}`, site=домен) либо
`sms_senders[]` (+ `sms_keyword`) для sms.
- Шеринг донора: `project_supplier_links` (M:N projects↔supplier_projects) связывает
Лидерра-проекты РАЗНЫХ тенантов с одним `supplier_project` (донором). Outbound-синк
(`SyncSupplierProjectsJob`) считает агрегатный лимит по всем клиентам источника
(`computeOrder = max(max, ceil(Σ/3))`, cap=3).
- Архив-ссылки (под снос): backend — `ProjectController` (destroy/index/bulk),
`ProjectService` (archive/bulk/update/resolveBulkScope), `Project` (scopeActive/scopeArchived,
fillable+cast `archived_at`), `ProjectResource`, `DashboardController`,
`SyncSupplierProjectsJob`, `BulkProjectActionRequest`. Frontend — `BulkActionsBar`,
`ProjectCard`, `ProjectDetailsDrawer`, `ProjectsView`, `projectsStore`.
## Решения (утверждены заказчиком)
### Решение 1 — удаление вместо архива, с защитой по сделкам
- `DELETE /api/projects/{id}`**hard delete** через новый `ProjectService::delete()`.
- **Guard:** если по проекту есть хоть одна `deals` (любой статус, включая `deleted_at`-soft)
→ удаление блокируется HTTP 422 (`{errors:{...}}`/`message`, формат фронта) с сообщением:
*«Нельзя удалить проект: по нему есть сделки. Остановите приём (пауза), чтобы скрыть из
работы».* Пустой проект (0 сделок) → удаляется насовсем.
- Архивация убирается **полностью**: код-пути `archive`, scope `archived`, фильтр «Архивные»,
bulk-action `archive`. Колонка `archived_at` дропается миграцией (schema bump). Пауза
(`is_active`) сохраняется — это отдельный механизм.
- Bulk: action `archive``delete` (с тем же guard'ом per-project; проекты со сделками
попадают в `skipped` с причиной, не роняют весь батч).
### Решение 2 — удаление у поставщика с учётом шеринга
При удалении Лидерра-проекта P (тенант T) по источнику X (один или несколько доноров
B1/B2/B3 через `project_supplier_links`):
1. Удаляем P локально (его `project_supplier_links` уходят каскадом).
2. Для каждого затронутого `supplier_project` S (донора источника X):
- Считаем оставшиеся `project_supplier_links` на S (проекты ДРУГИХ тенантов).
- **Остались** → донор нужен другим клиентам → **пере-синк** S (агрегатный лимит/регионы/дни
без T) через outbound-синк. У поставщика проект НЕ удаляем.
- **Не осталось** (T был последним) → у поставщика **удаляем** донора
(`SupplierPortalClient::deleteProject(external_id)`) + удаляем локальную запись
`supplier_projects` S.
3. Внешние вызовы к поставщику — через job (resilience, retry), не inline в HTTP-запросе.
Граничные сценарии:
- P не привязан ни к одному донору (например, синк ещё не прошёл) → шаг 2 пропускается.
- Несколько доноров (B1/B2/B3) у одного источника → шаг 2 для каждого независимо.
- Падение удаления у поставщика → job ретраит; локальное удаление P уже выполнено
(eventual consistency; «висячий» донор у поставщика подметёт следующий синк/ретрай).
### Решение 3 — дедуп источника внутри клиента
- При создании и при изменении источника: внутри `tenant_id` источник должен быть уникален
среди проектов клиента.
- «Источник» (source key):
- call/site → `signal_identifier`;
- sms → нормализованный `sms_senders` (сортировка+lower) + `sms_keyword`.
- Enforcement: app-level проверка в `ProjectService::create()`/`update()` → 422 с сообщением
*«У вас уже есть проект с этим источником: "<название>"»*. После сноса архива «существующие
проекты» = все проекты клиента (soft-deleted проектов нет — мы их hard-delete'им).
- DB-уровень: partial unique index как защита-эшелон (опционально, в той же миграции);
его нарушение перехватывается общим обработчиком (Решение 4) и не утекает.
### Решение 4 — человеческие ошибки вместо SQL
- App-level pre-checks (до DB): уникальность `name` в рамках клиента + уникальность источника
(Решение 3) → 422 `{errors: {field: [msg]}}` (формат, который уже понимает фронт).
- Глобальный перехват `Illuminate\Database\QueryException` в `bootstrap/app.php`
(`withExceptions`): в лог — полный текст; пользователю — generic
*«Не удалось сохранить. Проверьте данные или попробуйте ещё раз»* (HTTP 422 для JSON-запросов).
Никакой `SQLSTATE` в UI.
- Фронт `NewProjectDialog`/`projectsStore` — убедиться, что 422 `errors`/`message`
показываются как есть (уже умеет; правок минимум).
## Затрагиваемые компоненты
**Backend**
- `ProjectService`: +`delete()` (guard сделок + оркестрация шеринга), −`archive()`,
bulk `archive``delete`, чистка `archived_at`/`archived` из update/resolveBulkScope,
+дедуп источника в create/update.
- `ProjectController`: `destroy()``delete()`, index `status=archived` убрать, bulk doc.
- `BulkProjectActionRequest`: `archive``delete`, status `archived` убрать.
- `Project` (модель): scopeArchived, scopeActive упростить/убрать, −`archived_at` fillable+cast.
- `ProjectResource`: `archived_at`.
- `DashboardController`, `SyncSupplierProjectsJob`: убрать `whereNull('archived_at')`.
- Новый job `DeleteSupplierProjectJob` (или расширение существующего) — удаление донора у
поставщика, когда источник остался без потребителей.
- Миграция: `DROP COLUMN projects.archived_at` (+ опц. partial unique index источника) →
schema bump v8.27 + `db/CHANGELOG_schema.md`.
- `bootstrap/app.php`: глобальный handler `QueryException`.
**Frontend**
- `BulkActionsBar`: «Архивировать»→«Удалить» (+подтверждение/иконка).
- `ProjectCard`, `ProjectDetailsDrawer`: «Архивировать»→«Удалить».
- `ProjectsView`: `@archive``@delete`, убрать фильтр «Архивные».
- `projectsStore`: `archive()``delete()`, bulk `archive``delete`, тип `archived_at` убрать.
## Тестирование (TDD)
- Backend (Pest): delete пустого проекта → 204 + строки нет; delete со сделками → 409/422,
проект жив; шеринг — delete последнего потребителя → донор удалён у поставщика (mock client);
delete при оставшихся потребителях → донор НЕ удалён, пере-синк; дедуп источника create/update
→ 422; имя-дубль → 422 (не SQL); глобальный QueryException handler → generic message.
- Frontend (Vitest): кнопки «Удалить» вместо «Архивировать»; нет фильтра «Архивные»;
store.delete дёргает DELETE; ошибка сервера показывается человеческим текстом.
- Live («проверь на практике» по каждой задаче, затем чистка тестовых данных):
1) удаление пустого проекта + блок на проекте со сделками;
2) два тенанта с одинаковым источником — создание проходит;
3) попытка дубля источника у одного тенанта — отказ с понятным текстом;
4) создание дубля имени — человеческое сообщение, не SQL.
## Вне scope (YAGNI)
- Restore/корзина удалённых проектов (удаление окончательное по решению заказчика).
- Массовая авто-чистка уже-архивированных проектов dev-БД (разовая ручная операция).
- `StatusPill 'archived'` mapping не трогаем (используется и для статусов сделок).
@@ -0,0 +1,235 @@
# Регистрация: подтверждение email кодом + обязательный телефон — дизайн
**Дата:** 2026-05-21
**Контекст:** пилот. Решение держим простым, без переусложнения (YAGNI).
**Статус:** утверждён заказчиком, готов к плану реализации.
## 1. Проблема
Текущая регистрация (`POST /api/auth/register`) принимает только email + пароль +
2 click-wrap'а (оферта, ПДн) и сразу создаёт аккаунт. Два пробела:
1. **Нет подтверждения владения email** — любой может зарегистрироваться на чужой
или несуществующий адрес.
2. **Нет телефона** — невозможно связаться с пользователем.
## 2. Цели
- Доказать владение email **до** создания аккаунта (6-значный код на почту).
- Сделать телефон **обязательным** при регистрации, ввод по маске `+7 (XXX) XXX-XX-XX`.
- Реальная доставка писем через **Яндекс SMTP**.
## 3. Не-цели (явно вне scope пилота)
- SMS-подтверждение телефона (нет SMS-провайдера) — только сбор номера, проверка формата.
- Новые таблицы БД / правка `db/schema.sql` — используем серверную сессию (паттерн 2FA).
- Принуждение существующих пользователей (демо-аккаунты) добавить телефон или
переподтвердить почту. Новые требования — **только для новых регистраций**.
- DEV-показ кода в ответе/логе приложения. Код доставляется **только письмом**.
- Резолв телефон→регион при регистрации (`PhonePrefixService` используется в другом месте).
## 4. Текущее состояние кода (что уже есть)
- `app/app/Http/Controllers/Api/AuthController.php``register()` (одношаговый).
- `app/app/Http/Requests/Auth/RegisterRequest.php` — валидация (email/password/2 чекбокса).
- `app/resources/js/views/auth/RegisterView.vue` — форма (email/пароль/strength/2 чекбокса).
- `app/resources/js/stores/auth.ts``register(payload)` + `authApi.register`.
- `db/schema.sql`: таблица `users` уже имеет колонки `phone VARCHAR(20)` и
`email_verified_at TIMESTAMPTZ`; модель `User` уже приводит тип `email_verified_at` (cast) и
имеет `phone` в `$fillable`. **Схему менять не нужно.**
- Существует неиспользуемая таблица `email_verifications` (с `user_id NOT NULL`) —
она спроектирована под верификацию **существующего** пользователя и **не подходит**
под «код до создания аккаунта». Оставляем как есть (не трогаем).
- Паттерн «отложенного» состояния в сессии уже применяется: 2FA-логин кладёт
`auth.pending_user_id` в session между `login` и `2fa/verify`. Повторяем этот же приём.
- Mail-инфраструктура: есть Mailable'ы (напр. `SuspiciousLoginNotification`),
отправка через `Mail::to(...)->send(...)`.
## 5. Выбранный подход
**Хранение незавершённой регистрации — серверная сессия** (не новая таблица).
Причина: не требует изменения схемы/RLS, переживает между двумя запросами
(сессионная cookie выдаётся и гостю), консистентно с уже существующим 2FA-pending.
Минус (теряется при смене браузера/вкладки) для пилота приемлем.
## 6. Поток регистрации
```
Шаг 1 (форма) POST /api/auth/register/start
вход: email, phone, password, accept_offer, accept_pdn
проверки: email формат+уникальность, пароль (текущие правила),
phone формат, оба чекбокса
действие: генерируем 6-значный код, кладём pending в session,
шлём письмо с кодом на email
ответ: 200 { message, email }
Шаг 2 (ввод кода) POST /api/auth/register/verify
вход: code
проверки: pending есть в session; не истёк; попыток < 5; код совпал
действие: создаём User (phone нормализован, email_verified_at = now()),
Auth::login, session()->regenerate(), чистим pending
ответ: 201 { user, requires_2fa: false }
Повторная отправка POST /api/auth/register/resend
действие: перегенерировать код, обновить expires_at, переотправить письмо
ограничение: cooldown 60 сек между отправками; не чаще лимита start
ответ: 200 { message }
```
## 7. Backend — детали
### 7.1 Эндпоинты и роуты (`app/routes/web.php`, группа `/api/auth`)
- Заменяем `POST /register` на:
- `POST /register/start``AuthController@registerStart`
- `POST /register/verify``AuthController@registerVerify`
- `POST /register/resend``AuthController@registerResend`
- Все три — публичные (как был `register`).
- Старый одношаговый `register()` удаляем (единственный потребитель — фронт, его обновляем).
### 7.2 Form Requests
- `RegisterStartRequest` (на базе текущего `RegisterRequest` + `HasPasswordRules`):
- `email`: required, string, email, max:255, **unique(users,email)**.
- `password`: текущие `passwordRules()`.
- `phone`: required, string; после нормализации обязан матчить `^7\d{10}$`.
- `accept_offer`: required, accepted.
- `accept_pdn`: required, accepted.
- Сообщения — RU, в стиле текущего `RegisterRequest::messages()`.
- `RegisterVerifyRequest`:
- `code`: required, string, regex `^\d{6}$`.
### 7.3 Нормализация телефона
- Хелпер (метод/Service): убрать всё кроме цифр; ведущую `8``7`; `+7``7`;
результат обязан быть `7` + 10 цифр (`^7\d{10}$`), иначе ошибка валидации.
- Хранение в БД: нормализованный `7XXXXXXXXXX` (консистентно с `PhonePrefixService`).
### 7.4 Структура pending в session (ключ `registration.pending`)
```
{
email, password_hash, // password хешируем сразу (Hash::make) — не храним plaintext
phone, // нормализованный 7XXXXXXXXXX
accept_offer, accept_pdn,
code_hash, // sha256(code) — не храним сам код
expires_at, // now + 15 мин
attempts, // счётчик неверных вводов, старт 0, лимит 5
send_count, last_sent_at // для cooldown/лимита отправок
}
```
### 7.5 Код подтверждения
- 6 цифр, `str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT)`.
- Срок жизни 15 минут.
- Сверка: `hash_equals(code_hash, hash('sha256', input))`.
- Лимит неверных вводов — 5; при превышении pending инвалидируется (нужно начать заново).
### 7.6 Rate-limiting (защита от спама писем)
- `register/start`: ключ `email|ip`, лимит ~5 запросов/час (через `RateLimiter`).
- `register/resend`: cooldown 60 сек между отправками + общий лимит как у start.
- `register/verify`: лимит попыток в pending (5) + опц. `RateLimiter` по ip.
### 7.7 Письмо
- Новый Mailable `RegisterEmailVerificationCode` (зеркалит существующие Mailable'ы):
- Тема: «Код подтверждения регистрации — Лидерра».
- Тело (blade/markdown, бренд «Лидерра»): код крупно + «срок действия 15 минут,
если вы не регистрировались — проигнорируйте письмо».
- Получатель — email из pending.
### 7.8 Создание пользователя (на verify)
Зеркалит текущий `register()`:
- `tenant_id` = `Tenant::first()->id` (MVP attach; если нет тенанта — 503).
- `first_name='Новый'`, `last_name='Пользователь'` (как сейчас; меняются в профиле).
- `phone` = нормализованный.
- `email_verified_at` = `now()` (почта доказана кодом).
- `is_active=true`, `totp_enabled=false`.
- `Auth::login` + `session()->regenerate()` + очистка pending.
## 8. Frontend — детали
### 8.1 `RegisterView.vue` — двухшаговый
- **Шаг 1 (форма):** email, **phone (маска `+7 (XXX) XXX-XX-XX`)**, пароль (+индикатор
силы, как сейчас), 2 чекбокса. Кнопка «Получить код». `canSubmit` учитывает заполненный
и валидный по маске телефон.
- **Шаг 2 (код):** поле на 6 цифр, кнопка «Подтвердить и создать аккаунт»,
ссылка «Отправить код повторно» (с обратным отсчётом cooldown 60 сек),
ссылка «Изменить данные» (вернуться к шагу 1).
- Ошибки backend (`extractValidationErrors`) показываются под полями;
email-unique и формат телефона — на шаге 1, неверный/истёкший код — на шаге 2.
### 8.2 Маска телефона
- Без новой тяжёлой зависимости: маленький dependency-free форматтер/composable
(`formatPhone(digits) → "+7 (XXX) XXX-XX-XX"`, хранит нормализованные цифры
`7XXXXXXXXXX`). Тестируется юнит-тестом.
### 8.3 `auth.ts` store + `api`
- `register(payload)` заменяется на: `registerStart(payload)`, `registerVerify({code})`,
`registerResend()`; соответствующие методы в `authApi`.
- На успешный verify — поведение как сейчас: переход на `/dashboard`
(`requires_2fa` всегда false при регистрации).
## 9. Конфигурация почты (операционный шаг, не в git)
Яндекс SMTP в `app/.env` (значения — от заказчика, **в репозиторий не коммитим**,
gitleaks в pre-commit защищает):
```
MAIL_MAILER=smtp
MAIL_HOST=smtp.yandex.ru
MAIL_PORT=465
MAIL_ENCRYPTION=ssl # либо 587 + tls
MAIL_USERNAME=<ящик@yandex.ru>
MAIL_PASSWORD=<пароль приложения Яндекса>
MAIL_FROM_ADDRESS=<тот же ящик@yandex.ru> # Яндекс требует совпадения From с авторизованным ящиком
MAIL_FROM_NAME="Лидерра"
```
NB: на Яндексе нужен **пароль приложения** (не основной пароль), SMTP должен быть
включён в настройках ящика; `MAIL_FROM_ADDRESS` обязан совпадать с `MAIL_USERNAME`.
## 10. Тестирование
### 10.1 Pest (Feature) — `Mail::fake()`
- `register/start` валидный → 200, `Mail::assertSent(RegisterEmailVerificationCode)` на email, pending в session.
- start: дубль email → 422; плохой phone → 422; слабый пароль → 422; без чекбоксов → 422.
- start: throttle/cooldown.
- `register/verify` верный код → 201, user создан с `email_verified_at != null` и нормализованным `phone`, залогинен.
- verify: неверный код → 422 + инкремент attempts; после 5 → pending инвалидирован; истёкший код → 422; нет pending → 422/409.
- `register/resend` → новый код, cooldown enforced.
- Обновить существующие тесты старого `register` (заменены новым потоком).
### 10.2 Vitest
- `RegisterView`: переход шаг1→шаг2, форматирование маски телефона, `canSubmit`-гейтинг,
показ ошибок, cooldown повторной отправки.
- Юнит-тест форматтера телефона (нормализация `8…`/`+7…`/`7…`).
## 11. Затрагиваемые файлы (ориентир)
**Backend:** `AuthController.php`, новые `RegisterStartRequest.php` / `RegisterVerifyRequest.php`,
новый `RegisterEmailVerificationCode` Mailable + blade-шаблон, `routes/web.php`,
(опц.) маленький phone-normalizer service/helper. Существующие register-тесты.
**Frontend:** `RegisterView.vue`, `stores/auth.ts`, `api` client, новый phone-format composable,
соответствующие spec-файлы (Vitest) + `RegisterView.story.vue` (при необходимости).
**Конфиг:** `app/.env` (Яндекс SMTP — вне git).
**Схема БД:** без изменений.
## 12. Открытые риски
- Реальная доставка зависит от корректных SMTP-доступа Яндекса (логин + пароль приложения,
включённый SMTP, совпадение From). Проверяется живой отправкой при настройке.
- Сессионное хранение pending: при смене браузера/долгом простое регистрацию надо
начать заново — приемлемо для пилота.
@@ -0,0 +1,141 @@
# Тестовый деплой портала Лидерра в Yandex Cloud — дизайн
**Дата:** 2026-05-21
**Статус:** черновик дизайна (brainstorming) → ожидает вычитки заказчиком → writing-plans
**Автор:** Claude + Дмитрий
**Тип:** инфраструктура / деплой (не фича приложения)
## 1. Цель
Поднять рабочую копию портала Лидерры в интернете по стабильному адресу с настоящим
HTTPS, чтобы её могли открывать **только заказчик (Дмитрий) и Claude** для сквозного
ручного тестирования. Это **тестовое/staging-окружение**, не продакшен: без юр.лица,
без реальной почты, без SSO, под снос в любой момент.
## 2. Что НЕ входит (YAGNI / границы)
- ❌ Yandex 360 SSO (корпоративный вход админов) — ждёт Б-1 (ООО).
- ❌ Реальный landing, реальная почта (Unisender Go), Sentry-мониторинг, бэкапы,
автодеплой из GitHub (CI/CD).
- ❌ Управляемые БД/Redis Yandex (Managed PostgreSQL/Redis) — это для будущего прода.
- ❌ Перенос текущей dev-базы — на сервере свежие демо-данные.
- ❌ Публичный доступ для чужих тестеров (для этого понадобились бы реальная почта,
закрытие админки, реальная изоляция — отдельный этап).
## 3. Решения, принятые в brainstorming
| Развилка | Выбор |
|---|---|
| Где хостить | Отдельный Linux-сервер в **Yandex Cloud** (вариант A — всё на одной VM) |
| Аккаунт YC | Заводится с нуля заказчиком (создан 21.05.2026: облако `cloud-sasha261185`, каталог `default`); ожидает привязки платёжного аккаунта + грант 60 дней |
| Адрес | **Свой домен** (поддомен вида `test.<домен>`) + настоящий HTTPS (Let's Encrypt) |
| Кто настраивает сервер | **Claude по SSH** с dev-машины; заказчик даёт доступ (вставляет публичный ключ при создании VM) |
| Архитектура | Вариант A — один сервер, нативная установка (nginx + PHP-FPM + PostgreSQL + Redis), без Docker, без управляемых сервисов |
## 4. Архитектура сервера
Одна VM (Ubuntu LTS, ~2 vCPU / 24 ГБ, диск 15–20 ГБ SSD, зона `ru-central1-a`):
```
интернет
ваш домен (test.…) ──DNS A-запись──► публичный IP VM
┌───────┴─ nginx (HTTPS, Let's Encrypt авто-продление) ──────────┐
│ • HTTP Basic Auth на весь сайт (пускает только нас двоих) │
│ — кроме пути webhook поставщика (защищён HMAC-подписью) │
└───────┬─────────────────────────────────────────────────────────┘
PHP-FPM 8.3 ← код портала + собранный фронтенд (public/build)
┌───────┼─────────┐
PostgreSQL 16 Redis 7 (на этой же машине)
systemd-службы: queue worker (queue:work redis) + scheduler
(php artisan schedule:run по cron) — переживают перезагрузку
```
**Поток выкладки кода:**
1. Сервер тянет код из приватного репо `CoralMinister/lidpotok` по **read-only deploy-key**
(генерируется на сервере, заказчик добавляет в GitHub → Deploy keys).
2. `composer install --no-dev --optimize-autoloader`.
3. **Фронтенд собирается на dev-машине** (`npm --prefix app run build`) и заливается
(`app/public/build`) на сервер — чтобы не держать Node и не упираться в RAM при сборке.
4. Накат схемы БД (`db/schema.sql` v8.27) + демо-данные (seed + 5 учёток).
5. `php artisan config:cache route:cache view:cache`.
**Обновление новой версии** (когда понадобится) — одна идемпотентная команда/скрипт:
`git pull` → composer → залить новый build → migrate → пересобрать кэши → перезапустить
php-fpm + queue. Оформлю как `deploy.sh` на сервере + короткую инструкцию.
## 5. Безопасность теста
1. **Edge-дверь:** nginx HTTP Basic Auth на весь сайт (один общий логин/пароль для нас
двоих; хранится в `/etc/nginx/.htpasswd`). Посторонние и поисковики сайт не видят.
Исключение — путь приёма лидов от поставщика (webhook, защищён HMAC), чтобы при
желании протестировать живой приём от `crm.bp-gr.ru`.
2. **Админка:** middleware `EnsureSaasAdmin` в проде отдаёт 503 (ждёт Yandex SSO).
Добавляется **минимальный временный флаг** `SAAS_ADMIN_TEST_BYPASS` (config
`app.saas_admin_test_bypass`, default `false`): когда `true` — middleware пропускает.
Включается только на тест-сервере, помечен «убрать после внедрения реального SSO».
Правка в коде — небольшая, закоммичена, по умолчанию выключена → прод не затронут.
3. **Боевой режим без утечек:** `APP_ENV=production`, `APP_DEBUG=false`.
4. **Реальная изоляция компаний (RLS):** на сервере подключаются настоящие роли БД
(`db/00_create_roles.sql` + `db/02_grants.sql`; приложение ходит как `crm_app_user`,
джобы — как `crm_supplier_worker` BYPASSRLS). В отличие от dev (postgres-суперюзер,
RLS обходится) — изоляция реально работает.
- ⚠️ **Риск:** RLS включается «вживую» впервые. Возможен запрос, работавший под
суперюзером и падающий под RLS. Реакция: чиню точечно либо временно ослабляю роль.
Считается полезным для теста.
5. **SSH:** доступ по ключу (пароли отключены); порт 22 в группе безопасности по
возможности ограничить IP dev-машины + заказчика. Открыты порты 80/443/22.
6. **Почта:** `MAIL_MAILER=log` (письма в лог, не на ящик) — не нужны, заходим под
готовыми демо-учётками.
## 6. Данные
Демо-набор как на dev: 5 изолированных компаний, входы `admin@demo.local` +
`manager1..4@demo.local`, пароль у всех `password`. Демо-данные — стираемые.
## 7. Разделение работ
**Заказчик (через веб-интерфейсы, по инструкции Claude):**
1. Завершить регистрацию YC + привязать карту + забрать грант 60 дней.
2. Создать VM (Ubuntu), вставить публичный SSH-ключ Claude.
3. Сообщить публичный IP машины.
4. Прописать у домена A-запись `test.<домен>` → IP.
5. Добавить read-only deploy-key в GitHub-репо.
6. Придумать общий логин/пароль «двери» сайта.
**Claude (по SSH, сам):** вся установка/настройка сервера, выкладка кода, сборка-загрузка
фронтенда, схема БД + демо-данные, HTTPS, systemd-службы, проверка (портал открывается,
логин работает, изоляция компаний работает), `deploy.sh` + инструкция обновления.
**Доступ Claude:** только IP сервера + SSH по ключу, который Claude генерирует сам.
Паролей/карт заказчика Claude не получает.
## 8. Стоимость и жизненный цикл
- ~10001500 ₽/мес за VM (2 vCPU / 2–4 ГБ); грант 60 дней + до 10 000 ₽ — вероятно,
первый период бесплатно.
- Домен — ~200–1500 ₽/год (если ещё нет).
- Тест не нужен → VM остановить/удалить → оплата прекращается.
## 9. Критерии готовности (Definition of Done)
- По адресу `https://test.<домен>` открывается портал с валидным HTTPS-замочком.
- Сайт под Basic Auth (посторонний без логина не входит).
- Вход `admin@demo.local` / `password` работает; видны 4 демо-проекта.
- `manager1@demo.local` видит только свою компанию (RLS работает).
- Админка `/admin/*` доступна (через временный флаг).
- queue worker + scheduler работают как службы, переживают перезагрузку VM.
- Есть `deploy.sh` + инструкция «как выложить новую версию».
## 10. Открытые мелочи (решим в плане)
- Точный размер VM (2 ГБ vs 4 ГБ) — зависит от того, собираем ли фронт на сервере
(план: собираем на dev → 2 ГБ хватит).
- Точный путь webhook-исключения в nginx — уточнить по `routes/`.
- Имя поддомена и сам домен — от заказчика.
+51 -9
View File
@@ -9,7 +9,7 @@
**перепроверять реальной командой**, не доверять снимку вслепую.
- Обновляется по команде заказчика **«обнови эталон»**.
**Снимок снят:** 21.05.2026 (ночь, после сквозного чек-листа всего портала + 6 фиксов: 3 stale эпик-теста под схему v8.26 + 3 UI-бага; запушено в main; volatile §1–§4 пересверены).
**Снимок снят:** 21.05.2026 (вечер, после поднятия **тест-сервера Yandex Cloud** и настройки всех 3 каналов миграции с поставщиком на нём; volatile §1–§4 пересверены). Прежний снимок — день, после supplier-синк фикса + баннера 18:00.
---
@@ -17,11 +17,11 @@
- Git-корень репозитория — папка `Документация/` (**не** `app/`).
- Remote: `CoralMinister/lidpotok` (приватный).
- Текущая локальная ветка: **`feat/project-migration-redesign`**.
- Локальный HEAD = origin/main HEAD = **`31b5355`** (style(backend): pint concat_space — tip параллельного эпика A1 backend-tooling; мои 6 фиксов чек-листа ниже на `a0e18a1..b7466eb`; сверять `git log -1 origin/main`).
- Push паттерн: `git push origin <ветка>:main`. Мой push 21.05: `a0e18a1..b7466eb` (4 коммита FF: `95ee664` sync stale эпик-тестов + schema header v8.26, `ba49805` dashboard greeting, `17e3c04` topbar title, `b7466eb` admin mock-счётчики; pre-push gitleaks-full 1119/0, lychee 64/0). **Поверх** параллельная сессия влила эпик A1 backend-tooling `b7466eb..31b5355` (8 коммитов: Rector #64 / PHP Insights #65 / laravel-backend-patterns skill #66 / #67 + ADR-013 + нормативка Tooling v2.19 / PSR v3.19 / Pravila v1.35 / CLAUDE v2.22 + карта 137→141 узлов) — детали в её памяти/нормативке.
- Pre-push lefthook прошёл чисто (gitleaks-full 1115/0, lychee 64/0) — `--no-verify` не понадобился.
- **Незакоммиченного нет** (фикс + тест запушены).
- Текущая локальная ветка: **`feat/test-deploy`** (дрейфнула — параллельная сессия переключила; сессия началась на `feat/project-migration-redesign`). Сверять `git branch --show-current`.
- **origin/main HEAD = `68f42ad`** (feat: баннер «до 18:00 МСК») ← `83613b4` (fix supplier: пересоздание донора + UI-бейдж). Сверять `git log -1 origin/main`.
- Локальная `feat/test-deploy` = **запушена на `origin/feat/test-deploy`** (HEAD `dcc1040`, push 21.05 вечер `bf4ed65..dcc1040`). Это **test-deploy эпик**: впереди main на 7 коммитов (test-deploy спека/runbook/флаг `SAAS_ADMIN_TEST_BYPASS` + дубли banner/supplier-фикс + мои 2 свежих: `b873c53` PlaywrightBridge timeout 75→180, `dcc1040` runbook supplier-каналы), отстаёт от main на 13 нормативка/observer-коммитов других сессий. **В main эпик НЕ влит** (несёт временный bypass-флаг + чужую незавершёнку; merge = отдельное решение, после удаления флага per runbook «После теста»). PlaywrightBridge-фикс при желании выносится в main отдельным cherry-pick (на main `TIMEOUT_SECONDS=75`).
- Push паттерн: `git push origin <ветка>:main`. Push 21.05 supplier+баннер: **через временный worktree от origin/main** (`git worktree add --detach C:\tmp-liderra-push origin/main` → cherry-pick `1220bdd 5fef464``push origin HEAD:main` = `cf0be8a..68f42ad`) — чтобы не утащить чужой test-deploy и не перезатереть main. Worktree удалён. Так делать при дрейфе ветки + чужих коммитах в общей ветке.
- **Мои изменения запушены.** Локально на `feat/test-deploy` остаются дубли моих коммитов (другие SHA, чем на main) + чужой test-deploy — это нормально, дедуп при будущем rebase/merge ветки.
- Прочее незакоммиченное: `docs/observer/STATUS.md` + `episodes-2026-05.jsonl` (hook-артефакты brain governance, не мои); untracked artifacts (см. §4).
- Остатки от rebase (безопасно): `stash@{0}` с не-моими hook/parallel-артефактами (+5 других parallel-стэшей); `/tmp/plan4-rebase-bak/` — устаревшие untracked-копии 2 observer-файлов (committed-версии origin/main авторитетнее).
@@ -74,19 +74,61 @@
## 5. Ключевые факты (стабильно)
- Структура: git-корень `Документация/` · Laravel-приложение `app/` · фронтенд `app/resources/js/`
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (**фактическая v8.26** —
Plans 1+3 эпика project-migration-redesign добавили `supplier_projects.subject_code` +
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (**фактическая v8.27** —
21.05.2026 дропнута колонка `projects.archived_at` (фича архива заменена настоящим удалением);
v8.26 наследие — Plans 1+3 эпика project-migration-redesign: `supplier_projects.subject_code` +
`project_supplier_links` pivot + `deals.subject_code` + seed `supplier_export_mode` + CHECK chk_deals_subject_code.
Schema header синхронизирован на v8.26 (65 таблиц / 123 индекса, commit `95ee664`); CHANGELOG_schema.md содержит v8.26 entries).
Header v8.27 в commit `07d7387`; CHANGELOG_schema.md содержит v8.27 + v8.26 entries).
- Стек: Laravel 13 + PHP 8.3 · Vue 3 + Vuetify 3 (не Tailwind/Inertia) · PostgreSQL 16 · Redis.
- **Демо-доступ к порталу:** 5 изолированных компаний — `admin@demo.local` (Demo Tenant, 4 проекта) + `manager1@demo.local` (Компания Ивана) + `manager2@demo.local` (Компания Анны) + `manager3@demo.local` (Компания Петра) + `manager4@demo.local` (Компания Марии). Пароль у всех **`password`**. Каждый логин видит только своё. Админка `/admin/*` в local открыта любому залогиненному (`EnsureSaasAdmin` — стаб local/testing).
- Поставщик лидов: `crm.bp-gr.ru` (учётка в `.env` `SUPPLIER_*`); портал — Vue 2 + Element UI;
`/admin/visit/rt` «Мои проекты» (форма add-project — Element UI внутри Vuetify v-dialog).
- **ТЕСТ-СЕРВЕР (Yandex Cloud, отдельно от dev!):** `http://111.88.246.137` (статический IP, HTTP, дверь
HTTP Basic Auth `liderra` / пароль в `/home/ubuntu/liderra-secrets.txt`). SSH `ssh -i ~/.ssh/liderra_deploy
ubuntu@111.88.246.137`; БД `sudo -u postgres psql -d liderra`. Демо-вход в портал: `admin@demo.local`/`password`
(tenant demo) + `info@lkomega.ru`/`password` (Компания 1, переименован из client1@liderra.test 21.05) +
`client2..4@liderra.test`/`password`. Runbook `docs/deploy/test-server-runbook.md`. **Все 3 канала поставщика
настроены и проверены вживую (21.05): supplier-портал указывает на тест-сервер → dev живых лидов больше не
получает.** Доустановлены Node20+Playwright+Chromium (`/var/www/.cache/ms-playwright`, владелец www-data).
- Оперативная карта проекта: `CLAUDE.md` (правится только плагином `claude-md-management`).
- Память Claude: индекс `MEMORY.md` — подгружается каждую сессию.
## 6. Текущие рабочие нити (детали — в памяти Claude)
- **Тест-сервер YC: все 3 канала миграции с поставщиком настроены вживую — DONE** (21.05.2026 вечер). webhook
(202/dup-200/404), CSV reconcile (185 реальных строк, ok), export (create+delete external_id=12764235). Чинились
5 пробелов: `.env` SUPPLIER creds (как dev), webhook secret 17→48-hex, allowlist `[]``["0.0.0.0/0"]` (TODO сузить),
**Node не стоял** → поставлен Node20+Playwright+Chromium, PlaywrightBridge timeout 75→180 (2GB VM). Supplier-портал
`/admin/user/api``http://111.88.246.137/...` HTTP/Активный (dev живых лидов больше не получает). Запушено
`origin/feat/test-deploy dcc1040` (`b873c53` timeout + `dcc1040` runbook). **TODO:** привязать client1..4 к реальным
supplier-каналам (pivot) иначе лиды ghost; HTTPS после домена; сузить allowlist; cherry-pick timeout в main.
Детали — память `project_supplier_channels_2026-05-18.md` (§21.05) + runbook §«Каналы миграции».
- **Supplier-синк: пересоздание удалённого донора + UI-бейдж + баннер 18:00 — DONE+ЗАПУШЕНО** (21.05.2026, main `83613b4`+`68f42ad`).
Заказчик пожаловался «одни проекты у разных ЛК» (оказалось — общий cookie браузера между вкладками, не баг) и «стек у поставщика
не создаётся». Диагностика (systematic-debugging): (1) донор 7913XXXXXXX на портале **БЫЛ всё время** (listProjects API вернул
449 проектов, 12742042-44 присутствуют — заказчик не нашёл визуально из-за сортировки списка портала); (2) реальная проблема —
бейдж «Sync pending» залипал: online-режим пишет связь в pivot `project_supplier_links`, а `aggregateSyncStatus` читал legacy FK
`supplier_b{1,2,3}_project_id` (в online NULL) → **фикс: online теперь заполняет и FK-колонки**; (3) ночной cron падал на
`archived_at` (worker держал старый код после дропа колонки) → **фикс: `php artisan queue:restart`**. Плюс safety-net: в обоих
джобах (`SyncSupplierProjectJob` + `SyncSupplierProjectsJob`) перед update сверяем external_id с живым `listProjects` и пересоздаём
мёртвых доноров in-place без удаления записей (на supplier_projects висят лиды/списания — лид №358/сделка №50 целы). Реальный
синк проекта 22 прогнан (донор подтверждён жив). Регрессия: Pest Supplier **107/107** + Plan5 **52/52**, Vitest ProjectsView
**11/11**, vue-tsc 0. **Баннер 18:00**: `v-alert` на странице «Проекты», закрывается крестиком (localStorage), информационный
(без блокировок); наш cutoff = 18:00 МСК (синк-крон), не 21:00/22:00 портала. NB tinker зависает на этой машине (кириллица в
пути) → для разовых скриптов use standalone bootstrap (`require bootstrap/app.php` + Kernel::bootstrap), не `artisan tinker file`.
- **Удаление проектов вместо архива + дедуп источника + человеческие ошибки — DONE+ЗАПУШЕНО** (21.05.2026 день,
`3b6992d..22e81cc`, 10 коммитов FF в main). По заказу: (1) кнопка «архивировать» заменена на настоящее удаление
(если по проекту есть сделки — блок с понятным сообщением, иначе hard delete + удаление донора у поставщика
с учётом шеринга — пере-синк остатка, если другие клиенты ещё пользуются источником); (2) подтверждена возможность
шеринга одинаковых источников между разными клиентами (модель не меняется); (3) добавлен запрет дубля источника
внутри одного клиента (раньше не было); (4) сырой `SQLSTATE` больше не уходит в UI — глобальный handler
`QueryException` + app-level pre-checks (имя/источник) с понятными сообщениями. Колонка `projects.archived_at`
и все `scopeActive/scopeArchived/->archived()` снесены, миграция `2026_05_21_000000`, schema bump v8.26→**v8.27**.
Новый job `App\Jobs\Supplier\DeleteSupplierProjectJob` — удаление/пере-синк донора при шеринге.
Регрессия: Pest filter=Project **229/228+1sk/0**, Vitest **896/3sk/0**, vue-tsc 0, Vite build OK, Pint/Larastan 0.
Живая проверка 5 сценариев (имя/источник дубль / sharing / удаление пустого / блок при сделках) — все ✓ с чисткой.
Спека: `docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md`, план:
`docs/superpowers/plans/2026-05-21-project-delete-dedup-errors.md`. Память: `project_delete_dedup_errors.md`.
- **Сквозной чек-лист всего портала + 6 фиксов — ЗАПУШЕНО** (21.05.2026, `a0e18a1..b7466eb`). Прогон всех ~30 экранов
(Playwright) + полная регрессия (Pest 1010/0, Vitest 895/0, pint/larastan/vue-tsc/build чисто). Портал здоров: все
экраны рендерятся, консоль чистая. Найдено и исправлено 3 UI-бага: (1) дашборд здоровался захардкоженным «Доброе утро,