Compare commits

...

32 Commits

Author SHA1 Message Date
Дмитрий fef9499e1a docs(plan): Sprint 5A — Auth polish (A1/A4/A5/A6/A8)
План portal-audit Sprint 5 под-план A: 5 P2 UX-debt эпиков подсистемы
Auth — A1 (Yandex SSO disabled+tooltip), A4 (ResetPassword confirm
mismatch error), A5 (ForgotPassword fallback regression-тест),
A6 (TwoFactor реальный TOTP-отсчёт), A8 (DemoSeeder demo:seed + README).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:23:26 +03:00
Дмитрий 72c8cad963 fix(dev): A8 review — production-guard в DemoSeeder + точность README/теста (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий aa77814206 feat(dev): A8 — composer demo:seed + README демо-данные + idempotency-тест (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий fcf8626c26 fix(auth): A6 review — ранний return при redirect на /login (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий be51c97dce feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий 4a1663b426 test(auth): A5 — regression generic fallback ForgotPassword (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий 17d9f16b7d feat(auth): A4 — ResetPassword ошибка несовпадения паролей (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий efb0dea5ed feat(auth): A1 — Yandex 360 SSO disabled + tooltip (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий 120a386f05 feat(map): automation-graph — раздел «Хотелки» (отложенный backlog)
Слой WISHLIST: панель отложенных хотелок развития мозга/портала + кнопка-легенда «💡 Хотелки» в нижней легенде. Засеяно 4 хотелками раздела E8: K7-spike, мост claude-mem→ReasoningBank, claude-mem #1, двухуровневый ремонтник. Аддитивно — режим легенды наравне с «Разделы»; счётчики узлов/рёбер не меняются.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:31:26 +03:00
Дмитрий c64be74992 fix(import): final review — /import в явный список Route::view
Final review (🟢 low): SPA-маршрут /import работал через Route::fallback,
но все остальные app-маршруты перечислены явно в Route::view-блоке
(CLAUDE.md документирует явный список как намеренный паттерн — catch-all
перехватывал бы _test/* runtime-роуты Pest). /import добавлен в список
для консистентности и устойчивости.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:33:15 +03:00
Дмитрий 6a3593de7a fix(import): final review — tenant-изоляция import_unknown_statuses под BYPASSRLS
Final review нашёл: HistoricalImportService::loadStatusOverrides и
persistUnknownStatuses запрашивали import_unknown_statuses без явного
where(tenant_id), полагаясь на RLS через SET LOCAL. Но queue worker на prod
работает под crm_supplier_worker — BYPASSRLS-роль (00_create_roles.sql §5),
SET LOCAL не фильтрует → cross-tenant утечка: импорт тенанта A мог подхватить
resolved-маппинг тенанта B и инкрементировать его occurrences.

Добавлен явный where(tenant_id) в обе выборки (конвенция defense-in-depth
00_create_roles.sql:64 — WHERE-фильтры обязательны под BYPASSRLS). +тест
cross-tenant изоляции (red-green verified: без фикса 'Архив' тенанта A
получал status 'closed' из чужого маппинга).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:31:56 +03:00
Дмитрий de066145d3 feat(import): маршрут /import + сайдбар + инструкция H9
- router/index.ts: добавлен маршрут /import (name=import, layout=app,
  requiresAuth=true, transition=ld-route-fadeup, devIndex=29)
- AppSidebar.vue: пункт «Импорт данных» (mdi-database-import-outline)
  добавлен в группу «Работа» следом за Дашборд
- router.spec.ts: TDD-кейс маршрута /import (layout=app, requiresAuth=true)
- docs/Как_перенести_данные_из_crm-bp-gr.md: инструкция H9 (4 шага + таблица ошибок)
- cspell-words.txt: добавлены формы глагола «замапить»

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:14:04 +03:00
Дмитрий 96cb64f33a refactor(import): Task 10 code-review — POLL_INTERVAL_MS константа
Code-review Task 10 (🟡): магическое число 2000 (интервал polling'а) вынесено
в именованную константу POLL_INTERVAL_MS — паттерн файла (как в DashboardView).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:08:33 +03:00
Дмитрий 59dac9be56 feat(import): ImportView — экран импорта CSV
TDD: spec (3 tests) first, then component.
ImportView.vue: upload form + polling + history table + unknown-statuses banner.
Uses api/imports (uploadImport/listImports/getImport/getUnknownStatuses).
setInterval callback wrapped in named async fn (pollOnce) — no eslint-disable needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:05:15 +03:00
Дмитрий 7f05c4ab16 feat(import): api/imports.ts + UnknownStatusesDialog (wizard маппинга)
- api/imports.ts: типы ImportLogResource/UnknownStatus/StatusMapping,
  функции uploadImport/listImports/getImport/getUnknownStatuses/resolveUnknownStatuses
  (apiClient из ./client, стиль api/dashboard.ts)
- UnknownStatusesDialog.vue: wizard маппинга незамапленных статусов воронки
  (ТЗ §6.4/§6.6), 14 канонических slug, defineExpose(selection, save)
- Vitest 3/3 (tests/Frontend/UnknownStatusesDialog.spec.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:58:33 +03:00
Дмитрий 5d64ca552e test(import): Task 9 code-review — cross-tenant тест ImportController::show
Code-review Task 9 (🟡): добавлен тест защиты show() — пользователь одного
тенанта получает 403 при запросе import_log другого тенанта (покрывает
abort_if defense-in-depth в ImportController::show). phpstan-baseline
регенерирован — инкремент count ложного TestCall-срабатывания (квирк 25).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:52:31 +03:00
Дмитрий a7038367e4 feat(import): ImportController + маршруты /api/imports
Task 9 Sprint 4: ImportController с 5 методами (store/index/show/
unknownStatuses/resolveUnknownStatuses), 2 FormRequest (StoreImportRequest
/ ResolveUnknownStatusesRequest), 5 маршрутов в routes/web.php под
auth:sanctum+tenant. Defense-in-depth: явный where(tenant_id) поверх RLS
(postgres superuser обходит BYPASSRLS на dev — паттерн DealController).
Тест 8/8, Larastan baseline regen (только TestCall false positives).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:45:47 +03:00
Дмитрий 15b53a9b2b feat(import): ImportLeadsJob — queued-обработчик CSV-импорта
ShouldQueue-job: читает CSV через Storage::disk('local'), парсит через
CsvLeadsParser, импортирует через HistoricalImportService (4 аргумента),
обновляет import_log (pending→processing→done|failed), шлёт
ImportCompletedNotification. RLS через SET LOCAL в каждой транзакции.
tries=1 (идемпотентность на уровне строк, повторный прогон искажает
счётчики — авто-ретрай отключён). Larastan: 0 новых ошибок.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:31:19 +03:00
Дмитрий 952263b3e5 feat(import): Mailable ImportCompletedNotification
Task 8 — email-уведомление пользователю по завершении CSV-импорта
исторических лидов (ТЗ §6.6). Два исхода: done (счётчики строк) /
failed (сообщение об ошибке). Blade-шаблон markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:23:00 +03:00
Дмитрий 5416f809a3 fix(import): Task 6 code-review — final-класс + честное имя поля errors
Code-review Task 6 (non-blocking 🟡): HistoricalImportService объявлен final
(симметрия с ImportResult, утилитарный сервис без наследования). Ключ ошибки
upsert'а переименован 'line' → 'source_crm_id' — поле хранит идентификатор из
исходной CRM, а не файловую строку (в отличие от CsvParseResult::errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:17:59 +03:00
Дмитрий 0b9d73018d feat(import): HistoricalImportService — идемпотентный upsert лидов
Реализован HistoricalImportService с ImportResult DTO и 7 feature-тестами
(TDD). Идемпотентный upsert через pg_advisory_xact_lock + webhook_dedup_keys;
создание партиций через MonthlyPartitionManager; напоминания; unknown-статусы
с tenant-переопределениями; dry_run режим; historical_import tx без списания
баланса. Попутный fix CarbonImmutable-петли в MonthlyPartitionManager::ensureRange.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:23 +03:00
Дмитрий 29a4d01ff4 fix(import): Task 5 code-review — final-класс CsvLeadsParser + self::EXPECTED_COLUMNS
Code-review Task 5 (non-blocking 🟡): CsvLeadsParser объявлен final (симметрия
с DTO ParsedLeadRow/CsvParseResult, утилитарный класс без наследования);
строка ошибки про число колонок использует self::EXPECTED_COLUMNS вместо
литерала 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:51:27 +03:00
Дмитрий 8f2b82405a feat(import): CsvLeadsParser + DTO ParsedLeadRow/CsvParseResult
Парсер CSV-выгрузки лидов crm.bp-gr.ru (ТЗ §6.2/§6.3): срезает UTF-8 BOM,
разбирает строки через str_getcsv, валидирует телефон (7XXXXXXXXXX) и даты
(Y/m/d H:i:s), срезает префикс B[123]_ из названия проекта. Невалидные
строки не роняют парсинг — собираются в errors[] с абсолютным номером строки.
Тесты: 5/5 (unit, без DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:47:15 +03:00
Дмитрий 424987bedb feat(import): сервис StatusRuToSlugMapper (ТЗ §6.4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:42:49 +03:00
Дмитрий ef4df2925f feat(import): сервис MonthlyPartitionManager + рефактор partitions:create-months
Выносит DDL-логику создания месячных RANGE-партиций из команды
PartitionsCreateMonths в переиспользуемый сервис MonthlyPartitionManager.
Сервис используется командой (DRY) и будет использован HistoricalImportService
для партиций под исторические даты CSV.

- MonthlyPartitionManager::ensureRange(table, from, to) — гарантирует партиции
  под диапазон дат, идемпотентно; отвергает незарегистрированные таблицы
- MonthlyPartitionManager::ensureMonth(table, monthStart) — одна партиция
- PartitionsCreateMonths рефакторена: убраны PARTITIONED_TABLES, partitionExists(),
  use DB; inject MonthlyPartitionManager через handle()
- Test: MonthlyPartitionManagerTest (3 теста, DatabaseTransactions — DDL откат)
- Regression: PartitionsCreateMonthsTest (4 теста) — зелёный, поведение не изменилось

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:29 +03:00
Дмитрий 8bc8c53a3b feat(import): Eloquent-модели ImportLog + ImportUnknownStatus
- ImportLog: $attributes зеркалят DB DEFAULT'ов (status/entity_type/dry_run),
  CREATED_AT/UPDATED_AT=null (таблица использует started_at/finished_at),
  casts для mapping_config (array) и dry_run (boolean)
- ImportUnknownStatus: scope unresolved() (whereNull mapped_to_slug),
  BelongsTo tenant
- Фабрики ImportLogFactory + ImportUnknownStatusFactory
- Тест ImportModelsTest (2/2, DatabaseTransactions, idempotent)
- ide-helper:models перегенерирован под новые модели
- phpstan-baseline регенерирован (квирк 25: TestCall::$tenant/$user)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:29:33 +03:00
Дмитрий 98549c52be fix(import): Task 1 code-review — убран фантомный GRANT-блок + усилен UNIQUE-тест
Code-review Task 1: явный per-table GRANT-блок для import_unknown_statuses
использовал несуществующие роли (crm_app_admin / crm_readonly). Реальные роли —
crm_app_user / crm_admin_user / crm_migrator / crm_audit_writer /
crm_supplier_worker (db/00_create_roles.sql). Блок удалён целиком из
db/02_grants.sql и db/schema.sql: import_unknown_statuses — обычная
tenant-scoped таблица, покрыта umbrella GRANT ... ON ALL TABLES +
ALTER DEFAULT PRIVILEGES (как import_log), явный per-table grant не нужен.

ImportSchemaTest: UNIQUE-тест усилен — проверяет состав колонок
(status_ru, tenant_id), а не только наличие constraint'а типа 'u'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:20:12 +03:00
Дмитрий 70f8b210f4 feat(import): H1+H2 — схема import_unknown_statuses + enrichment import_log
Sprint 4 Task 1 (schema delta §6):
- H1: новая таблица import_unknown_statuses (RLS tenant_isolation,
  UNIQUE(tenant_id,status_ru), FK→tenants/import_log/lead_statuses/users)
- H2: +5 колонок import_log (entity_type, source_system, mapping_config,
  unknown_statuses_count, dry_run)
- schema.sql v8.20→v8.21 (64 таблицы / 118 индексов / 40 RLS-политик)
- db/CHANGELOG_schema.md v8.21 entry
- db/02_grants.sql v8.21 section (crm_app_user/crm_app_admin/crm_readonly)
- migrate: hasTable/hasColumn guards (fresh-safe)
- tests: 3 Pest-теста (ImportSchemaTest) + SchemaDeltaTest v8.21 metrics
- ide-helper: _ide_helper.php + _ide_helper_models.php (были отсутствуют
  в worktree, phpstan падал молча из-за missing scanFiles entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:01:51 +03:00
Дмитрий 4937225da3 docs(plan): Sprint 4 — историческая миграция лидов §6 (H1-H6/H8/H9)
План CSV-импорта исторических лидов из crm.bp-gr.ru. 12 задач: schema delta
(import_unknown_statuses + enrichment import_log), сервисы парсинга/маппинга/
upsert'а, ImportLeadsJob, ImportController, frontend ImportView + wizard
маппинга статусов, маршрут /import + инструкция H9. H7 (импорт проектов)
вынесен — формат CSV проектов не специфицирован в ТЗ §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:28:30 +03:00
Дмитрий da4d46b0d8 feat(map): automation-graph — полная актуализация по аудиту
Аудит карты против фактического состояния (~/.claude/settings.json,
project .claude/settings.json, .mcp.json, lefthook.yml, .claude/skills,
memory/). +20 узлов (83 → 103):
- плагины 5→9: +skill-creator, claude-code-setup, plugin-dev, context7
- хуки 5→12: +economy-self-check/skill-marker/skill-check/state-guard/
  postcompact/verifier (Stop) + ruflo-queen-hook
- memory 16→24: +audit_B/C, supplier_crm, full_audit_05-12/14, sprint1/2/3
- скилы проекта 2→3: +regression
Квирк 72 устранён (commit 0fa1a73) — 2 конфликта переоценены:
ag_pest↔mcp_redis BLACK→GREEN; ruflo_daemon↔ag_pest → квирки 73/77.
Все 103 узла размечены по разделам; E8 «Самообучение Claude» наполнен
(skill-creator, claude-code-setup). Топология 103 / 106 рёбер /
11 конфликтов (🔴1/3/🟢7). Smoke ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:08:07 +03:00
Дмитрий f9f9fec97d feat(map): automation-graph — раздел E8 «Самообучение Claude»
+1 раздел в блок E «Мета и управление». Итого 40 разделов
(13 наполнены / 27 пусты). E8 — пустой каркас под будущий playbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:08:07 +03:00
Дмитрий e74e8aa6d6 feat(map): automation-graph — слой функциональных разделов (iter7)
39 разделов деятельности Лидерры (5 блоков A–E) как классификация:
все 83 узла распределены по разделам — 13 наполнены, 26 пусты
(пустые — бизнес-домены, под которые в карте dev-автоматики узлов
ещё нет). Кнопка-панель «📂 Разделы» + строка «Раздел» в Паспорте
узла. Топология карты (83/90/11) и радиальный layout без изменений.
Основа будущего «мозга»: 1 раздел = 1 playbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:08:07 +03:00
57 changed files with 7486 additions and 68 deletions
+26
View File
@@ -56,3 +56,29 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
## Демо-данные (dev)
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
`php artisan migrate --seed` в окружениях `local` и `testing`
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
**Учётные данные демо-входа:**
- URL: `/login`
- Email: `admin@demo.local`
- Пароль: `password`
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
**Пере-сидировать демо-данные** (идемпотентно — повторный запуск не создаёт дублей):
```bash
composer demo:seed
```
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\MonthlyPartitionManager;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
@@ -30,14 +30,7 @@ class PartitionsCreateMonths extends Command
/** @var string */
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
/**
* Список таблиц, которые партиционируются по received_at помесячно.
*
* @var array<int, string>
*/
private const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
public function handle(): int
public function handle(MonthlyPartitionManager $manager): int
{
$ahead = max(1, (int) $this->option('ahead'));
$now = Carbon::now()->startOfMonth();
@@ -47,27 +40,17 @@ class PartitionsCreateMonths extends Command
for ($i = 0; $i <= $ahead; $i++) {
$monthStart = $now->copy()->addMonths($i);
$monthEnd = $monthStart->copy()->addMonth();
foreach (self::PARTITIONED_TABLES as $table) {
foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
if ($this->partitionExists($partitionName)) {
if ($manager->ensureMonth($table, $monthStart)) {
$created++;
$this->info(" create <fg=green>{$partitionName}</>");
} else {
$skipped++;
$this->line(" skip <fg=gray>{$partitionName}</> (already exists)");
continue;
}
DB::statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partitionName,
$table,
$monthStart->format('Y-m-d'),
$monthEnd->format('Y-m-d'),
));
$created++;
$this->info(" create <fg=green>{$partitionName}</> [{$monthStart->format('Y-m-d')}{$monthEnd->format('Y-m-d')})");
}
}
@@ -76,17 +59,4 @@ class PartitionsCreateMonths extends Command
return self::SUCCESS;
}
/**
* Проверка существования партиции через pg_class (быстрее information_schema).
*/
private function partitionExists(string $name): bool
{
$row = DB::selectOne(
"SELECT 1 AS exists FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$name],
);
return $row !== null;
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ResolveUnknownStatusesRequest;
use App\Http\Requests\StoreImportRequest;
use App\Jobs\ImportLeadsJob;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* CSV-импорт исторических лидов из crm.bp-gr.ru (ТЗ §6).
*
* Все маршруты под auth:sanctum + tenant (RLS-контекст задан middleware).
* tenant_id берётся из авторизованного пользователя, не из запроса.
*/
class ImportController extends Controller
{
/**
* POST /api/imports загрузка CSV, создание import_log, dispatch job'а.
*/
public function store(StoreImportRequest $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$file = $request->file('file');
$storedName = Str::uuid()->toString().'.csv';
$path = $file->storeAs("imports/{$tenantId}", $storedName, 'local');
$log = ImportLog::create([
'tenant_id' => $tenantId,
'user_id' => $request->user()->id,
'filename' => $file->getClientOriginalName(),
'file_path' => $path,
'status' => 'pending',
'entity_type' => 'leads',
'source_system' => 'crm.bp-gr.ru',
'dry_run' => $request->boolean('dry_run'),
]);
ImportLeadsJob::dispatch($log->id, $tenantId);
return response()->json(['data' => $this->toResource($log)], 201);
}
/**
* GET /api/imports история импортов тенанта (RLS отфильтрует по tenant).
*
* Defense-in-depth: явный where(tenant_id) поверх RLS на dev через
* `postgres` superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
* изоляцию (паттерн из DealController).
*/
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$logs = ImportLog::query()
->where('tenant_id', $tenantId)
->orderByDesc('id')
->limit(50)
->get()
->map(fn (ImportLog $log) => $this->toResource($log));
return response()->json(['data' => $logs]);
}
/**
* GET /api/imports/{importLog} прогресс одного импорта (для polling'а).
*
* Defense-in-depth: явная проверка tenant_id на принадлежность поверх RLS.
*/
public function show(Request $request, ImportLog $importLog): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
abort_if($importLog->tenant_id !== $tenantId, 403, 'Доступ к импорту другого тенанта запрещён.');
return response()->json(['data' => $this->toResource($importLog)]);
}
/**
* GET /api/imports/unknown-statuses незамапленные статусы (вход wizard'а §6.6).
*
* Defense-in-depth: явный where(tenant_id) поверх RLS.
*/
public function unknownStatuses(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$rows = ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->unresolved()
->orderByDesc('occurrences')
->get()
->map(fn (ImportUnknownStatus $s) => [
'id' => $s->id,
'status_ru' => $s->status_ru,
'occurrences' => $s->occurrences,
]);
return response()->json(['data' => $rows]);
}
/**
* POST /api/imports/unknown-statuses/resolve ручной маппинг статусов.
*
* Defense-in-depth: явный where(tenant_id) поверх RLS.
*/
public function resolveUnknownStatuses(ResolveUnknownStatusesRequest $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$userId = (int) $request->user()->id;
DB::transaction(function () use ($request, $tenantId, $userId): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
foreach ($request->validated()['mappings'] as $mapping) {
ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->where('status_ru', $mapping['status_ru'])
->update([
'mapped_to_slug' => $mapping['slug'],
'resolved_at' => now(),
'resolved_by' => $userId,
]);
}
});
return response()->json(['data' => ['resolved' => count($request->validated()['mappings'])]]);
}
/**
* @return array<string, mixed>
*/
private function toResource(ImportLog $log): array
{
return [
'id' => $log->id,
'filename' => $log->filename,
'status' => $log->status,
'rows_total' => $log->rows_total,
'rows_added' => $log->rows_added,
'rows_updated' => $log->rows_updated,
'rows_skipped' => $log->rows_skipped,
'unknown_statuses_count' => $log->unknown_statuses_count,
'dry_run' => $log->dry_run,
'error_message' => $log->error_message,
'started_at' => $log->started_at?->toIso8601String(),
'finished_at' => $log->finished_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Валидация ручного маппинга неизвестных статусов воронки (§6.4 wizard).
*/
class ResolveUnknownStatusesRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'mappings' => ['required', 'array', 'min:1'],
'mappings.*.status_ru' => ['required', 'string', 'max:100'],
'mappings.*.slug' => ['required', 'string', Rule::exists('lead_statuses', 'slug')],
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация загрузки CSV-файла импорта (ТЗ §6.2).
*/
class StoreImportRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// mimes csv,txt — экспорт crm.bp-gr.ru отдаётся как text/csv или text/plain.
'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
'dry_run' => ['sometimes', 'boolean'],
];
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Mail\ImportCompletedNotification;
use App\Models\ImportLog;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
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 Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
/**
* Асинхронная обработка CSV-импорта исторических лидов (ТЗ §6.6).
*
* Жизненный цикл import_log: pending processing done | failed.
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
* вне middleware-контекста паритет с ProcessWebhookJob).
*/
class ImportLeadsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 600;
public function __construct(
public int $importLogId,
public int $tenantId,
) {}
public function handle(HistoricalImportService $service, CsvLeadsParser $parser): void
{
$log = $this->loadLog();
if ($log === null) {
Log::error('import.log_not_found', ['import_log_id' => $this->importLogId]);
return;
}
$this->updateLog($log->id, ['status' => 'processing', 'started_at' => now()]);
try {
if (! Storage::disk('local')->exists($log->file_path)) {
throw new RuntimeException("Файл импорта не найден: {$log->file_path}");
}
$content = (string) Storage::disk('local')->get($log->file_path);
$parsed = $parser->parse($content);
$result = $service->import($this->tenantId, $log->user_id, $log, $parsed->rows);
$this->updateLog($log->id, [
'status' => 'done',
'rows_total' => count($parsed->rows) + count($parsed->errors),
'rows_added' => $result->added,
'rows_updated' => $result->updated,
'rows_skipped' => count($parsed->errors) + $result->skipped,
'unknown_statuses_count' => count($result->unknownStatuses),
'finished_at' => now(),
]);
$this->notify($log->user_id, 'done');
} catch (Throwable $e) {
Log::error('import.job_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
$this->updateLog($log->id, [
'status' => 'failed',
'error_message' => $e->getMessage(),
'finished_at' => now(),
]);
$this->notify($log->user_id, 'failed');
}
}
private function loadLog(): ?ImportLog
{
return DB::transaction(function (): ?ImportLog {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
return ImportLog::query()->find($this->importLogId);
});
}
/**
* @param array<string, mixed> $attributes
*/
private function updateLog(int $logId, array $attributes): void
{
DB::transaction(function () use ($logId, $attributes): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
ImportLog::query()->whereKey($logId)->update($attributes);
});
}
private function notify(int $userId, string $outcome): void
{
$log = $this->loadLog();
$user = DB::transaction(function () use ($userId): ?User {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
return User::query()->find($userId);
});
if ($log === null || $user === null || $user->email === '') {
return;
}
try {
Mail::to($user->email)->send(new ImportCompletedNotification($log, $outcome));
} catch (Throwable $e) {
// Отказ почтового канала не должен валить успешный импорт.
Log::warning('import.mail_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
}
}
/**
* Финальный callback после исчерпания ретраев ($tries=1).
*/
public function failed(Throwable $e): void
{
$this->updateLog($this->importLogId, [
'status' => 'failed',
'error_message' => $e->getMessage(),
'finished_at' => now(),
]);
Log::error('import.job_failed_permanently', [
'import_log_id' => $this->importLogId,
'exception' => $e->getMessage(),
]);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\ImportLog;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Уведомление о завершении CSV-импорта исторических лидов (ТЗ §6.6).
*/
class ImportCompletedNotification extends Mailable
{
use Queueable;
use SerializesModels;
/**
* @param string $outcome 'done' | 'failed'
*/
public function __construct(
public ImportLog $log,
public string $outcome,
) {}
public function envelope(): Envelope
{
$subject = $this->outcome === 'done'
? 'Импорт данных завершён — Лидерра'
: 'Импорт данных не удался — Лидерра';
return new Envelope(subject: $subject);
}
public function content(): Content
{
return new Content(
markdown: 'mail.import-completed',
with: [
'log' => $this->log,
'outcome' => $this->outcome,
],
);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ImportLogFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Журнал CSV-импорта (schema §6.7, Sprint 4).
*
* Tenant-aware модель с RLS: tenant_isolation по current_setting('app.current_tenant_id').
* Sprint 4 enrichment: entity_type / source_system / mapping_config / unknown_statuses_count / dry_run.
*
* @mixin IdeHelperImportLog
*/
class ImportLog extends Model
{
/** @use HasFactory<ImportLogFactory> */
use HasFactory;
public const UPDATED_AT = null;
public const CREATED_AT = null;
protected $table = 'import_log';
/** Зеркало DB DEFAULT'ов: Laravel не читает их из БД после INSERT без refresh(). */
protected $attributes = [
'status' => 'pending',
'entity_type' => 'leads',
'source_system' => 'crm.bp-gr.ru',
'dry_run' => false,
'unknown_statuses_count' => 0,
'rows_total' => 0,
'rows_added' => 0,
'rows_updated' => 0,
'rows_skipped' => 0,
];
protected $fillable = [
'tenant_id',
'user_id',
'filename',
'file_path',
'rows_total',
'rows_added',
'rows_updated',
'rows_skipped',
'status',
'error_message',
'started_at',
'finished_at',
'entity_type',
'source_system',
'mapping_config',
'unknown_statuses_count',
'dry_run',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'user_id' => 'integer',
'rows_total' => 'integer',
'rows_added' => 'integer',
'rows_updated' => 'integer',
'rows_skipped' => 'integer',
'unknown_statuses_count' => 'integer',
'dry_run' => 'boolean',
'mapping_config' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ImportUnknownStatusFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Неизвестный статус воронки из CSV-импорта (schema §6.4, Sprint 4 H1).
*
* Tenant-aware модель с RLS. UNIQUE (tenant_id, status_ru): повторный импорт
* инкрементит occurrences и переиспользует ранее проставленный mapped_to_slug.
*
* @mixin IdeHelperImportUnknownStatus
*/
class ImportUnknownStatus extends Model
{
/** @use HasFactory<ImportUnknownStatusFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'import_log_id',
'status_ru',
'occurrences',
'mapped_to_slug',
'resolved_at',
'resolved_by',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'import_log_id' => 'integer',
'occurrences' => 'integer',
'resolved_by' => 'integer',
'resolved_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/**
* Незамапленные статусы (mapped_to_slug IS NULL) вход для wizard'а §6.6.
*
* @param Builder<ImportUnknownStatus> $query
* @return Builder<ImportUnknownStatus>
*/
public function scopeUnresolved(Builder $query): Builder
{
return $query->whereNull('mapped_to_slug');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use Carbon\CarbonImmutable;
use Throwable;
/**
* Парсер CSV-выгрузки лидов из crm.bp-gr.ru (ТЗ §6.2/§6.3).
*
* Формат: UTF-8 с BOM, разделитель запятая, дата `Y/m/d H:i:s`,
* телефон `7XXXXXXXXXX`. Заголовок:
* id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
*
* Невалидные строки не роняют парсинг собираются в errors[].
* Файл целиком загружается в память (MVP: ожидаемый объём единицы тысяч строк).
*/
final class CsvLeadsParser
{
private const EXPECTED_COLUMNS = 9;
private const DATE_FORMAT = 'Y/m/d H:i:s';
public function parse(string $content): CsvParseResult
{
// Срезаем UTF-8 BOM.
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
}
$lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: [];
$rows = [];
$errors = [];
// Строка 1 — заголовок, пропускаем. dataLine — абсолютный номер строки файла (заголовок = 1).
foreach (array_slice($lines, 1) as $index => $rawLine) {
$dataLine = $index + 2; // +2: пропущен заголовок (index 0 → строка 2)
if (trim($rawLine) === '') {
continue;
}
$cells = str_getcsv($rawLine);
if (count($cells) < self::EXPECTED_COLUMNS) {
$errors[] = ['line' => $dataLine, 'message' => 'Ожидалось '.self::EXPECTED_COLUMNS.' колонок, получено '.count($cells)];
continue;
}
$parsed = $this->parseRow($cells, $dataLine, $errors);
if ($parsed !== null) {
$rows[] = $parsed;
}
}
return new CsvParseResult($rows, $errors);
}
/**
* @param array<int, string> $cells
* @param array<int, array{line: int, message: string}> $errors
*/
private function parseRow(array $cells, int $dataLine, array &$errors): ?ParsedLeadRow
{
[$id, $project, $tag, $phone, $createdAt, $reminder, $comment, $status, $name] = $cells;
$phone = trim($phone);
if (preg_match('/^7\d{10}$/', $phone) !== 1) {
$errors[] = ['line' => $dataLine, 'message' => "Невалидный телефон: '{$phone}'"];
return null;
}
$receivedAt = $this->parseDate($createdAt);
if ($receivedAt === null) {
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Создано': '{$createdAt}'"];
return null;
}
$reminderAt = trim($reminder) === '' ? null : $this->parseDate($reminder);
if (trim($reminder) !== '' && $reminderAt === null) {
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Напоминание': '{$reminder}'"];
return null;
}
$status = trim($status);
if ($status === '') {
$errors[] = ['line' => $dataLine, 'message' => 'Пустое поле «Состояние»'];
return null;
}
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
if ($projectName === '') {
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
return null;
}
return new ParsedLeadRow(
sourceCrmId: (int) trim($id),
projectName: $projectName,
projectTag: trim($tag) === '' ? null : trim($tag),
phone: $phone,
receivedAt: $receivedAt,
reminderAt: $reminderAt,
comment: trim($comment) === '' ? null : trim($comment),
statusRu: $status,
contactName: trim($name) === '' ? null : trim($name),
);
}
private function parseDate(string $value): ?CarbonImmutable
{
try {
$date = CarbonImmutable::createFromFormat(self::DATE_FORMAT, trim($value));
} catch (Throwable) {
return null;
}
// createFromFormat возвращает false при несовпадении формата.
return $date instanceof CarbonImmutable ? $date : null;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
/**
* Результат парсинга CSV: валидные строки + ошибки по номеру строки.
*/
final readonly class CsvParseResult
{
/**
* @param array<int, ParsedLeadRow> $rows
* @param array<int, array{line: int, message: string}> $errors
*/
public function __construct(
public array $rows,
public array $errors,
) {}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Project;
use App\Models\Reminder;
use App\Services\MonthlyPartitionManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Оркестрация исторической миграции лидов из CSV crm.bp-gr.ru (ТЗ §6).
*
* Идемпотентность через webhook_dedup_keys (та же advisory-lock логика, что
* ProcessWebhookJob). Баланс НЕ списывается: исторические данные не являются
* новыми лидами (ТЗ §6.5) фиксируется одна транзакция типа historical_import.
*/
final class HistoricalImportService
{
public function __construct(
private readonly MonthlyPartitionManager $partitions,
private readonly StatusRuToSlugMapper $statusMapper,
) {}
/**
* @param array<int, ParsedLeadRow> $rows
*/
public function import(int $tenantId, int $userId, ImportLog $log, array $rows): ImportResult
{
$dryRun = $log->dry_run;
if ($rows === []) {
return new ImportResult(0, 0, 0, [], []);
}
// Партиции deals под исторический диапазон дат CSV (один раз заранее).
if (! $dryRun) {
$dates = array_map(fn (ParsedLeadRow $r) => $r->receivedAt, $rows);
$this->partitions->ensureRange(
'deals',
min($dates),
max($dates),
);
}
// Tenant-резолвленные переопределения неизвестных статусов.
$overrides = $this->loadStatusOverrides($tenantId);
$added = 0;
$updated = 0;
$skipped = 0;
$unknown = [];
$errors = [];
foreach ($rows as $row) {
$slug = $this->resolveStatus($row->statusRu, $overrides, $unknown);
if ($dryRun) {
$added++; // проекция: для dry-run не различаем add/update
continue;
}
try {
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
$wasCreated ? $added++ : $updated++;
} catch (Throwable $e) {
$skipped++;
$errors[] = ['source_crm_id' => $row->sourceCrmId, 'message' => $e->getMessage()];
Log::warning('import.row_failed', ['source_crm_id' => $row->sourceCrmId, 'error' => $e->getMessage()]);
}
}
if (! $dryRun) {
$this->persistUnknownStatuses($tenantId, $log->id, $unknown);
$this->recordHistoricalTransaction($tenantId, $added + $updated);
}
return new ImportResult($added, $updated, $skipped, $unknown, $errors);
}
/**
* @return array<string, string> status_ru => slug (только resolved)
*/
private function loadStatusOverrides(int $tenantId): array
{
return DB::transaction(function () use ($tenantId): array {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Явный where(tenant_id) — defense-in-depth: queue worker на prod
// (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует
// (00_create_roles.sql §5). Без фильтра — cross-tenant утечка маппинга.
return ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->whereNotNull('mapped_to_slug')
->pluck('mapped_to_slug', 'status_ru')
->all();
});
}
/**
* Маппит статус: каноническая таблица §6.4 tenant-override fallback 'new'.
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
*
* @param array<string, string> $overrides
* @param array<string, int> $unknown
*/
private function resolveStatus(string $statusRu, array $overrides, array &$unknown): string
{
$slug = $this->statusMapper->toSlug($statusRu);
if ($slug !== null) {
return $slug;
}
$key = trim($statusRu);
if (isset($overrides[$key])) {
return $overrides[$key];
}
$unknown[$key] = ($unknown[$key] ?? 0) + 1;
return 'new';
}
/**
* Идемпотентный upsert одной строки в собственной транзакции.
* Возвращает true создана новая сделка, false обновлена существующая.
*/
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
{
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$project = Project::firstOrCreate(
['tenant_id' => $tenantId, 'name' => $row->projectName],
['tag' => $row->projectTag, 'type' => 'import'],
);
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5).
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
$existing = DB::selectOne(
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
[$tenantId, $row->sourceCrmId],
);
if ($existing !== null) {
$deal = Deal::query()
->where('id', $existing->deal_id)
->where('received_at', $existing->deal_received_at)
->firstOrFail();
// §6.5 стадия 3a: для исторической миграции status перезаписывается.
$deal->update([
'status' => $slug,
'contact_name' => $row->contactName,
'comment' => $row->comment,
]);
$this->syncReminder($tenantId, $userId, $deal, $row);
return false;
}
$deal = Deal::create([
'tenant_id' => $tenantId,
'source_crm_id' => $row->sourceCrmId,
'project_id' => $project->id,
'phone' => $row->phone,
'status' => $slug,
'contact_name' => $row->contactName,
'comment' => $row->comment,
'received_at' => $row->receivedAt,
]);
DB::table('webhook_dedup_keys')->insert([
'tenant_id' => $tenantId,
'source_crm_id' => $row->sourceCrmId,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'created_at' => now(),
]);
$this->syncReminder($tenantId, $userId, $deal, $row);
return true;
});
}
/**
* Создаёт reminders-строку для непустого «Напоминание» (ТЗ §6.3 поле
* deals.reminder_at удалено в v8.3, заменено таблицей reminders).
* Идемпотентно: не дублирует напоминание при повторном импорте.
*/
private function syncReminder(int $tenantId, int $userId, Deal $deal, ParsedLeadRow $row): void
{
if ($row->reminderAt === null) {
return;
}
$exists = Reminder::query()
->where('deal_id', $deal->id)
->where('remind_at', $row->reminderAt)
->exists();
if ($exists) {
return;
}
Reminder::create([
'tenant_id' => $tenantId,
'deal_id' => $deal->id,
'text' => 'Импортировано из crm.bp-gr.ru',
'remind_at' => $row->reminderAt,
'created_by' => $userId,
]);
}
/**
* upsert import_unknown_statuses: инкремент occurrences, маппинг не трогаем.
*
* @param array<string, int> $unknown
*/
private function persistUnknownStatuses(int $tenantId, int $importLogId, array $unknown): void
{
if ($unknown === []) {
return;
}
DB::transaction(function () use ($tenantId, $importLogId, $unknown): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
foreach ($unknown as $statusRu => $count) {
// Явный where(tenant_id) — defense-in-depth под BYPASSRLS queue worker
// (00_create_roles.sql §5): иначе increment мог бы попасть в строку
// чужого тенанта с тем же status_ru.
$existing = ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->where('status_ru', $statusRu)
->first();
if ($existing !== null) {
$existing->increment('occurrences', $count);
continue;
}
ImportUnknownStatus::create([
'tenant_id' => $tenantId,
'import_log_id' => $importLogId,
'status_ru' => $statusRu,
'occurrences' => $count,
]);
}
});
}
/**
* Одна информационная транзакция historical_import (баланс не меняется, ТЗ §6.5).
*/
private function recordHistoricalTransaction(int $tenantId, int $count): void
{
if ($count === 0) {
return;
}
DB::transaction(function () use ($tenantId, $count): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenantId,
'type' => 'historical_import',
'amount_rub' => 0,
'amount_leads' => 0,
'description' => "Импортировано {$count} исторических сделок (баланс не списан)",
'created_at' => now(),
]);
});
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
/**
* Итог импорта одного файла.
*/
final readonly class ImportResult
{
/**
* @param array<string, int> $unknownStatuses статус_ru => количество вхождений
* @param array<int, array{source_crm_id: int, message: string}> $errors ошибки upsert'а по строке (идентификатор source_crm_id)
*/
public function __construct(
public int $added,
public int $updated,
public int $skipped,
public array $unknownStatuses,
public array $errors,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use Carbon\CarbonImmutable;
/**
* Одна валидная строка CSV-импорта лидов (ТЗ §6.3).
*/
final readonly class ParsedLeadRow
{
public function __construct(
public int $sourceCrmId,
public string $projectName,
public ?string $projectTag,
public string $phone,
public CarbonImmutable $receivedAt,
public ?CarbonImmutable $reminderAt,
public ?string $comment,
public string $statusRu,
public ?string $contactName,
) {}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
/**
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
*
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
*/
class StatusRuToSlugMapper
{
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
private const STATUS_RU_TO_SLUG = [
'Новые' => 'new',
'Просмотрено' => 'viewed',
'Проработан' => 'worked',
'База' => 'base',
'Недозвон' => 'missed',
'Переговоры' => 'negotiations',
'Ожидаем оплаты' => 'waiting_payment',
'Партнерка' => 'partnership',
'Оплачено' => 'paid',
'Закрыто и не реализовано' => 'closed',
'Тест драйв' => 'test_drive',
'Горячий' => 'hot',
'На замену' => 'replacement',
'Конечный недозвон' => 'final_missed',
];
/**
* Возвращает slug или null, если статус не входит в каноническую таблицу.
*/
public function toSlug(string $statusRu): ?string
{
return self::STATUS_RU_TO_SLUG[trim($statusRu)] ?? null;
}
/**
* Полная каноническая таблица для UI wizard'а (показать варианты).
*
* @return array<string, string>
*/
public function map(): array
{
return self::STATUS_RU_TO_SLUG;
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
*
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
*
* Используется:
* - cron `partitions:create-months` N месяцев вперёд;
* - HistoricalImportService под исторический диапазон дат CSV.
*/
class MonthlyPartitionManager
{
/** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
/**
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
* пересекающих [$from, $to] включительно.
*
* @return int Сколько партиций реально создано (0 все уже были).
*/
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
{
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
}
$month = $from->copy()->startOfMonth();
$last = $to->copy()->startOfMonth();
$created = 0;
while ($month->lessThanOrEqualTo($last)) {
$created += $this->ensureMonth($table, $month) ? 1 : 0;
$month = $month->addMonth();
}
return $created;
}
/**
* Создаёт одну месячную партицию. Возвращает true, если партиция создана,
* false если уже существовала.
*/
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
{
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
}
$start = $monthStart->copy()->startOfMonth();
$end = $start->copy()->addMonth();
$partition = sprintf('%s_%s', $table, $start->format('Y_m'));
$exists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$partition],
);
if ($exists !== null) {
return false;
}
DB::statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
$table,
$start->format('Y-m-d'),
$end->format('Y-m-d'),
));
return true;
}
}
+1
View File
@@ -65,6 +65,7 @@
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
"audit-offline": "@composer audit --locked",
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
"ide-helper": [
"@php artisan ide-helper:generate",
"@php artisan ide-helper:meta"
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ImportLog> */
class ImportLogFactory extends Factory
{
protected $model = ImportLog::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'filename' => 'leads-export.csv',
'file_path' => 'imports/1/'.$this->faker->uuid().'.csv',
'status' => 'pending',
'entity_type' => 'leads',
'source_system' => 'crm.bp-gr.ru',
'dry_run' => false,
];
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ImportUnknownStatus> */
class ImportUnknownStatusFactory extends Factory
{
protected $model = ImportUnknownStatus::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'status_ru' => $this->faker->unique()->word(),
'occurrences' => $this->faker->numberBetween(1, 20),
'mapped_to_slug' => null,
];
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Sprint 4 (H1+H2) историческая миграция лидов §6.
*
* H1: новая таблица import_unknown_statuses (tenant-level resolved mappings).
* H2: enrichment import_log +5 колонок.
*
* Guard'ы: migrate:fresh грузит schema.sql v8.21+ (где delta уже есть) до миграций,
* поэтому каждый кусок применяется только при отсутствии.
*/
return new class extends Migration
{
public function up(): void
{
foreach ([
'entity_type' => "ALTER TABLE import_log ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'leads' CHECK (entity_type IN ('leads','projects'))",
'source_system' => "ALTER TABLE import_log ADD COLUMN source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru'",
'mapping_config' => 'ALTER TABLE import_log ADD COLUMN mapping_config JSONB',
'unknown_statuses_count' => 'ALTER TABLE import_log ADD COLUMN unknown_statuses_count INT NOT NULL DEFAULT 0',
'dry_run' => 'ALTER TABLE import_log ADD COLUMN dry_run BOOLEAN NOT NULL DEFAULT FALSE',
] as $column => $ddl) {
if (! Schema::hasColumn('import_log', $column)) {
DB::statement($ddl);
}
}
if (! Schema::hasTable('import_unknown_statuses')) {
DB::statement(<<<'SQL'
CREATE TABLE import_unknown_statuses (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
status_ru VARCHAR(100) NOT NULL,
occurrences INT NOT NULL DEFAULT 0,
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
resolved_at TIMESTAMPTZ,
resolved_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
UNIQUE (tenant_id, status_ru)
)
SQL);
DB::statement(
'CREATE INDEX idx_import_unknown_statuses_unresolved
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL'
);
DB::statement('ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY');
DB::statement(
"CREATE POLICY tenant_isolation ON import_unknown_statuses
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"
);
}
}
public function down(): void
{
// down() не симметричен: на проекте rollback применяется только после
// migrate:fresh (см. add_archived_at_to_projects). Для отката v8.21 —
// отдельный schema-bump, не эта миграция.
DB::statement('DROP TABLE IF EXISTS import_unknown_statuses');
foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
if (Schema::hasColumn('import_log', $column)) {
Schema::table('import_log', fn ($table) => $table->dropColumn($column));
}
}
}
};
+10
View File
@@ -14,6 +14,16 @@ class DemoSeeder extends Seeder
{
public function run(): void
{
// DemoSeeder создаёт демо-данные и НЕ должен исполняться в production.
// DatabaseSeeder вызывает его только в local/testing — этот guard
// дополнительно защищает прямой вызов `db:seed --class=DemoSeeder`
// (в т.ч. через `composer demo:seed`).
if (app()->isProduction()) {
$this->command->warn('DemoSeeder пропущен: запрещён в production.');
return;
}
$tenant = Tenant::query()->where('subdomain', 'demo')->first()
?? Tenant::factory()->create([
'subdomain' => 'demo',
+84
View File
@@ -1008,6 +1008,90 @@ parameters:
count: 17
path: tests/Feature/ImpersonationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#'
identifier: property.notFound
count: 10
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 23
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 20
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportCompletedNotificationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportCompletedNotificationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 12
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 9
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 4
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 4
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
+66
View File
@@ -0,0 +1,66 @@
import { apiClient } from './client';
/**
* API-клиент исторической миграции лидов (ТЗ §6).
* Эндпоинты: POST/GET /api/imports, /api/imports/unknown-statuses, /api/imports/unknown-statuses/resolve.
*/
export interface ImportLogResource {
id: number;
filename: string;
status: 'pending' | 'processing' | 'done' | 'failed';
rows_total: number;
rows_added: number;
rows_updated: number;
rows_skipped: number;
unknown_statuses_count: number;
dry_run: boolean;
error_message: string | null;
started_at: string | null;
finished_at: string | null;
}
export interface UnknownStatus {
id: number;
status_ru: string;
occurrences: number;
}
export interface StatusMapping {
status_ru: string;
slug: string;
}
/** POST /api/imports — загрузить CSV. */
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
const form = new FormData();
form.append('file', file);
if (dryRun) {
form.append('dry_run', '1');
}
const { data } = await apiClient.post<{ data: ImportLogResource }>('/api/imports', form);
return data.data;
}
/** GET /api/imports — история импортов. */
export async function listImports(): Promise<ImportLogResource[]> {
const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports');
return data.data;
}
/** GET /api/imports/{id} — прогресс одного импорта. */
export async function getImport(id: number): Promise<ImportLogResource> {
const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
return data.data;
}
/** GET /api/imports/unknown-statuses — незамапленные статусы. */
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
const { data } = await apiClient.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
return data.data;
}
/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings });
}
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
*
* Для каждого незамапленного русского статуса пользователь выбирает один из
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
const props = defineProps<{
modelValue: boolean;
statuses: UnknownStatus[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
resolved: [];
}>();
/** 14 канонических статусов воронки (ТЗ §6.4). */
const STATUS_OPTIONS: { value: string; title: string }[] = [
{ value: 'new', title: 'Новые' },
{ value: 'viewed', title: 'Просмотрено' },
{ value: 'worked', title: 'Проработан' },
{ value: 'base', title: 'База' },
{ value: 'missed', title: 'Недозвон' },
{ value: 'negotiations', title: 'Переговоры' },
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
{ value: 'partnership', title: 'Партнерка' },
{ value: 'paid', title: 'Оплачено' },
{ value: 'closed', title: 'Закрыто и не реализовано' },
{ value: 'test_drive', title: 'Тест драйв' },
{ value: 'hot', title: 'Горячий' },
{ value: 'replacement', title: 'На замену' },
{ value: 'final_missed', title: 'Конечный недозвон' },
];
const selection = reactive<Record<string, string | null>>({});
const saving = ref(false);
const error = ref<string | null>(null);
const dialogOpen = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const allMapped = computed(
() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
);
async function save(): Promise<void> {
if (!allMapped.value) {
return;
}
saving.value = true;
error.value = null;
try {
const mappings: StatusMapping[] = props.statuses.map((s) => ({
status_ru: s.status_ru,
slug: selection[s.status_ru] as string,
}));
await resolveUnknownStatuses(mappings);
emit('resolved');
} catch {
error.value = 'Не удалось сохранить маппинг. Повторите попытку.';
} finally {
saving.value = false;
}
}
defineExpose({ selection, save });
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="640">
<v-card>
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
Эти статусы из CSV не входят в стандартную воронку. Выберите
соответствие повторный импорт применит маппинг автоматически.
</p>
<div
v-for="status in statuses"
:key="status.id"
class="d-flex align-center ga-3 mb-3"
>
<div class="flex-grow-1">
<strong>{{ status.status_ru }}</strong>
<span class="text-caption text-medium-emphasis ml-2">
({{ status.occurrences }} шт.)
</span>
</div>
<v-select
v-model="selection[status.status_ru]"
:items="STATUS_OPTIONS"
label="Статус воронки"
density="compact"
variant="outlined"
hide-details
style="max-width: 280px"
/>
</div>
<v-alert v-if="error" type="error" variant="tonal" class="mt-2">
{{ error }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialogOpen = false">Отмена</v-btn>
<v-btn
data-test="save-mappings"
color="primary"
variant="flat"
:loading="saving"
:disabled="!allMapped"
@click="save"
>
Сохранить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -35,6 +35,7 @@ const navGroups = computed<NavGroup[]>(() => [
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
],
},
{
+13
View File
@@ -180,6 +180,19 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Напоминания',
},
},
{
path: '/import',
name: 'import',
component: () => import('../views/ImportView.vue'),
meta: {
layout: 'app',
title: 'Импорт данных',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 29,
devLabel: 'Импорт данных',
},
},
// Админка SaaS — отдельный layout с под-брендом ADMIN.
// TODO: дополнительный role-guard на super_admin.
{
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
/**
* Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
*
* Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
* Неизвестные статусы маппятся через UnknownStatusesDialog.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
getImport,
getUnknownStatuses,
listImports,
uploadImport,
type ImportLogResource,
type UnknownStatus,
} from '../api/imports';
import UnknownStatusesDialog from '../components/import/UnknownStatusesDialog.vue';
const file = ref<File | null>(null);
const dryRun = ref(false);
const uploading = ref(false);
const errorMessage = ref<string | null>(null);
const history = ref<ImportLogResource[]>([]);
const activeImport = ref<ImportLogResource | null>(null);
const unknownStatuses = ref<UnknownStatus[]>([]);
const wizardOpen = ref(false);
/** Интервал опроса прогресса активного импорта, мс. */
const POLL_INTERVAL_MS = 2000;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const canUpload = computed(() => file.value !== null && !uploading.value);
const isProcessing = computed(
() =>
activeImport.value?.status === 'pending' ||
activeImport.value?.status === 'processing',
);
async function refreshHistory(): Promise<void> {
try {
history.value = await listImports();
} catch {
// история — не критично, тихо игнорируем
}
}
async function refreshUnknown(): Promise<void> {
try {
unknownStatuses.value = await getUnknownStatuses();
} catch {
unknownStatuses.value = [];
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollOnce(id: number): Promise<void> {
try {
activeImport.value = await getImport(id);
if (!isProcessing.value) {
stopPolling();
await refreshHistory();
await refreshUnknown();
}
} catch {
stopPolling();
}
}
function startPolling(id: number): void {
stopPolling();
pollTimer = setInterval(() => {
void pollOnce(id);
}, POLL_INTERVAL_MS);
}
async function submit(): Promise<void> {
if (file.value === null) {
return;
}
uploading.value = true;
errorMessage.value = null;
try {
activeImport.value = await uploadImport(file.value, dryRun.value);
startPolling(activeImport.value.id);
file.value = null;
} catch {
errorMessage.value = 'Не удалось загрузить файл. Проверьте формат (CSV, до 10 МБ).';
} finally {
uploading.value = false;
}
}
async function onWizardResolved(): Promise<void> {
wizardOpen.value = false;
await refreshUnknown();
}
onMounted(async () => {
await refreshHistory();
await refreshUnknown();
});
onUnmounted(stopPolling);
</script>
<template>
<v-container fluid class="import-view pa-6">
<header class="page-head mb-4">
<h1 class="text-h4 mb-2">Импорт данных</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Перенос исторических лидов из crm.bp-gr.ru. Формат CSV-выгрузка (UTF-8).
</p>
</header>
<v-alert
v-if="unknownStatuses.length > 0"
data-test="unknown-banner"
type="warning"
variant="tonal"
class="mb-4"
>
Найдено {{ unknownStatuses.length }} неизвестных статусов воронки замапьте вручную.
<template #append>
<v-btn size="small" variant="flat" @click="wizardOpen = true">Замапить</v-btn>
</template>
</v-alert>
<v-card variant="outlined" class="pa-6 mb-6">
<v-file-input
v-model="file"
label="CSV-файл выгрузки лидов"
accept=".csv,text/csv"
prepend-icon="mdi-database-import-outline"
variant="outlined"
density="comfortable"
:disabled="uploading"
/>
<v-checkbox
v-model="dryRun"
label="Пробный прогон (проверить файл без записи сделок)"
density="compact"
hide-details
/>
<v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</v-alert>
<div class="mt-4">
<v-btn
data-test="upload-btn"
color="primary"
:loading="uploading"
:disabled="!canUpload"
@click="submit"
>
Загрузить
</v-btn>
</div>
</v-card>
<v-card v-if="activeImport" variant="outlined" class="pa-6 mb-6">
<h2 class="text-h6 mb-3">Текущий импорт {{ activeImport.filename }}</h2>
<v-progress-linear v-if="isProcessing" indeterminate color="primary" class="mb-3" />
<div data-test="active-status" class="text-body-2">
Статус: <strong>{{ activeImport.status }}</strong>
</div>
<v-table v-if="!isProcessing" density="compact" class="mt-3">
<tbody>
<tr>
<td>Добавлено</td>
<td>{{ activeImport.rows_added }}</td>
</tr>
<tr>
<td>Обновлено</td>
<td>{{ activeImport.rows_updated }}</td>
</tr>
<tr>
<td>Пропущено</td>
<td>{{ activeImport.rows_skipped }}</td>
</tr>
<tr>
<td>Неизвестных статусов</td>
<td>{{ activeImport.unknown_statuses_count }}</td>
</tr>
</tbody>
</v-table>
<v-alert
v-if="activeImport.status === 'failed'"
type="error"
variant="tonal"
class="mt-3"
>
{{ activeImport.error_message }}
</v-alert>
</v-card>
<v-card variant="outlined" class="pa-6">
<h2 class="text-h6 mb-3">История импортов</h2>
<v-table v-if="history.length > 0" density="compact">
<thead>
<tr>
<th>Файл</th>
<th>Статус</th>
<th>Добавлено</th>
<th>Обновлено</th>
<th>Пропущено</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.id">
<td>{{ row.filename }}</td>
<td>{{ row.status }}</td>
<td>{{ row.rows_added }}</td>
<td>{{ row.rows_updated }}</td>
<td>{{ row.rows_skipped }}</td>
</tr>
</tbody>
</v-table>
<p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
</v-card>
<UnknownStatusesDialog
v-model="wizardOpen"
:statuses="unknownStatuses"
@resolved="onWizardResolved"
/>
</v-container>
</template>
<style scoped>
.import-view {
max-width: 1100px;
}
</style>
+16 -1
View File
@@ -104,7 +104,18 @@ async function handleSubmit() {
<span class="text-caption text-medium-emphasis">или</span>
</v-divider>
<v-btn block size="large" variant="outlined"> Войти через Yandex 360 </v-btn>
<v-tooltip
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
location="top"
>
<template #activator="{ props }">
<div v-bind="props" class="yandex-sso-wrap">
<v-btn block size="large" variant="outlined" disabled>
Войти через Yandex 360
</v-btn>
</div>
</template>
</v-tooltip>
</v-form>
</v-card>
</template>
@@ -126,4 +137,8 @@ async function handleSubmit() {
flex-direction: column;
gap: 4px;
}
.yandex-sso-wrap {
width: 100%;
}
</style>
@@ -37,6 +37,17 @@ const canSubmit = computed(
password.value === passwordConfirmation.value,
);
/**
* Ошибка поля подтверждения: client-side проверка совпадения +
* проброс backend-ошибки `password_confirmation` если придёт с 422.
*/
const confirmationError = computed<string[]>(() => {
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
return ['Пароли не совпадают'];
}
return errors.value.password_confirmation ?? [];
});
async function handleSubmit() {
errors.value = {};
try {
@@ -115,6 +126,7 @@ async function handleSubmit() {
variant="outlined"
density="comfortable"
required
:error-messages="confirmationError"
/>
<v-btn
+27 -2
View File
@@ -11,7 +11,7 @@
*/
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
const code = ref(['', '', '', '', '', '']);
@@ -27,12 +27,32 @@ const router = useRouter();
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
/**
* TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд.
* Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»).
* Значение 30..1 секунд, формат «00:NN».
*/
function totpWindowLeft(): number {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
const totpSecondsLeft = ref(totpWindowLeft());
const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`);
let totpTimer: ReturnType<typeof setInterval> | undefined;
// Если попали на /2fa без pending state (requires2fa=false и не залогинен)
// прямой URL без login отправляем на /login.
onMounted(() => {
if (!auth.requires2fa && !auth.isAuthenticated) {
router.replace('/login');
return;
}
totpTimer = setInterval(() => {
totpSecondsLeft.value = totpWindowLeft();
}, 1000);
});
onUnmounted(() => {
if (totpTimer) clearInterval(totpTimer);
});
function onInput(index: number, event: Event) {
@@ -126,7 +146,12 @@ async function handleSubmit() {
<RouterLink to="/recovery-use" class="text-body-2 text-primary">
Использовать резервный код
</RouterLink>
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
<span
class="text-caption text-medium-emphasis font-mono"
:title="`До смены кода в приложении: ${totpCountdown}`"
data-testid="totp-countdown"
>{{ totpCountdown }}</span
>
</div>
<v-btn
@@ -0,0 +1,33 @@
<x-mail::message>
@if ($outcome === 'done')
# Импорт завершён
Импорт файла **{{ $log->filename }}** успешно завершён.
| Показатель | Значение |
|:-----------|---------:|
| Добавлено сделок | {{ $log->rows_added }} |
| Обновлено сделок | {{ $log->rows_updated }} |
| Пропущено строк | {{ $log->rows_skipped }} |
| Неизвестных статусов | {{ $log->unknown_statuses_count }} |
@if ($log->unknown_statuses_count > 0)
Обнаружены неизвестные статусы воронки замапьте их вручную на экране «Импорт данных».
@endif
@else
# Импорт не удался
Импорт файла **{{ $log->filename }}** завершился ошибкой:
> {{ $log->error_message }}
Проверьте формат файла и повторите загрузку на экране «Импорт данных».
@endif
<x-mail::button :url="config('app.url').'/import'">
Открыть «Импорт данных»
</x-mail::button>
С уважением,<br>
Лидерра
</x-mail::message>
+12
View File
@@ -199,6 +199,17 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
});
// Sprint 4 — CSV-импорт исторических лидов (ТЗ §6).
// ВАЖНО: /unknown-statuses и /unknown-statuses/resolve объявлены ДО
// /{importLog}, иначе литеральный сегмент перехватывается параметром.
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::get('/api/imports/unknown-statuses', 'App\Http\Controllers\Api\ImportController@unknownStatuses');
Route::post('/api/imports/unknown-statuses/resolve', 'App\Http\Controllers\Api\ImportController@resolveUnknownStatuses');
Route::get('/api/imports', 'App\Http\Controllers\Api\ImportController@index');
Route::post('/api/imports', 'App\Http\Controllers\Api\ImportController@store');
Route::get('/api/imports/{importLog}', 'App\Http\Controllers\Api\ImportController@show');
});
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
@@ -264,6 +275,7 @@ Route::view('/billing', 'welcome');
Route::view('/settings', 'welcome');
Route::view('/reports', 'welcome');
Route::view('/reminders', 'welcome');
Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт исторических лидов §6
Route::view('/admin', 'welcome');
Route::view('/admin/tenants', 'welcome');
Route::view('/admin/billing', 'welcome');
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\DemoSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('DemoSeeder идемпотентен — повторный запуск не дублирует demo-tenant и admin', function () {
$this->seed(DemoSeeder::class);
$this->seed(DemoSeeder::class);
// tenant + admin покрывают оба create-пути сидера (first()??create / updateOrCreate);
// projects/deals используют updateOrInsert + skip-guard — тот же класс идемпотентности.
expect(Tenant::query()->where('subdomain', 'demo')->count())->toBe(1)
->and(User::query()->where('email', 'admin@demo.local')->count())->toBe(1);
});
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Reminder;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create(['balance_leads' => 5]);
$this->user = User::factory()->for($this->tenant)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->service = app(HistoricalImportService::class);
});
function importLog(Tenant $tenant, User $user, bool $dryRun = false): ImportLog
{
return ImportLog::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/x.csv',
'dry_run' => $dryRun,
]);
}
function parseFixture(string $body): array
{
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
return (new CsvLeadsParser)->parse($header."\n".$body)->rows;
}
test('импортирует исторические лиды, создавая партиции под старые даты', function (): void {
$rows = parseFixture(
'5001,Окна,окна,79161112233,2023/07/10 10:00:00,,Комментарий,Переговоры,Иван'
);
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect($result->added)->toBe(1)
->and($result->updated)->toBe(0);
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
expect($deal->status)->toBe('negotiations')
->and($deal->phone)->toBe('79161112233')
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
});
test('баланс лидов не списывается, фиксируется транзакция historical_import', function (): void {
$rows = parseFixture(
'5002,Окна,окна,79161112234,2023/07/11 10:00:00,,,Новые,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect($this->tenant->fresh()->balance_leads)->toBe(5); // не изменился
$tx = DB::table('balance_transactions')
->where('tenant_id', $this->tenant->id)
->where('type', 'historical_import')
->first();
expect($tx)->not->toBeNull()
->and((int) $tx->amount_leads)->toBe(0);
});
test('повторный импорт того же файла не создаёт дублей (idempotent UPDATE)', function (): void {
$rows = parseFixture(
'5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Старый,Новые,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
$rows2 = parseFixture(
'5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Обновлённый,Оплачено,Пётр'
);
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows2);
expect($result->added)->toBe(0)
->and($result->updated)->toBe(1)
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
->and($deal->contact_name)->toBe('Пётр')
->and($deal->comment)->toBe('Обновлённый');
});
test('непустое «Напоминание» создаёт строку reminders', function (): void {
$rows = parseFixture(
'5004,Окна,окна,79161112236,2023/09/01 10:00:00,2023/09/05 14:00:00,,Новые,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
$deal = Deal::query()->where('source_crm_id', 5004)->firstOrFail();
$reminder = Reminder::query()->where('deal_id', $deal->id)->firstOrFail();
expect($reminder->remind_at->format('Y-m-d H:i'))->toBe('2023-09-05 14:00')
->and($reminder->created_by)->toBe($this->user->id);
});
test('неизвестный статус → сделка new + запись в import_unknown_statuses', function (): void {
$log = importLog($this->tenant, $this->user);
$rows = parseFixture(
"5005,Окна,окна,79161112237,2023/10/01 10:00:00,,,Архив,\n".
'5006,Окна,окна,79161112238,2023/10/02 10:00:00,,,Архив,'
);
$result = $this->service->import($this->tenant->id, $this->user->id, $log, $rows);
expect($result->unknownStatuses)->toBe(['Архив' => 2])
->and(Deal::query()->where('source_crm_id', 5005)->firstOrFail()->status)->toBe('new');
$unknown = ImportUnknownStatus::query()->where('status_ru', 'Архив')->firstOrFail();
expect($unknown->occurrences)->toBe(2)
->and($unknown->mapped_to_slug)->toBeNull();
});
test('resolved-маппинг tenant-а применяется к ранее неизвестному статусу', function (): void {
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id,
'status_ru' => 'Архив',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'resolved_at' => now(),
]);
$rows = parseFixture(
'5007,Окна,окна,79161112239,2023/11/01 10:00:00,,,Архив,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
});
test('dry_run не пишет сделки, но считает проекцию', function (): void {
$rows = parseFixture(
'5008,Окна,окна,79161112240,2023/12/01 10:00:00,,,Новые,'
);
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user, dryRun: true), $rows);
expect($result->added)->toBe(1)
->and(Deal::query()->where('source_crm_id', 5008)->exists())->toBeFalse();
});
test('неизвестные статусы и resolved-маппинг изолированы по тенантам', function (): void {
// Тенант B уже резолвил «Архив» → closed и накопил 9 вхождений.
$otherTenant = Tenant::factory()->create();
ImportUnknownStatus::create([
'tenant_id' => $otherTenant->id,
'status_ru' => 'Архив',
'occurrences' => 9,
'mapped_to_slug' => 'closed',
'resolved_at' => now(),
]);
// Тенант A (this) импортирует лид со статусом «Архив». Под BYPASSRLS queue
// worker'ом без явного where(tenant_id) сервис подхватил бы маппинг тенанта B
// и инкрементировал бы его строку — это и проверяется.
$rows = parseFixture(
'6001,Окна,окна,79161119999,2023/06/01 10:00:00,,,Архив,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
// Сделка тенанта A — 'new': маппинг тенанта B НЕ применён.
expect(Deal::query()->where('source_crm_id', 6001)->firstOrFail()->status)->toBe('new');
// У тенанта A — собственная запись import_unknown_statuses, occurrences=1.
$ownRow = ImportUnknownStatus::query()
->where('tenant_id', $this->tenant->id)
->where('status_ru', 'Архив')
->firstOrFail();
expect($ownRow->occurrences)->toBe(1);
// Строка тенанта B не тронута (occurrences остался 9).
$otherRow = ImportUnknownStatus::query()
->where('tenant_id', $otherTenant->id)
->where('status_ru', 'Архив')
->firstOrFail();
expect($otherRow->occurrences)->toBe(9);
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Mail\ImportCompletedNotification;
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
});
test('письмо об успешном импорте содержит счётчики', function (): void {
$log = ImportLog::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'status' => 'done',
'rows_added' => 120,
'rows_updated' => 8,
'rows_skipped' => 2,
]);
$rendered = (new ImportCompletedNotification($log, 'done'))->render();
expect($rendered)->toContain('120')
->and($rendered)->toContain('Импорт завершён');
});
test('письмо о неуспешном импорте сообщает об ошибке', function (): void {
$log = ImportLog::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'status' => 'failed',
'error_message' => 'Файл повреждён',
]);
$mailable = new ImportCompletedNotification($log, 'failed');
$rendered = $mailable->render();
expect($rendered)->toContain('Файл повреждён')
->and($mailable->envelope()->subject)->toContain('не удался');
});
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use App\Jobs\ImportLeadsJob;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
// Устанавливаем контекст тенанта на уровне outer-транзакции DatabaseTransactions.
// Middleware SetTenantContext использует SET LOCAL внутри savepoint'а — без этой
// строки RLS-фильтрация активна только внутри HTTP-запроса, но прямые DB-запросы
// в тестах (count, factory) видят все тенанты. Паттерн из DealIndexTest.php.
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->actingAs($this->user);
});
test('POST /api/imports принимает CSV, создаёт import_log, диспатчит job', function (): void {
Queue::fake();
Storage::fake('local');
$csv = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя'."\n".
'9001,Окна,окна,79161112233,2023/05/10 10:00:00,,,Новые,';
$response = $this->postJson('/api/imports', [
'file' => UploadedFile::fake()->createWithContent('leads.csv', $csv),
]);
$response->assertStatus(201)
->assertJsonPath('data.status', 'pending')
->assertJsonPath('data.filename', 'leads.csv');
Queue::assertPushed(ImportLeadsJob::class);
// Defense-in-depth: superuser на dev обходит RLS (BYPASSRLS), поэтому явно
// фильтруем по tenant_id — паттерн из DealIndexTest / DealController.
expect(ImportLog::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
test('POST /api/imports отвергает не-CSV файл', function (): void {
Storage::fake('local');
$response = $this->postJson('/api/imports', [
'file' => UploadedFile::fake()->create('image.png', 10, 'image/png'),
]);
$response->assertStatus(422)->assertJsonValidationErrorFor('file');
});
test('POST /api/imports требует авторизации', function (): void {
app('auth')->forgetGuards();
$this->postJson('/api/imports', [])->assertStatus(401);
});
test('GET /api/imports возвращает только import_log своего тенанта', function (): void {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
ImportLog::factory()->count(2)->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
]);
$this->getJson('/api/imports')
->assertStatus(200)
->assertJsonCount(2, 'data');
});
test('GET /api/imports/{id} отдаёт прогресс', function (): void {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$log = ImportLog::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'status' => 'processing',
'rows_added' => 10,
]);
$this->getJson("/api/imports/{$log->id}")
->assertStatus(200)
->assertJsonPath('data.status', 'processing')
->assertJsonPath('data.rows_added', 10);
});
test('GET /api/imports/unknown-statuses возвращает незамапленные статусы', function (): void {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 3,
]);
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
'mapped_to_slug' => 'closed', 'resolved_at' => now(),
]);
$this->getJson('/api/imports/unknown-statuses')
->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.status_ru', 'Архив');
});
test('POST /api/imports/unknown-statuses/resolve проставляет маппинг', function (): void {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$unknown = ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id, 'status_ru' => 'Архив', 'occurrences' => 3,
]);
$this->postJson('/api/imports/unknown-statuses/resolve', [
'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
])->assertStatus(200);
expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
->and($unknown->resolved_at)->not->toBeNull();
});
test('resolve отвергает несуществующий slug', function (): void {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->postJson('/api/imports/unknown-statuses/resolve', [
'mappings' => [['status_ru' => 'Архив', 'slug' => 'нет-такого']],
])->assertStatus(422);
});
test('GET /api/imports/{id} отвергает import_log чужого тенанта (403)', function (): void {
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->for($otherTenant)->create();
$foreignLog = ImportLog::factory()->create([
'tenant_id' => $otherTenant->id,
'user_id' => $otherUser->id,
]);
// Авторизован пользователь $this->tenant; запрашиваем чужой import_log.
// abort_if(tenant_id mismatch, 403) в ImportController::show — defense-in-depth.
$this->getJson("/api/imports/{$foreignLog->id}")->assertStatus(403);
});
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Jobs\ImportLeadsJob;
use App\Mail\ImportCompletedNotification;
use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
Mail::fake();
Storage::fake('local');
});
function storedCsv(int $tenantId, string $body): string
{
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
$path = "imports/{$tenantId}/test.csv";
Storage::disk('local')->put($path, $header."\n".$body);
return $path;
}
function runImportJob(int $logId, int $tenantId): void
{
(new ImportLeadsJob($logId, $tenantId))->handle(
app(HistoricalImportService::class),
app(CsvLeadsParser::class),
);
}
test('job импортирует лиды и переводит import_log в done', function (): void {
$path = storedCsv($this->tenant->id,
'7001,Окна,окна,79161112233,2023/05/10 10:00:00,,,Новые,Иван'
);
$log = ImportLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'filename' => 'leads.csv',
'file_path' => $path,
'status' => 'pending',
]);
runImportJob($log->id, $this->tenant->id);
$log->refresh();
expect($log->status)->toBe('done')
->and($log->rows_added)->toBe(1)
->and($log->finished_at)->not->toBeNull()
->and(Deal::query()->where('source_crm_id', 7001)->exists())->toBeTrue();
Mail::assertSent(ImportCompletedNotification::class);
});
test('job переводит import_log в failed при отсутствии файла', function (): void {
$log = ImportLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/missing.csv',
'status' => 'pending',
]);
runImportJob($log->id, $this->tenant->id);
expect($log->refresh()->status)->toBe('failed')
->and($log->error_message)->not->toBeNull();
});
test('job пишет unknown_statuses_count и rows_skipped', function (): void {
$path = storedCsv($this->tenant->id,
"7002,Окна,окна,79161112234,2023/05/11 10:00:00,,,Архив,\n".
'7003,Окна,окна,BADPHONE,2023/05/12 10:00:00,,,Новые,'
);
$log = ImportLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'filename' => 'leads.csv',
'file_path' => $path,
'status' => 'pending',
]);
runImportJob($log->id, $this->tenant->id);
$log->refresh();
expect($log->status)->toBe('done')
->and($log->unknown_statuses_count)->toBe(1) // «Архив»
->and($log->rows_skipped)->toBe(1); // битый телефон
});
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
});
test('ImportLog создаётся с дефолтами и кастует mapping_config/dry_run', function (): void {
$log = ImportLog::create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/1/uuid.csv',
'mapping_config' => ['status' => ['Новые' => 'new']],
'dry_run' => true,
]);
expect($log->status)->toBe('pending')
->and($log->entity_type)->toBe('leads')
->and($log->dry_run)->toBeTrue()
->and($log->mapping_config)->toBe(['status' => ['Новые' => 'new']]);
});
test('ImportUnknownStatus хранит маппинг и фильтруется scope unresolved', function (): void {
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id,
'status_ru' => 'Архив',
'occurrences' => 3,
]);
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id,
'status_ru' => 'Спам',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'resolved_at' => now(),
]);
expect(ImportUnknownStatus::unresolved()->count())->toBe(1)
->and(ImportUnknownStatus::unresolved()->first()->status_ru)->toBe('Архив');
});
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
test('import_log имеет 5 новых колонок enrichment', function (): void {
foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
expect(Schema::hasColumn('import_log', $column))->toBeTrue("import_log.$column отсутствует");
}
});
test('import_unknown_statuses существует с RLS', function (): void {
expect(Schema::hasTable('import_unknown_statuses'))->toBeTrue();
$rls = DB::selectOne(
"SELECT relrowsecurity FROM pg_class WHERE relname = 'import_unknown_statuses'"
);
expect($rls)->not->toBeNull('pg_class row for import_unknown_statuses не найден');
/** @var object{relrowsecurity: bool} $rls */
expect($rls->relrowsecurity)->toBeTrue('RLS не включён на import_unknown_statuses');
$policy = DB::selectOne(
"SELECT 1 AS ok FROM pg_policies WHERE tablename = 'import_unknown_statuses' AND policyname = 'tenant_isolation'"
);
expect($policy)->not->toBeNull('Политика tenant_isolation отсутствует');
});
test('import_unknown_statuses имеет UNIQUE именно по (tenant_id, status_ru)', function (): void {
$row = DB::selectOne(
"SELECT array_to_string(array_agg(a.attname ORDER BY a.attname), ',') AS cols
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'import_unknown_statuses'::regclass AND c.contype = 'u'"
);
expect($row)->not->toBeNull('UNIQUE-ограничение отсутствует');
/** @var object{cols: string} $row */
expect($row->cols)->toBe('status_ru,tenant_id');
});
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function partitionExists(string $name): bool
{
return DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$name],
) !== null;
}
test('ensureRange создаёт месячные партиции deals под диапазон', function (): void {
$manager = app(MonthlyPartitionManager::class);
$created = $manager->ensureRange(
'deals',
Carbon::parse('2024-02-15'),
Carbon::parse('2024-04-03'),
);
expect($created)->toBeGreaterThanOrEqual(3)
->and(partitionExists('deals_2024_02'))->toBeTrue()
->and(partitionExists('deals_2024_03'))->toBeTrue()
->and(partitionExists('deals_2024_04'))->toBeTrue();
});
test('ensureRange идемпотентна — повторный вызов не падает', function (): void {
$manager = app(MonthlyPartitionManager::class);
$manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
$secondRun = $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20'));
expect($secondRun)->toBe(0); // всё уже существует
});
test('ensureRange отвергает неизвестную таблицу', function (): void {
app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now());
})->throws(InvalidArgumentException::class);
@@ -59,24 +59,25 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.19 has correct metrics — 62 base tables, 117 indexes, 39 RLS policies', function () {
it('schema.sql v8.21 has correct metrics — 63 base tables, 118 indexes, 40 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.19.
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.21.
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
$schema = file_get_contents($schemaPath);
expect($schema)->not->toBeFalse();
// 62 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
// 63 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(62);
expect($baseTables)->toBe(63);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(117);
expect($createIndexes)->toBe(118);
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(39);
expect($createPolicies)->toBe(40);
});
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
@@ -95,4 +95,18 @@ describe('ForgotPasswordView.vue', () => {
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('10 мин');
});
it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => {
// forgotPassword отклоняется обычной ошибкой; extractValidationErrors и
// extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла).
vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error'));
const wrapper = await mountForgot();
await wrapper.find('input[type="email"]').setValue('user@example.ru');
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
const messages = wrapper.findAll('.v-messages__message').map((m) => m.text());
expect(messages.join(' ')).toContain('Произошла ошибка. Попробуйте позже.');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
return { ...orig };
});
const importsApi = await import('../../resources/js/api/imports');
const ImportView = (await import('../../resources/js/views/ImportView.vue')).default;
const vuetify = createVuetify({ components, directives });
function mountView() {
return mount(ImportView, {
global: {
plugins: [vuetify],
stubs: { UnknownStatusesDialog: true },
},
});
}
describe('ImportView', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(importsApi, 'listImports').mockResolvedValue([]);
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([]);
});
it('грузит историю импортов при монтировании', async () => {
const spy = vi.spyOn(importsApi, 'listImports').mockResolvedValue([
{
id: 1,
filename: 'leads.csv',
status: 'done',
rows_total: 5,
rows_added: 5,
rows_updated: 0,
rows_skipped: 0,
unknown_statuses_count: 0,
dry_run: false,
error_message: null,
started_at: null,
finished_at: null,
},
]);
const wrapper = mountView();
await flushPromises();
expect(spy).toHaveBeenCalled();
expect(wrapper.text()).toContain('leads.csv');
});
it('кнопка загрузки заблокирована без выбранного файла', async () => {
const wrapper = mountView();
await flushPromises();
const uploadBtn = wrapper.find('[data-test="upload-btn"]');
expect(uploadBtn.attributes('disabled')).toBeDefined();
});
it('показывает баннер о неизвестных статусах', async () => {
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([
{ id: 1, status_ru: 'Архив', occurrences: 3 },
]);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-test="unknown-banner"]').exists()).toBe(true);
});
});
+7
View File
@@ -80,4 +80,11 @@ describe('LoginView.vue', () => {
expect(alert.text()).toContain('10 мин');
expect(alert.text()).toContain('Слишком много попыток');
});
it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => {
const wrapper = await mountLoginView();
const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));
expect(ssoBtn).toBeDefined();
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
});
});
@@ -103,4 +103,13 @@ describe('ResetPasswordView.vue', () => {
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('10 мин');
});
it('A4: показывает ошибку при несовпадении пароля и подтверждения', async () => {
const wrapper = await mountReset();
const pwInputs = wrapper.findAll('input[type="password"]');
await pwInputs[0].setValue('new-strong-pass-1234');
await pwInputs[1].setValue('different-pass-9999');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Пароли не совпадают');
});
});
+22 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
@@ -51,4 +51,25 @@ describe('TwoFactorView.vue', () => {
const links = wrapper.findAll('a').map((a) => a.text());
expect(links.some((t) => t.includes('резервный код'))).toBe(true);
});
it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20
try {
const wrapper = await mountTwoFactor();
const el = wrapper.find('[data-testid="totp-countdown"]');
expect(el.exists()).toBe(true);
expect(el.text()).toBe('00:20');
// Устанавливаем время так, чтобы после срабатывания интервала (+1000ms)
// Date.now() оказался на 15000 ms: 15000 - 1000 = 14000.
// epoch 15 c → 30 - (15 % 30) = 15
vi.setSystemTime(new Date(14_000));
vi.advanceTimersByTime(1000); // interval fires, Date.now() → 15000
await wrapper.vm.$nextTick();
expect(el.text()).toBe('00:15');
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
return {
...orig,
resolveUnknownStatuses: vi.fn(),
};
});
const importsApi = await import('../../resources/js/api/imports');
const UnknownStatusesDialog = (await import('../../resources/js/components/import/UnknownStatusesDialog.vue')).default;
// VDialog в JSDOM не рендерит через teleport — стаб делает <slot/> доступным
// для wrapper.text() / find(). Паттерн из EditProjectDialog.spec.ts.
function mountDialog() {
return mount(UnknownStatusesDialog, {
props: {
modelValue: true,
statuses: [
{ id: 1, status_ru: 'Архив', occurrences: 3 },
{ id: 2, status_ru: 'Спам', occurrences: 1 },
],
},
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
}
describe('UnknownStatusesDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(importsApi.resolveUnknownStatuses).mockResolvedValue(undefined);
});
it('рендерит строку на каждый неизвестный статус', async () => {
const wrapper = mountDialog();
await flushPromises();
expect(wrapper.text()).toContain('Архив');
expect(wrapper.text()).toContain('Спам');
wrapper.unmount();
});
it('кнопка сохранения заблокирована пока не выбраны все маппинги', async () => {
const wrapper = mountDialog();
await flushPromises();
const saveBtn = wrapper.find('[data-test="save-mappings"]');
expect(saveBtn.exists()).toBe(true);
expect(saveBtn.attributes('disabled')).toBeDefined();
wrapper.unmount();
});
it('сохраняет маппинги и эмитит resolved', async () => {
const spy = vi.mocked(importsApi.resolveUnknownStatuses);
const wrapper = mountDialog();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selection['Архив'] = 'closed';
vm.selection['Спам'] = 'closed';
await flushPromises();
await vm.save();
await flushPromises();
expect(spy).toHaveBeenCalledWith([
{ status_ru: 'Архив', slug: 'closed' },
{ status_ru: 'Спам', slug: 'closed' },
]);
expect(wrapper.emitted('resolved')).toBeTruthy();
wrapper.unmount();
});
});
+8
View File
@@ -133,4 +133,12 @@ describe('router/index.ts', () => {
expect(router.currentRoute.value.name).toBe('reset-password');
expect(router.currentRoute.value.params.token).toBe('abc123-token-xyz');
});
it('маршрут /import зарегистрирован с layout app', () => {
const importRoute = router.getRoutes().find((r) => r.name === 'import');
expect(importRoute, 'route import not found').toBeDefined();
expect(importRoute?.path).toBe('/import');
expect(importRoute?.meta.layout).toBe('app');
expect(importRoute?.meta.requiresAuth).toBe(true);
});
});
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Services\Import\CsvLeadsParser;
function csvLeads(string $body, bool $withBom = true): string
{
$header = 'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
return ($withBom ? "\xEF\xBB\xBF" : '').$header."\n".$body;
}
test('парсит валидную строку в ParsedLeadRow', function (): void {
$result = (new CsvLeadsParser)->parse(csvLeads(
'1001,B1_Окна,окна,79161234567,2024/03/15 10:30:00,,Тёплый клиент,Переговоры,Иван'
));
expect($result->rows)->toHaveCount(1)
->and($result->errors)->toBeEmpty();
$row = $result->rows[0];
expect($row->sourceCrmId)->toBe(1001)
->and($row->projectName)->toBe('Окна') // префикс B1_ срезан
->and($row->projectTag)->toBe('окна')
->and($row->phone)->toBe('79161234567')
->and($row->statusRu)->toBe('Переговоры')
->and($row->contactName)->toBe('Иван')
->and($row->reminderAt)->toBeNull()
->and($row->receivedAt->format('Y-m-d H:i:s'))->toBe('2024-03-15 10:30:00');
});
test('срезает BOM и не считает заголовок строкой данных', function (): void {
$result = (new CsvLeadsParser)->parse(csvLeads(
'1,Проект,тег,79990001122,2024/01/01 00:00:00,,,Новые,'
));
expect($result->rows)->toHaveCount(1)
->and($result->rows[0]->sourceCrmId)->toBe(1);
});
test('парсит напоминание когда оно непустое', function (): void {
$result = (new CsvLeadsParser)->parse(csvLeads(
'2,П,т,79990001122,2024/01/01 09:00:00,2024/01/05 12:00:00,,Новые,'
));
expect($result->rows[0]->reminderAt?->format('Y-m-d H:i:s'))->toBe('2024-01-05 12:00:00');
});
test('собирает ошибки невалидного телефона и даты, не роняя парсинг', function (): void {
$result = (new CsvLeadsParser)->parse(csvLeads(
"3,П,т,8916123,2024/01/01 00:00:00,,,Новые,\n".
"4,П,т,79990001122,НЕ-ДАТА,,,Новые,\n".
'5,П,т,79990001133,2024/01/02 00:00:00,,,Новые,'
));
expect($result->rows)->toHaveCount(1) // только строка 5 валидна
->and($result->rows[0]->sourceCrmId)->toBe(5)
->and($result->errors)->toHaveCount(2);
expect($result->errors[0]['line'])->toBe(2); // 1-я data-строка (после header)
});
test('обрабатывает кавычки и запятые внутри поля', function (): void {
$result = (new CsvLeadsParser)->parse(csvLeads(
'6,П,т,79990001122,2024/01/01 00:00:00,,"Комментарий, с запятой",Новые,Пётр'
));
expect($result->rows[0]->comment)->toBe('Комментарий, с запятой');
});
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Services\Import\StatusRuToSlugMapper;
test('маппит все 14 канонических статусов §6.4', function (): void {
$mapper = new StatusRuToSlugMapper;
expect($mapper->toSlug('Новые'))->toBe('new')
->and($mapper->toSlug('Оплачено'))->toBe('paid')
->and($mapper->toSlug('Конечный недозвон'))->toBe('final_missed')
->and($mapper->map())->toHaveCount(14);
});
test('тримит пробелы вокруг значения', function (): void {
expect((new StatusRuToSlugMapper)->toSlug(' Переговоры '))->toBe('negotiations');
});
test('возвращает null для неизвестного статуса', function (): void {
expect((new StatusRuToSlugMapper)->toSlug('Архив'))->toBeNull()
->and((new StatusRuToSlugMapper)->toSlug(''))->toBeNull();
});
+11
View File
@@ -1297,3 +1297,14 @@ recollage
гварда
вестигиальным
спеков
# Sprint 4 — historical import (2026-05-16) — domain vocab
замапьте
замапить
замапите
замапил
замапили
замаплен
замапленного
замапленных
замапленные
+9 -2
View File
@@ -1,11 +1,18 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит девятнадцать записей в обратном хронологическом порядке (v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать записей в обратном хронологическом порядке (v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.20, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.21, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.21 — 2026-05-16 — Sprint 4 (историческая миграция лидов §6)
- **+1 таблица** `import_unknown_statuses` (tenant-level маппинг неизвестных статусов CSV; RLS `tenant_isolation`; UNIQUE `(tenant_id, status_ru)`; partial index `idx_import_unknown_statuses_unresolved`).
- **+5 колонок** в `import_log`: `entity_type`, `source_system`, `mapping_config`, `unknown_statuses_count`, `dry_run`.
- GRANTs: `import_unknown_statuses` покрыта umbrella `GRANT ... ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES` (`db/02_grants.sql`) — явный per-table grant не требуется (как у `import_log`).
- Миграция: `2026_05_16_120000_sprint4_historical_import_schema.php` (guard'ы `hasTable`/`hasColumn`).
## v8.20 (11.05.2026 — Plan 5)
**Added:**
+35 -3
View File
@@ -1,7 +1,8 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
-- Метрики: 63 базовые таблицы (61 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 117 индексов / 39 RLS-политик / 5 функций / 13 триггеров
-- Версия: v8.21 (16.05.2026 — Sprint 4: import_unknown_statuses + import_log enrichment (+5 колонок))
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 118 индексов / 40 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK projects.supplier_b{1,2,3}_project_id → supplier_projects (ON DELETE SET NULL) + 3 partial index + CHECK chk_projects_b1_not_for_sms (defense-in-depth дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне). Закрывает code-review BLOCKER#1 + WARNING#3 от 10.05.2026 поздний вечер)
@@ -1489,9 +1490,38 @@ CREATE TABLE import_log (
CHECK (status IN ('pending','processing','done','failed')),
error_message TEXT,
started_at TIMESTAMPTZ DEFAULT NOW(),
finished_at TIMESTAMPTZ
finished_at TIMESTAMPTZ,
-- Sprint 4 (H2): enrichment-колонки для исторической миграции лидов (раздел 6.4)
entity_type VARCHAR(20) NOT NULL DEFAULT 'leads'
CHECK (entity_type IN ('leads','projects')),
source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru',
mapping_config JSONB,
unknown_statuses_count INT NOT NULL DEFAULT 0,
dry_run BOOLEAN NOT NULL DEFAULT FALSE
);
-- -----------------------------------------------------------------------------
-- import_unknown_statuses — tenant-level маппинг неизвестных статусов CSV (раздел 6.4)
-- Sprint 4 (H1): русский статус из CSV, не найденный в STATUS_RU_TO_SLUG, пишется сюда.
-- Wizard (§6.6) проставляет mapped_to_slug; повторный импорт применяет маппинг.
-- -----------------------------------------------------------------------------
CREATE TABLE import_unknown_statuses (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
status_ru VARCHAR(100) NOT NULL,
occurrences INT NOT NULL DEFAULT 0,
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
resolved_at TIMESTAMPTZ,
resolved_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
UNIQUE (tenant_id, status_ru)
);
CREATE INDEX idx_import_unknown_statuses_unresolved
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL;
-- =============================================================================
-- 5. DEALS — партиционированная по received_at (раздел 7.3)
@@ -2703,6 +2733,7 @@ ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE comment_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE deal_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE import_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
@@ -2743,6 +2774,7 @@ CREATE POLICY tenant_isolation ON push_subscriptions USING (tenant_id = cur
CREATE POLICY tenant_isolation ON comment_templates USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
+483 -15
View File
@@ -90,6 +90,34 @@
box-shadow: inset 0 0 0 1px rgba(253,246,227,0.4);
color: #fdf6e3;
}
/* ── Панель «Разделы» (функциональная квалификация) ── */
#legend-sections-content { display: flex; flex-direction: column; gap: 10px; }
#legend-sections-title { font-size: 15px; font-weight: 600; color: #fdf6e3; }
.sect-bucket-h { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #b58900; font-weight: 600; margin: 8px 0 2px; }
.sect-row { background: #073642; border-radius: 6px; padding: 7px 10px; }
.sect-row.sect-empty { opacity: 0.5; }
.sect-name { font-size: 12px; color: #eee8d5; font-weight: 600; }
.sect-name .sect-id { color: #839496; font-weight: 400; }
.sect-cnt { font-size: 11px; color: #839496; }
.sect-empty-mark { font-size: 11px; color: #586e75; font-style: italic; margin-top: 3px; }
.sect-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
.sect-chip { font-size: 10px; color: #93a1a1; background: #002b36; border: 1px solid #586e75; border-radius: 3px; padding: 1px 5px; cursor: pointer; }
.sect-chip:hover { background: #0d4a5a; color: #fdf6e3; }
/* ── Панель «Хотелки» (отложенный backlog развития мозга) ── */
#legend-wishlist-content { display: flex; flex-direction: column; gap: 10px; }
#legend-wishlist-title { font-size: 15px; font-weight: 600; color: #fdf6e3; }
.wish-row { background: #073642; border-radius: 6px; padding: 8px 10px; border-left: 3px solid #586e75; }
.wish-row.wish-next { border-left-color: #859900; }
.wish-row.wish-blocked { border-left-color: #b58900; }
.wish-row.wish-idea { border-left-color: #586e75; }
.wish-head { font-size: 12px; color: #eee8d5; font-weight: 600; }
.wish-head .wish-id { color: #839496; font-weight: 400; }
.wish-status { font-size: 11px; margin-top: 3px; }
.wish-note { font-size: 11px; color: #93a1a1; margin-top: 4px; line-height: 1.5; }
.wish-sect { font-size: 10px; color: #586e75; margin-top: 4px; }
.wish-legend { font-size: 10px; color: #586e75; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
</style>
</head>
<body>
@@ -116,6 +144,7 @@
<h4>📇 Паспорт узла</h4>
<p><span class="pp-k">Внедрён:</span> <span id="ld-since"></span></p>
<p><span class="pp-k">Последнее изменение:</span> <span id="ld-changed"></span></p>
<p><span class="pp-k">Раздел:</span> <span id="ld-section"></span></p>
<p><span class="pp-k">Использований за 7 дней:</span> <span id="ld-uses"></span></p>
<p id="ld-dup-row" style="display:none;"><span class="pp-k">Дубль:</span> <span id="ld-dup"></span></p>
</div>
@@ -141,6 +170,23 @@
<div class="legend-section"><h4>Обязательность</h4><p id="le-mandatory"></p></div>
<div class="legend-section"><h4>Регламент</h4><p id="le-rule"></p></div>
</div>
<div id="legend-sections-content" style="display:none;">
<div id="legend-sections-title">📂 Разделы деятельности Лидерры</div>
<div class="legend-section">
<p>Узлы карты распределены по функциональным разделам. Пустые разделы — будущие домены «мозга», под которые в карте dev-автоматики ещё нет узлов (playbook не наполнен).</p>
</div>
<div id="sect-list"></div>
</div>
<div id="legend-wishlist-content" style="display:none;">
<div id="legend-wishlist-title">💡 Хотелки — отложенный backlog</div>
<div class="legend-section">
<p>Отложенные «хотелки» развития мозга и портала — то, что решили сделать позже, чтобы не забыть. Источник правды — массив WISHLIST в этом HTML-файле; новая хотелка = новый объект.</p>
<div class="wish-legend"><span>▶ к работе</span><span>⏸ ждёт зависимости</span><span>💭 идея</span></div>
</div>
<div id="wish-list"></div>
</div>
</div>
</div>
@@ -162,6 +208,8 @@
<span class="cat-ctl-sep"></span>
<button class="cat-ctl" id="cat-ctl-heat" title="Подсветить узлы по числу вызовов за 7 дней">🔥 По использованию</button>
<button class="cat-ctl" id="cat-ctl-dup" title="Подсветить явные пары дублей (D1–D5, D7)">⧉ Дубли</button>
<button class="cat-ctl" id="cat-ctl-sect" title="Показать функциональные разделы и распределение узлов по ним">📂 Разделы</button>
<button class="cat-ctl" id="cat-ctl-wish" title="Показать отложенные хотелки — backlog развития мозга и портала">💡 Хотелки</button>
</div>
<script>
@@ -185,12 +233,16 @@ const NODES = [
{ id: 'psr_v1', label: 'PSR_v1 v3.2', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.2', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
// ── ПЛАГИНЫ (5) ── второе кольцо ───────────────
// ── ПЛАГИНЫ (9) ── второе кольцо ───────────────
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
// ── СКИЛЫ SUPERPOWERS (14) — N sector (090) ────
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
@@ -208,16 +260,24 @@ const NODES = [
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
// ── СКИЛЫ ПРОЕКТА (2) — W sector (RLS) ─────────
// ── СКИЛЫ ПРОЕКТА (3) — W sector (RLS) ─────────
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
// ── ХУКИ (5) — S+infra ────────────────────────
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'hooks', size: 20, ring: 4, ...pos(4, 165) },
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
@@ -253,7 +313,7 @@ const NODES = [
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
// ── MEMORY FILES (15) — внешнее кольцо ──────────
// ── MEMORY FILES (23) — внешнее кольцо ──────────
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
@@ -269,6 +329,14 @@ const NODES = [
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
@@ -396,7 +464,7 @@ const EDGES = [
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
// ── MEMORY читается Claude ──────────────────────
E('mem_env', 'ag_pest', 'квирки 72/77\nиспользует агент'),
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
@@ -405,6 +473,24 @@ const EDGES = [
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
E('psr_v1', 'skill_creator', 'R10.1:\nвнешний инструмент'),
E('psr_v1', 'claude_setup', 'R10.1:\nвнешний инструмент'),
E('psr_v1', 'plugin_dev', 'R10.1:\nвнешний инструмент'),
E('psr_v1', 'context7', 'R10.1:\nвнешний инструмент'),
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
// ══════════════════════════════════════════════════
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
@@ -412,7 +498,7 @@ const EDGES = [
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
CONFLICT('hookify_plugin', 'hk_pre_claude', 'hookify может перезаписать существующий хук', 'RED'),
CONFLICT('mcp_pw', 'sk_parallel', 'Browser is already in use (квирк #2)', 'BLACK'),
CONFLICT('ag_pest', 'mcp_redis', 'Гонка в Redis при Pest --parallel из подкаталога (квирк 72)', 'BLACK'),
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
@@ -443,7 +529,7 @@ const EDGES = [
// 3 конфликта ruflo (3-color, iter2 §4)
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
CONFLICT('ruflo_daemon', 'ag_pest', 'Daemon worker-jitter усиливает частоту Pest квирка 72', 'BLACK'),
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
];
// ════════════════════════════════════════════════════
@@ -773,15 +859,15 @@ const NODE_DETAILS = {
// ── АГЕНТЫ ───────────────────────────────────────
ag_pest: nd(
'Разбирает падения тестов Pest --parallel: отличает настоящую ошибку от одного из пяти известных квирков (72/73/77...).',
'Разбирает падения тестов Pest --parallel: отличает настоящую ошибку от известных квирков (73/77 и др.; квирк 72 устранён 16.05.2026 — commit 0fa1a73).',
'При падении Pest --parallel ИЛИ при дымовом тесте (быстрой проверке функциональности) только из подкаталога (как в аудите Phase 3 SyncSupplierProjectsJobTest).',
'READ-ONLY (только чтение — только читает код, ничего не правит). Каждую гипотезу подтверждает реальным запуском, а не «похоже на квирк».',
[{ name: 'CLAUDE.md §6', cond: 'описывает когда вызывать' }],
[],
[{ name: 'MCP-сервер redis', cond: 'читает Redis для отладки квирка 72 (гонка supplier:session)' }],
[
{ name: 'MCP-сервер redis', desc: 'Pest --parallel — гонка (race condition) с кэшем Redis при запуске из подкаталога (квирк 72)', type: 'BLACK' },
{ name: 'демон ruflo', desc: 'Фоновый демон ruflo (PM2) worker-jitter усиливает частоту Pest --parallel квирка 72 (гонка в Redis). ruflo не трогает Redis :6379 — лишь timing-amplifier. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93).', type: 'BLACK' }
{ name: 'MCP-сервер redis', desc: 'Квирк 72 (гонка с кэшем Redis при Pest --parallel из подкаталога) устранён 16.05.2026 — commit 0fa1a73, array-стор в тестах. Конфликт закрыт.', type: 'GREEN' },
{ name: 'демон ruflo', desc: 'Worker-jitter фонового демона ruflo (PM2) усиливает частоту Pest-квирков 73/77. Квирк 72 устранён 16.05 — его jitter больше не усиливает. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93, переоценён).', type: 'BLACK' }
]
),
ag_rls: nd(
@@ -910,12 +996,12 @@ const NODE_DETAILS = {
),
mcp_redis: nd(
'Читает Redis/Memurai — ключи, очереди, кэш для отладки гонок (race conditions). СТРОГО READ-ONLY.',
'При отладке очередей Redis (Pest --parallel квирк 72), при анализе инвалидации кэша.',
'При отладке очередей Redis (Pest --parallel квирки 73/77), при анализе инвалидации кэша.',
'**СТРОГО READ-ONLY** — никаких DEL/FLUSHDB/SET/LPUSH из Claude (только GET/KEYS/LIST). Источник Anthropic устарел — миграция post-MVP.',
[{ name: 'CLAUDE.md §3.3 #35', cond: 'вне основных фаз (для отладки во время работы); PSR_v1 R10.1 блок 3' }],
[],
[{ name: 'pest-parallel-debugger агент', cond: 'агент использует для квирка 72 (гонка в Redis)' }],
[{ name: 'агент pest-parallel-debugger', desc: 'Гонка в Redis при Pest --parallel при запуске из подкаталога (квирк 72)', type: 'BLACK' }]
[{ name: 'агент pest-parallel-debugger', desc: 'Квирк 72 (гонка с кэшем Redis при Pest --parallel из подкаталога) устранён 16.05.2026 — commit 0fa1a73. Конфликт закрыт.', type: 'GREEN' }]
),
mcp_21st: nd(
'Генератор стартовых шаблонов UI-компонентов через LLM. Активация только через процедуру R14.4.',
@@ -1033,7 +1119,7 @@ const NODE_DETAILS = {
[],
[],
[
{ name: 'pest-parallel-debugger агент', cond: 'квирки 72/77 используются агентом' },
{ name: 'pest-parallel-debugger агент', cond: 'квирки 73/77 используются агентом' },
{ name: 'SessionStart хук', cond: 'читается при старте' }
]
),
@@ -1167,11 +1253,11 @@ const NODE_DETAILS = {
ruflo_daemon: nd(
'Фоновый демон ruflo под управлением PM2 (процесс `ruflo-daemon`). По расписанию запускает 5 воркеров: map (каждые 15 мин), audit (10 мин), optimize (15 мин), consolidate (30 мин), testgaps (20 мин). Переживает перезагрузку через планировщик задач Windows.',
'Работает постоянно в фоне.',
'Инспекция рантайма 15.05.2026: воркеры audit / optimize / testgaps пытаются запустить `claude` и КАЖДЫЙ РАЗ падают с ошибкой «файл не найден» (spawn claude ENOENT) — результат пустой (за сутки: audit 29 запусков, optimize 19, testgaps 14 — все пустые). Журнал демона при этом помечает их «успешными» — расхождение метрики и факта. Локально работают только воркеры map и consolidate (без вызова `claude`). Worker-jitter усиливает частоту Pest-квирка 72 — на baseline-регрессии нужно `pm2 stop ruflo-daemon`.',
'Инспекция рантайма 15.05.2026: воркеры audit / optimize / testgaps пытаются запустить `claude` и КАЖДЫЙ РАЗ падают с ошибкой «файл не найден» (spawn claude ENOENT) — результат пустой (за сутки: audit 29 запусков, optimize 19, testgaps 14 — все пустые). Журнал демона при этом помечает их «успешными» — расхождение метрики и факта. Локально работают только воркеры map и consolidate (без вызова `claude`). Worker-jitter усиливает частоту Pest-квирков 73/77 (квирк 72 устранён 16.05) — на baseline-регрессии нужно `pm2 stop ruflo-daemon`.',
[],
[],
[{ name: 'память ruflo', cond: 'воркер consolidate обращается к хранилищу' }],
[{ name: 'агент pest-parallel-debugger', desc: 'Фоновый демон ruflo (PM2) worker-jitter усиливает частоту Pest --parallel квирка 72 (гонка в Redis). ruflo не трогает Redis :6379 — лишь timing-amplifier. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93).', type: 'BLACK' }]
[{ name: 'агент pest-parallel-debugger', desc: 'Worker-jitter фонового демона ruflo (PM2) усиливает частоту Pest-квирков 73/77. Квирк 72 устранён 16.05 (commit 0fa1a73) — его jitter больше не усиливает. На baseline-регрессии — `pm2 stop ruflo-daemon` (квирк #93, переоценён).', type: 'BLACK' }]
),
ruflo_mcp: nd(
'MCP-сервер ruflo (внешний сервис-инструмент Клода) — отдельный процесс `ruflo mcp start`, режим stdio, 7-й сервер в `.mcp.json`. Экспонирует ~210 инструментов (агенты / память / рой / хуки / нейросеть и др.).',
@@ -1237,6 +1323,159 @@ const NODE_DETAILS = {
[],
[{ name: 'ruflo Queen', cond: 'документирует ruflo-интеграцию' }]
),
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 ────────────────
skill_creator: nd(
'Плагин Anthropic для создания новых скилов — eval-driven подход: датасеты сценариев, train/test split, бенчмарк-цикл.',
'При формализации повторяющегося процесса в скил с проверяемым выводом (генерация кода, преобразование файлов).',
'Включён в настройках (~/.claude/settings.json). Для discipline-скилов (TDD-типа) предпочтительнее скил writing-skills плагина Superpowers — у них разные философии.',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[],
[{ name: 'скил writing-skills', cond: 'обе создают скилы — skill-creator eval-driven, writing-skills через TDD' }]
),
claude_setup: nd(
'Плагин Anthropic — рекомендатель автоматизаций (claude-automation-recommender): анализирует репозиторий и советует, какие MCP-серверы, скилы, хуки, суб-агентов добавить.',
'При настройке/ревизии автоматизации проекта — «чего не хватает в тулчейне».',
'Включён в настройках (~/.claude/settings.json). Рекомендации — совещательные, решение за заказчиком.',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[],
[]
),
plugin_dev: nd(
'Плагин Anthropic для разработки плагинов Claude Code — 7 скилов (структура плагина, разработка скилов / агентов / хуков / команд, интеграция MCP, настройки).',
'При создании или правке плагина и его компонентов.',
'Включён в настройках. Содержит 3 агента, уже представленные на карте (agent-creator / plugin-validator / skill-reviewer).',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[
{ name: 'агент plugin-dev:agent-creator', cond: 'входит в плагин' },
{ name: 'агент plugin-dev:plugin-validator', cond: 'входит в плагин' },
{ name: 'агент plugin-dev:skill-reviewer', cond: 'входит в плагин' }
],
[]
),
context7: nd(
'Плагин Anthropic — актуальная документация библиотек / фреймворков / API через MCP-инструменты query-docs и resolve-library-id.',
'При вопросах по библиотеке / фреймворку / SDK / CLI — синтаксис API, конфигурация, миграция версий. Предпочтительнее веб-поиска для документации библиотек.',
'Включён в настройках. Не для рефакторинга / отладки бизнес-логики / ревью — только документация.',
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
[],
[]
),
hk_self_check: nd(
'Хук SessionStart — economy-self-check.py: при старте сессии восстанавливает уровень экономии из файла состояния и проверяет согласованность.',
'SessionStart — единожды при инициализации сессии.',
'Часть архитектуры economy-хука из 6 компонентов (memory feedback_superpowers_hard_rule). §12 economy-режим не отменяет.',
[{ name: '~/.claude/settings.json', cond: 'хук SessionStart (user-level)' }],
[],
[{ name: 'хук economy-mode', cond: 'оба — часть системы экономии' }]
),
hk_skill_marker: nd(
'Хук PreToolUse на вызов Skill — skill-marker.py: фиксирует факт инвокации скила в состоянии сессии (пара со skill-check).',
'PreToolUse, matcher Skill — перед каждым вызовом скила.',
'Часть пары skill-marker / skill-check — runtime-энфорсмент §12 (скил инвокируется первым).',
[{ name: '~/.claude/settings.json', cond: 'хук PreToolUse (user-level)' }],
[],
[{ name: 'хук skill-check', cond: 'пара: marker фиксирует вызов скила, check проверяет' }]
),
hk_skill_check: nd(
'Хук PreToolUse на Edit/Write/MultiEdit — skill-check.py: проверяет, был ли инвокирован подходящий скил перед правкой кода (§12 дисциплина).',
'PreToolUse, matcher Edit|Write|MultiEdit.',
'Пара со skill-marker. Энфорсит §12 — нельзя править код, минуя обязательный скил.',
[{ name: '~/.claude/settings.json', cond: 'хук PreToolUse (user-level)' }],
[],
[{ name: 'хук skill-marker', cond: 'пара skill-marker / skill-check' }]
),
hk_state_guard: nd(
'Хук PreToolUse на Edit/Write/MultiEdit/Bash/Agent — economy-state-guard.py: ловит обходы режима экономии (в т.ч. Bash-обход Edit и наследование суб-агентами).',
'PreToolUse, matcher Edit|Write|MultiEdit|Bash|Agent.',
'Часть системы экономии из 6 компонентов. Закрывает bypass-пути (Bash вместо Edit, суб-агенты).',
[{ name: '~/.claude/settings.json', cond: 'хук PreToolUse (user-level)' }],
[],
[{ name: 'хук economy-mode', cond: 'оба — система экономии' }]
),
hk_postcompact: nd(
'Хук PostCompact — economy-postcompact.py: после компактификации сессии переинжектит состояние режима экономии в контекст.',
'PostCompact — после сжатия истории сессии.',
'Часть системы экономии из 6 компонентов — гарантирует, что режим переживает компакт.',
[{ name: '~/.claude/settings.json', cond: 'хук PostCompact (user-level)' }],
[],
[]
),
hk_verifier: nd(
'Хук Stop — агент-верификатор (модель Sonnet 4.6): после ответа проверяет соответствие режиму экономии — заявления «готово» без тестов, правки без тестов, выборочные результаты.',
'Stop — после каждого ответа Claude (кроме тривиальных Q&A-ходов).',
'Решение decision:block при нарушении. На уровне экономии 5 — short-circuit {compliant:true}. Стоит денег (вызовы Sonnet).',
[{ name: '~/.claude/settings.json', cond: 'хук Stop типа agent (user-level)' }],
[],
[{ name: 'скил verification-before-completion', cond: 'верификатор энфорсит то, что требует скил' }]
),
hk_ruflo_queen: nd(
'Хук UserPromptSubmit — ruflo-queen-hook.mjs: при триггер-словах queen/королева в промпте инжектит жёсткую директиву маршрутизации задачи через ruflo Queen (Pravila §14).',
'UserPromptSubmit — перед каждым промптом; срабатывает на queen / королева.',
'Энфорсит §14 Pravila (hard-rule). Перед платным спавном роя — превью. Зарегистрирован в project .claude/settings.json.',
[
{ name: '.claude/settings.json', cond: 'хук UserPromptSubmit (project-level)' },
{ name: 'Pravila §14', cond: 'энфорсит queen-триггер' }
],
[],
[{ name: 'ruflo Queen', cond: 'хук маршрутизирует queen-задачи на Queen' }]
),
sk_regression: nd(
'Проектный скил /regression — единый прогон регрессии: Pest --parallel + Vitest + сборка, парсинг результатов (JSON-first для pest --parallel, см. квирк 94).',
'Перед коммитом/пушем или при запросе полной регрессии — единая сводка по тестам.',
'Реализация — .claude/skills/regression/ (SKILL.md + run.mjs + run.test.mjs). parsePest: JSON.parse строки {"tool":"pest"}, regex — fallback.',
[],
[],
[{ name: 'агент pest-parallel-debugger', cond: 'при падениях Pest --parallel передаёт разбор агенту' }]
),
mem_audit_b: nd(
'Memory-файл audit_B_status — статус и находки аудитов D и B (закрыты, 6 коммитов, 34/34 RLS после хотфикса).',
'При вопросах про аудиты D / B — историческая запись, аудит не запускать повторно.',
'Снимок-история. Не редактировать — новые аудиты в новые файлы.',
[], [], []
),
mem_audit_c: nd(
'Memory-файл audit_C_pending — аудит C, полностью реализован 07.05 (4 коммита).',
'При вопросах про аудит C — историческая запись, не запускать повторно.',
'Снимок-история. Детали — реестр §13.10, CHANGELOG §Y.',
[], [], []
),
mem_suppliercrm: nd(
'Memory-файл supplier_crm — бизнес-модель поставщика лидов crm.bp-gr.ru: B1/B2/B3 = платформы-источники, проекты = каналы, сделки = лиды, 14 статусов.',
'При работе с интеграцией поставщика — для доменной модели.',
'Доменное описание, синхронизировано со схемой Лидерры.',
[], [], []
),
mem_audit12: nd(
'Memory-файл full_audit_2026-05-12 — аудит портала 12.05 + post-audit + закрытия Q.DEFER.003/004.',
'При вопросах про аудит 12.05 и его последствия.',
'Снимок-история. Содержит уроки про axe-core race-condition и mis-attribution квирка 72.',
[], [], []
),
mem_audit14: nd(
'Memory-файл full_audit_2026-05-14 — аудит портала #3 (14 фаз, 26 находок, вердикт зелёный).',
'При вопросах про аудит #3 от 14.05.',
'Снимок-история. Не редактировать при последующих аудитах.',
[], [], []
),
mem_sprint1: nd(
'Memory-файл sprint1_p0_closure — закрытие 5 P0 UI-находок аудита (DealsView, Kanban DnD, BulkActionsBar, Admin-экраны).',
'При вопросах про Sprint 1 / P0-фиксы портала.',
'Снимок-история спринта. 10 атомарных коммитов.',
[], [], []
),
mem_sprint2: nd(
'Memory-файл sprint2_p1_progress — Sprint 2 P1 wave 1: планы A (Auth) / B (Settings) / C (Billing) — закрыты и запушены.',
'При вопросах про Sprint 2 / P1-фиксы.',
'Снимок-история. Содержит коррекцию: SyncSupplierProjectsJobTest — реальный баг времени, не квирк 72.',
[], [], []
),
mem_sprint3: nd(
'Memory-файл sprint3_progress — Sprint 3 (P1 wave 2): под-планы 3A-3F, 3A-3D закрыты и запушены, 3E-3F в ожидании.',
'При вопросах про Sprint 3 / P1 wave 2.',
'Снимок-история, обновляется по ходу спринта.',
[], [], []
),
};
// ════════════════════════════════════════════════════
@@ -1479,6 +1718,29 @@ const NODE_META = {
// ── MEMORY +1 (артефакт ruflo big-bang) ──
mem_ruflo: { since: '15.05.2026', changed: '16.05.2026', uses: 18, usesSrc: 'memory-чтение' },
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — узлы добавлены по полному аудиту карты ──
// uses новых узлов по транскриптам не измерялись (null = нет данных).
skill_creator: { since: '11.05.2026', changed: '—', uses: null, usesSrc: '—' },
claude_setup: { since: '11.05.2026', changed: '—', uses: null, usesSrc: '—' },
plugin_dev: { since: '—', changed: '—', uses: null, usesSrc: '—' },
context7: { since: '—', changed: '—', uses: null, usesSrc: '—' },
hk_self_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_skill_marker: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_skill_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_state_guard: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_postcompact: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_verifier: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
hk_ruflo_queen: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
sk_regression: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_audit_b: { since: '08.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_audit_c: { since: '07.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_suppliercrm: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_audit12: { since: '12.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_audit14: { since: '14.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_sprint1: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_sprint2: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
mem_sprint3: { since: '16.05.2026', changed: '—', uses: null, usesSrc: '—' },
};
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
@@ -1505,6 +1767,138 @@ DUPLICATE_GROUPS.forEach(g => g.members.forEach(m => {
}));
const DUP_NODE_SET = new Set(DUP_BY_NODE.keys()); // 12 узлов-членов парных дублей
// ════════════════════════════════════════════════════
// SECTION 3.5: ФУНКЦИОНАЛЬНЫЕ РАЗДЕЛЫ (функциональная квалификация)
// ════════════════════════════════════════════════════
// Разделы деятельности Лидерры. Каждый узел карты отнесён к одному разделу
// (NODE_SECTION). Часть разделов пока пустая — это бизнес-домены, под которые
// в карте dev-автоматики ещё нет узлов. Основа будущего «мозга»: 1 раздел =
// 1 playbook «как и что делать».
const SECTION_BUCKETS = [
{ id: 'A', label: 'Технические и продуктовые' },
{ id: 'B', label: 'Коммуникации' },
{ id: 'C', label: 'Бизнес и операции' },
{ id: 'D', label: 'Право и комплаенс' },
{ id: 'E', label: 'Мета и управление' },
];
const SECTIONS = [
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
{ id: 'C2', bucket: 'C', label: 'Продажи' },
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
{ id: 'E3', bucket: 'E', label: 'Документация' },
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
{ id: 'E7', bucket: 'E', label: 'Исследования' },
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
];
// Узел -> раздел. Покрывает все 83 узла карты.
const NODE_SECTION = {
// правила (4)
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
// плагины (5)
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
// скилы superpowers (14)
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
sk_wskills: 'E2', sk_elements: 'E3',
// скилы проекта (2)
sk_rls: 'A9', sk_qitem: 'E3',
// хуки (5)
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
// агенты (11)
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
ag_skreview: 'E2', ag_rls: 'A9',
// MCP-серверы (7)
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
// lefthook jobs (10)
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
lh_larastan: 'A1', lh_squawk: 'A9',
// memory files (16)
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
// ruflo (9)
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
ruflo_recall_hook: 'E4',
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
sk_regression: 'A5',
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
};
// Производные индексы для рендера панели и Паспорта.
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
const SECTION_NODES = new Map(SECTIONS.map(s => [s.id, []]));
NODES.forEach(n => {
const sid = NODE_SECTION[n.id];
if (sid && SECTION_NODES.has(sid)) SECTION_NODES.get(sid).push(n.id);
});
// ════════════════════════════════════════════════════
// SECTION 3.5: WISHLIST — отложенный backlog (todo-лист хотелок)
// ════════════════════════════════════════════════════
// Редактируемый список отложенных «хотелок» развития мозга/портала.
// Добавить хотелку = добавить объект. status: 'next' | 'blocked' | 'idea'.
const WISH_STATUS = {
next: { emoji: '▶', label: 'к работе', color: '#859900' },
blocked: { emoji: '⏸', label: 'ждёт зависимости', color: '#b58900' },
idea: { emoji: '💭', label: 'идея', color: '#586e75' },
};
const WISHLIST = [
{ id: 'W1', status: 'next', section: 'E8',
title: 'K7-spike — починка embeddings ruflo',
note: 'Tensor.location / onnxruntime version sync. Гейт жизнеспособности моста claude-mem→ReasoningBank. ~1-2 ч, systematic-debugging, ≥3 гипотезы.' },
{ id: 'W2', status: 'blocked', section: 'E8',
title: 'Мост claude-mem → ReasoningBank',
note: 'Замкнутый self-learning loop: захват (claude-mem) → адаптер+LLM-трансформ → сток (ruflo memory) → recall. Gated на W1.' },
{ id: 'W3', status: 'blocked', section: 'E8',
title: 'claude-mem #1 — установка плагином',
note: 'Слой авто-захвата моста. Ставить как плагин (не npx — риск перезаписи settings.json). Роль решается после W1.' },
{ id: 'W4', status: 'blocked', section: 'E8',
title: 'Двухуровневый ремонтник моста',
note: 'Tier 1 — auto-heal операционки (рестарт демона, re-run h7-patch, retry). Tier 2 — circuit-breaker на семантику (halt, не чинить). Часть W2.' },
];
// ════════════════════════════════════════════════════
// SECTION 4: VIS INIT
// ════════════════════════════════════════════════════
@@ -1564,6 +1958,8 @@ function renderLegendItem(item) {
function showNodeLegend(nodeId) {
document.getElementById('legend-node-content').style.display = '';
document.getElementById('legend-edge-content').style.display = 'none';
document.getElementById('legend-sections-content').style.display = 'none';
document.getElementById('legend-wishlist-content').style.display = 'none';
const node = NODES.find(n => n.id === nodeId);
const details = NODE_DETAILS[nodeId];
const panel = document.getElementById('legend-panel');
@@ -1581,6 +1977,8 @@ function showNodeLegend(nodeId) {
const meta = NODE_META[nodeId] || { since: '—', changed: '—', uses: null, usesSrc: '—' };
document.getElementById('ld-since').textContent = meta.since || '—';
document.getElementById('ld-changed').textContent = meta.changed || '—';
const _sec = NODE_SECTION[nodeId] ? SECTION_BY_ID.get(NODE_SECTION[nodeId]) : null;
document.getElementById('ld-section').textContent = _sec ? `${_sec.id} · ${_sec.label}` : '—';
const usesEl = document.getElementById('ld-uses');
if (meta.uses === null || meta.uses === undefined) {
@@ -1654,6 +2052,8 @@ function showEdgeLegend(edgeId) {
const panel = document.getElementById('legend-panel');
document.getElementById('legend-node-content').style.display = 'none';
document.getElementById('legend-edge-content').style.display = '';
document.getElementById('legend-sections-content').style.display = 'none';
document.getElementById('legend-wishlist-content').style.display = 'none';
const fromNode = NODES.find(n => n.id === edge.from);
const toNode = NODES.find(n => n.id === edge.to);
@@ -1693,6 +2093,59 @@ function showEdgeLegend(edgeId) {
panel.classList.add('visible');
}
// ── Панель «Разделы» — функциональная квалификация (3-й режим легенды) ──
function showSectionsLegend() {
document.getElementById('legend-node-content').style.display = 'none';
document.getElementById('legend-edge-content').style.display = 'none';
document.getElementById('legend-wishlist-content').style.display = 'none';
document.getElementById('legend-sections-content').style.display = '';
let html = '';
for (const bucket of SECTION_BUCKETS) {
html += `<div class="sect-bucket-h">${bucket.id}. ${bucket.label}</div>`;
for (const sec of SECTIONS.filter(s => s.bucket === bucket.id)) {
const nodeIds = SECTION_NODES.get(sec.id) || [];
const empty = nodeIds.length === 0;
html += `<div class="sect-row${empty ? ' sect-empty' : ''}">`;
html += `<div><span class="sect-name"><span class="sect-id">${sec.id}</span> ${sec.label}</span> <span class="sect-cnt">· ${nodeIds.length}</span></div>`;
if (empty) {
html += `<div class="sect-empty-mark">— пусто (playbook ещё не наполнен) —</div>`;
} else {
html += '<div class="sect-chips">' + nodeIds.map(id => {
const node = NODES.find(n => n.id === id);
const lbl = node ? node.label.replace(/\n/g, ' ') : id;
return `<span class="sect-chip" data-node="${id}">${lbl}</span>`;
}).join('') + '</div>';
}
html += '</div>';
}
}
document.getElementById('sect-list').innerHTML = html;
document.getElementById('legend-panel').classList.add('visible');
}
// ── Панель «Хотелки» — отложенный backlog (режим легенды) ──
function showWishlistLegend() {
document.getElementById('legend-node-content').style.display = 'none';
document.getElementById('legend-edge-content').style.display = 'none';
document.getElementById('legend-sections-content').style.display = 'none';
document.getElementById('legend-wishlist-content').style.display = '';
let html = '';
for (const w of WISHLIST) {
const st = WISH_STATUS[w.status] || WISH_STATUS.idea;
html += `<div class="wish-row wish-${w.status}">`;
html += `<div class="wish-head"><span class="wish-id">${w.id}</span> ${w.title}</div>`;
html += `<div class="wish-status" style="color:${st.color}">${st.emoji} ${st.label}</div>`;
html += `<div class="wish-note">${w.note}</div>`;
if (w.section) {
const sec = SECTION_BY_ID.get(w.section);
html += `<div class="wish-sect">Раздел: ${w.section}${sec ? ' · ' + sec.label : ''}</div>`;
}
html += '</div>';
}
document.getElementById('wish-list').innerHTML = html;
document.getElementById('legend-panel').classList.add('visible');
}
network.on('click', params => {
if (params.nodes.length === 1) {
const id = params.nodes[0];
@@ -2003,6 +2456,8 @@ const HIGHLIGHT = (function setupHighlight() {
// iter6 — клик по кнопке режима heat/dup
const ctl = e.target.closest('.cat-ctl');
if (ctl) {
if (ctl.id === 'cat-ctl-sect') { showSectionsLegend(); return; }
if (ctl.id === 'cat-ctl-wish') { showWishlistLegend(); return; }
setViewMode(ctl.id === 'cat-ctl-heat' ? 'heat' : 'dup');
applyHighlight();
updateLegendVisuals();
@@ -2026,6 +2481,19 @@ const HIGHLIGHT = (function setupHighlight() {
};
})();
// Клик по чипу узла в панели «Разделы» — открыть паспорт узла + сфокусировать граф.
document.getElementById('sect-list').addEventListener('click', e => {
const chip = e.target.closest('.sect-chip');
if (!chip) return;
const id = chip.dataset.node;
if (HIGHLIGHT.state.viewMode === null) {
HIGHLIGHT.setSelectedNode(id);
HIGHLIGHT.applyHighlight();
}
network.focus(id, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
showNodeLegend(id);
});
window.addEventListener('DOMContentLoaded', restoreLegendWidth);
</script>
</body>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,558 @@
# Sprint 5A — Auth polish 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:** Закрыть 5 P2-эпиков подсистемы Auth из portal-wide аудита (A1, A4, A5, A6, A8) — Sprint 5, под-план A.
**Architecture:** Точечные правки 4 auth-view'ов (Vue 3 + Vuetify 3) + DemoSeeder-тулинг. Каждый эпик — отдельная атомарная задача с TDD-циклом (failing test → impl → green → commit). A5 — задача-характеризация (regression-тест), т.к. находка аудита не воспроизводится против текущего кода.
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (frontend), Pest 4 (backend), Laravel 13.
**Источник:** [docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md) §3 Sprint 5 + §4 раздел A.
**Исполнять в изолированном worktree** off `origin/main` (`c64be74`): текущая ветка `feat/sprint3f-api-middleware` отстала от origin/main и содержит несвязанный staged WIP (`automation-graph.html`, `dev-indices.json`) — его НЕ трогать. Ветка спринта: `feat/sprint5a-auth-polish`.
---
## Эпики (из аудита §4.A)
| ID | Объект | Находка | Решение |
|---|---|---|---|
| A1 | `LoginView.vue` Yandex 360 SSO | dead stub без `:disabled` | disabled + tooltip «после Б-1» |
| A4 | `ResetPasswordView.vue` поле подтверждения | нет `:error-messages` на несовпадение | computed-ошибка «Пароли не совпадают» |
| A5 | `ForgotPasswordView.vue` catch | аудит: «fallback недостижим» | **не воспроизводится** → regression-тест, фиксирующий достижимость |
| A6 | `TwoFactorView.vue` таймер | хардкод «02:34» | реальный обратный отсчёт TOTP-окна (30 с) |
| A8 | DemoSeeder | «422 при логине» (не пере-сидирован) | `composer demo:seed` + раздел в README + idempotency-тест |
## File Structure
| Файл | Ответственность | Задача |
|---|---|---|
| `app/resources/js/views/auth/LoginView.vue` | экран входа — SSO-кнопка в disabled-tooltip обёртке | T1 (A1) |
| `app/resources/js/views/auth/ResetPasswordView.vue` | экран нового пароля — computed-ошибка подтверждения | T2 (A4) |
| `app/resources/js/views/auth/ForgotPasswordView.vue` | без правок — только regression-тест | T3 (A5) |
| `app/resources/js/views/auth/TwoFactorView.vue` | экран 2FA — таймер TOTP-окна на `setInterval` | T4 (A6) |
| `app/composer.json` | +script `demo:seed` | T5 (A8) |
| `app/README.md` | +раздел «Демо-данные» | T5 (A8) |
| `app/tests/Feature/DemoSeederTest.php` | новый — idempotency DemoSeeder | T5 (A8) |
| `app/tests/Frontend/{LoginView,ResetPasswordView,ForgotPasswordView,TwoFactorView}.spec.ts` | +по 1 тесту на эпик | T1-T4 |
**Команды (запускать из `app/`):**
- Один Vitest-файл: `npx vitest run tests/Frontend/<File>.spec.ts`
- Один Vitest-тест: `npx vitest run tests/Frontend/<File>.spec.ts -t "<имя>"`
- Pest-файл: `php artisan test tests/Feature/DemoSeederTest.php`
- Полная регрессия: `npm run test:vue`, `php artisan test`, `npm run type-check`, `npm run lint:vue`, `composer pint`
---
## Task 1: A1 — LoginView Yandex 360 SSO disabled + tooltip
**Files:**
- Modify: `app/resources/js/views/auth/LoginView.vue:107` (SSO-кнопка) + `<style>` блок
- Test: `app/tests/Frontend/LoginView.spec.ts` (+1 тест)
- [ ] **Step 1: Написать падающий тест**
Добавить в `app/tests/Frontend/LoginView.spec.ts` внутрь `describe('LoginView.vue', ...)` после последнего `it`:
```ts
it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => {
const wrapper = await mountLoginView();
const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));
expect(ssoBtn).toBeDefined();
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
});
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
Run: `npx vitest run tests/Frontend/LoginView.spec.ts -t "A1"`
Expected: FAIL — кнопка не disabled, класс `v-btn--disabled` отсутствует.
- [ ] **Step 3: Реализация**
В `app/resources/js/views/auth/LoginView.vue` заменить строку 107:
```html
<v-btn block size="large" variant="outlined"> Войти через Yandex 360 </v-btn>
```
на (disabled-кнопка не ловит hover — tooltip вешаем на обёртку-`div`):
```html
<v-tooltip
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
location="top"
>
<template #activator="{ props }">
<div v-bind="props" class="yandex-sso-wrap">
<v-btn block size="large" variant="outlined" disabled>
Войти через Yandex 360
</v-btn>
</div>
</template>
</v-tooltip>
```
В `<style scoped>` добавить после блока `.login-form { ... }`:
```css
.yandex-sso-wrap {
width: 100%;
}
```
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
Run: `npx vitest run tests/Frontend/LoginView.spec.ts`
Expected: PASS — все тесты файла (старые 6 + новый A1). Старый тест «содержит ... Yandex 360 SSO» проходит — текст кнопки сохранён.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/auth/LoginView.vue app/tests/Frontend/LoginView.spec.ts
git commit -m "feat(auth): A1 — Yandex 360 SSO disabled + tooltip (Sprint 5A)"
```
---
## Task 2: A4 — ResetPasswordView ошибка несовпадения паролей
**Files:**
- Modify: `app/resources/js/views/auth/ResetPasswordView.vue` (script + поле подтверждения, строки 110-118)
- Test: `app/tests/Frontend/ResetPasswordView.spec.ts` (+1 тест)
- [ ] **Step 1: Написать падающий тест**
Добавить в `app/tests/Frontend/ResetPasswordView.spec.ts` внутрь `describe('ResetPasswordView.vue', ...)` после последнего `it`:
```ts
it('A4: показывает ошибку при несовпадении пароля и подтверждения', async () => {
const wrapper = await mountReset();
const pwInputs = wrapper.findAll('input[type="password"]');
await pwInputs[0].setValue('new-strong-pass-1234');
await pwInputs[1].setValue('different-pass-9999');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Пароли не совпадают');
});
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts -t "A4"`
Expected: FAIL — текст «Пароли не совпадают» отсутствует (у поля подтверждения нет `:error-messages`).
- [ ] **Step 3: Реализация**
В `app/resources/js/views/auth/ResetPasswordView.vue` в `<script setup>` добавить после объявления `canSubmit` (после строки 38, перед `async function handleSubmit`):
```ts
/**
* Ошибка поля подтверждения: client-side проверка совпадения +
* проброс backend-ошибки `password_confirmation` если придёт с 422.
*/
const confirmationError = computed<string[]>(() => {
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
return ['Пароли не совпадают'];
}
return errors.value.password_confirmation ?? [];
});
```
В `<template>` заменить блок поля «Повторите пароль» (строки 110-118):
```html
<v-text-field
v-model="passwordConfirmation"
label="Повторите пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
required
/>
```
на:
```html
<v-text-field
v-model="passwordConfirmation"
label="Повторите пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
required
:error-messages="confirmationError"
/>
```
(`computed` уже импортирован — строка 17: `import { computed, ref } from 'vue';`.)
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts`
Expected: PASS — старые 5 тестов + новый A4. Тест «успешный submit» проходит: при совпадающих паролях `confirmationError` пустой.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/auth/ResetPasswordView.vue app/tests/Frontend/ResetPasswordView.spec.ts
git commit -m "feat(auth): A4 — ResetPassword ошибка несовпадения паролей (Sprint 5A)"
```
---
## Task 3: A5 — ForgotPasswordView regression-тест generic fallback
> **NB:** Находка аудита A5 «fallback недостижим» **не воспроизводится** против текущего кода (`extractValidationErrors` возвращает строго `Record|null`; store сбрасывает `lockoutSeconds=null` в начале запроса). Эта задача — не TDD-фикс, а **характеризационный regression-тест**, фиксирующий, что generic-fallback показывается на не-валидационной/не-429 ошибке. Код view НЕ меняется.
**Files:**
- Test: `app/tests/Frontend/ForgotPasswordView.spec.ts` (+1 тест)
- (правок в `ForgotPasswordView.vue` не предполагается)
- [ ] **Step 1: Написать характеризационный тест**
Добавить в `app/tests/Frontend/ForgotPasswordView.spec.ts` внутрь `describe('ForgotPasswordView.vue', ...)` после последнего `it`:
```ts
it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => {
// forgotPassword отклоняется обычной ошибкой; extractValidationErrors и
// extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла).
vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error'));
const wrapper = await mountForgot();
await wrapper.find('input[type="email"]').setValue('user@example.ru');
await wrapper.find('form').trigger('submit.prevent');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Произошла ошибка. Попробуйте позже.');
});
```
- [ ] **Step 2: Запустить тест**
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts -t "A5"`
Expected: **PASS** — поведение fallback корректно, находка не воспроизводится.
- [ ] **Step 3: Развилка по результату Step 2**
- **Если PASS** (ожидаемо) → A5 классифицируется как «verified — not reproduced»; код view не меняется; переходим к Step 4.
- **Если FAIL** (неожиданно — реальный баг) → применить фикс в `app/resources/js/views/auth/ForgotPasswordView.vue`, заменив блок `catch` (строки 32-39):
```ts
} catch (error: unknown) {
const validationErrors = extractValidationErrors(error);
if (validationErrors && Object.keys(validationErrors).length > 0) {
errors.value = validationErrors;
} else if (auth.lockoutSeconds === null) {
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
}
}
```
Перезапустить Step 2 — добиться PASS, и `git add` также файл view.
- [ ] **Step 4: Запустить весь файл — убедиться, что все проходят**
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts`
Expected: PASS — старые 5 тестов + новый A5.
- [ ] **Step 5: Commit**
```bash
git add app/tests/Frontend/ForgotPasswordView.spec.ts
git commit -m "test(auth): A5 — regression generic fallback ForgotPassword (Sprint 5A)"
```
(При срабатывании развилки FAIL — добавить в `git add` также `app/resources/js/views/auth/ForgotPasswordView.vue` и заменить тип коммита на `fix(auth): A5 — ...`.)
---
## Task 4: A6 — TwoFactorView реальный обратный отсчёт TOTP-окна
**Files:**
- Modify: `app/resources/js/views/auth/TwoFactorView.vue` (script: import + countdown-логика + onMounted/onUnmounted; template строка 129)
- Test: `app/tests/Frontend/TwoFactorView.spec.ts` (+1 тест с fake timers)
- [ ] **Step 1: Написать падающий тест**
Добавить в `app/tests/Frontend/TwoFactorView.spec.ts` внутрь `describe('TwoFactorView.vue', ...)` после последнего `it`:
```ts
it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20
try {
const wrapper = await mountTwoFactor();
const el = wrapper.find('[data-testid="totp-countdown"]');
expect(el.exists()).toBe(true);
expect(el.text()).toBe('00:20');
vi.setSystemTime(new Date(15_000)); // epoch 15 c → 30 - 15 = 15
vi.advanceTimersByTime(1000);
await wrapper.vm.$nextTick();
expect(el.text()).toBe('00:15');
} finally {
vi.useRealTimers();
}
});
```
В первой строке файла дополнить импорт vitest — `vi` сейчас не импортируется:
```ts
import { describe, it, expect } from 'vitest';
```
заменить на:
```ts
import { describe, it, expect, vi } from 'vitest';
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts -t "A6"`
Expected: FAIL — элемент `[data-testid="totp-countdown"]` не существует (сейчас хардкод `02:34` без testid).
- [ ] **Step 3: Реализация**
В `app/resources/js/views/auth/TwoFactorView.vue` заменить строку 14 (импорт):
```ts
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
```
на:
```ts
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
```
Заменить блок (строки 28-36) — `userEmail` + `onMounted`:
```ts
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
// прямой URL без login → отправляем на /login.
onMounted(() => {
if (!auth.requires2fa && !auth.isAuthenticated) {
router.replace('/login');
}
});
```
на:
```ts
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
/**
* TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд.
* Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»).
* Значение 30..1 секунд, формат «00:NN».
*/
function totpWindowLeft(): number {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
const totpSecondsLeft = ref(totpWindowLeft());
const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`);
let totpTimer: ReturnType<typeof setInterval> | undefined;
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
// прямой URL без login → отправляем на /login.
onMounted(() => {
if (!auth.requires2fa && !auth.isAuthenticated) {
router.replace('/login');
}
totpTimer = setInterval(() => {
totpSecondsLeft.value = totpWindowLeft();
}, 1000);
});
onUnmounted(() => {
if (totpTimer) clearInterval(totpTimer);
});
```
В `<template>` заменить строку 129:
```html
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
```
на:
```html
<span
class="text-caption text-medium-emphasis font-mono"
:title="`До смены кода в приложении: ${totpCountdown}`"
data-testid="totp-countdown"
>{{ totpCountdown }}</span
>
```
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts`
Expected: PASS — старые 3 теста + новый A6. Старые тесты используют реальные таймеры; `setInterval` чистится через `onUnmounted` при teardown.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/auth/TwoFactorView.vue app/tests/Frontend/TwoFactorView.spec.ts
git commit -m "feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A)"
```
---
## Task 5: A8 — DemoSeeder re-seed script + README + idempotency-тест
**Files:**
- Create: `app/tests/Feature/DemoSeederTest.php`
- Modify: `app/composer.json` (блок `scripts`)
- Modify: `app/README.md` (+раздел «Демо-данные»)
- [ ] **Step 1: Написать падающий тест**
Создать `app/tests/Feature/DemoSeederTest.php`:
```php
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\DemoSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('DemoSeeder идемпотентен — повторный запуск не дублирует demo-tenant и admin', function () {
$this->seed(DemoSeeder::class);
$this->seed(DemoSeeder::class);
expect(Tenant::query()->where('subdomain', 'demo')->count())->toBe(1)
->and(User::query()->where('email', 'admin@demo.local')->count())->toBe(1);
});
```
- [ ] **Step 2: Запустить тест — убедиться, что он есть и проходит/падает осознанно**
Run: `php artisan test tests/Feature/DemoSeederTest.php`
Expected: **PASS** — DemoSeeder уже идемпотентен (`updateOrCreate`/`updateOrInsert`). Тест фиксирует это как регрессионную защиту «re-seed скрипта».
Если FAIL — значит сидер не идемпотентен; это реальный баг, исправить `DemoSeeder.php` (привести вставки к `updateOrInsert` с ключом `tenant_id`+`name`) и добиться PASS.
- [ ] **Step 3: Добавить composer-script `demo:seed`**
В `app/composer.json` в блоке `"scripts"` добавить строку после `"audit-offline"`:
Заменить:
```json
"audit-offline": "@composer audit --locked",
"ide-helper": [
```
на:
```json
"audit-offline": "@composer audit --locked",
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
"ide-helper": [
```
- [ ] **Step 4: Добавить раздел в `app/README.md`**
Дописать в конец файла `app/README.md` раздел:
```markdown
## Демо-данные (dev)
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
`php artisan migrate --seed` в окружениях `local` и `testing`
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
**Учётные данные демо-входа:**
- URL: `/login`
- Email: `admin@demo.local`
- Пароль: `password`
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
**Пере-сидировать демо-данные** (идемпотентно, существующие записи обновляются,
дублей не создаётся):
```bash
composer demo:seed
```
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
```
- [ ] **Step 5: Проверить `demo:seed` вручную + запустить тест**
Run: `composer demo:seed`
Expected: вывод содержит `Demo tenant id=... subdomain=demo` и `Login: admin@demo.local / password`.
Run: `php artisan test tests/Feature/DemoSeederTest.php`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add app/tests/Feature/DemoSeederTest.php app/composer.json app/README.md
git commit -m "feat(dev): A8 — composer demo:seed + README демо-данные + idempotency-тест (Sprint 5A)"
```
---
## Task 6: Регрессия Sprint 5A
**Files:** нет правок — финальный gate.
- [ ] **Step 1: Полный Vitest**
Run (из `app/`): `npm run test:vue`
Expected: все файлы зелёные, 0 failed (4 новых теста A1/A4/A5/A6 + дельта).
- [ ] **Step 2: Полный Pest**
Run (из `app/`): `php artisan test`
Expected: 0 failed (новый `DemoSeederTest` зелёный).
- [ ] **Step 3: Type-check + lint + формат**
Run (из `app/`):
```
npm run type-check
npm run lint:vue
composer pint
```
Expected: vue-tsc 0 ошибок; ESLint 0 ошибок; Pint без изменений (или авто-формат закоммитить отдельным `style:`-коммитом).
- [ ] **Step 4: Зафиксировать результат**
Выписать в финальный отчёт фактические числа Pest/Vitest (passed/failed/skipped) с указанием дельты. Если что-то красное — НЕ заявлять Sprint 5A закрытым; чинить по `superpowers:systematic-debugging`.
---
## Self-Review
**1. Spec coverage:** A1 ✅ T1 / A4 ✅ T2 / A5 ✅ T3 (re-classified verify) / A6 ✅ T4 / A8 ✅ T5. Все 5 эпиков раздела A из аудита §3 Sprint 5 покрыты.
**2. Placeholder scan:** код приведён полностью в каждом шаге; команды и Expected — конкретны. Развилка T3 Step 3 содержит готовый фикс-код, не «TODO». Раздел README — полный текст.
**3. Type consistency:** `confirmationError` (T2) — `computed<string[]>`, совместим с `:error-messages`. `totpWindowLeft()`/`totpSecondsLeft`/`totpCountdown`/`totpTimer` (T4) — имена консистентны между script и template (`data-testid="totp-countdown"` ↔ тест T4 Step 1). `mountLoginView`/`mountReset`/`mountForgot`/`mountTwoFactor` — существующие хелперы spec-файлов, не переопределяются.
**Известное ограничение:** A5 — задача-характеризация, не фикс (находка аудита не воспроизводится). При закрытии Sprint 5A зафиксировать статус A5 как «verified — not reproduced» в отчёте/реестре.
@@ -0,0 +1,46 @@
# Как перенести данные из crm.bp-gr.ru
> Опциональный модуль. Нужен, если вы раньше вели лиды в crm.bp-gr.ru и хотите
> перенести историю в Лидерру. Новые лиды приходят через webhook автоматически —
> импорт нужен только для исторических данных.
## Шаг 1. Выгрузите CSV из crm.bp-gr.ru
Откройте в crm.bp-gr.ru:
`https://crm.bp-gr.ru/admin/visit/index-visit-archive?ext=csv`
Сохранится файл с колонками:
`id, Проект, Тег проекта, Телефон, Создано, Напоминание, Комментарий, Состояние, Имя`.
Файл должен быть в кодировке UTF-8, разделитель — запятая.
## Шаг 2. Загрузите файл в Лидерре
1. Откройте раздел **«Импорт данных»** в боковом меню.
2. Выберите CSV-файл.
3. (Опционально) включите **«Пробный прогон»** — проверка файла без записи сделок.
4. Нажмите **«Загрузить»**.
## Шаг 3. Дождитесь завершения
Импорт идёт в фоне. На экране виден прогресс; по завершении придёт письмо
со сводкой: сколько сделок добавлено, обновлено, пропущено.
Повторная загрузка того же файла безопасна — дубли не создаются (сделки
сопоставляются по `id` из crm.bp-gr.ru). Баланс лидов при импорте **не списывается**.
## Шаг 4. Замапьте неизвестные статусы (если появятся)
Если в файле встретятся статусы воронки, которых нет в Лидерре, появится баннер
**«Найдено N неизвестных статусов»**. Нажмите **«Замапить»**, выберите для каждого
соответствие из стандартной воронки и сохраните. Затем загрузите файл ещё раз —
сделки с этими статусами обновятся автоматически.
## Возможные ошибки
| Симптом | Причина | Решение |
|---|---|---|
| Строка пропущена | Невалидный телефон (не `7XXXXXXXXXX`) или дата | Исправьте строку в CSV |
| Импорт «failed» | Файл повреждён / не CSV | Перевыгрузите файл из crm.bp-gr.ru |
| Статус сделки стал «Новые» | Неизвестный статус, ещё не замаплен | Замапьте статус и повторите импорт |