From 51b83e9d7848abe86369b44e993afd122feb82cb Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 15 Aug 2026 10:17:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E8=80=83=E3=80=81=E7=BC=93=E8=80=83?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E8=87=AA=E5=8A=A8=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/MakeupExamsController.cs | 69 ++++++++++++++++--- .../Exams/MakeupExamEligibilityService.cs | 22 +++++- .../Infrastructure/Grades/GradeCalculator.cs | 2 +- .../Jiaowu.Api.Tests/GradeCalculatorTests.cs | 10 ++- .../TeachingWorkflowRosterTests.cs | 22 +++++- web/src/views/GradesView.vue | 11 +-- web/src/views/MakeupExamsView.vue | 6 +- 7 files changed, 122 insertions(+), 20 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs index 99ab915..fb085cd 100644 --- a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs +++ b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs @@ -754,6 +754,27 @@ public sealed class MakeupExamsController( } var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId); + var unresolvedStudentIds = scores + .Where(x => enrollmentByStudent.TryGetValue(x.StudentId, out var enrollment) && + !enrollment.SourceGradeRecordId.HasValue) + .Select(x => x.StudentId) + .Distinct() + .ToArray(); + var fallbackGradeRecordIds = unresolvedStudentIds.Length == 0 + ? new Dictionary() + : (await db.GradeRecords.AsNoTracking() + .Where(x => x.GradeSheet!.TeachingTaskId == session.TeachingTaskId) + .WhereIn(unresolvedStudentIds, x => x.StudentId) + .OrderByDescending(x => x.UpdatedAt) + .Select(x => new { x.StudentId, x.Id }) + .ToListAsync(cancellationToken)) + .GroupBy(x => x.StudentId) + .ToDictionary(x => x.Key, x => x.First().Id); + var missingReplacementTargets = unresolvedStudentIds + .Where(studentId => !fallbackGradeRecordIds.ContainsKey(studentId)) + .ToArray(); + if (missingReplacementTargets.Length > 0) + return ConflictProblem("存在无法关联原课程成绩的补考考生,不能录入补考成绩。请先建立该教学班成绩单后重试。"); foreach (var entry in scores) { @@ -762,22 +783,54 @@ public sealed class MakeupExamsController( enrollment.MakeupScore = entry.Score; + // Backfill legacy deferred-exam enrollments created before they + // carried SourceGradeRecordId, then use the same replacement + // path as absent and failed candidates. + if (!enrollment.SourceGradeRecordId.HasValue && + fallbackGradeRecordIds.TryGetValue(entry.StudentId, out var fallbackGradeRecordId)) + enrollment.SourceGradeRecordId = fallbackGradeRecordId; + // Update the grade record — 补考合格按60分记 if (enrollment.SourceGradeRecordId.HasValue) { var record = await db.GradeRecords + .Include(x => x.GradeSheet) + .ThenInclude(x => x!.Items) + .Include(x => x.ItemScores) .FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value, cancellationToken); if (record is not null) { - record.ExamStatus = GradeExamStatus.Makeup; - // Cap passing score at 60 - var cappedScore = entry.Score >= 60 ? 60m : entry.Score; - record.TotalScore = cappedScore; - record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore); - record.Notes = entry.Score >= 60 - ? $"补考合格(原始{entry.Score}分,按60分记)" - : $"补考不合格({entry.Score}分)"; + if (enrollment.Reason == MakeupReason.DeferredApproved && + record.GradeSheet is not null) + { + // A deferred exam is a later sitting of the normal + // final. Its score therefore combines with the + // already-entered regular and component scores. + record.ExamStatus = GradeExamStatus.Deferred; + record.FinalScore = entry.Score; + record.TotalScore = GradeCalculator.CalculateTotal( + record.RegularScore, + record.FinalScore, + record.ItemScores.ToList(), + record.GradeSheet.RegularWeight, + record.GradeSheet.FinalWeight, + record.GradeSheet.Items.ToList(), + record.ExamStatus); + record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore); + record.Notes = $"缓考成绩(期末{entry.Score}分,已按正常权重计入平时分)"; + } + else + { + record.ExamStatus = GradeExamStatus.Makeup; + // Cap passing score at 60 + var cappedScore = entry.Score >= 60 ? 60m : entry.Score; + record.TotalScore = cappedScore; + record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore); + record.Notes = entry.Score >= 60 + ? $"补考合格(原始{entry.Score}分,按60分记)" + : $"补考不合格({entry.Score}分)"; + } } } } diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs index d036758..a69e7e8 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs @@ -44,13 +44,33 @@ public sealed class MakeupExamEligibilityService(AppDbContext db) }) .ToListAsync(cancellationToken); + // A deferred exam replaces the same course-grade record after the + // makeup score is entered. Keep that link at enrollment time instead + // of leaving deferred candidates without a score-replacement target. + var deferredStudentIds = deferredStudents.Select(x => x.StudentId).ToArray(); + var deferredGradeRecordIds = deferredStudentIds.Length == 0 + ? new Dictionary() + : (await db.GradeRecords.AsNoTracking() + .Where(x => x.GradeSheet!.TeachingTaskId == teachingTaskId) + .WhereIn(deferredStudentIds, x => x.StudentId) + .OrderByDescending(x => x.UpdatedAt) + .Select(x => new { x.StudentId, x.Id }) + .ToListAsync(cancellationToken)) + .GroupBy(x => x.StudentId) + .ToDictionary(x => x.Key, x => x.First().Id); + foreach (var s in deferredStudents) { if (!results.ContainsKey(s.StudentId)) { + var sourceGradeRecordId = deferredGradeRecordIds.TryGetValue( + s.StudentId, out var gradeRecordId) + ? gradeRecordId + : (Guid?)null; results[s.StudentId] = new EligibleStudent( s.StudentId, s.StudentNumber, s.Name, s.ClassName, - MakeupReason.DeferredApproved, null, + MakeupReason.DeferredApproved, + SourceGradeRecordId: sourceGradeRecordId, SourceDeferredExamId: s.Id); } } diff --git a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs index 1de9e32..1e4beb6 100644 --- a/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs +++ b/src/Jiaowu.Api/Infrastructure/Grades/GradeCalculator.cs @@ -22,7 +22,7 @@ public static class GradeCalculator IReadOnlyCollection items, GradeExamStatus examStatus) { - if (examStatus != GradeExamStatus.Normal && examStatus != GradeExamStatus.Makeup) + if (examStatus is not (GradeExamStatus.Normal or GradeExamStatus.Deferred or GradeExamStatus.Makeup)) return null; if (regularWeight > 0 && !regularScore.HasValue) diff --git a/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs b/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs index 5c0a242..50fe31c 100644 --- a/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs +++ b/tests/Jiaowu.Api.Tests/GradeCalculatorTests.cs @@ -55,10 +55,18 @@ public sealed class GradeCalculatorTests [Theory] [InlineData(GradeExamStatus.Absent)] - [InlineData(GradeExamStatus.Deferred)] [InlineData(GradeExamStatus.Exempt)] public void Exceptional_exam_status_has_no_numeric_total(GradeExamStatus status) { Assert.Null(GradeCalculator.CalculateTotal(90, 90, [], 30, 70, [], status)); } + + [Fact] + public void Deferred_exam_uses_regular_and_final_scores_like_normal_exam() + { + var total = GradeCalculator.CalculateTotal( + 83, 91, [], 30, 70, [], GradeExamStatus.Deferred); + + Assert.Equal(88.6m, total); + } } diff --git a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs index 5847976..ae68e07 100644 --- a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs @@ -234,6 +234,19 @@ public sealed class TeachingWorkflowRosterTests CancellationToken.None); Assert.IsType(gradeResult); Assert.Equal(1, await db.GradeRecords.CountAsync()); + var originalGradeRecord = await db.GradeRecords.SingleAsync(); + originalGradeRecord.RegularScore = 80; + await db.SaveChangesAsync(); + + var deferredExam = new DeferredExam + { + StudentId = student.Id, + TeachingTaskId = task.Id, + Reason = "病假", + Status = ApprovalStatus.Approved + }; + db.DeferredExams.Add(deferredExam); + await db.SaveChangesAsync(); var makeupPlan = new MakeupExamPlan { @@ -256,7 +269,10 @@ public sealed class TeachingWorkflowRosterTests new MakeupExamEnrollment { StudentId = student.Id, - Reason = MakeupReason.Failed + Reason = MakeupReason.DeferredApproved, + // Simulate an existing deferred-exam enrollment that was + // created before SourceGradeRecordId was persisted. + SourceDeferredExamId = deferredExam.Id } ] }; @@ -279,6 +295,10 @@ public sealed class TeachingWorkflowRosterTests .Where(x => x.MakeupExamSessionId == makeupSession.Id) .Select(x => x.MakeupScore) .SingleAsync()); + var replacement = await db.GradeRecords.SingleAsync(); + Assert.Equal(77, replacement.TotalScore); + Assert.Equal(GradeExamStatus.Deferred, replacement.ExamStatus); + Assert.Equal("缓考成绩(期末75分,已按正常权重计入平时分)", replacement.Notes); var selections = new CourseSelectionsController(db, scope); Assert.Single(ReadItems(await selections.GetMyTeachingTasks( diff --git a/web/src/views/GradesView.vue b/web/src/views/GradesView.vue index ab014d3..1d0ac09 100644 --- a/web/src/views/GradesView.vue +++ b/web/src/views/GradesView.vue @@ -75,6 +75,7 @@ const examStatusLabels: Record = { Absent: '缺考', Deferred: '缓考', Exempt: '免修', + Makeup: '补考', } const itemsWeight = computed(() => createForm.items.reduce((sum, item) => sum + item.weight, 0)) @@ -415,7 +416,7 @@ function itemScore(record: any, gradeItemId: string) { /** Client-side preview calculation — mirrors backend GradeCalculator.CalculateTotal */ function calcPreviewTotal(record: any): number | null { if (!detail.value) return null - if (record.examStatus !== 'Normal') return null + if (!['Normal', 'Deferred'].includes(record.examStatus)) return null const { regularWeight, finalWeight, items } = detail.value const itemWeightById: Record = {} @@ -642,7 +643,7 @@ onMounted(async () => {