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.

786 lines
29 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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\ResearchDirectionResolver;
use App\Services\TeacherFollowPlanService;
use App\Support\ApiResponse;
use App\Support\TeacherLibraryStatus;
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,
protected ResearchDirectionResolver $directionResolver,
) {}
public function filterOptions(Request $request): JsonResponse
{
return $this->ok([
'research_directions' => $this->researchDirectionOptions($request),
]);
}
/**
* 老师库首屏配置:字典 + 研究方向 + 高校轻量列表(合并多次请求)。
*/
public function bootstrap(Request $request): JsonResponse
{
$user = $this->gridScope->userFromRequest($request);
$dictCodes = [
'teacher_source',
'teacher_level',
'teacher_status',
];
$dicts = [];
$types = DictType::query()
->whereIn('code', $dictCodes)
->where('status', 1)
->get()
->keyBy('code');
$typeIds = $types->pluck('id')->all();
$itemsByType = $typeIds === []
? collect()
: DictItem::query()
->whereIn('dict_type_id', $typeIds)
->where('status', 1)
->orderBy('sort')
->orderBy('id')
->get()
->groupBy('dict_type_id');
foreach ($dictCodes as $code) {
$type = $types->get($code);
$dicts[$code] = $type
? ($itemsByType->get($type->id) ?? collect())->map(fn (DictItem $i) => [
'id' => $i->id,
'label' => $i->label,
'value' => $i->value,
])->values()->all()
: [];
}
$universitiesQuery = \App\Models\University::query()
->where('status', 1)
->orderBy('sort_order')
->orderBy('id')
->select(['id', 'name', 'city']);
if ($this->gridScope->isGridMember($user)) {
$scopeIds = $this->gridScope->universityIds($user);
if ($scopeIds !== []) {
$universitiesQuery->whereIn('id', $scopeIds);
} else {
$universitiesQuery->whereRaw('1 = 0');
}
}
$universities = $universitiesQuery
->limit(500)
->get()
->map(fn ($u) => [
'id' => (int) $u->id,
'name' => $u->name,
'city' => $u->city,
])
->values()
->all();
return $this->ok([
'dicts' => $dicts,
'research_directions' => $this->researchDirectionOptions($request),
'universities' => $universities,
]);
}
/**
* @return list<array{id:int,name:string}>
*/
protected function researchDirectionOptions(Request $request): array
{
$user = $this->gridScope->userFromRequest($request);
if ($this->gridScope->isGridMember($user)) {
return $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])
->values()
->all();
}
return ResearchDirection::query()
->where('status', 1)
->orderBy('sort')
->orderBy('name')
->get()
->map(fn (ResearchDirection $d) => ['id' => $d->id, 'name' => $d->name])
->values()
->all();
}
public function stats(Request $request): JsonResponse
{
$today = Carbon::today()->toDateString();
$monthStart = Carbon::today()->startOfMonth()->toDateString();
$monthEnd = Carbon::today()->endOfMonth()->toDateString();
$user = $this->gridScope->userFromRequest($request);
$base = Teacher::query()->inLibrary();
$this->gridScope->applyTeacherScope($base, $user);
$row = (clone $base)
->selectRaw(
'SUM(CASE WHEN next_follow_date IS NOT NULL AND next_follow_date BETWEEN ? AND ? AND next_follow_date >= ? THEN 1 ELSE 0 END) AS month_pending',
[$monthStart, $monthEnd, $today]
)
->selectRaw(
'SUM(CASE WHEN next_follow_date IS NOT NULL AND next_follow_date < ? AND is_partner = 0 THEN 1 ELSE 0 END) AS overdue',
[$today]
)
->selectRaw('SUM(CASE WHEN is_partner = 1 THEN 1 ELSE 0 END) AS partners')
->first();
$monthFollowedQuery = Teacher::query()->inLibrary();
$this->gridScope->applyTeacherScope($monthFollowedQuery, $user);
$monthFollowed = $monthFollowedQuery
->whereHas('followRecords', function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('followed_at', [$monthStart, $monthEnd]);
})
->count();
$libraryPending = Teacher::query()
->where('library_status', TeacherLibraryStatus::Pending);
$this->gridScope->applyTeacherScope($libraryPending, $user);
return $this->ok([
'month_pending' => (int) ($row->month_pending ?? 0),
'month_followed' => $monthFollowed,
'overdue' => (int) ($row->overdue ?? 0),
'partners' => (int) ($row->partners ?? 0),
'library_pending' => $libraryPending->count(),
]);
}
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));
$this->applyLibraryStatusFilter($query, $request->query('library_status'));
if ($kw = $request->query('keyword')) {
$query->where(function ($q) use ($kw) {
$q->where('name', 'like', "%{$kw}%")
->orWhere('title', 'like', "%{$kw}%")
->orWhere('department', '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);
$teacher->library_status = TeacherLibraryStatus::Active;
$teacher->approved_at = now();
$teacher->approved_by = $user->id;
$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
{
$user = $this->gridScope->userFromRequest($request);
$model = Teacher::query()->findOrFail($teacher);
$this->gridScope->assertTeacherAccessible($user, $model);
// 先鉴权再加载关联,避免无效 withCount详情编辑不依赖跟进次数
$model->load([
'university:id,name,city',
'sourceItem:id,label,value',
'starLevelItem:id,label,value',
'statusItem:id,label,value',
'researchDirections:id,name',
]);
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 approve(Request $request, int $teacher): JsonResponse
{
$user = $this->gridScope->userFromRequest($request);
$this->gridScope->assertCanMutateTeachers($user);
$model = Teacher::query()->findOrFail($teacher);
$this->gridScope->assertTeacherAccessible($user, $model);
if ($model->library_status !== TeacherLibraryStatus::Pending) {
return $this->fail('仅待确认老师可确认入库', 422);
}
$model->library_status = TeacherLibraryStatus::Active;
$model->approved_at = now();
$model->approved_by = $user->id;
$model->save();
return $this->ok($this->serializeDetail($model->fresh([
'university', 'sourceItem', 'starLevelItem', 'statusItem', 'researchDirections',
])->loadCount('followRecords')), '已确认入库');
}
public function reject(Request $request, int $teacher): JsonResponse
{
$user = $this->gridScope->userFromRequest($request);
$this->gridScope->assertCanMutateTeachers($user);
$model = Teacher::query()->findOrFail($teacher);
$this->gridScope->assertTeacherAccessible($user, $model);
if ($model->library_status !== TeacherLibraryStatus::Pending) {
return $this->fail('仅待确认老师可驳回', 422);
}
$model->library_status = TeacherLibraryStatus::Rejected;
$model->approved_at = now();
$model->approved_by = $user->id;
$model->save();
return $this->ok($this->serializeDetail($model->fresh([
'university', 'sourceItem', 'starLevelItem', 'statusItem', 'researchDirections',
])->loadCount('followRecords')), '已驳回');
}
public function batchApprove(Request $request): JsonResponse
{
$user = $this->gridScope->userFromRequest($request);
$this->gridScope->assertCanMutateTeachers($user);
$data = $request->validate([
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['integer', 'exists:teachers,id'],
]);
$updated = 0;
DB::transaction(function () use ($data, $user, &$updated) {
$teachers = Teacher::query()->whereIn('id', $data['ids'])->get();
foreach ($teachers as $teacher) {
$this->gridScope->assertTeacherAccessible($user, $teacher);
if ($teacher->library_status !== TeacherLibraryStatus::Pending) {
continue;
}
$teacher->library_status = TeacherLibraryStatus::Active;
$teacher->approved_at = now();
$teacher->approved_by = $user->id;
$teacher->save();
$updated++;
}
});
return $this->ok(['updated' => $updated], "已确认入库 {$updated} 位老师");
}
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 applyLibraryStatusFilter($query, ?string $libraryStatus): void
{
if ($libraryStatus === 'linkable') {
$query->linkable();
return;
}
if ($libraryStatus === 'all') {
return;
}
if ($libraryStatus && in_array($libraryStatus, [
TeacherLibraryStatus::Pending,
TeacherLibraryStatus::Active,
TeacherLibraryStatus::Rejected,
], true)) {
$query->where('library_status', $libraryStatus);
return;
}
$query->inLibrary();
}
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'],
'department' => ['nullable', 'string', 'max:255'],
'bio' => ['nullable', 'string'],
'research_direction_ids' => ['nullable', 'array'],
'research_direction_ids.*' => ['integer', 'distinct', ...$this->researchDirectionIdRules()],
'new_research_directions' => ['nullable', 'array'],
'new_research_directions.*' => ['string', 'max:255'],
'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, 'nullable'),
'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);
$newNames = $data['new_research_directions'] ?? [];
unset($data['new_research_directions']);
$directionTouched = ! $partial
|| $request->has('research_direction_ids')
|| $request->has('new_research_directions');
if ($directionTouched) {
$ids = $data['research_direction_ids'] ?? [];
$data['research_direction_ids'] = $this->directionResolver->resolveIds($ids, $newNames);
} else {
unset($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,
'department' => $t->department,
'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),
'library_status' => $t->library_status,
'library_status_label' => TeacherLibraryStatus::label($t->library_status),
'approved_at' => $t->approved_at?->toIso8601String(),
'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['bio'] = $t->bio;
$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,
];
}
}