From 6536c19c966313636d98ede09b013c5af0f6b87c 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: Sun, 28 Jun 2026 10:14:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4?= =?UTF-8?q?):=20=D0=AD=D1=82=D0=B0=D0=BF=20A=20=E2=80=94=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=9B=D0=B8=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BE=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран «Лиды» (/admin/leads): серверный список с фильтрами (дата/канал/поставщик/ статус/поиск) + пагинация (масштаб 10⁴+ лидов). Карточка лида (/admin/leads/{id}): полная цепочка — ОТКУДА (поставщик B1/B2/B3 + канал + источник + регион) → КОМУ (сделки клиентов через deals.source_crm_id = supplier_leads.vid). Дашборд: drill Лиды +топ-10 последних + «Открыть все лиды →». Nav-пункт «Лиды». ПДн-телефон маскируется (152-ФЗ). Тесты: backend 3 + FE 5 (38 FE всего зелёные). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Api/AdminDashboardController.php | 32 ++- .../Controllers/Api/AdminLeadsController.php | 210 ++++++++++++++++++ app/phpstan-baseline.neon | 6 + app/resources/js/api/adminDashboard.ts | 10 + app/resources/js/api/adminLeads.ts | 81 +++++++ app/resources/js/layouts/AdminLayout.vue | 1 + app/resources/js/router/index.ts | 12 + .../js/views/admin/AdminDashboardView.vue | 47 +++- .../js/views/admin/AdminLeadDetailView.vue | 147 ++++++++++++ .../js/views/admin/AdminLeadsView.vue | 194 ++++++++++++++++ app/routes/web.php | 2 + app/tests/Feature/Admin/AdminLeadsTest.php | 89 ++++++++ app/tests/Frontend/AdminDashboardView.spec.ts | 14 ++ app/tests/Frontend/AdminLeads.spec.ts | 71 ++++++ docs/observer/STATUS.md | 11 +- 15 files changed, 918 insertions(+), 9 deletions(-) create mode 100644 app/app/Http/Controllers/Api/AdminLeadsController.php create mode 100644 app/resources/js/api/adminLeads.ts create mode 100644 app/resources/js/views/admin/AdminLeadDetailView.vue create mode 100644 app/resources/js/views/admin/AdminLeadsView.vue create mode 100644 app/tests/Feature/Admin/AdminLeadsTest.php create mode 100644 app/tests/Frontend/AdminLeads.spec.ts diff --git a/app/app/Http/Controllers/Api/AdminDashboardController.php b/app/app/Http/Controllers/Api/AdminDashboardController.php index 10cc0d79..93e84ca7 100644 --- a/app/app/Http/Controllers/Api/AdminDashboardController.php +++ b/app/app/Http/Controllers/Api/AdminDashboardController.php @@ -279,11 +279,29 @@ class AdminDashboardController extends Controller ]; } - /** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */ + /** GET /api/admin/dashboard/leads — KPI распределения лидов + топ-10 последних (L2). */ public function leads(): JsonResponse { $m = $this->leadsMetrics(); + // Топ-10 последних лидов для drill (полный список — на экране /admin/leads). + $recent = DB::table('supplier_leads as sl') + ->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id') + ->orderByDesc('sl.received_at') + ->limit(10) + ->get(['sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.processed_at', + 'sl.deals_created_count', 'sp.signal_type as channel', 'sp.unique_key']) + ->map(fn ($r) => [ + 'id' => (int) $r->id, + 'received_at' => $r->received_at, + 'platform' => $r->platform, + 'channel' => $r->channel, + 'source' => $r->unique_key, + 'phone_masked' => $this->maskPhoneShort($r->phone), + 'delivered' => ((int) ($r->deals_created_count ?? 0)) > 0, + 'processed' => $r->processed_at !== null, + ]); + return response()->json([ 'light' => $m['light'], 'kpi' => [ @@ -292,9 +310,21 @@ class AdminDashboardController extends Controller 'stuck' => $m['stuck'], 'unrouted' => $m['unrouted'], ], + 'recent' => $recent, ]); } + /** Короткая маска телефона для drill (152-ФЗ). */ + private function maskPhoneShort(?string $phone): string + { + if (! $phone) { + return '—'; + } + $d = preg_replace('/\D/', '', $phone); + + return strlen((string) $d) >= 4 ? substr((string) $d, 0, 2).'***'.substr((string) $d, -2) : '***'; + } + // === Этап 2: Заказ у поставщика === /** diff --git a/app/app/Http/Controllers/Api/AdminLeadsController.php b/app/app/Http/Controllers/Api/AdminLeadsController.php new file mode 100644 index 00000000..59e13d23 --- /dev/null +++ b/app/app/Http/Controllers/Api/AdminLeadsController.php @@ -0,0 +1,210 @@ +error !== null && $r->error !== '') { + return 'error'; + } + if ($r->processed_at !== null) { + return ((int) ($r->deals_created_count ?? 0)) > 0 ? 'delivered' : 'no_match'; + } + + return 'pending'; // визуально «завис» определяет фронт по времени, но базово pending + } + + /** Базовый запрос лидов с присоединённым источником (supplier_projects). */ + private function baseQuery(Request $request): Builder + { + $q = DB::table('supplier_leads as sl') + ->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id'); + + if (($df = (string) $request->query('date_from', '')) !== '' && ($dt = (string) $request->query('date_to', '')) !== '') { + $q->whereBetween('sl.received_at', [$df.' 00:00:00', $dt.' 23:59:59']); + } + if (($channel = (string) $request->query('channel', '')) !== '') { + $q->where('sp.signal_type', $channel); + } + if (($platform = (string) $request->query('platform', '')) !== '') { + $q->where('sl.platform', $platform); + } + if (($search = trim((string) $request->query('search', ''))) !== '') { + $q->where(function ($w) use ($search) { + $w->where('sl.phone', 'like', '%'.$search.'%') + ->orWhere('sp.unique_key', 'like', '%'.$search.'%') + ->orWhere('sl.vid', '=', ctype_digit($search) ? (int) $search : 0); + }); + } + if (($status = (string) $request->query('status', '')) !== '') { + $this->applyStatusFilter($q, $status); + } + if (($tenantId = (int) $request->query('tenant_id', 0)) > 0) { + $q->whereExists(function ($e) use ($tenantId) { + $e->select(DB::raw(1))->from('deals') + ->whereColumn('deals.source_crm_id', 'sl.vid') + ->where('deals.tenant_id', $tenantId); + }); + } + + return $q; + } + + private function applyStatusFilter(Builder $q, string $status): void + { + match ($status) { + 'error' => $q->whereNotNull('sl.error')->where('sl.error', '<>', ''), + 'delivered' => $q->whereNotNull('sl.processed_at')->where('sl.deals_created_count', '>', 0), + 'no_match' => $q->whereNotNull('sl.processed_at') + ->where(fn ($w) => $w->whereNull('sl.deals_created_count')->orWhere('sl.deals_created_count', '=', 0)), + 'stuck' => $q->whereNull('sl.processed_at')->where('sl.received_at', '<', now()->subHours(self::STUCK_HOURS)), + 'pending' => $q->whereNull('sl.processed_at'), + default => null, + }; + } + + /** @return array */ + private function rowToArray(object $r): array + { + return [ + 'id' => (int) $r->id, + 'received_at' => $r->received_at, + 'platform' => $r->platform, + 'channel' => $r->channel, + 'source' => $r->unique_key, + 'region_code' => $r->resolved_subject_code !== null ? (int) $r->resolved_subject_code : null, + 'phone_masked' => $this->maskPhone($r->phone), + 'deals_created_count' => (int) ($r->deals_created_count ?? 0), + 'status' => $this->statusOf($r), + ]; + } + + /** GET /api/admin/leads — серверный список с фильтрами/пагинацией. */ + public function index(Request $request): JsonResponse + { + $perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->query('per_page', self::PER_PAGE_DEFAULT))); + $page = max(1, (int) $request->query('page', 1)); + + $base = $this->baseQuery($request); + $total = (clone $base)->count(); + + $rows = $base + ->orderByDesc('sl.received_at') + ->offset(($page - 1) * $perPage) + ->limit($perPage) + ->get([ + 'sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.deals_created_count', + 'sl.processed_at', 'sl.error', 'sl.resolved_subject_code', + 'sp.signal_type as channel', 'sp.unique_key', + ]) + ->map(fn ($r) => $this->rowToArray($r)); + + return response()->json([ + 'data' => $rows, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]); + } + + /** GET /api/admin/leads/{id} — карточка лида: источник + сделки клиентов (цепочка). */ + public function show(int $id): JsonResponse + { + $lead = DB::table('supplier_leads as sl') + ->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id') + ->where('sl.id', $id) + ->first([ + 'sl.id', 'sl.received_at', 'sl.processed_at', 'sl.error', 'sl.platform', 'sl.phone', + 'sl.vid', 'sl.deals_created_count', 'sl.resolved_subject_code', 'sl.region_source', + 'sl.phone_operator', 'sp.signal_type as channel', 'sp.unique_key', 'sp.id as supplier_project_id', + ]); + + if ($lead === null) { + return response()->json(['message' => 'Лид не найден'], 404); + } + + $deals = DB::table('deals') + ->join('tenants', 'tenants.id', '=', 'deals.tenant_id') + ->where('deals.source_crm_id', $lead->vid) + ->orderByDesc('deals.received_at') + ->limit(50) + ->get([ + 'deals.id', 'deals.tenant_id', 'tenants.organization_name', 'tenants.subdomain', + 'deals.status', 'deals.project_id', 'deals.received_at', + ]) + ->map(fn ($d) => [ + 'id' => (int) $d->id, + 'tenant_id' => (int) $d->tenant_id, + 'tenant_name' => $d->organization_name ?: $d->subdomain, + 'subdomain' => $d->subdomain, + 'status' => $d->status, + 'project_id' => $d->project_id !== null ? (int) $d->project_id : null, + 'received_at' => $d->received_at, + ]); + + return response()->json([ + 'lead' => [ + 'id' => (int) $lead->id, + 'platform' => $lead->platform, + 'phone_masked' => $this->maskPhone($lead->phone), + 'received_at' => $lead->received_at, + 'processed_at' => $lead->processed_at, + 'error' => $lead->error, + 'region_code' => $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null, + 'region_source' => $lead->region_source, + 'phone_operator' => $lead->phone_operator, + 'deals_created_count' => (int) ($lead->deals_created_count ?? 0), + 'status' => $this->statusOf($lead), + ], + 'source' => [ + 'platform' => $lead->platform, + 'channel' => $lead->channel, + 'identifier' => $lead->unique_key, + 'supplier_project_id' => $lead->supplier_project_id !== null ? (int) $lead->supplier_project_id : null, + ], + 'deals' => $deals, + ]); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 3b6e5b5d..30af1dbb 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -336,6 +336,12 @@ parameters: count: 1 path: tests/Feature/Admin/AdminDashboardSupplyTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Admin/AdminLeadsTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound diff --git a/app/resources/js/api/adminDashboard.ts b/app/resources/js/api/adminDashboard.ts index 69eda943..f6fe88b4 100644 --- a/app/resources/js/api/adminDashboard.ts +++ b/app/resources/js/api/adminDashboard.ts @@ -111,6 +111,16 @@ export interface LeadsDetail { stuck: number; unrouted: number; }; + recent: Array<{ + id: number; + received_at: string; + platform: string; + channel: string | null; + source: string | null; + phone_masked: string; + delivered: boolean; + processed: boolean; + }>; } export interface SupplyDetail { diff --git a/app/resources/js/api/adminLeads.ts b/app/resources/js/api/adminLeads.ts new file mode 100644 index 00000000..eded2c7e --- /dev/null +++ b/app/resources/js/api/adminLeads.ts @@ -0,0 +1,81 @@ +import { apiClient } from './client'; + +/** + * SaaS-admin «Лиды» (L3) — сквозная вложенность дашборда до источника. + * Серверная пагинация/фильтры. Backend: AdminLeadsController. + * Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md + */ + +export type LeadStatus = 'delivered' | 'no_match' | 'pending' | 'stuck' | 'error'; + +export interface LeadRow { + id: number; + received_at: string; + platform: string; + channel: string | null; + source: string | null; + region_code: number | null; + phone_masked: string; + deals_created_count: number; + status: string; +} + +export interface LeadsPage { + data: LeadRow[]; + total: number; + page: number; + per_page: number; +} + +export interface LeadsFilters { + page?: number; + per_page?: number; + date_from?: string; + date_to?: string; + channel?: string; + platform?: string; + status?: string; + tenant_id?: number; + search?: string; +} + +export interface LeadDetail { + lead: { + id: number; + platform: string; + phone_masked: string; + received_at: string; + processed_at: string | null; + error: string | null; + region_code: number | null; + region_source: string | null; + phone_operator: string | null; + deals_created_count: number; + status: string; + }; + source: { + platform: string; + channel: string | null; + identifier: string | null; + supplier_project_id: number | null; + }; + deals: Array<{ + id: number; + tenant_id: number; + tenant_name: string; + subdomain: string; + status: string; + project_id: number | null; + received_at: string; + }>; +} + +export async function getLeads(filters: LeadsFilters): Promise { + const { data } = await apiClient.get('/api/admin/leads', { params: filters }); + return data; +} + +export async function getLead(id: number | string): Promise { + const { data } = await apiClient.get(`/api/admin/leads/${id}`); + return data; +} diff --git a/app/resources/js/layouts/AdminLayout.vue b/app/resources/js/layouts/AdminLayout.vue index d6e44de4..a9b78cf6 100644 --- a/app/resources/js/layouts/AdminLayout.vue +++ b/app/resources/js/layouts/AdminLayout.vue @@ -27,6 +27,7 @@ interface NavItem { const navItems: NavItem[] = [ { title: 'Командный центр', icon: 'mdi-view-dashboard-outline', to: '/admin/dashboard' }, { title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' }, + { title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' }, { title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' }, { title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' }, { title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' }, diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index c19cc142..27e7a735 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -222,6 +222,18 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/admin/AdminBillingView.vue'), meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' }, }, + { + path: '/admin/leads', + name: 'admin-leads', + component: () => import('../views/admin/AdminLeadsView.vue'), + meta: { layout: 'admin', title: 'Лиды', requiresAuth: true, devLabel: 'Admin Leads' }, + }, + { + path: '/admin/leads/:id', + name: 'admin-lead-detail', + component: () => import('../views/admin/AdminLeadDetailView.vue'), + meta: { layout: 'admin', title: 'Лид', requiresAuth: true, devLabel: 'Admin Lead Detail' }, + }, { path: '/admin/incidents', name: 'admin-incidents', diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index ab57263f..825d1e42 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -216,6 +216,19 @@ function openTenant(subdomain: string) { router.push({ name: 'admin-tenant-detail', params: { code: subdomain } }); } +function openLead(id: number) { + router.push({ name: 'admin-lead-detail', params: { id: String(id) } }); +} + +function leadChannel(c: string | null): string { + return c === 'site' ? 'Сайт' : c === 'call' ? 'Звонок' : c === 'sms' ? 'SMS' : '—'; +} + +function leadTime(v: string): string { + const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/); + return m ? `${m[3]}.${m[2]} ${m[4]}` : v; +} + onMounted(load); defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance, health, leads, supply, balances, clients, loading, fetchError, load }); @@ -706,9 +719,39 @@ defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance, + +
+

Последние лиды

+ + Открыть все лиды → + +
+ + + ВремяКаналИсточникПоставщикТелефонСтатус + + + + {{ leadTime(r.received_at) }} + {{ leadChannel(r.channel) }} + {{ r.source ?? '—' }} + {{ r.platform }} + {{ r.phone_masked }} + + + {{ r.delivered ? 'доставлен' : r.processed ? 'без получателя' : 'в обработке' }} + + + + + Лидов пока нет + + +

- «Доставлено» — сделки, созданные клиентам сегодня. «Получено» — лиды, пришедшие от поставщика - сегодня. «Зависшие» — лиды без распределения дольше 4 часов (если их много — проблема синхронизации). + Клик по лиду — карточка с полной цепочкой: откуда пришёл (поставщик + канал + регион) и кому ушёл. + Полный список с фильтрами и поиском — «Открыть все лиды».

diff --git a/app/resources/js/views/admin/AdminLeadDetailView.vue b/app/resources/js/views/admin/AdminLeadDetailView.vue new file mode 100644 index 00000000..95912fdc --- /dev/null +++ b/app/resources/js/views/admin/AdminLeadDetailView.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/app/resources/js/views/admin/AdminLeadsView.vue b/app/resources/js/views/admin/AdminLeadsView.vue new file mode 100644 index 00000000..3691fa47 --- /dev/null +++ b/app/resources/js/views/admin/AdminLeadsView.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/app/routes/web.php b/app/routes/web.php index d1ffd89a..d0e092f4 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -117,6 +117,8 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () { Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply'); Route::get('/api/admin/dashboard/balances', 'App\Http\Controllers\Api\AdminDashboardController@balances'); Route::get('/api/admin/dashboard/clients', 'App\Http\Controllers\Api\AdminDashboardController@clients'); + Route::get('/api/admin/leads', 'App\Http\Controllers\Api\AdminLeadsController@index'); + Route::get('/api/admin/leads/{id}', 'App\Http\Controllers\Api\AdminLeadsController@show')->whereNumber('id'); // SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin). Route::prefix('/api/admin/impersonation')->group(function () { diff --git a/app/tests/Feature/Admin/AdminLeadsTest.php b/app/tests/Feature/Admin/AdminLeadsTest.php new file mode 100644 index 00000000..286fdb94 --- /dev/null +++ b/app/tests/Feature/Admin/AdminLeadsTest.php @@ -0,0 +1,89 @@ +insertGetId([ + 'subdomain' => 'acme'.uniqid(), 'organization_name' => 'Acme', 'contact_email' => 'a@acme.ru', + 'status' => 'active', 'is_trial' => false, 'balance_rub' => 0, 'balance_leads' => 0, + 'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(), + ]); +} + +function seedSupplierProject(string $signal, string $key): int +{ + return DB::table('supplier_projects')->insertGetId([ + 'platform' => 'B1', 'signal_type' => $signal, 'unique_key' => $key, + 'current_limit' => 10, 'sync_status' => 'ok', 'created_at' => now(), 'updated_at' => now(), + ]); +} + +it('GET /api/admin/leads — пагинированный список с фильтром по каналу', function () { + $sp = seedSupplierProject('site', 'okna.ru'); + DB::table('supplier_leads')->insert([ + ['supplier_project_id' => $sp, 'platform' => 'B1', 'raw_payload' => json_encode(['x' => 1]), + 'phone' => '+79135397707', 'vid' => 1001, 'received_at' => now()->subHour(), + 'processed_at' => now()->subHour(), 'deals_created_count' => 2], + ['supplier_project_id' => null, 'platform' => 'B2', 'raw_payload' => json_encode(['x' => 2]), + 'phone' => '+79990001122', 'vid' => 1002, 'received_at' => now()->subDays(2), + 'processed_at' => null, 'deals_created_count' => 0], + ]); + + $res = $this->getJson('/api/admin/leads?per_page=10'); + $res->assertOk(); + $res->assertJsonStructure([ + 'data' => [['id', 'received_at', 'platform', 'channel', 'source', 'region_code', 'phone_masked', 'deals_created_count', 'status']], + 'total', 'page', 'per_page', + ]); + expect($res->json('total'))->toBeGreaterThanOrEqual(2); + + // фильтр по каналу site → только лид с supplier_project site + $res2 = $this->getJson('/api/admin/leads?channel=site'); + expect(collect($res2->json('data'))->pluck('channel')->unique()->all())->toBe(['site']); +}); + +it('телефон в списке маскируется', function () { + $sp = seedSupplierProject('call', '+74950000000'); + DB::table('supplier_leads')->insert([ + 'supplier_project_id' => $sp, 'platform' => 'B1', 'raw_payload' => json_encode([]), + 'phone' => '+79135397707', 'vid' => 2001, 'received_at' => now(), 'deals_created_count' => 0, + ]); + $res = $this->getJson('/api/admin/leads?channel=call'); + $masked = collect($res->json('data'))->firstWhere('id', '!=', null)['phone_masked'] ?? ''; + expect($masked)->not->toContain('9135397'); // середина скрыта + expect($masked)->toContain('**'); +}); + +it('GET /api/admin/leads/{id} — карточка с цепочкой: источник + сделки клиентов', function () { + $sp = seedSupplierProject('site', 'okna.ru'); + $leadId = DB::table('supplier_leads')->insertGetId([ + 'supplier_project_id' => $sp, 'platform' => 'B1', 'raw_payload' => json_encode(['tag' => 77]), + 'phone' => '+79135397707', 'vid' => 5005, 'received_at' => now()->subHour(), + 'processed_at' => now()->subHour(), 'deals_created_count' => 1, 'resolved_subject_code' => 77, + ]); + $tenant = seedLeadTenant(); + $project = DB::table('projects')->insertGetId([ + 'tenant_id' => $tenant, 'name' => 'Проект', 'is_active' => true, + 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('deals')->insert([ + 'tenant_id' => $tenant, 'project_id' => $project, 'source_crm_id' => 5005, 'phone' => '+79135397707', + 'status' => 'new', 'is_test' => false, 'received_at' => now()->subHour(), 'created_at' => now(), 'updated_at' => now(), + ]); + + $res = $this->getJson("/api/admin/leads/{$leadId}"); + $res->assertOk(); + $res->assertJsonStructure([ + 'lead' => ['id', 'platform', 'phone_masked', 'received_at', 'region_code', 'status'], + 'source' => ['platform', 'channel', 'identifier'], + 'deals' => [['tenant_id', 'tenant_name', 'status']], + ]); + expect($res->json('source.identifier'))->toBe('okna.ru'); + expect($res->json('deals.0.tenant_name'))->toBe('Acme'); +}); diff --git a/app/tests/Frontend/AdminDashboardView.spec.ts b/app/tests/Frontend/AdminDashboardView.spec.ts index c5f06d55..6e0c5fe0 100644 --- a/app/tests/Frontend/AdminDashboardView.spec.ts +++ b/app/tests/Frontend/AdminDashboardView.spec.ts @@ -45,6 +45,9 @@ vi.mock('../../resources/js/api/adminDashboard', () => ({ getDashboardLeads: vi.fn().mockResolvedValue({ light: 'green', kpi: { delivered_today: 71, received_today: 80, stuck: 0, unrouted: 0 }, + recent: [ + { id: 501, received_at: '2026-06-28 07:55', platform: 'B1', channel: 'site', source: 'okna.ru', phone_masked: '79***07', delivered: true, processed: true }, + ], }), getDashboardSupply: vi.fn().mockResolvedValue({ snapshot_date: '2026-06-28', @@ -160,6 +163,17 @@ describe('AdminDashboardView.vue', () => { expect(wrapper.text()).toContain('По группам'); }); + it('drill Лиды показывает последние лиды и ссылку «Открыть все лиды»', async () => { + const { wrapper } = await factory(); + await wrapper.find('[data-testid="tile-leads"]').trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-testid="drill-leads"]').exists()).toBe(true); + expect(wrapper.text()).toContain('okna.ru'); // recent lead source + const link = wrapper.find('[data-testid="open-all-leads"]'); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toContain('/admin/leads'); + }); + it('Финансы и Здоровье показывают живые числа из API', async () => { const { wrapper } = await factory(); const text = wrapper.text(); diff --git a/app/tests/Frontend/AdminLeads.spec.ts b/app/tests/Frontend/AdminLeads.spec.ts new file mode 100644 index 00000000..c3b475aa --- /dev/null +++ b/app/tests/Frontend/AdminLeads.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import AdminLeadsView from '../../resources/js/views/admin/AdminLeadsView.vue'; +import AdminLeadDetailView from '../../resources/js/views/admin/AdminLeadDetailView.vue'; + +vi.mock('../../resources/js/api/adminLeads', () => ({ + getLeads: vi.fn().mockResolvedValue({ + data: [ + { id: 501, received_at: '2026-06-28 07:55', platform: 'B1', channel: 'site', source: 'okna.ru', region_code: 77, phone_masked: '79***07', deals_created_count: 2, status: 'delivered' }, + ], + total: 1, page: 1, per_page: 25, + }), + getLead: vi.fn().mockResolvedValue({ + lead: { id: 501, platform: 'B1', phone_masked: '79***07', received_at: '2026-06-28 07:55', processed_at: '2026-06-28 07:56', error: null, region_code: 77, region_source: 'dadata', phone_operator: 'МТС', deals_created_count: 1, status: 'delivered' }, + source: { platform: 'B1', channel: 'site', identifier: 'okna.ru', supplier_project_id: 9 }, + deals: [{ id: 1, tenant_id: 2, tenant_name: 'Компания 1', subdomain: 'c1', status: 'new', project_id: 5, received_at: '2026-06-28 07:56' }], + }), +})); + +beforeEach(() => vi.clearAllMocks()); + +function routerWith(path: string) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/admin/leads', name: 'admin-leads', component: AdminLeadsView }, + { path: '/admin/leads/:id', name: 'admin-lead-detail', component: AdminLeadDetailView }, + { path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '
' } }, + { path: '/admin/supplier-projects', name: 'admin-supplier-projects', component: { template: '
' } }, + { path: '/admin/dashboard', name: 'admin-dashboard', component: { template: '
' } }, + ], + }); + return router.push(path).then(() => router); +} + +describe('AdminLeadsView', () => { + it('грузит список и показывает строку лида', async () => { + const router = await routerWith('/admin/leads'); + const wrapper = mount(AdminLeadsView, { global: { plugins: [createVuetify(), router] } }); + await flushPromises(); + expect(wrapper.text()).toContain('okna.ru'); + expect(wrapper.text()).toContain('доставлен'); + const api = await import('../../resources/js/api/adminLeads'); + expect(api.getLeads).toHaveBeenCalled(); + }); + + it('фильтр по каналу шлёт channel и сбрасывает на 1 страницу', async () => { + const router = await routerWith('/admin/leads'); + const wrapper = mount(AdminLeadsView, { global: { plugins: [createVuetify(), router] } }); + await flushPromises(); + const api = await import('../../resources/js/api/adminLeads'); + wrapper.vm.filters.channel = 'call'; + await wrapper.find('[data-testid="apply-filters"]').trigger('click'); + await flushPromises(); + expect(vi.mocked(api.getLeads).mock.calls.at(-1)?.[0]).toMatchObject({ channel: 'call', page: 1 }); + }); +}); + +describe('AdminLeadDetailView', () => { + it('показывает цепочку: откуда (источник) и кому (сделки)', async () => { + const router = await routerWith('/admin/leads/501'); + const wrapper = mount(AdminLeadDetailView, { global: { plugins: [createVuetify(), router] } }); + await flushPromises(); + expect(wrapper.find('[data-testid="lead-source"]').exists()).toBe(true); + expect(wrapper.text()).toContain('okna.ru'); // источник + expect(wrapper.find('[data-testid="lead-deals"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Компания 1'); // кому ушёл + }); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 79291154..036d8c74 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T05:42:31.988Z +Last updated: 2026-06-28T06:54:27.588Z | Контролёр | Состояние | Детали | |---|---|---| @@ -33,7 +33,7 @@ Last updated: 2026-06-28T05:42:31.988Z | enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 | | enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 | -Недавние escape владельца: 0 · Недавние блоки: 5 +Недавние escape владельца: 0 · Недавние блоки: 4 **Недавние блоки (детали):** @@ -43,7 +43,6 @@ Last updated: 2026-06-28T05:42:31.988Z | 2026-06-27T10:01:08.010Z | bash:git restore --staged docs/observer/STATUS.md 2>/dev/null; git diff --staged --name-only | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git restore --staged d | | 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d | | 2026-06-27T07:03:56.852Z | bash:node -e "1" 2>/dev/null; for f in docs/secretary/*/protocol.md; do printf '%6s %s\n' "$(wc -l < "$f")" "$f"; done | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "1" 2>/dev/nul | -| 2026-06-27T05:45:19.915Z | bash:rm ~/.claude/runtime/secretary-mode-*.json | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:rm ~/.claude/runtime/s | ## Метрики (информационные, не алерты) @@ -127,9 +126,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.25ч | NaNч | -| 21928 | Code | 7.53ч | 0.0ч | -| 1212 | svchost | 4.42ч | 0.0ч | +| 3440 | MsMpEng | 17.42ч | 0.0ч | +| 21928 | Code | 7.68ч | NaNч | +| 1212 | svchost | 4.45ч | 451353.9ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.