gridScope->userFromRequest($request); if ($this->gridScope->isGridMember($user)) { $items = $user->researchDirections() ->where('research_directions.status', 1) ->orderBy('research_directions.sort') ->orderBy('research_directions.name') ->get() ->map(fn (ResearchDirection $d) => ['id' => $d->id, 'name' => $d->name]); return $this->ok(['research_directions' => $items->values()->all()]); } $items = ResearchDirection::query() ->where('status', 1) ->orderBy('sort') ->orderBy('name') ->get() ->map(fn (ResearchDirection $d) => ['id' => $d->id, 'name' => $d->name]); return $this->ok(['research_directions' => $items->values()->all()]); } public function stats(Request $request): JsonResponse { $today = Carbon::today(); $monthStart = $today->copy()->startOfMonth(); $monthEnd = $today->copy()->endOfMonth(); $base = Teacher::query(); $this->gridScope->applyTeacherScope($base, $this->gridScope->userFromRequest($request)); $monthPending = (clone $base) ->whereNotNull('next_follow_date') ->whereBetween('next_follow_date', [$monthStart->toDateString(), $monthEnd->toDateString()]) ->where('next_follow_date', '>=', $today->toDateString()) ->count(); $monthFollowed = Teacher::query() ->whereHas('followRecords', function ($q) use ($monthStart, $monthEnd) { $q->whereBetween('followed_at', [$monthStart->toDateString(), $monthEnd->toDateString()]); }) ->count(); $overdue = (clone $base) ->whereNotNull('next_follow_date') ->where('next_follow_date', '<', $today->toDateString()) ->where('is_partner', false) ->count(); $partners = (clone $base)->where('is_partner', true)->count(); return $this->ok([ 'month_pending' => $monthPending, 'month_followed' => $monthFollowed, 'overdue' => $overdue, 'partners' => $partners, ]); } public function index(Request $request): JsonResponse { $query = Teacher::query() ->with(['university', 'sourceItem', 'starLevelItem', 'statusItem', 'researchDirections']) ->withCount('followRecords'); $this->gridScope->applyTeacherScope($query, $this->gridScope->userFromRequest($request)); if ($kw = $request->query('keyword')) { $query->where(function ($q) use ($kw) { $q->where('name', 'like', "%{$kw}%") ->orWhere('title', 'like', "%{$kw}%") ->orWhere('university_text', 'like', "%{$kw}%") ->orWhereHas('university', fn ($uq) => $uq->where('name', 'like', "%{$kw}%")) ->orWhereHas('researchDirections', fn ($dq) => $dq->where('name', 'like', "%{$kw}%")); }); } if ($request->filled('source_dict_item_id')) { $query->where('source_dict_item_id', (int) $request->query('source_dict_item_id')); } if ($request->filled('star_level_dict_item_id')) { $query->where('star_level_dict_item_id', (int) $request->query('star_level_dict_item_id')); } if ($request->filled('status_dict_item_id')) { $query->where('status_dict_item_id', (int) $request->query('status_dict_item_id')); } if ($request->filled('university_id')) { $query->where('university_id', (int) $request->query('university_id')); } if ($request->filled('research_direction_id')) { $dirId = (int) $request->query('research_direction_id'); $query->whereHas('researchDirections', fn ($q) => $q->where('research_directions.id', $dirId)); } elseif ($request->filled('research_direction')) { $name = $request->query('research_direction'); $query->whereHas('researchDirections', fn ($q) => $q->where('research_directions.name', $name)); } $this->applyStatBucket($query, $request->query('stat_bucket')); $paginator = $query ->orderByDesc('id') ->paginate((int) $request->query('page_size', 20)) ->withQueryString(); $paginator->getCollection()->transform(fn (Teacher $t) => $this->serializeList($t)); return $this->paginated($paginator); } public function store(Request $request): JsonResponse { $user = $this->gridScope->userFromRequest($request); $this->gridScope->assertCanMutateTeachers($user); $this->ensureTeacherDicts(); $data = $this->validatedTeacher($request); $this->gridScope->assertTeacherDataInScope($user, $data); $directionIds = $data['research_direction_ids']; unset($data['research_direction_ids']); $teacher = new Teacher($data); $this->syncPartnerFlags($teacher, $data); $this->applyStarFollowDate($teacher, $request->boolean('recalc_next_follow_date', true)); $teacher->save(); $teacher->researchDirections()->sync($directionIds); return $this->ok(['id' => $teacher->id], '已创建'); } public function show(Request $request, int $teacher): JsonResponse { $model = Teacher::query() ->with(['university', 'sourceItem', 'starLevelItem', 'statusItem', 'researchDirections']) ->withCount('followRecords') ->findOrFail($teacher); $this->gridScope->assertTeacherAccessible($this->gridScope->userFromRequest($request), $model); return $this->ok($this->serializeDetail($model)); } public function update(Request $request, int $teacher): JsonResponse { $user = $this->gridScope->userFromRequest($request); $model = Teacher::query()->findOrFail($teacher); $this->gridScope->assertTeacherAccessible($user, $model); $data = $this->validatedTeacher($request, partial: true); $this->gridScope->assertTeacherDataInScope($user, $data, $model); $directionIds = $data['research_direction_ids'] ?? null; unset($data['research_direction_ids']); $starChanged = array_key_exists('star_level_dict_item_id', $data) && (int) $data['star_level_dict_item_id'] !== (int) $model->star_level_dict_item_id; $model->fill($data); if (array_key_exists('university_id', $data) && ! empty($data['university_id'])) { $model->university_text = null; } $this->syncPartnerFlags($model, $data); if ($starChanged && $request->boolean('recalc_next_follow_date', true)) { $this->applyStarFollowDate($model, true); } $model->save(); if ($directionIds !== null) { $model->researchDirections()->sync($directionIds); } return $this->ok(null, '已保存'); } public function destroy(Request $request, int $teacher): JsonResponse { $user = $this->gridScope->userFromRequest($request); $this->gridScope->assertCanMutateTeachers($user); Teacher::query()->findOrFail($teacher)->delete(); return $this->ok(null, '已删除'); } public function batchUpdateStar(Request $request): JsonResponse { $user = $this->gridScope->userFromRequest($request); $data = $request->validate([ 'ids' => ['required', 'array', 'min:1'], 'ids.*' => ['integer', 'exists:teachers,id'], 'star_level_dict_item_id' => ['required', 'integer'], 'recalc_next_follow_date' => ['nullable', 'boolean'], ]); $starTypeId = DictType::query()->where('code', 'teacher_level')->where('status', 1)->value('id'); if (! $starTypeId) { return $this->fail('字典「老师星级」未配置', 422); } $starItem = DictItem::query() ->where('id', $data['star_level_dict_item_id']) ->where('dict_type_id', $starTypeId) ->where('status', 1) ->first(); if (! $starItem) { return $this->fail('无效的星级', 422); } $recalc = $request->boolean('recalc_next_follow_date', true); $updated = 0; DB::transaction(function () use ($data, $starItem, $recalc, &$updated, $user) { $teachers = Teacher::query()->whereIn('id', $data['ids'])->get(); foreach ($teachers as $teacher) { if ($this->gridScope->isGridMember($user)) { $this->gridScope->assertTeacherAccessible($user, $teacher); } $teacher->star_level_dict_item_id = $starItem->id; if ($recalc) { $teacher->next_follow_date = $this->followPlan->nextFollowDateFromStar($starItem); } $teacher->save(); $updated++; } }); return $this->ok(['updated' => $updated], '已批量更新星级'); } public function papers(Request $request, int $teacher): JsonResponse { $model = Teacher::query()->findOrFail($teacher); $this->gridScope->assertTeacherAccessible($this->gridScope->userFromRequest($request), $model); $items = $model->papers() ->orderByDesc('published_at') ->orderByDesc('id') ->get() ->map(fn (Paper $p) => $this->serializePaper($p)); return $this->ok(['items' => $items]); } public function storePaper(Request $request, int $teacher): JsonResponse { $model = Teacher::query()->with('university')->findOrFail($teacher); $this->gridScope->assertTeacherAccessible($this->gridScope->userFromRequest($request), $model); $data = $request->validate([ 'title' => ['required', 'string', 'max:512'], 'authors' => ['required', 'string', 'max:512'], 'school_name' => ['nullable', 'string', 'max:255'], 'published_at' => ['required', 'date'], 'url' => ['nullable', 'string', 'max:512'], 'summary' => ['nullable', 'string'], ]); $paper = Paper::query()->create([ 'title' => $data['title'], 'authors' => $data['authors'], 'university_id' => $model->university_id, 'school_name' => $data['school_name'] ?? $model->university?->name, 'published_at' => $data['published_at'], 'url' => $data['url'] ?? null, 'summary' => $data['summary'] ?? null, 'source' => 'manual', ]); $model->papers()->syncWithoutDetaching([$paper->id]); return $this->ok(['id' => $paper->id], '已添加论文'); } public function destroyPaper(Request $request, int $teacher, int $paper): JsonResponse { $model = Teacher::query()->findOrFail($teacher); $this->gridScope->assertTeacherAccessible($this->gridScope->userFromRequest($request), $model); $model->papers()->detach($paper); Paper::query()->where('id', $paper)->where('source', 'manual')->delete(); return $this->ok(null, '已删除'); } protected function applyStatBucket($query, ?string $bucket): void { if (! $bucket) { return; } $today = Carbon::today(); $monthStart = $today->copy()->startOfMonth(); $monthEnd = $today->copy()->endOfMonth(); match ($bucket) { 'month_pending' => $query ->whereNotNull('next_follow_date') ->whereBetween('next_follow_date', [$monthStart->toDateString(), $monthEnd->toDateString()]) ->where('next_follow_date', '>=', $today->toDateString()), 'month_followed' => $query->whereHas('followRecords', function ($q) use ($monthStart, $monthEnd) { $q->whereBetween('followed_at', [$monthStart->toDateString(), $monthEnd->toDateString()]); }), 'overdue' => $query ->whereNotNull('next_follow_date') ->where('next_follow_date', '<', $today->toDateString()) ->where('is_partner', false), 'partner' => $query->where('is_partner', true), default => null, }; } /** * @param array $data */ protected function syncPartnerFlags(Teacher $teacher, array $data): void { if (! array_key_exists('status_dict_item_id', $data)) { return; } $statusItem = DictItem::query()->find($data['status_dict_item_id']); if ($statusItem && $statusItem->value === 'partner') { $teacher->is_partner = true; $teacher->converted_at = $teacher->converted_at ?? now(); } } protected function applyStarFollowDate(Teacher $teacher, bool $recalc): void { if (! $recalc || ! $teacher->star_level_dict_item_id) { return; } $starItem = DictItem::query()->find($teacher->star_level_dict_item_id); $teacher->next_follow_date = $this->followPlan->nextFollowDateFromStar($starItem); } protected function ensureTeacherDicts(): void { $codes = ['teacher_source', 'teacher_level', 'teacher_status']; foreach ($codes as $code) { if (! DictType::query()->where('code', $code)->where('status', 1)->exists()) { throw new \Illuminate\Http\Exceptions\HttpResponseException( response()->json([ 'message' => '老师库字典未配置,请执行 TeacherDictionarySeeder', 'data' => null, ], 422) ); } } } /** * @return array */ protected function validatedTeacher(Request $request, bool $partial = false): array { $sourceTypeId = DictType::query()->where('code', 'teacher_source')->where('status', 1)->value('id'); $levelTypeId = DictType::query()->where('code', 'teacher_level')->where('status', 1)->value('id'); $statusTypeId = DictType::query()->where('code', 'teacher_status')->where('status', 1)->value('id'); $rules = [ 'name' => [$partial ? 'sometimes' : 'required', 'string', 'max:64'], 'university_id' => [$partial ? 'sometimes' : 'required', 'integer', 'exists:universities,id'], 'city' => [$partial ? 'sometimes' : 'required', 'string', 'max:64'], 'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:64'], 'research_direction_ids' => array_merge( [$partial ? 'sometimes' : 'required', 'array', 'min:1'], $this->researchDirectionIdRules() ), 'research_direction_ids.*' => ['integer', 'distinct'], 'phone' => ['nullable', 'string', 'max:32'], 'email' => ['nullable', 'email', 'max:128'], 'source_dict_item_id' => $this->dictItemRules($sourceTypeId, $partial ? 'sometimes' : 'required'), 'star_level_dict_item_id' => $this->dictItemRules($levelTypeId, 'nullable'), 'status_dict_item_id' => $this->dictItemRules($statusTypeId, $partial ? 'sometimes' : 'required'), 'next_follow_date' => ['nullable', 'date'], 'next_follow_subject' => ['nullable', 'string', 'max:255'], 'is_partner' => ['nullable', 'boolean'], 'remark' => ['nullable', 'string'], 'sort' => ['nullable', 'integer'], ]; $data = $request->validate($rules); if (array_key_exists('research_direction_ids', $data)) { $data['research_direction_ids'] = $this->normalizeDirectionIds($data['research_direction_ids']); } return $data; } /** * @param array $ids * @return array */ protected function normalizeDirectionIds(array $ids): array { return ResearchDirection::query() ->whereIn('id', $ids) ->where('status', 1) ->pluck('id') ->map(fn ($id) => (int) $id) ->values() ->all(); } /** * @return array */ protected function researchDirectionIdRules(): array { if (! ResearchDirection::query()->where('status', 1)->exists()) { return []; } return [ Rule::exists('research_directions', 'id')->where( fn ($q) => $q->where('status', 1)->whereNull('deleted_at') ), ]; } protected function dictItemRules(?int $dictTypeId, string $presence): array { if (! $dictTypeId) { return match ($presence) { 'required' => ['required', 'integer'], 'sometimes' => ['sometimes', 'nullable', 'integer'], default => ['nullable', 'integer'], }; } $exists = Rule::exists('dict_items', 'id')->where( fn ($q) => $q->where('dict_type_id', $dictTypeId)->where('status', 1) ); return match ($presence) { 'required' => ['required', 'integer', $exists], 'sometimes' => ['sometimes', 'nullable', 'integer', $exists], default => ['nullable', 'integer', $exists], }; } /** * @return array */ protected function serializeList(Teacher $t): array { $today = Carbon::today(); return [ 'id' => $t->id, 'name' => $t->name, 'university_id' => $t->university_id, 'university_text' => $t->university_text, 'university_name' => $t->displayUniversityName(), 'university_linked' => $t->university_id !== null, 'city' => $t->city ?? $t->university?->city, 'title' => $t->title, 'research_directions' => $t->researchDirections->map(fn (ResearchDirection $d) => [ 'id' => $d->id, 'name' => $d->name, ])->values()->all(), 'research_direction' => $t->researchDirections->pluck('name')->join('、') ?: null, 'research_direction_ids' => $t->researchDirections->pluck('id')->values()->all(), 'source_dict_item_id' => $t->source_dict_item_id, 'source_item' => $this->serializeDictItem($t->sourceItem), 'star_level_dict_item_id' => $t->star_level_dict_item_id, 'star_level_item' => $this->serializeDictItem($t->starLevelItem), 'status_dict_item_id' => $t->status_dict_item_id, 'status_item' => $this->serializeDictItem($t->statusItem), 'next_follow_date' => $t->next_follow_date?->toDateString(), 'is_partner' => (bool) $t->is_partner, 'is_overdue' => $t->next_follow_date && $t->next_follow_date->lt($today) && ! $t->is_partner, 'follow_records_count' => (int) ($t->follow_records_count ?? 0), 'created_at' => $t->created_at?->toIso8601String(), ]; } /** * @return array */ protected function serializeDetail(Teacher $t): array { $row = $this->serializeList($t); $row['phone'] = $t->phone; $row['email'] = $t->email; $row['next_follow_subject'] = $t->next_follow_subject; $row['remark'] = $t->remark; $row['converted_at'] = $t->converted_at?->toIso8601String(); return $row; } /** * @return array{id:int,label:string,value:string}|null */ protected function serializeDictItem(?DictItem $item): ?array { if (! $item) { return null; } return [ 'id' => $item->id, 'label' => $item->label, 'value' => $item->value, ]; } /** * @return array */ protected function serializePaper(Paper $p): array { return [ 'id' => $p->id, 'title' => $p->title, 'authors' => $p->authors, 'school_name' => $p->school_name, 'published_at' => $p->published_at?->toDateString(), 'url' => $p->url, 'summary' => $p->summary, ]; } }