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] [Route("api/attendance")] public sealed class AttendanceController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string AttendanceRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor + "," + SystemRoles.Teacher; [HttpGet("my-tasks")] [Authorize(Roles = AttendanceRoles)] 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")] [Authorize(Roles = AttendanceRoles)] 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")] [Authorize(Roles = AttendanceRoles)] 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}")] [Authorize(Roles = AttendanceRoles)] 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(); if (!await AccessibleTasks().AsNoTracking() .AnyAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken)) return NotFound(); // Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite) var exemptStudentIds = await db.CourseExemptions .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved) .Select(e => e.StudentId).ToHashSetAsync(cancellationToken); var deferredStudentIds = await db.DeferredExams .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved) .Select(e => e.StudentId).ToHashSetAsync(cancellationToken); var canEdit = sheet.Status == AttendanceSheetStatus.Draft && await CanManageTaskAsync( sheet.TeachingTaskId, cancellationToken); return Ok(new { Sheet = new { sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate, sheet.Status, sheet.Notes, sheet.SubmittedAt, sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName, Records = sheet.Records.Select(r => new { r.StudentId, r.StudentNumber, r.Name, r.ClassName, r.Status, r.Notes, IsExempt = exemptStudentIds.Contains(r.StudentId), IsDeferred = deferredStudentIds.Contains(r.StudentId) }) }, CanEdit = canEdit }); } [HttpPut("sheets/{id:guid}/records")] [Authorize(Roles = AttendanceRoles)] 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) .ThenInclude(x => x.Teacher) .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")] [Authorize(Roles = AttendanceRoles)] 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) .ThenInclude(x => x.Teacher) .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")] [Authorize(Roles = AttendanceRoles)] public async Task Export(Guid id, CancellationToken cancellationToken) { var sheetData = await db.AttendanceSheets.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.TeachingTaskId, 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(); if (!await AccessibleTasks().AsNoTracking() .AnyAsync(x => x.Id == sheetData.TeachingTaskId, cancellationToken)) 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"); } [HttpGet("tasks/{teachingTaskId:guid}/statistics")] [Authorize(Roles = AttendanceRoles)] public async Task GetTaskStatistics( Guid teachingTaskId, CancellationToken cancellationToken) { var statistics = await LoadTaskStatisticsAsync( teachingTaskId, cancellationToken); return statistics is null ? NotFound() : Ok(statistics); } [HttpGet("tasks/{teachingTaskId:guid}/statistics.xlsx")] [Authorize(Roles = AttendanceRoles)] public async Task ExportTaskStatistics( Guid teachingTaskId, CancellationToken cancellationToken) { var statistics = await LoadTaskStatisticsAsync( teachingTaskId, cancellationToken); if (statistics is null) return NotFound(); var bytes = CreateStatisticsWorkbook(statistics); return File( bytes, ExcelWorkbookHelper.ContentType, $"课程考勤统计-{statistics.Course.TaskNumber}.xlsx"); } [HttpPost("sheets/{id:guid}/submit")] [Authorize(Roles = AttendanceRoles)] public async Task Submit(Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .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 (!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(); } // ═══════════════ Student endpoints ═══════════════ [HttpGet("my-records")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyRecords( Guid? academicTermId, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var source = db.AttendanceRecords.AsNoTracking() .Where(r => r.StudentId == studentId.Value && r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted); if (academicTermId.HasValue) source = source.Where(r => r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); return Ok(await source .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate) .Select(r => new { r.AttendanceSheetId, SheetName = r.AttendanceSheet!.Name, r.AttendanceSheet.AttendanceDate, TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code, CourseName = r.AttendanceSheet.TeachingTask.Course.Name, TeacherNames = r.AttendanceSheet.TeachingTask.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name), r.Status, r.Notes, r.AppealStatus, r.AppealReason, r.AppealSubmittedAt, r.AppealReviewComment, r.AppealReviewedAt }) .ToListAsync(cancellationToken)); } [HttpPost("records/appeal")] [Authorize(Roles = SystemRoles.Student)] public async Task SubmitAppeal( AttendanceAppealRequest request, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var record = await db.AttendanceRecords .FirstOrDefaultAsync(r => r.AttendanceSheetId == request.AttendanceSheetId && r.StudentId == studentId.Value, cancellationToken); if (record is null) return NotFound(); if (record.AppealStatus == AttendanceAppealStatus.Pending) return ConflictProblem("已有申诉正在处理中。"); if (record.AppealStatus == AttendanceAppealStatus.Approved) return ConflictProblem("该考勤记录申诉已通过。"); record.AppealStatus = AttendanceAppealStatus.Pending; record.AppealReason = request.Reason.Trim(); record.AppealSubmittedAt = DateTime.UtcNow; record.AppealReviewComment = null; record.AppealReviewedAt = null; await db.SaveChangesAsync(cancellationToken); // Notify the course teacher var teacherUserIds = await db.TeachingTaskTeachers .Where(x => x.TeachingTaskId == record.AttendanceSheet!.TeachingTaskId) .Select(x => x.Teacher!.UserId) .Where(id => id != null) .Select(id => id!.Value) .ToListAsync(cancellationToken); if (teacherUserIds.Count > 0) { var studentName = await db.Students .Where(s => s.Id == studentId.Value) .Select(s => s.Name) .FirstOrDefaultAsync(cancellationToken); var courseName = record.AttendanceSheet!.TeachingTask?.Course?.Name ?? ""; await NotificationService.SendToUserIdsAsync(db, teacherUserIds, "考勤申诉待处理", $"学生 {studentName} 对《{courseName}》考勤记录提出申诉。", "/teacher-attendance", cancellationToken); } return NoContent(); } // ═══════════════ Counselor endpoints ═══════════════ [HttpGet("counselor-records")] [Authorize(Roles = SystemRoles.Counselor)] public async Task GetCounselorRecords( Guid? academicTermId, Guid? classId, bool? withAppeal, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var classIds = await db.AdministrativeClasses .Where(c => c.CounselorUserId == userId) .Select(c => c.Id) .ToListAsync(cancellationToken); if (classIds.Count == 0) return ConflictProblem("当前账号未关联任何班级。"); if (classId.HasValue && !classIds.Contains(classId.Value)) return ConflictProblem("您不是该班级的辅导员。"); var targetClassIds = classId.HasValue ? [classId.Value] : classIds; var source = db.AttendanceRecords.AsNoTracking() .Where(r => r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && targetClassIds.Contains(r.Student!.AdministrativeClassId)); if (academicTermId.HasValue) source = source.Where(r => r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); if (withAppeal == true) source = source.Where(r => r.AppealStatus == AttendanceAppealStatus.Pending); return Ok(await source .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate) .Select(r => new { r.AttendanceSheetId, SheetName = r.AttendanceSheet!.Name, r.AttendanceSheet.AttendanceDate, TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code, CourseName = r.AttendanceSheet.TeachingTask.Course.Name, StudentNumber = r.Student!.StudentNumber, StudentName = r.Student.Name, ClassName = r.Student.AdministrativeClass!.Name, r.Status, r.Notes, r.AppealStatus, r.AppealReason, r.AppealSubmittedAt, r.AppealReviewComment }) .ToListAsync(cancellationToken)); } [HttpPost("appeals/{attendanceSheetId:guid}/{studentId:guid}/review")] [Authorize(Roles = AttendanceRoles)] public async Task ReviewAppeal( Guid attendanceSheetId, Guid studentId, AttendanceAppealReviewRequest request, CancellationToken cancellationToken) { var record = await db.AttendanceRecords .Include(r => r.AttendanceSheet) .ThenInclude(s => s!.TeachingTask) .ThenInclude(t => t!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(r => r.AttendanceSheetId == attendanceSheetId && r.StudentId == studentId, cancellationToken); if (record is null) return NotFound(); if (!CanManageSheet(record.AttendanceSheet!)) return Forbid(); if (record.AppealStatus != AttendanceAppealStatus.Pending) return ConflictProblem("该申诉不在待处理状态。"); record.AppealStatus = request.Approve ? AttendanceAppealStatus.Approved : AttendanceAppealStatus.Rejected; record.AppealReviewComment = request.Comment?.Trim(); record.AppealReviewedAt = DateTime.UtcNow; // Update record status on approved appeal if (request.Approve) record.Status = AttendanceStatus.Excused; await db.SaveChangesAsync(cancellationToken); // Notify student var studentUserId = await db.Students .Where(s => s.Id == studentId) .Select(s => s.UserId) .FirstOrDefaultAsync(cancellationToken); if (studentUserId.HasValue) { var result = request.Approve ? "已通过" : "已驳回"; await NotificationService.SendAsync(db, studentUserId.Value, $"考勤申诉{result}", request.Comment is not null ? $"您的考勤申诉{result}。意见:{request.Comment}" : $"您的考勤申诉{result}。", null, cancellationToken); } return NoContent(); } [HttpDelete("sheets/{id:guid}")] [Authorize(Roles = AttendanceRoles)] public async Task Delete(Guid id, CancellationToken cancellationToken) { var sheet = await db.AttendanceSheets .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 (!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.Counselor)) { var collegeIds = db.AdministrativeClasses .Where(c => c.CounselorUserId == scope.UserId) .Select(c => c.Major!.CollegeId) .Distinct(); return source.Where(x => collegeIds.Contains(x.Course!.CollegeId)); } 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 async Task CanManageTaskAsync( Guid teachingTaskId, CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; if (scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.AcademicAdmin)) return true; return await db.TeachingTaskTeachers.AsNoTracking() .AnyAsync( x => x.TeachingTaskId == teachingTaskId && x.Teacher!.UserId == scope.UserId, cancellationToken); } private async Task LoadTaskStatisticsAsync( Guid teachingTaskId, CancellationToken cancellationToken) { var course = await AccessibleTasks().AsNoTracking() .Where(x => x.Id == teachingTaskId) .Select(x => new AttendanceStatisticsCourse( x.Id, x.TaskNumber, x.Name, x.Course!.Code, x.Course.Name, x.AcademicTerm!.Name)) .FirstOrDefaultAsync(cancellationToken); if (course is null) return null; var enrolledStudents = await db.CourseEnrollments.AsNoTracking() .Where(x => x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId) .Select(x => new AttendanceStatisticsStudentIdentity( x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Student.AdministrativeClass!.Name)) .Distinct() .ToListAsync(cancellationToken); var submittedSheets = await db.AttendanceSheets.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId && x.Status == AttendanceSheetStatus.Submitted) .OrderBy(x => x.AttendanceDate) .ThenBy(x => x.Name) .Select(x => new AttendanceStatisticsSheet( x.Id, x.Name, x.AttendanceDate)) .ToListAsync(cancellationToken); var records = await db.AttendanceRecords.AsNoTracking() .Where(x => x.AttendanceSheet!.TeachingTaskId == teachingTaskId && x.AttendanceSheet.Status == AttendanceSheetStatus.Submitted) .Select(x => new AttendanceStatisticsRecord( x.AttendanceSheetId, x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Student.AdministrativeClass!.Name, x.Status)) .ToListAsync(cancellationToken); var identities = enrolledStudents .Concat(records.Select(x => new AttendanceStatisticsStudentIdentity( x.StudentId, x.StudentNumber, x.StudentName, x.ClassName))) .GroupBy(x => x.StudentId) .Select(x => x.First()) .ToList(); var recordsByStudent = records .GroupBy(x => x.StudentId) .ToDictionary(x => x.Key, x => x.ToList()); var students = identities.Select(identity => { var studentRecords = recordsByStudent.GetValueOrDefault(identity.StudentId) ?? []; var present = studentRecords.Count(x => x.Status == AttendanceStatus.Present); var absent = studentRecords.Count(x => x.Status == AttendanceStatus.Absent); var late = studentRecords.Count(x => x.Status == AttendanceStatus.Late); var leave = studentRecords.Count(x => x.Status == AttendanceStatus.Leave); var excused = studentRecords.Count(x => x.Status == AttendanceStatus.Excused); var required = studentRecords.Count - excused; return new AttendanceStudentStatistics( identity.StudentId, identity.StudentNumber, identity.StudentName, identity.ClassName, studentRecords.Count, required, present, absent, late, leave, excused, CalculateAttendanceRate(present, late, required)); }) .OrderBy(x => x.AttendanceRate ?? decimal.MaxValue) .ThenBy(x => x.StudentNumber) .ToList(); var recordsBySheet = records .GroupBy(x => x.AttendanceSheetId) .ToDictionary(x => x.Key, x => x.ToList()); var sessions = submittedSheets.Select(sheet => { var sheetRecords = recordsBySheet.GetValueOrDefault(sheet.Id) ?? []; var present = sheetRecords.Count(x => x.Status == AttendanceStatus.Present); var absent = sheetRecords.Count(x => x.Status == AttendanceStatus.Absent); var late = sheetRecords.Count(x => x.Status == AttendanceStatus.Late); var leave = sheetRecords.Count(x => x.Status == AttendanceStatus.Leave); var excused = sheetRecords.Count(x => x.Status == AttendanceStatus.Excused); var required = sheetRecords.Count - excused; return new AttendanceSessionStatistics( sheet.Id, sheet.Name, sheet.AttendanceDate, sheetRecords.Count, required, present, absent, late, leave, excused, CalculateAttendanceRate(present, late, required)); }).ToList(); var totalPresent = records.Count(x => x.Status == AttendanceStatus.Present); var totalAbsent = records.Count(x => x.Status == AttendanceStatus.Absent); var totalLate = records.Count(x => x.Status == AttendanceStatus.Late); var totalLeave = records.Count(x => x.Status == AttendanceStatus.Leave); var totalExcused = records.Count(x => x.Status == AttendanceStatus.Excused); var totalRequired = records.Count - totalExcused; var summary = new AttendanceStatisticsSummary( students.Count, sessions.Count, records.Count, totalRequired, CalculateAttendanceRate(totalPresent, totalLate, totalRequired), students.Count(x => x.RequiredCount > 0 && x.PresentCount == x.RequiredCount), students.Count(x => x.AbsentCount > 0 || x.LateCount > 0)); var distribution = new[] { new AttendanceStatusStatistics( AttendanceStatus.Present, "出勤", totalPresent), new AttendanceStatusStatistics( AttendanceStatus.Absent, "缺勤", totalAbsent), new AttendanceStatusStatistics( AttendanceStatus.Late, "迟到", totalLate), new AttendanceStatusStatistics( AttendanceStatus.Leave, "请假", totalLeave), new AttendanceStatusStatistics( AttendanceStatus.Excused, "免修", totalExcused) }; return new AttendanceCourseStatistics( course, summary, distribution, sessions, students); } private static decimal? CalculateAttendanceRate( int present, int late, int required) { if (required <= 0) return null; return Math.Round( (present + late) * 100m / required, 1, MidpointRounding.AwayFromZero); } private static byte[] CreateStatisticsWorkbook( AttendanceCourseStatistics statistics) { using var workbook = new XLWorkbook(); var overview = workbook.Worksheets.Add("课程概览"); overview.Cell("A1").Value = "课程考勤统计"; overview.Cell("A1").Style.Font.Bold = true; overview.Cell("A1").Style.Font.FontSize = 16; overview.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#1F3A6D"); var overviewRows = new (string Label, object? Value)[] { ("课程", $"{statistics.Course.CourseCode} {statistics.Course.CourseName}"), ("教学任务", statistics.Course.TaskNumber), ("学期", statistics.Course.TermName), ("学生人数", statistics.Summary.StudentCount), ("已提交点名", statistics.Summary.SubmittedSheetCount), ("考勤记录", statistics.Summary.RecordCount), ("课程总体出勤率", statistics.Summary.OverallAttendanceRate), ("全勤人数", statistics.Summary.PerfectAttendanceCount), ("存在缺勤或迟到人数", statistics.Summary.AbnormalStudentCount), ("统计口径", "仅统计已提交点名;出勤和迟到计为到课,免修不计入应到次数。") }; for (var index = 0; index < overviewRows.Length; index++) { var row = index + 3; overview.Cell(row, 1).Value = overviewRows[index].Label; overview.Cell(row, 1).Style.Font.Bold = true; if (overviewRows[index].Value is decimal rate) { overview.Cell(row, 2).Value = rate / 100m; overview.Cell(row, 2).Style.NumberFormat.Format = "0.0%"; } else { overview.Cell(row, 2).Value = overviewRows[index].Value?.ToString() ?? "暂无"; } } overview.Column(1).Width = 22; overview.Column(2).Width = 58; overview.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; var studentSheet = workbook.Worksheets.Add("学生出勤明细"); var studentHeaders = new[] { "学号", "姓名", "行政班", "统计次数", "应到次数", "出勤", "缺勤", "迟到", "请假", "免修", "出勤率" }; WriteHeader(studentSheet, studentHeaders); for (var index = 0; index < statistics.Students.Count; index++) { var student = statistics.Students[index]; var row = index + 2; studentSheet.Cell(row, 1).Value = student.StudentNumber; studentSheet.Cell(row, 2).Value = student.StudentName; studentSheet.Cell(row, 3).Value = student.ClassName; studentSheet.Cell(row, 4).Value = student.TotalCount; studentSheet.Cell(row, 5).Value = student.RequiredCount; studentSheet.Cell(row, 6).Value = student.PresentCount; studentSheet.Cell(row, 7).Value = student.AbsentCount; studentSheet.Cell(row, 8).Value = student.LateCount; studentSheet.Cell(row, 9).Value = student.LeaveCount; studentSheet.Cell(row, 10).Value = student.ExcusedCount; if (student.AttendanceRate.HasValue) { studentSheet.Cell(row, 11).Value = student.AttendanceRate.Value / 100m; studentSheet.Cell(row, 11).Style.NumberFormat.Format = "0.0%"; } else { studentSheet.Cell(row, 11).Value = "暂无"; } } FinishDataSheet(studentSheet, studentHeaders.Length); var sessionSheet = workbook.Worksheets.Add("历次点名趋势"); var sessionHeaders = new[] { "日期", "点名名称", "记录数", "应到人数", "出勤", "缺勤", "迟到", "请假", "免修", "出勤率" }; WriteHeader(sessionSheet, sessionHeaders); for (var index = 0; index < statistics.Sessions.Count; index++) { var session = statistics.Sessions[index]; var row = index + 2; sessionSheet.Cell(row, 1).Value = session.AttendanceDate; sessionSheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd"; sessionSheet.Cell(row, 2).Value = session.Name; sessionSheet.Cell(row, 3).Value = session.TotalCount; sessionSheet.Cell(row, 4).Value = session.RequiredCount; sessionSheet.Cell(row, 5).Value = session.PresentCount; sessionSheet.Cell(row, 6).Value = session.AbsentCount; sessionSheet.Cell(row, 7).Value = session.LateCount; sessionSheet.Cell(row, 8).Value = session.LeaveCount; sessionSheet.Cell(row, 9).Value = session.ExcusedCount; if (session.AttendanceRate.HasValue) { sessionSheet.Cell(row, 10).Value = session.AttendanceRate.Value / 100m; sessionSheet.Cell(row, 10).Style.NumberFormat.Format = "0.0%"; } else { sessionSheet.Cell(row, 10).Value = "暂无"; } } FinishDataSheet(sessionSheet, sessionHeaders.Length); using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } private static void WriteHeader( IXLWorksheet sheet, IReadOnlyList headers) { for (var index = 0; index < headers.Count; index++) { var cell = sheet.Cell(1, index + 1); cell.Value = headers[index]; cell.Style.Font.Bold = true; cell.Style.Font.FontColor = XLColor.White; cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#1F3A6D"); cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; } } private static void FinishDataSheet(IXLWorksheet sheet, int columnCount) { sheet.SheetView.FreezeRows(1); sheet.RangeUsed()?.SetAutoFilter(); sheet.Columns(1, columnCount).AdjustToContents(10, 28); sheet.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; } 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); public sealed record AttendanceAppealRequest( Guid AttendanceSheetId, [Required, MaxLength(500)] string Reason); public sealed record AttendanceAppealReviewRequest( bool Approve, [MaxLength(300)] string? Comment); public sealed record AttendanceCourseStatistics( AttendanceStatisticsCourse Course, AttendanceStatisticsSummary Summary, IReadOnlyList StatusDistribution, IReadOnlyList Sessions, IReadOnlyList Students); public sealed record AttendanceStatisticsCourse( Guid Id, string TaskNumber, string TaskName, string CourseCode, string CourseName, string TermName); public sealed record AttendanceStatisticsSummary( int StudentCount, int SubmittedSheetCount, int RecordCount, int RequiredCount, decimal? OverallAttendanceRate, int PerfectAttendanceCount, int AbnormalStudentCount); public sealed record AttendanceStatusStatistics( AttendanceStatus Status, string Label, int Count); public sealed record AttendanceSessionStatistics( Guid Id, string Name, DateTime AttendanceDate, int TotalCount, int RequiredCount, int PresentCount, int AbsentCount, int LateCount, int LeaveCount, int ExcusedCount, decimal? AttendanceRate); public sealed record AttendanceStudentStatistics( Guid StudentId, string StudentNumber, string StudentName, string ClassName, int TotalCount, int RequiredCount, int PresentCount, int AbsentCount, int LateCount, int LeaveCount, int ExcusedCount, decimal? AttendanceRate); internal sealed record AttendanceStatisticsStudentIdentity( Guid StudentId, string StudentNumber, string StudentName, string ClassName); internal sealed record AttendanceStatisticsSheet( Guid Id, string Name, DateTime AttendanceDate); internal sealed record AttendanceStatisticsRecord( Guid AttendanceSheetId, Guid StudentId, string StudentNumber, string StudentName, string ClassName, AttendanceStatus Status);