Implement comprehensive academic workflow updates

This commit is contained in:
biss committed 2026-08-18 11:49:25 +08:00
1 parent 4f031d4b60
commit 868367855d
11 files changed
+8366 -8

No files matched your search

@@ -96,11 +96,9 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
}
}
var followUpTaskCount = 0;
if (generated.Count > 0)
{
db.WarningRecords.AddRange(generated);
await db.SaveChangesAsync(ct);
var studentsById = studentInfos.ToDictionary(student => student.Id);
var rulesByType = rules.ToDictionary(rule => rule.Type);
var classIds = studentInfos.Select(student => student.ClassId).Distinct().ToArray();
@@ -109,6 +107,19 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
.Where(item => item.CounselorUserId != null)
.Select(item => new { item.Id, CounselorUserId = item.CounselorUserId!.Value })
.ToDictionaryAsync(item => item.Id, item => item.CounselorUserId, ct);
db.WarningRecords.AddRange(generated);
var riskCases = generated
.Where(warning => counselorsByClass.ContainsKey(studentsById[warning.StudentId].ClassId))
.Select(warning => new AcademicRiskCase
{
WarningRecord = warning,
StudentId = warning.StudentId,
AssignedCounselorUserId = counselorsByClass[studentsById[warning.StudentId].ClassId],
FollowUpDueDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7))
}).ToList();
followUpTaskCount = riskCases.Count;
db.AcademicRiskCases.AddRange(riskCases);
await db.SaveChangesAsync(ct);
foreach (var w in generated)
{
var si = studentsById[w.StudentId];
@@ -122,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
}
}
return Ok(new { Generated = generated.Count });
return Ok(new { Generated = generated.Count, FollowUpTasks = followUpTaskCount, Unassigned = generated.Count - followUpTaskCount });
}
[HttpGet("records")]
@@ -184,6 +195,23 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
.ToListAsync(ct));
}
[HttpGet("my-risk-cases")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyRiskCases(CancellationToken ct)
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
return Ok(await db.AcademicRiskCases.AsNoTracking()
.Where(x => x.StudentId == studentId)
.OrderByDescending(x => x.CreatedAt)
.Select(x => new AcademicRiskCaseStudentItem(x.Id, (int)x.Status,
x.FollowUpDueDate, x.ReviewDate,
x.Entries.OrderByDescending(entry => entry.OccurredAt).Select(entry =>
new AcademicRiskCaseEntryItem(entry.Id, (int)entry.Type, entry.Content,
entry.OccurredAt, entry.RecordedByUser!.DisplayName)).ToList()))
.ToListAsync(ct));
}
[HttpPost("{id:guid}/acknowledge")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Acknowledge(Guid id, [FromBody] AckBody body, CancellationToken ct)
@@ -200,6 +228,90 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
return NoContent();
}
// ═══════════ Confidential risk disposition ═══════════
[HttpGet("risk-cases")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> GetRiskCases(bool mineOnly = false, CancellationToken ct = default)
{
var current = scope.Current;
var q = db.AcademicRiskCases.AsNoTracking();
if (!IsRiskManager(current))
q = q.Where(x => x.AssignedCounselorUserId == current.UserId);
else if (mineOnly)
q = q.Where(x => x.AssignedCounselorUserId == current.UserId);
return Ok(await q.OrderBy(x => x.Status).ThenBy(x => x.FollowUpDueDate)
.Select(x => new AcademicRiskCaseListItem(x.Id, x.WarningRecordId, x.StudentId,
x.Student!.Name, x.Student.StudentNumber, x.Student.AdministrativeClass!.Name,
(int)x.WarningRecord!.Type, (int)x.Status, x.FollowUpDueDate, x.ReviewDate,
x.TaskCompletedAt, x.AssignedCounselorUser!.DisplayName, x.CreatedAt))
.ToListAsync(ct));
}
[HttpGet("risk-cases/{id:guid}")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> GetRiskCase(Guid id, CancellationToken ct)
{
var item = await RiskCaseQuery().FirstOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return NotFound();
if (!CanAccessRiskCase(item)) return Forbid();
return Ok(new AcademicRiskCaseDetail(item.Id, item.WarningRecordId, item.StudentId,
item.Student!.Name, item.Student.StudentNumber, item.Student.AdministrativeClass!.Name,
item.WarningRecord!.Detail, (int)item.Status, item.FollowUpDueDate, item.ReviewDate,
item.TaskCompletedAt, item.AssignedCounselorUser!.DisplayName,
item.Entries.OrderByDescending(x => x.OccurredAt).Select(x => new AcademicRiskCaseEntryItem(
x.Id, (int)x.Type, x.Content, x.OccurredAt, x.RecordedByUser!.DisplayName)).ToList()));
}
[HttpPut("risk-cases/{id:guid}")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> UpdateRiskCase(Guid id, [FromBody] UpdateAcademicRiskCaseBody body, CancellationToken ct)
{
var item = await db.AcademicRiskCases.FirstOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return NotFound();
if (!CanAccessRiskCase(item)) return Forbid();
if (!Enum.IsDefined(body.Status)) return ValidationProblem("处置状态无效。");
if (body.FollowUpDueDate < DateOnly.FromDateTime(item.CreatedAt))
return ValidationProblem("跟进截止日期不能早于任务创建日期。");
if (body.ReviewDate.HasValue && body.ReviewDate < body.FollowUpDueDate)
return ValidationProblem("复核日期不能早于跟进截止日期。");
item.FollowUpDueDate = body.FollowUpDueDate;
item.ReviewDate = body.ReviewDate;
item.Status = body.Status;
item.TaskCompletedAt = body.Status is AcademicRiskCaseStatus.PendingReview or AcademicRiskCaseStatus.Closed
? item.TaskCompletedAt ?? DateTime.UtcNow : null;
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("risk-cases/{id:guid}/entries")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> AddRiskCaseEntry(Guid id, [FromBody] CreateAcademicRiskCaseEntryBody body, CancellationToken ct)
{
var item = await db.AcademicRiskCases.FirstOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return NotFound();
if (!CanAccessRiskCase(item)) return Forbid();
if (!Enum.IsDefined(body.Type)) return ValidationProblem("记录类别无效。");
if (string.IsNullOrWhiteSpace(body.Content)) return ValidationProblem("请填写记录内容。");
db.AcademicRiskCaseEntries.Add(new AcademicRiskCaseEntry
{
AcademicRiskCaseId = id, Type = body.Type, Content = body.Content.Trim(),
OccurredAt = body.OccurredAt ?? DateTime.UtcNow, RecordedByUserId = scope.Current.UserId
});
if (item.Status == AcademicRiskCaseStatus.PendingFollowUp) item.Status = AcademicRiskCaseStatus.InProgress;
await db.SaveChangesAsync(ct);
return NoContent();
}
private IQueryable<AcademicRiskCase> RiskCaseQuery() => db.AcademicRiskCases.AsNoTracking()
.Include(x => x.Student).ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.WarningRecord).Include(x => x.AssignedCounselorUser)
.Include(x => x.Entries).ThenInclude(x => x.RecordedByUser);
private static bool IsRiskManager(CurrentUserScope current) =>
current.IsInRole(SystemRoles.SuperAdmin) || current.IsInRole(SystemRoles.AcademicAdmin);
private bool CanAccessRiskCase(AcademicRiskCase item) =>
IsRiskManager(scope.Current) || item.AssignedCounselorUserId == scope.Current.UserId;
// ═══════════ Detection logic ═══════════
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{
@@ -290,3 +402,16 @@ public sealed record WarningRecordListItem(
DateTime CreatedAt);
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
public sealed record AckBody([MaxLength(300)] string? Comment);
public sealed record AcademicRiskCaseListItem(Guid Id, Guid WarningRecordId, Guid StudentId,
string StudentName, string StudentNumber, string ClassName, int WarningType, int Status,
DateOnly FollowUpDueDate, DateOnly? ReviewDate, DateTime? TaskCompletedAt,
string AssignedCounselorName, DateTime CreatedAt);
public sealed record AcademicRiskCaseDetail(Guid Id, Guid WarningRecordId, Guid StudentId,
string StudentName, string StudentNumber, string ClassName, string WarningDetail, int Status,
DateOnly FollowUpDueDate, DateOnly? ReviewDate, DateTime? TaskCompletedAt,
string AssignedCounselorName, List<AcademicRiskCaseEntryItem> Entries);
public sealed record AcademicRiskCaseEntryItem(Guid Id, int Type, string Content, DateTime OccurredAt, string RecordedByName);
public sealed record AcademicRiskCaseStudentItem(Guid Id, int Status, DateOnly FollowUpDueDate,
DateOnly? ReviewDate, List<AcademicRiskCaseEntryItem> Entries);
public sealed record UpdateAcademicRiskCaseBody(AcademicRiskCaseStatus Status, DateOnly FollowUpDueDate, DateOnly? ReviewDate);
public sealed record CreateAcademicRiskCaseEntryBody(AcademicRiskEntryType Type, [MaxLength(2000)] string Content, DateTime? OccurredAt);