diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs new file mode 100644 index 0000000..fae2480 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -0,0 +1,359 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using ClosedXML.Excel; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = AttendanceRoles)] +[Route("api/attendance")] +public sealed class AttendanceController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string AttendanceRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Teacher; + + [HttpGet("my-tasks")] + public async Task GetMyTasks( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var tasks = AccessibleTasks().AsNoTracking() + .Where(x => x.Status == TeachingTaskStatus.Published); + if (academicTermId.HasValue) + tasks = tasks.Where(x => x.AcademicTermId == academicTermId); + var result = await tasks + .OrderBy(x => x.Course!.Code) + .Select(x => new + { + x.Id, + x.TaskNumber, + x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + CourseCode = x.Course!.Code, + CourseName = x.Course!.Name, + StudentCount = db.CourseEnrollments.Count(enrollment => + enrollment.Status == CourseEnrollmentStatus.Enrolled && + enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id), + SheetCount = db.AttendanceSheets.Count(sheet => + sheet.TeachingTaskId == x.Id) + }) + .ToListAsync(cancellationToken); + return Ok(result); + } + + [HttpGet("sheets")] + public async Task GetSheets( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var task = await AccessibleTasks().AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); + if (task is null) return NotFound(); + + var sheets = await db.AttendanceSheets.AsNoTracking() + .Where(x => x.TeachingTaskId == teachingTaskId) + .OrderByDescending(x => x.AttendanceDate) + .Select(x => new + { + x.Id, + x.Name, + x.AttendanceDate, + x.Status, + x.Notes, + x.SubmittedAt, + PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present), + AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent), + LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late), + LeaveCount = x.Records.Count(r => r.Status == AttendanceStatus.Leave), + ExcusedCount = x.Records.Count(r => r.Status == AttendanceStatus.Excused), + TotalCount = x.Records.Count + }) + .ToListAsync(cancellationToken); + return Ok(sheets); + } + + [HttpPost("sheets")] + public async Task CreateSheet( + AttendanceSheetRequest request, + CancellationToken cancellationToken) + { + var task = await AccessibleTasks().AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); + if (task is null) return NotFound(); + + var studentIds = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId) + .Select(x => x.StudentId) + .Distinct() + .ToListAsync(cancellationToken); + if (studentIds.Count == 0) + return ConflictProblem("该教学班没有有效选课学生。"); + + var sheet = new AttendanceSheet + { + TeachingTaskId = request.TeachingTaskId, + Name = request.Name.Trim(), + AttendanceDate = request.AttendanceDate, + Notes = Normalize(request.Notes), + Records = studentIds.Select(studentId => new AttendanceRecord + { + StudentId = studentId + }).ToList() + }; + db.AttendanceSheets.Add(sheet); + await db.SaveChangesAsync(cancellationToken); + return Created(string.Empty, new { sheet.Id }); + } + + [HttpGet("sheets/{id:guid}")] + public async Task GetSheet(Guid id, CancellationToken cancellationToken) + { + var sheet = await db.AttendanceSheets.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.TeachingTaskId, + x.Name, + x.AttendanceDate, + x.Status, + x.Notes, + x.SubmittedAt, + TaskNumber = x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + Records = x.Records + .OrderBy(r => r.Student!.StudentNumber) + .Select(r => new + { + r.StudentId, + r.Student!.StudentNumber, + r.Student.Name, + ClassName = r.Student.AdministrativeClass!.Name, + r.Status, + r.Notes + }) + }) + .FirstOrDefaultAsync(cancellationToken); + if (sheet is null) return NotFound(); + + var canEdit = sheet.Status == AttendanceSheetStatus.Draft; + return Ok(new { Sheet = sheet, CanEdit = canEdit }); + } + + [HttpPut("sheets/{id:guid}/records")] + public async Task UpdateRecords( + Guid id, + AttendanceRecordsRequest request, + CancellationToken cancellationToken) + { + var sheet = await db.AttendanceSheets + .Include(x => x.Records) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanManageSheet(sheet)) return Forbid(); + if (sheet.Status != AttendanceSheetStatus.Draft) + return ConflictProblem("考勤表已提交,不能修改。"); + + var recordMap = sheet.Records.ToDictionary(r => r.StudentId); + foreach (var item in request.Records) + { + if (recordMap.TryGetValue(item.StudentId, out var record)) + { + record.Status = item.Status; + record.Notes = Normalize(item.Notes); + } + } + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPost("sheets/{id:guid}/import")] + public async Task Import( + Guid id, + IFormFile file, + CancellationToken cancellationToken) + { + var sheet = await db.AttendanceSheets + .Include(x => x.Records) + .ThenInclude(x => x.Student) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanManageSheet(sheet)) return Forbid(); + if (sheet.Status != AttendanceSheetStatus.Draft) + return ConflictProblem("考勤表已提交,不能导入。"); + + var rows = await ExcelWorkbookHelper.ReadAsync( + file, + new HashSet { "学号", "考勤状态" }, + cancellationToken); + var studentNumberMap = sheet.Records + .ToDictionary(r => r.Student!.StudentNumber, r => r); + var statusMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["出勤"] = AttendanceStatus.Present, + ["缺勤"] = AttendanceStatus.Absent, + ["迟到"] = AttendanceStatus.Late, + ["请假"] = AttendanceStatus.Leave, + ["免修"] = AttendanceStatus.Excused + }; + + var updated = 0; + foreach (var row in rows) + { + var studentNumber = row["学号"]; + var statusText = row["考勤状态"]; + if (string.IsNullOrEmpty(studentNumber)) continue; + if (!studentNumberMap.TryGetValue(studentNumber, out var record)) continue; + if (statusMap.TryGetValue(statusText, out var status)) + { + record.Status = status; + updated++; + } + } + await db.SaveChangesAsync(cancellationToken); + return Ok(new { Updated = updated }); + } + + [HttpGet("sheets/{id:guid}/export.xlsx")] + public async Task Export(Guid id, CancellationToken cancellationToken) + { + var sheetData = await db.AttendanceSheets.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Name, + x.AttendanceDate, + TaskNumber = x.TeachingTask!.TaskNumber, + CourseName = x.TeachingTask.Course!.Name, + Records = x.Records + .OrderBy(r => r.Student!.StudentNumber) + .Select(r => new + { + r.Student!.StudentNumber, + r.Student.Name, + ClassName = r.Student.AdministrativeClass!.Name, + r.Status, + r.Notes + }) + .ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + if (sheetData is null) return NotFound(); + + var statusLabels = new Dictionary + { + [AttendanceStatus.Present] = "出勤", + [AttendanceStatus.Absent] = "缺勤", + [AttendanceStatus.Late] = "迟到", + [AttendanceStatus.Leave] = "请假", + [AttendanceStatus.Excused] = "免修" + }; + var bytes = ExcelWorkbookHelper.Create( + "考勤表", + ["学号", "姓名", "班级", "考勤状态", "备注"], + sheetData.Records.Select(r => new List + { + r.StudentNumber, r.Name, r.ClassName, + statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes + }).ToList>()); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx"); + } + + [HttpPost("sheets/{id:guid}/submit")] + public async Task Submit(Guid id, CancellationToken cancellationToken) + { + var sheet = await db.AttendanceSheets + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanManageSheet(sheet)) return Forbid(); + if (sheet.Status != AttendanceSheetStatus.Draft) + return ConflictProblem("考勤表已提交。"); + sheet.Status = AttendanceSheetStatus.Submitted; + sheet.SubmittedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpDelete("sheets/{id:guid}")] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + var sheet = await db.AttendanceSheets + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanManageSheet(sheet)) return Forbid(); + db.AttendanceSheets.Remove(sheet); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private IQueryable AccessibleTasks() + { + var source = db.TeachingTasks.AsQueryable(); + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.All) return source; + if (scope.Scope == DataScope.College) + return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); + if (scope.IsInRole(SystemRoles.Teacher)) + return source.Where(x => + x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); + return source.Where(_ => false); + } + + private bool CanManageSheet(AttendanceSheet sheet) => + currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || + sheet.TeachingTask!.Teachers.Any(x => + x.Teacher?.UserId == currentUserDataScope.Current.UserId); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private ActionResult ConflictProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "无法完成考勤操作", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); +} + +public sealed record AttendanceSheetRequest( + Guid TeachingTaskId, + [MaxLength(120)] string Name, + DateTime AttendanceDate, + [MaxLength(500)] string? Notes); + +public sealed record AttendanceRecordsRequest( + IReadOnlyCollection Records); + +public sealed record AttendanceRecordRequest( + Guid StudentId, + AttendanceStatus Status, + [MaxLength(300)] string? Notes); diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 01e3a0c..1d79619 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.CourseSelection; +using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -868,6 +869,100 @@ public sealed class CourseSelectionsController( return await SaveAsync(id, false, cancellationToken); } + [HttpGet("my-offerings")] + [Authorize(Roles = SystemRoles.Teacher)] + public async Task GetMyOfferings( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var offeringsQuery = db.CourseSelectionOfferings.AsNoTracking() + .Where(x => + x.TeachingTask!.Teachers.Any(t => + t.Teacher!.UserId == userId) && + x.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft); + if (academicTermId.HasValue) + offeringsQuery = offeringsQuery.Where(x => + x.CourseSelectionRound!.AcademicTermId == academicTermId); + var offerings = await offeringsQuery + .OrderByDescending(x => x.CourseSelectionRound!.AcademicTerm!.StartDate) + .ThenBy(x => x.TeachingTask!.Course!.Code) + .Select(x => new + { + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + x.TeachingTask.AcademicTermId, + TermName = x.TeachingTask.AcademicTerm!.Name, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + RoundName = x.CourseSelectionRound!.Name, + x.Capacity, + EnrolledCount = x.Enrollments.Count(e => + e.Status == CourseEnrollmentStatus.Enrolled), + RoundStatus = x.CourseSelectionRound.Status + }) + .ToListAsync(cancellationToken); + return Ok(offerings); + } + + [HttpGet("offerings/{id:guid}/roster/export.xlsx")] + [Authorize(Roles = RosterReaders)] + public async Task ExportRoster( + Guid id, + CancellationToken cancellationToken) + { + var offering = await db.CourseSelectionOfferings.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.TeachingTask!.TaskNumber, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + RoundName = x.CourseSelectionRound!.Name, + CollegeId = x.TeachingTask.Course.CollegeId, + TeacherUserIds = x.TeachingTask.Teachers + .Select(item => item.Teacher!.UserId) + }) + .FirstOrDefaultAsync(cancellationToken); + if (offering is null) return NotFound(); + + var scope = currentUserDataScope.Current; + var isAssignedTeacher = + scope.IsInRole(SystemRoles.Teacher) && + offering.TeacherUserIds.Contains(scope.UserId); + if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) + return Forbid(); + + var students = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.CourseSelectionOfferingId == id && + x.Status == CourseEnrollmentStatus.Enrolled) + .OrderBy(x => x.Student!.StudentNumber) + .Select(x => new + { + x.Student!.StudentNumber, + x.Student.Name, + ClassName = x.Student.AdministrativeClass!.Name, + MajorName = x.Student.AdministrativeClass.Major!.Name, + x.EnrolledAt + }) + .ToListAsync(cancellationToken); + + var bytes = ExcelWorkbookHelper.Create( + "选课名单", + ["学号", "姓名", "班级", "专业", "选课时间"], + students.Select(s => new List + { + s.StudentNumber, s.Name, s.ClassName, s.MajorName, + s.EnrolledAt.ToString("yyyy-MM-dd HH:mm") + }).ToList>()); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"选课名单-{offering.TaskNumber}.xlsx"); + } + private IQueryable ScopedOfferings() { var source = db.CourseSelectionOfferings.AsQueryable(); diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index ac3a923..b7ff903 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -71,8 +71,13 @@ public sealed class GradesController( sheet.Id, sheet.Status, sheet.RegularWeight, - sheet.MidtermWeight, sheet.FinalWeight, + Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new + { + item.Id, + item.Name, + item.Weight + }), StudentCount = sheet.Records.Count, CompletedCount = sheet.Records.Count(record => record.TotalScore != null || @@ -99,11 +104,20 @@ public sealed class GradesController( GradeSheetRequest request, CancellationToken cancellationToken) { + var items = request.Items? + .Select((item, index) => new GradeItem + { + Name = item.Name.Trim(), + Weight = item.Weight, + SortOrder = index + }) + .ToList() ?? []; + if (!GradeCalculator.AreWeightsValid( request.RegularWeight, - request.MidtermWeight, - request.FinalWeight)) - return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。"); + request.FinalWeight, + items)) + return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。"); var task = await AccessibleTasks() .Include(x => x.Teachers) @@ -131,11 +145,15 @@ public sealed class GradesController( { TeachingTaskId = task.Id, RegularWeight = request.RegularWeight, - MidtermWeight = request.MidtermWeight, FinalWeight = request.FinalWeight, + Items = items, Records = studentIds.Select(studentId => new GradeRecord { - StudentId = studentId + StudentId = studentId, + ItemScores = items.Select(item => new GradeItemScore + { + GradeItemId = item.Id + }).ToList() }).ToList() }; db.GradeSheets.Add(sheet); @@ -166,8 +184,13 @@ public sealed class GradesController( ClassNames = x.TeachingTask.Classes .Select(item => item.AdministrativeClass!.Name), x.RegularWeight, - x.MidtermWeight, x.FinalWeight, + Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new + { + item.Id, + item.Name, + item.Weight + }), x.Status, x.ReviewComment, x.SubmittedAt, @@ -183,8 +206,15 @@ public sealed class GradesController( record.Student.Name, ClassName = record.Student.AdministrativeClass!.Name, record.RegularScore, - record.MidtermScore, record.FinalScore, + ItemScores = record.ItemScores + .OrderBy(itemScore => itemScore.GradeItem!.SortOrder) + .Select(itemScore => new + { + itemScore.GradeItemId, + itemScore.GradeItem!.Name, + itemScore.Score + }), record.TotalScore, record.GradePoint, record.ExamStatus, @@ -218,20 +248,58 @@ public sealed class GradesController( GradeWeightsRequest request, CancellationToken cancellationToken) { + var items = request.Items? + .Select((item, index) => new GradeItem + { + Name = item.Name.Trim(), + Weight = item.Weight, + SortOrder = index + }) + .ToList() ?? []; + if (!GradeCalculator.AreWeightsValid( request.RegularWeight, - request.MidtermWeight, - request.FinalWeight)) - return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。"); - var sheet = await EditableSheetAsync(id, cancellationToken); + request.FinalWeight, + items)) + return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。"); + + var sheet = await AccessibleSheets() + .Include(x => x.Items) + .Include(x => x.Records) + .ThenInclude(x => x.ItemScores) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) return ConflictProblem("当前状态不能修改成绩构成。"); + sheet.RegularWeight = request.RegularWeight; - sheet.MidtermWeight = request.MidtermWeight; sheet.FinalWeight = request.FinalWeight; - foreach (var record in sheet.Records) Recalculate(sheet, record); + + db.GradeItems.RemoveRange(sheet.Items); + var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet(); + foreach (var record in sheet.Records) + { + var toRemove = record.ItemScores + .Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId)) + .ToList(); + foreach (var removed in toRemove) + record.ItemScores.Remove(removed); + } + + sheet.Items = items; + foreach (var record in sheet.Records) + { + foreach (var item in items) + { + record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id }); + } + Recalculate(sheet, record); + } + return await SaveAsync(id, false, cancellationToken); } @@ -242,28 +310,49 @@ public sealed class GradesController( GradeRecordsRequest request, CancellationToken cancellationToken) { - var sheet = await EditableSheetAsync(id, cancellationToken); + var sheet = await AccessibleSheets() + .Include(x => x.Items) + .Include(x => x.Records) + .ThenInclude(x => x.ItemScores) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) return ConflictProblem("成绩单提交后不能继续修改。"); + var records = sheet.Records.ToDictionary(x => x.Id); if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count || request.Records.Any(x => !records.ContainsKey(x.Id))) return ValidationProblem("包含无效或重复的成绩记录。"); + var itemIds = sheet.Items.Select(item => item.Id).ToHashSet(); foreach (var item in request.Records) { if (!ValidScore(item.RegularScore) || - !ValidScore(item.MidtermScore) || !ValidScore(item.FinalScore)) return ValidationProblem("成绩必须在 0—100 分之间。"); + var record = records[item.Id]; record.RegularScore = item.RegularScore; - record.MidtermScore = item.MidtermScore; record.FinalScore = item.FinalScore; record.ExamStatus = item.ExamStatus; record.Notes = Normalize(item.Notes); + + if (item.ItemScores is not null) + { + var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId); + foreach (var scoreEntry in item.ItemScores) + { + if (!ValidScore(scoreEntry.Score)) + return ValidationProblem("分项成绩必须在 0—100 分之间。"); + if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing)) + existing.Score = scoreEntry.Score; + } + } + Recalculate(sheet, record); } return await SaveAsync(id, false, cancellationToken); @@ -273,7 +362,14 @@ public sealed class GradesController( [Authorize(Roles = SheetUsers)] public async Task Submit(Guid id, CancellationToken cancellationToken) { - var sheet = await EditableSheetAsync(id, cancellationToken); + var sheet = await AccessibleSheets() + .Include(x => x.Items) + .Include(x => x.Records) + .ThenInclude(x => x.ItemScores) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) @@ -412,16 +508,6 @@ public sealed class GradesController( db.GradeSheets.Where(x => AccessibleTasks().Any(task => task.Id == x.TeachingTaskId)); - private async Task EditableSheetAsync( - Guid id, - CancellationToken cancellationToken) => - await AccessibleSheets() - .Include(x => x.Records) - .Include(x => x.TeachingTask) - .ThenInclude(x => x!.Teachers) - .ThenInclude(x => x.Teacher) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - private bool CanInitialize(TeachingTask task) => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || @@ -449,11 +535,11 @@ public sealed class GradesController( { record.TotalScore = GradeCalculator.CalculateTotal( record.RegularScore, - record.MidtermScore, record.FinalScore, + record.ItemScores.ToList(), sheet.RegularWeight, - sheet.MidtermWeight, sheet.FinalWeight, + sheet.Items.ToList(), record.ExamStatus); record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore); } @@ -492,13 +578,17 @@ public sealed class GradesController( public sealed record GradeSheetRequest( Guid TeachingTaskId, [Range(typeof(decimal), "0", "100")] decimal RegularWeight, - [Range(typeof(decimal), "0", "100")] decimal MidtermWeight, - [Range(typeof(decimal), "0", "100")] decimal FinalWeight); + [Range(typeof(decimal), "0", "100")] decimal FinalWeight, + IReadOnlyCollection? Items); + +public sealed record GradeItemRequest( + [MaxLength(60)] string Name, + [Range(typeof(decimal), "0", "100")] decimal Weight); public sealed record GradeWeightsRequest( [Range(typeof(decimal), "0", "100")] decimal RegularWeight, - [Range(typeof(decimal), "0", "100")] decimal MidtermWeight, - [Range(typeof(decimal), "0", "100")] decimal FinalWeight); + [Range(typeof(decimal), "0", "100")] decimal FinalWeight, + IReadOnlyCollection? Items); public sealed record GradeRecordsRequest( IReadOnlyCollection Records); @@ -506,10 +596,14 @@ public sealed record GradeRecordsRequest( public sealed record GradeRecordRequest( Guid Id, decimal? RegularScore, - decimal? MidtermScore, decimal? FinalScore, + IReadOnlyCollection? ItemScores, GradeExamStatus ExamStatus, [MaxLength(300)] string? Notes); +public sealed record GradeItemScoreRequest( + Guid GradeItemId, + decimal? Score); + public sealed record GradeReviewRequest( [MaxLength(500)] string? Comment); diff --git a/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs b/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs new file mode 100644 index 0000000..5d17135 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs @@ -0,0 +1,40 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class AttendanceSheet : EntityBase +{ + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public required string Name { get; set; } + public DateTime AttendanceDate { get; set; } + public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft; + public string? Notes { get; set; } + public DateTime? SubmittedAt { get; set; } + public ICollection Records { get; set; } = []; +} + +public sealed class AttendanceRecord +{ + public Guid AttendanceSheetId { get; set; } + public AttendanceSheet? AttendanceSheet { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public AttendanceStatus Status { get; set; } = AttendanceStatus.Present; + public string? Notes { get; set; } +} + +public enum AttendanceSheetStatus +{ + Draft = 1, + Submitted = 2 +} + +public enum AttendanceStatus +{ + Present = 1, + Absent = 2, + Late = 3, + Leave = 4, + Excused = 5 +} diff --git a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs index ae4fd8c..9cbfa38 100644 --- a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs @@ -7,7 +7,6 @@ public sealed class GradeSheet : EntityBase public Guid TeachingTaskId { get; set; } public TeachingTask? TeachingTask { get; set; } public decimal RegularWeight { get; set; } = 30; - public decimal MidtermWeight { get; set; } public decimal FinalWeight { get; set; } = 70; public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft; public string? ReviewComment { get; set; } @@ -15,6 +14,17 @@ public sealed class GradeSheet : EntityBase public DateTime? ReviewedAt { get; set; } public DateTime? PublishedAt { get; set; } public ICollection Records { get; set; } = []; + public ICollection Items { get; set; } = []; +} + +public sealed class GradeItem : EntityBase +{ + public Guid GradeSheetId { get; set; } + public GradeSheet? GradeSheet { get; set; } + public required string Name { get; set; } + public decimal Weight { get; set; } + public int SortOrder { get; set; } + public ICollection Scores { get; set; } = []; } public sealed class GradeRecord : EntityBase @@ -24,12 +34,21 @@ public sealed class GradeRecord : EntityBase public Guid StudentId { get; set; } public Student? Student { get; set; } public decimal? RegularScore { get; set; } - public decimal? MidtermScore { get; set; } public decimal? FinalScore { get; set; } public decimal? TotalScore { get; set; } public decimal? GradePoint { get; set; } public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal; public string? Notes { get; set; } + public ICollection ItemScores { get; set; } = []; +} + +public sealed class GradeItemScore +{ + public Guid GradeRecordId { get; set; } + public GradeRecord? GradeRecord { get; set; } + public Guid GradeItemId { get; set; } + public GradeItem? GradeItem { get; set; } + public decimal? Score { get; set; } } public enum GradeSheetStatus diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs index b2d37c4..d8431fd 100644 --- a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs @@ -6,34 +6,45 @@ public static class GradeCalculator { public static bool AreWeightsValid( decimal regularWeight, - decimal midtermWeight, - decimal finalWeight) => + decimal finalWeight, + IEnumerable items) => regularWeight is >= 0 and <= 100 && - midtermWeight is >= 0 and <= 100 && finalWeight is >= 0 and <= 100 && - regularWeight + midtermWeight + finalWeight == 100; + items.All(item => item.Weight is >= 0 and <= 100) && + regularWeight + finalWeight + items.Sum(item => item.Weight) == 100; public static decimal? CalculateTotal( decimal? regularScore, - decimal? midtermScore, decimal? finalScore, + IReadOnlyCollection itemScores, decimal regularWeight, - decimal midtermWeight, decimal finalWeight, + IReadOnlyCollection items, GradeExamStatus examStatus) { - if (examStatus != GradeExamStatus.Normal || - regularWeight > 0 && !regularScore.HasValue || - midtermWeight > 0 && !midtermScore.HasValue || - finalWeight > 0 && !finalScore.HasValue) - { + if (examStatus != GradeExamStatus.Normal) return null; + + if (regularWeight > 0 && !regularScore.HasValue) + return null; + if (finalWeight > 0 && !finalScore.HasValue) + return null; + + var itemWeightById = items.ToDictionary(item => item.Id, item => item.Weight); + foreach (var itemScore in itemScores) + { + if (itemWeightById.TryGetValue(itemScore.GradeItemId, out var weight) && + weight > 0 && !itemScore.Score.HasValue) + return null; } - var total = - (regularScore ?? 0) * regularWeight / 100 + - (midtermScore ?? 0) * midtermWeight / 100 + - (finalScore ?? 0) * finalWeight / 100; + var total = (regularScore ?? 0) * regularWeight / 100; + foreach (var itemScore in itemScores) + { + if (itemWeightById.TryGetValue(itemScore.GradeItemId, out var weight)) + total += (itemScore.Score ?? 0) * weight / 100; + } + total += (finalScore ?? 0) * finalWeight / 100; return Math.Round(total, 1, MidpointRounding.AwayFromZero); } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index ae5cbd3..1051df5 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -47,6 +47,10 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet CourseEnrollments => Set(); public DbSet GradeSheets => Set(); public DbSet GradeRecords => Set(); + public DbSet GradeItems => Set(); + public DbSet GradeItemScores => Set(); + public DbSet AttendanceSheets => Set(); + public DbSet AttendanceRecords => Set(); public DbSet ExamPlans => Set(); public DbSet ExamSessions => Set(); public DbSet ExamSessionInvigilators => @@ -468,7 +472,6 @@ public sealed class AppDbContext(DbContextOptions options) builder.Entity(entity => { entity.Property(x => x.RegularWeight).HasPrecision(5, 1); - entity.Property(x => x.MidtermWeight).HasPrecision(5, 1); entity.Property(x => x.FinalWeight).HasPrecision(5, 1); entity.Property(x => x.ReviewComment).HasMaxLength(500); entity.HasIndex(x => x.TeachingTaskId).IsUnique(); @@ -479,10 +482,20 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(60); + entity.Property(x => x.Weight).HasPrecision(5, 1); + entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder }); + entity.HasOne(x => x.GradeSheet) + .WithMany(x => x.Items) + .HasForeignKey(x => x.GradeSheetId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => { entity.Property(x => x.RegularScore).HasPrecision(5, 1); - entity.Property(x => x.MidtermScore).HasPrecision(5, 1); entity.Property(x => x.FinalScore).HasPrecision(5, 1); entity.Property(x => x.TotalScore).HasPrecision(5, 1); entity.Property(x => x.GradePoint).HasPrecision(3, 1); @@ -499,6 +512,45 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.HasKey(x => new { x.GradeRecordId, x.GradeItemId }); + entity.Property(x => x.Score).HasPrecision(5, 1); + entity.HasOne(x => x.GradeRecord) + .WithMany(x => x.ItemScores) + .HasForeignKey(x => x.GradeRecordId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.GradeItem) + .WithMany(x => x.Scores) + .HasForeignKey(x => x.GradeItemId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate }); + entity.HasOne(x => x.TeachingTask) + .WithMany() + .HasForeignKey(x => x.TeachingTaskId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.HasKey(x => new { x.AttendanceSheetId, x.StudentId }); + entity.Property(x => x.Notes).HasMaxLength(300); + entity.HasOne(x => x.AttendanceSheet) + .WithMany(x => x.Records) + .HasForeignKey(x => x.AttendanceSheetId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Student) + .WithMany() + .HasForeignKey(x => x.StudentId) + .OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => { entity.Property(x => x.Name).HasMaxLength(120); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index 47d64e2..c9b39a6 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -546,7 +546,6 @@ public sealed class DatabaseInitializer( { TeachingTaskId = task.Id, RegularWeight = 30, - MidtermWeight = 0, FinalWeight = 70, Status = GradeSheetStatus.Draft, Records = diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index e793aab..ed7c8c2 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -14,6 +14,7 @@ public sealed class DevelopmentSqliteMigrator( private const string CourseSelectionMigration = "20260724_06_course_selection"; private const string GradesMigration = "20260724_07_grades"; private const string ExamsMigration = "20260724_08_exams"; + private const string AttendanceMigration = "20260724_08b_attendance"; private const string StudentStatusChangesMigration = "20260724_09_student_status_changes"; private const string GraduationAuditsMigration = "20260724_10_graduation_audits"; private const string DegreeAwardsMigration = "20260724_11_degree_awards"; @@ -29,6 +30,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_17_teaching_task_scheduling_modes"; private const string SchedulePublishJobsMigration = "20260725_18_schedule_publish_jobs"; + private const string FlexibleGradesMigration = + "20260725_19_flexible_grades"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -177,6 +180,34 @@ public sealed class DevelopmentSqliteMigrator( SchedulePublishJobsMigration, schedulePublishJobsExist ? [] : SchedulePublishJobStatements, cancellationToken); + + var midtermWeightExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('GradeSheets') + WHERE name = 'MidtermWeight' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + FlexibleGradesMigration, + midtermWeightExists + ? FlexibleGradesUpgradeStatements + : FlexibleGradesNewStatements, + cancellationToken); + + var attendanceSheetsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'AttendanceSheets' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + AttendanceMigration, + attendanceSheetsExist ? [] : AttendanceStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -629,7 +660,6 @@ public sealed class DevelopmentSqliteMigrator( "Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY, "TeachingTaskId" TEXT NOT NULL, "RegularWeight" TEXT NOT NULL, - "MidtermWeight" TEXT NOT NULL, "FinalWeight" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, @@ -656,7 +686,6 @@ public sealed class DevelopmentSqliteMigrator( "GradeSheetId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "RegularScore" TEXT NULL, - "MidtermScore" TEXT NULL, "FinalScore" TEXT NULL, "TotalScore" TEXT NULL, "GradePoint" TEXT NULL, @@ -723,6 +752,182 @@ public sealed class DevelopmentSqliteMigrator( """CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");""" ]; + private static readonly string[] FlexibleGradesNewStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "GradeItems" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_GradeItems" PRIMARY KEY, + "GradeSheetId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Weight" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_GradeItems_GradeSheets_GradeSheetId" + FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE + ); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_GradeItems_GradeSheetId_SortOrder" + ON "GradeItems" ("GradeSheetId", "SortOrder"); + """, + """ + CREATE TABLE IF NOT EXISTS "GradeItemScores" ( + "GradeRecordId" TEXT NOT NULL, + "GradeItemId" TEXT NOT NULL, + "Score" TEXT NULL, + CONSTRAINT "PK_GradeItemScores" PRIMARY KEY ("GradeRecordId", "GradeItemId"), + CONSTRAINT "FK_GradeItemScores_GradeRecords_GradeRecordId" + FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_GradeItemScores_GradeItems_GradeItemId" + FOREIGN KEY ("GradeItemId") REFERENCES "GradeItems" ("Id") ON DELETE RESTRICT + ); + """ + ]; + + private static readonly string[] FlexibleGradesUpgradeStatements = + [ + """ + CREATE TABLE "GradeSheets_v2" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY, + "TeachingTaskId" TEXT NOT NULL, + "RegularWeight" TEXT NOT NULL, + "FinalWeight" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "ReviewComment" TEXT NULL, + "SubmittedAt" TEXT NULL, + "ReviewedAt" TEXT NULL, + "PublishedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_GradeSheets_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT + ); + """, + """ + INSERT INTO "GradeSheets_v2" ("Id","TeachingTaskId","RegularWeight","FinalWeight", + "Status","ReviewComment","SubmittedAt","ReviewedAt","PublishedAt","CreatedAt","UpdatedAt") + SELECT "Id","TeachingTaskId","RegularWeight","FinalWeight", + "Status","ReviewComment","SubmittedAt","ReviewedAt","PublishedAt","CreatedAt","UpdatedAt" + FROM "GradeSheets"; + """, + "DROP TABLE \"GradeSheets\";", + "ALTER TABLE \"GradeSheets_v2\" RENAME TO \"GradeSheets\";", + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeSheets_TeachingTaskId" + ON "GradeSheets" ("TeachingTaskId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_GradeSheets_Status" + ON "GradeSheets" ("Status"); + """, + """ + CREATE TABLE "GradeRecords_v2" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_GradeRecords" PRIMARY KEY, + "GradeSheetId" TEXT NOT NULL, + "StudentId" TEXT NOT NULL, + "RegularScore" TEXT NULL, + "FinalScore" TEXT NULL, + "TotalScore" TEXT NULL, + "GradePoint" TEXT NULL, + "ExamStatus" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_GradeRecords_GradeSheets_GradeSheetId" + FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_GradeRecords_Students_StudentId" + FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT + ); + """, + """ + INSERT INTO "GradeRecords_v2" ("Id","GradeSheetId","StudentId","RegularScore","FinalScore", + "TotalScore","GradePoint","ExamStatus","Notes","CreatedAt","UpdatedAt") + SELECT "Id","GradeSheetId","StudentId","RegularScore","FinalScore", + "TotalScore","GradePoint","ExamStatus","Notes","CreatedAt","UpdatedAt" + FROM "GradeRecords"; + """, + "DROP TABLE \"GradeRecords\";", + "ALTER TABLE \"GradeRecords_v2\" RENAME TO \"GradeRecords\";", + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeRecords_GradeSheetId_StudentId" + ON "GradeRecords" ("GradeSheetId", "StudentId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_GradeRecords_StudentId_TotalScore" + ON "GradeRecords" ("StudentId", "TotalScore"); + """, + """ + CREATE TABLE IF NOT EXISTS "GradeItems" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_GradeItems" PRIMARY KEY, + "GradeSheetId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Weight" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_GradeItems_GradeSheets_GradeSheetId" + FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE + ); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_GradeItems_GradeSheetId_SortOrder" + ON "GradeItems" ("GradeSheetId", "SortOrder"); + """, + """ + CREATE TABLE IF NOT EXISTS "GradeItemScores" ( + "GradeRecordId" TEXT NOT NULL, + "GradeItemId" TEXT NOT NULL, + "Score" TEXT NULL, + CONSTRAINT "PK_GradeItemScores" PRIMARY KEY ("GradeRecordId", "GradeItemId"), + CONSTRAINT "FK_GradeItemScores_GradeRecords_GradeRecordId" + FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_GradeItemScores_GradeItems_GradeItemId" + FOREIGN KEY ("GradeItemId") REFERENCES "GradeItems" ("Id") ON DELETE RESTRICT + ); + """ + ]; + + private static readonly string[] AttendanceStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "AttendanceSheets" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceSheets" PRIMARY KEY, + "TeachingTaskId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "AttendanceDate" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "Notes" TEXT NULL, + "SubmittedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_AttendanceSheets_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_AttendanceSheets_TeachingTaskId_AttendanceDate" + ON "AttendanceSheets" ("TeachingTaskId", "AttendanceDate"); + """, + """ + CREATE TABLE IF NOT EXISTS "AttendanceRecords" ( + "AttendanceSheetId" TEXT NOT NULL, + "StudentId" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "Notes" TEXT NULL, + CONSTRAINT "PK_AttendanceRecords" PRIMARY KEY ("AttendanceSheetId", "StudentId"), + CONSTRAINT "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId" + FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_AttendanceRecords_Students_StudentId" + FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AttendanceSheetId_StudentId" + ON "AttendanceRecords" ("AttendanceSheetId", "StudentId"); + """ + ]; + private static readonly string[] StudentStatusChangesStatements = [ """ diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725051000_FlexibleGradesAndAttendance.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725051000_FlexibleGradesAndAttendance.cs new file mode 100644 index 0000000..22b3f45 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725051000_FlexibleGradesAndAttendance.cs @@ -0,0 +1,184 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class FlexibleGradesAndAttendance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GradeItems", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + GradeSheetId = table.Column(type: "char(36)", nullable: false), + Name = table.Column(type: "varchar(60)", maxLength: 60, nullable: false), + Weight = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GradeItems", x => x.Id); + table.ForeignKey( + name: "FK_GradeItems_GradeSheets_GradeSheetId", + column: x => x.GradeSheetId, + principalTable: "GradeSheets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "GradeItemScores", + columns: table => new + { + GradeRecordId = table.Column(type: "char(36)", nullable: false), + GradeItemId = table.Column(type: "char(36)", nullable: false), + Score = table.Column(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GradeItemScores", x => new { x.GradeRecordId, x.GradeItemId }); + table.ForeignKey( + name: "FK_GradeItemScores_GradeItems_GradeItemId", + column: x => x.GradeItemId, + principalTable: "GradeItems", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_GradeItemScores_GradeRecords_GradeRecordId", + column: x => x.GradeRecordId, + principalTable: "GradeRecords", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_GradeItems_GradeSheetId_SortOrder", + table: "GradeItems", + columns: new[] { "GradeSheetId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_GradeItemScores_GradeRecordId_GradeItemId", + table: "GradeItemScores", + columns: new[] { "GradeRecordId", "GradeItemId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GradeItemScores_GradeItemId", + table: "GradeItemScores", + column: "GradeItemId"); + + migrationBuilder.DropColumn( + name: "MidtermWeight", + table: "GradeSheets"); + + migrationBuilder.DropColumn( + name: "MidtermScore", + table: "GradeRecords"); + + migrationBuilder.CreateTable( + name: "AttendanceSheets", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + Name = table.Column(type: "varchar(120)", maxLength: 120, nullable: false), + AttendanceDate = table.Column(type: "datetime(6)", nullable: false), + Status = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AttendanceSheets", x => x.Id); + table.ForeignKey( + name: "FK_AttendanceSheets_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "AttendanceRecords", + columns: table => new + { + AttendanceSheetId = table.Column(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + Status = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(300)", maxLength: 300, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AttendanceRecords", x => new { x.AttendanceSheetId, x.StudentId }); + table.ForeignKey( + name: "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId", + column: x => x.AttendanceSheetId, + principalTable: "AttendanceSheets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AttendanceRecords_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceSheets_TeachingTaskId_AttendanceDate", + table: "AttendanceSheets", + columns: new[] { "TeachingTaskId", "AttendanceDate" }); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceRecords_AttendanceSheetId_StudentId", + table: "AttendanceRecords", + columns: new[] { "AttendanceSheetId", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceRecords_StudentId", + table: "AttendanceRecords", + column: "StudentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "AttendanceRecords"); + migrationBuilder.DropTable(name: "AttendanceSheets"); + migrationBuilder.DropTable(name: "GradeItemScores"); + migrationBuilder.DropTable(name: "GradeItems"); + + migrationBuilder.AddColumn( + name: "MidtermWeight", + table: "GradeSheets", + type: "decimal(5,1)", + precision: 5, + scale: 1, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "MidtermScore", + table: "GradeRecords", + type: "decimal(5,1)", + precision: 5, + scale: 1, + nullable: true); + } + } +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 123e2f1..ed54c3c 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -20,6 +20,7 @@ declare module 'vue' { ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 7f531e9..5fb4430 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -106,6 +106,8 @@ const navigationGroups = computed(() => [ ), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), + ...whenVisible(isTeacher.value || isTeachingAdmin.value, { path: '/teacher-attendance', label: '教学点名' }), + ...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }), ...whenVisible(!isStudent.value, { path: '/class-timetable', label: isTimetableManager.value ? '课表查询中心' : '班级课表查询', diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 9e33534..b54d0bd 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -149,6 +149,18 @@ const router = createRouter({ component: () => import('../views/TimetableView.vue'), meta: { teacherView: true }, }, + { + path: 'teacher-roster', + name: 'teacher-roster', + component: () => import('../views/TeacherRosterView.vue'), + meta: { roles: ['Teacher'] }, + }, + { + path: 'teacher-attendance', + name: 'teacher-attendance', + component: () => import('../views/TeacherAttendanceView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'] }, + }, { path: 'free-classrooms', name: 'free-classrooms', diff --git a/web/src/views/GradesView.vue b/web/src/views/GradesView.vue index 4e2d028..65a1bd6 100644 --- a/web/src/views/GradesView.vue +++ b/web/src/views/GradesView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { Check, + Delete, DocumentChecked, EditPen, Plus, @@ -28,11 +29,15 @@ const termId = ref() const status = ref() const createForm = reactive({ regularWeight: 30, - midtermWeight: 0, finalWeight: 70, + items: [] as { name: string; weight: number }[], }) +const newItemName = ref('') +const newItemWeight = ref(0) const returnComment = ref('') +const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中'] + const statusLabels: Record = { Draft: '录入中', Submitted: '待审核', @@ -46,6 +51,10 @@ const examStatusLabels: Record = { Deferred: '缓考', Exempt: '免修', } +const itemsWeight = computed(() => + createForm.items.reduce((sum, item) => sum + item.weight, 0)) +const weightSum = computed(() => + createForm.regularWeight + createForm.finalWeight + itemsWeight.value) const completedCount = computed(() => detail.value?.records.filter((record: any) => record.totalScore != null || record.examStatus !== 'Normal').length ?? 0) @@ -69,8 +78,7 @@ const transcriptGpa = computed(() => { const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0) if (!credits) return '—' const points = numeric.reduce( - (sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), - 0, + (sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), 0, ) return (points / credits).toFixed(2) }) @@ -79,6 +87,29 @@ const earnedCredits = computed(() => .filter((record: any) => Number(record.totalScore) >= 60) .reduce((sum: number, record: any) => sum + Number(record.credits), 0)) +function addItem() { + const name = newItemName.value.trim() || '自定义分项' + if (createForm.items.some(item => item.name === name)) { + ElMessage.warning('分项名称不能重复。') + return + } + createForm.items.push({ name, weight: newItemWeight.value || 0 }) + newItemName.value = '' + newItemWeight.value = 0 +} + +function addPresetItem(name: string) { + if (createForm.items.some(item => item.name === name)) { + ElMessage.warning('该分项已存在。') + return + } + createForm.items.push({ name, weight: 0 }) +} + +function removeItem(index: number) { + createForm.items.splice(index, 1) +} + async function load() { loading.value = true try { @@ -127,23 +158,25 @@ async function selectTask(task: any) { } function openCreate() { - Object.assign(createForm, { - regularWeight: 30, - midtermWeight: 0, - finalWeight: 70, - }) + createForm.regularWeight = 30 + createForm.finalWeight = 70 + createForm.items = [] + newItemName.value = '' + newItemWeight.value = 0 createDialog.value = true } async function createSheet() { - if (createForm.regularWeight + createForm.midtermWeight + createForm.finalWeight !== 100) { - ElMessage.warning('三个成绩分项的比例必须合计 100%。') + if (weightSum.value !== 100) { + ElMessage.warning(`平时、期末与所有分项的比例必须合计 100%(当前 ${weightSum.value}%)。`) return } try { await http.post('/grades/sheets', { teachingTaskId: selectedTask.value.id, - ...createForm, + regularWeight: createForm.regularWeight, + finalWeight: createForm.finalWeight, + items: createForm.items.map(item => ({ name: item.name, weight: item.weight })), }) createDialog.value = false ElMessage.success('成绩登记册已建立') @@ -159,8 +192,11 @@ async function saveRecords() { records: detail.value.records.map((record: any) => ({ id: record.id, regularScore: record.regularScore, - midtermScore: record.midtermScore, finalScore: record.finalScore, + itemScores: record.itemScores?.map((itemScore: any) => ({ + gradeItemId: itemScore.gradeItemId, + score: itemScore.score, + })), examStatus: record.examStatus, notes: record.notes, })), @@ -241,6 +277,10 @@ function scoreClass(score: number | null) { return '' } +function itemScore(record: any, gradeItemId: string) { + return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId) +} + onMounted(async () => { try { terms.value = (await http.get('/base-data/terms')).data @@ -259,7 +299,7 @@ onMounted(async () => { ACADEMIC RECORD

{{ isStudent ? '学业成绩单' : '成绩管理' }}

查看学校已正式发布的课程成绩、学分与绩点。

-

从教师登记、学院复核到校级发布,保留每张成绩单的明确状态。

+

从教师登记、学院复核到校级发布,支持自定义平时、实验、实习等分项。

刷新 @@ -366,7 +406,7 @@ onMounted(async () => {
及格率{{ passRate }}

平时 {{ detail.regularWeight }}% - + · 期末 {{ detail.finalWeight }}%

@@ -389,58 +429,58 @@ onMounted(async () => { - + - + - + - + - + - + @@ -463,24 +503,54 @@ onMounted(async () => { - - + +
- + - - - - + + +
+ {{ weightSum }}% + 需为 100% + +
+
+
+ + 成绩分项(实验、实习、课程设计等) + +
+ 快速添加: + {{ name }} +
+ +
+
+ {{ item.name }} + + % + +
+
+ +
+ + + % + 添加
@@ -497,3 +567,18 @@ onMounted(async () => {
+ + diff --git a/web/src/views/TeacherAttendanceView.vue b/web/src/views/TeacherAttendanceView.vue new file mode 100644 index 0000000..3788947 --- /dev/null +++ b/web/src/views/TeacherAttendanceView.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/web/src/views/TeacherRosterView.vue b/web/src/views/TeacherRosterView.vue new file mode 100644 index 0000000..1f42eba --- /dev/null +++ b/web/src/views/TeacherRosterView.vue @@ -0,0 +1,162 @@ + + + + +