From cde754621a0c5ecc082bf006813225cc1e973212 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 19:21:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E7=BB=A9=E6=8F=90=E4=BA=A4=E6=97=B6?= =?UTF-8?q?=E6=8C=89=E8=80=83=E8=AF=95=E4=B8=80=E6=AC=A1=E6=80=A7=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=B9=B6=E5=AD=98=E5=82=A8=E5=8D=95=E7=A7=91=E5=90=8D?= =?UTF-8?q?=E6=AC=A1=E3=80=81=E6=8E=92=E5=90=8D=E7=99=BE=E5=88=86=E6=AF=94?= =?UTF-8?q?=E3=80=81=E7=AD=89=E7=BA=A7=E3=80=81=E5=90=88=E6=A0=BC=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E3=80=81=E6=80=BB=E5=88=86=E6=8E=92=E5=90=8D=E5=8F=8A?= =?UTF-8?q?=E6=95=B4=E5=9C=BA=E5=90=88=E6=A0=BC=E7=8A=B6=E6=80=81=E3=80=82?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=20result=5Fstatistics=E3=80=81registratio?= =?UTF-8?q?n=5Fresult=5Fsummaries=20=E4=B8=A4=E5=BC=A0=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E7=8E=B0=E6=9C=89=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=90=AF=E5=8A=A8=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E3=80=82=20SQL=20=E6=9F=A5=E8=AF=A2=E9=A6=96?= =?UTF-8?q?=E5=85=88=E9=80=9A=E8=BF=87=20examId=20=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E3=80=82=20=E7=A7=91=E7=9B=AE=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E5=8F=AA=E6=8E=92=E5=BA=8F=E4=B8=80=E6=AC=A1=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BD=BF=E7=94=A8=20registration/subject=20=E5=AD=97?= =?UTF-8?q?=E5=85=B8=E7=BB=84=E7=BB=87=E6=95=B0=E6=8D=AE=E3=80=82=20?= =?UTF-8?q?=E6=B1=87=E6=80=BB=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E6=8E=A5=E5=8F=A3=E5=B7=B2=E6=8B=86=E5=88=86?= =?UTF-8?q?=EF=BC=8C=E6=98=8E=E7=BB=86=E3=80=81=E6=88=90=E7=BB=A9=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E5=90=8D=E5=8D=95=E5=92=8C=E7=89=B9=E5=BE=81=E5=88=86?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E5=9D=87=E6=94=B9=E4=B8=BA=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E5=88=86=E9=A1=B5=E3=80=82=20=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E5=86=99=E5=85=A5=E5=8F=A3=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=99=90=E5=88=B6=E4=B8=BA=EF=BC=9A0=E2=80=94=E6=BB=A1?= =?UTF-8?q?=E5=88=86=EF=BC=8C=E4=B8=94=E5=8F=AA=E8=83=BD=E6=98=AF=E6=95=B4?= =?UTF-8?q?=E6=95=B0=E6=88=96=20.5=EF=BC=8C=E5=8C=85=E6=8B=AC=E6=89=8B?= =?UTF-8?q?=E5=B7=A5=E5=BD=95=E5=85=A5=E3=80=81=E6=89=B9=E9=87=8F=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E3=80=81Excel=20=E5=AF=BC=E5=85=A5=E5=92=8C=E5=A4=8D?= =?UTF-8?q?=E8=AE=AE=E6=94=B9=E5=88=86=E3=80=82=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B9=9F=E5=A2=9E=E5=8A=A0=E4=BA=86=E7=9B=B8=E5=90=8C=E7=9A=84?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E6=A0=A1=E9=AA=8C=E3=80=82=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=85=B6=E4=BB=96=E8=80=83=E8=AF=95=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20undefined=20=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Administration/IAdminResultService.cs | 8 +- .../Administration/AdminAdmissionService.cs | 12 +- .../Administration/AdminResultRepository.cs | 145 +++++ .../Administration/AdminResultScoring.cs | 86 --- .../Administration/AdminResultService.cs | 553 ++++++++++++------ .../Administration/AdminResultStatistics.cs | 264 +++++++++ .../AdminResultStatisticsBackfillService.cs | 29 + .../AdminWorkflowResultSnapshot.cs | 128 +++- .../Administration/AdminWorkflowService.cs | 53 +- .../Candidate/CandidateReadSnapshot.cs | 128 ++-- .../Candidate/CandidateService.Admissions.cs | 9 +- .../Candidate/CandidateService.Results.cs | 130 +--- .../Data/DatabaseInitializer.cs | 3 + src/Eis.Infrastructure/Data/Schema.mysql.sql | 35 ++ src/Eis.Infrastructure/Data/Schema.sqlite.sql | 30 + src/Eis.Infrastructure/DependencyInjection.cs | 1 + .../NativeAdminReadEndpoints.cs | 31 + src/Eis.Web/wwwroot/js/app.js | 14 +- src/Eis.Web/wwwroot/js/client/admin-views.js | 75 ++- .../AdminResultStatisticsTests.cs | 120 ++++ 20 files changed, 1365 insertions(+), 489 deletions(-) delete mode 100644 src/Eis.Infrastructure/Administration/AdminResultScoring.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminResultStatistics.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminResultStatisticsBackfillService.cs create mode 100644 tests/Eis.Infrastructure.Tests/Administration/AdminResultStatisticsTests.cs diff --git a/src/Eis.Application/Administration/IAdminResultService.cs b/src/Eis.Application/Administration/IAdminResultService.cs index 0150f63..ac2f476 100644 --- a/src/Eis.Application/Administration/IAdminResultService.cs +++ b/src/Eis.Application/Administration/IAdminResultService.cs @@ -4,7 +4,13 @@ namespace Eis.Application.Administration; public interface IAdminResultService { - Task GetAsync(string sessionToken, string examId, CancellationToken cancellationToken); + Task GetSummaryAsync(string sessionToken, string examId, CancellationToken cancellationToken); + Task GetAsync( + string sessionToken, string examId, int page, int pageSize, string query, string status, + CancellationToken cancellationToken); + Task GetRosterAsync( + string sessionToken, string examId, string subjectId, bool featureMode, + int page, int pageSize, string query, string status, CancellationToken cancellationToken); Task SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs index 80883a9..7daa1a3 100644 --- a/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs @@ -673,7 +673,8 @@ internal sealed partial class AdminAdmissionService( await admissionRepository.LoadAsync(cancellationToken), await operationalLoader.LoadAsync(cancellationToken), await directoryLoader.LoadAsync(cancellationToken), - await resultLoader.LoadAsync(cancellationToken)); + await resultLoader.LoadAsync(cancellationToken), + await resultLoader.LoadSummariesAsync(cancellationToken)); private static IEnumerable Records(AdmissionData data, string kind, string? examId = null) => data.Admission.Records.Where(item => item.Kind == kind && (examId is null || item.ExamId == examId)); @@ -985,10 +986,8 @@ internal sealed partial class AdminAdmissionService( var registration = data.Operational.Registrations.FirstOrDefault(item => item.ExamId == examId && item.UserId == userId && item.Status == "approved"); if (registration is null) return null; - var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToArray(); - var results = data.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); - if (subjectIds.Length == 0 || subjectIds.Any(id => results.All(result => result.SubjectId != id))) return null; - return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero); + var summary = data.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id); + return summary is { Complete: true } ? summary.TotalScore : null; } private static List AssignNoticeNumbers(AdmissionData data, IReadOnlyList placements) @@ -1340,7 +1339,8 @@ internal sealed partial class AdminAdmissionService( CandidateAdmissionSnapshot Admission, AdminOperationalSnapshot Operational, AdminAccountBatchSnapshot Directory, - IReadOnlyList Results); + IReadOnlyList Results, + IReadOnlyList ResultSummaries); private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); diff --git a/src/Eis.Infrastructure/Administration/AdminResultRepository.cs b/src/Eis.Infrastructure/Administration/AdminResultRepository.cs index 17d5845..6637c90 100644 --- a/src/Eis.Infrastructure/Administration/AdminResultRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminResultRepository.cs @@ -11,7 +11,19 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) => SaveManyAsync([entry], cancellationToken); + public Task SaveAsync( + ResultWrite entry, + AdminResultCalculation calculation, + CancellationToken cancellationToken) => + SaveManyAsync([entry], [calculation], cancellationToken); + public async Task SaveManyAsync(IReadOnlyList entries, CancellationToken cancellationToken) + => await SaveManyAsync(entries, [], cancellationToken); + + public async Task SaveManyAsync( + IReadOnlyList entries, + IReadOnlyList calculations, + CancellationToken cancellationToken) { await TransactionAsync(async (connection, transaction) => { @@ -46,9 +58,43 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect cancellationToken); await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken); } + foreach (var calculation in calculations) + { + await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken); + } }, cancellationToken); } + public async Task ReplaceStatisticsAsync( + IReadOnlyList calculations, + CancellationToken cancellationToken) + { + await TransactionAsync(async (connection, transaction) => + { + foreach (var calculation in calculations) + { + await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken); + } + }, cancellationToken); + } + + public async Task NeedsStatisticsBackfillAsync(CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT + (SELECT COUNT(*) FROM results result + LEFT JOIN result_statistics statistic ON statistic.result_id = result.id + WHERE statistic.result_id IS NULL) + + (SELECT COUNT(*) FROM registrations registration + LEFT JOIN registration_result_summaries summary ON summary.registration_id = registration.id + WHERE registration.status = 'approved' AND summary.registration_id IS NULL) + """; + return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken)) > 0; + } + public async Task SaveFeatureScoresAsync( IReadOnlyList entries, CancellationToken cancellationToken) @@ -72,6 +118,7 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect AccountWorkflowInstance instance, AccountWorkflowAction action, AdminWorkflowResult? result, + AdminResultCalculation? calculation, AdminAuditEntry audit, CancellationToken cancellationToken) { @@ -130,10 +177,108 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect ], cancellationToken); } + if (calculation is not null) + { + await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken); + } await InsertAuditAsync(connection, transaction, audit, cancellationToken); }, cancellationToken); } + private static async Task ReplaceStatisticsAsync( + DbConnection connection, + DbTransaction transaction, + AdminResultCalculation calculation, + CancellationToken cancellationToken) + { + await ExecuteAsync( + connection, + transaction, + """ + DELETE FROM result_statistics + WHERE result_id IN ( + SELECT result.id FROM results result + JOIN registrations registration ON registration.id = result.registration_id + WHERE registration.exam_id = @examId + ) + """, + [new("@examId", calculation.ExamId)], + cancellationToken); + await ExecuteAsync( + connection, + transaction, + "DELETE FROM registration_result_summaries WHERE exam_id = @examId", + [new("@examId", calculation.ExamId)], + cancellationToken); + foreach (var metric in calculation.Metrics) + { + await ExecuteAsync( + connection, + transaction, + """ + INSERT INTO result_statistics ( + result_id, subject_rank, cohort_size, rank_percent, grade, qualified, + pass_score, cutoff_rank, calculated_at + ) VALUES ( + @resultId, @rank, @cohortSize, @rankPercent, @grade, @qualified, + @passScore, @cutoffRank, @calculatedAt + ) + """, + [ + new("@resultId", metric.ResultId), + new("@rank", metric.Rank), + new("@cohortSize", metric.CohortSize), + new("@rankPercent", metric.RankPercent), + new("@grade", metric.Grade), + new("@qualified", metric.Qualified is null ? null : metric.Qualified.Value ? 1 : 0), + new("@passScore", metric.PassScore), + new("@cutoffRank", metric.CutoffRank), + new("@calculatedAt", metric.CalculatedAt) + ], + cancellationToken); + await ExecuteAsync( + connection, + transaction, + "UPDATE results SET grade = @grade WHERE id = @resultId", + [new("@grade", metric.Grade), new("@resultId", metric.ResultId)], + cancellationToken); + } + foreach (var summary in calculation.Summaries) + { + await ExecuteAsync( + connection, + transaction, + """ + INSERT INTO registration_result_summaries ( + registration_id, exam_id, published_subject_count, subject_count, complete, + total_score, full_score, score_ratio, total_rank, cohort_size, rank_percent, + qualified, cutoff_rank, calculated_at + ) VALUES ( + @registrationId, @examId, @publishedSubjectCount, @subjectCount, @complete, + @totalScore, @fullScore, @scoreRatio, @rank, @cohortSize, @rankPercent, + @qualified, @cutoffRank, @calculatedAt + ) + """, + [ + new("@registrationId", summary.RegistrationId), + new("@examId", summary.ExamId), + new("@publishedSubjectCount", summary.PublishedSubjectCount), + new("@subjectCount", summary.SubjectCount), + new("@complete", summary.Complete ? 1 : 0), + new("@totalScore", summary.TotalScore), + new("@fullScore", summary.FullScore), + new("@scoreRatio", summary.ScoreRatio), + new("@rank", summary.Rank), + new("@cohortSize", summary.CohortSize), + new("@rankPercent", summary.RankPercent), + new("@qualified", summary.Qualified is null ? null : summary.Qualified.Value ? 1 : 0), + new("@cutoffRank", summary.CutoffRank), + new("@calculatedAt", summary.CalculatedAt) + ], + cancellationToken); + } + } + private async Task TransactionAsync( Func operation, CancellationToken cancellationToken) diff --git a/src/Eis.Infrastructure/Administration/AdminResultScoring.cs b/src/Eis.Infrastructure/Administration/AdminResultScoring.cs deleted file mode 100644 index 50f43e8..0000000 --- a/src/Eis.Infrastructure/Administration/AdminResultScoring.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Globalization; -using System.Text.Json.Nodes; - -namespace Eis.Infrastructure.Administration; - -internal sealed record AdminRankInfo(int? Rank, int CohortSize, double? RankPercent, string Grade); -internal sealed record AdminPassInfo(bool? Qualified, double? PassScore, int? CutoffRank, AdminRankInfo Rank); - -internal static class AdminResultScoring -{ - public static AdminRankInfo Rank( - IReadOnlyList results, - AdminWorkflowResult result, - double? scoreOverride = null) - { - var score = scoreOverride ?? result.Score; - if (!double.IsFinite(score) || result.SubjectId.Length == 0) - return new(null, 0, null, ""); - var scores = results - .Where(item => item.SubjectId == result.SubjectId && item.Published) - .Select(item => item.Score) - .Order() - .ToArray(); - var cohortSize = scores.Length + (result.Published ? 0 : 1); - if (cohortSize == 0) return new(null, 0, null, ""); - var greater = scores.Count(item => item > score); - if (result.Published && result.Score > score) greater--; - var rank = 1 + Math.Max(0, greater); - var percent = Math.Round((double)rank / cohortSize * 100, 2, MidpointRounding.AwayFromZero); - return new(rank, cohortSize, percent, Grade(rank, cohortSize)); - } - - public static AdminPassInfo Pass( - IReadOnlyList results, - AdminWorkflowResult result, - JsonObject? subject, - double? scoreOverride = null) - { - var score = scoreOverride ?? result.Score; - var rank = Rank(results, result, score); - var rule = Text(subject?["passRule"]); - if (rule.Length == 0) rule = "fixed_score"; - if (rule == "none") return new(null, null, null, rank); - if (rule == "rank_percent") - { - var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (Number(subject?["passValue"]) ?? 60) / 100)); - return new(rank.Rank <= cutoff, null, cutoff, rank); - } - var threshold = Math.Round( - Number(subject?["passValue"] ?? subject?["passScore"]) ?? 0, - 2, - MidpointRounding.AwayFromZero); - return new(score >= threshold, threshold, null, rank); - } - - public static string PassText(JsonObject? subject) - { - var rule = Text(subject?["passRule"]); - if (rule == "none") return "不设单科线"; - if (rule == "rank_percent") - return $"本科排名前 {Format(Number(subject?["passValue"]) ?? 60)}% 达线"; - return $"固定 {Format(Math.Round(Number(subject?["passValue"] ?? subject?["passScore"]) ?? 0, 2, MidpointRounding.AwayFromZero))} 分"; - } - - public static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); - - private static double? Number(JsonNode? node) => - double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) - ? value - : null; - - private static string Text(JsonNode? node) => - node is JsonValue value && value.TryGetValue(out var text) - ? text - : node?.ToString() ?? ""; - - private static string Grade(int rank, int cohortSize) - { - int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio)); - if (rank <= Cutoff(0.1)) return "A+"; - if (rank <= Cutoff(0.25)) return "A"; - if (rank <= Cutoff(0.5)) return "B+"; - if (rank <= Cutoff(0.7)) return "B"; - return rank <= Cutoff(0.9) ? "C" : "D"; - } -} diff --git a/src/Eis.Infrastructure/Administration/AdminResultService.cs b/src/Eis.Infrastructure/Administration/AdminResultService.cs index 2afd808..615c0dd 100644 --- a/src/Eis.Infrastructure/Administration/AdminResultService.cs +++ b/src/Eis.Infrastructure/Administration/AdminResultService.cs @@ -16,7 +16,7 @@ internal sealed class AdminResultService( AdminResultRepository repository, IApplicationCache cache) : IAdminResultService { - public async Task GetAsync( + public async Task GetSummaryAsync( string sessionToken, string examId, CancellationToken cancellationToken) @@ -29,9 +29,7 @@ internal sealed class AdminResultService( { ["ok"] = true, ["selectedExamId"] = "", - ["results"] = new JsonArray(), ["appeals"] = new JsonArray(), - ["registrations"] = new JsonArray(), ["exams"] = new JsonArray(), ["resultCache"] = CacheStatus() }); @@ -42,30 +40,27 @@ internal sealed class AdminResultService( var selectedExam = operational.Exams.FirstOrDefault(item => item.Id == examId); if (selectedExam is null) return Error(404, "请选择有效的考试后再读取成绩"); var directory = await directorySnapshotLoader.LoadAsync(cancellationToken); - var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken); + var examResults = await resultSnapshotLoader.LoadForExamAsync(selectedExam.Id, cancellationToken); + var summaries = await resultSnapshotLoader.LoadSummariesForExamAsync(selectedExam.Id, cancellationToken); + var lookups = ResultLookups.Create(operational, directory); var scoped = operational.Registrations.Where(item => - { - var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId); - return profile is not null && InScope(user, profile); - }).ToArray(); + lookups.ProfileByUserId.TryGetValue(item.UserId, out var profile) && InScope(user, profile)).ToArray(); var selectedScoped = scoped.Where(item => item.ExamId == selectedExam.Id).ToArray(); if (Level(user) != "super" && selectedScoped.Length == 0) return Error(404, "该考试不在当前管理范围内"); var approved = selectedScoped.Where(item => item.Status == "approved").ToArray(); var selectedIds = selectedScoped.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); - var examRegistrations = operational.Registrations - .Where(item => item.ExamId == selectedExam.Id && item.Status == "approved") - .ToArray(); - var examIds = examRegistrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); - var examResults = allResults.Where(item => examIds.Contains(item.RegistrationId)).ToArray(); var rawScopedResults = examResults.Where(item => selectedIds.Contains(item.RegistrationId)).ToArray(); - var resultViews = rawScopedResults - .Select(item => ResultJson(operational, directory, selectedExam, examResults, item)) - .ToArray(); - var resultById = resultViews.ToDictionary( - item => Text(item["id"]), - item => item, - StringComparer.Ordinal); + var appealedResultIds = directory.Instances + .Where(item => item.BusinessType == "score_appeal") + .Select(item => item.BusinessId) + .ToHashSet(StringComparer.Ordinal); + var resultById = rawScopedResults + .Where(item => appealedResultIds.Contains(item.Id)) + .ToDictionary( + item => item.Id, + item => ResultJson(lookups, selectedExam, item), + StringComparer.Ordinal); var appealViews = directory.Instances .Where(item => item.BusinessType == "score_appeal" && resultById.ContainsKey(item.BusinessId)) .Select(item => @@ -77,38 +72,169 @@ internal sealed class AdminResultService( return output; }) .ToArray(); + var summaryByRegistration = summaries.ToDictionary(item => item.RegistrationId, StringComparer.Ordinal); var exams = operational.Exams.Select(exam => { var output = PublicExamJson(exam); if (exam.Id != selectedExam.Id) return output; - var summaries = approved.Select(item => Summary(selectedExam, examRegistrations, examResults, item)).ToArray(); var enrolled = approved.Sum(item => SubjectIds(item).Count); + var scopedSummaries = approved + .Select(item => summaryByRegistration.GetValueOrDefault(item.Id)) + .Where(item => item is not null) + .Cast() + .ToArray(); output["registrationCount"] = approved.Length; output["enrolledSubjects"] = enrolled; output["scored"] = rawScopedResults.Length; output["published"] = rawScopedResults.Count(item => item.Published); output["missing"] = Math.Max(0, enrolled - rawScopedResults.Length); - output["complete"] = summaries.Count(item => item.Complete); - output["qualified"] = summaries.Count(item => item.Complete && item.Qualified is true); - output["unqualified"] = summaries.Count(item => item.Complete && item.Qualified is false); + output["complete"] = scopedSummaries.Count(item => item.Complete); + output["qualified"] = scopedSummaries.Count(item => item.Complete && item.Qualified is true); + output["unqualified"] = scopedSummaries.Count(item => item.Complete && item.Qualified is false); output["appeals"] = appealViews.Length; return output; }).ToArray(); - var registrations = Level(user) == "super" - ? approved.Select(item => RegistrationJson(operational, directory, item)).ToArray() - : []; return Success(new JsonObject { ["ok"] = true, ["selectedExamId"] = selectedExam.Id, - ["results"] = new JsonArray(resultViews), ["appeals"] = new JsonArray(appealViews), - ["registrations"] = new JsonArray(registrations), ["exams"] = new JsonArray(exams), ["resultCache"] = CacheStatus() }); } + public async Task GetAsync( + string sessionToken, + string examId, + int page, + int pageSize, + string query, + string status, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken); + if (context.Error is not null) return context.Error; + if (examId.Length == 0) return Error(400, "请选择考试后再读取成绩明细"); + var user = context.User!; + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var exam = operational.Exams.FirstOrDefault(item => item.Id == examId); + if (exam is null) return Error(404, "考试不存在"); + var directory = await directorySnapshotLoader.LoadAsync(cancellationToken); + var lookups = ResultLookups.Create(operational, directory); + var scopedRegistrationIds = operational.Registrations + .Where(item => item.ExamId == exam.Id && item.Status == "approved" && + lookups.ProfileByUserId.TryGetValue(item.UserId, out var profile) && InScope(user, profile)) + .Select(item => item.Id) + .ToHashSet(StringComparer.Ordinal); + if (Level(user) != "super" && scopedRegistrationIds.Count == 0) + return Error(404, "该考试不在当前管理范围内"); + var results = await resultSnapshotLoader.LoadForExamAsync(exam.Id, cancellationToken); + var filtered = results.Where(item => scopedRegistrationIds.Contains(item.RegistrationId)); + status = status.Length == 0 ? "all" : status; + filtered = status switch + { + "published" => filtered.Where(item => item.Published), + "draft" => filtered.Where(item => !item.Published), + "qualified" => filtered.Where(item => item.Qualified is true), + "unqualified" => filtered.Where(item => item.Qualified is false), + _ => filtered + }; + var normalizedQuery = query.Trim(); + if (normalizedQuery.Length > 0) + { + filtered = filtered.Where(item => ResultSearchText(lookups, exam, item) + .Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)); + } + var ordered = filtered + .OrderBy(item => lookups.AccountByRegistrationId.GetValueOrDefault(item.RegistrationId)?.CandidateNumber, StringComparer.Ordinal) + .ThenBy(item => item.SubjectId, StringComparer.Ordinal) + .ToArray(); + var pagination = Page(ordered.Length, page, pageSize, "resultTable"); + var items = ordered.Skip(pagination.Offset).Take(pagination.PageSize) + .Select(item => ResultJson(lookups, exam, item)) + .ToArray(); + return Success(new JsonObject + { + ["ok"] = true, + ["items"] = new JsonArray(items), + ["pagination"] = pagination.Json + }); + } + + public async Task GetRosterAsync( + string sessionToken, + string examId, + string subjectId, + bool featureMode, + int page, + int pageSize, + string query, + string status, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken); + if (context.Error is not null) return context.Error; + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var exam = operational.Exams.FirstOrDefault(item => item.Id == examId); + if (exam is null) return Error(404, "考试不存在"); + if (!featureMode && exam.Subjects.All(item => Text(item["id"]) != subjectId)) + return Error(400, "请选择有效的考试科目"); + var directory = await directorySnapshotLoader.LoadAsync(cancellationToken); + var lookups = ResultLookups.Create(operational, directory); + var subjectResults = featureMode + ? [] + : await resultSnapshotLoader.LoadForExamAsync(exam.Id, cancellationToken, subjectId); + var resultByRegistration = subjectResults.ToDictionary(item => item.RegistrationId, StringComparer.Ordinal); + var baseRegistrations = operational.Registrations + .Where(item => item.ExamId == exam.Id && item.Status == "approved" && + (featureMode || SubjectIds(item).Contains(subjectId))) + .ToArray(); + var registrations = baseRegistrations + .Where(item => + { + var result = resultByRegistration.GetValueOrDefault(item.Id); + return status switch + { + "missing" => result is null, + "draft" => result is { Published: false }, + "published" => result is { Published: true }, + "zero" => Number(item.Data["featureScore"]) == 0, + "modified" => Number(item.Data["featureScore"]) != 0, + "specialty" => Text(lookups.ProfileByUserId.GetValueOrDefault(item.UserId)?.Data["specialtyType"]).Length > 0, + _ => true + }; + }) + .Where(item => query.Trim().Length == 0 || RegistrationSearchText(lookups, item) + .Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)) + .OrderBy(item => lookups.AccountByUserId.GetValueOrDefault(item.UserId)?.CandidateNumber, StringComparer.Ordinal) + .ToArray(); + var pagination = Page(registrations.Length, page, pageSize, featureMode ? "featureScoreTable" : "resultEntryTable"); + var items = registrations.Skip(pagination.Offset).Take(pagination.PageSize).Select(item => + { + var output = RegistrationJson(lookups, item); + output["result"] = resultByRegistration.TryGetValue(item.Id, out var result) + ? ResultJson(lookups, exam, result) + : null; + return output; + }).ToArray(); + return Success(new JsonObject + { + ["ok"] = true, + ["items"] = new JsonArray(items), + ["pagination"] = pagination.Json, + ["counts"] = new JsonObject + { + ["total"] = baseRegistrations.Length, + ["recorded"] = baseRegistrations.Count(item => resultByRegistration.ContainsKey(item.Id)), + ["published"] = baseRegistrations.Count(item => resultByRegistration.GetValueOrDefault(item.Id)?.Published is true), + ["modified"] = baseRegistrations.Count(item => Number(item.Data["featureScore"]) != 0), + ["specialty"] = baseRegistrations.Count(item => + Text(lookups.ProfileByUserId.GetValueOrDefault(item.UserId)?.Data["specialtyType"]).Length > 0) + } + }); + } + public async Task SaveAsync( string sessionToken, JsonObject body, @@ -129,9 +255,9 @@ internal sealed class AdminResultService( return Error(400, "该考生未报名此科目"); var score = Number(body["score"]); var fullScore = Number(subject["fullScore"]); - if (!double.IsFinite(score) || score < 0 || score > fullScore) - return Error(400, $"成绩必须在 0—{AdminResultScoring.Format(fullScore)} 之间"); - var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList(); + if (!AdminResultStatistics.IsValidScore(score, fullScore)) + return Error(400, $"成绩必须在 0—{AdminResultStatistics.Format(fullScore)} 之间,且只能填写整数或 .5"); + var allResults = (await resultSnapshotLoader.LoadForExamAsync(exam!.Id, cancellationToken)).ToList(); var existing = allResults.FirstOrDefault(item => item.RegistrationId == registration.Id && item.SubjectId == subjectId); var published = JsBoolean(body["published"]); @@ -146,10 +272,12 @@ internal sealed class AdminResultService( now, published ? existing?.PublishedAt ?? now : null); Replace(allResults, candidate); - candidate = candidate with - { - Grade = published ? AdminResultScoring.Rank(allResults, candidate).Grade : "待发布" - }; + var calculation = AdminResultStatistics.Calculate( + exam, + operational.Registrations, + allResults, + now); + candidate = ApplyMetric(candidate, calculation); var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId); await repository.SaveAsync( new ResultWrite( @@ -158,7 +286,8 @@ internal sealed class AdminResultService( Audit( user, published ? "发布成绩" : "保存成绩", - $"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(score)}")), + $"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultStatistics.Format(score)}")), + calculation, cancellationToken); await cache.InvalidateAsync("results"); return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) }); @@ -180,7 +309,7 @@ internal sealed class AdminResultService( var sourceRows = body["rows"] as JsonArray ?? []; if (sourceRows.Count == 0) return Error(400, "没有需要保存的成绩"); var published = JsBoolean(body["published"]); - var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList(); + var allResults = (await resultSnapshotLoader.LoadForExamAsync(exam.Id, cancellationToken)).ToList(); var prepared = new List(); var seen = new HashSet(StringComparer.Ordinal); for (var index = 0; index < sourceRows.Count; index++) @@ -195,8 +324,8 @@ internal sealed class AdminResultService( return Error(400, $"第 {index + 1} 条考生成绩无效或重复"); var score = Number(row["score"]); var fullScore = Number(subject["fullScore"]); - if (!double.IsFinite(score) || score < 0 || score > fullScore) - return Error(400, $"第 {index + 3} 行:成绩须在 0—{AdminResultScoring.Format(fullScore)} 之间;请返回预览修正后重试"); + if (!AdminResultStatistics.IsValidScore(score, fullScore)) + return Error(400, $"第 {index + 1} 条:成绩须在 0—{AdminResultStatistics.Format(fullScore)} 之间,且只能填写整数或 .5"); var existing = allResults.FirstOrDefault(item => item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"])); var now = NowIso(); @@ -212,12 +341,14 @@ internal sealed class AdminResultService( Replace(allResults, result); prepared.Add(new(registration, existing, result)); } + var calculation = AdminResultStatistics.Calculate( + exam, + operational.Registrations, + allResults, + NowIso()); var writes = prepared.Select(item => { - var result = item.Result with - { - Grade = published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布" - }; + var result = ApplyMetric(item.Result, calculation); var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.Registration.UserId); var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId); return new ResultWrite( @@ -226,9 +357,9 @@ internal sealed class AdminResultService( Audit( user, published ? "Excel 批量发布成绩" : "Excel 批量保存成绩", - $"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(result.Score)}")); + $"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultStatistics.Format(result.Score)}")); }).ToArray(); - await repository.SaveManyAsync(writes, cancellationToken); + await repository.SaveManyAsync(writes, [calculation], cancellationToken); await cache.InvalidateAsync("results"); var create = prepared.Count(item => item.Existing is null); return Success(new JsonObject @@ -286,8 +417,8 @@ internal sealed class AdminResultService( else if (registration is null) message = "该考生没有已通过的本场考试报名"; else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目"; var score = Text(row["score"]).Length == 0 ? double.NaN : Number(row["score"]); - if (message is null && (!double.IsFinite(score) || score < 0 || score > Number(subject?["fullScore"]))) - message = $"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间"; + if (message is null && !AdminResultStatistics.IsValidScore(score, Number(subject?["fullScore"]))) + message = $"成绩须在 0—{AdminResultStatistics.Format(Number(subject?["fullScore"]))} 之间,且只能填写整数或 .5"; var publishText = Text(row["published"]); bool published; if (row["published"] is JsonValue publishedValue && @@ -316,19 +447,28 @@ internal sealed class AdminResultService( Replace(allResults, result); prepared.Add(new(registration, exam!, subject!, existing, result, candidateNumber)); } + var calculations = prepared + .Select(item => item.Exam) + .DistinctBy(item => item.Id) + .Select(exam => AdminResultStatistics.Calculate( + exam, + operational.Registrations, + allResults, + NowIso())) + .ToArray(); + var metricByResultId = calculations + .SelectMany(item => item.Metrics) + .ToDictionary(item => item.ResultId, StringComparer.Ordinal); var writes = prepared.Select(item => { - var result = item.Result with - { - Grade = item.Result.Published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布" - }; + var result = ApplyMetric(item.Result, metricByResultId.GetValueOrDefault(item.Result.Id)); return new ResultWrite( result, item.Existing is null, Audit(user, result.Published ? "Excel 批量发布成绩" : "Excel 批量保存成绩", - $"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.Subject["name"])} · {AdminResultScoring.Format(result.Score)}")); + $"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.Subject["name"])} · {AdminResultStatistics.Format(result.Score)}")); }).ToArray(); - await repository.SaveManyAsync(writes, cancellationToken); + await repository.SaveManyAsync(writes, calculations, cancellationToken); await cache.InvalidateAsync("results"); var create = prepared.Count(item => item.Existing is null); return Success(new JsonObject @@ -388,8 +528,8 @@ internal sealed class AdminResultService( !SubjectIds(registration).Contains(Text(subject["id"]))) AddError("该考生未报考此科目"); var score = Text(source["score"]).Length == 0 ? double.NaN : Number(source["score"]); - if (!double.IsFinite(score) || score < 0 || subject is not null && score > Number(subject["fullScore"])) - AddError($"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间"); + if (!AdminResultStatistics.IsValidScore(score, Number(subject?["fullScore"]))) + AddError($"成绩须在 0—{AdminResultStatistics.Format(Number(subject?["fullScore"]))} 之间,且只能填写整数或 .5"); var publishedText = Text(source["published"]); var published = source["published"] is JsonValue publishedValue && publishedValue.TryGetValue(out var publishedBoolean) @@ -441,21 +581,35 @@ internal sealed class AdminResultService( ["errors"] = errors }); } + var previewCalculations = output + .Where(item => (item["errors"] as JsonArray)?.Count == 0) + .Select(item => Text(item["examId"])) + .Distinct(StringComparer.Ordinal) + .Select(examId => operational.Exams.First(item => item.Id == examId)) + .Select(exam => AdminResultStatistics.Calculate( + exam, + operational.Registrations, + previewResults, + NowIso())) + .ToArray(); + var previewMetricByResultId = previewCalculations + .SelectMany(item => item.Metrics) + .ToDictionary(item => item.ResultId, StringComparer.Ordinal); foreach (var row in output.Where(item => (item["errors"] as JsonArray)?.Count == 0)) { var result = previewResults.First(item => item.RegistrationId == Text(row["registrationId"]) && item.SubjectId == Text(row["subjectId"])); var exam = operational.Exams.First(item => item.Id == Text(row["examId"])); var subject = exam.Subjects.First(item => Text(item["id"]) == result.SubjectId); - var pass = AdminResultScoring.Pass(previewResults, result, subject); - row["rank"] = pass.Rank.Rank; - row["cohortSize"] = pass.Rank.CohortSize; - row["rankPercent"] = pass.Rank.RankPercent; - row["grade"] = result.Published ? pass.Rank.Grade : "待发布"; - row["passScore"] = pass.PassScore; - row["cutoffRank"] = pass.CutoffRank; - row["qualified"] = JsonValue.Create(pass.Qualified); - row["passText"] = AdminResultScoring.PassText(subject); + var metric = previewMetricByResultId[result.Id]; + row["rank"] = metric.Rank; + row["cohortSize"] = metric.CohortSize; + row["rankPercent"] = metric.RankPercent; + row["grade"] = metric.Grade; + row["passScore"] = metric.PassScore; + row["cutoffRank"] = metric.CutoffRank; + row["qualified"] = JsonValue.Create(metric.Qualified); + row["passText"] = AdminResultStatistics.PassText(subject); } var invalid = output.Count(item => (item["errors"] as JsonArray)?.Count > 0); return Success(new JsonObject @@ -515,7 +669,7 @@ internal sealed class AdminResultService( entries.Add(new( registration.Id, score, - Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultScoring.Format(score)}"))); + Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultStatistics.Format(score)}"))); } await repository.SaveFeatureScoresAsync(entries, cancellationToken); await cache.InvalidateAsync("results"); @@ -546,7 +700,7 @@ internal sealed class AdminResultService( [new( registration.Id, score, - Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultScoring.Format(score)}"))], + Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultStatistics.Format(score)}"))], cancellationToken); await cache.InvalidateAsync("results"); var output = registration.Data.DeepClone().AsObject(); @@ -582,7 +736,9 @@ internal sealed class AdminResultService( var user = context.User!; var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); var directory = await directorySnapshotLoader.LoadAsync(cancellationToken); - var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken); + var allResults = examId.Length > 0 + ? await resultSnapshotLoader.LoadForExamAsync(examId, cancellationToken) + : await resultSnapshotLoader.LoadAsync(cancellationToken); var registrations = operational.Registrations.Where(item => { if (item.Status != "approved" || (examId.Length > 0 && item.ExamId != examId)) return false; @@ -600,7 +756,6 @@ internal sealed class AdminResultService( { var result = allResults.FirstOrDefault(item => item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"])); - var pass = result is null ? null : AdminResultScoring.Pass(allResults, result, subject); var card = registration.Data["admitCard"] as JsonObject; rows.Add(new( account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]), @@ -612,13 +767,13 @@ internal sealed class AdminResultService( Text(exam?.Data["name"]), Text(subject["name"]), Number(subject["fullScore"]), - AdminResultScoring.PassText(subject), - pass?.PassScore is double passScore ? passScore : "", + AdminResultStatistics.PassText(subject), + result?.PassScore is double passScore ? passScore : "", result is null ? "" : result.Score, - pass?.Rank.Rank is int rank ? rank : "", - pass?.Rank.RankPercent is double percent ? percent : "", - result is null || pass?.Qualified is null ? "" : pass.Qualified.Value ? "达线" : "未达线", - result is null ? "" : result.Published ? pass?.Rank.Grade ?? "" : "待发布", + result?.Rank is int rank ? rank : "", + result?.RankPercent is double percent ? percent : "", + result?.Qualified is null ? "" : result.Qualified.Value ? "达线" : "未达线", + result is null ? "" : result.Published ? result.Grade : "待发布", result?.Published is true ? "发布" : "不发布", result?.UpdatedAt ?? result?.PublishedAt ?? "")); } @@ -674,8 +829,8 @@ internal sealed class AdminResultService( { reviewedScore = Number(body["reviewedScore"]); var fullScore = Number(subject?["fullScore"]); - if (!double.IsFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > fullScore) - return Error(400, $"最终审批必须填写 0—{AdminResultScoring.Format(fullScore)} 之间的复核后分数"); + if (!AdminResultStatistics.IsValidScore(reviewedScore, fullScore)) + return Error(400, $"最终审批必须填写 0—{AdminResultStatistics.Format(fullScore)} 之间的整数或 .5 分数"); } var now = NowIso(); @@ -684,6 +839,7 @@ internal sealed class AdminResultService( string? nextAssigneeId = null; AccountWorkflowInstance updatedInstance; AdminWorkflowResult? updatedResult = null; + AdminResultCalculation? calculation = null; if (requestedStatus == "rejected") { actionName = "reject"; @@ -712,22 +868,23 @@ internal sealed class AdminResultService( var originalScore = result.Score; updatedResult = result with { Score = reviewedScore, UpdatedAt = now }; Replace(allResults, updatedResult); - updatedResult = updatedResult with - { - Grade = AdminResultScoring.Rank(allResults, updatedResult, reviewedScore).Grade - }; + calculation = AdminResultStatistics.Calculate( + exam!, + operational.Registrations, + allResults, + now); + updatedResult = ApplyMetric(updatedResult, calculation); Replace(allResults, updatedResult); - var pass = AdminResultScoring.Pass(allResults, updatedResult, subject, reviewedScore); - var conclusion = pass.Qualified is null + var conclusion = updatedResult.Qualified is null ? "本科不判定单科达线" - : pass.Qualified.Value - ? $"达到{PassConclusion(subject, pass)}" - : $"未达到{PassConclusion(subject, pass)}"; + : updatedResult.Qualified.Value + ? $"达到{PassConclusion(subject, updatedResult)}" + : $"未达到{PassConclusion(subject, updatedResult)}"; note = string.Join( ';', new[] { - $"原分 {AdminResultScoring.Format(originalScore)} → 复核后 {AdminResultScoring.Format(reviewedScore)}", + $"原分 {AdminResultStatistics.Format(originalScore)} → 复核后 {AdminResultStatistics.Format(reviewedScore)}", conclusion, note }.Where(item => item.Length > 0)); @@ -745,11 +902,12 @@ internal sealed class AdminResultService( ? "退回成绩复议" : finalStep ? "复议终审并更新成绩" : "处理成绩复议"; var detail = $"{Text(profile.Data["name"])} · {Text(exam?.Data["name"])} · {Text(subject?["name"])}" + - (requestedStatus == "approved" && finalStep ? $" · {AdminResultScoring.Format(reviewedScore)} 分" : ""); + (requestedStatus == "approved" && finalStep ? $" · {AdminResultStatistics.Format(reviewedScore)} 分" : ""); await repository.ReviewAppealAsync( updatedInstance, action, updatedResult, + calculation, Audit(user, auditAction, detail), cancellationToken); if (updatedResult is not null) @@ -757,16 +915,13 @@ internal sealed class AdminResultService( await cache.InvalidateAsync("results"); } var responseResult = updatedResult ?? result; - var rank = AdminResultScoring.Rank(allResults, responseResult); - var responsePass = AdminResultScoring.Pass(allResults, responseResult, subject); var resultOutput = RawResultJson(responseResult); - resultOutput["rank"] = JsonValue.Create(rank.Rank); - resultOutput["cohortSize"] = rank.CohortSize; - resultOutput["rankPercent"] = JsonValue.Create(rank.RankPercent); - resultOutput["grade"] = rank.Grade; - resultOutput["passScore"] = JsonValue.Create(responsePass.PassScore); - resultOutput["cutoffRank"] = JsonValue.Create(responsePass.CutoffRank); - resultOutput["qualified"] = JsonValue.Create(responsePass.Qualified); + resultOutput["rank"] = JsonValue.Create(responseResult.Rank); + resultOutput["cohortSize"] = responseResult.CohortSize; + resultOutput["rankPercent"] = JsonValue.Create(responseResult.RankPercent); + resultOutput["passScore"] = JsonValue.Create(responseResult.PassScore); + resultOutput["cutoffRank"] = JsonValue.Create(responseResult.CutoffRank); + resultOutput["qualified"] = JsonValue.Create(responseResult.Qualified); return Success(new JsonObject { ["ok"] = true, @@ -776,23 +931,19 @@ internal sealed class AdminResultService( } private static JsonObject ResultJson( - AdminOperationalSnapshot operational, - AdminAccountBatchSnapshot directory, + ResultLookups lookups, OperationalExam exam, - IReadOnlyList cohortResults, AdminWorkflowResult result) { - var registration = operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId); - var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId); - var account = operational.Users.FirstOrDefault(item => item.Id == registration?.UserId); - var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId); + var registration = lookups.RegistrationById.GetValueOrDefault(result.RegistrationId); + var profile = registration is null ? null : lookups.ProfileByUserId.GetValueOrDefault(registration.UserId); + var account = registration is null ? null : lookups.AccountByUserId.GetValueOrDefault(registration.UserId); + var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId); var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId); - var pass = AdminResultScoring.Pass(cohortResults, result, subject); var output = RawResultJson(result); - output["grade"] = result.Published ? pass.Rank.Grade : result.Grade; - output["rank"] = JsonValue.Create(pass.Rank.Rank); - output["cohortSize"] = pass.Rank.CohortSize; - output["rankPercent"] = JsonValue.Create(pass.Rank.RankPercent); + output["rank"] = JsonValue.Create(result.Rank); + output["cohortSize"] = result.CohortSize; + output["rankPercent"] = JsonValue.Create(result.RankPercent); output["candidateName"] = Text(profile?.Data["name"]); output["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]); output["schoolName"] = Text(profile?.Data["school"]); @@ -804,20 +955,19 @@ internal sealed class AdminResultService( output["fullScore"] = subject?["fullScore"]?.DeepClone(); output["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score"; output["passValue"] = (subject?["passValue"] ?? subject?["passScore"])?.DeepClone(); - output["passScore"] = JsonValue.Create(pass.PassScore); - output["cutoffRank"] = JsonValue.Create(pass.CutoffRank); - output["passText"] = AdminResultScoring.PassText(subject); - output["qualified"] = JsonValue.Create(pass.Qualified); + output["passScore"] = JsonValue.Create(result.PassScore); + output["cutoffRank"] = JsonValue.Create(result.CutoffRank); + output["passText"] = AdminResultStatistics.PassText(subject); + output["qualified"] = JsonValue.Create(result.Qualified); return output; } private static JsonObject RegistrationJson( - AdminOperationalSnapshot operational, - AdminAccountBatchSnapshot directory, + ResultLookups lookups, OperationalRegistration registration) { var output = registration.Data.DeepClone().AsObject(); - var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId); + var exam = lookups.Operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId); var subjects = (exam?.Subjects ?? []) .Where(item => SubjectIds(registration).Contains(Text(item["id"]))) .Select(item => item.DeepClone()) @@ -825,15 +975,15 @@ internal sealed class AdminResultService( output["exam"] = exam?.Data.DeepClone(); output["subjects"] = new JsonArray(subjects); output["amountDue"] = Math.Round(subjects.Sum(item => Number(item?["fee"])), 2, MidpointRounding.AwayFromZero); - output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? ""; - var instance = directory.Instances.FirstOrDefault(item => + output["paidByName"] = lookups.Operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? ""; + var instance = lookups.Directory.Instances.FirstOrDefault(item => item.BusinessType == "registration_review" && item.BusinessId == registration.Id && item.Status == "pending") ?? - directory.Instances.FirstOrDefault(item => + lookups.Directory.Instances.FirstOrDefault(item => item.BusinessType == "registration_review" && item.BusinessId == registration.Id); - output["workflow"] = instance is null ? null : WorkflowJson(directory, instance); - var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId); - var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId); - var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId); + output["workflow"] = instance is null ? null : WorkflowJson(lookups.Directory, instance); + var profile = lookups.ProfileByUserId.GetValueOrDefault(registration.UserId); + var account = lookups.AccountByUserId.GetValueOrDefault(registration.UserId); + var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId); output["candidateName"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? ""; output["candidateNumber"] = account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]); output["schoolName"] = Text(profile?.Data["school"]); @@ -846,46 +996,6 @@ internal sealed class AdminResultService( return output; } - private static ResultSummary Summary( - OperationalExam exam, - IReadOnlyList examRegistrations, - IReadOnlyList examResults, - OperationalRegistration registration) - { - var subjects = exam.Subjects.Where(item => SubjectIds(registration).Contains(Text(item["id"]))).ToArray(); - var published = examResults.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); - var bySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal); - var complete = subjects.Length > 0 && subjects.All(item => bySubject.ContainsKey(Text(item["id"]))); - if (!complete) return new(false, null); - var total = subjects.Sum(item => bySubject[Text(item["id"])].Score); - var policy = Text(exam.Data["passPolicy"]); - if (policy == "score_ratio") policy = "rank_percent"; - if (policy.Length == 0) policy = "rank_percent"; - var value = Number(exam.Data["passValue"]); - if (policy == "none") return new(true, null); - if (policy == "fixed_score") return new(true, total >= value); - if (policy == "subject_scores") - return new(true, subjects.All(item => - AdminResultScoring.Pass(examResults, bySubject[Text(item["id"])], item).Qualified is not false)); - var key = string.Join('|', SubjectIds(registration).Order(StringComparer.Ordinal)); - var totals = examRegistrations.Where(item => - string.Join('|', SubjectIds(item).Order(StringComparer.Ordinal)) == key) - .Select(item => - { - var itemResults = examResults.Where(result => result.RegistrationId == item.Id && result.Published) - .ToDictionary(result => result.SubjectId, StringComparer.Ordinal); - return subjects.All(subject => itemResults.ContainsKey(Text(subject["id"]))) - ? subjects.Sum(subject => itemResults[Text(subject["id"])].Score) - : (double?)null; - }) - .Where(item => item.HasValue) - .Select(item => item!.Value) - .ToArray(); - var rank = 1 + totals.Count(item => item > total); - var cutoff = Math.Max(1, (int)Math.Ceiling(totals.Length * value / 100)); - return new(true, rank <= cutoff); - } - private static JsonObject WorkflowJson( AdminAccountBatchSnapshot snapshot, AccountWorkflowInstance instance, @@ -945,6 +1055,60 @@ internal sealed class AdminResultService( ? items.Select(Text).Where(item => item.Length > 0).ToHashSet(StringComparer.Ordinal) : []; + private static string ResultSearchText(ResultLookups lookups, OperationalExam exam, AdminWorkflowResult result) + { + var registration = lookups.RegistrationById.GetValueOrDefault(result.RegistrationId); + var profile = registration is null ? null : lookups.ProfileByUserId.GetValueOrDefault(registration.UserId); + var account = registration is null ? null : lookups.AccountByUserId.GetValueOrDefault(registration.UserId); + var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId); + var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId); + return string.Join(' ', + account?.CandidateNumber, + account?.DisplayName, + Text(profile?.Data["name"]), + Text(profile?.Data["school"]), + schoolClass?.Name, + Text(subject?["name"]), + Text(exam.Data["code"]), + Text(exam.Data["name"])); + } + + private static string RegistrationSearchText(ResultLookups lookups, OperationalRegistration registration) + { + var profile = lookups.ProfileByUserId.GetValueOrDefault(registration.UserId); + var account = lookups.AccountByUserId.GetValueOrDefault(registration.UserId); + var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId); + return string.Join(' ', + account?.CandidateNumber, + account?.DisplayName, + Text(profile?.Data["name"]), + Text(profile?.Data["school"]), + schoolClass?.Name, + Text(profile?.Data["specialtyCategory"]), + Text(profile?.Data["specialtyType"])); + } + + private static PageResult Page(int total, int requestedPage, int requestedPageSize, string key) + { + var pageSize = requestedPageSize is 20 or 50 or 100 ? requestedPageSize : 50; + var totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize)); + var page = Math.Min(Math.Max(1, requestedPage), totalPages); + return new( + page, + pageSize, + total, + totalPages, + (page - 1) * pageSize, + new JsonObject + { + ["page"] = page, + ["pageSize"] = pageSize, + ["total"] = total, + ["totalPages"] = totalPages, + ["key"] = key + }); + } + private static JsonObject RawResultJson(AdminWorkflowResult result) => new() { ["id"] = result.Id, @@ -988,6 +1152,28 @@ internal sealed class AdminResultService( ["status"] = cache.Status }; + private static AdminWorkflowResult ApplyMetric( + AdminWorkflowResult result, + AdminResultCalculation calculation) => + ApplyMetric( + result, + calculation.Metrics.FirstOrDefault(item => item.ResultId == result.Id)); + + private static AdminWorkflowResult ApplyMetric( + AdminWorkflowResult result, + AdminResultMetric? metric) => metric is null + ? result + : result with + { + Grade = metric.Grade, + Rank = metric.Rank, + CohortSize = metric.CohortSize, + RankPercent = metric.RankPercent, + Qualified = metric.Qualified, + PassScore = metric.PassScore, + CutoffRank = metric.CutoffRank + }; + private static void Replace(List results, AdminWorkflowResult result) { var index = results.FindIndex(item => item.Id == result.Id); @@ -1053,11 +1239,11 @@ internal sealed class AdminResultService( .FirstOrDefault(); } - private static string PassConclusion(JsonObject? subject, AdminPassInfo pass) + private static string PassConclusion(JsonObject? subject, AdminWorkflowResult result) { if (Text(subject?["passRule"]) == "rank_percent") - return $"排名前 {AdminResultScoring.Format(Number(subject?["passValue"]))}%(当前第 {pass.Rank.Rank} 名,截止第 {pass.CutoffRank} 名)"; - return $"固定及格线 {AdminResultScoring.Format(pass.PassScore ?? 0)} 分"; + return $"排名前 {AdminResultStatistics.Format(Number(subject?["passValue"]))}%(当前第 {result.Rank} 名,截止第 {result.CutoffRank} 名)"; + return $"固定及格线 {AdminResultStatistics.Format(result.PassScore ?? 0)} 分"; } private static JsonObject StepJson(AccountWorkflowStep item) => new() @@ -1156,7 +1342,40 @@ internal sealed class AdminResultService( AdminWorkflowResult? Existing, AdminWorkflowResult Result, string CandidateNumber); - private sealed record ResultSummary(bool Complete, bool? Qualified); + private sealed record PageResult( + int Page, + int PageSize, + int Total, + int TotalPages, + int Offset, + JsonObject Json); + private sealed record ResultLookups( + AdminOperationalSnapshot Operational, + AdminAccountBatchSnapshot Directory, + IReadOnlyDictionary RegistrationById, + IReadOnlyDictionary ProfileByUserId, + IReadOnlyDictionary AccountByUserId, + IReadOnlyDictionary AccountByRegistrationId, + IReadOnlyDictionary ClassById) + { + public static ResultLookups Create( + AdminOperationalSnapshot operational, + AdminAccountBatchSnapshot directory) + { + var registrations = operational.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal); + var accounts = operational.Users.ToDictionary(item => item.Id, StringComparer.Ordinal); + return new( + operational, + directory, + registrations, + operational.Profiles.ToDictionary(item => item.UserId, StringComparer.Ordinal), + accounts, + registrations.Values + .Where(item => accounts.ContainsKey(item.UserId)) + .ToDictionary(item => item.Id, item => accounts[item.UserId], StringComparer.Ordinal), + directory.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal)); + } + } private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); diff --git a/src/Eis.Infrastructure/Administration/AdminResultStatistics.cs b/src/Eis.Infrastructure/Administration/AdminResultStatistics.cs new file mode 100644 index 0000000..6c31ab1 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminResultStatistics.cs @@ -0,0 +1,264 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace Eis.Infrastructure.Administration; + +internal sealed record AdminResultMetric( + string ResultId, + int? Rank, + int CohortSize, + double? RankPercent, + string Grade, + bool? Qualified, + double? PassScore, + int? CutoffRank, + string CalculatedAt); + +internal sealed record AdminRegistrationResultSummary( + string RegistrationId, + string ExamId, + int PublishedSubjectCount, + int SubjectCount, + bool Complete, + double TotalScore, + double FullScore, + double ScoreRatio, + int? Rank, + int CohortSize, + double? RankPercent, + bool? Qualified, + int? CutoffRank, + string CalculatedAt); + +internal sealed record AdminResultCalculation( + string ExamId, + IReadOnlyList Metrics, + IReadOnlyList Summaries); + +internal static class AdminResultStatistics +{ + public static AdminResultCalculation Calculate( + OperationalExam exam, + IReadOnlyList registrations, + IReadOnlyList results, + string calculatedAt) + { + var approved = registrations + .Where(item => item.ExamId == exam.Id && item.Status == "approved") + .ToArray(); + var registrationById = approved.ToDictionary(item => item.Id, StringComparer.Ordinal); + var examResults = results + .Where(item => registrationById.ContainsKey(item.RegistrationId)) + .ToArray(); + var subjectById = exam.Subjects + .Where(item => Text(item["id"]).Length > 0) + .ToDictionary(item => Text(item["id"]), StringComparer.Ordinal); + var resultsBySubject = examResults + .GroupBy(item => item.SubjectId) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var metrics = new List(examResults.Length); + + foreach (var subjectGroup in resultsBySubject) + { + var subject = subjectById.GetValueOrDefault(subjectGroup.Key); + var publishedScores = subjectGroup.Value + .Where(item => item.Published) + .Select(item => item.Score) + .OrderByDescending(item => item) + .ToArray(); + var publishedCount = publishedScores.Length; + foreach (var result in subjectGroup.Value) + { + var cohortSize = publishedCount + (result.Published ? 0 : 1); + int? rank = cohortSize == 0 ? null : 1 + CountGreater(publishedScores, result.Score); + double? rankPercent = rank is null + ? null + : Math.Round(rank.Value / (double)cohortSize * 100, 2, MidpointRounding.AwayFromZero); + var grade = result.Published && rank is not null ? Grade(rank.Value, cohortSize) : "待发布"; + var pass = Pass(subject, result.Score, rank, cohortSize); + metrics.Add(new( + result.Id, + rank, + cohortSize, + rankPercent, + grade, + pass.Qualified, + pass.PassScore, + pass.CutoffRank, + calculatedAt)); + } + } + + var metricByResultId = metrics.ToDictionary(item => item.ResultId, StringComparer.Ordinal); + var resultsByRegistration = examResults + .GroupBy(item => item.RegistrationId) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var states = approved.Select(registration => + { + var subjectIds = SubjectIds(registration) + .Where(subjectById.ContainsKey) + .Order(StringComparer.Ordinal) + .ToArray(); + var publishedBySubject = resultsByRegistration.GetValueOrDefault(registration.Id, []) + .Where(item => item.Published && subjectIds.Contains(item.SubjectId, StringComparer.Ordinal)) + .GroupBy(item => item.SubjectId) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal); + var complete = subjectIds.Length > 0 && subjectIds.All(publishedBySubject.ContainsKey); + var total = Math.Round( + subjectIds.Sum(subjectId => publishedBySubject.GetValueOrDefault(subjectId)?.Score ?? 0), + 2, + MidpointRounding.AwayFromZero); + var fullScore = Math.Round( + subjectIds.Sum(subjectId => Number(subjectById[subjectId]["fullScore"])), + 2, + MidpointRounding.AwayFromZero); + return new RegistrationState( + registration, + subjectIds, + publishedBySubject, + complete, + total, + fullScore, + string.Join('|', subjectIds)); + }).ToArray(); + + var rankByRegistration = new Dictionary(StringComparer.Ordinal); + foreach (var group in states.Where(item => item.Complete).GroupBy(item => item.SubjectKey)) + { + var scores = group.Select(item => item.TotalScore).OrderByDescending(item => item).ToArray(); + foreach (var item in group) + { + var rank = 1 + CountGreater(scores, item.TotalScore); + rankByRegistration[item.Registration.Id] = new( + rank, + scores.Length, + Math.Round(rank / (double)scores.Length * 100, 2, MidpointRounding.AwayFromZero)); + } + } + + var policy = Text(exam.Data["passPolicy"]); + if (policy is "" or "score_ratio") policy = "rank_percent"; + var policyValue = Number(exam.Data["passValue"]); + var summaries = states.Select(item => + { + var totalRank = rankByRegistration.GetValueOrDefault(item.Registration.Id); + bool? qualified = null; + int? cutoffRank = null; + if (item.Complete) + { + if (policy == "fixed_score") qualified = item.TotalScore >= policyValue; + else if (policy == "subject_scores") + qualified = item.PublishedBySubject.Values.All(result => + metricByResultId.GetValueOrDefault(result.Id)?.Qualified is not false); + else if (policy == "rank_percent" && totalRank is not null) + { + cutoffRank = Math.Max(1, (int)Math.Ceiling(totalRank.CohortSize * policyValue / 100)); + qualified = totalRank.Rank <= cutoffRank; + } + } + var scoreRatio = item.FullScore > 0 + ? Math.Round(item.TotalScore / item.FullScore * 100, 2, MidpointRounding.AwayFromZero) + : 0; + return new AdminRegistrationResultSummary( + item.Registration.Id, + exam.Id, + item.PublishedBySubject.Count, + item.SubjectIds.Length, + item.Complete, + item.TotalScore, + item.FullScore, + scoreRatio, + totalRank?.Rank, + totalRank?.CohortSize ?? 0, + totalRank?.RankPercent, + qualified, + cutoffRank, + calculatedAt); + }).ToArray(); + + return new(exam.Id, metrics, summaries); + } + + public static string PassText(JsonObject? subject) + { + var rule = Text(subject?["passRule"]); + if (rule == "none") return "不设单科线"; + if (rule == "rank_percent") + return $"本科排名前 {Format(Number(subject?["passValue"], 60))}% 达线"; + return $"固定 {Format(Math.Round(Number(subject?["passValue"] ?? subject?["passScore"]), 2, MidpointRounding.AwayFromZero))} 分"; + } + + public static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + + public static bool IsValidScore(double score, double fullScore) => + double.IsFinite(score) && + score >= 0 && + score <= fullScore && + Math.Abs(score * 2 - Math.Round(score * 2, MidpointRounding.AwayFromZero)) < 0.0000001; + + private static SubjectPass Pass(JsonObject? subject, double score, int? rank, int cohortSize) + { + var rule = Text(subject?["passRule"]); + if (rule.Length == 0) rule = "fixed_score"; + if (rule == "none") return new(null, null, null); + if (rule == "rank_percent") + { + var cutoff = Math.Max(1, (int)Math.Ceiling(cohortSize * Number(subject?["passValue"], 60) / 100)); + return new(rank <= cutoff, null, cutoff); + } + var threshold = Math.Round( + Number(subject?["passValue"] ?? subject?["passScore"]), + 2, + MidpointRounding.AwayFromZero); + return new(score >= threshold, threshold, null); + } + + private static int CountGreater(IReadOnlyList descendingScores, double score) + { + var low = 0; + var high = descendingScores.Count; + while (low < high) + { + var middle = low + (high - low) / 2; + if (descendingScores[middle] > score) low = middle + 1; + else high = middle; + } + return low; + } + + private static string Grade(int rank, int cohortSize) + { + int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio)); + if (rank <= Cutoff(0.1)) return "A+"; + if (rank <= Cutoff(0.25)) return "A"; + if (rank <= Cutoff(0.5)) return "B+"; + if (rank <= Cutoff(0.7)) return "B"; + return rank <= Cutoff(0.9) ? "C" : "D"; + } + + private static string[] SubjectIds(OperationalRegistration registration) => + registration.Data["subjectIds"] is JsonArray items + ? items.Select(Text).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray() + : []; + + private static double Number(JsonNode? node, double fallback = 0) => + double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : fallback; + + private static string Text(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out var text) + ? text + : node?.ToString() ?? ""; + + private sealed record SubjectPass(bool? Qualified, double? PassScore, int? CutoffRank); + private sealed record TotalRank(int Rank, int CohortSize, double RankPercent); + private sealed record RegistrationState( + OperationalRegistration Registration, + string[] SubjectIds, + IReadOnlyDictionary PublishedBySubject, + bool Complete, + double TotalScore, + double FullScore, + string SubjectKey); +} diff --git a/src/Eis.Infrastructure/Administration/AdminResultStatisticsBackfillService.cs b/src/Eis.Infrastructure/Administration/AdminResultStatisticsBackfillService.cs new file mode 100644 index 0000000..6cb68ec --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminResultStatisticsBackfillService.cs @@ -0,0 +1,29 @@ +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminResultStatisticsBackfillService( + AdminOperationalSnapshotLoader operationalSnapshotLoader, + AdminWorkflowResultSnapshotLoader resultSnapshotLoader, + AdminResultRepository repository) +{ + public async Task EnsureAsync(CancellationToken cancellationToken) + { + if (!await repository.NeedsStatisticsBackfillAsync(cancellationToken)) return; + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var calculations = new List(); + foreach (var exam in operational.Exams) + { + var registrations = operational.Registrations + .Where(item => item.ExamId == exam.Id && item.Status == "approved") + .ToArray(); + if (registrations.Length == 0) continue; + var results = await resultSnapshotLoader.LoadForExamAsync(exam.Id, cancellationToken); + calculations.Add(AdminResultStatistics.Calculate( + exam, + registrations, + results, + DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"))); + } + if (calculations.Count > 0) + await repository.ReplaceStatisticsAsync(calculations, cancellationToken); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs index 6ab8590..34fd2cd 100644 --- a/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs +++ b/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs @@ -12,15 +12,97 @@ internal sealed record AdminWorkflowResult( string Grade, bool Published, string? UpdatedAt, - string? PublishedAt); + string? PublishedAt) +{ + public int? Rank { get; init; } + public int CohortSize { get; init; } + public double? RankPercent { get; init; } + public bool? Qualified { get; init; } + public double? PassScore { get; init; } + public int? CutoffRank { get; init; } +} internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory) { public async Task> LoadAsync(CancellationToken cancellationToken) + => await LoadAsync(null, null, cancellationToken); + + public async Task> LoadForExamAsync( + string examId, + CancellationToken cancellationToken, + string? subjectId = null) => + await LoadAsync(examId, subjectId, cancellationToken); + + public async Task> LoadSummariesForExamAsync( + string examId, + CancellationToken cancellationToken) + => await LoadSummariesAsync(examId, cancellationToken); + + public async Task> LoadSummariesAsync( + CancellationToken cancellationToken) + => await LoadSummariesAsync(null, cancellationToken); + + private async Task> LoadSummariesAsync( + string? examId, + CancellationToken cancellationToken) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); - command.CommandText = "SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id"; + command.CommandText = + """ + SELECT registration_id, exam_id, published_subject_count, subject_count, complete, + total_score, full_score, score_ratio, total_rank, cohort_size, rank_percent, + qualified, cutoff_rank, calculated_at + FROM registration_result_summaries + WHERE (@examId IS NULL OR exam_id = @examId) ORDER BY registration_id + """; + AddParameter(command, "@examId", examId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var output = new List(); + while (await reader.ReadAsync(cancellationToken)) + { + output.Add(new( + Text(reader, "registration_id"), + Text(reader, "exam_id"), + Integer(reader, "published_subject_count") ?? 0, + Integer(reader, "subject_count") ?? 0, + Boolean(reader, "complete"), + Number(reader, "total_score"), + Number(reader, "full_score"), + Number(reader, "score_ratio"), + Integer(reader, "total_rank"), + Integer(reader, "cohort_size") ?? 0, + NullableNumber(reader, "rank_percent"), + NullableBoolean(reader, "qualified"), + Integer(reader, "cutoff_rank"), + Text(reader, "calculated_at"))); + } + return output; + } + + private async Task> LoadAsync( + string? examId, + string? subjectId, + CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT result.id, result.registration_id, result.subject_id, result.score, + COALESCE(statistic.grade, result.grade) AS grade, result.published, + result.updated_at, result.published_at, statistic.subject_rank, + statistic.cohort_size, statistic.rank_percent, statistic.qualified, + statistic.pass_score, statistic.cutoff_rank + FROM results result + JOIN registrations registration ON registration.id = result.registration_id + LEFT JOIN result_statistics statistic ON statistic.result_id = result.id + WHERE (@examId IS NULL OR registration.exam_id = @examId) + AND (@subjectId IS NULL OR result.subject_id = @subjectId) + ORDER BY result.id + """; + AddParameter(command, "@examId", examId); + AddParameter(command, "@subjectId", subjectId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); var output = new List(); while (await reader.ReadAsync(cancellationToken)) @@ -33,20 +115,60 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac Text(reader, "grade"), Boolean(reader, "published"), Optional(reader, "updated_at"), - Optional(reader, "published_at"))); + Optional(reader, "published_at")) + { + Rank = Integer(reader, "subject_rank"), + CohortSize = Integer(reader, "cohort_size") ?? 0, + RankPercent = NullableNumber(reader, "rank_percent"), + Qualified = NullableBoolean(reader, "qualified"), + PassScore = NullableNumber(reader, "pass_score"), + CutoffRank = Integer(reader, "cutoff_rank") + }); } return output; } + private static void AddParameter(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? ""; private static double Number(DbDataReader reader, string name) => Convert.ToDouble(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); + private static double? NullableNumber(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) + ? null + : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private static int? Integer(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) + ? null + : Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + private static bool Boolean(DbDataReader reader, string name) => Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0; + private static bool? NullableBoolean(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) + ? null + : Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0; + } + private static string? Optional(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); diff --git a/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs b/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs index 972e5ac..6e10094 100644 --- a/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs +++ b/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs @@ -231,63 +231,26 @@ internal sealed class AdminWorkflowService( var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId); var exam = snapshots.Operational.Exams.FirstOrDefault(item => item.Id == registration?.ExamId); var subject = exam?.Subjects.FirstOrDefault(item => item["id"]?.GetValue() == result.SubjectId); - var rank = Rank(snapshots.Results, result); - var pass = Pass(snapshots.Results, result, subject); return new JsonObject { ["score"] = result.Score, - ["grade"] = rank.Grade.Length > 0 ? rank.Grade : result.Grade, - ["rank"] = JsonValue.Create(rank.Rank), - ["cohortSize"] = rank.CohortSize, - ["rankPercent"] = JsonValue.Create(rank.RankPercent), + ["grade"] = result.Grade, + ["rank"] = JsonValue.Create(result.Rank), + ["cohortSize"] = result.CohortSize, + ["rankPercent"] = JsonValue.Create(result.RankPercent), ["examName"] = exam?.Data["name"]?.GetValue() ?? "", ["examCode"] = exam?.Data["code"]?.GetValue() ?? "", ["subjectName"] = subject?["name"]?.GetValue() ?? "", ["fullScore"] = JsonValue.Create(NullableNumber(subject?["fullScore"])), ["passRule"] = subject?["passRule"]?.GetValue() ?? "fixed_score", ["passValue"] = JsonValue.Create(NullableNumber(subject?["passValue"] ?? subject?["passScore"])), - ["passScore"] = JsonValue.Create(pass.PassScore), - ["cutoffRank"] = JsonValue.Create(pass.CutoffRank), + ["passScore"] = JsonValue.Create(result.PassScore), + ["cutoffRank"] = JsonValue.Create(result.CutoffRank), ["passText"] = PassText(subject), - ["qualified"] = JsonValue.Create(pass.Qualified) + ["qualified"] = JsonValue.Create(result.Qualified) }; } - private static RankInfo Rank(IReadOnlyList results, AdminWorkflowResult result) - { - var scores = results.Where(item => item.SubjectId == result.SubjectId && item.Published).Select(item => item.Score).Order().ToArray(); - var cohortSize = scores.Length + (result.Published ? 0 : 1); - if (cohortSize == 0) return new(null, 0, null, ""); - var rank = 1 + scores.Count(score => score > result.Score); - var percent = Math.Round((double)rank / cohortSize * 100, 2, MidpointRounding.AwayFromZero); - return new(rank, cohortSize, percent, Grade(rank, cohortSize)); - } - - private static string Grade(int rank, int cohortSize) - { - int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio)); - if (rank <= Cutoff(0.1)) return "A+"; - if (rank <= Cutoff(0.25)) return "A"; - if (rank <= Cutoff(0.5)) return "B+"; - if (rank <= Cutoff(0.7)) return "B"; - return rank <= Cutoff(0.9) ? "C" : "D"; - } - - private static PassInfo Pass(IReadOnlyList results, AdminWorkflowResult result, JsonObject? subject) - { - var rule = subject?["passRule"]?.GetValue() ?? "fixed_score"; - var rank = Rank(results, result); - if (rule == "none") return new(null, null, null); - if (rule == "rank_percent") - { - var value = NullableNumber(subject?["passValue"]) ?? 60; - var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * value / 100)); - return new(rank.Rank <= cutoff, null, cutoff); - } - var passScore = Math.Round(NullableNumber(subject?["passValue"] ?? subject?["passScore"]) ?? 0, 2, MidpointRounding.AwayFromZero); - return new(result.Score >= passScore, passScore, null); - } - private static string PassText(JsonObject? subject) { var rule = subject?["passRule"]?.GetValue() ?? "fixed_score"; @@ -387,7 +350,5 @@ internal sealed class AdminWorkflowService( private sealed record WorkflowSnapshots(AdminAccountBatchSnapshot Workflow, AdminOperationalSnapshot Operational, AdminCenterSnapshot Centers, IReadOnlyList Results); private sealed record WorkflowScope(string? SchoolId, string? ClassId, string Name, string SchoolName, string ClassName); - private sealed record RankInfo(int? Rank, int CohortSize, double? RankPercent, string Grade); - private sealed record PassInfo(bool? Qualified, double? PassScore, int? CutoffRank); private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); } } diff --git a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs index 73735bb..b800747 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs @@ -8,8 +8,7 @@ internal sealed record CandidateReadSnapshot( IReadOnlyList Exams, IReadOnlyList Registrations, IReadOnlyList Results, - IReadOnlyList AllRegistrations, - IReadOnlyList AllResults, + IReadOnlyList ResultSummaries, IReadOnlyList Workflows, IReadOnlyList WorkflowInstances, IReadOnlyList WorkflowActions, @@ -115,7 +114,27 @@ internal sealed record CandidateResult( string Grade, bool Published, string? UpdatedAt, - string? PublishedAt); + string? PublishedAt, + int? Rank, + int CohortSize, + double? RankPercent, + bool? Qualified, + double? PassScore, + int? CutoffRank); + +internal sealed record CandidateResultSummary( + string RegistrationId, + int PublishedSubjectCount, + int SubjectCount, + bool Complete, + double TotalScore, + double FullScore, + double ScoreRatio, + int? Rank, + int CohortSize, + double? RankPercent, + bool? Qualified, + int? CutoffRank); internal sealed record CandidateWorkflow( string Id, @@ -172,8 +191,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c { public async Task LoadAsync( string userId, - CancellationToken cancellationToken, - bool includeCohort = false) + CancellationToken cancellationToken) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); var subjects = await QueryAsync(connection, @@ -188,16 +206,11 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c "SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams ORDER BY created_at, id", reader => ReadExam(reader, subjectsByExam), cancellationToken); - var registrationRows = includeCohort - ? await QueryAsync(connection, - "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations ORDER BY created_at, id", - ReadRegistrationRow, - cancellationToken) - : await QueryAsync(connection, - "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id", - ReadRegistrationRow, - cancellationToken, - new QueryParameter("@userId", userId)); + var registrationRows = await QueryAsync(connection, + "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id", + ReadRegistrationRow, + cancellationToken, + new QueryParameter("@userId", userId)); var registrationSubjects = await QueryAsync(connection, "SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id", @@ -245,24 +258,34 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c cardsByRegistration.GetValueOrDefault(row.Id))).ToArray(); var registrations = allRegistrations.Where(item => item.UserId == userId).ToArray(); - var allResults = includeCohort - ? await QueryAsync(connection, - "SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id", - ReadResult, - cancellationToken) - : await QueryAsync(connection, - """ - SELECT result.id, result.registration_id, result.subject_id, result.score, result.grade, - result.published, result.updated_at, result.published_at - FROM results result - JOIN registrations registration ON registration.id = result.registration_id - WHERE registration.user_id = @userId ORDER BY result.id - """, - ReadResult, - cancellationToken, - new QueryParameter("@userId", userId)); - var userRegistrationIds = registrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); - var results = allResults.Where(item => userRegistrationIds.Contains(item.RegistrationId)).ToArray(); + var results = await QueryAsync(connection, + """ + SELECT result.id, result.registration_id, result.subject_id, result.score, + COALESCE(statistic.grade, result.grade) AS grade, result.published, + result.updated_at, result.published_at, statistic.subject_rank, + statistic.cohort_size, statistic.rank_percent, statistic.qualified, + statistic.pass_score, statistic.cutoff_rank + FROM results result + JOIN registrations registration ON registration.id = result.registration_id + LEFT JOIN result_statistics statistic ON statistic.result_id = result.id + WHERE registration.user_id = @userId ORDER BY result.id + """, + ReadResult, + cancellationToken, + new QueryParameter("@userId", userId)); + var resultSummaries = await QueryAsync(connection, + """ + SELECT summary.registration_id, summary.published_subject_count, summary.subject_count, + summary.complete, summary.total_score, summary.full_score, summary.score_ratio, + summary.total_rank, summary.cohort_size, summary.rank_percent, summary.qualified, + summary.cutoff_rank + FROM registration_result_summaries summary + JOIN registrations registration ON registration.id = summary.registration_id + WHERE registration.user_id = @userId ORDER BY summary.registration_id + """, + ReadResultSummary, + cancellationToken, + new QueryParameter("@userId", userId)); var workflowSteps = await QueryAsync(connection, "SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id", reader => new WorkflowStepRow( @@ -333,8 +356,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c exams, registrations, results, - allRegistrations, - allResults, + resultSummaries, workflows, instances, actions, @@ -480,7 +502,27 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c ReadString(reader, "grade"), ReadBoolean(reader, "published"), ReadOptionalString(reader, "updated_at"), - ReadOptionalString(reader, "published_at")); + ReadOptionalString(reader, "published_at"), + ReadNullableInt32(reader, "subject_rank"), + ReadNullableInt32(reader, "cohort_size") ?? 0, + ReadNullableDouble(reader, "rank_percent"), + ReadNullableBoolean(reader, "qualified"), + ReadNullableDouble(reader, "pass_score"), + ReadNullableInt32(reader, "cutoff_rank")); + + private static CandidateResultSummary ReadResultSummary(DbDataReader reader) => new( + ReadString(reader, "registration_id"), + ReadInt32(reader, "published_subject_count"), + ReadInt32(reader, "subject_count"), + ReadBoolean(reader, "complete"), + ReadDouble(reader, "total_score"), + ReadDouble(reader, "full_score"), + ReadDouble(reader, "score_ratio"), + ReadNullableInt32(reader, "total_rank"), + ReadInt32(reader, "cohort_size"), + ReadNullableDouble(reader, "rank_percent"), + ReadNullableBoolean(reader, "qualified"), + ReadNullableInt32(reader, "cutoff_rank")); private static CandidateWorkflowInstance ReadWorkflowInstance(DbDataReader reader) => new( ReadString(reader, "id"), @@ -540,6 +582,20 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c private static int ReadInt32(DbDataReader reader, string name) => Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); + private static int? ReadNullableInt32(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? null : Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private static bool? ReadNullableBoolean(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) + ? null + : Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0; + } + private static double ReadDouble(DbDataReader reader, string name, double fallback = 0) { var ordinal = reader.GetOrdinal(name); diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs b/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs index ca5251d..2cc2a9e 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs @@ -475,13 +475,8 @@ internal sealed partial class CandidateService return null; } - var results = snapshot.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); - if (registration.SubjectIds.Any(id => results.All(result => result.SubjectId != id))) - { - return null; - } - - return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero); + var summary = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id); + return summary is { Complete: true } ? summary.TotalScore : null; } private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category) diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs b/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs index 5eafee9..0a96aed 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs @@ -36,7 +36,7 @@ internal sealed partial class CandidateService string verificationBaseUrl, CancellationToken cancellationToken) { - var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken, includeCohort: true); + var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); var registrationById = snapshot.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal); var results = snapshot.Results .Where(item => item.Published && registrationById.ContainsKey(item.RegistrationId)) @@ -52,7 +52,7 @@ internal sealed partial class CandidateService } var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); - var reportResults = snapshot.AllResults + var reportResults = snapshot.Results .Where(item => item.RegistrationId == registration.Id && item.Published) .Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt)); var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults); @@ -157,8 +157,6 @@ internal sealed partial class CandidateService { var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); var subject = exam.Subjects.FirstOrDefault(item => item.Id == result.SubjectId); - var rank = ResultRankInfo(snapshot, result); - var pass = SubjectPassEvaluation(snapshot, result, subject); var appealInstance = FindInstance(snapshot, "score_appeal", result.Id); var appeal = WorkflowView(snapshot, appealInstance); if (appeal is not null) @@ -168,10 +166,10 @@ internal sealed partial class CandidateService } var json = ResultJson(result); - json["rank"] = JsonValue.Create(rank.Rank); - json["cohortSize"] = rank.CohortSize; - json["rankPercent"] = JsonValue.Create(rank.RankPercent); - json["grade"] = rank.Grade; + json["rank"] = JsonValue.Create(result.Rank); + json["cohortSize"] = result.CohortSize; + json["rankPercent"] = JsonValue.Create(result.RankPercent); + json["grade"] = result.Grade; json["examId"] = exam.Id; json["examName"] = exam.Name; json["examCode"] = exam.Code; @@ -181,10 +179,10 @@ internal sealed partial class CandidateService json["fullScore"] = subject?.FullScore ?? 150; json["passRule"] = subject?.PassRule ?? "fixed_score"; json["passValue"] = subject?.PassValue ?? subject?.PassScore; - json["passScore"] = JsonValue.Create(pass.PassScore); - json["cutoffRank"] = JsonValue.Create(pass.CutoffRank); + json["passScore"] = JsonValue.Create(result.PassScore); + json["cutoffRank"] = JsonValue.Create(result.CutoffRank); json["passText"] = SubjectPassText(subject); - json["qualified"] = JsonValue.Create(pass.Qualified); + json["qualified"] = JsonValue.Create(result.Qualified); json["appeal"] = appeal; return json; } @@ -205,14 +203,14 @@ internal sealed partial class CandidateService } var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray(); - var published = snapshot.AllResults + var published = snapshot.Results .Where(item => item.RegistrationId == registration.Id && item.Published) .ToArray(); - var resultsBySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal); - var complete = subjects.Length > 0 && subjects.All(item => resultsBySubject.ContainsKey(item.Id)); - var total = subjects.Sum(item => resultsBySubject.GetValueOrDefault(item.Id)?.Score ?? 0); - var fullScore = subjects.Sum(item => item.FullScore); - var scoreRatio = fullScore > 0 ? total / fullScore * 100 : 0; + var stored = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id); + var complete = stored?.Complete ?? false; + var total = stored?.TotalScore ?? 0; + var fullScore = stored?.FullScore ?? subjects.Sum(item => item.FullScore); + var scoreRatio = stored?.ScoreRatio ?? 0; var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy; if (policy.Length == 0) { @@ -220,32 +218,6 @@ internal sealed partial class CandidateService } var value = exam.PassValue; - bool? qualified = null; - int? rank = null; - int? cohortSize = null; - if (complete && policy == "fixed_score") - { - qualified = total >= value; - } - else if (complete && policy == "subject_scores") - { - qualified = subjects.All(item => SubjectPassEvaluation(snapshot, resultsBySubject[item.Id], item).Qualified != false); - } - else if (complete && policy == "rank_percent") - { - var subjectKey = string.Join('|', registration.SubjectIds.Order(StringComparer.Ordinal)); - var totals = snapshot.AllRegistrations - .Where(item => item.Status == "approved" && item.ExamId == exam.Id && - string.Join('|', item.SubjectIds.Order(StringComparer.Ordinal)) == subjectKey) - .Select(item => CompleteTotal(snapshot, exam, item)) - .Where(item => item.HasValue) - .Select(item => item!.Value) - .Order() - .ToArray(); - cohortSize = totals.Length; - rank = 1 + totals.Count(item => item > total); - qualified = rank <= Math.Max(1, (int)Math.Ceiling(cohortSize.Value * value / 100)); - } return new JsonObject { @@ -263,63 +235,14 @@ internal sealed partial class CandidateService ["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero), ["passPolicy"] = policy, ["passValue"] = value, - ["qualified"] = JsonValue.Create(qualified), - ["rank"] = JsonValue.Create(rank), - ["cohortSize"] = JsonValue.Create(cohortSize) + ["qualified"] = JsonValue.Create(stored?.Qualified), + ["rank"] = JsonValue.Create(stored?.Rank), + ["cohortSize"] = JsonValue.Create(stored?.CohortSize), + ["rankPercent"] = JsonValue.Create(stored?.RankPercent), + ["cutoffRank"] = JsonValue.Create(stored?.CutoffRank) }; } - private static double? CompleteTotal( - CandidateReadSnapshot snapshot, - CandidateExam exam, - CandidateRegistration registration) - { - var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray(); - var scores = snapshot.AllResults - .Where(item => item.RegistrationId == registration.Id && item.Published) - .ToDictionary(item => item.SubjectId, item => item.Score, StringComparer.Ordinal); - return subjects.Length > 0 && subjects.All(item => scores.ContainsKey(item.Id)) - ? subjects.Sum(item => scores[item.Id]) - : null; - } - - private static ResultRank ResultRankInfo(CandidateReadSnapshot snapshot, CandidateResult result) - { - var scores = snapshot.AllResults - .Where(item => item.Published && item.SubjectId == result.SubjectId) - .Select(item => item.Score) - .Order() - .ToArray(); - var cohortSize = scores.Length; - var rank = 1 + scores.Count(item => item > result.Score); - var rankPercent = cohortSize == 0 - ? 0 - : Math.Round(rank / (double)cohortSize * 100, 2, MidpointRounding.AwayFromZero); - return new ResultRank(rank, cohortSize, rankPercent, GradeForRank(rank, cohortSize)); - } - - private static PassEvaluation SubjectPassEvaluation( - CandidateReadSnapshot snapshot, - CandidateResult result, - CandidateSubject? subject) - { - var rank = ResultRankInfo(snapshot, result); - var rule = subject?.PassRule ?? "fixed_score"; - if (rule == "none") - { - return new PassEvaluation(null, null, null, rank); - } - - if (rule == "rank_percent") - { - var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (subject?.PassValue ?? 60) / 100)); - return new PassEvaluation(rank.Rank <= cutoff, null, cutoff, rank); - } - - var passScore = Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero); - return new PassEvaluation(result.Score >= passScore, passScore, null, rank); - } - private static string SubjectPassText(CandidateSubject? subject) => (subject?.PassRule ?? "fixed_score") switch { "none" => "不设单科线", @@ -327,16 +250,6 @@ internal sealed partial class CandidateService _ => $"固定 {FormatNumber(Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero))} 分" }; - private static string GradeForRank(int rank, int cohortSize) - { - int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio)); - if (rank <= Cutoff(0.1)) return "A+"; - if (rank <= Cutoff(0.25)) return "A"; - if (rank <= Cutoff(0.5)) return "B+"; - if (rank <= Cutoff(0.7)) return "B"; - if (rank <= Cutoff(0.9)) return "C"; - return "D"; - } private static string FormatNumber(double value) => value.ToString("0.#############################", CultureInfo.InvariantCulture); @@ -349,7 +262,4 @@ internal sealed partial class CandidateService return $"data:image/png;base64,{Convert.ToBase64String(qrCode.GetGraphic(10))}"; } - private sealed record ResultRank(int Rank, int CohortSize, double RankPercent, string Grade); - - private sealed record PassEvaluation(bool? Qualified, double? PassScore, int? CutoffRank, ResultRank Rank); } diff --git a/src/Eis.Infrastructure/Data/DatabaseInitializer.cs b/src/Eis.Infrastructure/Data/DatabaseInitializer.cs index b77ac1a..f64e446 100644 --- a/src/Eis.Infrastructure/Data/DatabaseInitializer.cs +++ b/src/Eis.Infrastructure/Data/DatabaseInitializer.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Reflection; using System.Text.Json; using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Administration; using Microsoft.Extensions.DependencyInjection; namespace Eis.Infrastructure.Data; @@ -294,5 +295,7 @@ public static class DatabaseInitializationExtensions await using var scope = serviceProvider.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService() .InitializeAsync(cancellationToken); + await scope.ServiceProvider.GetRequiredService() + .EnsureAsync(cancellationToken); } } diff --git a/src/Eis.Infrastructure/Data/Schema.mysql.sql b/src/Eis.Infrastructure/Data/Schema.mysql.sql index 8f25584..87d9d8b 100644 --- a/src/Eis.Infrastructure/Data/Schema.mysql.sql +++ b/src/Eis.Infrastructure/Data/Schema.mysql.sql @@ -257,6 +257,41 @@ CREATE TABLE IF NOT EXISTS results ( CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci -- EIS-STATEMENT +CREATE TABLE IF NOT EXISTS result_statistics ( + result_id VARCHAR(64) NOT NULL, + subject_rank INT NULL, + cohort_size INT UNSIGNED NOT NULL DEFAULT 0, + rank_percent DOUBLE NULL, + grade VARCHAR(20) NOT NULL, + qualified BOOLEAN NULL, + pass_score DOUBLE NULL, + cutoff_rank INT NULL, + calculated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (result_id), + CONSTRAINT fk_result_statistics_result FOREIGN KEY (result_id) REFERENCES results(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +-- EIS-STATEMENT +CREATE TABLE IF NOT EXISTS registration_result_summaries ( + registration_id VARCHAR(64) NOT NULL, + exam_id VARCHAR(64) NOT NULL, + published_subject_count INT UNSIGNED NOT NULL DEFAULT 0, + subject_count INT UNSIGNED NOT NULL DEFAULT 0, + complete BOOLEAN NOT NULL DEFAULT FALSE, + total_score DOUBLE NOT NULL DEFAULT 0, + full_score DOUBLE NOT NULL DEFAULT 0, + score_ratio DOUBLE NOT NULL DEFAULT 0, + total_rank INT NULL, + cohort_size INT UNSIGNED NOT NULL DEFAULT 0, + rank_percent DOUBLE NULL, + qualified BOOLEAN NULL, + cutoff_rank INT NULL, + calculated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (registration_id), + KEY idx_result_summaries_exam (exam_id, complete, qualified), + CONSTRAINT fk_result_summaries_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, + CONSTRAINT fk_result_summaries_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +-- EIS-STATEMENT CREATE TABLE IF NOT EXISTS test_centers ( id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, diff --git a/src/Eis.Infrastructure/Data/Schema.sqlite.sql b/src/Eis.Infrastructure/Data/Schema.sqlite.sql index e53089c..c3c0221 100644 --- a/src/Eis.Infrastructure/Data/Schema.sqlite.sql +++ b/src/Eis.Infrastructure/Data/Schema.sqlite.sql @@ -253,6 +253,35 @@ PRAGMA foreign_keys = ON; UNIQUE (registration_id, subject_id) ) STRICT; + CREATE TABLE IF NOT EXISTS result_statistics ( + result_id TEXT PRIMARY KEY REFERENCES results(id) ON DELETE CASCADE, + subject_rank INTEGER, + cohort_size INTEGER NOT NULL DEFAULT 0 CHECK (cohort_size >= 0), + rank_percent REAL, + grade TEXT NOT NULL, + qualified INTEGER CHECK (qualified IN (0, 1)), + pass_score REAL, + cutoff_rank INTEGER, + calculated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS registration_result_summaries ( + registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + published_subject_count INTEGER NOT NULL DEFAULT 0 CHECK (published_subject_count >= 0), + subject_count INTEGER NOT NULL DEFAULT 0 CHECK (subject_count >= 0), + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), + total_score REAL NOT NULL DEFAULT 0, + full_score REAL NOT NULL DEFAULT 0, + score_ratio REAL NOT NULL DEFAULT 0, + total_rank INTEGER, + cohort_size INTEGER NOT NULL DEFAULT 0 CHECK (cohort_size >= 0), + rank_percent REAL, + qualified INTEGER CHECK (qualified IN (0, 1)), + cutoff_rank INTEGER, + calculated_at TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS test_centers ( id TEXT PRIMARY KEY, school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, @@ -464,6 +493,7 @@ PRAGMA foreign_keys = ON; CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at); CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position); CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); + CREATE INDEX IF NOT EXISTS idx_result_summaries_exam ON registration_result_summaries(exam_id, complete, qualified); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status); diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 8808904..14347d2 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -70,6 +70,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 8f5544c..19f1467 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -189,6 +189,28 @@ public static class NativeAdminReadEndpoints if (options.NativeResultsEnabled) { + endpoints.MapGet("/api/admin/results/summary", + (HttpContext context, IAdminResultService service, CancellationToken cancellationToken) => + Execute( + context, + service.GetSummaryAsync( + Token(context), + context.Request.Query["examId"].ToString(), + cancellationToken))); + endpoints.MapGet("/api/admin/results/roster", + (HttpContext context, IAdminResultService service, CancellationToken cancellationToken) => + Execute( + context, + service.GetRosterAsync( + Token(context), + context.Request.Query["examId"].ToString(), + context.Request.Query["subjectId"].ToString(), + context.Request.Query["mode"].ToString() == "feature", + QueryInteger(context, "page", 1), + QueryInteger(context, "pageSize", 50), + context.Request.Query["query"].ToString(), + context.Request.Query["status"].ToString(), + cancellationToken))); endpoints.MapGet("/api/admin/results", (HttpContext context, IAdminResultService service, CancellationToken cancellationToken) => Execute( @@ -196,6 +218,10 @@ public static class NativeAdminReadEndpoints service.GetAsync( Token(context), context.Request.Query["examId"].ToString(), + QueryInteger(context, "page", 1), + QueryInteger(context, "pageSize", 50), + context.Request.Query["query"].ToString(), + context.Request.Query["status"].ToString(), cancellationToken))); endpoints.MapPost("/api/admin/results", (HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) => @@ -381,6 +407,11 @@ public static class NativeAdminReadEndpoints private static string Token(HttpContext context) => context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; + private static int QueryInteger(HttpContext context, string name, int fallback) => + int.TryParse(context.Request.Query[name].ToString(), out var value) && value > 0 + ? value + : fallback; + private static async Task ReadWorkbookAsync(HttpRequest request, CancellationToken cancellationToken) { const int maximumBytes = 20 * 1024 * 1024; diff --git a/src/Eis.Web/wwwroot/js/app.js b/src/Eis.Web/wwwroot/js/app.js index 5470c8b..2e09dc2 100644 --- a/src/Eis.Web/wwwroot/js/app.js +++ b/src/Eis.Web/wwwroot/js/app.js @@ -300,11 +300,11 @@ function updateResultScoreInput(input) { const value = input.value.trim(); const score = Number(value); const max = Number(input.max); - const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max); + const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max || !Number.isInteger(score * 2)); input.dataset.dirty = String(value !== input.defaultValue); input.setAttribute('aria-invalid', String(invalid)); row?.classList.toggle('score-row-invalid', invalid); - if (error) error.textContent = invalid ? `须在 0—${max} 之间` : ''; + if (error) error.textContent = invalid ? `须在 0—${max} 之间,且只能填写整数或 .5` : ''; const form = input.closest('form'); const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0; const count = form?.querySelector('[data-result-dirty-count]'); @@ -1306,15 +1306,15 @@ document.addEventListener('submit', async event => { } else if (kind === 'result-bulk-entry') { const mode = event.submitter?.dataset.resultMode || 'draft'; const inputs = [...form.querySelectorAll('[data-result-score]')]; - const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max))); - if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间`); } + const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max) || !Number.isInteger(Number(input.value) * 2))); + if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间,且只能填写整数或 .5`); } const targets = mode === 'publish' ? inputs.filter(input => input.value.trim() !== '') : inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== ''); if (mode === 'publish') { const missing = inputs.filter(input => input.value.trim() === ''); - if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); } - if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return; + if (missing.length) { missing[0].focus(); throw new Error(`当前页还有 ${missing.length} 名考生未录入成绩`); } + if (!window.confirm(`确认发布当前页 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return; } if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩'); const body = { @@ -1324,7 +1324,7 @@ document.addEventListener('submit', async event => { rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) })) }; const result = await api('/api/admin/results/bulk', { method: 'POST', body }); - toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`); + toast(mode === 'publish' ? '当前页成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存并重算本场排名`); renderRoute(); } else if (kind === 'feature-score-entry') { const body = formObject(form); body.featureScore = Number(body.featureScore || 0); diff --git a/src/Eis.Web/wwwroot/js/client/admin-views.js b/src/Eis.Web/wwwroot/js/client/admin-views.js index 3f0b8db..f15313e 100644 --- a/src/Eis.Web/wwwroot/js/client/admin-views.js +++ b/src/Eis.Web/wwwroot/js/client/admin-views.js @@ -1,7 +1,7 @@ import { formatRegionAddress } from './region-select.js'; import { admissionCategoriesEditor } from './admission-plan-editor.js'; import { specialtyLabel } from '../data/specialty-types.js'; -import { filterTableItems } from './table-state.js'; +import { filterTableItems, getTableControl } from './table-state.js'; export const numberSegmentMeta = { year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], @@ -62,10 +62,46 @@ export function createAdminViews(context) { app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); try { const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page.startsWith('admission-') ? 'admissions' : page; - const query = page === 'results' && state.resultExamFilter ? `?examId=${encodeURIComponent(state.resultExamFilter)}` : ''; - const data = page === 'results' && !state.resultExamFilter + let data = page === 'results' && !state.resultExamFilter ? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } } - : page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`); + : page === 'security' ? await api('/api/auth/totp') : page === 'results' + ? await api(`/api/admin/results/summary?examId=${encodeURIComponent(state.resultExamFilter)}`) + : await api(`/api/admin/${endpoint}`); + if (page === 'results' && state.resultExamFilter) { + const activeExam = data.exams.find(item => item.id === state.resultExamFilter); + const subjectId = state.resultSubjectFilter || activeExam?.subjects?.[0]?.id || ''; + state.resultSubjectFilter = subjectId; + const requestQuery = (key, extra = {}) => { + const current = state.tablePages[key] || {}; + const control = getTableControl(state, key); + const params = new URLSearchParams({ + examId: state.resultExamFilter, + page: String(current.page || 1), + pageSize: String(current.pageSize || 50), + query: control.query || '', + status: control.status || 'all', + ...extra + }); + return params.toString(); + }; + const resultPromise = api(`/api/admin/results?${requestQuery('resultTable')}`); + const entryPromise = state.user.adminLevel === 'super' && subjectId + ? api(`/api/admin/results/roster?${requestQuery('resultEntryTable', { subjectId })}`) + : Promise.resolve({ items: [], pagination: { page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'resultEntryTable' } }); + const featurePromise = state.user.adminLevel === 'super' + ? api(`/api/admin/results/roster?${requestQuery('featureScoreTable', { mode: 'feature' })}`) + : Promise.resolve({ items: [], pagination: { page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'featureScoreTable' } }); + const [resultData, entryData, featureData] = await Promise.all([resultPromise, entryPromise, featurePromise]); + data = { + ...data, + results: resultData.items || [], + resultPage: { ...(resultData.pagination || {}), items: resultData.items || [] }, + entryPage: { ...(entryData.pagination || {}), items: entryData.items || [] }, + featurePage: { ...(featureData.pagination || {}), items: featureData.items || [] }, + entryCounts: entryData.counts || {}, + featureCounts: featureData.counts || {} + }; + } if (page === 'results' && data.selectedExamId) state.resultExamFilter = data.selectedExamId; if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams; state.pageData = data; @@ -323,35 +359,34 @@ export function createAdminViews(context) { } const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0]; if (activeSubject) state.resultSubjectFilter = activeSubject.id; - const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results; - const resultPage = paged(examResults, 'resultTable'); + const examResults = data.results || []; + const resultPage = data.resultPage || { items: examResults, page: 1, pageSize: 50, total: examResults.length, totalPages: 1, key: 'resultTable' }; const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []); const appealPage = paged(examAppeals, 'resultAppealTable'); const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; - const subjectRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id && item.subjectIds.includes(activeSubject?.id)); - const resultEntryPage = paged(subjectRegistrations, 'resultEntryTable'); - const subjectResults = new Map(examResults.filter(item => item.subjectId === activeSubject?.id).map(item => [item.registrationId, item])); - const recordedCount = subjectRegistrations.filter(item => subjectResults.has(item.id)).length; - const publishedCount = subjectRegistrations.filter(item => subjectResults.get(item.id)?.published).length; + const resultEntryPage = data.entryPage || { items: [], page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'resultEntryTable' }; + const subjectRegistrations = resultEntryPage.items; + const recordedCount = Number(data.entryCounts?.recorded || 0); + const publishedCount = Number(data.entryCounts?.published || 0); const entryRows = resultEntryPage.items.map((registration, index) => { - const result = subjectResults.get(registration.id); + const result = registration.result; const status = result?.published ? 'published' : result ? 'draft' : 'missing'; - return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${result ? badge(result.published ? 'published' : 'draft') : '未录入'}${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}`; + return `${(resultEntryPage.page - 1) * resultEntryPage.pageSize + index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${result ? badge(result.published ? 'published' : 'draft') : '未录入'}${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}`; }).join(''); - const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
SCORE ENTRY ROSTER

按名单录入单科成绩

选定考试和科目后,直接在完整考生名单中录分;暂存不会向考生发布。

应录人数${subjectRegistrations.length}已录入${recordedCount}已暂存${Math.max(0, recordedCount - publishedCount)}已发布${publishedCount}

${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}

${entryRows || ''}
序号考生报名号准考证号成绩(0—${h(activeSubject?.fullScore || '—')})状态最近保存
该科目暂无已通过报名的考生
尚无未保存修改按 Ctrl / ⌘ + S 可快速暂存;发布前必须补齐本科学目全部考生成绩。
` : ''; - const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id); - const featurePage = paged(featureRegistrations, 'featureScoreTable'); - const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length; + const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
SCORE ENTRY ROSTER

按名单录入单科成绩

选定考试和科目后,按服务端分页录分;暂存不会向考生发布。

应录人数${Number(data.entryCounts?.total || 0)}已录入${recordedCount}已暂存${Math.max(0, recordedCount - publishedCount)}已发布${publishedCount}

${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}

${entryRows || ''}
序号考生报名号准考证号成绩(0—${h(activeSubject?.fullScore || '—')})状态最近保存
该科目暂无符合条件的考生
尚无未保存修改按 Ctrl / ⌘ + S 可快速暂存;发布操作只处理当前页已填写的成绩。
` : ''; + const featurePage = data.featurePage || { items: [], page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'featureScoreTable' }; + const featureRegistrations = featurePage.items; + const featureModified = Number(data.featureCounts?.modified || 0); const featureRows = featurePage.items.map((registration, index) => { const featureScore = Number(registration.featureScore || 0); const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`; - return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${h(registration.specialtyLabel || '普通生')}${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}`; + return `${(featurePage.page - 1) * featurePage.pageSize + index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${h(registration.specialtyLabel || '普通生')}${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}`; }).join(''); - const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
UNIFIED SPECIALTY TEST

按名单登记特征分

所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。

分类计分规则普通类别:文化课总分特长类别:文化课总分 + 特征分
本场考生${featureRegistrations.length}默认 0 分${featureRegistrations.length - featureModified}已修改${featureModified}具有特长资格${featureRegistrations.filter(item => item.specialtyType).length}

特征分按考试、按考生独立保存,不改变文化课成绩。

${featureRows || ''}
序号考生报名号准考证号特长资格 / 计分范围特征分(0—1000)
本场暂无已通过报名的考生
尚无未保存修改未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。
` : ''; + const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
UNIFIED SPECIALTY TEST

按名单登记特征分

所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。

分类计分规则普通类别:文化课总分特长类别:文化课总分 + 特征分
本场考生${Number(data.featureCounts?.total || 0)}默认 0 分${Math.max(0, Number(data.featureCounts?.total || 0) - featureModified)}已修改${featureModified}具有特长资格${Number(data.featureCounts?.specialty || 0)}

特征分按考试、按考生独立保存,不改变文化课成绩。

${featureRows || ''}
序号考生报名号准考证号特长资格 / 计分范围特征分(0—1000)
本场暂无符合条件的考生
尚无未保存修改未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。
` : ''; const importPreviewPage = preview ? paged(preview.rows, 'resultImportPreviewTable') : null; const importPreview = preview ? `
EXCEL STAGING AREA

${h(preview.fileName || '成绩导入预览')}

此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。

总行数${preview.summary.total}可提交${preview.summary.valid}有错误${preview.summary.invalid}新增 / 更新${preview.summary.create} / ${preview.summary.update}将发布${preview.summary.publish}
${importPreviewPage.items.map(row => ``).join('')}
Excel 行考生考试 / 科目成绩独立及格线发布写入方式 / 校验
${h(row.sourceRow)}${h(row.candidateName || '未匹配')}${h(row.candidateNumber)}${h(row.examName || row.examCode)}${h(row.subjectName)}${Number.isFinite(row.score) ? h(row.score) : '—'}${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}${h(row.passText || '—')}${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}${badge(row.published ? 'published' : 'draft')}${row.errors.length ? `
    ${row.errors.map(error => `
  • ${h(error)}
  • `).join('')}
` : `${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}校验通过`}
${pagination(importPreviewPage)}

${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}

` : ''; const appealLedger = `

本场成绩复议

复议按考生所属班级和学校自动分配,可在流程中心办理。

${appealPage.items.map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
本场暂无成绩复议申请
${pagination(appealPage)}
`; - const examButton = exam => ``; + const examButton = exam => ``; const currentExams = data.exams.filter(exam => !exam.archivedAt); const archivedExams = data.exams.filter(exam => exam.archivedAt); const examStrip = `
${currentExams.map(examButton).join('')}
${archivedExams.length ? `
历史归档考试 ${archivedExams.length} 场 · 成绩永久锁定
${archivedExams.map(examButton).join('')}
` : ''}`; diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminResultStatisticsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminResultStatisticsTests.cs new file mode 100644 index 0000000..8902303 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminResultStatisticsTests.cs @@ -0,0 +1,120 @@ +using System.Text.Json.Nodes; +using Eis.Infrastructure.Administration; + +namespace Eis.Infrastructure.Tests.Administration; + +public sealed class AdminResultStatisticsTests +{ + [Theory] + [InlineData(0, 100, true)] + [InlineData(10, 100, true)] + [InlineData(10.5, 100, true)] + [InlineData(10.1, 100, false)] + [InlineData(-0.5, 100, false)] + [InlineData(100.5, 100, false)] + public void IsValidScore_OnlyAcceptsWholeOrHalfPoints(double score, double fullScore, bool expected) + { + Assert.Equal(expected, AdminResultStatistics.IsValidScore(score, fullScore)); + } + + [Fact] + public void Calculate_ComputesEachSubjectAndTotalRankingOnceForTheExam() + { + var exam = Exam("rank_percent", 50); + var registrations = new[] { Registration("r1"), Registration("r2"), Registration("r3") }; + var results = new[] + { + Result("r1-s1", "r1", "s1", 90), Result("r1-s2", "r1", "s2", 80), + Result("r2-s1", "r2", "s1", 90), Result("r2-s2", "r2", "s2", 70), + Result("r3-s1", "r3", "s1", 60), Result("r3-s2", "r3", "s2", 70) + }; + + var calculation = AdminResultStatistics.Calculate(exam, registrations, results, "2026-07-23T00:00:00.000Z"); + + Assert.Equal(6, calculation.Metrics.Count); + Assert.Equal(1, calculation.Metrics.Single(item => item.ResultId == "r1-s1").Rank); + Assert.Equal(1, calculation.Metrics.Single(item => item.ResultId == "r2-s1").Rank); + Assert.Equal(3, calculation.Metrics.Single(item => item.ResultId == "r3-s1").Rank); + Assert.Equal(2, calculation.Metrics.Single(item => item.ResultId == "r2-s2").Rank); + Assert.Equal(2, calculation.Metrics.Single(item => item.ResultId == "r3-s2").Rank); + + var summaries = calculation.Summaries.ToDictionary(item => item.RegistrationId); + Assert.Equal(170, summaries["r1"].TotalScore); + Assert.Equal(1, summaries["r1"].Rank); + Assert.True(summaries["r1"].Qualified); + Assert.Equal(2, summaries["r2"].Rank); + Assert.True(summaries["r2"].Qualified); + Assert.Equal(3, summaries["r3"].Rank); + Assert.False(summaries["r3"].Qualified); + Assert.All(summaries.Values, item => Assert.Equal(3, item.CohortSize)); + } + + [Fact] + public void Calculate_PersistsIncompleteAndDraftStatesWithoutIncludingDraftInPublishedSummary() + { + var exam = Exam("subject_scores", 0); + var registrations = new[] { Registration("r1"), Registration("r2") }; + var results = new[] + { + Result("r1-s1", "r1", "s1", 95), + Result("r1-s2", "r1", "s2", 88, published: false), + Result("r2-s1", "r2", "s1", 80), + Result("r2-s2", "r2", "s2", 75) + }; + + var calculation = AdminResultStatistics.Calculate(exam, registrations, results, "2026-07-23T00:00:00.000Z"); + var draft = calculation.Metrics.Single(item => item.ResultId == "r1-s2"); + var summaries = calculation.Summaries.ToDictionary(item => item.RegistrationId); + + Assert.Equal("待发布", draft.Grade); + Assert.Equal(2, draft.CohortSize); + Assert.False(summaries["r1"].Complete); + Assert.Null(summaries["r1"].Qualified); + Assert.Equal(1, summaries["r1"].PublishedSubjectCount); + Assert.True(summaries["r2"].Complete); + } + + private static OperationalExam Exam(string passPolicy, double passValue) + { + var subjects = new[] + { + new JsonObject { ["id"] = "s1", ["name"] = "语文", ["fullScore"] = 100, ["passRule"] = "fixed_score", ["passValue"] = 60 }, + new JsonObject { ["id"] = "s2", ["name"] = "数学", ["fullScore"] = 100, ["passRule"] = "fixed_score", ["passValue"] = 60 } + }; + return new("exam-1", new JsonObject + { + ["id"] = "exam-1", + ["passPolicy"] = passPolicy, + ["passValue"] = passValue + }, subjects); + } + + private static OperationalRegistration Registration(string id) => new( + id, + $"user-{id}", + "exam-1", + "approved", + "paid", + null, + "2026-07-23T00:00:00.000Z", + new JsonObject + { + ["id"] = id, + ["subjectIds"] = new JsonArray("s1", "s2") + }); + + private static AdminWorkflowResult Result( + string id, + string registrationId, + string subjectId, + double score, + bool published = true) => new( + id, + registrationId, + subjectId, + score, + published ? "" : "待发布", + published, + "2026-07-23T00:00:00.000Z", + published ? "2026-07-23T00:00:00.000Z" : null); +}