From 16d8191d08fbc0095dde60bbdf192f8401e98751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 7 Aug 2026 08:05:43 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=BA=D0=B8=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8=20=C2=AB=D0=90=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=C2=BB=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=20=C2=AB=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=20=D0=BB=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 задач по TDD: два сервиса на бэкенде (вердикт с полоской и лента дел из пяти источников), ручка подгрузки, живая сортировка списка клиентов, типы и маппер, русские фразы, панель Vue, точка состояния в списке, полный прогон, приёмка глазами. Co-Authored-By: Claude Opus 5 (1M context) --- docs/observer/STATUS.md | 8 +- ...-08-07-aktivnost-tenanta-zhiv-li-klient.md | 2361 +++++++++++++++++ 2 files changed, 2365 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 9a50bc9d..ee29e3fe 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-08-07T04:49:02.229Z +Last updated: 2026-08-07T05:04:59.524Z | Контролёр | Состояние | Детали | |---|---|---| @@ -116,9 +116,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 23856 | MsMpEng | 18.23ч | 10094695.3ч | -| 11840 | Code | 16.29ч | 0.0ч | -| 4 | System | 9.14ч | NaNч | +| 23856 | MsMpEng | 18.87ч | 0.0ч | +| 11840 | Code | 16.41ч | NaNч | +| 4 | System | 9.20ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md b/docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md new file mode 100644 index 00000000..7d0a0635 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-aktivnost-tenanta-zhiv-li-klient.md @@ -0,0 +1,2361 @@ +# Вкладка «Активность» — «жив ли клиент». План работ + +> **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:** Переделать вкладку «Активность» в карточке клиента админки так, чтобы она одним взглядом отвечала «жив ли клиент», и оживить мёртвую сортировку списка клиентов по последней активности. + +**Architecture:** Два новых сервиса на бэкенде считают всё на лету из журналов (`auth_log`, `tenant_operations_log`, `activity_log`, `balance_transactions`, `saas_invoices`) — в базу ничего не пишем, миграций нет. Первый сервис отдаёт вердикт «жив/остывает/пропал», полоску 90 дней и четыре счётчика; второй собирает ленту дел одним объединяющим запросом с постраничной подгрузкой. Фронт получает структурные события и складывает из них русские фразы. + +**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (партиционированные журналы) / Pest 4 · Vue 3 + Vuetify 3 / Vitest + +**Спека:** [docs/superpowers/specs/2026-08-07-aktivnost-tenanta-zhiv-li-klient-design.md](../specs/2026-08-07-aktivnost-tenanta-zhiv-li-klient-design.md) + +--- + +## Карта файлов + +**Создаём:** + +| Файл | За что отвечает | +|---|---| +| `app/app/Services/Admin/TenantAlivenessService.php` | Вердикт, полоска 90 дней, четыре счётчика. Один класс — один вопрос «жив ли». | +| `app/app/Services/Admin/TenantActivityFeedService.php` | Лента дел из пяти источников + счётчик дел за период. Держит единственную копию объединяющего SQL. | +| `app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue` | Вся панель «Активность»: верхний блок, полоска, лента, кнопка «показать ещё». | +| `app/resources/js/composables/tenantActivityFormatters.ts` | Русские фразы событий, расшифровка устройства, подписи вердикта. | +| `app/tests/Feature/Admin/AdminTenantAlivenessTest.php` | Пороги, московские сутки, пустой журнал. | +| `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php` | Состав ленты, отсутствие системного шума, постраничность. | +| `app/tests/Feature/Admin/AdminTenantsListActivityTest.php` | Сортировка списка и число запросов. | +| `app/tests/Frontend/tenantActivityFormatters.spec.ts` | Фразы и устройство. | +| `app/tests/Frontend/TenantActivityPane.spec.ts` | Отрисовка панели. | + +**Меняем:** + +| Файл | Что | +|---|---| +| `app/app/Http/Controllers/Api/AdminTenantsController.php` | `show` отдаёт `aliveness` и новую `activity`; `index` считает последний вход и состояние; новый метод `activity` для подгрузки. | +| `app/routes/web.php:178` (рядом) | Новый маршрут подгрузки ленты. | +| `app/resources/js/api/admin.ts` | Типы `ApiTenantAliveness`, `ApiTenantActivityItem`, функция `getAdminTenantActivity`. | +| `app/resources/js/composables/adminTenantDetailMapper.ts` | Разбор новых полей вместо старого `mapActivity`. | +| `app/resources/js/composables/mockTenantDetail.ts` | Типы `TenantAliveness`, `TenantActivityItem` вместо `TenantActivityEvent`. | +| `app/resources/js/composables/mockTenants.ts` | Поле `alivenessState` в `AdminTenant`. | +| `app/resources/js/composables/adminTenantsMapper.ts` | Проброс состояния и текста последней активности. | +| `app/resources/js/components/admin/tenants/TenantsTable.vue` | Цветная точка состояния рядом с колонкой активности. | +| `app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue` | Панель `activity` заменяется на ``; колонка на вкладке «Пользователи» переключается на «Последний вход». | +| `app/resources/js/views/admin/AdminTenantDetailView.vue` | Проброс `aliveness` и обработчик подгрузки. | + +--- + +## Договорённости об именах (нарушать нельзя — на них завязаны все задачи) + +Состояния (одна строка, приходит с бэкенда): + +``` +'active' заходил ≤ 3 суток назад зелёный +'cooling' 4–13 суток жёлтый +'lost' ≥ 14 суток красный +'never_logged_in' есть регистрация, входов ноль красный +'no_data' в журнале нет вообще ничего серый +``` + +Форма блока `aliveness` в ответе `GET /api/admin/tenants/{subdomain}`: + +```json +{ + "state": "cooling", + "last_login_at": "2026-08-06T03:23:12+00:00", + "days_since_login": 1, + "last_login_ip": "5.166.40.65", + "last_login_user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", + "days": [{ "date": "2026-08-05", "visits": 1 }, { "date": "2026-08-06", "visits": 1 }], + "counters": { "visits_30d": 2, "active_days_30d": 2, "actions_30d": 2, "failed_logins_30d": 0 } +} +``` + +`days` содержит только сутки, в которых визиты были. Пустые сутки фронт достраивает сам, зная окно в 90 дней. + +Форма элемента ленты: + +```json +{ + "id": "ops-1234", + "at": "2026-08-05T07:34:48+00:00", + "kind": "project", + "event": "project.created", + "actor": "ekb96@well.ru", + "subject": "Окна ЕКБ", + "amount_rub": null, + "bulk_count": 0 +} +``` + +`kind` — `project` | `deal` | `money`. `id` уникален между источниками за счёт приставки: `ops-`, `act-`, `tx-`, `inv-{id}-issued`, `inv-{id}-paid`. + +--- + +## Задача 1: Сервис «жив ли клиент» — вердикт и пороги + +**Files:** +- Create: `app/app/Services/Admin/TenantAlivenessService.php` +- Test: `app/tests/Feature/Admin/AdminTenantAlivenessTest.php` + +- [ ] **Шаг 1: Написать падающий тест на пороги** + +Создать `app/tests/Feature/Admin/AdminTenantAlivenessTest.php`: + +```php +insert([ + 'actor_type' => 'tenant_user', + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'email' => 'client@example.test', + 'event' => $event, + 'ip_address' => '5.166.40.65', + 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + 'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(), + ]); +} + +function alivenessTenant(): array +{ + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + return [(int) $tenant->id, (int) $user->id]; +} + +it('считает клиента живым, если он заходил не позже трёх суток назад', function () { + [$tenantId, $userId] = alivenessTenant(); + $now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow'); + + alivenessLog($tenantId, $userId, 'login_success', '2026-08-04 09:00:00'); + + $result = app(TenantAlivenessService::class)->forTenant($tenantId, $now); + + expect($result['state'])->toBe('active'); + expect($result['days_since_login'])->toBe(3); +}); + +it('считает клиента остывающим на четвёртые сутки', function () { + [$tenantId, $userId] = alivenessTenant(); + $now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow'); + + alivenessLog($tenantId, $userId, 'login_success', '2026-08-03 09:00:00'); + + $result = app(TenantAlivenessService::class)->forTenant($tenantId, $now); + + expect($result['state'])->toBe('cooling'); + expect($result['days_since_login'])->toBe(4); +}); + +it('считает клиента пропавшим на четырнадцатые сутки', function () { + [$tenantId, $userId] = alivenessTenant(); + $now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow'); + + alivenessLog($tenantId, $userId, 'login_success', '2026-07-24 09:00:00'); + + $result = app(TenantAlivenessService::class)->forTenant($tenantId, $now); + + expect($result['state'])->toBe('lost'); + expect($result['days_since_login'])->toBe(14); +}); + +it('не путает сутки в первые три часа ночи по Москве', function () { + [$tenantId, $userId] = alivenessTenant(); + // 01:30 МСК седьмого августа — это ещё 22:30 UTC шестого. + // Наивная граница дала бы «вчера» вместо «сегодня». + $now = CarbonImmutable::parse('2026-08-07 01:30:00', 'Europe/Moscow'); + + alivenessLog($tenantId, $userId, 'login_success', '2026-08-07 01:00:00'); + + $result = app(TenantAlivenessService::class)->forTenant($tenantId, $now); + + expect($result['days_since_login'])->toBe(0); + expect($result['state'])->toBe('active'); +}); + +it('отличает зарегистрировавшегося без единого входа', function () { + [$tenantId, $userId] = alivenessTenant(); + $now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow'); + + alivenessLog($tenantId, $userId, 'register_success', '2026-08-06 09:00:00'); + + $result = app(TenantAlivenessService::class)->forTenant($tenantId, $now); + + expect($result['state'])->toBe('never_logged_in'); +}); + +it('отдаёт no_data, когда в журнале нет ни одной строки', function () { + [$tenantId] = alivenessTenant(); + $now = CarbonImmutable::parse('2026-08-07 12:00:00', 'Europe/Moscow'); + + $result = app(TenantAlivenessService::class)->forTenant($tenantId, $now); + + expect($result['state'])->toBe('no_data'); + expect($result['last_login_at'])->toBeNull(); + expect($result['days'])->toBe([]); +}); +``` + +- [ ] **Шаг 2: Запустить тест и убедиться, что он падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantAlivenessTest.php +``` + +Ожидаемо: падение с `Target class [App\Services\Admin\TenantAlivenessService] does not exist.` + +- [ ] **Шаг 3: Написать сервис** + +Создать `app/app/Services/Admin/TenantAlivenessService.php`: + +```php +, + * counters: array{visits_30d: int, active_days_30d: int, actions_30d: int, failed_logins_30d: int} + * } + */ + public function forTenant(int $tenantId, ?CarbonImmutable $now = null): array + { + $now ??= CarbonImmutable::now('Europe/Moscow'); + $now = $now->setTimezone('Europe/Moscow'); + + $lastLogin = $this->lastVisit($tenantId, 'login_success'); + $hasAnyRow = $this->hasAnyRow($tenantId); + + $daysSince = $lastLogin !== null + ? $this->fullMoscowDaysBetween($lastLogin->created_at, $now) + : null; + + return [ + 'state' => $this->deriveState($daysSince, $hasAnyRow), + 'last_login_at' => $lastLogin !== null + ? CarbonImmutable::parse($lastLogin->created_at)->toIso8601String() + : null, + 'days_since_login' => $daysSince, + 'last_login_ip' => $lastLogin->ip_address ?? null, + 'last_login_user_agent' => $lastLogin->user_agent ?? null, + 'days' => $this->visitDays($tenantId, $now), + 'counters' => $this->counters($tenantId, $now), + ]; + } + + /** + * Последняя строка нужного события. Без ограничения по окну: LIMIT 1 по + * индексу idx_auth_log_tenant_user позволяет Postgres обойти месячные + * куски журнала от свежего к старому и остановиться на первом попадании. + */ + private function lastVisit(int $tenantId, string $event): ?object + { + return DB::table('auth_log') + ->where('tenant_id', $tenantId) + ->where('event', $event) + ->orderByDesc('created_at') + ->limit(1) + ->first(['created_at', 'ip_address', 'user_agent']); + } + + private function hasAnyRow(int $tenantId): bool + { + return DB::table('auth_log')->where('tenant_id', $tenantId)->limit(1)->exists(); + } + + /** Полных московских суток между днём события и сегодняшним днём. */ + private function fullMoscowDaysBetween(string $eventAt, CarbonImmutable $now): int + { + $eventDay = CarbonImmutable::parse($eventAt)->setTimezone('Europe/Moscow')->startOfDay(); + $today = $now->startOfDay(); + + return (int) $eventDay->diffInDays($today); + } + + private function deriveState(?int $daysSince, bool $hasAnyRow): string + { + if ($daysSince === null) { + return $hasAnyRow ? 'never_logged_in' : 'no_data'; + } + if ($daysSince >= self::LOST_AFTER_DAYS) { + return 'lost'; + } + if ($daysSince >= self::COOLING_AFTER_DAYS) { + return 'cooling'; + } + + return 'active'; + } + + /** @return array */ + private function visitDays(int $tenantId, CarbonImmutable $now): array + { + $since = MskBoundary::instant($now->subDays(self::STRIP_DAYS - 1)->startOfDay()); + + $rows = DB::table('auth_log') + ->selectRaw("(created_at AT TIME ZONE 'Europe/Moscow')::date AS d, count(*) AS visits") + ->where('tenant_id', $tenantId) + ->whereIn('event', self::VISIT_EVENTS) + ->where('created_at', '>=', $since) + ->groupBy('d') + ->orderBy('d') + ->get(); + + return $rows->map(fn ($r) => [ + 'date' => (string) $r->d, + 'visits' => (int) $r->visits, + ])->all(); + } + + /** @return array{visits_30d: int, active_days_30d: int, actions_30d: int, failed_logins_30d: int} */ + private function counters(int $tenantId, CarbonImmutable $now): array + { + $since = MskBoundary::instant($now->subDays(self::COUNTERS_DAYS - 1)->startOfDay()); + + $visitList = "'".implode("','", self::VISIT_EVENTS)."'"; + + $row = DB::table('auth_log') + ->selectRaw(" + count(*) FILTER (WHERE event IN ({$visitList})) AS visits, + count(DISTINCT (created_at AT TIME ZONE 'Europe/Moscow')::date) + FILTER (WHERE event IN ({$visitList})) AS active_days, + count(*) FILTER (WHERE event = 'login_failed') AS failed_logins + ") + ->where('tenant_id', $tenantId) + ->where('created_at', '>=', $since) + ->first(); + + return [ + 'visits_30d' => (int) ($row->visits ?? 0), + 'active_days_30d' => (int) ($row->active_days ?? 0), + 'actions_30d' => $this->feed->countSince($tenantId, $since), + 'failed_logins_30d' => (int) ($row->failed_logins ?? 0), + ]; + } +} +``` + +- [ ] **Шаг 4: Запустить тест — он всё ещё падает на отсутствии второго сервиса** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantAlivenessTest.php +``` + +Ожидаемо: `Class "App\Services\Admin\TenantActivityFeedService" not found`. Это нормально — сервис ленты делается задачей 2. Не чинить заглушкой, идти дальше. + +--- + +## Задача 2: Сервис ленты дел + +**Files:** +- Create: `app/app/Services/Admin/TenantActivityFeedService.php` +- Test: `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php`: + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'client@example.test']); + + return [(int) $tenant->id, (int) $user->id]; +} + +function feedOps(int $tenantId, ?int $userId, string $event, ?int $entityId, ?array $after, ?array $before, string $mskTime): void +{ + DB::table('tenant_operations_log')->insert([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'entity_type' => 'project', + 'entity_id' => $entityId, + 'event' => $event, + 'payload_before' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null, + 'payload_after' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null, + 'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(), + ]); +} + +function feedActivity(int $tenantId, ?int $userId, string $event, int $dealId, string $mskTime): void +{ + DB::table('activity_log')->insert([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'deal_id' => $dealId, + 'event' => $event, + 'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(), + ]); +} + +it('берёт имя проекта из журнала, а не номер', function () { + [$tenantId, $userId] = feedTenant(); + feedOps($tenantId, $userId, 'project.created', 999_001, ['name' => 'Двери'], null, '2026-08-05 10:10:00'); + + $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); + + expect($items)->toHaveCount(1); + expect($items[0]['kind'])->toBe('project'); + expect($items[0]['event'])->toBe('project.created'); + expect($items[0]['subject'])->toBe('Двери'); + expect($items[0]['actor'])->toBe('client@example.test'); +}); + +it('берёт имя удалённого проекта из снимка «до»', function () { + [$tenantId, $userId] = feedTenant(); + feedOps($tenantId, $userId, 'project.deleted', 999_002, null, ['name' => 'Окна ЕКБ'], '2026-08-05 11:00:00'); + + $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); + + expect($items[0]['subject'])->toBe('Окна ЕКБ'); +}); + +it('считает, сколько проектов задето групповым действием', function () { + [$tenantId, $userId] = feedTenant(); + feedOps($tenantId, $userId, 'project.bulk_pause', null, ['ids' => [1, 2, 3]], null, '2026-08-05 12:00:00'); + + $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); + + expect($items[0]['bulk_count'])->toBe(3); +}); + +it('пускает ручную работу со сделками и НЕ пускает системную', function () { + [$tenantId, $userId] = feedTenant(); + feedActivity($tenantId, $userId, 'deal.status_changed', 555, '2026-08-05 13:00:00'); + feedActivity($tenantId, null, 'deal.created', 556, '2026-08-05 13:05:00'); + + $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); + + expect($items)->toHaveCount(1); + expect($items[0]['event'])->toBe('deal.status_changed'); + expect($items[0]['subject'])->toBe('555'); +}); + +it('пускает пополнение и НЕ пускает списания за лид и правку баланса админом', function () { + [$tenantId, $userId] = feedTenant(); + + DB::table('balance_transactions')->insert([ + ['tenant_id' => $tenantId, 'user_id' => $userId, 'type' => 'topup', + 'amount_rub' => '1000.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:00:00', 'Europe/Moscow')->utc()], + ['tenant_id' => $tenantId, 'user_id' => null, 'type' => 'lead_charge', + 'amount_rub' => '-20.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:05:00', 'Europe/Moscow')->utc()], + ['tenant_id' => $tenantId, 'user_id' => null, 'type' => 'manual_adjustment', + 'amount_rub' => '500.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:10:00', 'Europe/Moscow')->utc()], + ]); + + $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); + + expect($items)->toHaveCount(1); + expect($items[0]['kind'])->toBe('money'); + expect($items[0]['event'])->toBe('balance.topup'); + expect($items[0]['amount_rub'])->toBe('1000.00'); +}); + +it('отдаёт свежее первым и умеет постранично', function () { + [$tenantId, $userId] = feedTenant(); + feedOps($tenantId, $userId, 'project.created', 999_010, ['name' => 'Первый'], null, '2026-08-01 10:00:00'); + feedOps($tenantId, $userId, 'project.created', 999_011, ['name' => 'Второй'], null, '2026-08-02 10:00:00'); + feedOps($tenantId, $userId, 'project.created', 999_012, ['name' => 'Третий'], null, '2026-08-03 10:00:00'); + + $service = app(TenantActivityFeedService::class); + + $first = $service->forTenant($tenantId, 2, 0); + expect(array_column($first, 'subject'))->toBe(['Третий', 'Второй']); + + $second = $service->forTenant($tenantId, 2, 2); + expect(array_column($second, 'subject'))->toBe(['Первый']); +}); + +it('считает дела за период тем же отбором, что и лента', function () { + [$tenantId, $userId] = feedTenant(); + feedOps($tenantId, $userId, 'project.created', 999_020, ['name' => 'Свежий'], null, '2026-08-05 10:00:00'); + feedOps($tenantId, $userId, 'project.created', 999_021, ['name' => 'Старый'], null, '2026-05-01 10:00:00'); + feedActivity($tenantId, null, 'deal.created', 777, '2026-08-05 10:30:00'); + + $since = CarbonImmutable::parse('2026-07-09 00:00:00', 'Europe/Moscow')->utc(); + + expect(app(TenantActivityFeedService::class)->countSince($tenantId, $since))->toBe(1); +}); +``` + +- [ ] **Шаг 2: Запустить тест и убедиться, что он падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: `Target class [App\Services\Admin\TenantActivityFeedService] does not exist.` + +- [ ] **Шаг 3: Написать сервис** + +Создать `app/app/Services/Admin/TenantActivityFeedService.php`: + +```php + + */ + public function forTenant(int $tenantId, int $limit, int $offset): array + { + $sql = 'SELECT * FROM ('.$this->unionSql().') f ORDER BY f.at DESC, f.id DESC LIMIT ? OFFSET ?'; + + $rows = DB::select($sql, [...$this->unionBindings($tenantId), $limit, $offset]); + + return array_map(fn ($r) => [ + 'id' => (string) $r->id, + 'at' => CarbonImmutable::parse($r->at)->toIso8601String(), + 'kind' => (string) $r->kind, + 'event' => (string) $r->event, + 'actor' => $r->actor !== null ? (string) $r->actor : null, + 'subject' => $r->subject !== null ? (string) $r->subject : null, + 'amount_rub' => $r->amount_rub !== null ? (string) $r->amount_rub : null, + 'bulk_count' => (int) $r->bulk_count, + ], $rows); + } + + /** Сколько дел у клиента с указанного мгновения. Тот же отбор, что у ленты. */ + public function countSince(int $tenantId, DateTimeInterface $since): int + { + $sql = 'SELECT count(*) AS c FROM ('.$this->unionSql().') f WHERE f.at >= ?'; + + $rows = DB::select($sql, [...$this->unionBindings($tenantId), $since]); + + return (int) ($rows[0]->c ?? 0); + } + + /** Есть ли ещё страница после выданной. */ + public function hasMore(int $tenantId, int $limit, int $offset): bool + { + $sql = 'SELECT 1 FROM ('.$this->unionSql().') f ORDER BY f.at DESC, f.id DESC LIMIT 1 OFFSET ?'; + + return DB::select($sql, [...$this->unionBindings($tenantId), $limit + $offset]) !== []; + } + + /** + * Пять источников с одинаковым набором колонок. + * Порядок подстановок: пять раз tenant_id — по одному на каждый кусок. + */ + private function unionSql(): string + { + return <<<'SQL' + SELECT 'ops-' || o.id AS id, o.created_at AS at, 'project' AS kind, o.event AS event, + u.email AS actor, + COALESCE(p.name, o.payload_after->>'name', o.payload_before->>'name') AS subject, + NULL::text AS amount_rub, + CASE WHEN jsonb_typeof(o.payload_after->'ids') = 'array' + THEN jsonb_array_length(o.payload_after->'ids') ELSE 0 END AS bulk_count + FROM tenant_operations_log o + LEFT JOIN users u ON u.id = o.user_id + LEFT JOIN projects p ON p.id = o.entity_id AND o.entity_type = 'project' + WHERE o.tenant_id = ? + + UNION ALL + + SELECT 'act-' || a.id, a.created_at, 'deal', a.event, + u.email, a.deal_id::text, NULL::text, 0 + FROM activity_log a + LEFT JOIN users u ON u.id = a.user_id + WHERE a.tenant_id = ? AND a.user_id IS NOT NULL + + UNION ALL + + SELECT 'tx-' || b.id, b.created_at, 'money', 'balance.topup', + u.email, NULL::text, b.amount_rub::text, 0 + FROM balance_transactions b + LEFT JOIN users u ON u.id = b.user_id + WHERE b.tenant_id = ? AND b.type = 'topup' + + UNION ALL + + SELECT 'inv-' || i.id || '-issued', i.issued_at, 'money', 'invoice.issued', + i.payer_email, i.invoice_number, i.amount_total::text, 0 + FROM saas_invoices i + WHERE i.tenant_id = ? AND i.issued_at IS NOT NULL + + UNION ALL + + SELECT 'inv-' || i.id || '-paid', i.paid_at, 'money', 'invoice.paid', + i.payer_email, i.invoice_number, i.amount_total::text, 0 + FROM saas_invoices i + WHERE i.tenant_id = ? AND i.paid_at IS NOT NULL + SQL; + } + + /** @return array */ + private function unionBindings(int $tenantId): array + { + return [$tenantId, $tenantId, $tenantId, $tenantId, $tenantId]; + } +} +``` + +- [ ] **Шаг 4: Запустить оба теста — должны пройти** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php tests/Feature/Admin/AdminTenantAlivenessTest.php +``` + +Ожидаемо: все зелёные. Если `AdminTenantAlivenessTest` всё ещё красный — читать сообщение, а не править тест. + +- [ ] **Шаг 5: Проверить, что сторож умеет краснеть** + +Временно поменять в `TenantActivityFeedService::unionSql()` условие второго куска +`WHERE a.tenant_id = ? AND a.user_id IS NOT NULL` на `WHERE a.tenant_id = ?` +и прогнать тесты. + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: КРАСНОЕ на «пускает ручную работу со сделками и НЕ пускает системную» — +получено 2 вместо 1. Вернуть условие обратно, убедиться, что снова зелено. + +Зелёный сторож, которого не видели красным, ничего не охраняет — на этом +в проекте уже обжигались. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Services/Admin/ app/tests/Feature/Admin/AdminTenantAlivenessTest.php app/tests/Feature/Admin/AdminTenantActivityFeedTest.php +git commit -m "feat: сервисы «жив ли клиент» и ленты дел для карточки клиента" +``` + +--- + +## Задача 3: Подключить сервисы к карточке клиента + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php` (метод `show`, метод `fetchActivity` удаляется, добавляется метод `activity`) +- Modify: `app/routes/web.php` (рядом со строкой 178) +- Test: `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php` (дописываем) + +- [ ] **Шаг 1: Дописать падающий тест на ответ ручки** + +Добавить в конец `app/tests/Feature/Admin/AdminTenantActivityFeedTest.php`: + +```php +it('карточка клиента отдаёт блок «жив ли» и ленту без системного шума', function () { + $tenant = Tenant::factory()->create(['subdomain' => 'testalive']); + $user = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'client@example.test']); + + feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_100, ['name' => 'Двери'], null, '2026-08-05 10:10:00'); + feedActivity((int) $tenant->id, null, 'deal.created', 888, '2026-08-05 11:00:00'); + + $resp = $this->getJson('/api/admin/tenants/testalive'); + + $resp->assertOk() + ->assertJsonPath('aliveness.state', 'no_data') + ->assertJsonCount(1, 'activity.items') + ->assertJsonPath('activity.items.0.event', 'project.created') + ->assertJsonPath('activity.items.0.subject', 'Двери') + ->assertJsonPath('activity.has_more', false); +}); + +it('отдаёт следующую страницу ленты отдельной ручкой', function () { + $tenant = Tenant::factory()->create(['subdomain' => 'testpage']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_200, ['name' => 'Первый'], null, '2026-08-01 10:00:00'); + feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_201, ['name' => 'Второй'], null, '2026-08-02 10:00:00'); + + $resp = $this->getJson('/api/admin/tenants/testpage/activity?offset=1&limit=1'); + + $resp->assertOk() + ->assertJsonCount(1, 'items') + ->assertJsonPath('items.0.subject', 'Первый') + ->assertJsonPath('has_more', false); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: два падения — в первом ответе нет ключа `aliveness`, второй маршрут отдаёт 404. + +- [ ] **Шаг 3: Добавить маршрут** + +В `app/routes/web.php` сразу после строки с `AdminTenantsController@show` (строка 178) дописать: + +```php + Route::get('/api/admin/tenants/{subdomain}/activity', 'App\Http\Controllers\Api\AdminTenantsController@activity') + ->where('subdomain', '[A-Za-z0-9._-]+'); +``` + +Ограничение `where` повторяет соседний маршрут `show` — иначе точка в поддомене съест часть пути. + +- [ ] **Шаг 4: Переписать `show` и добавить `activity`** + +В `app/app/Http/Controllers/Api/AdminTenantsController.php`: + +добавить в начало файла к остальным `use`: + +```php +use App\Services\Admin\TenantActivityFeedService; +use App\Services\Admin\TenantAlivenessService; +``` + +добавить константу в тело класса, рядом с другими константами: + +```php + /** Сколько дел отдаём одной страницей ленты. */ + private const ACTIVITY_PAGE = 50; +``` + +в методе `show` заменить строку + +```php + 'activity' => $this->fetchActivity($tenantId), +``` + +на + +```php + 'aliveness' => app(TenantAlivenessService::class)->forTenant($tenantId), + 'activity' => $this->activityPage($tenantId, self::ACTIVITY_PAGE, 0), +``` + +удалить метод `fetchActivity` целиком (он читал только журнал сделок — тот самый, где 87% системного шума) и добавить вместо него: + +```php + /** + * GET /api/admin/tenants/{subdomain}/activity — подгрузка следующей страницы + * ленты дел («показать ещё»). Отдельная ручка, чтобы не пересобирать всю + * карточку ради пятидесяти строк. + */ + public function activity(Request $request, string $subdomain): JsonResponse + { + $tenant = DB::table('tenants') + ->where('subdomain', $subdomain) + ->whereNull('deleted_at') + ->first(['id']); + + if ($tenant === null) { + return response()->json(['message' => 'Тенант не найден.'], 404); + } + + $limit = max(1, min(self::ACTIVITY_PAGE, (int) $request->query('limit', (string) self::ACTIVITY_PAGE))); + $offset = max(0, (int) $request->query('offset', '0')); + + return response()->json($this->activityPage((int) $tenant->id, $limit, $offset)); + } + + /** @return array{items: array>, offset: int, limit: int, has_more: bool} */ + private function activityPage(int $tenantId, int $limit, int $offset): array + { + $feed = app(TenantActivityFeedService::class); + + return [ + 'items' => $feed->forTenant($tenantId, $limit, $offset), + 'offset' => $offset, + 'limit' => $limit, + 'has_more' => $feed->hasMore($tenantId, $limit, $offset), + ]; + } +``` + +- [ ] **Шаг 5: Запустить тесты** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantActivityFeedTest.php +``` + +Ожидаемо: все зелёные. + +- [ ] **Шаг 6: Прогнать соседние тесты карточки — вдруг что-то на неё опиралось** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/ +``` + +Ожидаемо: зелено. Если красное в чужом тесте, читающем `activity` как плоский массив, — это ожидаемое последствие смены формы ответа, чинить тест по новой форме. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Http/Controllers/Api/AdminTenantsController.php app/routes/web.php app/tests/Feature/Admin/AdminTenantActivityFeedTest.php +git commit -m "feat: карточка клиента отдаёт «жив ли» и ленту дел вместо журнала сделок" +``` + +--- + +## Задача 4: Список клиентов — живая сортировка по последнему входу + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php` (метод `index`) +- Test: `app/tests/Feature/Admin/AdminTenantsListActivityTest.php` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Feature/Admin/AdminTenantsListActivityTest.php`: + +```php +create(['subdomain' => $subdomain]); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + if ($mskTime !== null) { + DB::table('auth_log')->insert([ + 'actor_type' => 'tenant_user', + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'email' => 'x@example.test', + 'event' => 'login_success', + 'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(), + ]); + } + + return $tenant; +} + +it('сортирует список по последнему входу из журнала, а не по мёртвой колонке', function () { + // Мёртвая колонка нарочно заполнена наоборот: если сортировка всё ещё + // смотрит на неё, порядок получится обратный и тест покраснеет. + $stale = listTenantWithLogin('staleone', CarbonImmutable::now('Europe/Moscow')->subDays(10)->toDateTimeString()); + $fresh = listTenantWithLogin('freshone', CarbonImmutable::now('Europe/Moscow')->subHours(2)->toDateTimeString()); + + DB::table('tenants')->where('id', $stale->id)->update(['last_activity_at' => now()]); + DB::table('tenants')->where('id', $fresh->id)->update(['last_activity_at' => now()->subYear()]); + + $resp = $this->getJson('/api/admin/tenants?limit=100'); + + $resp->assertOk(); + $subdomains = array_column($resp->json('tenants'), 'subdomain'); + $freshPos = array_search('freshone', $subdomains, true); + $stalePos = array_search('staleone', $subdomains, true); + + expect($freshPos)->toBeLessThan($stalePos); +}); + +it('отдаёт состояние клиента по порогам три и четырнадцать', function () { + listTenantWithLogin('aliveone', CarbonImmutable::now('Europe/Moscow')->subDays(1)->toDateTimeString()); + listTenantWithLogin('coolone', CarbonImmutable::now('Europe/Moscow')->subDays(6)->toDateTimeString()); + listTenantWithLogin('lostone', CarbonImmutable::now('Europe/Moscow')->subDays(20)->toDateTimeString()); + listTenantWithLogin('noneone', null); + + $rows = collect($this->getJson('/api/admin/tenants?limit=100')->json('tenants')) + ->keyBy('subdomain'); + + expect($rows['aliveone']['aliveness_state'])->toBe('active'); + expect($rows['coolone']['aliveness_state'])->toBe('cooling'); + expect($rows['lostone']['aliveness_state'])->toBe('lost'); + expect($rows['noneone']['aliveness_state'])->toBe('lost'); + expect($rows['noneone']['last_activity_at'])->toBeNull(); +}); + +it('не делает запрос на каждого клиента', function () { + foreach (range(1, 12) as $i) { + listTenantWithLogin('many'.$i, CarbonImmutable::now('Europe/Moscow')->subDays($i)->toDateTimeString()); + } + + DB::enableQueryLog(); + $this->getJson('/api/admin/tenants?limit=100')->assertOk(); + $queries = count(DB::getQueryLog()); + DB::disableQueryLog(); + + // Список, счёт всего и блок сводки — единицы запросов, а не «по одному + // на клиента». Вопрос внутри перебора ловился в проекте дважды за неделю. + expect($queries)->toBeLessThan(15); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantsListActivityTest.php +``` + +Ожидаемо: падение на отсутствии ключа `aliveness_state` и на порядке сортировки. + +- [ ] **Шаг 3: Переписать `index`** + +В `app/app/Http/Controllers/Api/AdminTenantsController.php`, метод `index`. + +Добавить перед сборкой `$query` (после чтения фильтров): + +```php + // Последний вход — из журнала входов. Колонка tenants.last_activity_at + // мертва: её никто не пишет (замерено 07.08.2026 — ноль из одиннадцати), + // и сортировка по ней работала вслепую. + $stripSince = MskBoundary::instant( + CarbonImmutable::now('Europe/Moscow') + ->subDays(TenantAlivenessService::STRIP_DAYS - 1) + ->startOfDay() + ); +``` + +Последний вход подставляется подзапросом прямо в `select` — тогда по его +псевдониму можно и сортировать, и не тащить в запрос ещё один join. + +Заменить строку + +```php + 'tenants.last_activity_at', +``` + +в списке `select` на + +```php + DB::raw('(SELECT max(al.created_at) FROM auth_log al + WHERE al.tenant_id = tenants.id + AND al.event IN (\'login_success\',\'register_success\') + AND al.created_at >= ?) AS last_activity_at'), +``` + +и добавить привязку сразу после `->select([...])`: + +```php + ->addBinding($stripSince, 'select') +``` + +Заменить сортировку + +```php + ->orderByDesc('tenants.last_activity_at') +``` + +на + +```php + ->orderByRaw('last_activity_at DESC NULLS LAST') +``` + +В сборке ответа заменить + +```php + 'last_activity_at' => $t->last_activity_at !== null + ? CarbonImmutable::parse($t->last_activity_at)->toIso8601String() + : null, +``` + +на + +```php + 'last_activity_at' => $t->last_activity_at !== null + ? CarbonImmutable::parse($t->last_activity_at)->toIso8601String() + : null, + 'aliveness_state' => $this->listState($t->last_activity_at), +``` + +и добавить приватный метод в конец класса: + +```php + /** + * Состояние клиента для списка. Пустое значение означает «за девяносто дней + * входов не было» — для списка это то же самое, что «пропал»; тонкое различие + * «зарегистрировался и ни разу не вошёл» живёт в карточке, где для него есть + * место под подпись. + */ + private function listState(?string $lastLoginAt): string + { + if ($lastLoginAt === null) { + return 'lost'; + } + + $days = (int) CarbonImmutable::parse($lastLoginAt) + ->setTimezone('Europe/Moscow')->startOfDay() + ->diffInDays(CarbonImmutable::now('Europe/Moscow')->startOfDay()); + + if ($days >= TenantAlivenessService::LOST_AFTER_DAYS) { + return 'lost'; + } + if ($days >= TenantAlivenessService::COOLING_AFTER_DAYS) { + return 'cooling'; + } + + return 'active'; + } +``` + +Добавить к `use` в шапке файла, если ещё нет: + +```php +use App\Support\MskBoundary; +``` + +- [ ] **Шаг 4: Запустить тест** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantsListActivityTest.php +``` + +Ожидаемо: зелено. Если ругается на `?` внутри `DB::raw` в `select` — проверить, что `addBinding` вызван с типом `'select'` и стоит ПОСЛЕ `->select([...])`, но ДО применения фильтров `where`. + +- [ ] **Шаг 5: Проверить, что сторож умеет краснеть** + +Временно вернуть сортировку на `->orderByDesc('tenants.last_activity_at')` и прогнать. + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/AdminTenantsListActivityTest.php +``` + +Ожидаемо: КРАСНОЕ на первом тесте — порядок обратный. Вернуть `orderByRaw` обратно. + +- [ ] **Шаг 6: Прогнать весь блок админки** + +```bash +cd app && ./vendor/bin/pest tests/Feature/Admin/ +``` + +Ожидаемо: зелено. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Http/Controllers/Api/AdminTenantsController.php app/tests/Feature/Admin/AdminTenantsListActivityTest.php +git commit -m "fix: список клиентов сортируется по живому журналу входов, а не по мёртвой колонке" +``` + +--- + +## Задача 5: Типы API и маппер на фронте + +**Files:** +- Modify: `app/resources/js/api/admin.ts:182-213` +- Modify: `app/resources/js/composables/mockTenantDetail.ts:37-43` +- Modify: `app/resources/js/composables/adminTenantDetailMapper.ts` +- Modify: `app/resources/js/composables/mockTenants.ts:11-24` +- Modify: `app/resources/js/composables/adminTenantsMapper.ts` +- Test: `app/tests/Frontend/adminTenantDetailMapper.spec.ts` + +- [ ] **Шаг 1: Написать падающий тест маппера** + +Добавить в конец `app/tests/Frontend/adminTenantDetailMapper.spec.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { mapAdminTenantDetail } from '../../resources/js/composables/adminTenantDetailMapper'; + +describe('маппер: блок «жив ли клиент»', () => { + const base = { + tenant: { + id: 11, + subdomain: 'ekb96', + organization_name: 'ekb96', + contact_email: 'ekb96@well.ru', + status: 'active', + balance_rub: '1320.00', + balance_leads: 0, + is_trial: true, + last_activity_at: null, + tariff_id: null, + tariff_name: null, + mrr_rub: null, + desired_daily_numbers: null, + chargeback_unrecovered_rub: '0.00', + created_at: '2026-08-05T07:04:34+00:00', + }, + users: [], + projects: [], + balance_history: [], + aliveness: { + state: 'cooling', + last_login_at: '2026-08-06T03:23:12+00:00', + days_since_login: 1, + last_login_ip: '5.166.40.65', + last_login_user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + days: [{ date: '2026-08-06', visits: 1 }], + counters: { visits_30d: 2, active_days_30d: 2, actions_30d: 2, failed_logins_30d: 0 }, + }, + activity: { + items: [ + { + id: 'ops-1', + at: '2026-08-05T07:34:48+00:00', + kind: 'project', + event: 'project.created', + actor: 'ekb96@well.ru', + subject: 'Окна ЕКБ', + amount_rub: null, + bulk_count: 0, + }, + ], + offset: 0, + limit: 50, + has_more: false, + }, + metrics: { + leads_today: 0, + leads_this_week: 12, + leads_this_month: 12, + avg_lead_cost_rub: 15.25, + runway_days: 59, + }, + } as never; + + it('переносит вердикт и ленту без потерь', () => { + const mapped = mapAdminTenantDetail(base); + + expect(mapped.aliveness.state).toBe('cooling'); + expect(mapped.aliveness.daysSinceLogin).toBe(1); + expect(mapped.aliveness.days).toHaveLength(1); + expect(mapped.activity).toHaveLength(1); + expect(mapped.activity[0].subject).toBe('Окна ЕКБ'); + expect(mapped.activityHasMore).toBe(false); + }); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/adminTenantDetailMapper.spec.ts +``` + +Ожидаемо: падение — у `mapped` нет поля `aliveness`. + +- [ ] **Шаг 3: Заменить типы в `api/admin.ts`** + +Удалить `ApiTenantActivityEvent` (строки 182–189) и вписать на его место: + +```ts +export type ApiAlivenessState = 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data'; + +export interface ApiTenantAliveness { + state: ApiAlivenessState; + last_login_at: string | null; + days_since_login: number | null; + last_login_ip: string | null; + last_login_user_agent: string | null; + days: Array<{ date: string; visits: number }>; + counters: { + visits_30d: number; + active_days_30d: number; + actions_30d: number; + failed_logins_30d: number; + }; +} + +export interface ApiTenantActivityItem { + id: string; + at: string; + kind: 'project' | 'deal' | 'money'; + event: string; + actor: string | null; + subject: string | null; + amount_rub: string | null; + bulk_count: number; +} + +export interface ApiTenantActivityPage { + items: ApiTenantActivityItem[]; + offset: number; + limit: number; + has_more: boolean; +} +``` + +В `AdminTenantDetailResponse` заменить строку `activity: ApiTenantActivityEvent[];` на: + +```ts + aliveness: ApiTenantAliveness; + activity: ApiTenantActivityPage; +``` + +Дописать после `getAdminTenantDetail`: + +```ts +export async function getAdminTenantActivity( + subdomain: string, + offset: number, + limit = 50, +): Promise { + const { data } = await apiClient.get( + `/api/admin/tenants/${encodeURIComponent(subdomain)}/activity`, + { params: { offset, limit } }, + ); + return data; +} +``` + +В `AdminTenant` (тот, что описывает строку списка) дописать поле: + +```ts + aliveness_state: ApiAlivenessState; +``` + +- [ ] **Шаг 4: Заменить типы в `mockTenantDetail.ts`** + +Удалить `TenantActivityEvent` (строки 37–43), вписать: + +```ts +export type AlivenessState = 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data'; + +export interface TenantAliveness { + state: AlivenessState; + lastLoginAt: string | null; + daysSinceLogin: number | null; + lastLoginIp: string | null; + lastLoginUserAgent: string | null; + days: Array<{ date: string; visits: number }>; + visits30d: number; + activeDays30d: number; + actions30d: number; + failedLogins30d: number; +} + +export interface TenantActivityItem { + id: string; + at: string; + kind: 'project' | 'deal' | 'money'; + event: string; + actor: string | null; + subject: string | null; + amountRub: number | null; + bulkCount: number; +} +``` + +В `AdminTenantDetail` заменить `activity: TenantActivityEvent[];` на: + +```ts + aliveness: TenantAliveness; + activity: TenantActivityItem[]; + activityHasMore: boolean; +``` + +- [ ] **Шаг 5: Переписать маппер карточки** + +В `app/resources/js/composables/adminTenantDetailMapper.ts` удалить функции `mapActivity` и `buildActivitySummary`, вписать вместо них: + +```ts +function mapAliveness(a: ApiTenantAliveness): TenantAliveness { + return { + state: a.state, + lastLoginAt: a.last_login_at, + daysSinceLogin: a.days_since_login, + lastLoginIp: a.last_login_ip, + lastLoginUserAgent: a.last_login_user_agent, + days: a.days, + visits30d: a.counters.visits_30d, + activeDays30d: a.counters.active_days_30d, + actions30d: a.counters.actions_30d, + failedLogins30d: a.counters.failed_logins_30d, + }; +} + +export function mapTenantActivityItem(i: ApiTenantActivityItem): TenantActivityItem { + return { + id: i.id, + at: i.at, + kind: i.kind, + event: i.event, + actor: i.actor, + subject: i.subject, + amountRub: i.amount_rub !== null ? parseFloat(i.amount_rub) : null, + bulkCount: i.bulk_count, + }; +} +``` + +Поправить импорты в шапке файла: убрать `ApiTenantActivityEvent` и `TenantActivityEvent`, добавить `ApiTenantAliveness`, `ApiTenantActivityItem`, `TenantAliveness`, `TenantActivityItem`. + +В `mapAdminTenantDetail` заменить строку `activity: api.activity.map(mapActivity),` на: + +```ts + aliveness: mapAliveness(api.aliveness), + activity: api.activity.items.map(mapTenantActivityItem), + activityHasMore: api.activity.has_more, +``` + +- [ ] **Шаг 6: Пробросить состояние в список клиентов** + +В `app/resources/js/composables/mockTenants.ts`, в `AdminTenant`, дописать поле: + +```ts + alivenessState: 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data'; +``` + +В `app/resources/js/composables/adminTenantsMapper.ts`, в возвращаемый объект `mapApiAdminTenant`, дописать: + +```ts + alivenessState: api.aliveness_state, +``` + +В том же файле поправить `formatRelative`, чтобы пустое значение читалось честно: + +```ts +function formatRelative(iso: string | null, now: Date = new Date()): string { + if (iso === null) return 'больше 90 дней'; + const dt = new Date(iso); + const minutes = Math.max(0, Math.floor((now.getTime() - dt.getTime()) / 60000)); + if (minutes < 60) return `${minutes} мин назад`; + if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`; + return `${Math.floor(minutes / (60 * 24))} д назад`; +} +``` + +В `MOCK_TENANTS` (тот же файл `mockTenants.ts`) добавить `alivenessState: 'active'` каждому элементу — иначе не соберётся типизация. + +- [ ] **Шаг 7: Запустить тесты и проверку типов** + +```bash +cd app && npx vitest run tests/Frontend/adminTenantDetailMapper.spec.ts && npm run type-check +``` + +Ожидаемо: тест зелёный, проверка типов без ошибок. Если `type-check` ругается на `MOCK_TENANT_DETAIL` — дописать туда `aliveness`, `activity: []`, `activityHasMore: false` по форме из шага 4. + +- [ ] **Шаг 8: Коммит** + +```bash +git add app/resources/js/api/admin.ts app/resources/js/composables/ app/tests/Frontend/adminTenantDetailMapper.spec.ts +git commit -m "feat: типы и маппер под блок «жив ли клиент» и ленту дел" +``` + +--- + +## Задача 6: Русские фразы и расшифровка устройства + +**Files:** +- Create: `app/resources/js/composables/tenantActivityFormatters.ts` +- Test: `app/tests/Frontend/tenantActivityFormatters.spec.ts` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Frontend/tenantActivityFormatters.spec.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { TenantActivityItem } from '../../resources/js/composables/mockTenantDetail'; +import { + activityPhrase, + alivenessColor, + alivenessShortLabel, + alivenessTitle, + deviceLabel, +} from '../../resources/js/composables/tenantActivityFormatters'; + +function item(patch: Partial): TenantActivityItem { + return { + id: 'ops-1', + at: '2026-08-05T07:34:48+00:00', + kind: 'project', + event: 'project.created', + actor: 'client@example.test', + subject: 'Двери', + amountRub: null, + bulkCount: 0, + ...patch, + }; +} + +describe('фразы ленты', () => { + it('пишет название проекта, а не номер', () => { + expect(activityPhrase(item({}))).toBe('создал проект «Двери»'); + }); + + it('пишет, сколько проектов задето групповым действием', () => { + expect(activityPhrase(item({ event: 'project.bulk_pause', subject: null, bulkCount: 3 }))) + .toBe('остановил проекты: 3'); + }); + + it('пишет номер сделки', () => { + expect(activityPhrase(item({ kind: 'deal', event: 'deal.status_changed', subject: '555' }))) + .toBe('сменил статус сделки #555'); + }); + + it('пишет сумму пополнения', () => { + expect(activityPhrase(item({ kind: 'money', event: 'balance.topup', subject: null, amountRub: 1000 }))) + .toBe('пополнил баланс на 1 000 ₽'); + }); + + it('не падает на незнакомом событии', () => { + expect(activityPhrase(item({ event: 'nechto.strannoe', subject: null }))).toBe('nechto.strannoe'); + }); +}); + +describe('расшифровка устройства', () => { + it('узнаёт Windows и Chrome', () => { + expect(deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36')) + .toBe('Windows/Chrome'); + }); + + it('узнаёт айфон и Safari', () => { + expect(deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1')) + .toBe('iPhone/Safari'); + }); + + it('честно молчит, когда не знает', () => { + expect(deviceLabel(null)).toBe('устройство неизвестно'); + expect(deviceLabel('curl/8.4.0')).toBe('устройство неизвестно'); + }); +}); + +describe('вердикт', () => { + it('зелёный, когда заходил на днях', () => { + expect(alivenessColor('active')).toBe('success'); + expect(alivenessTitle('active', 0)).toBe('Заходил сегодня'); + expect(alivenessTitle('active', 1)).toBe('Заходил вчера'); + expect(alivenessTitle('active', 3)).toBe('Заходил 3 дня назад'); + }); + + it('жёлтый, когда остывает', () => { + expect(alivenessColor('cooling')).toBe('warning'); + expect(alivenessTitle('cooling', 6)).toBe('Не заходил 6 дней'); + }); + + it('красный, когда пропал или ни разу не заходил', () => { + expect(alivenessColor('lost')).toBe('error'); + expect(alivenessTitle('lost', 21)).toBe('Не заходил 21 день'); + expect(alivenessColor('never_logged_in')).toBe('error'); + expect(alivenessTitle('never_logged_in', null)).toBe('Ни разу не заходил после регистрации'); + }); + + it('серый, когда данных нет', () => { + expect(alivenessColor('no_data')).toBe('grey'); + expect(alivenessTitle('no_data', null)).toBe('Нет данных о входах'); + }); + + it('даёт короткую подпись для подсказки в списке', () => { + expect(alivenessShortLabel('active')).toBe('заходит'); + expect(alivenessShortLabel('cooling')).toBe('остывает'); + expect(alivenessShortLabel('lost')).toBe('пропал'); + expect(alivenessShortLabel('never_logged_in')).toBe('ни разу не заходил'); + expect(alivenessShortLabel('no_data')).toBe('нет данных'); + }); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/tenantActivityFormatters.spec.ts +``` + +Ожидаемо: `Failed to resolve import ".../tenantActivityFormatters"`. + +- [ ] **Шаг 3: Написать форматтеры** + +Создать `app/resources/js/composables/tenantActivityFormatters.ts`: + +```ts +/** + * Фразы для вкладки «Активность» карточки клиента. + * + * Бэкенд отдаёт событие структурно (что за событие, над чем, на какую сумму), + * а человеческую фразу складываем здесь — рядом с форматированием рублей. + */ +import type { AlivenessState, TenantActivityItem } from './mockTenantDetail'; +import { formatRub } from './adminTenantDetailFormatters'; + +/** Склонение слова «день» по числу. */ +function daysWord(n: number): string { + const mod100 = n % 100; + const mod10 = n % 10; + if (mod100 >= 11 && mod100 <= 14) return 'дней'; + if (mod10 === 1) return 'день'; + if (mod10 >= 2 && mod10 <= 4) return 'дня'; + return 'дней'; +} + +export function activityPhrase(i: TenantActivityItem): string { + const name = i.subject !== null ? `«${i.subject}»` : ''; + const money = i.amountRub !== null ? formatRub(i.amountRub) : ''; + + switch (i.event) { + case 'project.created': + return `создал проект ${name}`; + case 'project.updated': + return `изменил проект ${name}`; + case 'project.deleted': + return `удалил проект ${name}`; + case 'project.bulk_pause': + return `остановил проекты: ${i.bulkCount}`; + case 'project.bulk_resume': + return `возобновил проекты: ${i.bulkCount}`; + case 'project.bulk_delete': + return `удалил проекты: ${i.bulkCount}`; + case 'project.bulk_update_limit': + return `сменил дневной лимит у проектов: ${i.bulkCount}`; + case 'api_key.regenerated': + return 'перевыпустил ключ API'; + case 'webhook_settings.updated': + return 'изменил вебхук'; + case 'webhook_settings.disabled': + return 'отключил вебхук'; + case 'deal.status_changed': + return `сменил статус сделки #${i.subject}`; + case 'deal.commented': + return `написал комментарий к сделке #${i.subject}`; + case 'balance.topup': + return `пополнил баланс на ${money}`; + case 'invoice.issued': + return `выставил счёт ${i.subject} на ${money}`; + case 'invoice.paid': + return `счёт ${i.subject} оплачен, ${money}`; + default: + return i.event; + } +} + +const SYSTEMS: Array<[RegExp, string]> = [ + [/iPhone/i, 'iPhone'], + [/iPad/i, 'iPad'], + [/Android/i, 'Android'], + [/Windows/i, 'Windows'], + [/Macintosh|Mac OS X/i, 'Mac'], + [/Linux/i, 'Linux'], +]; + +const BROWSERS: Array<[RegExp, string]> = [ + [/YaBrowser/i, 'Яндекс'], + [/Edg\//i, 'Edge'], + [/OPR\//i, 'Opera'], + [/Firefox/i, 'Firefox'], + [/Chrome/i, 'Chrome'], + [/Safari/i, 'Safari'], +]; + +/** + * Порядок проверок важен: Яндекс, Edge и Opera представляются Chrome'ом, + * а Chrome — ещё и Safari. Кто раньше в списке, тот и выигрывает. + */ +export function deviceLabel(userAgent: string | null): string { + if (userAgent === null || userAgent.trim() === '') return 'устройство неизвестно'; + + const system = SYSTEMS.find(([re]) => re.test(userAgent))?.[1]; + const browser = BROWSERS.find(([re]) => re.test(userAgent))?.[1]; + + if (system === undefined && browser === undefined) return 'устройство неизвестно'; + if (system === undefined) return browser as string; + if (browser === undefined) return system; + + return `${system}/${browser}`; +} + +export function alivenessColor(state: AlivenessState): string { + return { active: 'success', cooling: 'warning', lost: 'error', never_logged_in: 'error', no_data: 'grey' }[state]; +} + +/** + * Короткая подпись для подсказки в списке клиентов. Там числа суток нет — + * полная фраза из alivenessTitle соврала бы «нет данных о входах» любому, + * у кого просто не запрошено количество дней. + */ +export function alivenessShortLabel(state: AlivenessState): string { + return { + active: 'заходит', + cooling: 'остывает', + lost: 'пропал', + never_logged_in: 'ни разу не заходил', + no_data: 'нет данных', + }[state]; +} + +export function alivenessTitle(state: AlivenessState, daysSinceLogin: number | null): string { + if (state === 'never_logged_in') return 'Ни разу не заходил после регистрации'; + if (state === 'no_data' || daysSinceLogin === null) return 'Нет данных о входах'; + if (daysSinceLogin === 0) return 'Заходил сегодня'; + if (daysSinceLogin === 1) return 'Заходил вчера'; + if (state === 'active') return `Заходил ${daysSinceLogin} ${daysWord(daysSinceLogin)} назад`; + + return `Не заходил ${daysSinceLogin} ${daysWord(daysSinceLogin)}`; +} +``` + +- [ ] **Шаг 4: Запустить тест** + +```bash +cd app && npx vitest run tests/Frontend/tenantActivityFormatters.spec.ts +``` + +Ожидаемо: зелено. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/resources/js/composables/tenantActivityFormatters.ts app/tests/Frontend/tenantActivityFormatters.spec.ts +git commit -m "feat: русские фразы ленты, расшифровка устройства и подписи вердикта" +``` + +--- + +## Задача 7: Панель «Активность» — верх, полоска, лента + +**Files:** +- Create: `app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue` +- Test: `app/tests/Frontend/TenantActivityPane.spec.ts` + +- [ ] **Шаг 1: Написать падающий тест** + +Создать `app/tests/Frontend/TenantActivityPane.spec.ts`: + +```ts +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createVuetify } from 'vuetify'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import TenantActivityPane from '../../resources/js/components/admin/tenant-detail/TenantActivityPane.vue'; +import type { TenantActivityItem, TenantAliveness } from '../../resources/js/composables/mockTenantDetail'; + +const vuetify = createVuetify({ components, directives }); + +const aliveness: TenantAliveness = { + state: 'cooling', + lastLoginAt: '2026-08-06T03:23:12+00:00', + daysSinceLogin: 6, + lastLoginIp: '5.166.40.65', + lastLoginUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0 Safari/537.36', + days: [{ date: '2026-08-06', visits: 1 }], + visits30d: 2, + activeDays30d: 2, + actions30d: 2, + failedLogins30d: 4, +}; + +const items: TenantActivityItem[] = [ + { + id: 'ops-1', + at: '2026-08-05T07:34:48+00:00', + kind: 'project', + event: 'project.created', + actor: 'client@example.test', + subject: 'Окна ЕКБ', + amountRub: null, + bulkCount: 0, + }, +]; + +function mountPane(props = {}) { + return mount(TenantActivityPane, { + global: { plugins: [vuetify] }, + props: { aliveness, items, hasMore: false, ...props }, + }); +} + +describe('панель «Активность»', () => { + it('показывает вердикт и устройство последнего входа', () => { + const text = mountPane().text(); + expect(text).toContain('Не заходил 6 дней'); + expect(text).toContain('Windows/Chrome'); + expect(text).toContain('5.166.40.65'); + }); + + it('показывает четыре счётчика', () => { + const text = mountPane().text(); + expect(text).toContain('Визитов за 30 дней'); + expect(text).toContain('Дней с визитами'); + expect(text).toContain('Действий'); + expect(text).toContain('Не смог войти'); + }); + + it('красит «не смог войти», когда попытки были', () => { + const wrapper = mountPane(); + expect(wrapper.find('[data-testid="failed-logins"]').classes()).toContain('text-error'); + }); + + it('строит полоску ровно на 90 суток', () => { + expect(mountPane().findAll('[data-testid="strip-day"]')).toHaveLength(90); + }); + + it('пишет дела человеческой фразой', () => { + expect(mountPane().text()).toContain('создал проект «Окна ЕКБ»'); + }); + + it('честно говорит, когда дел не было', () => { + expect(mountPane({ items: [] }).text()).toContain('Ничего не менял'); + }); + + it('просит следующую страницу по кнопке', async () => { + const wrapper = mountPane({ hasMore: true }); + await wrapper.find('[data-testid="load-more"]').trigger('click'); + expect(wrapper.emitted('load-more')).toHaveLength(1); + }); + + it('прячет кнопку, когда больше нечего грузить', () => { + expect(mountPane({ hasMore: false }).find('[data-testid="load-more"]').exists()).toBe(false); + }); +}); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/TenantActivityPane.spec.ts +``` + +Ожидаемо: `Failed to resolve import ".../TenantActivityPane.vue"`. + +- [ ] **Шаг 3: Написать компонент** + +Создать `app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue`: + +```vue + + + + + +``` + +- [ ] **Шаг 4: Запустить тест** + +```bash +cd app && npx vitest run tests/Frontend/TenantActivityPane.spec.ts +``` + +Ожидаемо: зелено. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/resources/js/components/admin/tenant-detail/TenantActivityPane.vue app/tests/Frontend/TenantActivityPane.spec.ts +git commit -m "feat: панель «Активность» — вердикт, полоска 90 дней, лента дел" +``` + +--- + +## Задача 8: Вставить панель в карточку и подключить подгрузку + +**Files:** +- Modify: `app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue:129-142` (панель activity) и `:71-98` (колонка на вкладке «Пользователи») +- Modify: `app/resources/js/views/admin/AdminTenantDetailView.vue` +- Test: `app/tests/Frontend/AdminTenantDetailView.spec.ts` (дописываем) + +- [ ] **Шаг 1: Дописать падающий тест** + +В файле уже есть `makeApiDetail(overrides)` (строка 22) и `mountDetail(subdomain)` +(строка ~141) — пользуемся ими, своих не заводим. + +Сначала привести фикстуру `makeApiDetail` к новой форме ответа: заменить в ней +поле `activity: [...]` на + +```ts + aliveness: { + state: 'cooling', + last_login_at: '2026-08-06T03:23:12+00:00', + days_since_login: 6, + last_login_ip: '5.166.40.65', + last_login_user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0 Safari/537.36', + days: [{ date: '2026-08-06', visits: 1 }], + counters: { visits_30d: 2, active_days_30d: 2, actions_30d: 1, failed_logins_30d: 0 }, + }, + activity: { + items: [ + { + id: 'ops-1', + at: '2026-08-05T07:34:48+00:00', + kind: 'project', + event: 'project.created', + actor: 'admin@okna-moscow.ru', + subject: 'Окна ЕКБ', + amount_rub: null, + bulk_count: 0, + }, + ], + offset: 0, + limit: 50, + has_more: false, + }, +``` + +Затем дописать в конец `describe('AdminTenantDetailView.vue (API integration)', ...)`: + +```ts + it('на вкладке «Пользователи» колонка называется «Последний вход»', async () => { + (adminApi.getAdminTenantDetail as ReturnType).mockResolvedValue(makeApiDetail()); + const wrapper = await mountDetail('okna-moscow'); + await wrapper.find('[data-testid="tab-users"]').trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('Последний вход'); + expect(wrapper.text()).not.toContain('Последняя активность'); + }); + + it('на вкладке «Активность» показывает вердикт, а не журнал сделок', async () => { + (adminApi.getAdminTenantDetail as ReturnType).mockResolvedValue(makeApiDetail()); + const wrapper = await mountDetail('okna-moscow'); + await wrapper.find('[data-testid="tab-activity"]').trigger('click'); + await flushPromises(); + expect(wrapper.find('[data-testid="pane-activity"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Не заходил 6 дней'); + expect(wrapper.text()).toContain('создал проект «Окна ЕКБ»'); + expect(wrapper.text()).not.toContain('deal.created'); + }); +``` + +- [ ] **Шаг 2: Запустить и убедиться, что падает** + +```bash +cd app && npx vitest run tests/Frontend/AdminTenantDetailView.spec.ts +``` + +Ожидаемо: падение — панель старая, колонка называется по-старому. + +- [ ] **Шаг 3: Заменить панель в `TenantDetailTabs.vue`** + +В `