Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/AttendanceController.cs
T
biss 08b81eec42 新增“课程统计”模式,展示总体出勤率、状态分布、历次趋势和每位学生的出勤明细。
支持按姓名/学号、行政班及异常情况筛选,默认优先显示低出勤率学生。
新增课程统计 Excel 导出,包含课程概览、学生出勤明细、历次点名趋势三个工作表。
统计仅使用已提交点名;出勤和迟到计为到课,免修不计入应到次数。
修复了枚举兼容问题,已提交点名不会再错误显示为“草稿”,考勤状态中文标签恢复正常。
统计与导出接口均执行教师/数据范围权限校验。
2026-07-25 20:47:56 +08:00

1076 lines
42 KiB
C#

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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<string> { "学号", "考勤状态" },
cancellationToken);
var studentNumberMap = sheet.Records
.ToDictionary(r => r.Student!.StudentNumber, r => r);
var statusMap = new Dictionary<string, AttendanceStatus>(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<ActionResult> 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, string>
{
[AttendanceStatus.Present] = "出勤",
[AttendanceStatus.Absent] = "缺勤",
[AttendanceStatus.Late] = "迟到",
[AttendanceStatus.Leave] = "请假",
[AttendanceStatus.Excused] = "免修"
};
var bytes = ExcelWorkbookHelper.Create(
"考勤表",
["学号", "姓名", "班级", "考勤状态", "备注"],
sheetData.Records.Select(r => new List<object?>
{
r.StudentNumber, r.Name, r.ClassName,
statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes
}).ToList<IReadOnlyList<object?>>());
return File(bytes, ExcelWorkbookHelper.ContentType,
$"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx");
}
[HttpGet("tasks/{teachingTaskId:guid}/statistics")]
[Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<TeachingTask> 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<bool> 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<AttendanceCourseStatistics?> 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<string> 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<AttendanceRecordRequest> 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<AttendanceStatusStatistics> StatusDistribution,
IReadOnlyList<AttendanceSessionStatistics> Sessions,
IReadOnlyList<AttendanceStudentStatistics> 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);