From ebcbfaac400d9c18ea28bfc669fb17212de686cc Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Tue, 17 Mar 2026 12:46:47 +0800 Subject: [PATCH] update dashboard --- backend/app/Service/DashboardStatsService.php | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 backend/app/Service/DashboardStatsService.php diff --git a/backend/app/Service/DashboardStatsService.php b/backend/app/Service/DashboardStatsService.php new file mode 100644 index 0000000..25ee26f --- /dev/null +++ b/backend/app/Service/DashboardStatsService.php @@ -0,0 +1,442 @@ + 实体表映射 + */ + private const TYPE_TABLE_MAP = [ + 'order' => 'orders', + 'product' => 'products', + 'refund' => 'refunds', + ]; + + /** + * 允许的 data_type 值(含 inventory,仅统计失败数) + */ + public const VALID_DATA_TYPES = ['order', 'product', 'refund', 'inventory']; + + /** + * 允许的 group_by 值 + */ + public const VALID_GROUP_BY = ['day', 'week', 'month']; + + /** + * 允许的 breakdown dimension 值 + */ + public const VALID_DIMENSIONS = ['company', 'platform', 'store']; + + /** + * dimension => 表名和名称字段映射 + */ + private const DIMENSION_TABLE_MAP = [ + 'company' => ['table' => 'companies', 'name_field' => 'name'], + 'platform' => ['table' => 'platforms', 'name_field' => 'name'], + 'store' => ['table' => 'stores', 'name_field' => 'name'], + ]; + + /** + * 获取概览统计 + * + * @return array{today: array, this_week: array, this_month: array, by_type: array} + */ + public function getOverview(string $scope_type, array $scope_ids): array + { + $now = Carbon::now(); + $today_start = $now->copy()->startOfDay()->toDateTimeString(); + $today_end = $now->copy()->endOfDay()->toDateTimeString(); + $week_start = $now->copy()->startOfWeek()->toDateTimeString(); + $month_start = $now->copy()->startOfMonth()->toDateTimeString(); + + return [ + 'today' => [ + 'success' => $this->countAllSuccess($today_start, $today_end, $scope_type, $scope_ids), + 'failed' => $this->countFailed($today_start, $today_end, $scope_type, $scope_ids), + ], + 'this_week' => [ + 'success' => $this->countAllSuccess($week_start, $today_end, $scope_type, $scope_ids), + 'failed' => $this->countFailed($week_start, $today_end, $scope_type, $scope_ids), + ], + 'this_month' => [ + 'success' => $this->countAllSuccess($month_start, $today_end, $scope_type, $scope_ids), + 'failed' => $this->countFailed($month_start, $today_end, $scope_type, $scope_ids), + ], + 'by_type' => $this->getByTypeStats($month_start, $today_end, $scope_type, $scope_ids), + ]; + } + + /** + * 获取趋势数据 + * + * @return array + */ + public function getTrend( + string $from, + string $to, + string $group_by, + ?string $data_type, + string $scope_type, + array $scope_ids, + ): array { + $trunc = $this->getDateTrunc($group_by); + $from_dt = $from . ' 00:00:00'; + $to_dt = $to . ' 23:59:59'; + + // 成功数 + $success_map = $this->getTrendSuccess($trunc, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids); + + // 失败数 + $failed_map = $this->getTrendFailed($trunc, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids); + + // 合并所有日期 + $all_dates = array_unique(array_merge(array_keys($success_map), array_keys($failed_map))); + sort($all_dates); + + $result = []; + foreach ($all_dates as $date) { + $result[] = [ + 'date' => $date, + 'success' => $success_map[$date] ?? 0, + 'failed' => $failed_map[$date] ?? 0, + ]; + } + + return $result; + } + + /** + * 获取分组统计 + * + * @return array + */ + public function getBreakdown( + string $dimension, + string $from, + string $to, + ?string $data_type, + string $scope_type, + array $scope_ids, + ): array { + $from_dt = $from . ' 00:00:00'; + $to_dt = $to . ' 23:59:59'; + $dim_col = $dimension . '_id'; + $dim_config = self::DIMENSION_TABLE_MAP[$dimension]; + + // 成功数按维度分组 + $success_map = $this->getBreakdownSuccess($dim_col, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids); + + // 失败数按维度分组 + $failed_map = $this->getBreakdownFailed($dim_col, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids); + + // 合并所有维度 ID + $all_ids = array_unique(array_merge(array_keys($success_map), array_keys($failed_map))); + + if (empty($all_ids)) { + return []; + } + + // 查询维度名称 + $placeholders = implode(',', array_fill(0, count($all_ids), '?')); + $name_rows = Db::select( + "SELECT id, {$dim_config['name_field']} as name FROM {$dim_config['table']} WHERE id IN ({$placeholders})", + array_values($all_ids) + ); + $name_map = []; + foreach ($name_rows as $row) { + $name_map[$row->id] = $row->name; + } + + $result = []; + foreach ($all_ids as $id) { + $result[] = [ + 'id' => (int) $id, + 'name' => $name_map[$id] ?? '', + 'success' => $success_map[$id] ?? 0, + 'failed' => $failed_map[$id] ?? 0, + ]; + } + + // 按 success 降序排列 + usort($result, fn($a, $b) => $b['success'] <=> $a['success']); + + return $result; + } + + /** + * 统计所有实体表的成功总数 + */ + private function countAllSuccess(string $from, string $to, string $scope_type, array $scope_ids): int + { + $total = 0; + foreach (self::TYPE_TABLE_MAP as $table) { + $total += $this->countSuccess($table, $from, $to, $scope_type, $scope_ids); + } + return $total; + } + + /** + * 统计单个实体表的成功数 + */ + private function countSuccess(string $table, string $from, string $to, string $scope_type, array $scope_ids): int + { + $bindings = [$from, $to]; + $scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings); + + $sql = "SELECT COUNT(*) as cnt FROM {$table} WHERE created_at BETWEEN ? AND ?{$scope_sql}"; + $result = Db::select($sql, $bindings); + + return (int) ($result[0]->cnt ?? 0); + } + + /** + * 统计失败消息数 + */ + private function countFailed( + string $from, + string $to, + string $scope_type, + array $scope_ids, + ?string $data_type = null, + ): int { + $bindings = [$from, $to]; + $scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings); + $type_sql = ''; + if ($data_type !== null) { + $type_sql = ' AND data_type = ?'; + $bindings[] = $data_type; + } + + $sql = "SELECT COUNT(*) as cnt FROM failed_messages WHERE failed_at BETWEEN ? AND ?{$scope_sql}{$type_sql}"; + $result = Db::select($sql, $bindings); + + return (int) ($result[0]->cnt ?? 0); + } + + /** + * 按 data_type 分别统计成功和失败数 + */ + private function getByTypeStats(string $from, string $to, string $scope_type, array $scope_ids): array + { + $stats = []; + foreach (self::VALID_DATA_TYPES as $type) { + $success = 0; + if (isset(self::TYPE_TABLE_MAP[$type])) { + $success = $this->countSuccess(self::TYPE_TABLE_MAP[$type], $from, $to, $scope_type, $scope_ids); + } + $failed = $this->countFailed($from, $to, $scope_type, $scope_ids, $type); + $stats[] = [ + 'data_type' => $type, + 'success' => $success, + 'failed' => $failed, + ]; + } + return $stats; + } + + /** + * 趋势查询:成功数按日期分组 + * + * @return array date => count + */ + private function getTrendSuccess( + string $trunc, + string $from, + string $to, + ?string $data_type, + string $scope_type, + array $scope_ids, + ): array { + $tables = $data_type !== null && isset(self::TYPE_TABLE_MAP[$data_type]) + ? [$data_type => self::TYPE_TABLE_MAP[$data_type]] + : self::TYPE_TABLE_MAP; + + // inventory 没有实体表,跳过 + if ($data_type === 'inventory') { + return []; + } + + $union_parts = []; + $all_bindings = []; + + foreach ($tables as $table) { + $bindings = [$from, $to]; + $scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings); + $union_parts[] = "SELECT {$trunc} as period FROM {$table} WHERE created_at BETWEEN ? AND ?{$scope_sql}"; + $all_bindings = array_merge($all_bindings, $bindings); + } + + if (empty($union_parts)) { + return []; + } + + $union_sql = implode(' UNION ALL ', $union_parts); + $sql = "SELECT period::date::text as date, COUNT(*) as cnt FROM ({$union_sql}) sub GROUP BY period ORDER BY period"; + $rows = Db::select($sql, $all_bindings); + + $map = []; + foreach ($rows as $row) { + $map[$row->date] = (int) $row->cnt; + } + return $map; + } + + /** + * 趋势查询:失败数按日期分组 + * + * @return array date => count + */ + private function getTrendFailed( + string $trunc, + string $from, + string $to, + ?string $data_type, + string $scope_type, + array $scope_ids, + ): array { + $bindings = [$from, $to]; + $scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings); + $type_sql = ''; + if ($data_type !== null) { + $type_sql = ' AND data_type = ?'; + $bindings[] = $data_type; + } + + $trunc_field = str_replace('created_at', 'failed_at', $trunc); + $sql = "SELECT {$trunc_field}::date::text as date, COUNT(*) as cnt FROM failed_messages WHERE failed_at BETWEEN ? AND ?{$scope_sql}{$type_sql} GROUP BY {$trunc_field} ORDER BY {$trunc_field}"; + $rows = Db::select($sql, $bindings); + + $map = []; + foreach ($rows as $row) { + $map[$row->date] = (int) $row->cnt; + } + return $map; + } + + /** + * 分组统计:成功数按维度分组 + * + * @return array dimension_id => count + */ + private function getBreakdownSuccess( + string $dim_col, + string $from, + string $to, + ?string $data_type, + string $scope_type, + array $scope_ids, + ): array { + $tables = $data_type !== null && isset(self::TYPE_TABLE_MAP[$data_type]) + ? [$data_type => self::TYPE_TABLE_MAP[$data_type]] + : self::TYPE_TABLE_MAP; + + if ($data_type === 'inventory') { + return []; + } + + $union_parts = []; + $all_bindings = []; + + foreach ($tables as $table) { + $bindings = [$from, $to]; + $scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings); + $union_parts[] = "SELECT {$dim_col} FROM {$table} WHERE created_at BETWEEN ? AND ?{$scope_sql}"; + $all_bindings = array_merge($all_bindings, $bindings); + } + + if (empty($union_parts)) { + return []; + } + + $union_sql = implode(' UNION ALL ', $union_parts); + $sql = "SELECT {$dim_col} as dim_id, COUNT(*) as cnt FROM ({$union_sql}) sub GROUP BY {$dim_col}"; + $rows = Db::select($sql, $all_bindings); + + $map = []; + foreach ($rows as $row) { + $map[(int) $row->dim_id] = (int) $row->cnt; + } + return $map; + } + + /** + * 分组统计:失败数按维度分组 + * + * @return array dimension_id => count + */ + private function getBreakdownFailed( + string $dim_col, + string $from, + string $to, + ?string $data_type, + string $scope_type, + array $scope_ids, + ): array { + $bindings = [$from, $to]; + $scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings); + $type_sql = ''; + if ($data_type !== null) { + $type_sql = ' AND data_type = ?'; + $bindings[] = $data_type; + } + + $sql = "SELECT {$dim_col} as dim_id, COUNT(*) as cnt FROM failed_messages WHERE failed_at BETWEEN ? AND ?{$scope_sql}{$type_sql} GROUP BY {$dim_col}"; + $rows = Db::select($sql, $bindings); + + $map = []; + foreach ($rows as $row) { + if ($row->dim_id !== null) { + $map[(int) $row->dim_id] = (int) $row->cnt; + } + } + return $map; + } + + /** + * 生成 DataScope SQL 条件片段 + */ + private function buildScopeCondition(string $scope_type, array $scope_ids, array &$bindings): string + { + if ($scope_type === 'all' || empty($scope_ids)) { + return ''; + } + + $placeholders = implode(',', array_fill(0, count($scope_ids), '?')); + + if ($scope_type === 'store') { + $bindings = array_merge($bindings, $scope_ids); + return " AND store_id IN ({$placeholders})"; + } + + if ($scope_type === 'platform') { + $bindings = array_merge($bindings, $scope_ids); + return " AND platform_id IN ({$placeholders})"; + } + + return ''; + } + + /** + * 获取 DATE_TRUNC SQL 表达式 + */ + private function getDateTrunc(string $group_by): string + { + return match ($group_by) { + 'day' => "DATE_TRUNC('day', created_at)", + 'week' => "DATE_TRUNC('week', created_at)", + 'month' => "DATE_TRUNC('month', created_at)", + }; + } +}