Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs
T
2026-07-26 22:16:58 +08:00

404 lines
17 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 statistics = await ScopedRecords().AsNoTracking()
.GroupBy(x => x.GraduationClearanceItem!.GraduationClearanceBatchId)
.Select(group => new
{
BatchId = group.Key,
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
RecordCount = group.Count(),
CompletedCount = group.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
}).ToDictionaryAsync(x => x.BatchId, token);
return Ok(batches.Select(batch =>
{
statistics.TryGetValue(batch.Id, out var summary);
return new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
StudentCount = summary?.StudentCount ?? 0,
RecordCount = summary?.RecordCount ?? 0,
CompletedCount = summary?.CompletedCount ?? 0
};
}));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatch(
Guid id,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? keyword = null,
[FromQuery] string? progress = null,
CancellationToken token = default)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 10, 100);
var normalizedProgress = progress?.Trim();
if (normalizedProgress is not null and not "Pending" and not "Completed")
return ValidationProblem("离校办理进度筛选值无效。");
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 = ScopedRecords().AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id);
var summary = await records
.GroupBy(_ => 1)
.Select(group => new
{
StudentCount = group.Select(x => x.StudentId).Distinct().Count(),
RecordCount = group.Count(),
CompletedCount = group.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
})
.FirstOrDefaultAsync(token);
var students = db.Students.AsNoTracking()
.Where(student => records.Any(record => record.StudentId == student.Id));
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
students = students.Where(student =>
student.StudentNumber.Contains(normalizedKeyword) ||
student.Name.Contains(normalizedKeyword) ||
student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
if (normalizedProgress == "Pending")
students = students.Where(student => records.Any(record =>
record.StudentId == student.Id &&
record.GraduationClearanceItem!.IsRequired &&
record.Status == GraduationClearanceRecordStatus.Pending));
else if (normalizedProgress == "Completed")
students = students.Where(student => !records.Any(record =>
record.StudentId == student.Id &&
record.GraduationClearanceItem!.IsRequired &&
record.Status == GraduationClearanceRecordStatus.Pending));
var total = await students.CountAsync(token);
var studentPage = await students
.OrderBy(student => student.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(student => new
{
student.Id,
student.StudentNumber,
student.Name,
ClassName = student.AdministrativeClass!.Name,
MajorName = student.AdministrativeClass.Major!.Name
}).ToListAsync(token);
var studentIds = studentPage.Select(student => student.Id).ToArray();
var pageRecords = await records
.WhereIn(studentIds, x => x.StudentId)
.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);
var recordsByStudent = pageRecords
.GroupBy(record => record.StudentId)
.ToDictionary(group => group.Key, group => group.ToList());
var studentItems = studentPage.Select(student => new
{
StudentId = student.Id,
student.StudentNumber,
student.Name,
student.ClassName,
student.MajorName,
Records = recordsByStudent.GetValueOrDefault(student.Id) ?? []
}).ToList();
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
StudentCount = summary?.StudentCount ?? 0,
RecordCount = summary?.RecordCount ?? 0,
CompletedCount = summary?.CompletedCount ?? 0,
Students = new
{
Items = studentItems,
Total = total,
Page = page,
PageSize = pageSize
}
});
}
[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 studentIds = 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)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(token);
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.Count, 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
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("该批次已经关闭。");
var hasIncompleteRequiredItem = await db.GraduationClearanceRecords
.AnyAsync(record =>
record.GraduationClearanceItem!.GraduationClearanceBatchId == id &&
record.GraduationClearanceItem.IsRequired &&
record.Status == GraduationClearanceRecordStatus.Pending,
token);
if (hasIncompleteRequiredItem)
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);