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

新增 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
@@ -673,7 +673,8 @@ internal sealed partial class AdminAdmissionService(
await admissionRepository.LoadAsync(cancellationToken),
await operationalLoader.LoadAsync(cancellationToken),
await directoryLoader.LoadAsync(cancellationToken),
await resultLoader.LoadAsync(cancellationToken));
await resultLoader.LoadAsync(cancellationToken),
await resultLoader.LoadSummariesAsync(cancellationToken));
private static IEnumerable<CandidateAdmissionRecord> Records(AdmissionData data, string kind, string? examId = null) =>
data.Admission.Records.Where(item => item.Kind == kind && (examId is null || item.ExamId == examId));
@@ -985,10 +986,8 @@ internal sealed partial class AdminAdmissionService(
var registration = data.Operational.Registrations.FirstOrDefault(item =>
item.ExamId == examId && item.UserId == userId && item.Status == "approved");
if (registration is null) return null;
var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToArray();
var results = data.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray();
if (subjectIds.Length == 0 || subjectIds.Any(id => results.All(result => result.SubjectId != id))) return null;
return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero);
var summary = data.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
return summary is { Complete: true } ? summary.TotalScore : null;
}
private static List<CandidateAdmissionRecord> AssignNoticeNumbers(AdmissionData data, IReadOnlyList<CandidateAdmissionRecord> placements)
@@ -1340,7 +1339,8 @@ internal sealed partial class AdminAdmissionService(
CandidateAdmissionSnapshot Admission,
AdminOperationalSnapshot Operational,
AdminAccountBatchSnapshot Directory,
IReadOnlyList<AdminWorkflowResult> Results);
IReadOnlyList<AdminWorkflowResult> Results,
IReadOnlyList<AdminRegistrationResultSummary> ResultSummaries);
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? 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) =>
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)
=> await SaveManyAsync(entries, [], cancellationToken);
public async Task SaveManyAsync(
IReadOnlyList<ResultWrite> entries,
IReadOnlyList<AdminResultCalculation> calculations,
CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
@@ -46,9 +58,43 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
cancellationToken);
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
}
foreach (var calculation in calculations)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
}, cancellationToken);
}
public async Task ReplaceStatisticsAsync(
IReadOnlyList<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(
IReadOnlyList<FeatureScoreWrite> entries,
CancellationToken cancellationToken)
@@ -72,6 +118,7 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
AccountWorkflowInstance instance,
AccountWorkflowAction action,
AdminWorkflowResult? result,
AdminResultCalculation? calculation,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
@@ -130,10 +177,108 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
],
cancellationToken);
}
if (calculation is not null)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
}, cancellationToken);
}
private static async Task ReplaceStatisticsAsync(
DbConnection connection,
DbTransaction transaction,
AdminResultCalculation calculation,
CancellationToken cancellationToken)
{
await ExecuteAsync(
connection,
transaction,
"""
DELETE FROM result_statistics
WHERE result_id IN (
SELECT result.id FROM results result
JOIN registrations registration ON registration.id = result.registration_id
WHERE registration.exam_id = @examId
)
""",
[new("@examId", calculation.ExamId)],
cancellationToken);
await ExecuteAsync(
connection,
transaction,
"DELETE FROM registration_result_summaries WHERE exam_id = @examId",
[new("@examId", calculation.ExamId)],
cancellationToken);
foreach (var metric in calculation.Metrics)
{
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO result_statistics (
result_id, subject_rank, cohort_size, rank_percent, grade, qualified,
pass_score, cutoff_rank, calculated_at
) VALUES (
@resultId, @rank, @cohortSize, @rankPercent, @grade, @qualified,
@passScore, @cutoffRank, @calculatedAt
)
""",
[
new("@resultId", metric.ResultId),
new("@rank", metric.Rank),
new("@cohortSize", metric.CohortSize),
new("@rankPercent", metric.RankPercent),
new("@grade", metric.Grade),
new("@qualified", metric.Qualified is null ? null : metric.Qualified.Value ? 1 : 0),
new("@passScore", metric.PassScore),
new("@cutoffRank", metric.CutoffRank),
new("@calculatedAt", metric.CalculatedAt)
],
cancellationToken);
await ExecuteAsync(
connection,
transaction,
"UPDATE results SET grade = @grade WHERE id = @resultId",
[new("@grade", metric.Grade), new("@resultId", metric.ResultId)],
cancellationToken);
}
foreach (var summary in calculation.Summaries)
{
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO registration_result_summaries (
registration_id, exam_id, published_subject_count, subject_count, complete,
total_score, full_score, score_ratio, total_rank, cohort_size, rank_percent,
qualified, cutoff_rank, calculated_at
) VALUES (
@registrationId, @examId, @publishedSubjectCount, @subjectCount, @complete,
@totalScore, @fullScore, @scoreRatio, @rank, @cohortSize, @rankPercent,
@qualified, @cutoffRank, @calculatedAt
)
""",
[
new("@registrationId", summary.RegistrationId),
new("@examId", summary.ExamId),
new("@publishedSubjectCount", summary.PublishedSubjectCount),
new("@subjectCount", summary.SubjectCount),
new("@complete", summary.Complete ? 1 : 0),
new("@totalScore", summary.TotalScore),
new("@fullScore", summary.FullScore),
new("@scoreRatio", summary.ScoreRatio),
new("@rank", summary.Rank),
new("@cohortSize", summary.CohortSize),
new("@rankPercent", summary.RankPercent),
new("@qualified", summary.Qualified is null ? null : summary.Qualified.Value ? 1 : 0),
new("@cutoffRank", summary.CutoffRank),
new("@calculatedAt", summary.CalculatedAt)
],
cancellationToken);
}
}
private async Task TransactionAsync(
Func<DbConnection, DbTransaction, Task> operation,
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,
IApplicationCache cache) : IAdminResultService
{
public async Task<AdminEndpointResult> GetAsync(
public async Task<AdminEndpointResult> GetSummaryAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken)
@@ -29,9 +29,7 @@ internal sealed class AdminResultService(
{
["ok"] = true,
["selectedExamId"] = "",
["results"] = new JsonArray(),
["appeals"] = new JsonArray(),
["registrations"] = new JsonArray(),
["exams"] = new JsonArray(),
["resultCache"] = CacheStatus()
});
@@ -42,30 +40,27 @@ internal sealed class AdminResultService(
var selectedExam = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (selectedExam is null) return Error(404, "请选择有效的考试后再读取成绩");
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken);
var examResults = await resultSnapshotLoader.LoadForExamAsync(selectedExam.Id, cancellationToken);
var summaries = await resultSnapshotLoader.LoadSummariesForExamAsync(selectedExam.Id, cancellationToken);
var lookups = ResultLookups.Create(operational, directory);
var scoped = operational.Registrations.Where(item =>
{
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile);
}).ToArray();
lookups.ProfileByUserId.TryGetValue(item.UserId, out var profile) && InScope(user, profile)).ToArray();
var selectedScoped = scoped.Where(item => item.ExamId == selectedExam.Id).ToArray();
if (Level(user) != "super" && selectedScoped.Length == 0)
return Error(404, "该考试不在当前管理范围内");
var approved = selectedScoped.Where(item => item.Status == "approved").ToArray();
var selectedIds = selectedScoped.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var examRegistrations = operational.Registrations
.Where(item => item.ExamId == selectedExam.Id && item.Status == "approved")
.ToArray();
var examIds = examRegistrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var examResults = allResults.Where(item => examIds.Contains(item.RegistrationId)).ToArray();
var rawScopedResults = examResults.Where(item => selectedIds.Contains(item.RegistrationId)).ToArray();
var resultViews = rawScopedResults
.Select(item => ResultJson(operational, directory, selectedExam, examResults, item))
.ToArray();
var resultById = resultViews.ToDictionary(
item => Text(item["id"]),
item => item,
StringComparer.Ordinal);
var appealedResultIds = directory.Instances
.Where(item => item.BusinessType == "score_appeal")
.Select(item => item.BusinessId)
.ToHashSet(StringComparer.Ordinal);
var resultById = rawScopedResults
.Where(item => appealedResultIds.Contains(item.Id))
.ToDictionary(
item => item.Id,
item => ResultJson(lookups, selectedExam, item),
StringComparer.Ordinal);
var appealViews = directory.Instances
.Where(item => item.BusinessType == "score_appeal" && resultById.ContainsKey(item.BusinessId))
.Select(item =>
@@ -77,38 +72,169 @@ internal sealed class AdminResultService(
return output;
})
.ToArray();
var summaryByRegistration = summaries.ToDictionary(item => item.RegistrationId, StringComparer.Ordinal);
var exams = operational.Exams.Select(exam =>
{
var output = PublicExamJson(exam);
if (exam.Id != selectedExam.Id) return output;
var summaries = approved.Select(item => Summary(selectedExam, examRegistrations, examResults, item)).ToArray();
var enrolled = approved.Sum(item => SubjectIds(item).Count);
var scopedSummaries = approved
.Select(item => summaryByRegistration.GetValueOrDefault(item.Id))
.Where(item => item is not null)
.Cast<AdminRegistrationResultSummary>()
.ToArray();
output["registrationCount"] = approved.Length;
output["enrolledSubjects"] = enrolled;
output["scored"] = rawScopedResults.Length;
output["published"] = rawScopedResults.Count(item => item.Published);
output["missing"] = Math.Max(0, enrolled - rawScopedResults.Length);
output["complete"] = summaries.Count(item => item.Complete);
output["qualified"] = summaries.Count(item => item.Complete && item.Qualified is true);
output["unqualified"] = summaries.Count(item => item.Complete && item.Qualified is false);
output["complete"] = scopedSummaries.Count(item => item.Complete);
output["qualified"] = scopedSummaries.Count(item => item.Complete && item.Qualified is true);
output["unqualified"] = scopedSummaries.Count(item => item.Complete && item.Qualified is false);
output["appeals"] = appealViews.Length;
return output;
}).ToArray();
var registrations = Level(user) == "super"
? approved.Select(item => RegistrationJson(operational, directory, item)).ToArray()
: [];
return Success(new JsonObject
{
["ok"] = true,
["selectedExamId"] = selectedExam.Id,
["results"] = new JsonArray(resultViews),
["appeals"] = new JsonArray(appealViews),
["registrations"] = new JsonArray(registrations),
["exams"] = new JsonArray(exams),
["resultCache"] = CacheStatus()
});
}
public async Task<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(
string sessionToken,
JsonObject body,
@@ -129,9 +255,9 @@ internal sealed class AdminResultService(
return Error(400, "该考生未报名此科目");
var score = Number(body["score"]);
var fullScore = Number(subject["fullScore"]);
if (!double.IsFinite(score) || score < 0 || score > fullScore)
return Error(400, $"成绩必须在 0—{AdminResultScoring.Format(fullScore)} 之间");
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
if (!AdminResultStatistics.IsValidScore(score, fullScore))
return Error(400, $"成绩必须在 0—{AdminResultStatistics.Format(fullScore)} 之间,且只能填写整数或 .5");
var allResults = (await resultSnapshotLoader.LoadForExamAsync(exam!.Id, cancellationToken)).ToList();
var existing = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == subjectId);
var published = JsBoolean(body["published"]);
@@ -146,10 +272,12 @@ internal sealed class AdminResultService(
now,
published ? existing?.PublishedAt ?? now : null);
Replace(allResults, candidate);
candidate = candidate with
{
Grade = published ? AdminResultScoring.Rank(allResults, candidate).Grade : "待发布"
};
var calculation = AdminResultStatistics.Calculate(
exam,
operational.Registrations,
allResults,
now);
candidate = ApplyMetric(candidate, calculation);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
await repository.SaveAsync(
new ResultWrite(
@@ -158,7 +286,8 @@ internal sealed class AdminResultService(
Audit(
user,
published ? "发布成绩" : "保存成绩",
$"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(score)}")),
$"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultStatistics.Format(score)}")),
calculation,
cancellationToken);
await cache.InvalidateAsync("results");
return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) });
@@ -180,7 +309,7 @@ internal sealed class AdminResultService(
var sourceRows = body["rows"] as JsonArray ?? [];
if (sourceRows.Count == 0) return Error(400, "没有需要保存的成绩");
var published = JsBoolean(body["published"]);
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
var allResults = (await resultSnapshotLoader.LoadForExamAsync(exam.Id, cancellationToken)).ToList();
var prepared = new List<PreparedResult>();
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < sourceRows.Count; index++)
@@ -195,8 +324,8 @@ internal sealed class AdminResultService(
return Error(400, $"第 {index + 1} 条考生成绩无效或重复");
var score = Number(row["score"]);
var fullScore = Number(subject["fullScore"]);
if (!double.IsFinite(score) || score < 0 || score > fullScore)
return Error(400, $"第 {index + 3} :成绩须在 0—{AdminResultScoring.Format(fullScore)} 之间;请返回预览修正后重试");
if (!AdminResultStatistics.IsValidScore(score, fullScore))
return Error(400, $"第 {index + 1} :成绩须在 0—{AdminResultStatistics.Format(fullScore)} 之间,且只能填写整数或 .5");
var existing = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
var now = NowIso();
@@ -212,12 +341,14 @@ internal sealed class AdminResultService(
Replace(allResults, result);
prepared.Add(new(registration, existing, result));
}
var calculation = AdminResultStatistics.Calculate(
exam,
operational.Registrations,
allResults,
NowIso());
var writes = prepared.Select(item =>
{
var result = item.Result with
{
Grade = published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
};
var result = ApplyMetric(item.Result, calculation);
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.Registration.UserId);
var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId);
return new ResultWrite(
@@ -226,9 +357,9 @@ internal sealed class AdminResultService(
Audit(
user,
published ? "Excel 批量发布成绩" : "Excel 批量保存成绩",
$"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
$"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultStatistics.Format(result.Score)}"));
}).ToArray();
await repository.SaveManyAsync(writes, cancellationToken);
await repository.SaveManyAsync(writes, [calculation], cancellationToken);
await cache.InvalidateAsync("results");
var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject
@@ -286,8 +417,8 @@ internal sealed class AdminResultService(
else if (registration is null) message = "该考生没有已通过的本场考试报名";
else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目";
var score = Text(row["score"]).Length == 0 ? double.NaN : Number(row["score"]);
if (message is null && (!double.IsFinite(score) || score < 0 || score > Number(subject?["fullScore"])))
message = $"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间";
if (message is null && !AdminResultStatistics.IsValidScore(score, Number(subject?["fullScore"])))
message = $"成绩须在 0—{AdminResultStatistics.Format(Number(subject?["fullScore"]))} 之间,且只能填写整数或 .5";
var publishText = Text(row["published"]);
bool published;
if (row["published"] is JsonValue publishedValue &&
@@ -316,19 +447,28 @@ internal sealed class AdminResultService(
Replace(allResults, result);
prepared.Add(new(registration, exam!, subject!, existing, result, candidateNumber));
}
var calculations = prepared
.Select(item => item.Exam)
.DistinctBy(item => item.Id)
.Select(exam => AdminResultStatistics.Calculate(
exam,
operational.Registrations,
allResults,
NowIso()))
.ToArray();
var metricByResultId = calculations
.SelectMany(item => item.Metrics)
.ToDictionary(item => item.ResultId, StringComparer.Ordinal);
var writes = prepared.Select(item =>
{
var result = item.Result with
{
Grade = item.Result.Published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
};
var result = ApplyMetric(item.Result, metricByResultId.GetValueOrDefault(item.Result.Id));
return new ResultWrite(
result,
item.Existing is null,
Audit(user, result.Published ? "Excel 批量发布成绩" : "Excel 批量保存成绩",
$"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.Subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
$"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.Subject["name"])} · {AdminResultStatistics.Format(result.Score)}"));
}).ToArray();
await repository.SaveManyAsync(writes, cancellationToken);
await repository.SaveManyAsync(writes, calculations, cancellationToken);
await cache.InvalidateAsync("results");
var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject
@@ -388,8 +528,8 @@ internal sealed class AdminResultService(
!SubjectIds(registration).Contains(Text(subject["id"])))
AddError("该考生未报考此科目");
var score = Text(source["score"]).Length == 0 ? double.NaN : Number(source["score"]);
if (!double.IsFinite(score) || score < 0 || subject is not null && score > Number(subject["fullScore"]))
AddError($"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间");
if (!AdminResultStatistics.IsValidScore(score, Number(subject?["fullScore"])))
AddError($"成绩须在 0—{AdminResultStatistics.Format(Number(subject?["fullScore"]))} 之间,且只能填写整数或 .5");
var publishedText = Text(source["published"]);
var published = source["published"] is JsonValue publishedValue &&
publishedValue.TryGetValue<bool>(out var publishedBoolean)
@@ -441,21 +581,35 @@ internal sealed class AdminResultService(
["errors"] = errors
});
}
var previewCalculations = output
.Where(item => (item["errors"] as JsonArray)?.Count == 0)
.Select(item => Text(item["examId"]))
.Distinct(StringComparer.Ordinal)
.Select(examId => operational.Exams.First(item => item.Id == examId))
.Select(exam => AdminResultStatistics.Calculate(
exam,
operational.Registrations,
previewResults,
NowIso()))
.ToArray();
var previewMetricByResultId = previewCalculations
.SelectMany(item => item.Metrics)
.ToDictionary(item => item.ResultId, StringComparer.Ordinal);
foreach (var row in output.Where(item => (item["errors"] as JsonArray)?.Count == 0))
{
var result = previewResults.First(item =>
item.RegistrationId == Text(row["registrationId"]) && item.SubjectId == Text(row["subjectId"]));
var exam = operational.Exams.First(item => item.Id == Text(row["examId"]));
var subject = exam.Subjects.First(item => Text(item["id"]) == result.SubjectId);
var pass = AdminResultScoring.Pass(previewResults, result, subject);
row["rank"] = pass.Rank.Rank;
row["cohortSize"] = pass.Rank.CohortSize;
row["rankPercent"] = pass.Rank.RankPercent;
row["grade"] = result.Published ? pass.Rank.Grade : "待发布";
row["passScore"] = pass.PassScore;
row["cutoffRank"] = pass.CutoffRank;
row["qualified"] = JsonValue.Create(pass.Qualified);
row["passText"] = AdminResultScoring.PassText(subject);
var metric = previewMetricByResultId[result.Id];
row["rank"] = metric.Rank;
row["cohortSize"] = metric.CohortSize;
row["rankPercent"] = metric.RankPercent;
row["grade"] = metric.Grade;
row["passScore"] = metric.PassScore;
row["cutoffRank"] = metric.CutoffRank;
row["qualified"] = JsonValue.Create(metric.Qualified);
row["passText"] = AdminResultStatistics.PassText(subject);
}
var invalid = output.Count(item => (item["errors"] as JsonArray)?.Count > 0);
return Success(new JsonObject
@@ -515,7 +669,7 @@ internal sealed class AdminResultService(
entries.Add(new(
registration.Id,
score,
Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultScoring.Format(score)}")));
Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultStatistics.Format(score)}")));
}
await repository.SaveFeatureScoresAsync(entries, cancellationToken);
await cache.InvalidateAsync("results");
@@ -546,7 +700,7 @@ internal sealed class AdminResultService(
[new(
registration.Id,
score,
Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultScoring.Format(score)}"))],
Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultStatistics.Format(score)}"))],
cancellationToken);
await cache.InvalidateAsync("results");
var output = registration.Data.DeepClone().AsObject();
@@ -582,7 +736,9 @@ internal sealed class AdminResultService(
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken);
var allResults = examId.Length > 0
? await resultSnapshotLoader.LoadForExamAsync(examId, cancellationToken)
: await resultSnapshotLoader.LoadAsync(cancellationToken);
var registrations = operational.Registrations.Where(item =>
{
if (item.Status != "approved" || (examId.Length > 0 && item.ExamId != examId)) return false;
@@ -600,7 +756,6 @@ internal sealed class AdminResultService(
{
var result = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
var pass = result is null ? null : AdminResultScoring.Pass(allResults, result, subject);
var card = registration.Data["admitCard"] as JsonObject;
rows.Add(new(
account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]),
@@ -612,13 +767,13 @@ internal sealed class AdminResultService(
Text(exam?.Data["name"]),
Text(subject["name"]),
Number(subject["fullScore"]),
AdminResultScoring.PassText(subject),
pass?.PassScore is double passScore ? passScore : "",
AdminResultStatistics.PassText(subject),
result?.PassScore is double passScore ? passScore : "",
result is null ? "" : result.Score,
pass?.Rank.Rank is int rank ? rank : "",
pass?.Rank.RankPercent is double percent ? percent : "",
result is null || pass?.Qualified is null ? "" : pass.Qualified.Value ? "达线" : "未达线",
result is null ? "" : result.Published ? pass?.Rank.Grade ?? "" : "待发布",
result?.Rank is int rank ? rank : "",
result?.RankPercent is double percent ? percent : "",
result?.Qualified is null ? "" : result.Qualified.Value ? "达线" : "未达线",
result is null ? "" : result.Published ? result.Grade : "待发布",
result?.Published is true ? "发布" : "不发布",
result?.UpdatedAt ?? result?.PublishedAt ?? ""));
}
@@ -674,8 +829,8 @@ internal sealed class AdminResultService(
{
reviewedScore = Number(body["reviewedScore"]);
var fullScore = Number(subject?["fullScore"]);
if (!double.IsFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > fullScore)
return Error(400, $"最终审批必须填写 0—{AdminResultScoring.Format(fullScore)} 之间的复核后分数");
if (!AdminResultStatistics.IsValidScore(reviewedScore, fullScore))
return Error(400, $"最终审批必须填写 0—{AdminResultStatistics.Format(fullScore)} 之间的整数或 .5 分数");
}
var now = NowIso();
@@ -684,6 +839,7 @@ internal sealed class AdminResultService(
string? nextAssigneeId = null;
AccountWorkflowInstance updatedInstance;
AdminWorkflowResult? updatedResult = null;
AdminResultCalculation? calculation = null;
if (requestedStatus == "rejected")
{
actionName = "reject";
@@ -712,22 +868,23 @@ internal sealed class AdminResultService(
var originalScore = result.Score;
updatedResult = result with { Score = reviewedScore, UpdatedAt = now };
Replace(allResults, updatedResult);
updatedResult = updatedResult with
{
Grade = AdminResultScoring.Rank(allResults, updatedResult, reviewedScore).Grade
};
calculation = AdminResultStatistics.Calculate(
exam!,
operational.Registrations,
allResults,
now);
updatedResult = ApplyMetric(updatedResult, calculation);
Replace(allResults, updatedResult);
var pass = AdminResultScoring.Pass(allResults, updatedResult, subject, reviewedScore);
var conclusion = pass.Qualified is null
var conclusion = updatedResult.Qualified is null
? "本科不判定单科达线"
: pass.Qualified.Value
? $"达到{PassConclusion(subject, pass)}"
: $"未达到{PassConclusion(subject, pass)}";
: updatedResult.Qualified.Value
? $"达到{PassConclusion(subject, updatedResult)}"
: $"未达到{PassConclusion(subject, updatedResult)}";
note = string.Join(
'',
new[]
{
$"原分 {AdminResultScoring.Format(originalScore)} → 复核后 {AdminResultScoring.Format(reviewedScore)}",
$"原分 {AdminResultStatistics.Format(originalScore)} → 复核后 {AdminResultStatistics.Format(reviewedScore)}",
conclusion,
note
}.Where(item => item.Length > 0));
@@ -745,11 +902,12 @@ internal sealed class AdminResultService(
? "退回成绩复议"
: finalStep ? "复议终审并更新成绩" : "处理成绩复议";
var detail = $"{Text(profile.Data["name"])} · {Text(exam?.Data["name"])} · {Text(subject?["name"])}" +
(requestedStatus == "approved" && finalStep ? $" · {AdminResultScoring.Format(reviewedScore)} 分" : "");
(requestedStatus == "approved" && finalStep ? $" · {AdminResultStatistics.Format(reviewedScore)} 分" : "");
await repository.ReviewAppealAsync(
updatedInstance,
action,
updatedResult,
calculation,
Audit(user, auditAction, detail),
cancellationToken);
if (updatedResult is not null)
@@ -757,16 +915,13 @@ internal sealed class AdminResultService(
await cache.InvalidateAsync("results");
}
var responseResult = updatedResult ?? result;
var rank = AdminResultScoring.Rank(allResults, responseResult);
var responsePass = AdminResultScoring.Pass(allResults, responseResult, subject);
var resultOutput = RawResultJson(responseResult);
resultOutput["rank"] = JsonValue.Create(rank.Rank);
resultOutput["cohortSize"] = rank.CohortSize;
resultOutput["rankPercent"] = JsonValue.Create(rank.RankPercent);
resultOutput["grade"] = rank.Grade;
resultOutput["passScore"] = JsonValue.Create(responsePass.PassScore);
resultOutput["cutoffRank"] = JsonValue.Create(responsePass.CutoffRank);
resultOutput["qualified"] = JsonValue.Create(responsePass.Qualified);
resultOutput["rank"] = JsonValue.Create(responseResult.Rank);
resultOutput["cohortSize"] = responseResult.CohortSize;
resultOutput["rankPercent"] = JsonValue.Create(responseResult.RankPercent);
resultOutput["passScore"] = JsonValue.Create(responseResult.PassScore);
resultOutput["cutoffRank"] = JsonValue.Create(responseResult.CutoffRank);
resultOutput["qualified"] = JsonValue.Create(responseResult.Qualified);
return Success(new JsonObject
{
["ok"] = true,
@@ -776,23 +931,19 @@ internal sealed class AdminResultService(
}
private static JsonObject ResultJson(
AdminOperationalSnapshot operational,
AdminAccountBatchSnapshot directory,
ResultLookups lookups,
OperationalExam exam,
IReadOnlyList<AdminWorkflowResult> cohortResults,
AdminWorkflowResult result)
{
var registration = operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration?.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
var registration = lookups.RegistrationById.GetValueOrDefault(result.RegistrationId);
var profile = registration is null ? null : lookups.ProfileByUserId.GetValueOrDefault(registration.UserId);
var account = registration is null ? null : lookups.AccountByUserId.GetValueOrDefault(registration.UserId);
var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId);
var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
var pass = AdminResultScoring.Pass(cohortResults, result, subject);
var output = RawResultJson(result);
output["grade"] = result.Published ? pass.Rank.Grade : result.Grade;
output["rank"] = JsonValue.Create(pass.Rank.Rank);
output["cohortSize"] = pass.Rank.CohortSize;
output["rankPercent"] = JsonValue.Create(pass.Rank.RankPercent);
output["rank"] = JsonValue.Create(result.Rank);
output["cohortSize"] = result.CohortSize;
output["rankPercent"] = JsonValue.Create(result.RankPercent);
output["candidateName"] = Text(profile?.Data["name"]);
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]);
output["schoolName"] = Text(profile?.Data["school"]);
@@ -804,20 +955,19 @@ internal sealed class AdminResultService(
output["fullScore"] = subject?["fullScore"]?.DeepClone();
output["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score";
output["passValue"] = (subject?["passValue"] ?? subject?["passScore"])?.DeepClone();
output["passScore"] = JsonValue.Create(pass.PassScore);
output["cutoffRank"] = JsonValue.Create(pass.CutoffRank);
output["passText"] = AdminResultScoring.PassText(subject);
output["qualified"] = JsonValue.Create(pass.Qualified);
output["passScore"] = JsonValue.Create(result.PassScore);
output["cutoffRank"] = JsonValue.Create(result.CutoffRank);
output["passText"] = AdminResultStatistics.PassText(subject);
output["qualified"] = JsonValue.Create(result.Qualified);
return output;
}
private static JsonObject RegistrationJson(
AdminOperationalSnapshot operational,
AdminAccountBatchSnapshot directory,
ResultLookups lookups,
OperationalRegistration registration)
{
var output = registration.Data.DeepClone().AsObject();
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var exam = lookups.Operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var subjects = (exam?.Subjects ?? [])
.Where(item => SubjectIds(registration).Contains(Text(item["id"])))
.Select(item => item.DeepClone())
@@ -825,15 +975,15 @@ internal sealed class AdminResultService(
output["exam"] = exam?.Data.DeepClone();
output["subjects"] = new JsonArray(subjects);
output["amountDue"] = Math.Round(subjects.Sum(item => Number(item?["fee"])), 2, MidpointRounding.AwayFromZero);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = directory.Instances.FirstOrDefault(item =>
output["paidByName"] = lookups.Operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = lookups.Directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" && item.BusinessId == registration.Id && item.Status == "pending") ??
directory.Instances.FirstOrDefault(item =>
lookups.Directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" && item.BusinessId == registration.Id);
output["workflow"] = instance is null ? null : WorkflowJson(directory, instance);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
output["workflow"] = instance is null ? null : WorkflowJson(lookups.Directory, instance);
var profile = lookups.ProfileByUserId.GetValueOrDefault(registration.UserId);
var account = lookups.AccountByUserId.GetValueOrDefault(registration.UserId);
var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId);
output["candidateName"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "";
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]);
output["schoolName"] = Text(profile?.Data["school"]);
@@ -846,46 +996,6 @@ internal sealed class AdminResultService(
return output;
}
private static ResultSummary Summary(
OperationalExam exam,
IReadOnlyList<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(
AdminAccountBatchSnapshot snapshot,
AccountWorkflowInstance instance,
@@ -945,6 +1055,60 @@ internal sealed class AdminResultService(
? items.Select(Text).Where(item => item.Length > 0).ToHashSet(StringComparer.Ordinal)
: [];
private static string ResultSearchText(ResultLookups lookups, OperationalExam exam, AdminWorkflowResult result)
{
var registration = lookups.RegistrationById.GetValueOrDefault(result.RegistrationId);
var profile = registration is null ? null : lookups.ProfileByUserId.GetValueOrDefault(registration.UserId);
var account = registration is null ? null : lookups.AccountByUserId.GetValueOrDefault(registration.UserId);
var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId);
var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
return string.Join(' ',
account?.CandidateNumber,
account?.DisplayName,
Text(profile?.Data["name"]),
Text(profile?.Data["school"]),
schoolClass?.Name,
Text(subject?["name"]),
Text(exam.Data["code"]),
Text(exam.Data["name"]));
}
private static string RegistrationSearchText(ResultLookups lookups, OperationalRegistration registration)
{
var profile = lookups.ProfileByUserId.GetValueOrDefault(registration.UserId);
var account = lookups.AccountByUserId.GetValueOrDefault(registration.UserId);
var schoolClass = profile?.ClassId is null ? null : lookups.ClassById.GetValueOrDefault(profile.ClassId);
return string.Join(' ',
account?.CandidateNumber,
account?.DisplayName,
Text(profile?.Data["name"]),
Text(profile?.Data["school"]),
schoolClass?.Name,
Text(profile?.Data["specialtyCategory"]),
Text(profile?.Data["specialtyType"]));
}
private static PageResult Page(int total, int requestedPage, int requestedPageSize, string key)
{
var pageSize = requestedPageSize is 20 or 50 or 100 ? requestedPageSize : 50;
var totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize));
var page = Math.Min(Math.Max(1, requestedPage), totalPages);
return new(
page,
pageSize,
total,
totalPages,
(page - 1) * pageSize,
new JsonObject
{
["page"] = page,
["pageSize"] = pageSize,
["total"] = total,
["totalPages"] = totalPages,
["key"] = key
});
}
private static JsonObject RawResultJson(AdminWorkflowResult result) => new()
{
["id"] = result.Id,
@@ -988,6 +1152,28 @@ internal sealed class AdminResultService(
["status"] = cache.Status
};
private static AdminWorkflowResult ApplyMetric(
AdminWorkflowResult result,
AdminResultCalculation calculation) =>
ApplyMetric(
result,
calculation.Metrics.FirstOrDefault(item => item.ResultId == result.Id));
private static AdminWorkflowResult ApplyMetric(
AdminWorkflowResult result,
AdminResultMetric? metric) => metric is null
? result
: result with
{
Grade = metric.Grade,
Rank = metric.Rank,
CohortSize = metric.CohortSize,
RankPercent = metric.RankPercent,
Qualified = metric.Qualified,
PassScore = metric.PassScore,
CutoffRank = metric.CutoffRank
};
private static void Replace(List<AdminWorkflowResult> results, AdminWorkflowResult result)
{
var index = results.FindIndex(item => item.Id == result.Id);
@@ -1053,11 +1239,11 @@ internal sealed class AdminResultService(
.FirstOrDefault();
}
private static string PassConclusion(JsonObject? subject, AdminPassInfo pass)
private static string PassConclusion(JsonObject? subject, AdminWorkflowResult result)
{
if (Text(subject?["passRule"]) == "rank_percent")
return $"排名前 {AdminResultScoring.Format(Number(subject?["passValue"]))}%(当前第 {pass.Rank.Rank} 名,截止第 {pass.CutoffRank} 名)";
return $"固定及格线 {AdminResultScoring.Format(pass.PassScore ?? 0)} 分";
return $"排名前 {AdminResultStatistics.Format(Number(subject?["passValue"]))}%(当前第 {result.Rank} 名,截止第 {result.CutoffRank} 名)";
return $"固定及格线 {AdminResultStatistics.Format(result.PassScore ?? 0)} 分";
}
private static JsonObject StepJson(AccountWorkflowStep item) => new()
@@ -1156,7 +1342,40 @@ internal sealed class AdminResultService(
AdminWorkflowResult? Existing,
AdminWorkflowResult Result,
string CandidateNumber);
private sealed record ResultSummary(bool Complete, bool? Qualified);
private sealed record PageResult(
int Page,
int PageSize,
int Total,
int TotalPages,
int Offset,
JsonObject Json);
private sealed record ResultLookups(
AdminOperationalSnapshot Operational,
AdminAccountBatchSnapshot Directory,
IReadOnlyDictionary<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)
{
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,
bool Published,
string? UpdatedAt,
string? PublishedAt);
string? PublishedAt)
{
public int? Rank { get; init; }
public int CohortSize { get; init; }
public double? RankPercent { get; init; }
public bool? Qualified { get; init; }
public double? PassScore { get; init; }
public int? CutoffRank { get; init; }
}
internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
public async Task<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 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);
var output = new List<AdminWorkflowResult>();
while (await reader.ReadAsync(cancellationToken))
@@ -33,20 +115,60 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
Text(reader, "grade"),
Boolean(reader, "published"),
Optional(reader, "updated_at"),
Optional(reader, "published_at")));
Optional(reader, "published_at"))
{
Rank = Integer(reader, "subject_rank"),
CohortSize = Integer(reader, "cohort_size") ?? 0,
RankPercent = NullableNumber(reader, "rank_percent"),
Qualified = NullableBoolean(reader, "qualified"),
PassScore = NullableNumber(reader, "pass_score"),
CutoffRank = Integer(reader, "cutoff_rank")
});
}
return output;
}
private static void AddParameter(DbCommand command, string name, object? value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
private static string Text(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
private static double Number(DbDataReader reader, string name) =>
Convert.ToDouble(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private static double? NullableNumber(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal)
? null
: Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private static int? Integer(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal)
? null
: Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private static bool Boolean(DbDataReader reader, string name) =>
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
private static bool? NullableBoolean(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal)
? null
: Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0;
}
private static string? Optional(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
@@ -231,63 +231,26 @@ internal sealed class AdminWorkflowService(
var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId);
var exam = snapshots.Operational.Exams.FirstOrDefault(item => item.Id == registration?.ExamId);
var subject = exam?.Subjects.FirstOrDefault(item => item["id"]?.GetValue<string>() == result.SubjectId);
var rank = Rank(snapshots.Results, result);
var pass = Pass(snapshots.Results, result, subject);
return new JsonObject
{
["score"] = result.Score,
["grade"] = rank.Grade.Length > 0 ? rank.Grade : result.Grade,
["rank"] = JsonValue.Create(rank.Rank),
["cohortSize"] = rank.CohortSize,
["rankPercent"] = JsonValue.Create(rank.RankPercent),
["grade"] = result.Grade,
["rank"] = JsonValue.Create(result.Rank),
["cohortSize"] = result.CohortSize,
["rankPercent"] = JsonValue.Create(result.RankPercent),
["examName"] = exam?.Data["name"]?.GetValue<string>() ?? "",
["examCode"] = exam?.Data["code"]?.GetValue<string>() ?? "",
["subjectName"] = subject?["name"]?.GetValue<string>() ?? "",
["fullScore"] = JsonValue.Create(NullableNumber(subject?["fullScore"])),
["passRule"] = subject?["passRule"]?.GetValue<string>() ?? "fixed_score",
["passValue"] = JsonValue.Create(NullableNumber(subject?["passValue"] ?? subject?["passScore"])),
["passScore"] = JsonValue.Create(pass.PassScore),
["cutoffRank"] = JsonValue.Create(pass.CutoffRank),
["passScore"] = JsonValue.Create(result.PassScore),
["cutoffRank"] = JsonValue.Create(result.CutoffRank),
["passText"] = PassText(subject),
["qualified"] = JsonValue.Create(pass.Qualified)
["qualified"] = JsonValue.Create(result.Qualified)
};
}
private static RankInfo Rank(IReadOnlyList<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)
{
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 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); }
}
@@ -8,8 +8,7 @@ internal sealed record CandidateReadSnapshot(
IReadOnlyList<CandidateExam> Exams,
IReadOnlyList<CandidateRegistration> Registrations,
IReadOnlyList<CandidateResult> Results,
IReadOnlyList<CandidateRegistration> AllRegistrations,
IReadOnlyList<CandidateResult> AllResults,
IReadOnlyList<CandidateResultSummary> ResultSummaries,
IReadOnlyList<CandidateWorkflow> Workflows,
IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances,
IReadOnlyList<CandidateWorkflowAction> WorkflowActions,
@@ -115,7 +114,27 @@ internal sealed record CandidateResult(
string Grade,
bool Published,
string? UpdatedAt,
string? PublishedAt);
string? PublishedAt,
int? Rank,
int CohortSize,
double? RankPercent,
bool? Qualified,
double? PassScore,
int? CutoffRank);
internal sealed record CandidateResultSummary(
string RegistrationId,
int PublishedSubjectCount,
int SubjectCount,
bool Complete,
double TotalScore,
double FullScore,
double ScoreRatio,
int? Rank,
int CohortSize,
double? RankPercent,
bool? Qualified,
int? CutoffRank);
internal sealed record CandidateWorkflow(
string Id,
@@ -172,8 +191,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
{
public async Task<CandidateReadSnapshot> LoadAsync(
string userId,
CancellationToken cancellationToken,
bool includeCohort = false)
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var subjects = await QueryAsync(connection,
@@ -188,16 +206,11 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
"SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams ORDER BY created_at, id",
reader => ReadExam(reader, subjectsByExam),
cancellationToken);
var registrationRows = includeCohort
? await QueryAsync(connection,
"SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations ORDER BY created_at, id",
ReadRegistrationRow,
cancellationToken)
: await QueryAsync(connection,
"SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id",
ReadRegistrationRow,
cancellationToken,
new QueryParameter("@userId", userId));
var registrationRows = await QueryAsync(connection,
"SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id",
ReadRegistrationRow,
cancellationToken,
new QueryParameter("@userId", userId));
var registrationSubjects = await QueryAsync(connection,
"SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id",
@@ -245,24 +258,34 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
cardsByRegistration.GetValueOrDefault(row.Id))).ToArray();
var registrations = allRegistrations.Where(item => item.UserId == userId).ToArray();
var allResults = includeCohort
? await QueryAsync(connection,
"SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id",
ReadResult,
cancellationToken)
: await QueryAsync(connection,
"""
SELECT result.id, result.registration_id, result.subject_id, result.score, result.grade,
result.published, result.updated_at, result.published_at
FROM results result
JOIN registrations registration ON registration.id = result.registration_id
WHERE registration.user_id = @userId ORDER BY result.id
""",
ReadResult,
cancellationToken,
new QueryParameter("@userId", userId));
var userRegistrationIds = registrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var results = allResults.Where(item => userRegistrationIds.Contains(item.RegistrationId)).ToArray();
var results = await QueryAsync(connection,
"""
SELECT result.id, result.registration_id, result.subject_id, result.score,
COALESCE(statistic.grade, result.grade) AS grade, result.published,
result.updated_at, result.published_at, statistic.subject_rank,
statistic.cohort_size, statistic.rank_percent, statistic.qualified,
statistic.pass_score, statistic.cutoff_rank
FROM results result
JOIN registrations registration ON registration.id = result.registration_id
LEFT JOIN result_statistics statistic ON statistic.result_id = result.id
WHERE registration.user_id = @userId ORDER BY result.id
""",
ReadResult,
cancellationToken,
new QueryParameter("@userId", userId));
var resultSummaries = await QueryAsync(connection,
"""
SELECT summary.registration_id, summary.published_subject_count, summary.subject_count,
summary.complete, summary.total_score, summary.full_score, summary.score_ratio,
summary.total_rank, summary.cohort_size, summary.rank_percent, summary.qualified,
summary.cutoff_rank
FROM registration_result_summaries summary
JOIN registrations registration ON registration.id = summary.registration_id
WHERE registration.user_id = @userId ORDER BY summary.registration_id
""",
ReadResultSummary,
cancellationToken,
new QueryParameter("@userId", userId));
var workflowSteps = await QueryAsync(connection,
"SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id",
reader => new WorkflowStepRow(
@@ -333,8 +356,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
exams,
registrations,
results,
allRegistrations,
allResults,
resultSummaries,
workflows,
instances,
actions,
@@ -480,7 +502,27 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
ReadString(reader, "grade"),
ReadBoolean(reader, "published"),
ReadOptionalString(reader, "updated_at"),
ReadOptionalString(reader, "published_at"));
ReadOptionalString(reader, "published_at"),
ReadNullableInt32(reader, "subject_rank"),
ReadNullableInt32(reader, "cohort_size") ?? 0,
ReadNullableDouble(reader, "rank_percent"),
ReadNullableBoolean(reader, "qualified"),
ReadNullableDouble(reader, "pass_score"),
ReadNullableInt32(reader, "cutoff_rank"));
private static CandidateResultSummary ReadResultSummary(DbDataReader reader) => new(
ReadString(reader, "registration_id"),
ReadInt32(reader, "published_subject_count"),
ReadInt32(reader, "subject_count"),
ReadBoolean(reader, "complete"),
ReadDouble(reader, "total_score"),
ReadDouble(reader, "full_score"),
ReadDouble(reader, "score_ratio"),
ReadNullableInt32(reader, "total_rank"),
ReadInt32(reader, "cohort_size"),
ReadNullableDouble(reader, "rank_percent"),
ReadNullableBoolean(reader, "qualified"),
ReadNullableInt32(reader, "cutoff_rank"));
private static CandidateWorkflowInstance ReadWorkflowInstance(DbDataReader reader) => new(
ReadString(reader, "id"),
@@ -540,6 +582,20 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
private static int ReadInt32(DbDataReader reader, string name) =>
Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private static int? ReadNullableInt32(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal) ? null : Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private static bool? ReadNullableBoolean(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal)
? null
: Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0;
}
private static double ReadDouble(DbDataReader reader, string name, double fallback = 0)
{
var ordinal = reader.GetOrdinal(name);
@@ -475,13 +475,8 @@ internal sealed partial class CandidateService
return null;
}
var results = snapshot.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray();
if (registration.SubjectIds.Any(id => results.All(result => result.SubjectId != id)))
{
return null;
}
return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero);
var summary = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
return summary is { Complete: true } ? summary.TotalScore : null;
}
private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category)
@@ -36,7 +36,7 @@ internal sealed partial class CandidateService
string verificationBaseUrl,
CancellationToken cancellationToken)
{
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken, includeCohort: true);
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
var registrationById = snapshot.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal);
var results = snapshot.Results
.Where(item => item.Published && registrationById.ContainsKey(item.RegistrationId))
@@ -52,7 +52,7 @@ internal sealed partial class CandidateService
}
var exam = snapshot.Exams.First(item => item.Id == registration.ExamId);
var reportResults = snapshot.AllResults
var reportResults = snapshot.Results
.Where(item => item.RegistrationId == registration.Id && item.Published)
.Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt));
var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults);
@@ -157,8 +157,6 @@ internal sealed partial class CandidateService
{
var exam = snapshot.Exams.First(item => item.Id == registration.ExamId);
var subject = exam.Subjects.FirstOrDefault(item => item.Id == result.SubjectId);
var rank = ResultRankInfo(snapshot, result);
var pass = SubjectPassEvaluation(snapshot, result, subject);
var appealInstance = FindInstance(snapshot, "score_appeal", result.Id);
var appeal = WorkflowView(snapshot, appealInstance);
if (appeal is not null)
@@ -168,10 +166,10 @@ internal sealed partial class CandidateService
}
var json = ResultJson(result);
json["rank"] = JsonValue.Create(rank.Rank);
json["cohortSize"] = rank.CohortSize;
json["rankPercent"] = JsonValue.Create(rank.RankPercent);
json["grade"] = rank.Grade;
json["rank"] = JsonValue.Create(result.Rank);
json["cohortSize"] = result.CohortSize;
json["rankPercent"] = JsonValue.Create(result.RankPercent);
json["grade"] = result.Grade;
json["examId"] = exam.Id;
json["examName"] = exam.Name;
json["examCode"] = exam.Code;
@@ -181,10 +179,10 @@ internal sealed partial class CandidateService
json["fullScore"] = subject?.FullScore ?? 150;
json["passRule"] = subject?.PassRule ?? "fixed_score";
json["passValue"] = subject?.PassValue ?? subject?.PassScore;
json["passScore"] = JsonValue.Create(pass.PassScore);
json["cutoffRank"] = JsonValue.Create(pass.CutoffRank);
json["passScore"] = JsonValue.Create(result.PassScore);
json["cutoffRank"] = JsonValue.Create(result.CutoffRank);
json["passText"] = SubjectPassText(subject);
json["qualified"] = JsonValue.Create(pass.Qualified);
json["qualified"] = JsonValue.Create(result.Qualified);
json["appeal"] = appeal;
return json;
}
@@ -205,14 +203,14 @@ internal sealed partial class CandidateService
}
var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray();
var published = snapshot.AllResults
var published = snapshot.Results
.Where(item => item.RegistrationId == registration.Id && item.Published)
.ToArray();
var resultsBySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal);
var complete = subjects.Length > 0 && subjects.All(item => resultsBySubject.ContainsKey(item.Id));
var total = subjects.Sum(item => resultsBySubject.GetValueOrDefault(item.Id)?.Score ?? 0);
var fullScore = subjects.Sum(item => item.FullScore);
var scoreRatio = fullScore > 0 ? total / fullScore * 100 : 0;
var stored = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
var complete = stored?.Complete ?? false;
var total = stored?.TotalScore ?? 0;
var fullScore = stored?.FullScore ?? subjects.Sum(item => item.FullScore);
var scoreRatio = stored?.ScoreRatio ?? 0;
var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy;
if (policy.Length == 0)
{
@@ -220,32 +218,6 @@ internal sealed partial class CandidateService
}
var value = exam.PassValue;
bool? qualified = null;
int? rank = null;
int? cohortSize = null;
if (complete && policy == "fixed_score")
{
qualified = total >= value;
}
else if (complete && policy == "subject_scores")
{
qualified = subjects.All(item => SubjectPassEvaluation(snapshot, resultsBySubject[item.Id], item).Qualified != false);
}
else if (complete && policy == "rank_percent")
{
var subjectKey = string.Join('|', registration.SubjectIds.Order(StringComparer.Ordinal));
var totals = snapshot.AllRegistrations
.Where(item => item.Status == "approved" && item.ExamId == exam.Id &&
string.Join('|', item.SubjectIds.Order(StringComparer.Ordinal)) == subjectKey)
.Select(item => CompleteTotal(snapshot, exam, item))
.Where(item => item.HasValue)
.Select(item => item!.Value)
.Order()
.ToArray();
cohortSize = totals.Length;
rank = 1 + totals.Count(item => item > total);
qualified = rank <= Math.Max(1, (int)Math.Ceiling(cohortSize.Value * value / 100));
}
return new JsonObject
{
@@ -263,63 +235,14 @@ internal sealed partial class CandidateService
["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero),
["passPolicy"] = policy,
["passValue"] = value,
["qualified"] = JsonValue.Create(qualified),
["rank"] = JsonValue.Create(rank),
["cohortSize"] = JsonValue.Create(cohortSize)
["qualified"] = JsonValue.Create(stored?.Qualified),
["rank"] = JsonValue.Create(stored?.Rank),
["cohortSize"] = JsonValue.Create(stored?.CohortSize),
["rankPercent"] = JsonValue.Create(stored?.RankPercent),
["cutoffRank"] = JsonValue.Create(stored?.CutoffRank)
};
}
private static double? CompleteTotal(
CandidateReadSnapshot snapshot,
CandidateExam exam,
CandidateRegistration registration)
{
var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray();
var scores = snapshot.AllResults
.Where(item => item.RegistrationId == registration.Id && item.Published)
.ToDictionary(item => item.SubjectId, item => item.Score, StringComparer.Ordinal);
return subjects.Length > 0 && subjects.All(item => scores.ContainsKey(item.Id))
? subjects.Sum(item => scores[item.Id])
: null;
}
private static ResultRank ResultRankInfo(CandidateReadSnapshot snapshot, CandidateResult result)
{
var scores = snapshot.AllResults
.Where(item => item.Published && item.SubjectId == result.SubjectId)
.Select(item => item.Score)
.Order()
.ToArray();
var cohortSize = scores.Length;
var rank = 1 + scores.Count(item => item > result.Score);
var rankPercent = cohortSize == 0
? 0
: Math.Round(rank / (double)cohortSize * 100, 2, MidpointRounding.AwayFromZero);
return new ResultRank(rank, cohortSize, rankPercent, GradeForRank(rank, cohortSize));
}
private static PassEvaluation SubjectPassEvaluation(
CandidateReadSnapshot snapshot,
CandidateResult result,
CandidateSubject? subject)
{
var rank = ResultRankInfo(snapshot, result);
var rule = subject?.PassRule ?? "fixed_score";
if (rule == "none")
{
return new PassEvaluation(null, null, null, rank);
}
if (rule == "rank_percent")
{
var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (subject?.PassValue ?? 60) / 100));
return new PassEvaluation(rank.Rank <= cutoff, null, cutoff, rank);
}
var passScore = Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero);
return new PassEvaluation(result.Score >= passScore, passScore, null, rank);
}
private static string SubjectPassText(CandidateSubject? subject) => (subject?.PassRule ?? "fixed_score") switch
{
"none" => "不设单科线",
@@ -327,16 +250,6 @@ internal sealed partial class CandidateService
_ => $"固定 {FormatNumber(Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero))} 分"
};
private static string GradeForRank(int rank, int cohortSize)
{
int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio));
if (rank <= Cutoff(0.1)) return "A+";
if (rank <= Cutoff(0.25)) return "A";
if (rank <= Cutoff(0.5)) return "B+";
if (rank <= Cutoff(0.7)) return "B";
if (rank <= Cutoff(0.9)) return "C";
return "D";
}
private static string FormatNumber(double value) =>
value.ToString("0.#############################", CultureInfo.InvariantCulture);
@@ -349,7 +262,4 @@ internal sealed partial class CandidateService
return $"data:image/png;base64,{Convert.ToBase64String(qrCode.GetGraphic(10))}";
}
private sealed record ResultRank(int Rank, int CohortSize, double RankPercent, string Grade);
private sealed record PassEvaluation(bool? Qualified, double? PassScore, int? CutoffRank, ResultRank Rank);
}
@@ -3,6 +3,7 @@ using System.Globalization;
using System.Reflection;
using System.Text.Json;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Microsoft.Extensions.DependencyInjection;
namespace Eis.Infrastructure.Data;
@@ -294,5 +295,7 @@ public static class DatabaseInitializationExtensions
await using var scope = serviceProvider.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
.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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS result_statistics (
result_id VARCHAR(64) NOT NULL,
subject_rank INT NULL,
cohort_size INT UNSIGNED NOT NULL DEFAULT 0,
rank_percent DOUBLE NULL,
grade VARCHAR(20) NOT NULL,
qualified BOOLEAN NULL,
pass_score DOUBLE NULL,
cutoff_rank INT NULL,
calculated_at VARCHAR(35) NOT NULL,
PRIMARY KEY (result_id),
CONSTRAINT fk_result_statistics_result FOREIGN KEY (result_id) REFERENCES results(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS registration_result_summaries (
registration_id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL,
published_subject_count INT UNSIGNED NOT NULL DEFAULT 0,
subject_count INT UNSIGNED NOT NULL DEFAULT 0,
complete BOOLEAN NOT NULL DEFAULT FALSE,
total_score DOUBLE NOT NULL DEFAULT 0,
full_score DOUBLE NOT NULL DEFAULT 0,
score_ratio DOUBLE NOT NULL DEFAULT 0,
total_rank INT NULL,
cohort_size INT UNSIGNED NOT NULL DEFAULT 0,
rank_percent DOUBLE NULL,
qualified BOOLEAN NULL,
cutoff_rank INT NULL,
calculated_at VARCHAR(35) NOT NULL,
PRIMARY KEY (registration_id),
KEY idx_result_summaries_exam (exam_id, complete, qualified),
CONSTRAINT fk_result_summaries_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
CONSTRAINT fk_result_summaries_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS test_centers (
id VARCHAR(64) NOT NULL,
school_id VARCHAR(64) NOT NULL,
@@ -253,6 +253,35 @@ PRAGMA foreign_keys = ON;
UNIQUE (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS result_statistics (
result_id TEXT PRIMARY KEY REFERENCES results(id) ON DELETE CASCADE,
subject_rank INTEGER,
cohort_size INTEGER NOT NULL DEFAULT 0 CHECK (cohort_size >= 0),
rank_percent REAL,
grade TEXT NOT NULL,
qualified INTEGER CHECK (qualified IN (0, 1)),
pass_score REAL,
cutoff_rank INTEGER,
calculated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS registration_result_summaries (
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
published_subject_count INTEGER NOT NULL DEFAULT 0 CHECK (published_subject_count >= 0),
subject_count INTEGER NOT NULL DEFAULT 0 CHECK (subject_count >= 0),
complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)),
total_score REAL NOT NULL DEFAULT 0,
full_score REAL NOT NULL DEFAULT 0,
score_ratio REAL NOT NULL DEFAULT 0,
total_rank INTEGER,
cohort_size INTEGER NOT NULL DEFAULT 0 CHECK (cohort_size >= 0),
rank_percent REAL,
qualified INTEGER CHECK (qualified IN (0, 1)),
cutoff_rank INTEGER,
calculated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS test_centers (
id TEXT PRIMARY KEY,
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
@@ -464,6 +493,7 @@ PRAGMA foreign_keys = ON;
CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at);
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
CREATE INDEX IF NOT EXISTS idx_result_summaries_exam ON registration_result_summaries(exam_id, complete, qualified);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
@@ -70,6 +70,7 @@ public static class DependencyInjection
services.AddScoped<AdminArrangementSnapshotLoader>();
services.AddScoped<AdminArrangementRepository>();
services.AddScoped<AdminResultRepository>();
services.AddScoped<AdminResultStatisticsBackfillService>();
services.AddScoped<AdminAdmissionRepository>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();