diff --git a/app/app/Http/Controllers/Api/AdminDashboardController.php b/app/app/Http/Controllers/Api/AdminDashboardController.php index 5d66410d..e6025b98 100644 --- a/app/app/Http/Controllers/Api/AdminDashboardController.php +++ b/app/app/Http/Controllers/Api/AdminDashboardController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Services\Dashboard\SupplyReconciliation; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -36,6 +37,8 @@ class AdminDashboardController extends Controller 'period' => (string) $request->query('period', '7d'), 'finance' => $this->financeTile($from), 'health' => $this->healthTile(), + 'leads' => $this->leadsTile(), + 'supply' => $this->supplyTile(), ]); } @@ -169,4 +172,154 @@ class AdminDashboardController extends Controller 'last_sync_at' => $lastSync->finished_at ?? null, ]; } + + // === Этап 2: Лиды === + + /** @return array */ + private function leadsMetrics(): array + { + $todayStart = now('Europe/Moscow')->startOfDay(); + + $deliveredToday = (int) DB::table('projects')->sum('delivered_today'); + $received = DB::table('supplier_leads')->where('received_at', '>=', $todayStart)->count(); + $processed = DB::table('supplier_leads') + ->where('received_at', '>=', $todayStart) + ->whereNotNull('processed_at') + ->count(); + $unrouted = DB::table('supplier_leads')->whereNull('processed_at')->count(); + $stuck = DB::table('supplier_leads') + ->whereNull('processed_at') + ->where('received_at', '<', now()->subHours(4)) + ->count(); + $unassigned = DB::table('deals') + ->whereNull('manager_id')->where('status', 'new') + ->whereNull('deleted_at')->where('is_test', false) + ->count(); + + $deliveryPct = $received > 0 ? round($processed / $received * 100, 1) : 100.0; + + $light = 'green'; + if ($stuck > 0) { + $light = 'red'; + } elseif ($unrouted > 0) { + $light = 'amber'; + } + + return [ + 'light' => $light, + 'delivered_today' => $deliveredToday, + 'stuck' => $stuck, + 'unrouted' => $unrouted, + 'unassigned_deals' => $unassigned, + 'delivery_pct' => $deliveryPct, + ]; + } + + /** @return array */ + private function leadsTile(): array + { + $m = $this->leadsMetrics(); + + return [ + 'light' => $m['light'], + 'delivered_today' => $m['delivered_today'], + 'stuck' => $m['stuck'], + 'unrouted' => $m['unrouted'], + 'delivery_pct' => $m['delivery_pct'], + ]; + } + + /** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */ + public function leads(): JsonResponse + { + $m = $this->leadsMetrics(); + + return response()->json([ + 'light' => $m['light'], + 'kpi' => [ + 'delivered_today' => $m['delivered_today'], + 'stuck' => $m['stuck'], + 'unrouted' => $m['unrouted'], + 'unassigned_deals' => $m['unassigned_deals'], + 'delivery_pct' => $m['delivery_pct'], + ], + ]); + } + + // === Этап 2: Заказ у поставщика === + + /** + * Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects). + * + * @return array{snapshot_date:?string,result:array{groups:list>,totals:array}} + */ + private function supplyReconciliation(): array + { + /** @var string|null $latest */ + $latest = DB::table('project_routing_snapshots')->max('snapshot_date'); + + $demand = []; + if ($latest !== null) { + $rows = DB::table('project_routing_snapshots') + ->where('snapshot_date', $latest) + ->groupBy('signal_type', 'signal_identifier') + ->select( + 'signal_type', + 'signal_identifier', + DB::raw('SUM(daily_limit) AS demand'), + DB::raw('MAX(daily_limit) AS max_limit'), + ) + ->get(); + + foreach ($rows as $r) { + $demand[] = [ + 'signal_type' => (string) $r->signal_type, + 'identifier' => (string) $r->signal_identifier, + 'demand' => (int) $r->demand, + 'max_limit' => (int) $r->max_limit, + ]; + } + } + + /** @var array $orderedByKey */ + $orderedByKey = DB::table('supplier_projects') + ->groupBy('signal_type', 'unique_key') + ->select('signal_type', 'unique_key', DB::raw('SUM(current_limit) AS ordered')) + ->get() + ->mapWithKeys(fn ($r) => [$r->signal_type.'|'.$r->unique_key => (int) $r->ordered]) + ->all(); + + return [ + 'snapshot_date' => $latest, + 'result' => SupplyReconciliation::build($demand, $orderedByKey), + ]; + } + + /** @return array */ + private function supplyTile(): array + { + $totals = $this->supplyReconciliation()['result']['totals']; + + return [ + 'light' => $totals['mismatches'] > 0 ? 'red' : 'green', + 'demand' => $totals['demand'], + 'formula' => $totals['formula'], + 'ordered' => $totals['ordered'], + 'mismatches' => $totals['mismatches'], + ]; + } + + /** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */ + public function supply(): JsonResponse + { + $rec = $this->supplyReconciliation(); + $totals = $rec['result']['totals']; + + return response()->json([ + 'snapshot_date' => $rec['snapshot_date'], + 'light' => $totals['mismatches'] > 0 ? 'red' : 'green', + 'totals' => $totals, + 'groups' => $rec['result']['groups'], + ]); + } } diff --git a/app/app/Services/Dashboard/SupplyReconciliation.php b/app/app/Services/Dashboard/SupplyReconciliation.php new file mode 100644 index 00000000..0f662a18 --- /dev/null +++ b/app/app/Services/Dashboard/SupplyReconciliation.php @@ -0,0 +1,62 @@ + $demand + * @param array $orderedByKey ключ "signal_type|identifier" => SUM(current_limit) + * @return array{groups:list,totals:array{demand:int,formula:int,ordered:int,mismatches:int}} + */ + public static function build(array $demand, array $orderedByKey): array + { + $groups = []; + $sumDemand = 0; + $sumFormula = 0; + $sumOrdered = 0; + $mismatches = 0; + + foreach ($demand as $d) { + $formula = max((int) $d['max_limit'], (int) ceil($d['demand'] / 3)); + $key = $d['signal_type'].'|'.$d['identifier']; + $ordered = (int) ($orderedByKey[$key] ?? 0); + $inSync = $formula === $ordered; + + $groups[] = [ + 'signal_type' => (string) $d['signal_type'], + 'identifier' => (string) $d['identifier'], + 'demand' => (int) $d['demand'], + 'formula' => $formula, + 'ordered' => $ordered, + 'in_sync' => $inSync, + ]; + + $sumDemand += (int) $d['demand']; + $sumFormula += $formula; + $sumOrdered += $ordered; + if (! $inSync) { + $mismatches++; + } + } + + return [ + 'groups' => $groups, + 'totals' => [ + 'demand' => $sumDemand, + 'formula' => $sumFormula, + 'ordered' => $sumOrdered, + 'mismatches' => $mismatches, + ], + ]; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 9cae7fc8..bee4e7f6 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -304,8 +304,20 @@ parameters: message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound count: 1 + path: tests/Feature/Admin/AdminDashboardLeadsTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 2 path: tests/Feature/Admin/AdminDashboardSummaryTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Admin/AdminDashboardSupplyTest.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 32381b74..cc0c8e18 100644 --- a/app/resources/js/api/adminDashboard.ts +++ b/app/resources/js/api/adminDashboard.ts @@ -28,6 +28,45 @@ export interface DashboardSummary { last_sync_status: string; last_sync_at: string | null; }; + leads: { + light: Light; + delivered_today: number; + stuck: number; + unrouted: number; + delivery_pct: number; + }; + supply: { + light: Light; + demand: number; + formula: number; + ordered: number; + mismatches: number; + }; +} + +export interface LeadsDetail { + light: Light; + kpi: { + delivered_today: number; + stuck: number; + unrouted: number; + unassigned_deals: number; + delivery_pct: number; + }; +} + +export interface SupplyDetail { + snapshot_date: string | null; + light: Light; + totals: { demand: number; formula: number; ordered: number; mismatches: number }; + groups: Array<{ + signal_type: string; + identifier: string; + demand: number; + formula: number; + ordered: number; + in_sync: boolean; + }>; } export interface FinanceDetail { @@ -71,3 +110,13 @@ export async function getDashboardHealth(): Promise { const { data } = await apiClient.get('/api/admin/dashboard/health'); return data; } + +export async function getDashboardLeads(): Promise { + const { data } = await apiClient.get('/api/admin/dashboard/leads'); + return data; +} + +export async function getDashboardSupply(): Promise { + const { data } = await apiClient.get('/api/admin/dashboard/supply'); + return data; +} diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index f0d109b3..d39c3cc9 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -1,8 +1,8 @@