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 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 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 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 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 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 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 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 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 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);