using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Graduation; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = SystemRoles.Student)] [Route("api/student/curriculum-plan")] public sealed class StudentCurriculumController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { [HttpGet] public async Task GetMine(CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var student = await db.Students.AsNoTracking() .Include(x => x.AdministrativeClass) .ThenInclude(x => x!.Major) .ThenInclude(x => x!.College) .FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken); if (student is null) return ConflictProblem("当前账号尚未关联学生档案。"); var studentProfile = new { student.Id, student.StudentNumber, student.Name, student.EnrollmentYear, student.Status, ClassName = student.AdministrativeClass!.Name, MajorName = student.AdministrativeClass.Major!.Name, CollegeName = student.AdministrativeClass.Major.College!.Name }; var plan = await db.CurriculumPlans.AsNoTracking() .AsSplitQuery() .Include(x => x.Major) .ThenInclude(x => x!.College) .Include(x => x.Modules) .ThenInclude(x => x.Courses) .ThenInclude(x => x.Course) .ThenInclude(x => x!.CourseCategory) .Where(x => x.MajorId == student.AdministrativeClass.MajorId && x.EffectiveGrade == student.EnrollmentYear && x.Status == CurriculumPlanStatus.Published) .OrderByDescending(x => x.PublishedAt) .FirstOrDefaultAsync(cancellationToken); if (plan is null) { return Ok(new { Student = studentProfile, Plan = (object?)null, Message = $"{student.EnrollmentYear} 级{student.AdministrativeClass.Major.Name}尚未发布培养方案。" }); } var planCourseIds = plan.Modules .SelectMany(x => x.Courses) .Select(x => x.CourseId) .Distinct() .ToArray(); var gradeAttempts = await db.GradeRecords.AsNoTracking() .Where(x => x.StudentId == student.Id && x.GradeSheet!.Status == GradeSheetStatus.Published) .WhereIn(planCourseIds, x => x.GradeSheet!.TeachingTask!.CourseId) .Select(x => new StudentGradeAttempt( x.GradeSheet!.TeachingTask!.CourseId, x.TotalScore, x.GradePoint, x.ExamStatus, x.GradeSheet.TeachingTask.AcademicTerm!.Name, x.GradeSheet.TeachingTask.TaskNumber, x.GradeSheet.PublishedAt ?? x.GradeSheet.UpdatedAt)) .ToListAsync(cancellationToken); var currentCourseIds = await db.TeachingTasks.AsNoTracking() .Where(task => task.Status == TeachingTaskStatus.Published && task.AcademicTerm!.IsCurrent && (task.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id))) .WhereIn(planCourseIds, task => task.CourseId) .Select(x => x.CourseId) .Distinct() .ToListAsync(cancellationToken); var currentCourseIdSet = currentCourseIds.ToHashSet(); var attemptsByCourse = gradeAttempts .GroupBy(x => x.CourseId) .ToDictionary(x => x.Key, x => x.OrderByDescending( attempt => attempt.PublishedAt).ToList()); var progressByCourse = new Dictionary(); foreach (var courseId in planCourseIds) { var attempts = attemptsByCourse.GetValueOrDefault(courseId) ?? []; var evaluation = StudentCourseProgressRules.Evaluate( attempts.Select(x => new StudentCourseAttemptSnapshot( x.TotalScore, x.ExamStatus)), currentCourseIdSet.Contains(courseId)); var displayedAttempt = evaluation.HasPassedAttempt ? attempts.FirstOrDefault(x => StudentCourseProgressRules.IsPassed( new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus))) : attempts.FirstOrDefault(); progressByCourse[courseId] = new CourseProgress( evaluation, displayedAttempt); } var distinctCourses = plan.Modules .SelectMany(x => x.Courses) .GroupBy(x => x.CourseId) .Select(x => x.First()) .ToList(); var completedCredits = distinctCourses .Where(x => progressByCourse[x.CourseId].Evaluation.Status == StudentCourseProgressStatus.Completed) .Sum(x => x.Course?.Credits ?? 0); var completedCourseCount = distinctCourses.Count(x => progressByCourse[x.CourseId].Evaluation.Status == StudentCourseProgressStatus.Completed); var inProgressCourseCount = distinctCourses.Count(x => progressByCourse[x.CourseId].Evaluation.Status is StudentCourseProgressStatus.InProgress or StudentCourseProgressStatus.Retaking); var failedCourseCount = distinctCourses.Count(x => progressByCourse[x.CourseId].Evaluation.HasFailedAttempt && !progressByCourse[x.CourseId].Evaluation.HasPassedAttempt); return Ok(new { Student = studentProfile, Plan = new { plan.Id, plan.Name, plan.Version, plan.EffectiveGrade, plan.TotalCredits, plan.Description, plan.PublishedAt, MajorName = plan.Major!.Name, CollegeName = plan.Major.College!.Name, SchoolingYears = plan.Major.SchoolingYears, Summary = new { CourseCount = distinctCourses.Count, CompletedCourseCount = completedCourseCount, InProgressCourseCount = inProgressCourseCount, FailedCourseCount = failedCourseCount, CompletedCredits = completedCredits, CompletionRate = plan.TotalCredits <= 0 ? 0 : Math.Min(100, Math.Round( completedCredits / plan.TotalCredits * 100, 1)) }, Modules = plan.Modules .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .Select(module => new { module.Id, module.Code, module.Name, module.RequiredCredits, module.SortOrder, Courses = module.Courses .OrderBy(x => x.RecommendedSemester) .ThenBy(x => x.Course!.Code) .Select(item => { var progress = progressByCourse[item.CourseId]; return new { item.Id, item.CourseId, CourseCode = item.Course!.Code, CourseName = item.Course.Name, item.Course.Credits, item.Course.TotalHours, item.Course.Nature, CategoryName = item.Course.CourseCategory?.Name, item.RecommendedSemester, item.Type, item.Notes, progress.Evaluation.Status, progress.Evaluation.HasFailedAttempt, progress.Evaluation.HasPassedAttempt, LatestResult = progress.DisplayedAttempt is null ? null : new { progress.DisplayedAttempt.TotalScore, progress.DisplayedAttempt.GradePoint, progress.DisplayedAttempt.ExamStatus, progress.DisplayedAttempt.TermName, progress.DisplayedAttempt.TaskNumber, progress.DisplayedAttempt.PublishedAt } }; }) }) } }); } private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法查看培养方案", Detail = detail, Status = StatusCodes.Status409Conflict }); private sealed record StudentGradeAttempt( Guid CourseId, decimal? TotalScore, decimal? GradePoint, GradeExamStatus ExamStatus, string TermName, string TaskNumber, DateTime PublishedAt); private sealed record CourseProgress( StudentCourseProgressEvaluation Evaluation, StudentGradeAttempt? DisplayedAttempt); }