You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
569 lines
21 KiB
569 lines
21 KiB
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\DictItem;
|
|
use App\Models\DictType;
|
|
use App\Models\Paper;
|
|
use App\Models\ResearchDirection;
|
|
use App\Models\Teacher;
|
|
use App\Models\TeacherFollowRecord;
|
|
use App\Services\GridMemberScopeService;
|
|
use App\Services\TeacherFollowPlanService;
|
|
use App\Support\ApiResponse;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class TeacherController extends Controller
|
|
{
|
|
use ApiResponse;
|
|
|
|
public function __construct(
|
|
protected TeacherFollowPlanService $followPlan,
|
|
protected GridMemberScopeService $gridScope
|
|
) {}
|
|
|
|
public function filterOptions(Request $request): JsonResponse
|
|
{
|
|
$user = $this->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 linkPaper(Request $request, int $teacher): JsonResponse
|
|
{
|
|
$model = Teacher::query()->findOrFail($teacher);
|
|
$this->gridScope->assertTeacherAccessible($this->gridScope->userFromRequest($request), $model);
|
|
$data = $request->validate([
|
|
'paper_id' => ['required', 'integer', 'exists:papers,id'],
|
|
]);
|
|
|
|
$paper = Paper::query()->findOrFail((int) $data['paper_id']);
|
|
$model->papers()->syncWithoutDetaching([$paper->id]);
|
|
|
|
return $this->ok(null, '已关联论文');
|
|
}
|
|
|
|
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<string, mixed> $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<string, mixed>
|
|
*/
|
|
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<int> $ids
|
|
* @return array<int>
|
|
*/
|
|
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<int, \Illuminate\Contracts\Validation\Rule|string>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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,
|
|
];
|
|
}
|
|
}
|