已完成,学生端考勤记录现在按课程展示。
每门课程会独立显示: 课程名称、代码、教学班和任课教师。 课程总体出勤率。 出勤、迟到、缺勤、请假、免修次数。 按日期排列的历次点名记录。 缺勤或迟到记录仍可单独申诉。
This commit is contained in:
1 parent
72297aa23f
commit
3b73292d93
12 files changed
+2159
-41
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
@@ -76,8 +77,12 @@ public sealed class AttendanceController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.AttendanceDate,
|
x.AttendanceDate,
|
||||||
x.Status,
|
x.Status,
|
||||||
|
x.CheckInMethod,
|
||||||
|
x.CheckInStartsAt,
|
||||||
|
x.CheckInEndsAt,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
|
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
|
||||||
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
|
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
|
||||||
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
|
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
|
||||||
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
|
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
|
||||||
@@ -109,20 +114,71 @@ public sealed class AttendanceController(
|
|||||||
if (studentIds.Count == 0)
|
if (studentIds.Count == 0)
|
||||||
return ConflictProblem("该教学班没有有效选课学生。");
|
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
|
var sheet = new AttendanceSheet
|
||||||
{
|
{
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
AttendanceDate = request.AttendanceDate,
|
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),
|
Notes = Normalize(request.Notes),
|
||||||
Records = studentIds.Select(studentId => new AttendanceRecord
|
Records = studentIds.Select(studentId => new AttendanceRecord
|
||||||
{
|
{
|
||||||
StudentId = studentId
|
StudentId = studentId,
|
||||||
|
Status = checkInMethod == AttendanceCheckInMethod.Manual
|
||||||
|
? AttendanceStatus.Present
|
||||||
|
: AttendanceStatus.Absent
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
db.AttendanceSheets.Add(sheet);
|
db.AttendanceSheets.Add(sheet);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Created(string.Empty, new { sheet.Id });
|
return Created(string.Empty, new
|
||||||
|
{
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sheets/{id:guid}")]
|
[HttpGet("sheets/{id:guid}")]
|
||||||
@@ -138,6 +194,13 @@ public sealed class AttendanceController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.AttendanceDate,
|
x.AttendanceDate,
|
||||||
x.Status,
|
x.Status,
|
||||||
|
x.CheckInMethod,
|
||||||
|
x.CheckInToken,
|
||||||
|
x.CheckInStartsAt,
|
||||||
|
x.CheckInEndsAt,
|
||||||
|
x.TargetLatitude,
|
||||||
|
x.TargetLongitude,
|
||||||
|
x.LocationRadiusMeters,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
TaskNumber = x.TeachingTask!.TaskNumber,
|
TaskNumber = x.TeachingTask!.TaskNumber,
|
||||||
@@ -153,7 +216,11 @@ public sealed class AttendanceController(
|
|||||||
r.Student.Name,
|
r.Student.Name,
|
||||||
ClassName = r.Student.AdministrativeClass!.Name,
|
ClassName = r.Student.AdministrativeClass!.Name,
|
||||||
r.Status,
|
r.Status,
|
||||||
r.Notes
|
r.Notes,
|
||||||
|
r.CheckInAt,
|
||||||
|
r.CheckedInMethod,
|
||||||
|
r.CheckInAccuracyMeters,
|
||||||
|
r.CheckInDistanceMeters
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
@@ -170,10 +237,11 @@ 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 canManage = await CanManageTaskAsync(
|
||||||
await CanManageTaskAsync(
|
|
||||||
sheet.TeachingTaskId,
|
sheet.TeachingTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Sheet = new
|
Sheet = new
|
||||||
@@ -183,6 +251,20 @@ public sealed class AttendanceController(
|
|||||||
sheet.Name,
|
sheet.Name,
|
||||||
sheet.AttendanceDate,
|
sheet.AttendanceDate,
|
||||||
sheet.Status,
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
CheckInToken = canManage ? sheet.CheckInToken : null,
|
||||||
|
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.Notes,
|
||||||
sheet.SubmittedAt,
|
sheet.SubmittedAt,
|
||||||
sheet.TaskNumber,
|
sheet.TaskNumber,
|
||||||
@@ -197,6 +279,10 @@ public sealed class AttendanceController(
|
|||||||
r.ClassName,
|
r.ClassName,
|
||||||
r.Status,
|
r.Status,
|
||||||
r.Notes,
|
r.Notes,
|
||||||
|
r.CheckInAt,
|
||||||
|
r.CheckedInMethod,
|
||||||
|
r.CheckInAccuracyMeters,
|
||||||
|
r.CheckInDistanceMeters,
|
||||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||||
})
|
})
|
||||||
@@ -387,8 +473,231 @@ public sealed class AttendanceController(
|
|||||||
return NoContent();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════ Student endpoints ═══════════════
|
// ═══════════════ 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();
|
||||||
|
|
||||||
|
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == studentId.Value &&
|
||||||
|
x.AttendanceSheet!.CheckInToken == token.Trim())
|
||||||
|
.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,
|
||||||
|
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;
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
var token = request.Token.Trim();
|
||||||
|
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
if (record.CheckInAt.HasValue)
|
||||||
|
{
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
AlreadyCheckedIn = true,
|
||||||
|
record.CheckInAt,
|
||||||
|
record.CheckInDistanceMeters
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return ConflictProblem("签到尚未开始或已经结束。");
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||||
|
return ConflictProblem("该考勤表不支持学生在线签到。");
|
||||||
|
|
||||||
|
double? distanceMeters = null;
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Token) ||
|
||||||
|
!string.Equals(
|
||||||
|
sheet.CheckInToken,
|
||||||
|
request.Token.Trim(),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
else 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 ConflictProblem("未获取到有效的当前位置。");
|
||||||
|
if (sheet.TargetLatitude is null ||
|
||||||
|
sheet.TargetLongitude is null ||
|
||||||
|
sheet.LocationRadiusMeters is null)
|
||||||
|
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
||||||
|
|
||||||
|
distanceMeters = CalculateDistanceMeters(
|
||||||
|
(double)sheet.TargetLatitude.Value,
|
||||||
|
(double)sheet.TargetLongitude.Value,
|
||||||
|
(double)request.Latitude.Value,
|
||||||
|
(double)request.Longitude.Value);
|
||||||
|
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
AlreadyCheckedIn = false,
|
||||||
|
record.CheckInAt,
|
||||||
|
record.CheckInDistanceMeters
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("my-records")]
|
[HttpGet("my-records")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetMyRecords(
|
public async Task<ActionResult> GetMyRecords(
|
||||||
@@ -415,6 +724,7 @@ public sealed class AttendanceController(
|
|||||||
.Select(r => new
|
.Select(r => new
|
||||||
{
|
{
|
||||||
r.AttendanceSheetId,
|
r.AttendanceSheetId,
|
||||||
|
r.AttendanceSheet!.TeachingTaskId,
|
||||||
SheetName = r.AttendanceSheet!.Name,
|
SheetName = r.AttendanceSheet!.Name,
|
||||||
r.AttendanceSheet.AttendanceDate,
|
r.AttendanceSheet.AttendanceDate,
|
||||||
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
|
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
|
||||||
@@ -660,6 +970,51 @@ public sealed class AttendanceController(
|
|||||||
cancellationToken);
|
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 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(
|
private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
|
||||||
Guid teachingTaskId,
|
Guid teachingTaskId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -993,7 +1348,12 @@ public sealed record AttendanceSheetRequest(
|
|||||||
Guid TeachingTaskId,
|
Guid TeachingTaskId,
|
||||||
[MaxLength(120)] string Name,
|
[MaxLength(120)] string Name,
|
||||||
DateTime AttendanceDate,
|
DateTime AttendanceDate,
|
||||||
[MaxLength(500)] string? Notes);
|
[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(
|
public sealed record AttendanceRecordsRequest(
|
||||||
IReadOnlyCollection<AttendanceRecordRequest> Records);
|
IReadOnlyCollection<AttendanceRecordRequest> Records);
|
||||||
@@ -1011,6 +1371,13 @@ public sealed record AttendanceAppealReviewRequest(
|
|||||||
bool Approve,
|
bool Approve,
|
||||||
[MaxLength(300)] string? Comment);
|
[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);
|
||||||
|
|
||||||
public sealed record AttendanceCourseStatistics(
|
public sealed record AttendanceCourseStatistics(
|
||||||
AttendanceStatisticsCourse Course,
|
AttendanceStatisticsCourse Course,
|
||||||
AttendanceStatisticsSummary Summary,
|
AttendanceStatisticsSummary Summary,
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ public sealed class AttendanceSheet : EntityBase
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public DateTime AttendanceDate { get; set; }
|
public DateTime AttendanceDate { get; set; }
|
||||||
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
|
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
|
||||||
|
public AttendanceCheckInMethod CheckInMethod { get; set; } =
|
||||||
|
AttendanceCheckInMethod.Manual;
|
||||||
|
public string? CheckInToken { get; set; }
|
||||||
|
public DateTime? CheckInStartsAt { get; set; }
|
||||||
|
public DateTime? CheckInEndsAt { get; set; }
|
||||||
|
public decimal? TargetLatitude { get; set; }
|
||||||
|
public decimal? TargetLongitude { get; set; }
|
||||||
|
public int? LocationRadiusMeters { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public DateTime? SubmittedAt { get; set; }
|
public DateTime? SubmittedAt { get; set; }
|
||||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||||
@@ -22,6 +30,12 @@ public sealed class AttendanceRecord
|
|||||||
public Student? Student { get; set; }
|
public Student? Student { get; set; }
|
||||||
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
|
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public DateTime? CheckInAt { get; set; }
|
||||||
|
public AttendanceCheckInMethod? CheckedInMethod { get; set; }
|
||||||
|
public decimal? CheckInLatitude { get; set; }
|
||||||
|
public decimal? CheckInLongitude { get; set; }
|
||||||
|
public double? CheckInAccuracyMeters { get; set; }
|
||||||
|
public double? CheckInDistanceMeters { get; set; }
|
||||||
public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None;
|
public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None;
|
||||||
public string? AppealReason { get; set; }
|
public string? AppealReason { get; set; }
|
||||||
public DateTime? AppealSubmittedAt { get; set; }
|
public DateTime? AppealSubmittedAt { get; set; }
|
||||||
@@ -44,6 +58,13 @@ public enum AttendanceStatus
|
|||||||
Excused = 5
|
Excused = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum AttendanceCheckInMethod
|
||||||
|
{
|
||||||
|
Manual = 1,
|
||||||
|
QrCode = 2,
|
||||||
|
Location = 3
|
||||||
|
}
|
||||||
|
|
||||||
public enum AttendanceAppealStatus
|
public enum AttendanceAppealStatus
|
||||||
{
|
{
|
||||||
None = 0,
|
None = 0,
|
||||||
|
|||||||
@@ -561,8 +561,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
builder.Entity<AttendanceSheet>(entity =>
|
builder.Entity<AttendanceSheet>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
entity.Property(x => x.CheckInToken).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.TargetLatitude).HasPrecision(10, 7);
|
||||||
|
entity.Property(x => x.TargetLongitude).HasPrecision(10, 7);
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
|
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
|
||||||
|
entity.HasIndex(x => x.CheckInToken).IsUnique();
|
||||||
entity.HasOne(x => x.TeachingTask)
|
entity.HasOne(x => x.TeachingTask)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.TeachingTaskId)
|
.HasForeignKey(x => x.TeachingTaskId)
|
||||||
@@ -575,6 +579,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Notes).HasMaxLength(300);
|
entity.Property(x => x.Notes).HasMaxLength(300);
|
||||||
entity.Property(x => x.AppealReason).HasMaxLength(500);
|
entity.Property(x => x.AppealReason).HasMaxLength(500);
|
||||||
entity.Property(x => x.AppealReviewComment).HasMaxLength(300);
|
entity.Property(x => x.AppealReviewComment).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.CheckInLatitude).HasPrecision(10, 7);
|
||||||
|
entity.Property(x => x.CheckInLongitude).HasPrecision(10, 7);
|
||||||
entity.HasIndex(x => x.AppealStatus);
|
entity.HasIndex(x => x.AppealStatus);
|
||||||
entity.HasOne(x => x.AttendanceSheet)
|
entity.HasOne(x => x.AttendanceSheet)
|
||||||
.WithMany(x => x.Records)
|
.WithMany(x => x.Records)
|
||||||
|
|||||||
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user