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);
@@ -1,4 +1,5 @@
using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity;
namespace Jiaowu.Api.Domain.Academic;
@@ -32,6 +33,35 @@ public sealed class WarningRecord : EntityBase
public DateTime? AcknowledgedAt { get; set; }
public string? AcknowledgeComment { get; set; }
public Guid AcademicTermId { get; set; }
public AcademicRiskCase? RiskCase { get; set; }
}
/// <summary>Confidential follow-up work created for an academic warning.</summary>
public sealed class AcademicRiskCase : EntityBase
{
public Guid WarningRecordId { get; set; }
public WarningRecord? WarningRecord { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid AssignedCounselorUserId { get; set; }
public ApplicationUser? AssignedCounselorUser { get; set; }
public AcademicRiskCaseStatus Status { get; set; } = AcademicRiskCaseStatus.PendingFollowUp;
public DateOnly FollowUpDueDate { get; set; }
public DateOnly? ReviewDate { get; set; }
public DateTime? TaskCompletedAt { get; set; }
public List<AcademicRiskCaseEntry> Entries { get; set; } = [];
}
/// <summary>Confidential counseling or support entry. Never exposed to students or unassigned staff.</summary>
public sealed class AcademicRiskCaseEntry : EntityBase
{
public Guid AcademicRiskCaseId { get; set; }
public AcademicRiskCase? AcademicRiskCase { get; set; }
public AcademicRiskEntryType Type { get; set; }
public required string Content { get; set; }
public DateTime OccurredAt { get; set; }
public Guid RecordedByUserId { get; set; }
public ApplicationUser? RecordedByUser { get; set; }
}
public enum WarningType
@@ -49,3 +79,6 @@ public enum WarningStatus
Resolved = 3,
Dismissed = 4
}
public enum AcademicRiskCaseStatus { PendingFollowUp = 1, InProgress = 2, PendingReview = 3, Closed = 4 }
public enum AcademicRiskEntryType { Counseling = 1, SupportMeasure = 2, Review = 3 }
@@ -127,6 +127,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseEquivalencyRule> CourseEquivalencyRules => Set<CourseEquivalencyRule>();
public DbSet<WarningRule> WarningRules => Set<WarningRule>();
public DbSet<WarningRecord> WarningRecords => Set<WarningRecord>();
public DbSet<AcademicRiskCase> AcademicRiskCases => Set<AcademicRiskCase>();
public DbSet<AcademicRiskCaseEntry> AcademicRiskCaseEntries => Set<AcademicRiskCaseEntry>();
public DbSet<EvaluationSetup> EvaluationSetups => Set<EvaluationSetup>();
public DbSet<EvaluationDimension> EvaluationDimensions => Set<EvaluationDimension>();
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
@@ -1487,6 +1489,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AcademicRiskCase>(entity =>
{
entity.HasIndex(x => x.WarningRecordId).IsUnique();
entity.HasIndex(x => new { x.AssignedCounselorUserId, x.Status, x.FollowUpDueDate });
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasOne(x => x.WarningRecord).WithOne(x => x.RiskCase)
.HasForeignKey<AcademicRiskCase>(x => x.WarningRecordId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.AssignedCounselorUser).WithMany().HasForeignKey(x => x.AssignedCounselorUserId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AcademicRiskCaseEntry>(entity =>
{
entity.Property(x => x.Content).HasMaxLength(2000);
entity.HasIndex(x => new { x.AcademicRiskCaseId, x.OccurredAt });
entity.HasOne(x => x.AcademicRiskCase).WithMany(x => x.Entries)
.HasForeignKey(x => x.AcademicRiskCaseId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.RecordedByUser).WithMany().HasForeignKey(x => x.RecordedByUserId).OnDelete(DeleteBehavior.Restrict);
});
// ── Evaluation ──
builder.Entity<EvaluationSetup>(entity =>
Loaded 3 of 11 files, more files were not shown because too many files have changed in this diff. Show more