学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。

毕业审核:批次计算、缺失课程检查、人工复核、结果发布。
学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。
毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
This commit is contained in:
2026-07-24 17:30:05 +08:00 Unverified
parent 7493ab4a60
commit b0eaa20da6
34 changed files with 11137 additions and 8 deletions
@@ -51,6 +51,31 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
StudentStatusChanges = await db.StudentStatusChanges
.CountAsync(cancellationToken),
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
x => x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved,
cancellationToken),
GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken),
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(
x => x.Status == GraduationAuditBatchStatus.Published,
cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published,
cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,278 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/degree-awards")]
public sealed class DegreeAwardsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Reviewers = Managers + "," + SystemRoles.CollegeAdmin;
[HttpGet("batches")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var collegeId = RestrictedCollegeId();
return Ok(await db.DegreeAwardBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.DegreeName,
x.MinimumGradePoint, x.Status, x.Notes,
x.CalculatedAt, x.PublishedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
GrantedCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == DegreeAwardConclusion.Granted),
NotGrantedCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == DegreeAwardConclusion.NotGranted),
OverrideCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.IsOverridden)
}).ToListAsync(token));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var collegeId = RestrictedCollegeId();
var source = db.DegreeAwardResults.AsNoTracking()
.Where(x => x.DegreeAwardBatchId == id);
if (collegeId.HasValue)
source = source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName,
batch.MinimumGradePoint, batch.Status, batch.Notes,
batch.CalculatedAt, batch.PublishedAt,
Results = await source.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion,
x.ExceptionReason, x.IsOverridden,
x.ReviewComment, x.ReviewedAt
}).ToListAsync(token)
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(
DegreeAwardBatchRequest request,
CancellationToken token)
{
var batch = new DegreeAwardBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
DegreeName = request.DegreeName.Trim(),
MinimumGradePoint = request.MinimumGradePoint,
Notes = request.Notes?.Trim()
};
db.DegreeAwardBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/calculate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("已发布批次不能重新计算。");
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.ToListAsync(token);
var audits = auditCandidates
.GroupBy(x => x.StudentId)
.Select(x => x.First())
.ToList();
var studentIds = audits.Select(x => x.StudentId).ToArray();
var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue)
.Select(x => new
{
x.StudentId,
GradePoint = x.GradePoint!.Value,
x.GradeSheet!.TeachingTask!.Course!.Credits
}).ToListAsync(token);
var oldResults = await db.DegreeAwardResults
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
db.DegreeAwardResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var audit in audits)
{
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
var credits = grades.Sum(x => x.Credits);
var average = credits == 0
? 0
: Math.Round(
grades.Sum(x => x.GradePoint * x.Credits) / credits,
2,
MidpointRounding.AwayFromZero);
var conclusion = DegreeAwardRules.Evaluate(
true, audit.Student!.Status, average, batch.MinimumGradePoint);
var reason = audit.Student.Status != StudentStatus.Graduated
? "学籍状态尚未转为毕业"
: average < batch.MinimumGradePoint
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
: string.Empty;
db.DegreeAwardResults.Add(new DegreeAwardResult
{
DegreeAwardBatchId = batch.Id,
StudentId = audit.StudentId,
GraduationAuditResultId = audit.Id,
AverageGradePoint = average,
CalculatedConclusion = conclusion,
Conclusion = conclusion,
ExceptionReason = reason
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = audits.Count });
}
[HttpPut("results/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Review(
Guid id,
DegreeAwardDecisionRequest request,
CancellationToken token)
{
var result = await db.DegreeAwardResults
.Include(x => x.DegreeAwardBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (result is null) return NotFound();
if (result.DegreeAwardBatch!.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("已发布的授予结果不能修改。");
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue &&
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
return Forbid();
result.Conclusion = request.Conclusion;
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
result.ReviewComment = request.Comment.Trim();
result.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches.Include(x => x.Results)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("该批次已经发布。");
if (batch.Results.Count == 0)
return ConflictProblem("没有可发布的学位授予审核结果。");
batch.Status = DegreeAwardBatchStatus.Published;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-result")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyResult(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var result = await db.DegreeAwardResults.AsNoTracking()
.Where(x => x.Student!.UserId == userId &&
x.DegreeAwardBatch!.Status == DegreeAwardBatchStatus.Published)
.OrderByDescending(x => x.DegreeAwardBatch!.GraduationYear)
.ThenByDescending(x => x.DegreeAwardBatch!.PublishedAt)
.Select(x => new
{
x.Id,
BatchName = x.DegreeAwardBatch!.Name,
x.DegreeAwardBatch.GraduationYear,
x.DegreeAwardBatch.DegreeName,
x.DegreeAwardBatch.MinimumGradePoint,
x.Student!.StudentNumber, x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
x.IsOverridden, x.ReviewComment,
x.DegreeAwardBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
}
private Guid? RestrictedCollegeId()
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.CollegeAdmin)
? scope.CollegeId ?? Guid.Empty
: null;
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学位授予操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record DegreeAwardBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[Required, MinLength(2), MaxLength(80)] string DegreeName,
[Range(typeof(decimal), "0", "5")] decimal MinimumGradePoint,
[MaxLength(500)] string? Notes);
public sealed record DegreeAwardDecisionRequest(
DegreeAwardConclusion Conclusion,
[Required, MinLength(5), MaxLength(500)] string Comment);
@@ -0,0 +1,332 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/graduation-audits")]
public sealed class GraduationAuditsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Reviewers =
Managers + "," + SystemRoles.CollegeAdmin;
[HttpGet("batches")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var collegeId = RestrictedCollegeId();
return Ok(await db.GraduationAuditBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status,
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
EligibleCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == GraduationAuditConclusion.Eligible),
IneligibleCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == GraduationAuditConclusion.Ineligible),
OverrideCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.IsOverridden)
}).ToListAsync(token));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var collegeId = RestrictedCollegeId();
var results = db.GraduationAuditResults.AsNoTracking()
.Where(x => x.GraduationAuditBatchId == id);
if (collegeId.HasValue)
results = results.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt,
Results = await results
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.CalculatedConclusion, x.Conclusion, x.IsOverridden,
x.ReviewComment, x.ReviewedAt
}).ToListAsync(token)
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(
GraduationAuditBatchRequest request,
CancellationToken token)
{
if (request.EnrollmentYear > request.GraduationYear)
return ValidationProblem("入学年级不能晚于毕业年份。");
var batch = new GraduationAuditBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
EnrollmentYear = request.EnrollmentYear,
Notes = request.Notes?.Trim()
};
db.GraduationAuditBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/calculate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("已发布批次不能重新计算。");
var students = await db.Students.AsNoTracking()
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x => x.EnrollmentYear == batch.EnrollmentYear &&
(x.Status == StudentStatus.Active ||
x.Status == StudentStatus.Suspended))
.OrderBy(x => x.StudentNumber)
.ToListAsync(token);
var plans = await db.CurriculumPlans.AsNoTracking()
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(token);
var studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new GradeSnapshot(
x.StudentId,
x.GradeSheet!.TeachingTask!.CourseId,
x.GradeSheet.TeachingTask.Course!.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.ExamStatus))
.ToListAsync(token);
var oldResults = await db.GraduationAuditResults
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
db.GraduationAuditResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var student in students)
{
var plan = plans.FirstOrDefault(x =>
x.MajorId == student.AdministrativeClass!.MajorId);
var requiredCourses = plan?.Modules
.SelectMany(x => x.Courses)
.Where(x => x.Type == CurriculumCourseType.Required)
.ToList() ?? [];
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
var passedCourseIds = studentGrades
.Where(IsPassed)
.Select(x => x.CourseId)
.Distinct()
.ToHashSet();
var earnedCredits = studentGrades
.Where(IsPassed)
.GroupBy(x => x.CourseId)
.Sum(x => x.Max(item => item.Credits));
var missingCourses = requiredCourses
.Where(x => !passedCourseIds.Contains(x.CourseId))
.Select(x => x.Course!.Name)
.Distinct()
.ToArray();
var missingCourseNames = plan is null
? "未匹配已发布的培养方案"
: string.Join("、", missingCourses);
if (missingCourseNames.Length > 2000)
missingCourseNames = missingCourseNames[..2000];
var failedCourseCount = studentGrades
.GroupBy(x => x.CourseId)
.Count(x => !x.Any(IsPassed));
var conclusion = GraduationAuditRules.Evaluate(
plan is not null,
student.Status,
plan?.TotalCredits ?? 0,
earnedCredits,
requiredCourses.Count,
requiredCourses.Count - missingCourses.Length,
failedCourseCount);
db.GraduationAuditResults.Add(new GraduationAuditResult
{
GraduationAuditBatchId = batch.Id,
StudentId = student.Id,
CurriculumPlanId = plan?.Id,
StudentStatusSnapshot = student.Status,
RequiredCredits = plan?.TotalCredits ?? 0,
EarnedCredits = earnedCredits,
RequiredCourseCount = requiredCourses.Count,
PassedRequiredCourseCount = requiredCourses.Count - missingCourses.Length,
FailedCourseCount = failedCourseCount,
MissingCourseNames = missingCourseNames,
CalculatedConclusion = conclusion,
Conclusion = conclusion
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = students.Count });
}
[HttpPut("results/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> ReviewResult(
Guid id,
GraduationAuditDecisionRequest request,
CancellationToken token)
{
var result = await db.GraduationAuditResults
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (result is null) return NotFound();
if (result.GraduationAuditBatch!.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("已发布的审核结果不能修改。");
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue &&
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
return Forbid();
result.Conclusion = request.Conclusion;
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
result.ReviewComment = request.Comment.Trim();
result.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches
.Include(x => x.Results)
.ThenInclude(x => x.Student)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("该批次已经发布。");
if (batch.Results.Count == 0)
return ConflictProblem("请先计算毕业资格,再发布结果。");
batch.Status = GraduationAuditBatchStatus.Published;
batch.PublishedAt = DateTime.UtcNow;
foreach (var result in batch.Results.Where(x =>
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Active))
result.Student!.Status = StudentStatus.Graduated;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-result")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyResult(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var result = await db.GraduationAuditResults.AsNoTracking()
.Where(x => x.Student!.UserId == userId &&
x.GraduationAuditBatch!.Status ==
GraduationAuditBatchStatus.Published)
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new
{
x.Id,
BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear,
x.Student!.StudentNumber, x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.Conclusion, x.IsOverridden, x.ReviewComment,
x.GraduationAuditBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
}
private Guid? RestrictedCollegeId()
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.CollegeAdmin)
? scope.CollegeId ?? Guid.Empty
: null;
}
private static bool IsPassed(GradeSnapshot grade) =>
grade.ExamStatus == GradeExamStatus.Exempt ||
grade.TotalScore is decimal score && score >= 60;
private sealed record GradeSnapshot(
Guid StudentId,
Guid CourseId,
string CourseName,
decimal Credits,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成毕业审核操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record GraduationAuditBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[Range(2000, 2200)] int EnrollmentYear,
[MaxLength(500)] string? Notes);
public sealed record GraduationAuditDecisionRequest(
GraduationAuditConclusion Conclusion,
[Required, MinLength(5), MaxLength(500)] string Comment);
@@ -0,0 +1,318 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/graduation-clearance")]
public sealed class GraduationClearanceController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Workers =
Managers + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor;
[HttpGet("batches")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var batches = await db.GraduationClearanceBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
}).ToListAsync(token);
var records = await ScopedRecords().AsNoTracking()
.Select(x => new
{
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
x.StudentId, x.Status
}).ToListAsync(token);
return Ok(batches.Select(batch =>
{
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
return new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
RecordCount = scoped.Count,
CompletedCount = scoped.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
};
}));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status,
x.Notes, x.ClosedAt, x.CreatedAt,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id, item.Code, item.Name, item.ResponsibleUnit,
item.ResponsibleRole, item.IsRequired, item.SortOrder
})
}).FirstOrDefaultAsync(token);
if (batch is null) return NotFound();
var records = await ScopedRecords().AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.OrderBy(x => x.Student!.StudentNumber)
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
ItemId = x.GraduationClearanceItemId,
ItemName = x.GraduationClearanceItem!.Name,
x.GraduationClearanceItem.ResponsibleUnit,
x.GraduationClearanceItem.ResponsibleRole,
x.GraduationClearanceItem.IsRequired,
x.Status, x.Notes, x.CompletedAt
}).ToListAsync(token);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
Records = records
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(
GraduationClearanceBatchRequest request,
CancellationToken token)
{
var allowedRoles = new[]
{
SystemRoles.AcademicAdmin,
SystemRoles.CollegeAdmin,
SystemRoles.Counselor
};
if (request.Items.Count == 0)
return ValidationProblem("至少配置一个离校事项。");
if (request.Items.Select(x => x.Code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Items.Count)
return ValidationProblem("离校事项编码不能重复。");
if (request.Items.Any(x => !allowedRoles.Contains(
x.ResponsibleRole, StringComparer.OrdinalIgnoreCase)))
return ValidationProblem("离校事项责任角色无效。");
var batch = new GraduationClearanceBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
Notes = request.Notes?.Trim(),
Items = request.Items.Select((item, index) =>
new GraduationClearanceItem
{
Code = item.Code.Trim(),
Name = item.Name.Trim(),
ResponsibleUnit = item.ResponsibleUnit.Trim(),
ResponsibleRole = item.ResponsibleRole,
IsRequired = item.IsRequired,
SortOrder = index + 1
}).ToList()
};
db.GraduationClearanceBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/generate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Generate(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("已关闭批次不能重新生成办理记录。");
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Graduated)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new { x.StudentId })
.ToListAsync(token);
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
var existing = await db.GraduationClearanceRecords.AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
.ToListAsync(token);
var existingKeys = existing
.Select(x => (x.GraduationClearanceItemId, x.StudentId))
.ToHashSet();
var created = 0;
foreach (var item in batch.Items)
foreach (var studentId in studentIds)
{
if (existingKeys.Contains((item.Id, studentId))) continue;
db.GraduationClearanceRecords.Add(new GraduationClearanceRecord
{
GraduationClearanceItemId = item.Id,
StudentId = studentId
});
created++;
}
await db.SaveChangesAsync(token);
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
}
[HttpPut("records/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> UpdateRecord(
Guid id,
GraduationClearanceRecordRequest request,
CancellationToken token)
{
var record = await db.GraduationClearanceRecords
.Include(x => x.GraduationClearanceItem)
.ThenInclude(x => x!.GraduationClearanceBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (record is null) return NotFound();
if (record.GraduationClearanceItem!.GraduationClearanceBatch!.Status !=
GraduationClearanceBatchStatus.Open)
return ConflictProblem("批次已关闭,不能修改办理记录。");
if (!CanManage(record)) return Forbid();
record.Status = request.Status;
record.Notes = request.Notes?.Trim();
record.CompletedAt = request.Status == GraduationClearanceRecordStatus.Pending
? null
: DateTime.UtcNow;
record.CompletedByUserId =
request.Status == GraduationClearanceRecordStatus.Pending
? null
: currentUserDataScope.Current.UserId;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/close")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Close(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.ThenInclude(x => x.Records)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("该批次已经关闭。");
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
(item.IsRequired, record.Status)));
if (!GraduationClearanceRules.CanClose(records))
return ConflictProblem("仍有必办离校事项尚未完成。");
batch.Status = GraduationClearanceBatchStatus.Closed;
batch.ClosedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-clearance")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyClearance(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Items.Any(item =>
item.Records.Any(record => record.Student!.UserId == userId)))
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes, x.ClosedAt,
Items = x.Items.OrderBy(item => item.SortOrder)
.SelectMany(item => item.Records
.Where(record => record.Student!.UserId == userId)
.Select(record => new
{
record.Id, ItemName = item.Name, item.ResponsibleUnit,
item.IsRequired, record.Status,
record.Notes, record.CompletedAt
}))
}).FirstOrDefaultAsync(token);
return Ok(batch);
}
private IQueryable<GraduationClearanceRecord> ScopedRecords()
{
var scope = currentUserDataScope.Current;
var source = db.GraduationClearanceRecords.AsQueryable();
if (scope.IsInRole(SystemRoles.Counselor))
return source.Where(x =>
x.Student!.AdministrativeClass!.CounselorUserId == scope.UserId);
if (scope.IsInRole(SystemRoles.CollegeAdmin))
return source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == scope.CollegeId);
return source;
}
private bool CanManage(GraduationClearanceRecord record)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.SuperAdmin)) return true;
var role = record.GraduationClearanceItem!.ResponsibleRole;
if (role == SystemRoles.AcademicAdmin &&
scope.IsInRole(SystemRoles.AcademicAdmin)) return true;
if (role == SystemRoles.CollegeAdmin &&
scope.IsInRole(SystemRoles.CollegeAdmin))
return record.Student!.AdministrativeClass!.Major!.CollegeId ==
scope.CollegeId;
return role == SystemRoles.Counselor &&
scope.IsInRole(SystemRoles.Counselor) &&
record.Student!.AdministrativeClass!.CounselorUserId == scope.UserId;
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成毕业离校操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record GraduationClearanceBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[MaxLength(500)] string? Notes,
IReadOnlyList<GraduationClearanceItemRequest> Items);
public sealed record GraduationClearanceItemRequest(
[Required, MinLength(2), MaxLength(30)] string Code,
[Required, MinLength(2), MaxLength(100)] string Name,
[Required, MinLength(2), MaxLength(100)] string ResponsibleUnit,
[Required, MaxLength(30)] string ResponsibleRole,
bool IsRequired);
public sealed record GraduationClearanceRecordRequest(
GraduationClearanceRecordStatus Status,
[MaxLength(500)] string? Notes);
@@ -30,6 +30,37 @@ public sealed class StudentStatusChangesController(
}).ToListAsync(token));
}
[HttpGet("options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetOptions(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == userId, token);
if (student is null) return ConflictProblem("当前账号未关联学生档案。");
var types = student.Status switch
{
StudentStatus.Active => new[]
{
StudentStatusChangeType.Suspension,
StudentStatusChangeType.Withdrawal
},
StudentStatus.Suspended => new[]
{
StudentStatusChangeType.Resumption,
StudentStatusChangeType.Withdrawal
},
_ => []
};
var hasPending = await db.StudentStatusChanges.AnyAsync(x =>
x.StudentId == student.Id &&
(x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved), token);
return Ok(new { student.Status, Types = types, HasPending = hasPending });
}
[HttpPost]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Create(
@@ -76,14 +107,19 @@ public sealed class StudentStatusChangesController(
.FirstOrDefaultAsync(x => x.Id == id, token);
if (change is null) return NotFound();
var scope = currentUserDataScope.Current;
if (!CanReviewCurrentStage(scope, change.State))
return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
if (!request.Approved)
{
if (change.State is StudentStatusChangeState.Approved or
StudentStatusChangeState.Rejected or StudentStatusChangeState.Cancelled)
return ConflictProblem("该申请已经结束。");
if (string.IsNullOrWhiteSpace(request.Comment))
return BadRequest(new ProblemDetails
{
Title = "审核意见不完整",
Detail = "驳回申请时必须填写审核意见。",
Status = StatusCodes.Status400BadRequest
});
change.State = StudentStatusChangeState.Rejected;
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
}
else if (scope.IsInRole(SystemRoles.Counselor) &&
change.State == StudentStatusChangeState.Submitted)
@@ -100,12 +136,30 @@ public sealed class StudentStatusChangesController(
change.ApprovedAt = DateTime.UtcNow;
}
else return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("{id:guid}/cancel")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Cancel(Guid id, CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var change = await db.StudentStatusChanges.FirstOrDefaultAsync(
x => x.Id == id && x.Student!.UserId == userId, token);
if (change is null) return NotFound();
if (change.State != StudentStatusChangeState.Submitted)
return ConflictProblem("只有尚未进入审核的申请可以撤回。");
change.State = StudentStatusChangeState.Cancelled;
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
private IQueryable<StudentStatusChange> ScopedChanges()
{
var scope = currentUserDataScope.Current;
@@ -123,6 +177,21 @@ public sealed class StudentStatusChangesController(
return source.Where(_ => false);
}
private static bool CanReviewCurrentStage(
CurrentUserScope scope,
StudentStatusChangeState state) =>
state switch
{
StudentStatusChangeState.Submitted =>
scope.IsInRole(SystemRoles.Counselor),
StudentStatusChangeState.CounselorApproved =>
scope.IsInRole(SystemRoles.CollegeAdmin),
StudentStatusChangeState.CollegeApproved =>
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.SuperAdmin),
_ => false
};
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学籍异动操作", Detail = detail,
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class DegreeAwardBatch : EntityBase
{
public required string Name { get; set; }
public int GraduationYear { get; set; }
public required string DegreeName { get; set; }
public decimal MinimumGradePoint { get; set; } = 2.0m;
public DegreeAwardBatchStatus Status { get; set; } = DegreeAwardBatchStatus.Draft;
public string? Notes { get; set; }
public DateTime? CalculatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<DegreeAwardResult> Results { get; set; } = [];
}
public sealed class DegreeAwardResult : EntityBase
{
public Guid DegreeAwardBatchId { get; set; }
public DegreeAwardBatch? DegreeAwardBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid GraduationAuditResultId { get; set; }
public GraduationAuditResult? GraduationAuditResult { get; set; }
public decimal AverageGradePoint { get; set; }
public DegreeAwardConclusion CalculatedConclusion { get; set; }
public DegreeAwardConclusion Conclusion { get; set; }
public required string ExceptionReason { get; set; }
public bool IsOverridden { get; set; }
public string? ReviewComment { get; set; }
public DateTime? ReviewedAt { get; set; }
}
public enum DegreeAwardBatchStatus
{
Draft = 1,
Published = 2
}
public enum DegreeAwardConclusion
{
NotGranted = 1,
Granted = 2
}
@@ -0,0 +1,50 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class GraduationAuditBatch : EntityBase
{
public required string Name { get; set; }
public int GraduationYear { get; set; }
public int EnrollmentYear { get; set; }
public GraduationAuditBatchStatus Status { get; set; } =
GraduationAuditBatchStatus.Draft;
public string? Notes { get; set; }
public DateTime? CalculatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<GraduationAuditResult> Results { get; set; } = [];
}
public sealed class GraduationAuditResult : EntityBase
{
public Guid GraduationAuditBatchId { get; set; }
public GraduationAuditBatch? GraduationAuditBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid? CurriculumPlanId { get; set; }
public CurriculumPlan? CurriculumPlan { get; set; }
public StudentStatus StudentStatusSnapshot { get; set; }
public decimal RequiredCredits { get; set; }
public decimal EarnedCredits { get; set; }
public int RequiredCourseCount { get; set; }
public int PassedRequiredCourseCount { get; set; }
public int FailedCourseCount { get; set; }
public required string MissingCourseNames { get; set; }
public GraduationAuditConclusion CalculatedConclusion { get; set; }
public GraduationAuditConclusion Conclusion { get; set; }
public bool IsOverridden { get; set; }
public string? ReviewComment { get; set; }
public DateTime? ReviewedAt { get; set; }
}
public enum GraduationAuditBatchStatus
{
Draft = 1,
Published = 2
}
public enum GraduationAuditConclusion
{
Ineligible = 1,
Eligible = 2
}
@@ -0,0 +1,53 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class GraduationClearanceBatch : EntityBase
{
public required string Name { get; set; }
public int GraduationYear { get; set; }
public GraduationClearanceBatchStatus Status { get; set; } =
GraduationClearanceBatchStatus.Open;
public string? Notes { get; set; }
public DateTime? ClosedAt { get; set; }
public ICollection<GraduationClearanceItem> Items { get; set; } = [];
}
public sealed class GraduationClearanceItem : EntityBase
{
public Guid GraduationClearanceBatchId { get; set; }
public GraduationClearanceBatch? GraduationClearanceBatch { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public required string ResponsibleUnit { get; set; }
public required string ResponsibleRole { get; set; }
public bool IsRequired { get; set; } = true;
public int SortOrder { get; set; }
public ICollection<GraduationClearanceRecord> Records { get; set; } = [];
}
public sealed class GraduationClearanceRecord : EntityBase
{
public Guid GraduationClearanceItemId { get; set; }
public GraduationClearanceItem? GraduationClearanceItem { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public GraduationClearanceRecordStatus Status { get; set; } =
GraduationClearanceRecordStatus.Pending;
public string? Notes { get; set; }
public DateTime? CompletedAt { get; set; }
public Guid? CompletedByUserId { get; set; }
}
public enum GraduationClearanceBatchStatus
{
Open = 1,
Closed = 2
}
public enum GraduationClearanceRecordStatus
{
Pending = 1,
Completed = 2,
Waived = 3
}
@@ -0,0 +1,17 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public static class DegreeAwardRules
{
public static DegreeAwardConclusion Evaluate(
bool graduationEligible,
StudentStatus studentStatus,
decimal averageGradePoint,
decimal minimumGradePoint) =>
graduationEligible &&
studentStatus == StudentStatus.Graduated &&
averageGradePoint >= minimumGradePoint
? DegreeAwardConclusion.Granted
: DegreeAwardConclusion.NotGranted;
}
@@ -0,0 +1,24 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public static class GraduationAuditRules
{
public static GraduationAuditConclusion Evaluate(
bool hasPublishedPlan,
StudentStatus studentStatus,
decimal requiredCredits,
decimal earnedCredits,
int requiredCourseCount,
int passedRequiredCourseCount,
int failedCourseCount)
{
if (!hasPublishedPlan || studentStatus != StudentStatus.Active)
return GraduationAuditConclusion.Ineligible;
if (earnedCredits < requiredCredits ||
passedRequiredCourseCount < requiredCourseCount ||
failedCourseCount > 0)
return GraduationAuditConclusion.Ineligible;
return GraduationAuditConclusion.Eligible;
}
}
@@ -0,0 +1,16 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public static class GraduationClearanceRules
{
public static bool CanClose(
IEnumerable<(bool IsRequired, GraduationClearanceRecordStatus Status)> records)
{
var snapshot = records.ToArray();
return snapshot.Length > 0 && snapshot
.Where(x => x.IsRequired)
.All(x => x.Status is GraduationClearanceRecordStatus.Completed or
GraduationClearanceRecordStatus.Waived);
}
}
@@ -41,6 +41,18 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<ExamSessionInvigilator>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
Set<GraduationAuditBatch>();
public DbSet<GraduationAuditResult> GraduationAuditResults =>
Set<GraduationAuditResult>();
public DbSet<DegreeAwardBatch> DegreeAwardBatches => Set<DegreeAwardBatch>();
public DbSet<DegreeAwardResult> DegreeAwardResults => Set<DegreeAwardResult>();
public DbSet<GraduationClearanceBatch> GraduationClearanceBatches =>
Set<GraduationClearanceBatch>();
public DbSet<GraduationClearanceItem> GraduationClearanceItems =>
Set<GraduationClearanceItem>();
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
Set<GraduationClearanceRecord>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -396,6 +408,82 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GraduationAuditBatch>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationYear, x.EnrollmentYear });
entity.HasIndex(x => x.Status);
});
builder.Entity<GraduationAuditResult>(entity =>
{
entity.Property(x => x.RequiredCredits).HasPrecision(6, 2);
entity.Property(x => x.EarnedCredits).HasPrecision(6, 2);
entity.Property(x => x.MissingCourseNames).HasMaxLength(2000);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationAuditBatchId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.Conclusion, x.IsOverridden });
entity.HasOne(x => x.GraduationAuditBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.GraduationAuditBatchId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.CurriculumPlan).WithMany()
.HasForeignKey(x => x.CurriculumPlanId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<DegreeAwardBatch>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.DegreeName).HasMaxLength(80);
entity.Property(x => x.MinimumGradePoint).HasPrecision(3, 2);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationYear, x.Status });
});
builder.Entity<DegreeAwardResult>(entity =>
{
entity.Property(x => x.AverageGradePoint).HasPrecision(4, 2);
entity.Property(x => x.ExceptionReason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.DegreeAwardBatchId, x.StudentId }).IsUnique();
entity.HasIndex(x => new { x.Conclusion, x.IsOverridden });
entity.HasOne(x => x.DegreeAwardBatch).WithMany(x => x.Results)
.HasForeignKey(x => x.DegreeAwardBatchId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.GraduationAuditResult).WithMany()
.HasForeignKey(x => x.GraduationAuditResultId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GraduationClearanceBatch>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationYear, x.Status });
});
builder.Entity<GraduationClearanceItem>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.ResponsibleUnit).HasMaxLength(100);
entity.Property(x => x.ResponsibleRole).HasMaxLength(30);
entity.HasIndex(x => new { x.GraduationClearanceBatchId, x.Code }).IsUnique();
entity.HasOne(x => x.GraduationClearanceBatch).WithMany(x => x.Items)
.HasForeignKey(x => x.GraduationClearanceBatchId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<GraduationClearanceRecord>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.GraduationClearanceItemId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasOne(x => x.GraduationClearanceItem).WithMany(x => x.Records)
.HasForeignKey(x => x.GraduationClearanceItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
@@ -15,6 +15,9 @@ public sealed class DevelopmentSqliteMigrator(
private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams";
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -75,6 +78,18 @@ public sealed class DevelopmentSqliteMigrator(
StudentStatusChangesMigration,
StudentStatusChangesStatements,
cancellationToken);
await ApplyMigrationAsync(
GraduationAuditsMigration,
GraduationAuditsStatements,
cancellationToken);
await ApplyMigrationAsync(
DegreeAwardsMigration,
DegreeAwardsStatements,
cancellationToken);
await ApplyMigrationAsync(
GraduationClearanceMigration,
GraduationClearanceStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -637,4 +652,119 @@ public sealed class DevelopmentSqliteMigrator(
""",
"""CREATE INDEX IF NOT EXISTS "IX_StudentStatusChanges_StudentId_State" ON "StudentStatusChanges" ("StudentId", "State");"""
];
private static readonly string[] GraduationAuditsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GraduationAuditBatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationAuditBatches" PRIMARY KEY,
"Name" TEXT NOT NULL, "GraduationYear" INTEGER NOT NULL,
"EnrollmentYear" INTEGER NOT NULL, "Status" INTEGER NOT NULL,
"Notes" TEXT NULL, "CalculatedAt" TEXT NULL, "PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditBatches_GraduationYear_EnrollmentYear" ON "GraduationAuditBatches" ("GraduationYear", "EnrollmentYear");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditBatches_Status" ON "GraduationAuditBatches" ("Status");""",
"""
CREATE TABLE IF NOT EXISTS "GraduationAuditResults" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationAuditResults" PRIMARY KEY,
"GraduationAuditBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"CurriculumPlanId" TEXT NULL, "StudentStatusSnapshot" INTEGER NOT NULL,
"RequiredCredits" TEXT NOT NULL, "EarnedCredits" TEXT NOT NULL,
"RequiredCourseCount" INTEGER NOT NULL,
"PassedRequiredCourseCount" INTEGER NOT NULL,
"FailedCourseCount" INTEGER NOT NULL,
"MissingCourseNames" TEXT NOT NULL,
"CalculatedConclusion" INTEGER NOT NULL, "Conclusion" INTEGER NOT NULL,
"IsOverridden" INTEGER NOT NULL, "ReviewComment" TEXT NULL,
"ReviewedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GraduationAuditResults_GraduationAuditBatches_GraduationAuditBatchId"
FOREIGN KEY ("GraduationAuditBatchId") REFERENCES "GraduationAuditBatches" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GraduationAuditResults_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_GraduationAuditResults_CurriculumPlans_CurriculumPlanId"
FOREIGN KEY ("CurriculumPlanId") REFERENCES "CurriculumPlans" ("Id") ON DELETE SET NULL
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationAuditResults_GraduationAuditBatchId_StudentId" ON "GraduationAuditResults" ("GraduationAuditBatchId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditResults_StudentId" ON "GraduationAuditResults" ("StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditResults_CurriculumPlanId" ON "GraduationAuditResults" ("CurriculumPlanId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationAuditResults_Conclusion_IsOverridden" ON "GraduationAuditResults" ("Conclusion", "IsOverridden");"""
];
private static readonly string[] DegreeAwardsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "DegreeAwardBatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_DegreeAwardBatches" PRIMARY KEY,
"Name" TEXT NOT NULL, "GraduationYear" INTEGER NOT NULL,
"DegreeName" TEXT NOT NULL, "MinimumGradePoint" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL,
"CalculatedAt" TEXT NULL, "PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardBatches_GraduationYear_Status" ON "DegreeAwardBatches" ("GraduationYear", "Status");""",
"""
CREATE TABLE IF NOT EXISTS "DegreeAwardResults" (
"Id" TEXT NOT NULL CONSTRAINT "PK_DegreeAwardResults" PRIMARY KEY,
"DegreeAwardBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"GraduationAuditResultId" TEXT NOT NULL, "AverageGradePoint" TEXT NOT NULL,
"CalculatedConclusion" INTEGER NOT NULL, "Conclusion" INTEGER NOT NULL,
"ExceptionReason" TEXT NOT NULL, "IsOverridden" INTEGER NOT NULL,
"ReviewComment" TEXT NULL, "ReviewedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_DegreeAwardResults_DegreeAwardBatches_DegreeAwardBatchId"
FOREIGN KEY ("DegreeAwardBatchId") REFERENCES "DegreeAwardBatches" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_DegreeAwardResults_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_DegreeAwardResults_GraduationAuditResults_GraduationAuditResultId"
FOREIGN KEY ("GraduationAuditResultId") REFERENCES "GraduationAuditResults" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_DegreeAwardResults_DegreeAwardBatchId_StudentId" ON "DegreeAwardResults" ("DegreeAwardBatchId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardResults_StudentId" ON "DegreeAwardResults" ("StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardResults_GraduationAuditResultId" ON "DegreeAwardResults" ("GraduationAuditResultId");""",
"""CREATE INDEX IF NOT EXISTS "IX_DegreeAwardResults_Conclusion_IsOverridden" ON "DegreeAwardResults" ("Conclusion", "IsOverridden");"""
];
private static readonly string[] GraduationClearanceStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GraduationClearanceBatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationClearanceBatches" PRIMARY KEY,
"Name" TEXT NOT NULL, "GraduationYear" INTEGER NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL, "ClosedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceBatches_GraduationYear_Status" ON "GraduationClearanceBatches" ("GraduationYear", "Status");""",
"""
CREATE TABLE IF NOT EXISTS "GraduationClearanceItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationClearanceItems" PRIMARY KEY,
"GraduationClearanceBatchId" TEXT NOT NULL, "Code" TEXT NOT NULL,
"Name" TEXT NOT NULL, "ResponsibleUnit" TEXT NOT NULL,
"ResponsibleRole" TEXT NOT NULL, "IsRequired" INTEGER NOT NULL,
"SortOrder" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GraduationClearanceItems_GraduationClearanceBatches_GraduationClearanceBatchId"
FOREIGN KEY ("GraduationClearanceBatchId") REFERENCES "GraduationClearanceBatches" ("Id") ON DELETE CASCADE
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceItems_GraduationClearanceBatchId_Code" ON "GraduationClearanceItems" ("GraduationClearanceBatchId", "Code");""",
"""
CREATE TABLE IF NOT EXISTS "GraduationClearanceRecords" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GraduationClearanceRecords" PRIMARY KEY,
"GraduationClearanceItemId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL, "CompletedAt" TEXT NULL,
"CompletedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GraduationClearanceRecords_GraduationClearanceItems_GraduationClearanceItemId"
FOREIGN KEY ("GraduationClearanceItemId") REFERENCES "GraduationClearanceItems" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GraduationClearanceRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_GraduationClearanceItemId_StudentId" ON "GraduationClearanceRecords" ("GraduationClearanceItemId", "StudentId");""",
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_StudentId_Status" ON "GraduationClearanceRecords" ("StudentId", "Status");"""
];
}
@@ -0,0 +1,124 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class GraduationAudits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GraduationAuditBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
GraduationYear = table.Column<int>(type: "int", nullable: false),
EnrollmentYear = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationAuditBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GraduationAuditResults",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationAuditBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
CurriculumPlanId = table.Column<Guid>(type: "char(36)", nullable: true),
StudentStatusSnapshot = table.Column<int>(type: "int", nullable: false),
RequiredCredits = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
EarnedCredits = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
RequiredCourseCount = table.Column<int>(type: "int", nullable: false),
PassedRequiredCourseCount = table.Column<int>(type: "int", nullable: false),
FailedCourseCount = table.Column<int>(type: "int", nullable: false),
MissingCourseNames = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: false),
CalculatedConclusion = table.Column<int>(type: "int", nullable: false),
Conclusion = table.Column<int>(type: "int", nullable: false),
IsOverridden = table.Column<bool>(type: "tinyint(1)", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationAuditResults", x => x.Id);
table.ForeignKey(
name: "FK_GraduationAuditResults_CurriculumPlans_CurriculumPlanId",
column: x => x.CurriculumPlanId,
principalTable: "CurriculumPlans",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_GraduationAuditResults_GraduationAuditBatches_GraduationAudi~",
column: x => x.GraduationAuditBatchId,
principalTable: "GraduationAuditBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GraduationAuditResults_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditBatches_GraduationYear_EnrollmentYear",
table: "GraduationAuditBatches",
columns: new[] { "GraduationYear", "EnrollmentYear" });
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditBatches_Status",
table: "GraduationAuditBatches",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_Conclusion_IsOverridden",
table: "GraduationAuditResults",
columns: new[] { "Conclusion", "IsOverridden" });
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_CurriculumPlanId",
table: "GraduationAuditResults",
column: "CurriculumPlanId");
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_GraduationAuditBatchId_StudentId",
table: "GraduationAuditResults",
columns: new[] { "GraduationAuditBatchId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GraduationAuditResults_StudentId",
table: "GraduationAuditResults",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GraduationAuditResults");
migrationBuilder.DropTable(
name: "GraduationAuditBatches");
}
}
}
@@ -0,0 +1,115 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class DegreeAwards : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DegreeAwardBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
GraduationYear = table.Column<int>(type: "int", nullable: false),
DegreeName = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false),
MinimumGradePoint = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DegreeAwardBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "DegreeAwardResults",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
DegreeAwardBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationAuditResultId = table.Column<Guid>(type: "char(36)", nullable: false),
AverageGradePoint = table.Column<decimal>(type: "decimal(4,2)", precision: 4, scale: 2, nullable: false),
CalculatedConclusion = table.Column<int>(type: "int", nullable: false),
Conclusion = table.Column<int>(type: "int", nullable: false),
ExceptionReason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
IsOverridden = table.Column<bool>(type: "tinyint(1)", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DegreeAwardResults", x => x.Id);
table.ForeignKey(
name: "FK_DegreeAwardResults_DegreeAwardBatches_DegreeAwardBatchId",
column: x => x.DegreeAwardBatchId,
principalTable: "DegreeAwardBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DegreeAwardResults_GraduationAuditResults_GraduationAuditRes~",
column: x => x.GraduationAuditResultId,
principalTable: "GraduationAuditResults",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DegreeAwardResults_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardBatches_GraduationYear_Status",
table: "DegreeAwardBatches",
columns: new[] { "GraduationYear", "Status" });
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_Conclusion_IsOverridden",
table: "DegreeAwardResults",
columns: new[] { "Conclusion", "IsOverridden" });
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_DegreeAwardBatchId_StudentId",
table: "DegreeAwardResults",
columns: new[] { "DegreeAwardBatchId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_GraduationAuditResultId",
table: "DegreeAwardResults",
column: "GraduationAuditResultId");
migrationBuilder.CreateIndex(
name: "IX_DegreeAwardResults_StudentId",
table: "DegreeAwardResults",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DegreeAwardResults");
migrationBuilder.DropTable(
name: "DegreeAwardBatches");
}
}
}
@@ -0,0 +1,128 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class GraduationClearance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GraduationClearanceBatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
GraduationYear = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationClearanceBatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GraduationClearanceItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationClearanceBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
ResponsibleUnit = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
ResponsibleRole = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
IsRequired = table.Column<bool>(type: "tinyint(1)", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationClearanceItems", x => x.Id);
table.ForeignKey(
name: "FK_GraduationClearanceItems_GraduationClearanceBatches_Graduati~",
column: x => x.GraduationClearanceBatchId,
principalTable: "GraduationClearanceBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GraduationClearanceRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GraduationClearanceItemId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GraduationClearanceRecords", x => x.Id);
table.ForeignKey(
name: "FK_GraduationClearanceRecords_GraduationClearanceItems_Graduati~",
column: x => x.GraduationClearanceItemId,
principalTable: "GraduationClearanceItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GraduationClearanceRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceBatches_GraduationYear_Status",
table: "GraduationClearanceBatches",
columns: new[] { "GraduationYear", "Status" });
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceItems_GraduationClearanceBatchId_Code",
table: "GraduationClearanceItems",
columns: new[] { "GraduationClearanceBatchId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceRecords_GraduationClearanceItemId_Student~",
table: "GraduationClearanceRecords",
columns: new[] { "GraduationClearanceItemId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GraduationClearanceRecords_StudentId_Status",
table: "GraduationClearanceRecords",
columns: new[] { "StudentId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GraduationClearanceRecords");
migrationBuilder.DropTable(
name: "GraduationClearanceItems");
migrationBuilder.DropTable(
name: "GraduationClearanceBatches");
}
}
}
@@ -634,6 +634,115 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CurriculumPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DegreeName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int>("GraduationYear")
.HasColumnType("int");
b.Property<decimal>("MinimumGradePoint")
.HasPrecision(3, 2)
.HasColumnType("decimal(3,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationYear", "Status");
b.ToTable("DegreeAwardBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("AverageGradePoint")
.HasPrecision(4, 2)
.HasColumnType("decimal(4,2)");
b.Property<int>("CalculatedConclusion")
.HasColumnType("int");
b.Property<int>("Conclusion")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("DegreeAwardBatchId")
.HasColumnType("char(36)");
b.Property<string>("ExceptionReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<Guid>("GraduationAuditResultId")
.HasColumnType("char(36)");
b.Property<bool>("IsOverridden")
.HasColumnType("tinyint(1)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationAuditResultId");
b.HasIndex("StudentId");
b.HasIndex("Conclusion", "IsOverridden");
b.HasIndex("DegreeAwardBatchId", "StudentId")
.IsUnique();
b.ToTable("DegreeAwardResults");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Property<Guid>("Id")
@@ -836,6 +945,254 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("GradeSheets");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("EnrollmentYear")
.HasColumnType("int");
b.Property<int>("GraduationYear")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("GraduationYear", "EnrollmentYear");
b.ToTable("GraduationAuditBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("CalculatedConclusion")
.HasColumnType("int");
b.Property<int>("Conclusion")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("CurriculumPlanId")
.HasColumnType("char(36)");
b.Property<decimal>("EarnedCredits")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<int>("FailedCourseCount")
.HasColumnType("int");
b.Property<Guid>("GraduationAuditBatchId")
.HasColumnType("char(36)");
b.Property<bool>("IsOverridden")
.HasColumnType("tinyint(1)");
b.Property<string>("MissingCourseNames")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<int>("PassedRequiredCourseCount")
.HasColumnType("int");
b.Property<int>("RequiredCourseCount")
.HasColumnType("int");
b.Property<decimal>("RequiredCredits")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<int>("StudentStatusSnapshot")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CurriculumPlanId");
b.HasIndex("StudentId");
b.HasIndex("Conclusion", "IsOverridden");
b.HasIndex("GraduationAuditBatchId", "StudentId")
.IsUnique();
b.ToTable("GraduationAuditResults");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("GraduationYear")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationYear", "Status");
b.ToTable("GraduationClearanceBatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("GraduationClearanceBatchId")
.HasColumnType("char(36)");
b.Property<bool>("IsRequired")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("ResponsibleRole")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("ResponsibleUnit")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationClearanceBatchId", "Code")
.IsUnique();
b.ToTable("GraduationClearanceItems");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("CompletedByUserId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("GraduationClearanceItemId")
.HasColumnType("char(36)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GraduationClearanceItemId", "StudentId")
.IsUnique();
b.HasIndex("StudentId", "Status");
b.ToTable("GraduationClearanceRecords");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.Property<Guid>("Id")
@@ -1684,6 +2041,33 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch")
.WithMany("Results")
.HasForeignKey("DegreeAwardBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult")
.WithMany()
.HasForeignKey("GraduationAuditResultId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("DegreeAwardBatch");
b.Navigation("GraduationAuditResult");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
@@ -1771,6 +2155,62 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan")
.WithMany()
.HasForeignKey("CurriculumPlanId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch")
.WithMany("Results")
.HasForeignKey("GraduationAuditBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CurriculumPlan");
b.Navigation("GraduationAuditBatch");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch")
.WithMany("Items")
.HasForeignKey("GraduationClearanceBatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GraduationClearanceBatch");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem")
.WithMany("Records")
.HasForeignKey("GraduationClearanceItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("GraduationClearanceItem");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
@@ -1996,6 +2436,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b =>
{
b.Navigation("Results");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Navigation("Sessions");
@@ -2011,6 +2456,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b =>
{
b.Navigation("Results");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b =>
{
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Navigation("Entries");