毕业审核:批次计算、缺失课程检查、人工复核、结果发布。 学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。 毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
319 lines
14 KiB
C#
319 lines
14 KiB
C#
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);
|