where('status', 1) ->whereNotNull('latitude') ->whereNotNull('longitude') ->with(['teachers' => function ($q) { $q->with(['starLevelItem', 'researchDirections'])->orderBy('name'); }]) ->orderBy('name') ->get(); $bounds = $this->resolveBounds($universities); $schools = $universities->map(function (University $u) use ($bounds) { return [ 'id' => $u->id, 'name' => $u->name, 'city' => $u->city, 'latitude' => (float) $u->latitude, 'longitude' => (float) $u->longitude, 'left_percent' => $this->toPercent($u->longitude, $bounds['min_lng'], $bounds['max_lng']), 'top_percent' => $this->toPercentInverted($u->latitude, $bounds['min_lat'], $bounds['max_lat']), 'teachers_count' => $u->teachers->count(), 'teachers' => $u->teachers->map(fn ($t) => [ 'id' => $t->id, 'name' => $t->name, 'research_direction' => $t->researchDirections->pluck('name')->join('、') ?: null, 'research_directions' => $t->researchDirections->map(fn ($d) => [ 'id' => $d->id, 'name' => $d->name, ])->values()->all(), 'star_level_item' => $t->starLevelItem ? [ 'id' => $t->starLevelItem->id, 'label' => $t->starLevelItem->label, 'value' => $t->starLevelItem->value, ] : null, ])->values(), ]; })->values(); $summary = $this->buildSummary($universities); $quality = $this->buildQualityStats(); $researchFields = $this->buildResearchFields($universities); return $this->ok([ 'refreshed_at' => now()->toIso8601String(), 'bounds' => $bounds, 'schools' => $schools, 'summary' => $summary, 'quality' => $quality, 'research_fields' => $researchFields, ]); } /** * @param \Illuminate\Support\Collection $universities * @return array */ protected function buildSummary($universities): array { $coveredSchools = University::query() ->where('status', 1) ->whereNotNull('latitude') ->whereNotNull('longitude') ->count(); $mapTeachers = $universities->sum(fn (University $u) => $u->teachers->count()); $fiveStarId = $this->starLevelId('5'); $fiveStarTeachers = $fiveStarId ? Teacher::query()->where('star_level_dict_item_id', $fiveStarId)->count() : 0; $pendingCoords = University::query() ->where('status', 1) ->where(function ($q) { $q->whereNull('latitude')->orWhereNull('longitude'); }) ->count(); $maxStar = 0; foreach ($universities as $u) { foreach ($u->teachers as $t) { $val = (int) ($t->starLevelItem?->value ?? 0); if ($val > $maxStar) { $maxStar = $val; } } } return [ 'covered_schools' => $coveredSchools, 'map_teachers' => $mapTeachers, 'five_star_teachers' => $fiveStarTeachers, 'pending_coords' => $pendingCoords, 'visible_points' => $universities->count(), 'max_star' => $maxStar, ]; } /** * @return list */ protected function buildQualityStats(): array { $withCoords = University::query() ->where('status', 1) ->whereNotNull('latitude') ->whereNotNull('longitude') ->count(); $pendingCoords = University::query() ->where('status', 1) ->where(function ($q) { $q->whereNull('latitude')->orWhereNull('longitude'); }) ->count(); $teacherTotal = Teacher::query()->count(); $linkedTeachers = Teacher::query()->whereNotNull('university_id')->count(); $linkRate = $teacherTotal > 0 ? (int) round($linkedTeachers / $teacherTotal * 100) : 0; $noStar = Teacher::query()->whereNull('star_level_dict_item_id')->count(); $pendingPapers = \App\Models\Paper::query()->pendingTeacherLink()->count(); return [ [ 'label' => '坐标完整率', 'detail' => "{$withCoords} 所已配置坐标,{$pendingCoords} 所待补充", ], [ 'label' => '老师关联率', 'detail' => "已入库老师中 {$linkRate}% 关联高校", ], [ 'label' => '星级完整率', 'detail' => "{$noStar} 位老师尚未完成星级评定", ], [ 'label' => '论文反查', 'detail' => "{$pendingPapers} 篇论文待关联老师", ], ]; } /** * @param \Illuminate\Support\Collection $universities * @return list */ protected function buildResearchFields($universities): array { $counts = []; foreach ($universities as $u) { foreach ($u->teachers as $t) { $dirs = $t->researchDirections->pluck('name')->filter()->all(); if ($dirs === []) { $counts['未填写'] = ($counts['未填写'] ?? 0) + 1; continue; } foreach ($dirs as $dir) { $counts[$dir] = ($counts[$dir] ?? 0) + 1; } } } if ($counts === []) { return []; } arsort($counts); $total = array_sum($counts); $result = []; foreach (array_slice($counts, 0, 5, true) as $label => $count) { $result[] = [ 'label' => $label, 'count' => $count, 'percent' => (int) round($count / $total * 100), ]; } return $result; } protected function starLevelId(string $value): ?int { $typeId = DictType::query()->where('code', 'teacher_level')->value('id'); if (! $typeId) { return null; } return DictItem::query() ->where('dict_type_id', $typeId) ->where('value', $value) ->where('status', 1) ->value('id'); } /** * @param \Illuminate\Support\Collection $universities * @return array{min_lat:float,max_lat:float,min_lng:float,max_lng:float} */ protected function resolveBounds($universities): array { if ($universities->isEmpty()) { return [ 'min_lat' => 30.0, 'max_lat' => 32.0, 'min_lng' => 120.0, 'max_lng' => 122.0, ]; } return [ 'min_lat' => (float) $universities->min('latitude'), 'max_lat' => (float) $universities->max('latitude'), 'min_lng' => (float) $universities->min('longitude'), 'max_lng' => (float) $universities->max('longitude'), ]; } protected function toPercent(float $value, float $min, float $max): float { if ($max <= $min) { return 50.0; } $ratio = ($value - $min) / ($max - $min); return round(10 + $ratio * 80, 2); } protected function toPercentInverted(float $value, float $min, float $max): float { if ($max <= $min) { return 50.0; } $ratio = ($max - $value) / ($max - $min); return round(10 + $ratio * 80, 2); } }