using System.ComponentModel.DataAnnotations; 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/academic-planning")] public sealed class AcademicPlanningController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { [HttpGet] public async Task Get(CancellationToken cancellationToken) { var loaded = await LoadAsync(cancellationToken); if (loaded.Error is not null) return loaded.Error; var context = loaded.Context!; var completion = CurriculumCompletionRules.Evaluate( context.Plan.Modules, context.PassedCourseIds); var planCompletedCredits = context.Courses .Where(x => context.PassedCourseIds.Contains(x.CourseId)) .Sum(x => x.Credits); var suggestionIds = AcademicPlanningRules.SuggestNextSemester( context.CourseSnapshots, context.PassedCourseIds, context.InProgressCourseIds, context.NextSemester); var suggestionIdSet = suggestionIds.ToHashSet(); var latestAudit = await db.GraduationAuditResults.AsNoTracking() .Where(x => x.StudentId == context.Student.Id && x.GraduationAuditBatch!.Status == GraduationAuditBatchStatus.Published) .OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear) .ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt) .Select(x => new { BatchName = x.GraduationAuditBatch!.Name, x.GraduationAuditBatch.GraduationYear, x.RequiredCredits, x.EarnedCredits, x.RequiredCourseCount, x.PassedRequiredCourseCount, x.FailedCourseCount, x.MissingCourseNames, x.Conclusion, x.IsOverridden, x.ReviewComment, x.GraduationAuditBatch.PublishedAt }) .FirstOrDefaultAsync(cancellationToken); var endSemester = Math.Max( context.Plan.Major!.SchoolingYears * 2, context.NextSemester + 3); return Ok(new { Student = new { context.Student.Id, context.Student.StudentNumber, context.Student.Name, context.Student.EnrollmentYear, context.Student.Status, ClassName = context.Student.AdministrativeClass!.Name, MajorName = context.Student.AdministrativeClass.Major!.Name, CollegeName = context.Student.AdministrativeClass.Major.College!.Name }, Plan = new { context.Plan.Id, context.Plan.Name, context.Plan.Version, context.Plan.TotalCredits, SchoolingYears = context.Plan.Major.SchoolingYears, CurrentSemester = context.CurrentSemester, NextSemester = context.NextSemester, StandardGraduationSemester = context.Plan.Major.SchoolingYears * 2 }, Baseline = new { EarnedCredits = context.EarnedCredits, PlanCompletedCredits = planCompletedCredits, CreditGap = Math.Max( context.Plan.TotalCredits - context.EarnedCredits, 0), CompletionRate = context.Plan.TotalCredits <= 0 ? 0 : Math.Min(100, Math.Round( planCompletedCredits / context.Plan.TotalCredits * 100, 1)), completion.RequirementCount, completion.PassedRequirementCount, completion.MissingRequirements, InProgressCredits = context.Courses .Where(x => context.InProgressCourseIds.Contains(x.CourseId)) .Sum(x => x.Credits) }, Terms = Enumerable.Range( context.NextSemester, endSemester - context.NextSemester + 1) .Select(semester => new { Semester = semester, Label = FormatSemester( context.Student.EnrollmentYear, semester), IsBeyondStandard = semester > context.Plan.Major.SchoolingYears * 2 }), Courses = context.Courses .OrderBy(x => x.RecommendedSemester) .ThenBy(x => x.CourseCode) .Select(course => new { course.CourseId, course.CourseCode, course.CourseName, course.Credits, course.RecommendedSemester, course.Type, course.ModuleCode, course.ModuleName, Status = context.StatusByCourse[course.CourseId], IsSuggested = suggestionIdSet.Contains(course.CourseId), Prerequisites = course.Prerequisites.Select(item => new { item.CourseId, item.CourseCode, item.CourseName, IsCompleted = context.PassedCourseIds.Contains(item.CourseId), IsInProgress = context.InProgressCourseIds.Contains(item.CourseId) }) }), NextSemesterSuggestion = suggestionIds.Select(courseId => { var course = context.CourseById[courseId]; return new { course.CourseId, course.CourseCode, course.CourseName, course.Credits, course.Type, Reason = course.Type == CurriculumCourseType.Required && course.RecommendedSemester <= context.NextSemester ? "计划学期已到,且先修条件已满足" : course.Type == CurriculumCourseType.Required ? "必修课程,按培养方案顺序推进" : "用于补足培养模块与总学分" }; }), LatestGraduationAudit = latestAudit, Assumptions = new[] { "模拟课程按顺利通过计算,不会写入成绩或正式选课。", $"预计毕业学期按每学期最多 {AcademicPlanningRules.RecommendedSemesterCreditLimit:0} 学分估算。", "当前在读课程按本学期顺利完成计入预测。" } }); } [HttpPost("simulate")] public async Task Simulate( AcademicPlanningRequest request, CancellationToken cancellationToken) { var loaded = await LoadAsync(cancellationToken); if (loaded.Error is not null) return loaded.Error; var context = loaded.Context!; var selections = request.Terms .SelectMany(term => term.CourseIds.Select(courseId => new { term.Semester, CourseId = courseId })) .ToList(); var duplicate = selections .GroupBy(x => x.CourseId) .FirstOrDefault(group => group.Count() > 1); if (duplicate is not null) return ValidationProblem("同一门课程不能安排在多个学期。"); if (request.Terms.Any(x => x.Semester < context.NextSemester || x.Semester > context.NextSemester + 12)) return ValidationProblem("模拟学期超出了可规划范围。"); var invalidCourse = selections.FirstOrDefault(x => !context.CourseById.ContainsKey(x.CourseId)); if (invalidCourse is not null) return ValidationProblem("模拟计划包含不属于当前培养方案的课程。"); var alreadyHandled = selections.FirstOrDefault(x => context.PassedCourseIds.Contains(x.CourseId) || context.InProgressCourseIds.Contains(x.CourseId)); if (alreadyHandled is not null) return ValidationProblem("已完成或当前在读课程无需重复安排。"); var plannedSemesters = selections.ToDictionary( x => x.CourseId, x => x.Semester); var projectedCourseIds = context.PassedCourseIds .Concat(context.InProgressCourseIds) .Concat(plannedSemesters.Keys) .ToHashSet(); var completion = CurriculumCompletionRules.Evaluate( context.Plan.Modules, projectedCourseIds); var addedCredits = context.Courses .Where(x => !context.PassedCourseIds.Contains(x.CourseId) && (context.InProgressCourseIds.Contains(x.CourseId) || plannedSemesters.ContainsKey(x.CourseId))) .Sum(x => x.Credits); var projectedEarnedCredits = context.EarnedCredits + addedCredits; var creditGap = Math.Max( context.Plan.TotalCredits - projectedEarnedCredits, 0); var prerequisiteIssues = AcademicPlanningRules.FindPrerequisiteIssues( context.CourseSnapshots, context.PassedCourseIds, context.InProgressCourseIds, plannedSemesters); var conflicts = prerequisiteIssues.Select(issue => { var course = context.CourseById[issue.CourseId]; var prerequisite = context.AllCourseLabels.GetValueOrDefault( issue.PrerequisiteCourseId, new CourseLabel( issue.PrerequisiteCourseId, "未知课程", "未找到的先修课程")); return new { Type = "Prerequisite", issue.CourseId, course.CourseName, PrerequisiteCourseId = prerequisite.CourseId, PrerequisiteCourseName = prerequisite.CourseName, issue.PlannedSemester, issue.PrerequisitePlannedSemester, Message = issue.PrerequisitePlannedSemester.HasValue ? $"《{prerequisite.CourseName}》必须安排在《{course.CourseName}》之前。" : $"《{course.CourseName}》的先修课程《{prerequisite.CourseName}》尚未完成或安排。" }; }).ToList(); var workloadWarnings = request.Terms .Select(term => new { term.Semester, Credits = term.CourseIds.Sum(courseId => context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0) }) .Where(x => x.Credits > AcademicPlanningRules.HeavySemesterCreditLimit) .Select(x => new { Type = "Workload", x.Semester, x.Credits, Message = $"第 {x.Semester} 学期安排了 {x.Credits:0.#} 学分,超过建议上限 {AcademicPlanningRules.HeavySemesterCreditLimit:0.#} 学分。" }) .ToList(); var timingWarnings = selections .Select(item => new { item.Semester, Course = context.CourseById[item.CourseId] }) .Where(x => x.Semester < x.Course.RecommendedSemester) .Select(x => new { Type = "EarlyCourse", x.Course.CourseId, x.Course.CourseName, x.Semester, x.Course.RecommendedSemester, Message = $"《{x.Course.CourseName}》早于培养方案建议学期修读,请确认课程开设条件。" }) .ToList(); var unresolvedFailedCourseCount = context.FailedCourseIds.Count( courseId => !projectedCourseIds.Contains(courseId)); var conclusion = GraduationAuditRules.Evaluate( true, context.Student.Status, context.Plan.TotalCredits, projectedEarnedCredits, completion.RequirementCount, completion.PassedRequirementCount, unresolvedFailedCourseCount); var latestPlannedSemester = plannedSemesters.Count == 0 ? context.NextSemester - 1 : plannedSemesters.Values.Max(); var estimatedSemester = AcademicPlanningRules.EstimateCompletionSemester( context.NextSemester, latestPlannedSemester, creditGap, completion.RequirementCount - completion.PassedRequirementCount); var remainingRequiredSemesterFloor = context.Courses .Where(course => course.Type == CurriculumCourseType.Required && !projectedCourseIds.Contains(course.CourseId)) .Select(course => course.RecommendedSemester) .DefaultIfEmpty(estimatedSemester) .Max(); estimatedSemester = Math.Max( estimatedSemester, remainingRequiredSemesterFloor); var planCompletedCredits = context.Courses .Where(x => projectedCourseIds.Contains(x.CourseId)) .Sum(x => x.Credits); return Ok(new { Projected = new { EarnedCredits = projectedEarnedCredits, PlanCompletedCredits = planCompletedCredits, CreditGap = creditGap, CompletionRate = context.Plan.TotalCredits <= 0 ? 0 : Math.Min(100, Math.Round( planCompletedCredits / context.Plan.TotalCredits * 100, 1)), completion.RequirementCount, completion.PassedRequirementCount, completion.MissingRequirements, UnresolvedFailedCourseCount = unresolvedFailedCourseCount, GraduationConclusion = conclusion, EstimatedGraduationSemester = estimatedSemester, EstimatedGraduationTerm = FormatSemester( context.Student.EnrollmentYear, estimatedSemester), IsBeyondStandard = estimatedSemester > context.Plan.Major!.SchoolingYears * 2 }, Conflicts = conflicts, Warnings = workloadWarnings.Cast() .Concat(timingWarnings) .ToList(), TermSummaries = request.Terms .OrderBy(x => x.Semester) .Select(term => new { term.Semester, Label = FormatSemester( context.Student.EnrollmentYear, term.Semester), CourseCount = term.CourseIds.Count, Credits = term.CourseIds.Sum(courseId => context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0) }) }); } private async Task LoadAsync(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 new LoadResult( null, ConflictProblem("当前账号尚未关联学生档案。")); var plan = await db.CurriculumPlans.AsNoTracking() .AsSplitQuery() .Include(x => x.Major) .Include(x => x.Modules) .ThenInclude(x => x.Courses) .ThenInclude(x => x.Course) .ThenInclude(x => x!.Prerequisites) .ThenInclude(x => x.PrerequisiteCourse) .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 new LoadResult( null, ConflictProblem( $"{student.EnrollmentYear} 级{student.AdministrativeClass!.Major!.Name}尚未发布培养方案。")); var courses = plan.Modules .SelectMany(module => module.Courses.Select(item => new PlanningCourse( item.CourseId, item.Course!.Code, item.Course.Name, item.Course.Credits, item.RecommendedSemester, item.Type, module.Code, module.Name, item.Course.Prerequisites .Select(prerequisite => new CourseLabel( prerequisite.PrerequisiteCourseId, prerequisite.PrerequisiteCourse!.Code, prerequisite.PrerequisiteCourse.Name)) .OrderBy(x => x.CourseCode) .ToList()))) .GroupBy(x => x.CourseId) .Select(group => group.First()) .ToList(); var planCourseIds = courses.Select(x => x.CourseId).ToArray(); var gradeAttempts = await db.GradeRecords.AsNoTracking() .Where(x => x.StudentId == student.Id && x.GradeSheet!.Status == GradeSheetStatus.Published) .Select(x => new GradeAttempt( x.GradeSheet!.TeachingTask!.CourseId, x.GradeSheet.TeachingTask.Course!.Credits, x.TotalScore, x.ExamStatus)) .ToListAsync(cancellationToken); var passedCourseIds = gradeAttempts .Where(x => StudentCourseProgressRules.IsPassed( new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus))) .Select(x => x.CourseId) .Distinct() .ToHashSet(); var failedCourseIds = gradeAttempts .GroupBy(x => x.CourseId) .Where(group => !group.Any(x => StudentCourseProgressRules.IsPassed( new StudentCourseAttemptSnapshot( x.TotalScore, x.ExamStatus)))) .Select(group => group.Key) .ToHashSet(); var earnedCredits = gradeAttempts .Where(x => passedCourseIds.Contains(x.CourseId)) .GroupBy(x => x.CourseId) .Sum(group => group.Max(x => x.Credits)); var inProgressCourseIds = 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))) .Select(x => x.CourseId) .Distinct() .ToListAsync(cancellationToken); var inProgressSet = inProgressCourseIds .Where(planCourseIds.Contains) .Where(courseId => !passedCourseIds.Contains(courseId)) .ToHashSet(); var attemptsByCourse = gradeAttempts .Where(x => planCourseIds.Contains(x.CourseId)) .GroupBy(x => x.CourseId) .ToDictionary(x => x.Key, x => x.ToList()); var statuses = courses.ToDictionary( x => x.CourseId, x => StudentCourseProgressRules.Evaluate( attemptsByCourse.GetValueOrDefault(x.CourseId, []) .Select(attempt => new StudentCourseAttemptSnapshot( attempt.TotalScore, attempt.ExamStatus)), inProgressSet.Contains(x.CourseId)).Status); var currentTerm = await db.AcademicTerms.AsNoTracking() .Where(x => x.IsCurrent) .OrderByDescending(x => x.StartDate) .FirstOrDefaultAsync(cancellationToken); var currentSemester = CalculateCurrentSemester( student.EnrollmentYear, currentTerm); var snapshots = courses.Select(x => new AcademicPlanningCourseSnapshot( x.CourseId, x.CourseName, x.Credits, x.RecommendedSemester, x.Type, x.Prerequisites.Select(p => p.CourseId).ToArray())) .ToList(); var allLabels = courses .Select(x => new CourseLabel( x.CourseId, x.CourseCode, x.CourseName)) .Concat(courses.SelectMany(x => x.Prerequisites)) .GroupBy(x => x.CourseId) .ToDictionary(x => x.Key, x => x.First()); return new LoadResult( new PlanningContext( student, plan, courses, courses.ToDictionary(x => x.CourseId), snapshots, allLabels, passedCourseIds, inProgressSet, failedCourseIds, statuses, earnedCredits, currentSemester, currentSemester + 1), null); } private static int CalculateCurrentSemester( int enrollmentYear, AcademicTerm? currentTerm) { if (currentTerm is not null) { return Math.Max( 1, currentTerm.Season == TermSeason.Autumn ? (currentTerm.StartDate.Year - enrollmentYear) * 2 + 1 : (currentTerm.StartDate.Year - enrollmentYear - 1) * 2 + 2); } var today = DateOnly.FromDateTime(DateTime.Today); return Math.Max( 1, today.Month >= 8 ? (today.Year - enrollmentYear) * 2 + 1 : (today.Year - enrollmentYear - 1) * 2 + 2); } private static string FormatSemester(int enrollmentYear, int semester) { var startYear = enrollmentYear + (semester - 1) / 2; var season = semester % 2 == 1 ? "秋季学期" : "春季学期"; return $"{startYear}—{startYear + 1} 学年{season}"; } private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法进行学业规划", Detail = detail, Status = StatusCodes.Status409Conflict }); private sealed record GradeAttempt( Guid CourseId, decimal Credits, decimal? TotalScore, GradeExamStatus ExamStatus); private sealed record CourseLabel( Guid CourseId, string CourseCode, string CourseName); private sealed record PlanningCourse( Guid CourseId, string CourseCode, string CourseName, decimal Credits, int RecommendedSemester, CurriculumCourseType Type, string ModuleCode, string ModuleName, IReadOnlyList Prerequisites); private sealed record PlanningContext( Student Student, CurriculumPlan Plan, IReadOnlyList Courses, IReadOnlyDictionary CourseById, IReadOnlyList CourseSnapshots, IReadOnlyDictionary AllCourseLabels, IReadOnlySet PassedCourseIds, IReadOnlySet InProgressCourseIds, IReadOnlySet FailedCourseIds, IReadOnlyDictionary StatusByCourse, decimal EarnedCredits, int CurrentSemester, int NextSemester); private sealed record LoadResult( PlanningContext? Context, ActionResult? Error); } public sealed record AcademicPlanningRequest( IReadOnlyCollection Terms); public sealed record AcademicPlanningTermRequest( [Range(1, 30)] int Semester, IReadOnlyCollection CourseIds);