新增“课程统计”模式,展示总体出勤率、状态分布、历次趋势和每位学生的出勤明细。
支持按姓名/学号、行政班及异常情况筛选,默认优先显示低出勤率学生。 新增课程统计 Excel 导出,包含课程概览、学生出勤明细、历次点名趋势三个工作表。 统计仅使用已提交点名;出勤和迟到计为到课,免修不计入应到次数。 修复了枚举兼容问题,已提交点名不会再错误显示为“草稿”,考勤状态中文标签恢复正常。 统计与导出接口均执行教师/数据范围权限校验。
This commit is contained in:
@@ -158,6 +158,9 @@ public sealed class AttendanceController(
|
|||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
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)
|
// Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite)
|
||||||
var exemptStudentIds = await db.CourseExemptions
|
var exemptStudentIds = await db.CourseExemptions
|
||||||
@@ -167,7 +170,10 @@ public sealed class AttendanceController(
|
|||||||
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
|
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
|
||||||
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
|
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
|
||||||
|
|
||||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft;
|
var canEdit = sheet.Status == AttendanceSheetStatus.Draft &&
|
||||||
|
await CanManageTaskAsync(
|
||||||
|
sheet.TeachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Sheet = new
|
Sheet = new
|
||||||
@@ -277,6 +283,7 @@ public sealed class AttendanceController(
|
|||||||
.Where(x => x.Id == id)
|
.Where(x => x.Id == id)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.AttendanceDate,
|
x.AttendanceDate,
|
||||||
TaskNumber = x.TeachingTask!.TaskNumber,
|
TaskNumber = x.TeachingTask!.TaskNumber,
|
||||||
@@ -295,6 +302,9 @@ public sealed class AttendanceController(
|
|||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (sheetData is null) return NotFound();
|
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>
|
var statusLabels = new Dictionary<AttendanceStatus, string>
|
||||||
{
|
{
|
||||||
@@ -316,6 +326,36 @@ public sealed class AttendanceController(
|
|||||||
$"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx");
|
$"考勤表-{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")]
|
[HttpPost("sheets/{id:guid}/submit")]
|
||||||
[Authorize(Roles = AttendanceRoles)]
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||||
@@ -592,6 +632,339 @@ public sealed class AttendanceController(
|
|||||||
sheet.TeachingTask!.Teachers.Any(x =>
|
sheet.TeachingTask!.Teachers.Any(x =>
|
||||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
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) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
@@ -625,3 +998,78 @@ public sealed record AttendanceAppealRequest(
|
|||||||
public sealed record AttendanceAppealReviewRequest(
|
public sealed record AttendanceAppealReviewRequest(
|
||||||
bool Approve,
|
bool Approve,
|
||||||
[MaxLength(300)] string? Comment);
|
[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);
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AttendanceControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task TaskStatistics_UsesSubmittedSheetsAndExportsAllDetails()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "080901",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学学士"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "CS2026-01",
|
||||||
|
Name = "计科 2026-1 班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var firstStudent = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "202601001",
|
||||||
|
Name = "周同学",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var secondStudent = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "202601002",
|
||||||
|
Name = "吴同学",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-1",
|
||||||
|
Name = "2026—2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 20)
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2026-1-CS101-01",
|
||||||
|
Name = "程序设计基础教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
firstStudent,
|
||||||
|
secondStudent,
|
||||||
|
course,
|
||||||
|
term,
|
||||||
|
task);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.AttendanceSheets.AddRange(
|
||||||
|
new AttendanceSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
Name = "第1周点名",
|
||||||
|
AttendanceDate = new DateTime(2026, 9, 3),
|
||||||
|
Status = AttendanceSheetStatus.Submitted,
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = firstStudent.Id,
|
||||||
|
Status = AttendanceStatus.Present
|
||||||
|
},
|
||||||
|
new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = secondStudent.Id,
|
||||||
|
Status = AttendanceStatus.Absent
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new AttendanceSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
Name = "第2周点名",
|
||||||
|
AttendanceDate = new DateTime(2026, 9, 10),
|
||||||
|
Status = AttendanceSheetStatus.Submitted,
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = firstStudent.Id,
|
||||||
|
Status = AttendanceStatus.Late
|
||||||
|
},
|
||||||
|
new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = secondStudent.Id,
|
||||||
|
Status = AttendanceStatus.Excused
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new AttendanceSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
Name = "未提交点名",
|
||||||
|
AttendanceDate = new DateTime(2026, 9, 17),
|
||||||
|
Status = AttendanceSheetStatus.Draft,
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = firstStudent.Id,
|
||||||
|
Status = AttendanceStatus.Absent
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new AttendanceController(db, new AllDataScope());
|
||||||
|
var result = await controller.GetTaskStatistics(
|
||||||
|
task.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
var statistics = Assert.IsType<AttendanceCourseStatistics>(ok.Value);
|
||||||
|
Assert.Equal(2, statistics.Summary.SubmittedSheetCount);
|
||||||
|
Assert.Equal(4, statistics.Summary.RecordCount);
|
||||||
|
Assert.Equal(3, statistics.Summary.RequiredCount);
|
||||||
|
Assert.Equal(66.7m, statistics.Summary.OverallAttendanceRate);
|
||||||
|
Assert.Equal(0, statistics.Summary.PerfectAttendanceCount);
|
||||||
|
Assert.Equal(2, statistics.Students.Count);
|
||||||
|
Assert.Equal(0m, statistics.Students[0].AttendanceRate);
|
||||||
|
Assert.Equal(100m, statistics.Students[1].AttendanceRate);
|
||||||
|
|
||||||
|
var exportResult = await controller.ExportTaskStatistics(
|
||||||
|
task.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var file = Assert.IsType<FileContentResult>(exportResult);
|
||||||
|
using var workbook = new XLWorkbook(new MemoryStream(file.FileContents));
|
||||||
|
Assert.NotNull(workbook.Worksheet("课程概览"));
|
||||||
|
Assert.Equal(
|
||||||
|
3,
|
||||||
|
workbook.Worksheet("学生出勤明细").LastRowUsed()!.RowNumber());
|
||||||
|
Assert.Equal(
|
||||||
|
3,
|
||||||
|
workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AllDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user