Implement comprehensive academic workflow updates
This commit is contained in:
1 parent
4f031d4b60
commit
868367855d
11 files changed
+8366
-8
No files matched your search
@@ -84,7 +84,7 @@
|
||||
### P1:提升教学质量与服务体验
|
||||
|
||||
- [x] **教学评价闭环**:在现有学生评教之外加入同行/督导评价、改进计划、复评和按学期追踪,避免评价只形成分数。
|
||||
- [ ] **学业风险处置工作流**:预警后生成辅导员跟进任务、谈话记录、帮扶措施与复核日期;仅向授权人员可见敏感信息。
|
||||
- [x] **学业风险处置工作流**:预警后生成辅导员跟进任务、谈话记录、帮扶措施与复核日期;仅向授权人员可见敏感信息。
|
||||
- [ ] **学期校历与关键节点中心**:统一配置选课、成绩、评教、考试、毕业审核等截止时间,自动显示角色待办并在临期提醒。
|
||||
- [ ] **教室设备与故障闭环**:场地二维码报修、设备可用状态、维修工单和排课时的不可用自动避让。
|
||||
|
||||
|
||||
@@ -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 }
|
||||
Loaded 3 of 11 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user