1754 lines
68 KiB
C#
1754 lines
68 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
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 Jiaowu.Api.Infrastructure.Teaching;
|
|
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.Students.Count(student =>
|
|
(student.Status == StudentStatus.Active &&
|
|
x.Classes.Any(assignment =>
|
|
assignment.AdministrativeClassId ==
|
|
student.AdministrativeClassId)) ||
|
|
db.CourseEnrollments.Any(enrollment =>
|
|
enrollment.StudentId == student.Id &&
|
|
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.CheckInMethod,
|
|
x.CheckInStartsAt,
|
|
x.CheckInEndsAt,
|
|
x.Notes,
|
|
x.SubmittedAt,
|
|
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
|
|
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 TeachingTaskRosterQuery
|
|
.ForTask(db, request.TeachingTaskId)
|
|
.AsNoTracking()
|
|
.Select(x => x.Id)
|
|
.ToListAsync(cancellationToken);
|
|
if (studentIds.Count == 0)
|
|
return ConflictProblem("该教学班没有有效学生。");
|
|
|
|
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
|
if (!Enum.IsDefined(checkInMethod))
|
|
return ConflictProblem("不支持该签到方式。");
|
|
|
|
var now = DateTime.UtcNow;
|
|
DateTime? checkInEndsAt = null;
|
|
string? checkInToken = null;
|
|
if (checkInMethod != AttendanceCheckInMethod.Manual)
|
|
{
|
|
if (request.CheckInDurationMinutes is < 1 or > 180)
|
|
return ConflictProblem("签到时长应为 1 至 180 分钟。");
|
|
checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value);
|
|
}
|
|
if (checkInMethod == AttendanceCheckInMethod.QrCode)
|
|
checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
|
|
if (checkInMethod == AttendanceCheckInMethod.Location)
|
|
{
|
|
if (request.TargetLatitude is < -90 or > 90 ||
|
|
request.TargetLongitude is < -180 or > 180 ||
|
|
request.TargetLatitude is null ||
|
|
request.TargetLongitude is null)
|
|
return ConflictProblem("请获取有效的签到位置。");
|
|
if (request.LocationRadiusMeters is < 20 or > 1000)
|
|
return ConflictProblem("定位签到范围应为 20 至 1000 米。");
|
|
}
|
|
|
|
var sheet = new AttendanceSheet
|
|
{
|
|
TeachingTaskId = request.TeachingTaskId,
|
|
Name = request.Name.Trim(),
|
|
AttendanceDate = request.AttendanceDate,
|
|
CheckInMethod = checkInMethod,
|
|
CheckInToken = checkInToken,
|
|
CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual
|
|
? null
|
|
: now,
|
|
CheckInEndsAt = checkInEndsAt,
|
|
TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location
|
|
? request.TargetLatitude
|
|
: null,
|
|
TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location
|
|
? request.TargetLongitude
|
|
: null,
|
|
LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location
|
|
? request.LocationRadiusMeters
|
|
: null,
|
|
Notes = Normalize(request.Notes),
|
|
Records = studentIds.Select(studentId => new AttendanceRecord
|
|
{
|
|
StudentId = studentId,
|
|
Status = checkInMethod == AttendanceCheckInMethod.Manual
|
|
? AttendanceStatus.Present
|
|
: AttendanceStatus.Absent
|
|
}).ToList()
|
|
};
|
|
db.AttendanceSheets.Add(sheet);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return Created(string.Empty, new
|
|
{
|
|
sheet.Id,
|
|
sheet.CheckInMethod,
|
|
sheet.CheckInStartsAt,
|
|
sheet.CheckInEndsAt
|
|
});
|
|
}
|
|
|
|
[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.CheckInMethod,
|
|
x.CheckInStartsAt,
|
|
x.CheckInEndsAt,
|
|
x.TargetLatitude,
|
|
x.TargetLongitude,
|
|
x.LocationRadiusMeters,
|
|
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,
|
|
r.CheckInAt,
|
|
r.CheckedInMethod,
|
|
r.CheckInAccuracyMeters,
|
|
r.CheckInDistanceMeters
|
|
})
|
|
})
|
|
.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 canManage = await CanManageTaskAsync(
|
|
sheet.TeachingTaskId,
|
|
cancellationToken);
|
|
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
|
var now = DateTime.UtcNow;
|
|
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
|
|
.Where(x => x.AttendanceSheetId == id)
|
|
.Select(x => new
|
|
{
|
|
x.StudentId,
|
|
x.CreatedAt,
|
|
x.IsSuccessful,
|
|
x.FailureCode,
|
|
x.DeviceIdentifierHash,
|
|
x.DevicePlatform,
|
|
x.IpAddress,
|
|
x.RiskFlags
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var attemptsByStudent = attempts
|
|
.GroupBy(x => x.StudentId)
|
|
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
|
|
var deviceHashes = attempts
|
|
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
|
|
.Select(x => x.DeviceIdentifierHash!)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
if (deviceHashes.Count > 0)
|
|
{
|
|
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
|
.Where(x =>
|
|
x.IsSuccessful &&
|
|
x.CreatedAt >= now.AddHours(-24) &&
|
|
x.DeviceIdentifierHash != null)
|
|
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
|
|
.GroupBy(x => x.DeviceIdentifierHash!)
|
|
.Select(x => new
|
|
{
|
|
DeviceIdentifierHash = x.Key,
|
|
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
deviceReuseCounts = reuseRows.ToDictionary(
|
|
x => x.DeviceIdentifierHash,
|
|
x => x.StudentCount,
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
var responseRecords = sheet.Records.Select(r =>
|
|
{
|
|
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
|
|
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
|
|
var riskFlags = studentAttempts
|
|
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
|
|
riskFlags.Add("RepeatedFailures");
|
|
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
|
|
riskFlags.Add("HighFrequency");
|
|
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
|
|
? deviceReuseCounts.GetValueOrDefault(deviceHash)
|
|
: 0;
|
|
if (sharedDeviceStudentCount > 1)
|
|
riskFlags.Add("SharedDevice");
|
|
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
|
|
? attempts
|
|
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
|
|
.Select(x => x.StudentId)
|
|
.Distinct()
|
|
.Count()
|
|
: 0;
|
|
|
|
return new
|
|
{
|
|
r.StudentId,
|
|
r.StudentNumber,
|
|
r.Name,
|
|
r.ClassName,
|
|
r.Status,
|
|
r.Notes,
|
|
r.CheckInAt,
|
|
r.CheckedInMethod,
|
|
r.CheckInAccuracyMeters,
|
|
r.CheckInDistanceMeters,
|
|
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
|
IsDeferred = deferredStudentIds.Contains(r.StudentId),
|
|
CheckInAudit = latestSuccess is null
|
|
? null
|
|
: new
|
|
{
|
|
latestSuccess.IpAddress,
|
|
latestSuccess.DevicePlatform,
|
|
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
|
|
SharedDeviceStudentCount = sharedDeviceStudentCount,
|
|
SameIpStudentCount = sameIpStudentCount
|
|
},
|
|
AttemptCount = studentAttempts.Count,
|
|
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
|
|
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
|
|
};
|
|
}).ToList();
|
|
return Ok(new
|
|
{
|
|
Sheet = new
|
|
{
|
|
sheet.Id,
|
|
sheet.TeachingTaskId,
|
|
sheet.Name,
|
|
sheet.AttendanceDate,
|
|
sheet.Status,
|
|
sheet.CheckInMethod,
|
|
sheet.CheckInStartsAt,
|
|
sheet.CheckInEndsAt,
|
|
sheet.TargetLatitude,
|
|
sheet.TargetLongitude,
|
|
sheet.LocationRadiusMeters,
|
|
IsCheckInOpen = IsCheckInOpen(
|
|
sheet.Status,
|
|
sheet.CheckInMethod,
|
|
sheet.CheckInStartsAt,
|
|
sheet.CheckInEndsAt,
|
|
now),
|
|
CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null),
|
|
sheet.Notes,
|
|
sheet.SubmittedAt,
|
|
sheet.TaskNumber,
|
|
sheet.TaskName,
|
|
sheet.CourseCode,
|
|
sheet.CourseName,
|
|
Records = responseRecords,
|
|
RiskSummary = new
|
|
{
|
|
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
|
|
SharedDeviceStudentCount = responseRecords.Count(
|
|
x => x.RiskFlags.Contains("SharedDevice")),
|
|
FrequentAttemptStudentCount = responseRecords.Count(
|
|
x => x.RiskFlags.Contains("HighFrequency") ||
|
|
x.RiskFlags.Contains("RepeatedFailures"))
|
|
}
|
|
},
|
|
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();
|
|
}
|
|
|
|
[HttpPost("sheets/{id:guid}/close-check-in")]
|
|
[Authorize(Roles = AttendanceRoles)]
|
|
public async Task<ActionResult> CloseCheckIn(
|
|
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("考勤表已提交,签到活动已经结束。");
|
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
|
return ConflictProblem("普通点名没有在线签到活动。");
|
|
|
|
var now = DateTime.UtcNow;
|
|
if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now)
|
|
sheet.CheckInEndsAt = now;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
|
[Authorize(Roles = AttendanceRoles)]
|
|
public async Task<ActionResult> GetQrChallenge(
|
|
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.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
|
string.IsNullOrWhiteSpace(sheet.CheckInToken))
|
|
return ConflictProblem("该考勤表不是扫码签到。");
|
|
|
|
var now = DateTime.UtcNow;
|
|
if (!IsCheckInOpen(
|
|
sheet.Status,
|
|
sheet.CheckInMethod,
|
|
sheet.CheckInStartsAt,
|
|
sheet.CheckInEndsAt,
|
|
now))
|
|
return ConflictProblem("签到尚未开始或已经结束。");
|
|
|
|
var challenge = AttendanceCheckInChallenge.Create(
|
|
sheet.Id,
|
|
sheet.CheckInToken,
|
|
now);
|
|
return Ok(challenge);
|
|
}
|
|
|
|
// ═══════════════ Student endpoints ═══════════════
|
|
|
|
[HttpGet("check-in-info")]
|
|
[Authorize(Roles = SystemRoles.Student)]
|
|
public async Task<ActionResult> GetCheckInInfo(
|
|
[FromQuery, MaxLength(64)] string token,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
|
if (!studentId.HasValue)
|
|
return ConflictProblem("当前账号未关联学生档案。");
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
return NotFound();
|
|
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
|
|
return NotFound();
|
|
|
|
var activity = await db.AttendanceRecords.AsNoTracking()
|
|
.Where(x =>
|
|
x.StudentId == studentId.Value &&
|
|
x.AttendanceSheetId == sheetId)
|
|
.Select(x => new
|
|
{
|
|
SheetId = x.AttendanceSheetId,
|
|
SheetName = x.AttendanceSheet!.Name,
|
|
x.AttendanceSheet.AttendanceDate,
|
|
x.AttendanceSheet.Status,
|
|
x.AttendanceSheet.CheckInMethod,
|
|
x.AttendanceSheet.CheckInStartsAt,
|
|
x.AttendanceSheet.CheckInEndsAt,
|
|
x.AttendanceSheet.CheckInToken,
|
|
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
|
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
|
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
|
x.CheckInAt
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (activity is null) return NotFound();
|
|
|
|
var now = DateTime.UtcNow;
|
|
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
|
!AttendanceCheckInChallenge.IsValid(
|
|
token,
|
|
activity.SheetId,
|
|
activity.CheckInToken,
|
|
now))
|
|
return NotFound();
|
|
return Ok(new
|
|
{
|
|
activity.SheetId,
|
|
activity.SheetName,
|
|
activity.AttendanceDate,
|
|
activity.CheckInMethod,
|
|
activity.CheckInStartsAt,
|
|
activity.CheckInEndsAt,
|
|
activity.CourseCode,
|
|
activity.CourseName,
|
|
activity.TaskNumber,
|
|
activity.CheckInAt,
|
|
IsOpen = IsCheckInOpen(
|
|
activity.Status,
|
|
activity.CheckInMethod,
|
|
activity.CheckInStartsAt,
|
|
activity.CheckInEndsAt,
|
|
now)
|
|
});
|
|
}
|
|
|
|
[HttpGet("open-check-ins")]
|
|
[Authorize(Roles = SystemRoles.Student)]
|
|
public async Task<ActionResult> GetOpenCheckIns(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
|
if (!studentId.HasValue)
|
|
return ConflictProblem("当前账号未关联学生档案。");
|
|
|
|
var now = DateTime.UtcNow;
|
|
return Ok(await db.AttendanceRecords.AsNoTracking()
|
|
.Where(x =>
|
|
x.StudentId == studentId.Value &&
|
|
x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft &&
|
|
x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location &&
|
|
x.AttendanceSheet.CheckInStartsAt <= now &&
|
|
x.AttendanceSheet.CheckInEndsAt >= now)
|
|
.OrderBy(x => x.AttendanceSheet!.CheckInEndsAt)
|
|
.Select(x => new
|
|
{
|
|
SheetId = x.AttendanceSheetId,
|
|
SheetName = x.AttendanceSheet!.Name,
|
|
x.AttendanceSheet.AttendanceDate,
|
|
x.AttendanceSheet.CheckInMethod,
|
|
x.AttendanceSheet.CheckInStartsAt,
|
|
x.AttendanceSheet.CheckInEndsAt,
|
|
x.AttendanceSheet.LocationRadiusMeters,
|
|
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
|
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
|
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
|
x.CheckInAt
|
|
})
|
|
.ToListAsync(cancellationToken));
|
|
}
|
|
|
|
[HttpPost("check-in")]
|
|
[Authorize(Roles = SystemRoles.Student)]
|
|
public async Task<ActionResult> CheckIn(
|
|
AttendanceCheckInRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
|
if (!studentId.HasValue)
|
|
return ConflictProblem("当前账号未关联学生档案。");
|
|
|
|
var source = db.AttendanceRecords
|
|
.Include(x => x.AttendanceSheet)
|
|
.Where(x => x.StudentId == studentId.Value);
|
|
if (!string.IsNullOrWhiteSpace(request.Token))
|
|
{
|
|
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
|
request.Token.Trim(),
|
|
out var tokenSheetId))
|
|
return NotFound();
|
|
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
|
}
|
|
else if (request.AttendanceSheetId.HasValue)
|
|
{
|
|
source = source.Where(x =>
|
|
x.AttendanceSheetId == request.AttendanceSheetId.Value);
|
|
}
|
|
else
|
|
{
|
|
return ConflictProblem("缺少签到活动信息。");
|
|
}
|
|
|
|
var record = await source.FirstOrDefaultAsync(cancellationToken);
|
|
if (record?.AttendanceSheet is null) return NotFound();
|
|
var sheet = record.AttendanceSheet;
|
|
var now = DateTime.UtcNow;
|
|
|
|
async Task<ActionResult> RejectAttemptAsync(
|
|
string failureCode,
|
|
string detail,
|
|
double? distanceMeters = null)
|
|
{
|
|
await AddCheckInAttemptAsync(
|
|
sheet,
|
|
studentId.Value,
|
|
request,
|
|
false,
|
|
failureCode,
|
|
distanceMeters,
|
|
now,
|
|
cancellationToken);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return ConflictProblem(detail);
|
|
}
|
|
|
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
|
|
!AttendanceCheckInChallenge.IsValid(
|
|
request.Token,
|
|
sheet.Id,
|
|
sheet.CheckInToken,
|
|
now))
|
|
return await RejectAttemptAsync(
|
|
"InvalidQrChallenge",
|
|
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
|
|
if (!IsCheckInOpen(
|
|
sheet.Status,
|
|
sheet.CheckInMethod,
|
|
sheet.CheckInStartsAt,
|
|
sheet.CheckInEndsAt,
|
|
now))
|
|
return await RejectAttemptAsync(
|
|
"CheckInClosed",
|
|
"签到尚未开始或已经结束。");
|
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
|
return await RejectAttemptAsync(
|
|
"ManualSheet",
|
|
"该考勤表不支持学生在线签到。");
|
|
if (record.CheckInAt.HasValue)
|
|
{
|
|
await AddCheckInAttemptAsync(
|
|
sheet,
|
|
studentId.Value,
|
|
request,
|
|
true,
|
|
null,
|
|
record.CheckInDistanceMeters,
|
|
now,
|
|
cancellationToken);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return Ok(new
|
|
{
|
|
AlreadyCheckedIn = true,
|
|
record.CheckInAt,
|
|
record.CheckInDistanceMeters
|
|
});
|
|
}
|
|
|
|
double? distanceMeters = null;
|
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
|
{
|
|
if (request.Latitude is < -90 or > 90 ||
|
|
request.Longitude is < -180 or > 180 ||
|
|
request.Latitude is null ||
|
|
request.Longitude is null)
|
|
return await RejectAttemptAsync(
|
|
"InvalidLocation",
|
|
"未获取到有效的当前位置。");
|
|
if (sheet.TargetLatitude is null ||
|
|
sheet.TargetLongitude is null ||
|
|
sheet.LocationRadiusMeters is null)
|
|
return await RejectAttemptAsync(
|
|
"LocationNotConfigured",
|
|
"签到活动没有配置有效的位置范围。");
|
|
var maximumAllowedAccuracyMeters = Math.Min(
|
|
100d,
|
|
sheet.LocationRadiusMeters.Value);
|
|
if (request.AccuracyMeters is null ||
|
|
!double.IsFinite(request.AccuracyMeters.Value) ||
|
|
request.AccuracyMeters <= 0 ||
|
|
request.AccuracyMeters > maximumAllowedAccuracyMeters)
|
|
{
|
|
return await RejectAttemptAsync(
|
|
"InsufficientAccuracy",
|
|
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
|
|
}
|
|
|
|
distanceMeters = CalculateDistanceMeters(
|
|
(double)sheet.TargetLatitude.Value,
|
|
(double)sheet.TargetLongitude.Value,
|
|
(double)request.Latitude.Value,
|
|
(double)request.Longitude.Value);
|
|
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
|
{
|
|
return await RejectAttemptAsync(
|
|
"OutsideGeofence",
|
|
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
|
distanceMeters);
|
|
}
|
|
}
|
|
|
|
record.Status = AttendanceStatus.Present;
|
|
record.CheckInAt = now;
|
|
record.CheckedInMethod = sheet.CheckInMethod;
|
|
record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
|
? request.Latitude
|
|
: null;
|
|
record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
|
? request.Longitude
|
|
: null;
|
|
record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
|
? request.AccuracyMeters
|
|
: null;
|
|
record.CheckInDistanceMeters = distanceMeters;
|
|
await AddCheckInAttemptAsync(
|
|
sheet,
|
|
studentId.Value,
|
|
request,
|
|
true,
|
|
null,
|
|
distanceMeters,
|
|
now,
|
|
cancellationToken);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
return Ok(new
|
|
{
|
|
AlreadyCheckedIn = false,
|
|
record.CheckInAt,
|
|
record.CheckInDistanceMeters
|
|
});
|
|
}
|
|
|
|
[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,
|
|
r.AttendanceSheet!.TeachingTaskId,
|
|
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,
|
|
NotificationCategory.Attendance);
|
|
}
|
|
|
|
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)
|
|
.WhereIn(targetClassIds, r => 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, NotificationCategory.Attendance);
|
|
}
|
|
|
|
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<Guid?> GetCurrentStudentIdAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var userId = currentUserDataScope.Current.UserId;
|
|
return await db.Students.AsNoTracking()
|
|
.Where(x => x.UserId == userId)
|
|
.Select(x => (Guid?)x.Id)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task AddCheckInAttemptAsync(
|
|
AttendanceSheet sheet,
|
|
Guid studentId,
|
|
AttendanceCheckInRequest request,
|
|
bool isSuccessful,
|
|
string? failureCode,
|
|
double? distanceMeters,
|
|
DateTime now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
|
|
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
|
|
if (deviceIdentifierHash is null)
|
|
{
|
|
riskFlags.Add("MissingDeviceId");
|
|
}
|
|
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
|
|
x =>
|
|
x.IsSuccessful &&
|
|
x.StudentId != studentId &&
|
|
x.DeviceIdentifierHash == deviceIdentifierHash &&
|
|
x.CreatedAt >= now.AddHours(-24),
|
|
cancellationToken))
|
|
{
|
|
riskFlags.Add("SharedDevice");
|
|
}
|
|
|
|
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
|
.CountAsync(
|
|
x => x.StudentId == studentId &&
|
|
x.CreatedAt >= now.AddMinutes(-2),
|
|
cancellationToken);
|
|
if (recentAttemptCount >= 5)
|
|
riskFlags.Add("HighFrequency");
|
|
|
|
if (!isSuccessful)
|
|
{
|
|
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
|
.CountAsync(
|
|
x => x.StudentId == studentId &&
|
|
!x.IsSuccessful &&
|
|
x.CreatedAt >= now.AddMinutes(-5),
|
|
cancellationToken);
|
|
if (recentFailureCount >= 2)
|
|
riskFlags.Add("RepeatedFailures");
|
|
}
|
|
|
|
var context = ControllerContext.HttpContext;
|
|
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
|
|
{
|
|
AttendanceSheetId = sheet.Id,
|
|
StudentId = studentId,
|
|
CheckInMethod = sheet.CheckInMethod,
|
|
IsSuccessful = isSuccessful,
|
|
FailureCode = failureCode,
|
|
DeviceIdentifierHash = deviceIdentifierHash,
|
|
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
|
|
IpAddress = Limit(
|
|
context?.Connection.RemoteIpAddress?.ToString(),
|
|
64),
|
|
UserAgent = Limit(
|
|
context?.Request.Headers.UserAgent.ToString(),
|
|
500),
|
|
RiskFlags = riskFlags.Count == 0
|
|
? null
|
|
: string.Join(',', riskFlags.OrderBy(x => x)),
|
|
Latitude = request.Latitude,
|
|
Longitude = request.Longitude,
|
|
AccuracyMeters = request.AccuracyMeters,
|
|
DistanceMeters = distanceMeters,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
}
|
|
|
|
private static string? HashDeviceIdentifier(string? deviceId)
|
|
{
|
|
var normalized = Normalize(deviceId)?.ToLowerInvariant();
|
|
return normalized is null
|
|
? null
|
|
: Convert.ToHexString(
|
|
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
|
}
|
|
|
|
private static IEnumerable<string> ParseRiskFlags(string? value) =>
|
|
string.IsNullOrWhiteSpace(value)
|
|
? []
|
|
: value.Split(
|
|
',',
|
|
StringSplitOptions.RemoveEmptyEntries |
|
|
StringSplitOptions.TrimEntries);
|
|
|
|
private static string? Limit(string? value, int maximumLength) =>
|
|
value is null || value.Length <= maximumLength
|
|
? value
|
|
: value[..maximumLength];
|
|
|
|
private static bool IsCheckInOpen(
|
|
AttendanceSheetStatus status,
|
|
AttendanceCheckInMethod method,
|
|
DateTime? startsAt,
|
|
DateTime? endsAt,
|
|
DateTime now) =>
|
|
status == AttendanceSheetStatus.Draft &&
|
|
method != AttendanceCheckInMethod.Manual &&
|
|
startsAt.HasValue &&
|
|
endsAt.HasValue &&
|
|
startsAt.Value <= now &&
|
|
endsAt.Value >= now;
|
|
|
|
internal static double CalculateDistanceMeters(
|
|
double latitude1,
|
|
double longitude1,
|
|
double latitude2,
|
|
double longitude2)
|
|
{
|
|
const double earthRadiusMeters = 6_371_000;
|
|
var latitudeDelta = DegreesToRadians(latitude2 - latitude1);
|
|
var longitudeDelta = DegreesToRadians(longitude2 - longitude1);
|
|
var startLatitude = DegreesToRadians(latitude1);
|
|
var endLatitude = DegreesToRadians(latitude2);
|
|
var haversine =
|
|
Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) +
|
|
Math.Cos(startLatitude) * Math.Cos(endLatitude) *
|
|
Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2);
|
|
return earthRadiusMeters * 2 *
|
|
Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine));
|
|
}
|
|
|
|
private static double DegreesToRadians(double degrees) =>
|
|
degrees * Math.PI / 180;
|
|
|
|
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 TeachingTaskRosterQuery
|
|
.ForTask(db, teachingTaskId)
|
|
.AsNoTracking()
|
|
.Select(x => new AttendanceStatisticsStudentIdentity(
|
|
x.Id,
|
|
x.StudentNumber,
|
|
x.Name,
|
|
x.AdministrativeClass!.Name))
|
|
.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,
|
|
AttendanceCheckInMethod? CheckInMethod,
|
|
[Range(1, 180)] int? CheckInDurationMinutes,
|
|
[Range(-90, 90)] decimal? TargetLatitude,
|
|
[Range(-180, 180)] decimal? TargetLongitude,
|
|
[Range(20, 1000)] int? LocationRadiusMeters);
|
|
|
|
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 AttendanceCheckInRequest(
|
|
Guid? AttendanceSheetId,
|
|
[MaxLength(64)] string? Token,
|
|
[Range(-90, 90)] decimal? Latitude,
|
|
[Range(-180, 180)] decimal? Longitude,
|
|
[Range(0, 5000)] double? AccuracyMeters,
|
|
[MaxLength(128)] string? DeviceId = null,
|
|
[MaxLength(32)] string? DevicePlatform = null);
|
|
|
|
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);
|