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.

523 lines
21 KiB

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\ActivitySession;
use App\Models\ActivitySignup;
use App\Models\Course;
use App\Models\CourseCheckinDay;
use App\Models\CourseSession;
use App\Models\CourseSignup;
use App\Models\Demand;
use App\Models\DictItem;
use App\Models\DictType;
use App\Models\Paper;
use App\Models\Teacher;
use App\Models\University;
use App\Support\ApiResponse;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
class DashboardController extends Controller
{
use ApiResponse;
public function overview(): JsonResponse
{
$today = Carbon::today();
$monthStart = $today->copy()->startOfMonth();
$monthEnd = $today->copy()->endOfMonth();
$universityQuery = University::query()->where('status', 1);
$universityTotal = (clone $universityQuery)->count();
$yangtzeCount = (clone $universityQuery)
->where(function ($q) {
$q->whereIn('province', ['上海', '江苏', '浙江'])
->orWhereIn('city', ['上海', '苏州', '南京', '杭州', '无锡', '宁波']);
})
->count();
$highValueUniversities = University::query()
->where('status', 1)
->whereHas('teachers', function ($q) {
$q->whereHas('starLevelItem', fn ($sq) => $sq->where('value', '>=', '4'));
})
->count();
$teacherQuery = Teacher::query();
$teacherTotal = (clone $teacherQuery)->count();
$monthNewTeachers = (clone $teacherQuery)
->whereBetween('created_at', [$monthStart, $monthEnd->endOfDay()])
->count();
$fiveStarTeachers = (clone $teacherQuery)
->whereHas('starLevelItem', fn ($q) => $q->where('value', '5'))
->count();
$paperTotal = Paper::query()->count();
$monthNewPapers = Paper::query()
->whereBetween('created_at', [$monthStart, $monthEnd->endOfDay()])
->count();
$pendingLinkPapers = Paper::query()
->where(function ($q) {
$q->whereNull('university_id')
->orDoesntHave('teachers');
})
->count();
$demandTotal = Demand::query()->count();
$doneStatusId = $this->demandStatusId('done');
$activeStatusId = $this->demandStatusId('active');
$demandDone = $doneStatusId
? Demand::query()->where('status_dict_item_id', $doneStatusId)->count()
: 0;
$demandActive = $activeStatusId
? Demand::query()->where('status_dict_item_id', $activeStatusId)->count()
: 0;
$fulfillmentRate = $demandTotal > 0
? (int) round($demandDone / $demandTotal * 100)
: 0;
$teacherFollowTodo = Teacher::query()
->where('is_partner', false)
->whereNotNull('next_follow_date')
->where('next_follow_date', '<', $today->toDateString())
->whereHas('starLevelItem', fn ($q) => $q->whereIn('value', ['4', '5']))
->count();
$demandOverdue = $activeStatusId
? Demand::query()
->where('status_dict_item_id', $activeStatusId)
->where('submitted_at', '<', $today->copy()->subDays(7))
->count()
: 0;
$demandWaiting = $demandActive;
$demandTodo = $demandOverdue + min($demandWaiting, max(0, $demandWaiting - $demandOverdue));
$paperTodo = Paper::query()
->where(function ($q) {
$q->whereNull('university_id')
->orDoesntHave('teachers');
})
->where('created_at', '>=', $today->copy()->subDays(30))
->count();
$todoTotal = $teacherFollowTodo + $demandTodo + $paperTodo;
$courseSessions = $this->countCourseScheduleDaysInMonth($monthStart, $monthEnd);
$courseDraft = Course::query()->where('published', false)->count();
$activitySessions = $this->countActivityScheduleDaysInMonth($monthStart, $monthEnd);
$activityOpen = Activity::query()
->where('published', 1)
->where('signup_end_date', '>=', $today->toDateString())
->count();
$signupTotal = CourseSignup::query()->count() + ActivitySignup::query()->count();
$lastMonthSignup = CourseSignup::query()
->whereBetween('created_at', [$monthStart->copy()->subMonth(), $monthStart->copy()->subDay()->endOfDay()])
->count()
+ ActivitySignup::query()
->whereBetween('created_at', [$monthStart->copy()->subMonth(), $monthStart->copy()->subDay()->endOfDay()])
->count();
$monthSignup = CourseSignup::query()
->whereBetween('created_at', [$monthStart, $monthEnd->endOfDay()])
->count()
+ ActivitySignup::query()
->whereBetween('created_at', [$monthStart, $monthEnd->endOfDay()])
->count();
$signupDelta = $monthSignup - $lastMonthSignup;
$teacherLeads = Teacher::query()
->whereBetween('created_at', [$monthStart, $monthEnd->endOfDay()])
->count();
$demandLeads = Demand::query()
->whereBetween('created_at', [$monthStart, $monthEnd->endOfDay()])
->count();
$calendarEvents = $this->buildCalendarEvents($monthStart, $monthEnd);
return $this->ok([
'overview' => [
'universities' => [
'total' => $universityTotal,
'yangtze' => $yangtzeCount,
'high_value' => $highValueUniversities,
],
'teachers' => [
'total' => $teacherTotal,
'month_new' => $monthNewTeachers,
'five_star' => $fiveStarTeachers,
],
'papers' => [
'total' => $paperTotal,
'month_new' => $monthNewPapers,
'pending_link' => $pendingLinkPapers,
],
'demands' => [
'total' => $demandTotal,
'done' => $demandDone,
'active' => $demandActive,
'fulfillment_rate' => $fulfillmentRate,
],
],
'todos' => [
'total' => $todoTotal,
'teacher_follow' => $teacherFollowTodo,
'demand_process' => $demandTodo,
'demand_overdue' => $demandOverdue,
'demand_waiting' => $demandWaiting,
'paper_data' => $paperTodo,
],
'events' => [
'course_sessions' => $courseSessions,
'course_draft' => $courseDraft,
'activity_sessions' => $activitySessions,
'activity_open' => $activityOpen,
'signup_total' => $signupTotal,
'signup_delta' => $signupDelta,
'teacher_leads' => $teacherLeads,
'demand_leads' => $demandLeads,
],
'calendar' => [
'year' => (int) $today->year,
'month' => (int) $today->month,
'today' => $today->toDateString(),
'events' => $calendarEvents,
],
]);
}
protected function demandStatusId(string $value): ?int
{
$typeId = DictType::query()->where('code', 'demand_status')->value('id');
if (! $typeId) {
return null;
}
return DictItem::query()
->where('dict_type_id', $typeId)
->where('value', $value)
->where('status', 1)
->value('id');
}
/**
* @return list<array{date:string,type:string,title:string,entity_id:int,route:string}>
*/
protected function buildCalendarEvents(Carbon $monthStart, Carbon $monthEnd): array
{
$events = [];
$seen = [];
/** @var array<int, array<string, true>> */
$courseScheduledDates = [];
/** @var array<int, array<string, true>> */
$activityScheduledDates = [];
$push = function (
string $date,
string $type,
string $title,
int $entityId,
string $route,
) use (&$events, &$seen): void {
$title = $this->shortCalendarTitle($title);
if ($title === '') {
return;
}
$key = "{$date}|{$type}|{$entityId}|{$title}";
if (isset($seen[$key])) {
return;
}
$seen[$key] = true;
$events[] = [
'date' => $date,
'type' => $type,
'title' => $title,
'entity_id' => $entityId,
'route' => $route,
];
};
$markCourseDate = function (int $courseId, string $date) use (&$courseScheduledDates): void {
$courseScheduledDates[$courseId][$date] = true;
};
$markActivityDate = function (int $activityId, string $date) use (&$activityScheduledDates): void {
$activityScheduledDates[$activityId][$date] = true;
};
$hasCourseDate = function (int $courseId, string $date) use (&$courseScheduledDates): bool {
return isset($courseScheduledDates[$courseId][$date]);
};
$hasActivityDate = function (int $activityId, string $date) use (&$activityScheduledDates): bool {
return isset($activityScheduledDates[$activityId][$date]);
};
CourseSession::query()
->with('course:id,title')
->whereBetween('starts_at', [$monthStart, $monthEnd->endOfDay()])
->orderBy('starts_at')
->get()
->each(function (CourseSession $session) use ($push, $markCourseDate) {
if (! $session->starts_at || ! $session->course) {
return;
}
$date = $session->starts_at->toDateString();
$markCourseDate((int) $session->course_id, $date);
$push(
$date,
'course',
$session->title ?: $session->course->title,
(int) $session->course_id,
'/courses',
);
});
CourseCheckinDay::query()
->with('course:id,title')
->whereBetween('teach_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orderBy('teach_date')
->get()
->each(function (CourseCheckinDay $day) use ($push, $markCourseDate) {
if (! $day->course) {
return;
}
$date = $day->teach_date->toDateString();
$markCourseDate((int) $day->course_id, $date);
$push(
$date,
'course',
$day->course->title,
(int) $day->course_id,
'/courses',
);
});
Course::query()
->whereNotNull('teach_start_date')
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('teach_start_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhereBetween('teach_end_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('teach_start_date', '<=', $monthStart->toDateString())
->where('teach_end_date', '>=', $monthEnd->toDateString());
});
})
->get(['id', 'title', 'teach_start_date', 'teach_end_date'])
->each(function (Course $course) use ($push, $monthStart, $monthEnd) {
foreach ($this->eachDateInMonthRange(
$course->teach_start_date,
$course->teach_end_date ?? $course->teach_start_date,
$monthStart,
$monthEnd,
) as $date) {
$push($date, 'course', $course->title, (int) $course->id, '/courses');
}
});
ActivitySession::query()
->with('activity:id,title')
->whereBetween('starts_at', [$monthStart, $monthEnd->endOfDay()])
->orderBy('starts_at')
->get()
->each(function (ActivitySession $session) use ($push, $markActivityDate) {
if (! $session->starts_at || ! $session->activity) {
return;
}
$date = $session->starts_at->toDateString();
$activityTitle = $session->activity->title;
$sessionLabel = trim((string) $session->title) !== '' ? $session->title : '场次';
$markActivityDate((int) $session->activity_id, $date);
$push(
$date,
'activity',
$activityTitle.'·'.$sessionLabel,
(int) $session->activity_id,
'/activities',
);
});
Activity::query()
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('event_start_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhereBetween('event_end_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhereBetween('signup_end_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('event_start_date', '<=', $monthStart->toDateString())
->where('event_end_date', '>=', $monthEnd->toDateString());
});
})
->get(['id', 'title', 'event_start_date', 'event_end_date', 'signup_end_date'])
->each(function (Activity $activity) use ($push, $monthStart, $monthEnd, $hasActivityDate) {
if ($activity->event_start_date) {
foreach ($this->eachDateInMonthRange(
$activity->event_start_date,
$activity->event_end_date ?? $activity->event_start_date,
$monthStart,
$monthEnd,
) as $date) {
if ($hasActivityDate((int) $activity->id, $date)) {
continue;
}
$push($date, 'activity', $activity->title, (int) $activity->id, '/activities');
}
}
if ($activity->signup_end_date
&& $activity->signup_end_date->gte($monthStart)
&& $activity->signup_end_date->lte($monthEnd)) {
$push(
$activity->signup_end_date->toDateString(),
'activity',
$activity->title.'·报名截止',
(int) $activity->id,
'/activities',
);
}
});
usort($events, fn ($a, $b) => [$a['date'], $a['type'], $a['title']] <=> [$b['date'], $b['type'], $b['title']]);
return $events;
}
protected function countCourseScheduleDaysInMonth(Carbon $monthStart, Carbon $monthEnd): int
{
$dates = [];
CourseSession::query()
->whereBetween('starts_at', [$monthStart, $monthEnd->endOfDay()])
->get(['starts_at'])
->each(function (CourseSession $s) use (&$dates) {
if ($s->starts_at) {
$dates[$s->starts_at->toDateString()] = true;
}
});
CourseCheckinDay::query()
->whereBetween('teach_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->get(['teach_date'])
->each(function (CourseCheckinDay $d) use (&$dates) {
$dates[$d->teach_date->toDateString()] = true;
});
Course::query()
->whereNotNull('teach_start_date')
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('teach_start_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhereBetween('teach_end_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('teach_start_date', '<=', $monthStart->toDateString())
->where('teach_end_date', '>=', $monthEnd->toDateString());
});
})
->get(['teach_start_date', 'teach_end_date'])
->each(function (Course $c) use (&$dates, $monthStart, $monthEnd) {
foreach ($this->eachDateInMonthRange(
$c->teach_start_date,
$c->teach_end_date ?? $c->teach_start_date,
$monthStart,
$monthEnd,
) as $date) {
$dates[$date] = true;
}
});
return count($dates);
}
protected function countActivityScheduleDaysInMonth(Carbon $monthStart, Carbon $monthEnd): int
{
$dates = [];
/** @var array<int, array<string, true>> */
$activityScheduledDates = [];
ActivitySession::query()
->whereBetween('starts_at', [$monthStart, $monthEnd->endOfDay()])
->get(['activity_id', 'starts_at'])
->each(function (ActivitySession $s) use (&$dates, &$activityScheduledDates) {
if (! $s->starts_at) {
return;
}
$date = $s->starts_at->toDateString();
$dates[$date] = true;
$activityScheduledDates[(int) $s->activity_id][$date] = true;
});
Activity::query()
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('event_start_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhereBetween('event_end_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhereBetween('signup_end_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('event_start_date', '<=', $monthStart->toDateString())
->where('event_end_date', '>=', $monthEnd->toDateString());
});
})
->get(['id', 'event_start_date', 'event_end_date', 'signup_end_date'])
->each(function (Activity $a) use (&$dates, &$activityScheduledDates, $monthStart, $monthEnd) {
if ($a->event_start_date) {
foreach ($this->eachDateInMonthRange(
$a->event_start_date,
$a->event_end_date ?? $a->event_start_date,
$monthStart,
$monthEnd,
) as $date) {
if (isset($activityScheduledDates[(int) $a->id][$date])) {
continue;
}
$dates[$date] = true;
}
}
if ($a->signup_end_date
&& $a->signup_end_date->gte($monthStart)
&& $a->signup_end_date->lte($monthEnd)) {
$dates[$a->signup_end_date->toDateString()] = true;
}
});
return count($dates);
}
/**
* @return list<string> Y-m-d
*/
protected function eachDateInMonthRange(
?Carbon $start,
?Carbon $end,
Carbon $monthStart,
Carbon $monthEnd,
): array {
if (! $start) {
return [];
}
$from = $start->copy()->startOfDay()->max($monthStart->copy()->startOfDay());
$to = ($end ?? $start)->copy()->startOfDay()->min($monthEnd->copy()->startOfDay());
if ($from->gt($to)) {
return [];
}
$dates = [];
for ($cursor = $from->copy(); $cursor->lte($to); $cursor->addDay()) {
$dates[] = $cursor->toDateString();
if (count($dates) >= 31) {
break;
}
}
return $dates;
}
protected function shortCalendarTitle(string $title, int $max = 20): string
{
$title = trim($title);
if ($title === '') {
return '';
}
return mb_strlen($title) > $max ? mb_substr($title, 0, $max) : $title;
}
}