成绩提交时按考试一次性计算并存储单科名次、排名百分比、等级、合格状态、总分排名及整场合格状态。

新增 result_statistics、registration_result_summaries 两张统计表,现有数据库首次启动时自动回填。
SQL 查询首先通过 examId 限定范围。
科目成绩只排序一次,并使用 registration/subject 字典组织数据。
汇总接口与成绩明细接口已拆分,明细、成绩录入名单和特征分名单均改为服务端分页。
所有成绩写入口统一限制为:0—满分,且只能是整数或 .5,包括手工录入、批量录入、Excel 导入和复议改分。
前端也增加了相同的即时校验。
修复其他考试卡片显示 undefined 的问题。
This commit is contained in:
2026-07-23 19:21:56 +08:00 Unverified
parent b139834396
commit cde754621a
20 changed files with 1365 additions and 489 deletions
@@ -4,7 +4,13 @@ namespace Eis.Application.Administration;
public interface IAdminResultService public interface IAdminResultService
{ {
Task<AdminEndpointResult> GetAsync(string sessionToken, string examId, CancellationToken cancellationToken); Task<AdminEndpointResult> GetSummaryAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAsync(
string sessionToken, string examId, int page, int pageSize, string query, string status,
CancellationToken cancellationToken);
Task<AdminEndpointResult> GetRosterAsync(
string sessionToken, string examId, string subjectId, bool featureMode,
int page, int pageSize, string query, string status, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
@@ -673,7 +673,8 @@ internal sealed partial class AdminAdmissionService(
await admissionRepository.LoadAsync(cancellationToken), await admissionRepository.LoadAsync(cancellationToken),
await operationalLoader.LoadAsync(cancellationToken), await operationalLoader.LoadAsync(cancellationToken),
await directoryLoader.LoadAsync(cancellationToken), await directoryLoader.LoadAsync(cancellationToken),
await resultLoader.LoadAsync(cancellationToken)); await resultLoader.LoadAsync(cancellationToken),
await resultLoader.LoadSummariesAsync(cancellationToken));
private static IEnumerable<CandidateAdmissionRecord> Records(AdmissionData data, string kind, string? examId = null) => private static IEnumerable<CandidateAdmissionRecord> Records(AdmissionData data, string kind, string? examId = null) =>
data.Admission.Records.Where(item => item.Kind == kind && (examId is null || item.ExamId == examId)); 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 => var registration = data.Operational.Registrations.FirstOrDefault(item =>
item.ExamId == examId && item.UserId == userId && item.Status == "approved"); item.ExamId == examId && item.UserId == userId && item.Status == "approved");
if (registration is null) return null; if (registration is null) return null;
var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToArray(); var summary = data.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
var results = data.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); return summary is { Complete: true } ? summary.TotalScore : null;
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);
} }
private static List<CandidateAdmissionRecord> AssignNoticeNumbers(AdmissionData data, IReadOnlyList<CandidateAdmissionRecord> placements) private static List<CandidateAdmissionRecord> AssignNoticeNumbers(AdmissionData data, IReadOnlyList<CandidateAdmissionRecord> placements)
@@ -1340,7 +1339,8 @@ internal sealed partial class AdminAdmissionService(
CandidateAdmissionSnapshot Admission, CandidateAdmissionSnapshot Admission,
AdminOperationalSnapshot Operational, AdminOperationalSnapshot Operational,
AdminAccountBatchSnapshot Directory, AdminAccountBatchSnapshot Directory,
IReadOnlyList<AdminWorkflowResult> Results); IReadOnlyList<AdminWorkflowResult> Results,
IReadOnlyList<AdminRegistrationResultSummary> ResultSummaries);
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{ {
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
@@ -11,7 +11,19 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) => public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) =>
SaveManyAsync([entry], cancellationToken); SaveManyAsync([entry], cancellationToken);
public Task SaveAsync(
ResultWrite entry,
AdminResultCalculation calculation,
CancellationToken cancellationToken) =>
SaveManyAsync([entry], [calculation], cancellationToken);
public async Task SaveManyAsync(IReadOnlyList<ResultWrite> entries, CancellationToken cancellationToken) public async Task SaveManyAsync(IReadOnlyList<ResultWrite> entries, CancellationToken cancellationToken)
=> await SaveManyAsync(entries, [], cancellationToken);
public async Task SaveManyAsync(
IReadOnlyList<ResultWrite> entries,
IReadOnlyList<AdminResultCalculation> calculations,
CancellationToken cancellationToken)
{ {
await TransactionAsync(async (connection, transaction) => await TransactionAsync(async (connection, transaction) =>
{ {
@@ -46,9 +58,43 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
cancellationToken); cancellationToken);
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken); await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
} }
foreach (var calculation in calculations)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
}, cancellationToken); }, cancellationToken);
} }
public async Task ReplaceStatisticsAsync(
IReadOnlyList<AdminResultCalculation> calculations,
CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
foreach (var calculation in calculations)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
}, cancellationToken);
}
public async Task<bool> 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( public async Task SaveFeatureScoresAsync(
IReadOnlyList<FeatureScoreWrite> entries, IReadOnlyList<FeatureScoreWrite> entries,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -72,6 +118,7 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
AccountWorkflowInstance instance, AccountWorkflowInstance instance,
AccountWorkflowAction action, AccountWorkflowAction action,
AdminWorkflowResult? result, AdminWorkflowResult? result,
AdminResultCalculation? calculation,
AdminAuditEntry audit, AdminAuditEntry audit,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -130,10 +177,108 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
], ],
cancellationToken); cancellationToken);
} }
if (calculation is not null)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken); await InsertAuditAsync(connection, transaction, audit, cancellationToken);
}, 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( private async Task TransactionAsync(
Func<DbConnection, DbTransaction, Task> operation, Func<DbConnection, DbTransaction, Task> operation,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -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<AdminWorkflowResult> 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<AdminWorkflowResult> 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<string>(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";
}
}
@@ -16,7 +16,7 @@ internal sealed class AdminResultService(
AdminResultRepository repository, AdminResultRepository repository,
IApplicationCache cache) : IAdminResultService IApplicationCache cache) : IAdminResultService
{ {
public async Task<AdminEndpointResult> GetAsync( public async Task<AdminEndpointResult> GetSummaryAsync(
string sessionToken, string sessionToken,
string examId, string examId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -29,9 +29,7 @@ internal sealed class AdminResultService(
{ {
["ok"] = true, ["ok"] = true,
["selectedExamId"] = "", ["selectedExamId"] = "",
["results"] = new JsonArray(),
["appeals"] = new JsonArray(), ["appeals"] = new JsonArray(),
["registrations"] = new JsonArray(),
["exams"] = new JsonArray(), ["exams"] = new JsonArray(),
["resultCache"] = CacheStatus() ["resultCache"] = CacheStatus()
}); });
@@ -42,29 +40,26 @@ internal sealed class AdminResultService(
var selectedExam = operational.Exams.FirstOrDefault(item => item.Id == examId); var selectedExam = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (selectedExam is null) return Error(404, "请选择有效的考试后再读取成绩"); if (selectedExam is null) return Error(404, "请选择有效的考试后再读取成绩");
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken); 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 scoped = operational.Registrations.Where(item =>
{ lookups.ProfileByUserId.TryGetValue(item.UserId, out var profile) && InScope(user, profile)).ToArray();
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile);
}).ToArray();
var selectedScoped = scoped.Where(item => item.ExamId == selectedExam.Id).ToArray(); var selectedScoped = scoped.Where(item => item.ExamId == selectedExam.Id).ToArray();
if (Level(user) != "super" && selectedScoped.Length == 0) if (Level(user) != "super" && selectedScoped.Length == 0)
return Error(404, "该考试不在当前管理范围内"); return Error(404, "该考试不在当前管理范围内");
var approved = selectedScoped.Where(item => item.Status == "approved").ToArray(); var approved = selectedScoped.Where(item => item.Status == "approved").ToArray();
var selectedIds = selectedScoped.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); 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 rawScopedResults = examResults.Where(item => selectedIds.Contains(item.RegistrationId)).ToArray();
var resultViews = rawScopedResults var appealedResultIds = directory.Instances
.Select(item => ResultJson(operational, directory, selectedExam, examResults, item)) .Where(item => item.BusinessType == "score_appeal")
.ToArray(); .Select(item => item.BusinessId)
var resultById = resultViews.ToDictionary( .ToHashSet(StringComparer.Ordinal);
item => Text(item["id"]), var resultById = rawScopedResults
item => item, .Where(item => appealedResultIds.Contains(item.Id))
.ToDictionary(
item => item.Id,
item => ResultJson(lookups, selectedExam, item),
StringComparer.Ordinal); StringComparer.Ordinal);
var appealViews = directory.Instances var appealViews = directory.Instances
.Where(item => item.BusinessType == "score_appeal" && resultById.ContainsKey(item.BusinessId)) .Where(item => item.BusinessType == "score_appeal" && resultById.ContainsKey(item.BusinessId))
@@ -77,38 +72,169 @@ internal sealed class AdminResultService(
return output; return output;
}) })
.ToArray(); .ToArray();
var summaryByRegistration = summaries.ToDictionary(item => item.RegistrationId, StringComparer.Ordinal);
var exams = operational.Exams.Select(exam => var exams = operational.Exams.Select(exam =>
{ {
var output = PublicExamJson(exam); var output = PublicExamJson(exam);
if (exam.Id != selectedExam.Id) return output; 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 enrolled = approved.Sum(item => SubjectIds(item).Count);
var scopedSummaries = approved
.Select(item => summaryByRegistration.GetValueOrDefault(item.Id))
.Where(item => item is not null)
.Cast<AdminRegistrationResultSummary>()
.ToArray();
output["registrationCount"] = approved.Length; output["registrationCount"] = approved.Length;
output["enrolledSubjects"] = enrolled; output["enrolledSubjects"] = enrolled;
output["scored"] = rawScopedResults.Length; output["scored"] = rawScopedResults.Length;
output["published"] = rawScopedResults.Count(item => item.Published); output["published"] = rawScopedResults.Count(item => item.Published);
output["missing"] = Math.Max(0, enrolled - rawScopedResults.Length); output["missing"] = Math.Max(0, enrolled - rawScopedResults.Length);
output["complete"] = summaries.Count(item => item.Complete); output["complete"] = scopedSummaries.Count(item => item.Complete);
output["qualified"] = summaries.Count(item => item.Complete && item.Qualified is true); output["qualified"] = scopedSummaries.Count(item => item.Complete && item.Qualified is true);
output["unqualified"] = summaries.Count(item => item.Complete && item.Qualified is false); output["unqualified"] = scopedSummaries.Count(item => item.Complete && item.Qualified is false);
output["appeals"] = appealViews.Length; output["appeals"] = appealViews.Length;
return output; return output;
}).ToArray(); }).ToArray();
var registrations = Level(user) == "super"
? approved.Select(item => RegistrationJson(operational, directory, item)).ToArray()
: [];
return Success(new JsonObject return Success(new JsonObject
{ {
["ok"] = true, ["ok"] = true,
["selectedExamId"] = selectedExam.Id, ["selectedExamId"] = selectedExam.Id,
["results"] = new JsonArray(resultViews),
["appeals"] = new JsonArray(appealViews), ["appeals"] = new JsonArray(appealViews),
["registrations"] = new JsonArray(registrations),
["exams"] = new JsonArray(exams), ["exams"] = new JsonArray(exams),
["resultCache"] = CacheStatus() ["resultCache"] = CacheStatus()
}); });
} }
public async Task<AdminEndpointResult> 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<AdminEndpointResult> 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<AdminEndpointResult> SaveAsync( public async Task<AdminEndpointResult> SaveAsync(
string sessionToken, string sessionToken,
JsonObject body, JsonObject body,
@@ -129,9 +255,9 @@ internal sealed class AdminResultService(
return Error(400, "该考生未报名此科目"); return Error(400, "该考生未报名此科目");
var score = Number(body["score"]); var score = Number(body["score"]);
var fullScore = Number(subject["fullScore"]); var fullScore = Number(subject["fullScore"]);
if (!double.IsFinite(score) || score < 0 || score > fullScore) if (!AdminResultStatistics.IsValidScore(score, fullScore))
return Error(400, $"成绩必须在 0—{AdminResultScoring.Format(fullScore)} 之间"); return Error(400, $"成绩必须在 0—{AdminResultStatistics.Format(fullScore)} 之间,且只能填写整数或 .5");
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList(); var allResults = (await resultSnapshotLoader.LoadForExamAsync(exam!.Id, cancellationToken)).ToList();
var existing = allResults.FirstOrDefault(item => var existing = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == subjectId); item.RegistrationId == registration.Id && item.SubjectId == subjectId);
var published = JsBoolean(body["published"]); var published = JsBoolean(body["published"]);
@@ -146,10 +272,12 @@ internal sealed class AdminResultService(
now, now,
published ? existing?.PublishedAt ?? now : null); published ? existing?.PublishedAt ?? now : null);
Replace(allResults, candidate); Replace(allResults, candidate);
candidate = candidate with var calculation = AdminResultStatistics.Calculate(
{ exam,
Grade = published ? AdminResultScoring.Rank(allResults, candidate).Grade : "待发布" operational.Registrations,
}; allResults,
now);
candidate = ApplyMetric(candidate, calculation);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId); var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
await repository.SaveAsync( await repository.SaveAsync(
new ResultWrite( new ResultWrite(
@@ -158,7 +286,8 @@ internal sealed class AdminResultService(
Audit( Audit(
user, user,
published ? "发布成绩" : "保存成绩", published ? "发布成绩" : "保存成绩",
$"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(score)}")), $"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultStatistics.Format(score)}")),
calculation,
cancellationToken); cancellationToken);
await cache.InvalidateAsync("results"); await cache.InvalidateAsync("results");
return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) }); return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) });
@@ -180,7 +309,7 @@ internal sealed class AdminResultService(
var sourceRows = body["rows"] as JsonArray ?? []; var sourceRows = body["rows"] as JsonArray ?? [];
if (sourceRows.Count == 0) return Error(400, "没有需要保存的成绩"); if (sourceRows.Count == 0) return Error(400, "没有需要保存的成绩");
var published = JsBoolean(body["published"]); 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<PreparedResult>(); var prepared = new List<PreparedResult>();
var seen = new HashSet<string>(StringComparer.Ordinal); var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < sourceRows.Count; index++) for (var index = 0; index < sourceRows.Count; index++)
@@ -195,8 +324,8 @@ internal sealed class AdminResultService(
return Error(400, $"第 {index + 1} 条考生成绩无效或重复"); return Error(400, $"第 {index + 1} 条考生成绩无效或重复");
var score = Number(row["score"]); var score = Number(row["score"]);
var fullScore = Number(subject["fullScore"]); var fullScore = Number(subject["fullScore"]);
if (!double.IsFinite(score) || score < 0 || score > fullScore) if (!AdminResultStatistics.IsValidScore(score, fullScore))
return Error(400, $"第 {index + 3} :成绩须在 0—{AdminResultScoring.Format(fullScore)} 之间;请返回预览修正后重试"); return Error(400, $"第 {index + 1} :成绩须在 0—{AdminResultStatistics.Format(fullScore)} 之间,且只能填写整数或 .5");
var existing = allResults.FirstOrDefault(item => var existing = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"])); item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
var now = NowIso(); var now = NowIso();
@@ -212,12 +341,14 @@ internal sealed class AdminResultService(
Replace(allResults, result); Replace(allResults, result);
prepared.Add(new(registration, existing, result)); prepared.Add(new(registration, existing, result));
} }
var calculation = AdminResultStatistics.Calculate(
exam,
operational.Registrations,
allResults,
NowIso());
var writes = prepared.Select(item => var writes = prepared.Select(item =>
{ {
var result = item.Result with var result = ApplyMetric(item.Result, calculation);
{
Grade = published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
};
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.Registration.UserId); var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.Registration.UserId);
var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId); var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId);
return new ResultWrite( return new ResultWrite(
@@ -226,9 +357,9 @@ internal sealed class AdminResultService(
Audit( Audit(
user, user,
published ? "Excel 批量发布成绩" : "Excel 批量保存成绩", 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(); }).ToArray();
await repository.SaveManyAsync(writes, cancellationToken); await repository.SaveManyAsync(writes, [calculation], cancellationToken);
await cache.InvalidateAsync("results"); await cache.InvalidateAsync("results");
var create = prepared.Count(item => item.Existing is null); var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject return Success(new JsonObject
@@ -286,8 +417,8 @@ internal sealed class AdminResultService(
else if (registration is null) message = "该考生没有已通过的本场考试报名"; else if (registration is null) message = "该考生没有已通过的本场考试报名";
else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目"; else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目";
var score = Text(row["score"]).Length == 0 ? double.NaN : Number(row["score"]); 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"]))) if (message is null && !AdminResultStatistics.IsValidScore(score, Number(subject?["fullScore"])))
message = $"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间"; message = $"成绩须在 0—{AdminResultStatistics.Format(Number(subject?["fullScore"]))} 之间,且只能填写整数或 .5";
var publishText = Text(row["published"]); var publishText = Text(row["published"]);
bool published; bool published;
if (row["published"] is JsonValue publishedValue && if (row["published"] is JsonValue publishedValue &&
@@ -316,19 +447,28 @@ internal sealed class AdminResultService(
Replace(allResults, result); Replace(allResults, result);
prepared.Add(new(registration, exam!, subject!, existing, result, candidateNumber)); 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 writes = prepared.Select(item =>
{ {
var result = item.Result with var result = ApplyMetric(item.Result, metricByResultId.GetValueOrDefault(item.Result.Id));
{
Grade = item.Result.Published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
};
return new ResultWrite( return new ResultWrite(
result, result,
item.Existing is null, item.Existing is null,
Audit(user, result.Published ? "Excel 批量发布成绩" : "Excel 批量保存成绩", 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(); }).ToArray();
await repository.SaveManyAsync(writes, cancellationToken); await repository.SaveManyAsync(writes, calculations, cancellationToken);
await cache.InvalidateAsync("results"); await cache.InvalidateAsync("results");
var create = prepared.Count(item => item.Existing is null); var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject return Success(new JsonObject
@@ -388,8 +528,8 @@ internal sealed class AdminResultService(
!SubjectIds(registration).Contains(Text(subject["id"]))) !SubjectIds(registration).Contains(Text(subject["id"])))
AddError("该考生未报考此科目"); AddError("该考生未报考此科目");
var score = Text(source["score"]).Length == 0 ? double.NaN : Number(source["score"]); 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"])) if (!AdminResultStatistics.IsValidScore(score, Number(subject?["fullScore"])))
AddError($"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间"); AddError($"成绩须在 0—{AdminResultStatistics.Format(Number(subject?["fullScore"]))} 之间,且只能填写整数或 .5");
var publishedText = Text(source["published"]); var publishedText = Text(source["published"]);
var published = source["published"] is JsonValue publishedValue && var published = source["published"] is JsonValue publishedValue &&
publishedValue.TryGetValue<bool>(out var publishedBoolean) publishedValue.TryGetValue<bool>(out var publishedBoolean)
@@ -441,21 +581,35 @@ internal sealed class AdminResultService(
["errors"] = errors ["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)) foreach (var row in output.Where(item => (item["errors"] as JsonArray)?.Count == 0))
{ {
var result = previewResults.First(item => var result = previewResults.First(item =>
item.RegistrationId == Text(row["registrationId"]) && item.SubjectId == Text(row["subjectId"])); item.RegistrationId == Text(row["registrationId"]) && item.SubjectId == Text(row["subjectId"]));
var exam = operational.Exams.First(item => item.Id == Text(row["examId"])); var exam = operational.Exams.First(item => item.Id == Text(row["examId"]));
var subject = exam.Subjects.First(item => Text(item["id"]) == result.SubjectId); var subject = exam.Subjects.First(item => Text(item["id"]) == result.SubjectId);
var pass = AdminResultScoring.Pass(previewResults, result, subject); var metric = previewMetricByResultId[result.Id];
row["rank"] = pass.Rank.Rank; row["rank"] = metric.Rank;
row["cohortSize"] = pass.Rank.CohortSize; row["cohortSize"] = metric.CohortSize;
row["rankPercent"] = pass.Rank.RankPercent; row["rankPercent"] = metric.RankPercent;
row["grade"] = result.Published ? pass.Rank.Grade : "待发布"; row["grade"] = metric.Grade;
row["passScore"] = pass.PassScore; row["passScore"] = metric.PassScore;
row["cutoffRank"] = pass.CutoffRank; row["cutoffRank"] = metric.CutoffRank;
row["qualified"] = JsonValue.Create(pass.Qualified); row["qualified"] = JsonValue.Create(metric.Qualified);
row["passText"] = AdminResultScoring.PassText(subject); row["passText"] = AdminResultStatistics.PassText(subject);
} }
var invalid = output.Count(item => (item["errors"] as JsonArray)?.Count > 0); var invalid = output.Count(item => (item["errors"] as JsonArray)?.Count > 0);
return Success(new JsonObject return Success(new JsonObject
@@ -515,7 +669,7 @@ internal sealed class AdminResultService(
entries.Add(new( entries.Add(new(
registration.Id, registration.Id,
score, 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 repository.SaveFeatureScoresAsync(entries, cancellationToken);
await cache.InvalidateAsync("results"); await cache.InvalidateAsync("results");
@@ -546,7 +700,7 @@ internal sealed class AdminResultService(
[new( [new(
registration.Id, registration.Id,
score, 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); cancellationToken);
await cache.InvalidateAsync("results"); await cache.InvalidateAsync("results");
var output = registration.Data.DeepClone().AsObject(); var output = registration.Data.DeepClone().AsObject();
@@ -582,7 +736,9 @@ internal sealed class AdminResultService(
var user = context.User!; var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.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 => var registrations = operational.Registrations.Where(item =>
{ {
if (item.Status != "approved" || (examId.Length > 0 && item.ExamId != examId)) return false; 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 => var result = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"])); 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; var card = registration.Data["admitCard"] as JsonObject;
rows.Add(new( rows.Add(new(
account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]), account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]),
@@ -612,13 +767,13 @@ internal sealed class AdminResultService(
Text(exam?.Data["name"]), Text(exam?.Data["name"]),
Text(subject["name"]), Text(subject["name"]),
Number(subject["fullScore"]), Number(subject["fullScore"]),
AdminResultScoring.PassText(subject), AdminResultStatistics.PassText(subject),
pass?.PassScore is double passScore ? passScore : "", result?.PassScore is double passScore ? passScore : "",
result is null ? "" : result.Score, result is null ? "" : result.Score,
pass?.Rank.Rank is int rank ? rank : "", result?.Rank is int rank ? rank : "",
pass?.Rank.RankPercent is double percent ? percent : "", result?.RankPercent is double percent ? percent : "",
result is null || pass?.Qualified is null ? "" : pass.Qualified.Value ? "达线" : "未达线", result?.Qualified is null ? "" : result.Qualified.Value ? "达线" : "未达线",
result is null ? "" : result.Published ? pass?.Rank.Grade ?? "" : "待发布", result is null ? "" : result.Published ? result.Grade : "待发布",
result?.Published is true ? "发布" : "不发布", result?.Published is true ? "发布" : "不发布",
result?.UpdatedAt ?? result?.PublishedAt ?? "")); result?.UpdatedAt ?? result?.PublishedAt ?? ""));
} }
@@ -674,8 +829,8 @@ internal sealed class AdminResultService(
{ {
reviewedScore = Number(body["reviewedScore"]); reviewedScore = Number(body["reviewedScore"]);
var fullScore = Number(subject?["fullScore"]); var fullScore = Number(subject?["fullScore"]);
if (!double.IsFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > fullScore) if (!AdminResultStatistics.IsValidScore(reviewedScore, fullScore))
return Error(400, $"最终审批必须填写 0—{AdminResultScoring.Format(fullScore)} 之间的复核后分数"); return Error(400, $"最终审批必须填写 0—{AdminResultStatistics.Format(fullScore)} 之间的整数或 .5 分数");
} }
var now = NowIso(); var now = NowIso();
@@ -684,6 +839,7 @@ internal sealed class AdminResultService(
string? nextAssigneeId = null; string? nextAssigneeId = null;
AccountWorkflowInstance updatedInstance; AccountWorkflowInstance updatedInstance;
AdminWorkflowResult? updatedResult = null; AdminWorkflowResult? updatedResult = null;
AdminResultCalculation? calculation = null;
if (requestedStatus == "rejected") if (requestedStatus == "rejected")
{ {
actionName = "reject"; actionName = "reject";
@@ -712,22 +868,23 @@ internal sealed class AdminResultService(
var originalScore = result.Score; var originalScore = result.Score;
updatedResult = result with { Score = reviewedScore, UpdatedAt = now }; updatedResult = result with { Score = reviewedScore, UpdatedAt = now };
Replace(allResults, updatedResult); Replace(allResults, updatedResult);
updatedResult = updatedResult with calculation = AdminResultStatistics.Calculate(
{ exam!,
Grade = AdminResultScoring.Rank(allResults, updatedResult, reviewedScore).Grade operational.Registrations,
}; allResults,
now);
updatedResult = ApplyMetric(updatedResult, calculation);
Replace(allResults, updatedResult); Replace(allResults, updatedResult);
var pass = AdminResultScoring.Pass(allResults, updatedResult, subject, reviewedScore); var conclusion = updatedResult.Qualified is null
var conclusion = pass.Qualified is null
? "本科不判定单科达线" ? "本科不判定单科达线"
: pass.Qualified.Value : updatedResult.Qualified.Value
? $"达到{PassConclusion(subject, pass)}" ? $"达到{PassConclusion(subject, updatedResult)}"
: $"未达到{PassConclusion(subject, pass)}"; : $"未达到{PassConclusion(subject, updatedResult)}";
note = string.Join( note = string.Join(
'', '',
new[] new[]
{ {
$"原分 {AdminResultScoring.Format(originalScore)} → 复核后 {AdminResultScoring.Format(reviewedScore)}", $"原分 {AdminResultStatistics.Format(originalScore)} → 复核后 {AdminResultStatistics.Format(reviewedScore)}",
conclusion, conclusion,
note note
}.Where(item => item.Length > 0)); }.Where(item => item.Length > 0));
@@ -745,11 +902,12 @@ internal sealed class AdminResultService(
? "退回成绩复议" ? "退回成绩复议"
: finalStep ? "复议终审并更新成绩" : "处理成绩复议"; : finalStep ? "复议终审并更新成绩" : "处理成绩复议";
var detail = $"{Text(profile.Data["name"])} · {Text(exam?.Data["name"])} · {Text(subject?["name"])}" + 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( await repository.ReviewAppealAsync(
updatedInstance, updatedInstance,
action, action,
updatedResult, updatedResult,
calculation,
Audit(user, auditAction, detail), Audit(user, auditAction, detail),
cancellationToken); cancellationToken);
if (updatedResult is not null) if (updatedResult is not null)
@@ -757,16 +915,13 @@ internal sealed class AdminResultService(
await cache.InvalidateAsync("results"); await cache.InvalidateAsync("results");
} }
var responseResult = updatedResult ?? result; var responseResult = updatedResult ?? result;
var rank = AdminResultScoring.Rank(allResults, responseResult);
var responsePass = AdminResultScoring.Pass(allResults, responseResult, subject);
var resultOutput = RawResultJson(responseResult); var resultOutput = RawResultJson(responseResult);
resultOutput["rank"] = JsonValue.Create(rank.Rank); resultOutput["rank"] = JsonValue.Create(responseResult.Rank);
resultOutput["cohortSize"] = rank.CohortSize; resultOutput["cohortSize"] = responseResult.CohortSize;
resultOutput["rankPercent"] = JsonValue.Create(rank.RankPercent); resultOutput["rankPercent"] = JsonValue.Create(responseResult.RankPercent);
resultOutput["grade"] = rank.Grade; resultOutput["passScore"] = JsonValue.Create(responseResult.PassScore);
resultOutput["passScore"] = JsonValue.Create(responsePass.PassScore); resultOutput["cutoffRank"] = JsonValue.Create(responseResult.CutoffRank);
resultOutput["cutoffRank"] = JsonValue.Create(responsePass.CutoffRank); resultOutput["qualified"] = JsonValue.Create(responseResult.Qualified);
resultOutput["qualified"] = JsonValue.Create(responsePass.Qualified);
return Success(new JsonObject return Success(new JsonObject
{ {
["ok"] = true, ["ok"] = true,
@@ -776,23 +931,19 @@ internal sealed class AdminResultService(
} }
private static JsonObject ResultJson( private static JsonObject ResultJson(
AdminOperationalSnapshot operational, ResultLookups lookups,
AdminAccountBatchSnapshot directory,
OperationalExam exam, OperationalExam exam,
IReadOnlyList<AdminWorkflowResult> cohortResults,
AdminWorkflowResult result) AdminWorkflowResult result)
{ {
var registration = operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId); var registration = lookups.RegistrationById.GetValueOrDefault(result.RegistrationId);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId); var profile = registration is null ? null : lookups.ProfileByUserId.GetValueOrDefault(registration.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration?.UserId); var account = registration is null ? null : lookups.AccountByUserId.GetValueOrDefault(registration.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId); var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId);
var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId); var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
var pass = AdminResultScoring.Pass(cohortResults, result, subject);
var output = RawResultJson(result); var output = RawResultJson(result);
output["grade"] = result.Published ? pass.Rank.Grade : result.Grade; output["rank"] = JsonValue.Create(result.Rank);
output["rank"] = JsonValue.Create(pass.Rank.Rank); output["cohortSize"] = result.CohortSize;
output["cohortSize"] = pass.Rank.CohortSize; output["rankPercent"] = JsonValue.Create(result.RankPercent);
output["rankPercent"] = JsonValue.Create(pass.Rank.RankPercent);
output["candidateName"] = Text(profile?.Data["name"]); output["candidateName"] = Text(profile?.Data["name"]);
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]); output["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]);
output["schoolName"] = Text(profile?.Data["school"]); output["schoolName"] = Text(profile?.Data["school"]);
@@ -804,20 +955,19 @@ internal sealed class AdminResultService(
output["fullScore"] = subject?["fullScore"]?.DeepClone(); output["fullScore"] = subject?["fullScore"]?.DeepClone();
output["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score"; output["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score";
output["passValue"] = (subject?["passValue"] ?? subject?["passScore"])?.DeepClone(); output["passValue"] = (subject?["passValue"] ?? subject?["passScore"])?.DeepClone();
output["passScore"] = JsonValue.Create(pass.PassScore); output["passScore"] = JsonValue.Create(result.PassScore);
output["cutoffRank"] = JsonValue.Create(pass.CutoffRank); output["cutoffRank"] = JsonValue.Create(result.CutoffRank);
output["passText"] = AdminResultScoring.PassText(subject); output["passText"] = AdminResultStatistics.PassText(subject);
output["qualified"] = JsonValue.Create(pass.Qualified); output["qualified"] = JsonValue.Create(result.Qualified);
return output; return output;
} }
private static JsonObject RegistrationJson( private static JsonObject RegistrationJson(
AdminOperationalSnapshot operational, ResultLookups lookups,
AdminAccountBatchSnapshot directory,
OperationalRegistration registration) OperationalRegistration registration)
{ {
var output = registration.Data.DeepClone().AsObject(); 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 ?? []) var subjects = (exam?.Subjects ?? [])
.Where(item => SubjectIds(registration).Contains(Text(item["id"]))) .Where(item => SubjectIds(registration).Contains(Text(item["id"])))
.Select(item => item.DeepClone()) .Select(item => item.DeepClone())
@@ -825,15 +975,15 @@ internal sealed class AdminResultService(
output["exam"] = exam?.Data.DeepClone(); output["exam"] = exam?.Data.DeepClone();
output["subjects"] = new JsonArray(subjects); output["subjects"] = new JsonArray(subjects);
output["amountDue"] = Math.Round(subjects.Sum(item => Number(item?["fee"])), 2, MidpointRounding.AwayFromZero); output["amountDue"] = Math.Round(subjects.Sum(item => Number(item?["fee"])), 2, MidpointRounding.AwayFromZero);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? ""; output["paidByName"] = lookups.Operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = directory.Instances.FirstOrDefault(item => var instance = lookups.Directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" && item.BusinessId == registration.Id && item.Status == "pending") ?? 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); item.BusinessType == "registration_review" && item.BusinessId == registration.Id);
output["workflow"] = instance is null ? null : WorkflowJson(directory, instance); output["workflow"] = instance is null ? null : WorkflowJson(lookups.Directory, instance);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId); var profile = lookups.ProfileByUserId.GetValueOrDefault(registration.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId); var account = lookups.AccountByUserId.GetValueOrDefault(registration.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId); 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["candidateName"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "";
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]); output["candidateNumber"] = account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]);
output["schoolName"] = Text(profile?.Data["school"]); output["schoolName"] = Text(profile?.Data["school"]);
@@ -846,46 +996,6 @@ internal sealed class AdminResultService(
return output; return output;
} }
private static ResultSummary Summary(
OperationalExam exam,
IReadOnlyList<OperationalRegistration> examRegistrations,
IReadOnlyList<AdminWorkflowResult> 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( private static JsonObject WorkflowJson(
AdminAccountBatchSnapshot snapshot, AdminAccountBatchSnapshot snapshot,
AccountWorkflowInstance instance, AccountWorkflowInstance instance,
@@ -945,6 +1055,60 @@ internal sealed class AdminResultService(
? items.Select(Text).Where(item => item.Length > 0).ToHashSet(StringComparer.Ordinal) ? 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() private static JsonObject RawResultJson(AdminWorkflowResult result) => new()
{ {
["id"] = result.Id, ["id"] = result.Id,
@@ -988,6 +1152,28 @@ internal sealed class AdminResultService(
["status"] = cache.Status ["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<AdminWorkflowResult> results, AdminWorkflowResult result) private static void Replace(List<AdminWorkflowResult> results, AdminWorkflowResult result)
{ {
var index = results.FindIndex(item => item.Id == result.Id); var index = results.FindIndex(item => item.Id == result.Id);
@@ -1053,11 +1239,11 @@ internal sealed class AdminResultService(
.FirstOrDefault(); .FirstOrDefault();
} }
private static string PassConclusion(JsonObject? subject, AdminPassInfo pass) private static string PassConclusion(JsonObject? subject, AdminWorkflowResult result)
{ {
if (Text(subject?["passRule"]) == "rank_percent") if (Text(subject?["passRule"]) == "rank_percent")
return $"排名前 {AdminResultScoring.Format(Number(subject?["passValue"]))}%(当前第 {pass.Rank.Rank} 名,截止第 {pass.CutoffRank} 名)"; return $"排名前 {AdminResultStatistics.Format(Number(subject?["passValue"]))}%(当前第 {result.Rank} 名,截止第 {result.CutoffRank} 名)";
return $"固定及格线 {AdminResultScoring.Format(pass.PassScore ?? 0)} 分"; return $"固定及格线 {AdminResultStatistics.Format(result.PassScore ?? 0)} 分";
} }
private static JsonObject StepJson(AccountWorkflowStep item) => new() private static JsonObject StepJson(AccountWorkflowStep item) => new()
@@ -1156,7 +1342,40 @@ internal sealed class AdminResultService(
AdminWorkflowResult? Existing, AdminWorkflowResult? Existing,
AdminWorkflowResult Result, AdminWorkflowResult Result,
string CandidateNumber); 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<string, OperationalRegistration> RegistrationById,
IReadOnlyDictionary<string, OperationalProfile> ProfileByUserId,
IReadOnlyDictionary<string, OperationalUser> AccountByUserId,
IReadOnlyDictionary<string, OperationalUser> AccountByRegistrationId,
IReadOnlyDictionary<string, AdminClass> 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) private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{ {
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
@@ -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<AdminResultMetric> Metrics,
IReadOnlyList<AdminRegistrationResultSummary> Summaries);
internal static class AdminResultStatistics
{
public static AdminResultCalculation Calculate(
OperationalExam exam,
IReadOnlyList<OperationalRegistration> registrations,
IReadOnlyList<AdminWorkflowResult> 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<AdminResultMetric>(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<string, TotalRank>(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<double> 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<string>(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<string, AdminWorkflowResult> PublishedBySubject,
bool Complete,
double TotalScore,
double FullScore,
string SubjectKey);
}
@@ -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<AdminResultCalculation>();
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);
}
}
@@ -12,15 +12,97 @@ internal sealed record AdminWorkflowResult(
string Grade, string Grade,
bool Published, bool Published,
string? UpdatedAt, 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) internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{ {
public async Task<IReadOnlyList<AdminWorkflowResult>> LoadAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<AdminWorkflowResult>> LoadAsync(CancellationToken cancellationToken)
=> await LoadAsync(null, null, cancellationToken);
public async Task<IReadOnlyList<AdminWorkflowResult>> LoadForExamAsync(
string examId,
CancellationToken cancellationToken,
string? subjectId = null) =>
await LoadAsync(examId, subjectId, cancellationToken);
public async Task<IReadOnlyList<AdminRegistrationResultSummary>> LoadSummariesForExamAsync(
string examId,
CancellationToken cancellationToken)
=> await LoadSummariesAsync(examId, cancellationToken);
public async Task<IReadOnlyList<AdminRegistrationResultSummary>> LoadSummariesAsync(
CancellationToken cancellationToken)
=> await LoadSummariesAsync(null, cancellationToken);
private async Task<IReadOnlyList<AdminRegistrationResultSummary>> LoadSummariesAsync(
string? examId,
CancellationToken cancellationToken)
{ {
await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand(); 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<AdminRegistrationResultSummary>();
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<IReadOnlyList<AdminWorkflowResult>> 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); await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<AdminWorkflowResult>(); var output = new List<AdminWorkflowResult>();
while (await reader.ReadAsync(cancellationToken)) while (await reader.ReadAsync(cancellationToken))
@@ -33,20 +115,60 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
Text(reader, "grade"), Text(reader, "grade"),
Boolean(reader, "published"), Boolean(reader, "published"),
Optional(reader, "updated_at"), 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; 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) => private static string Text(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? ""; Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
private static double Number(DbDataReader reader, string name) => private static double Number(DbDataReader reader, string name) =>
Convert.ToDouble(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); 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) => private static bool Boolean(DbDataReader reader, string name) =>
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0; 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) private static string? Optional(DbDataReader reader, string name)
{ {
var ordinal = reader.GetOrdinal(name); var ordinal = reader.GetOrdinal(name);
@@ -231,63 +231,26 @@ internal sealed class AdminWorkflowService(
var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId); var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId);
var exam = snapshots.Operational.Exams.FirstOrDefault(item => item.Id == registration?.ExamId); var exam = snapshots.Operational.Exams.FirstOrDefault(item => item.Id == registration?.ExamId);
var subject = exam?.Subjects.FirstOrDefault(item => item["id"]?.GetValue<string>() == result.SubjectId); var subject = exam?.Subjects.FirstOrDefault(item => item["id"]?.GetValue<string>() == result.SubjectId);
var rank = Rank(snapshots.Results, result);
var pass = Pass(snapshots.Results, result, subject);
return new JsonObject return new JsonObject
{ {
["score"] = result.Score, ["score"] = result.Score,
["grade"] = rank.Grade.Length > 0 ? rank.Grade : result.Grade, ["grade"] = result.Grade,
["rank"] = JsonValue.Create(rank.Rank), ["rank"] = JsonValue.Create(result.Rank),
["cohortSize"] = rank.CohortSize, ["cohortSize"] = result.CohortSize,
["rankPercent"] = JsonValue.Create(rank.RankPercent), ["rankPercent"] = JsonValue.Create(result.RankPercent),
["examName"] = exam?.Data["name"]?.GetValue<string>() ?? "", ["examName"] = exam?.Data["name"]?.GetValue<string>() ?? "",
["examCode"] = exam?.Data["code"]?.GetValue<string>() ?? "", ["examCode"] = exam?.Data["code"]?.GetValue<string>() ?? "",
["subjectName"] = subject?["name"]?.GetValue<string>() ?? "", ["subjectName"] = subject?["name"]?.GetValue<string>() ?? "",
["fullScore"] = JsonValue.Create(NullableNumber(subject?["fullScore"])), ["fullScore"] = JsonValue.Create(NullableNumber(subject?["fullScore"])),
["passRule"] = subject?["passRule"]?.GetValue<string>() ?? "fixed_score", ["passRule"] = subject?["passRule"]?.GetValue<string>() ?? "fixed_score",
["passValue"] = JsonValue.Create(NullableNumber(subject?["passValue"] ?? subject?["passScore"])), ["passValue"] = JsonValue.Create(NullableNumber(subject?["passValue"] ?? subject?["passScore"])),
["passScore"] = JsonValue.Create(pass.PassScore), ["passScore"] = JsonValue.Create(result.PassScore),
["cutoffRank"] = JsonValue.Create(pass.CutoffRank), ["cutoffRank"] = JsonValue.Create(result.CutoffRank),
["passText"] = PassText(subject), ["passText"] = PassText(subject),
["qualified"] = JsonValue.Create(pass.Qualified) ["qualified"] = JsonValue.Create(result.Qualified)
}; };
} }
private static RankInfo Rank(IReadOnlyList<AdminWorkflowResult> 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<AdminWorkflowResult> results, AdminWorkflowResult result, JsonObject? subject)
{
var rule = subject?["passRule"]?.GetValue<string>() ?? "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) private static string PassText(JsonObject? subject)
{ {
var rule = subject?["passRule"]?.GetValue<string>() ?? "fixed_score"; var rule = subject?["passRule"]?.GetValue<string>() ?? "fixed_score";
@@ -387,7 +350,5 @@ internal sealed class AdminWorkflowService(
private sealed record WorkflowSnapshots(AdminAccountBatchSnapshot Workflow, AdminOperationalSnapshot Operational, AdminCenterSnapshot Centers, IReadOnlyList<AdminWorkflowResult> Results); private sealed record WorkflowSnapshots(AdminAccountBatchSnapshot Workflow, AdminOperationalSnapshot Operational, AdminCenterSnapshot Centers, IReadOnlyList<AdminWorkflowResult> Results);
private sealed record WorkflowScope(string? SchoolId, string? ClassId, string Name, string SchoolName, string ClassName); 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); } private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
} }
@@ -8,8 +8,7 @@ internal sealed record CandidateReadSnapshot(
IReadOnlyList<CandidateExam> Exams, IReadOnlyList<CandidateExam> Exams,
IReadOnlyList<CandidateRegistration> Registrations, IReadOnlyList<CandidateRegistration> Registrations,
IReadOnlyList<CandidateResult> Results, IReadOnlyList<CandidateResult> Results,
IReadOnlyList<CandidateRegistration> AllRegistrations, IReadOnlyList<CandidateResultSummary> ResultSummaries,
IReadOnlyList<CandidateResult> AllResults,
IReadOnlyList<CandidateWorkflow> Workflows, IReadOnlyList<CandidateWorkflow> Workflows,
IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances, IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances,
IReadOnlyList<CandidateWorkflowAction> WorkflowActions, IReadOnlyList<CandidateWorkflowAction> WorkflowActions,
@@ -115,7 +114,27 @@ internal sealed record CandidateResult(
string Grade, string Grade,
bool Published, bool Published,
string? UpdatedAt, 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( internal sealed record CandidateWorkflow(
string Id, string Id,
@@ -172,8 +191,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
{ {
public async Task<CandidateReadSnapshot> LoadAsync( public async Task<CandidateReadSnapshot> LoadAsync(
string userId, string userId,
CancellationToken cancellationToken, CancellationToken cancellationToken)
bool includeCohort = false)
{ {
await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var subjects = await QueryAsync(connection, var subjects = await QueryAsync(connection,
@@ -188,12 +206,7 @@ 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", "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), reader => ReadExam(reader, subjectsByExam),
cancellationToken); cancellationToken);
var registrationRows = includeCohort var registrationRows = await QueryAsync(connection,
? 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", "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, ReadRegistrationRow,
cancellationToken, cancellationToken,
@@ -245,24 +258,34 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
cardsByRegistration.GetValueOrDefault(row.Id))).ToArray(); cardsByRegistration.GetValueOrDefault(row.Id))).ToArray();
var registrations = allRegistrations.Where(item => item.UserId == userId).ToArray(); var registrations = allRegistrations.Where(item => item.UserId == userId).ToArray();
var allResults = includeCohort var results = await QueryAsync(connection,
? 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, SELECT result.id, result.registration_id, result.subject_id, result.score,
result.published, result.updated_at, result.published_at 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 FROM results result
JOIN registrations registration ON registration.id = result.registration_id 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 WHERE registration.user_id = @userId ORDER BY result.id
""", """,
ReadResult, ReadResult,
cancellationToken, cancellationToken,
new QueryParameter("@userId", userId)); new QueryParameter("@userId", userId));
var userRegistrationIds = registrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); var resultSummaries = await QueryAsync(connection,
var results = allResults.Where(item => userRegistrationIds.Contains(item.RegistrationId)).ToArray(); """
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, var workflowSteps = await QueryAsync(connection,
"SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id", "SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id",
reader => new WorkflowStepRow( reader => new WorkflowStepRow(
@@ -333,8 +356,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
exams, exams,
registrations, registrations,
results, results,
allRegistrations, resultSummaries,
allResults,
workflows, workflows,
instances, instances,
actions, actions,
@@ -480,7 +502,27 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
ReadString(reader, "grade"), ReadString(reader, "grade"),
ReadBoolean(reader, "published"), ReadBoolean(reader, "published"),
ReadOptionalString(reader, "updated_at"), 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( private static CandidateWorkflowInstance ReadWorkflowInstance(DbDataReader reader) => new(
ReadString(reader, "id"), ReadString(reader, "id"),
@@ -540,6 +582,20 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
private static int ReadInt32(DbDataReader reader, string name) => private static int ReadInt32(DbDataReader reader, string name) =>
Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); 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) private static double ReadDouble(DbDataReader reader, string name, double fallback = 0)
{ {
var ordinal = reader.GetOrdinal(name); var ordinal = reader.GetOrdinal(name);
@@ -475,13 +475,8 @@ internal sealed partial class CandidateService
return null; return null;
} }
var results = snapshot.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); var summary = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
if (registration.SubjectIds.Any(id => results.All(result => result.SubjectId != id))) return summary is { Complete: true } ? summary.TotalScore : null;
{
return null;
}
return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero);
} }
private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category) private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category)
@@ -36,7 +36,7 @@ internal sealed partial class CandidateService
string verificationBaseUrl, string verificationBaseUrl,
CancellationToken cancellationToken) 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 registrationById = snapshot.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal);
var results = snapshot.Results var results = snapshot.Results
.Where(item => item.Published && registrationById.ContainsKey(item.RegistrationId)) .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 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) .Where(item => item.RegistrationId == registration.Id && item.Published)
.Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt)); .Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt));
var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults); 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 exam = snapshot.Exams.First(item => item.Id == registration.ExamId);
var subject = exam.Subjects.FirstOrDefault(item => item.Id == result.SubjectId); 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 appealInstance = FindInstance(snapshot, "score_appeal", result.Id);
var appeal = WorkflowView(snapshot, appealInstance); var appeal = WorkflowView(snapshot, appealInstance);
if (appeal is not null) if (appeal is not null)
@@ -168,10 +166,10 @@ internal sealed partial class CandidateService
} }
var json = ResultJson(result); var json = ResultJson(result);
json["rank"] = JsonValue.Create(rank.Rank); json["rank"] = JsonValue.Create(result.Rank);
json["cohortSize"] = rank.CohortSize; json["cohortSize"] = result.CohortSize;
json["rankPercent"] = JsonValue.Create(rank.RankPercent); json["rankPercent"] = JsonValue.Create(result.RankPercent);
json["grade"] = rank.Grade; json["grade"] = result.Grade;
json["examId"] = exam.Id; json["examId"] = exam.Id;
json["examName"] = exam.Name; json["examName"] = exam.Name;
json["examCode"] = exam.Code; json["examCode"] = exam.Code;
@@ -181,10 +179,10 @@ internal sealed partial class CandidateService
json["fullScore"] = subject?.FullScore ?? 150; json["fullScore"] = subject?.FullScore ?? 150;
json["passRule"] = subject?.PassRule ?? "fixed_score"; json["passRule"] = subject?.PassRule ?? "fixed_score";
json["passValue"] = subject?.PassValue ?? subject?.PassScore; json["passValue"] = subject?.PassValue ?? subject?.PassScore;
json["passScore"] = JsonValue.Create(pass.PassScore); json["passScore"] = JsonValue.Create(result.PassScore);
json["cutoffRank"] = JsonValue.Create(pass.CutoffRank); json["cutoffRank"] = JsonValue.Create(result.CutoffRank);
json["passText"] = SubjectPassText(subject); json["passText"] = SubjectPassText(subject);
json["qualified"] = JsonValue.Create(pass.Qualified); json["qualified"] = JsonValue.Create(result.Qualified);
json["appeal"] = appeal; json["appeal"] = appeal;
return json; 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 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) .Where(item => item.RegistrationId == registration.Id && item.Published)
.ToArray(); .ToArray();
var resultsBySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal); var stored = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
var complete = subjects.Length > 0 && subjects.All(item => resultsBySubject.ContainsKey(item.Id)); var complete = stored?.Complete ?? false;
var total = subjects.Sum(item => resultsBySubject.GetValueOrDefault(item.Id)?.Score ?? 0); var total = stored?.TotalScore ?? 0;
var fullScore = subjects.Sum(item => item.FullScore); var fullScore = stored?.FullScore ?? subjects.Sum(item => item.FullScore);
var scoreRatio = fullScore > 0 ? total / fullScore * 100 : 0; var scoreRatio = stored?.ScoreRatio ?? 0;
var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy; var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy;
if (policy.Length == 0) if (policy.Length == 0)
{ {
@@ -220,32 +218,6 @@ internal sealed partial class CandidateService
} }
var value = exam.PassValue; 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 return new JsonObject
{ {
@@ -263,63 +235,14 @@ internal sealed partial class CandidateService
["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero), ["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero),
["passPolicy"] = policy, ["passPolicy"] = policy,
["passValue"] = value, ["passValue"] = value,
["qualified"] = JsonValue.Create(qualified), ["qualified"] = JsonValue.Create(stored?.Qualified),
["rank"] = JsonValue.Create(rank), ["rank"] = JsonValue.Create(stored?.Rank),
["cohortSize"] = JsonValue.Create(cohortSize) ["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 private static string SubjectPassText(CandidateSubject? subject) => (subject?.PassRule ?? "fixed_score") switch
{ {
"none" => "不设单科线", "none" => "不设单科线",
@@ -327,16 +250,6 @@ internal sealed partial class CandidateService
_ => $"固定 {FormatNumber(Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero))} 分" _ => $"固定 {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) => private static string FormatNumber(double value) =>
value.ToString("0.#############################", CultureInfo.InvariantCulture); value.ToString("0.#############################", CultureInfo.InvariantCulture);
@@ -349,7 +262,4 @@ internal sealed partial class CandidateService
return $"data:image/png;base64,{Convert.ToBase64String(qrCode.GetGraphic(10))}"; 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);
} }
@@ -3,6 +3,7 @@ using System.Globalization;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Eis.Infrastructure.Data; namespace Eis.Infrastructure.Data;
@@ -294,5 +295,7 @@ public static class DatabaseInitializationExtensions
await using var scope = serviceProvider.CreateAsyncScope(); await using var scope = serviceProvider.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>() await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
.InitializeAsync(cancellationToken); .InitializeAsync(cancellationToken);
await scope.ServiceProvider.GetRequiredService<AdminResultStatisticsBackfillService>()
.EnsureAsync(cancellationToken);
} }
} }
@@ -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 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT -- 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 ( CREATE TABLE IF NOT EXISTS test_centers (
id VARCHAR(64) NOT NULL, id VARCHAR(64) NOT NULL,
school_id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL,
@@ -253,6 +253,35 @@ PRAGMA foreign_keys = ON;
UNIQUE (registration_id, subject_id) UNIQUE (registration_id, subject_id)
) STRICT; ) 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 ( CREATE TABLE IF NOT EXISTS test_centers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, 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_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_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_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_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); CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
@@ -70,6 +70,7 @@ public static class DependencyInjection
services.AddScoped<AdminArrangementSnapshotLoader>(); services.AddScoped<AdminArrangementSnapshotLoader>();
services.AddScoped<AdminArrangementRepository>(); services.AddScoped<AdminArrangementRepository>();
services.AddScoped<AdminResultRepository>(); services.AddScoped<AdminResultRepository>();
services.AddScoped<AdminResultStatisticsBackfillService>();
services.AddScoped<AdminAdmissionRepository>(); services.AddScoped<AdminAdmissionRepository>();
services.AddScoped<IAdminReadService, AdminReadService>(); services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>(); services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
@@ -189,6 +189,28 @@ public static class NativeAdminReadEndpoints
if (options.NativeResultsEnabled) 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", endpoints.MapGet("/api/admin/results",
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) => (HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
Execute( Execute(
@@ -196,6 +218,10 @@ public static class NativeAdminReadEndpoints
service.GetAsync( service.GetAsync(
Token(context), Token(context),
context.Request.Query["examId"].ToString(), context.Request.Query["examId"].ToString(),
QueryInteger(context, "page", 1),
QueryInteger(context, "pageSize", 50),
context.Request.Query["query"].ToString(),
context.Request.Query["status"].ToString(),
cancellationToken))); cancellationToken)));
endpoints.MapPost("/api/admin/results", endpoints.MapPost("/api/admin/results",
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) => (HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
@@ -381,6 +407,11 @@ public static class NativeAdminReadEndpoints
private static string Token(HttpContext context) => private static string Token(HttpContext context) =>
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; 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<byte[]> ReadWorkbookAsync(HttpRequest request, CancellationToken cancellationToken) private static async Task<byte[]> ReadWorkbookAsync(HttpRequest request, CancellationToken cancellationToken)
{ {
const int maximumBytes = 20 * 1024 * 1024; const int maximumBytes = 20 * 1024 * 1024;
+7 -7
View File
@@ -300,11 +300,11 @@ function updateResultScoreInput(input) {
const value = input.value.trim(); const value = input.value.trim();
const score = Number(value); const score = Number(value);
const max = Number(input.max); 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.dataset.dirty = String(value !== input.defaultValue);
input.setAttribute('aria-invalid', String(invalid)); input.setAttribute('aria-invalid', String(invalid));
row?.classList.toggle('score-row-invalid', 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 form = input.closest('form');
const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0; const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0;
const count = form?.querySelector('[data-result-dirty-count]'); const count = form?.querySelector('[data-result-dirty-count]');
@@ -1306,15 +1306,15 @@ document.addEventListener('submit', async event => {
} else if (kind === 'result-bulk-entry') { } else if (kind === 'result-bulk-entry') {
const mode = event.submitter?.dataset.resultMode || 'draft'; const mode = event.submitter?.dataset.resultMode || 'draft';
const inputs = [...form.querySelectorAll('[data-result-score]')]; 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))); 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} 之间`); } if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间,且只能填写整数或 .5`); }
const targets = mode === 'publish' const targets = mode === 'publish'
? inputs.filter(input => input.value.trim() !== '') ? inputs.filter(input => input.value.trim() !== '')
: inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== ''); : inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== '');
if (mode === 'publish') { if (mode === 'publish') {
const missing = inputs.filter(input => input.value.trim() === ''); const missing = inputs.filter(input => input.value.trim() === '');
if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); } if (missing.length) { missing[0].focus(); throw new Error(`当前页还有 ${missing.length} 名考生未录入成绩`); }
if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return; if (!window.confirm(`确认发布当前页 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return;
} }
if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩'); if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩');
const body = { const body = {
@@ -1324,7 +1324,7 @@ document.addEventListener('submit', async event => {
rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) })) rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) }))
}; };
const result = await api('/api/admin/results/bulk', { method: 'POST', body }); const result = await api('/api/admin/results/bulk', { method: 'POST', body });
toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`); toast(mode === 'publish' ? '当前页成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存并重算本场排名`);
renderRoute(); renderRoute();
} else if (kind === 'feature-score-entry') { } else if (kind === 'feature-score-entry') {
const body = formObject(form); body.featureScore = Number(body.featureScore || 0); const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
+55 -20
View File
@@ -1,7 +1,7 @@
import { formatRegionAddress } from './region-select.js'; import { formatRegionAddress } from './region-select.js';
import { admissionCategoriesEditor } from './admission-plan-editor.js'; import { admissionCategoriesEditor } from './admission-plan-editor.js';
import { specialtyLabel } from '../data/specialty-types.js'; import { specialtyLabel } from '../data/specialty-types.js';
import { filterTableItems } from './table-state.js'; import { filterTableItems, getTableControl } from './table-state.js';
export const numberSegmentMeta = { export const numberSegmentMeta = {
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
@@ -62,10 +62,46 @@ export function createAdminViews(context) {
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
try { 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 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)}` : ''; let data = page === 'results' && !state.resultExamFilter
const data = page === 'results' && !state.resultExamFilter
? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } } ? { 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.selectedExamId) state.resultExamFilter = data.selectedExamId;
if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams; if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams;
state.pageData = data; state.pageData = data;
@@ -323,35 +359,34 @@ export function createAdminViews(context) {
} }
const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0]; const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0];
if (activeSubject) state.resultSubjectFilter = activeSubject.id; if (activeSubject) state.resultSubjectFilter = activeSubject.id;
const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results; const examResults = data.results || [];
const resultPage = paged(examResults, 'resultTable'); 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 examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []);
const appealPage = paged(examAppeals, 'resultAppealTable'); const appealPage = paged(examAppeals, 'resultAppealTable');
const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; 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 = data.entryPage || { items: [], page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'resultEntryTable' };
const resultEntryPage = paged(subjectRegistrations, 'resultEntryTable'); const subjectRegistrations = resultEntryPage.items;
const subjectResults = new Map(examResults.filter(item => item.subjectId === activeSubject?.id).map(item => [item.registrationId, item])); const recordedCount = Number(data.entryCounts?.recorded || 0);
const recordedCount = subjectRegistrations.filter(item => subjectResults.has(item.id)).length; const publishedCount = Number(data.entryCounts?.published || 0);
const publishedCount = subjectRegistrations.filter(item => subjectResults.get(item.id)?.published).length;
const entryRows = resultEntryPage.items.map((registration, index) => { 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'; const status = result?.published ? 'published' : result ? 'draft' : 'missing';
return `<tr data-status="${status}"><td class="mono result-row-index">${index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td class="result-score-input-cell"><input data-result-score data-registration-id="${h(registration.id)}" type="number" min="0" max="${h(activeSubject?.fullScore || '')}" step="0.5" value="${result ? h(result.score) : ''}" placeholder="0—${h(activeSubject?.fullScore || '')}" aria-label="${h(registration.candidateName)}成绩"><small data-score-error></small></td><td>${result ? badge(result.published ? 'published' : 'draft') : '<span class="result-missing">未录入</span>'}</td><td>${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}</td></tr>`; return `<tr data-status="${status}"><td class="mono result-row-index">${(resultEntryPage.page - 1) * resultEntryPage.pageSize + index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td class="result-score-input-cell"><input data-result-score data-registration-id="${h(registration.id)}" type="number" min="0" max="${h(activeSubject?.fullScore || '')}" step="0.5" value="${result ? h(result.score) : ''}" placeholder="0—${h(activeSubject?.fullScore || '')}" aria-label="${h(registration.candidateName)}成绩"><small data-score-error></small></td><td>${result ? badge(result.published ? 'published' : 'draft') : '<span class="result-missing">未录入</span>'}</td><td>${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}</td></tr>`;
}).join(''); }).join('');
const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry-workbench"><form data-form="result-bulk-entry"><header class="result-workbench-head"><div><span>SCORE ENTRY ROSTER</span><h2>按名单录入单科成绩</h2><p>选定考试和科目后,直接在完整考生名单中录分;暂存不会向考生发布。</p></div><div class="result-workbench-selectors"><label><span>考试</span><select name="examId" data-action="result-bulk-exam">${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>科目</span><select name="subjectId" data-action="result-bulk-subject">${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" ${subject.id === activeSubject?.id ? 'selected' : ''}>${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label></div></header><div class="result-workbench-summary"><span><small>应录人数</small><strong>${subjectRegistrations.length}</strong></span><span><small>已录入</small><strong>${recordedCount}</strong></span><span><small>已暂存</small><strong>${Math.max(0, recordedCount - publishedCount)}</strong></span><span><small>已发布</small><strong>${publishedCount}</strong></span><p>${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultEntryTable" placeholder="搜索姓名、报名号、准考证号、学校或班级"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="resultEntryTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="missing">未录入</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="draft">已暂存</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="published">已发布</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="resultEntryTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>成绩(0—${h(activeSubject?.fullScore || '—')}</th><th>状态</th><th>最近保存</th></tr></thead><tbody>${entryRows || '<tr><td colspan="7" class="empty-state">该科目暂无已通过报名的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-result-dirty-count>尚无未保存修改</strong><small>按 Ctrl / ⌘ + S 可快速暂存;发布前必须补齐本科学目全部考生成绩。</small></div><button type="submit" class="ghost-button" data-result-mode="draft">暂存已修改成绩</button><button type="submit" class="solid-button" data-result-mode="publish">发布本科学目成绩</button></footer></form></section>` : ''; const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry-workbench"><form data-form="result-bulk-entry"><header class="result-workbench-head"><div><span>SCORE ENTRY ROSTER</span><h2>按名单录入单科成绩</h2><p>选定考试和科目后,按服务端分页录分;暂存不会向考生发布。</p></div><div class="result-workbench-selectors"><label><span>考试</span><select name="examId" data-action="result-bulk-exam">${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>科目</span><select name="subjectId" data-action="result-bulk-subject">${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" ${subject.id === activeSubject?.id ? 'selected' : ''}>${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label></div></header><div class="result-workbench-summary"><span><small>应录人数</small><strong>${Number(data.entryCounts?.total || 0)}</strong></span><span><small>已录入</small><strong>${recordedCount}</strong></span><span><small>已暂存</small><strong>${Math.max(0, recordedCount - publishedCount)}</strong></span><span><small>已发布</small><strong>${publishedCount}</strong></span><p>${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultEntryTable" placeholder="搜索全部姓名、报名号、学校或班级"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="resultEntryTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="missing">未录入</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="draft">已暂存</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="published">已发布</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="resultEntryTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>成绩(0—${h(activeSubject?.fullScore || '—')}</th><th>状态</th><th>最近保存</th></tr></thead><tbody>${entryRows || '<tr><td colspan="7" class="empty-state">该科目暂无符合条件的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-result-dirty-count>尚无未保存修改</strong><small>按 Ctrl / ⌘ + S 可快速暂存;发布操作只处理当前页已填写的成绩。</small></div><button type="submit" class="ghost-button" data-result-mode="draft">暂存已修改成绩</button><button type="submit" class="solid-button" data-result-mode="publish">发布当前页成绩</button></footer></form></section>` : '';
const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id); const featurePage = data.featurePage || { items: [], page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'featureScoreTable' };
const featurePage = paged(featureRegistrations, 'featureScoreTable'); const featureRegistrations = featurePage.items;
const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length; const featureModified = Number(data.featureCounts?.modified || 0);
const featureRows = featurePage.items.map((registration, index) => { const featureRows = featurePage.items.map((registration, index) => {
const featureScore = Number(registration.featureScore || 0); const featureScore = Number(registration.featureScore || 0);
const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`; const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`;
return `<tr data-status="${status}"><td class="mono result-row-index">${index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td><strong>${h(registration.specialtyLabel || '普通生')}</strong><small>${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}</small></td><td class="result-score-input-cell feature-score-input-cell"><input data-feature-score data-registration-id="${h(registration.id)}" type="number" min="0" max="1000" step="0.01" value="${h(featureScore)}" aria-label="${h(registration.candidateName)}特征分"><small data-feature-score-error></small></td></tr>`; return `<tr data-status="${status}"><td class="mono result-row-index">${(featurePage.page - 1) * featurePage.pageSize + index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td><strong>${h(registration.specialtyLabel || '普通生')}</strong><small>${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}</small></td><td class="result-score-input-cell feature-score-input-cell"><input data-feature-score data-registration-id="${h(registration.id)}" type="number" min="0" max="1000" step="0.01" value="${h(featureScore)}" aria-label="${h(registration.candidateName)}特征分"><small data-feature-score-error></small></td></tr>`;
}).join(''); }).join('');
const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel feature-score-workbench"><form data-form="feature-score-bulk"><input type="hidden" name="examId" value="${h(activeExam?.id || '')}"><header><div><span>UNIFIED SPECIALTY TEST</span><h2>按名单登记特征分</h2><p>所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。</p></div><div class="feature-score-rule"><strong>分类计分规则</strong><span>普通类别:文化课总分</span><span>特长类别:文化课总分 + 特征分</span></div></header><div class="result-workbench-summary feature-score-summary"><span><small>本场考生</small><strong>${featureRegistrations.length}</strong></span><span><small>默认 0 分</small><strong>${featureRegistrations.length - featureModified}</strong></span><span><small>已修改</small><strong>${featureModified}</strong></span><span><small>具有特长资格</small><strong>${featureRegistrations.filter(item => item.specialtyType).length}</strong></span><p>特征分按考试、按考生独立保存,不改变文化课成绩。</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="featureScoreTable" placeholder="搜索姓名、报名号、准考证号、学校或特长类型"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="featureScoreTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="zero">默认 0 分</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="modified">已修改</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="specialty">有特长资格</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="featureScoreTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>特长资格 / 计分范围</th><th>特征分(0—1000</th></tr></thead><tbody>${featureRows || '<tr><td colspan="6" class="empty-state">本场暂无已通过报名的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-feature-dirty-count>尚无未保存修改</strong><small>未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。</small></div><button type="submit" class="solid-button">保存已修改特征分</button></footer></form></section>` : ''; const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel feature-score-workbench"><form data-form="feature-score-bulk"><input type="hidden" name="examId" value="${h(activeExam?.id || '')}"><header><div><span>UNIFIED SPECIALTY TEST</span><h2>按名单登记特征分</h2><p>所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。</p></div><div class="feature-score-rule"><strong>分类计分规则</strong><span>普通类别:文化课总分</span><span>特长类别:文化课总分 + 特征分</span></div></header><div class="result-workbench-summary feature-score-summary"><span><small>本场考生</small><strong>${Number(data.featureCounts?.total || 0)}</strong></span><span><small>默认 0 分</small><strong>${Math.max(0, Number(data.featureCounts?.total || 0) - featureModified)}</strong></span><span><small>已修改</small><strong>${featureModified}</strong></span><span><small>具有特长资格</small><strong>${Number(data.featureCounts?.specialty || 0)}</strong></span><p>特征分按考试、按考生独立保存,不改变文化课成绩。</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="featureScoreTable" placeholder="搜索全部姓名、报名号、学校或特长类型"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="featureScoreTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="zero">默认 0 分</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="modified">已修改</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="specialty">有特长资格</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="featureScoreTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>特长资格 / 计分范围</th><th>特征分(0—1000</th></tr></thead><tbody>${featureRows || '<tr><td colspan="6" class="empty-state">本场暂无符合条件的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-feature-dirty-count>尚无未保存修改</strong><small>未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。</small></div><button type="submit" class="solid-button">保存已修改特征分</button></footer></form></section>` : '';
const importPreviewPage = preview ? paged(preview.rows, 'resultImportPreviewTable') : null; const importPreviewPage = preview ? paged(preview.rows, 'resultImportPreviewTable') : null;
const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultImportPreviewTable" placeholder="跨页搜索考生、报名号、考试、科目或校验信息"></label><div class="table-filter-selects"><select data-table-filter="mode" data-target="resultImportPreviewTable"><option value="">全部写入方式</option><option value="create">新增成绩</option><option value="update">覆盖已有成绩</option></select></div></div><div class="table-scroll"><table id="resultImportPreviewTable" class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${importPreviewPage.items.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div>${pagination(importPreviewPage)}<form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : ''; const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultImportPreviewTable" placeholder="跨页搜索考生、报名号、考试、科目或校验信息"></label><div class="table-filter-selects"><select data-table-filter="mode" data-target="resultImportPreviewTable"><option value="">全部写入方式</option><option value="create">新增成绩</option><option value="update">覆盖已有成绩</option></select></div></div><div class="table-scroll"><table id="resultImportPreviewTable" class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${importPreviewPage.items.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div>${pagination(importPreviewPage)}<form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : '';
const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>本场成绩复议</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultAppealTable" placeholder="跨页搜索考生、科目、理由、责任人或步骤"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultAppealTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultAppealTable" data-status="pending">处理中</button><button data-action="status-filter" data-target="resultAppealTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="resultAppealTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="resultAppealTable"><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${appealPage.items.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div>${pagination(appealPage)}</section>`; const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>本场成绩复议</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultAppealTable" placeholder="跨页搜索考生、科目、理由、责任人或步骤"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultAppealTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultAppealTable" data-status="pending">处理中</button><button data-action="status-filter" data-target="resultAppealTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="resultAppealTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="resultAppealTable"><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${appealPage.items.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div>${pagination(appealPage)}</section>`;
const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`; const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount ?? 0} 人 · 已录 ${exam.scored ?? 0}/${exam.enrolledSubjects ?? 0} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round((exam.scored ?? 0) / exam.enrolledSubjects * 100) : 0}%"></i></button>`;
const currentExams = data.exams.filter(exam => !exam.archivedAt); const currentExams = data.exams.filter(exam => !exam.archivedAt);
const archivedExams = data.exams.filter(exam => exam.archivedAt); const archivedExams = data.exams.filter(exam => exam.archivedAt);
const examStrip = `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher" ${activeExam?.archivedAt ? 'open' : ''}><summary>历史归档考试 <span>${archivedExams.length} 场 · 成绩永久锁定</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}`; const examStrip = `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher" ${activeExam?.archivedAt ? 'open' : ''}><summary>历史归档考试 <span>${archivedExams.length} 场 · 成绩永久锁定</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}`;
@@ -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);
}