成绩提交时按考试一次性计算并存储单科名次、排名百分比、等级、合格状态、总分排名及整场合格状态。
新增 result_statistics、registration_result_summaries 两张统计表,现有数据库首次启动时自动回填。 SQL 查询首先通过 examId 限定范围。 科目成绩只排序一次,并使用 registration/subject 字典组织数据。 汇总接口与成绩明细接口已拆分,明细、成绩录入名单和特征分名单均改为服务端分页。 所有成绩写入口统一限制为:0—满分,且只能是整数或 .5,包括手工录入、批量录入、Excel 导入和复议改分。 前端也增加了相同的即时校验。 修复其他考试卡片显示 undefined 的问题。
This commit is contained in:
@@ -4,7 +4,13 @@ namespace Eis.Application.Administration;
|
||||
|
||||
public interface IAdminResultService
|
||||
{
|
||||
Task<AdminEndpointResult> GetAsync(string sessionToken, string examId, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> GetSummaryAsync(string sessionToken, string examId, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> GetAsync(
|
||||
string sessionToken, string examId, int page, int pageSize, string query, string status,
|
||||
CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> GetRosterAsync(
|
||||
string sessionToken, string examId, string subjectId, bool featureMode,
|
||||
int page, int pageSize, string query, string status, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||
|
||||
@@ -673,7 +673,8 @@ internal sealed partial class AdminAdmissionService(
|
||||
await admissionRepository.LoadAsync(cancellationToken),
|
||||
await 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>();
|
||||
|
||||
@@ -189,6 +189,28 @@ public static class NativeAdminReadEndpoints
|
||||
|
||||
if (options.NativeResultsEnabled)
|
||||
{
|
||||
endpoints.MapGet("/api/admin/results/summary",
|
||||
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||
Execute(
|
||||
context,
|
||||
service.GetSummaryAsync(
|
||||
Token(context),
|
||||
context.Request.Query["examId"].ToString(),
|
||||
cancellationToken)));
|
||||
endpoints.MapGet("/api/admin/results/roster",
|
||||
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||
Execute(
|
||||
context,
|
||||
service.GetRosterAsync(
|
||||
Token(context),
|
||||
context.Request.Query["examId"].ToString(),
|
||||
context.Request.Query["subjectId"].ToString(),
|
||||
context.Request.Query["mode"].ToString() == "feature",
|
||||
QueryInteger(context, "page", 1),
|
||||
QueryInteger(context, "pageSize", 50),
|
||||
context.Request.Query["query"].ToString(),
|
||||
context.Request.Query["status"].ToString(),
|
||||
cancellationToken)));
|
||||
endpoints.MapGet("/api/admin/results",
|
||||
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||
Execute(
|
||||
@@ -196,6 +218,10 @@ public static class NativeAdminReadEndpoints
|
||||
service.GetAsync(
|
||||
Token(context),
|
||||
context.Request.Query["examId"].ToString(),
|
||||
QueryInteger(context, "page", 1),
|
||||
QueryInteger(context, "pageSize", 50),
|
||||
context.Request.Query["query"].ToString(),
|
||||
context.Request.Query["status"].ToString(),
|
||||
cancellationToken)));
|
||||
endpoints.MapPost("/api/admin/results",
|
||||
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||
@@ -381,6 +407,11 @@ public static class NativeAdminReadEndpoints
|
||||
private static string Token(HttpContext context) =>
|
||||
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty;
|
||||
|
||||
private static int QueryInteger(HttpContext context, string name, int fallback) =>
|
||||
int.TryParse(context.Request.Query[name].ToString(), out var value) && value > 0
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
private static async Task<byte[]> ReadWorkbookAsync(HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
const int maximumBytes = 20 * 1024 * 1024;
|
||||
|
||||
@@ -300,11 +300,11 @@ function updateResultScoreInput(input) {
|
||||
const value = input.value.trim();
|
||||
const score = Number(value);
|
||||
const max = Number(input.max);
|
||||
const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max);
|
||||
const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max || !Number.isInteger(score * 2));
|
||||
input.dataset.dirty = String(value !== input.defaultValue);
|
||||
input.setAttribute('aria-invalid', String(invalid));
|
||||
row?.classList.toggle('score-row-invalid', invalid);
|
||||
if (error) error.textContent = invalid ? `须在 0—${max} 之间` : '';
|
||||
if (error) error.textContent = invalid ? `须在 0—${max} 之间,且只能填写整数或 .5` : '';
|
||||
const form = input.closest('form');
|
||||
const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0;
|
||||
const count = form?.querySelector('[data-result-dirty-count]');
|
||||
@@ -1306,15 +1306,15 @@ document.addEventListener('submit', async event => {
|
||||
} else if (kind === 'result-bulk-entry') {
|
||||
const mode = event.submitter?.dataset.resultMode || 'draft';
|
||||
const inputs = [...form.querySelectorAll('[data-result-score]')];
|
||||
const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max)));
|
||||
if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间`); }
|
||||
const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max) || !Number.isInteger(Number(input.value) * 2)));
|
||||
if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间,且只能填写整数或 .5`); }
|
||||
const targets = mode === 'publish'
|
||||
? inputs.filter(input => input.value.trim() !== '')
|
||||
: inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== '');
|
||||
if (mode === 'publish') {
|
||||
const missing = inputs.filter(input => input.value.trim() === '');
|
||||
if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); }
|
||||
if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return;
|
||||
if (missing.length) { missing[0].focus(); throw new Error(`当前页还有 ${missing.length} 名考生未录入成绩`); }
|
||||
if (!window.confirm(`确认发布当前页 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return;
|
||||
}
|
||||
if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩');
|
||||
const body = {
|
||||
@@ -1324,7 +1324,7 @@ document.addEventListener('submit', async event => {
|
||||
rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) }))
|
||||
};
|
||||
const result = await api('/api/admin/results/bulk', { method: 'POST', body });
|
||||
toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`);
|
||||
toast(mode === 'publish' ? '当前页成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存并重算本场排名`);
|
||||
renderRoute();
|
||||
} else if (kind === 'feature-score-entry') {
|
||||
const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { formatRegionAddress } from './region-select.js';
|
||||
import { admissionCategoriesEditor } from './admission-plan-editor.js';
|
||||
import { specialtyLabel } from '../data/specialty-types.js';
|
||||
import { filterTableItems } from './table-state.js';
|
||||
import { filterTableItems, getTableControl } from './table-state.js';
|
||||
|
||||
export const numberSegmentMeta = {
|
||||
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
|
||||
@@ -62,10 +62,46 @@ export function createAdminViews(context) {
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page.startsWith('admission-') ? 'admissions' : page;
|
||||
const query = page === 'results' && state.resultExamFilter ? `?examId=${encodeURIComponent(state.resultExamFilter)}` : '';
|
||||
const data = page === 'results' && !state.resultExamFilter
|
||||
let data = page === 'results' && !state.resultExamFilter
|
||||
? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } }
|
||||
: page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`);
|
||||
: page === 'security' ? await api('/api/auth/totp') : page === 'results'
|
||||
? await api(`/api/admin/results/summary?examId=${encodeURIComponent(state.resultExamFilter)}`)
|
||||
: await api(`/api/admin/${endpoint}`);
|
||||
if (page === 'results' && state.resultExamFilter) {
|
||||
const activeExam = data.exams.find(item => item.id === state.resultExamFilter);
|
||||
const subjectId = state.resultSubjectFilter || activeExam?.subjects?.[0]?.id || '';
|
||||
state.resultSubjectFilter = subjectId;
|
||||
const requestQuery = (key, extra = {}) => {
|
||||
const current = state.tablePages[key] || {};
|
||||
const control = getTableControl(state, key);
|
||||
const params = new URLSearchParams({
|
||||
examId: state.resultExamFilter,
|
||||
page: String(current.page || 1),
|
||||
pageSize: String(current.pageSize || 50),
|
||||
query: control.query || '',
|
||||
status: control.status || 'all',
|
||||
...extra
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
const resultPromise = api(`/api/admin/results?${requestQuery('resultTable')}`);
|
||||
const entryPromise = state.user.adminLevel === 'super' && subjectId
|
||||
? api(`/api/admin/results/roster?${requestQuery('resultEntryTable', { subjectId })}`)
|
||||
: Promise.resolve({ items: [], pagination: { page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'resultEntryTable' } });
|
||||
const featurePromise = state.user.adminLevel === 'super'
|
||||
? api(`/api/admin/results/roster?${requestQuery('featureScoreTable', { mode: 'feature' })}`)
|
||||
: Promise.resolve({ items: [], pagination: { page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'featureScoreTable' } });
|
||||
const [resultData, entryData, featureData] = await Promise.all([resultPromise, entryPromise, featurePromise]);
|
||||
data = {
|
||||
...data,
|
||||
results: resultData.items || [],
|
||||
resultPage: { ...(resultData.pagination || {}), items: resultData.items || [] },
|
||||
entryPage: { ...(entryData.pagination || {}), items: entryData.items || [] },
|
||||
featurePage: { ...(featureData.pagination || {}), items: featureData.items || [] },
|
||||
entryCounts: entryData.counts || {},
|
||||
featureCounts: featureData.counts || {}
|
||||
};
|
||||
}
|
||||
if (page === 'results' && data.selectedExamId) state.resultExamFilter = data.selectedExamId;
|
||||
if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams;
|
||||
state.pageData = data;
|
||||
@@ -323,35 +359,34 @@ export function createAdminViews(context) {
|
||||
}
|
||||
const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0];
|
||||
if (activeSubject) state.resultSubjectFilter = activeSubject.id;
|
||||
const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results;
|
||||
const resultPage = paged(examResults, 'resultTable');
|
||||
const examResults = data.results || [];
|
||||
const resultPage = data.resultPage || { items: examResults, page: 1, pageSize: 50, total: examResults.length, totalPages: 1, key: 'resultTable' };
|
||||
const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []);
|
||||
const appealPage = paged(examAppeals, 'resultAppealTable');
|
||||
const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null;
|
||||
const subjectRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id && item.subjectIds.includes(activeSubject?.id));
|
||||
const resultEntryPage = paged(subjectRegistrations, 'resultEntryTable');
|
||||
const subjectResults = new Map(examResults.filter(item => item.subjectId === activeSubject?.id).map(item => [item.registrationId, item]));
|
||||
const recordedCount = subjectRegistrations.filter(item => subjectResults.has(item.id)).length;
|
||||
const publishedCount = subjectRegistrations.filter(item => subjectResults.get(item.id)?.published).length;
|
||||
const resultEntryPage = data.entryPage || { items: [], page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'resultEntryTable' };
|
||||
const subjectRegistrations = resultEntryPage.items;
|
||||
const recordedCount = Number(data.entryCounts?.recorded || 0);
|
||||
const publishedCount = Number(data.entryCounts?.published || 0);
|
||||
const entryRows = resultEntryPage.items.map((registration, index) => {
|
||||
const result = subjectResults.get(registration.id);
|
||||
const result = registration.result;
|
||||
const status = result?.published ? 'published' : result ? 'draft' : 'missing';
|
||||
return `<tr data-status="${status}"><td class="mono result-row-index">${index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td class="result-score-input-cell"><input data-result-score data-registration-id="${h(registration.id)}" type="number" min="0" max="${h(activeSubject?.fullScore || '')}" step="0.5" value="${result ? h(result.score) : ''}" placeholder="0—${h(activeSubject?.fullScore || '')}" aria-label="${h(registration.candidateName)}成绩"><small data-score-error></small></td><td>${result ? badge(result.published ? 'published' : 'draft') : '<span class="result-missing">未录入</span>'}</td><td>${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}</td></tr>`;
|
||||
return `<tr data-status="${status}"><td class="mono result-row-index">${(resultEntryPage.page - 1) * resultEntryPage.pageSize + index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td class="result-score-input-cell"><input data-result-score data-registration-id="${h(registration.id)}" type="number" min="0" max="${h(activeSubject?.fullScore || '')}" step="0.5" value="${result ? h(result.score) : ''}" placeholder="0—${h(activeSubject?.fullScore || '')}" aria-label="${h(registration.candidateName)}成绩"><small data-score-error></small></td><td>${result ? badge(result.published ? 'published' : 'draft') : '<span class="result-missing">未录入</span>'}</td><td>${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}</td></tr>`;
|
||||
}).join('');
|
||||
const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry-workbench"><form data-form="result-bulk-entry"><header class="result-workbench-head"><div><span>SCORE ENTRY ROSTER</span><h2>按名单录入单科成绩</h2><p>选定考试和科目后,直接在完整考生名单中录分;暂存不会向考生发布。</p></div><div class="result-workbench-selectors"><label><span>考试</span><select name="examId" data-action="result-bulk-exam">${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>科目</span><select name="subjectId" data-action="result-bulk-subject">${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" ${subject.id === activeSubject?.id ? 'selected' : ''}>${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label></div></header><div class="result-workbench-summary"><span><small>应录人数</small><strong>${subjectRegistrations.length}</strong></span><span><small>已录入</small><strong>${recordedCount}</strong></span><span><small>已暂存</small><strong>${Math.max(0, recordedCount - publishedCount)}</strong></span><span><small>已发布</small><strong>${publishedCount}</strong></span><p>${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultEntryTable" placeholder="搜索姓名、报名号、准考证号、学校或班级"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="resultEntryTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="missing">未录入</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="draft">已暂存</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="published">已发布</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="resultEntryTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>成绩(0—${h(activeSubject?.fullScore || '—')})</th><th>状态</th><th>最近保存</th></tr></thead><tbody>${entryRows || '<tr><td colspan="7" class="empty-state">该科目暂无已通过报名的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-result-dirty-count>尚无未保存修改</strong><small>按 Ctrl / ⌘ + S 可快速暂存;发布前必须补齐本科学目全部考生成绩。</small></div><button type="submit" class="ghost-button" data-result-mode="draft">暂存已修改成绩</button><button type="submit" class="solid-button" data-result-mode="publish">发布本科学目成绩</button></footer></form></section>` : '';
|
||||
const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id);
|
||||
const featurePage = paged(featureRegistrations, 'featureScoreTable');
|
||||
const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length;
|
||||
const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry-workbench"><form data-form="result-bulk-entry"><header class="result-workbench-head"><div><span>SCORE ENTRY ROSTER</span><h2>按名单录入单科成绩</h2><p>选定考试和科目后,按服务端分页录分;暂存不会向考生发布。</p></div><div class="result-workbench-selectors"><label><span>考试</span><select name="examId" data-action="result-bulk-exam">${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>科目</span><select name="subjectId" data-action="result-bulk-subject">${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" ${subject.id === activeSubject?.id ? 'selected' : ''}>${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label></div></header><div class="result-workbench-summary"><span><small>应录人数</small><strong>${Number(data.entryCounts?.total || 0)}</strong></span><span><small>已录入</small><strong>${recordedCount}</strong></span><span><small>已暂存</small><strong>${Math.max(0, recordedCount - publishedCount)}</strong></span><span><small>已发布</small><strong>${publishedCount}</strong></span><p>${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultEntryTable" placeholder="搜索全部姓名、报名号、学校或班级"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="resultEntryTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="missing">未录入</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="draft">已暂存</button><button type="button" data-action="status-filter" data-target="resultEntryTable" data-status="published">已发布</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="resultEntryTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>成绩(0—${h(activeSubject?.fullScore || '—')})</th><th>状态</th><th>最近保存</th></tr></thead><tbody>${entryRows || '<tr><td colspan="7" class="empty-state">该科目暂无符合条件的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-result-dirty-count>尚无未保存修改</strong><small>按 Ctrl / ⌘ + S 可快速暂存;发布操作只处理当前页已填写的成绩。</small></div><button type="submit" class="ghost-button" data-result-mode="draft">暂存已修改成绩</button><button type="submit" class="solid-button" data-result-mode="publish">发布当前页成绩</button></footer></form></section>` : '';
|
||||
const featurePage = data.featurePage || { items: [], page: 1, pageSize: 50, total: 0, totalPages: 1, key: 'featureScoreTable' };
|
||||
const featureRegistrations = featurePage.items;
|
||||
const featureModified = Number(data.featureCounts?.modified || 0);
|
||||
const featureRows = featurePage.items.map((registration, index) => {
|
||||
const featureScore = Number(registration.featureScore || 0);
|
||||
const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`;
|
||||
return `<tr data-status="${status}"><td class="mono result-row-index">${index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td><strong>${h(registration.specialtyLabel || '普通生')}</strong><small>${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}</small></td><td class="result-score-input-cell feature-score-input-cell"><input data-feature-score data-registration-id="${h(registration.id)}" type="number" min="0" max="1000" step="0.01" value="${h(featureScore)}" aria-label="${h(registration.candidateName)}特征分"><small data-feature-score-error></small></td></tr>`;
|
||||
return `<tr data-status="${status}"><td class="mono result-row-index">${(featurePage.page - 1) * featurePage.pageSize + index + 1}</td><td><div class="person-cell"><span>${h((registration.candidateName || '?').slice(0, 1))}</span><div><strong>${h(registration.candidateName)}</strong><small>${h(registration.schoolName)} · ${h(registration.className)}</small></div></div></td><td><strong class="mono">${h(registration.candidateNumber)}</strong></td><td><strong class="mono">${h(registration.admitCard?.number || '待编排')}</strong></td><td><strong>${h(registration.specialtyLabel || '普通生')}</strong><small>${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}</small></td><td class="result-score-input-cell feature-score-input-cell"><input data-feature-score data-registration-id="${h(registration.id)}" type="number" min="0" max="1000" step="0.01" value="${h(featureScore)}" aria-label="${h(registration.candidateName)}特征分"><small data-feature-score-error></small></td></tr>`;
|
||||
}).join('');
|
||||
const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel feature-score-workbench"><form data-form="feature-score-bulk"><input type="hidden" name="examId" value="${h(activeExam?.id || '')}"><header><div><span>UNIFIED SPECIALTY TEST</span><h2>按名单登记特征分</h2><p>所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。</p></div><div class="feature-score-rule"><strong>分类计分规则</strong><span>普通类别:文化课总分</span><span>特长类别:文化课总分 + 特征分</span></div></header><div class="result-workbench-summary feature-score-summary"><span><small>本场考生</small><strong>${featureRegistrations.length}</strong></span><span><small>默认 0 分</small><strong>${featureRegistrations.length - featureModified}</strong></span><span><small>已修改</small><strong>${featureModified}</strong></span><span><small>具有特长资格</small><strong>${featureRegistrations.filter(item => item.specialtyType).length}</strong></span><p>特征分按考试、按考生独立保存,不改变文化课成绩。</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="featureScoreTable" placeholder="搜索姓名、报名号、准考证号、学校或特长类型"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="featureScoreTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="zero">默认 0 分</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="modified">已修改</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="specialty">有特长资格</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="featureScoreTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>特长资格 / 计分范围</th><th>特征分(0—1000)</th></tr></thead><tbody>${featureRows || '<tr><td colspan="6" class="empty-state">本场暂无已通过报名的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-feature-dirty-count>尚无未保存修改</strong><small>未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。</small></div><button type="submit" class="solid-button">保存已修改特征分</button></footer></form></section>` : '';
|
||||
const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel feature-score-workbench"><form data-form="feature-score-bulk"><input type="hidden" name="examId" value="${h(activeExam?.id || '')}"><header><div><span>UNIFIED SPECIALTY TEST</span><h2>按名单登记特征分</h2><p>所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。</p></div><div class="feature-score-rule"><strong>分类计分规则</strong><span>普通类别:文化课总分</span><span>特长类别:文化课总分 + 特征分</span></div></header><div class="result-workbench-summary feature-score-summary"><span><small>本场考生</small><strong>${Number(data.featureCounts?.total || 0)}</strong></span><span><small>默认 0 分</small><strong>${Math.max(0, Number(data.featureCounts?.total || 0) - featureModified)}</strong></span><span><small>已修改</small><strong>${featureModified}</strong></span><span><small>具有特长资格</small><strong>${Number(data.featureCounts?.specialty || 0)}</strong></span><p>特征分按考试、按考生独立保存,不改变文化课成绩。</p></div><div class="data-toolbar result-entry-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="featureScoreTable" placeholder="搜索全部姓名、报名号、学校或特长类型"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="featureScoreTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="zero">默认 0 分</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="modified">已修改</button><button type="button" data-action="status-filter" data-target="featureScoreTable" data-status="specialty">有特长资格</button></div></div><div class="table-scroll result-entry-table-wrap"><table id="featureScoreTable"><thead><tr><th>序号</th><th>考生</th><th>报名号</th><th>准考证号</th><th>特长资格 / 计分范围</th><th>特征分(0—1000)</th></tr></thead><tbody>${featureRows || '<tr><td colspan="6" class="empty-state">本场暂无符合条件的考生</td></tr>'}</tbody></table></div><footer class="result-workbench-actions"><div><strong data-feature-dirty-count>尚无未保存修改</strong><small>未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。</small></div><button type="submit" class="solid-button">保存已修改特征分</button></footer></form></section>` : '';
|
||||
const importPreviewPage = preview ? paged(preview.rows, 'resultImportPreviewTable') : null;
|
||||
const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultImportPreviewTable" placeholder="跨页搜索考生、报名号、考试、科目或校验信息"></label><div class="table-filter-selects"><select data-table-filter="mode" data-target="resultImportPreviewTable"><option value="">全部写入方式</option><option value="create">新增成绩</option><option value="update">覆盖已有成绩</option></select></div></div><div class="table-scroll"><table id="resultImportPreviewTable" class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${importPreviewPage.items.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div>${pagination(importPreviewPage)}<form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : '';
|
||||
const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>本场成绩复议</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultAppealTable" placeholder="跨页搜索考生、科目、理由、责任人或步骤"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultAppealTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultAppealTable" data-status="pending">处理中</button><button data-action="status-filter" data-target="resultAppealTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="resultAppealTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="resultAppealTable"><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${appealPage.items.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div>${pagination(appealPage)}</section>`;
|
||||
const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`;
|
||||
const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount ?? 0} 人 · 已录 ${exam.scored ?? 0}/${exam.enrolledSubjects ?? 0} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round((exam.scored ?? 0) / exam.enrolledSubjects * 100) : 0}%"></i></button>`;
|
||||
const currentExams = data.exams.filter(exam => !exam.archivedAt);
|
||||
const archivedExams = data.exams.filter(exam => exam.archivedAt);
|
||||
const examStrip = `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher" ${activeExam?.archivedAt ? 'open' : ''}><summary>历史归档考试 <span>${archivedExams.length} 场 · 成绩永久锁定</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}`;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Infrastructure.Administration;
|
||||
|
||||
namespace Eis.Infrastructure.Tests.Administration;
|
||||
|
||||
public sealed class AdminResultStatisticsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 100, true)]
|
||||
[InlineData(10, 100, true)]
|
||||
[InlineData(10.5, 100, true)]
|
||||
[InlineData(10.1, 100, false)]
|
||||
[InlineData(-0.5, 100, false)]
|
||||
[InlineData(100.5, 100, false)]
|
||||
public void IsValidScore_OnlyAcceptsWholeOrHalfPoints(double score, double fullScore, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AdminResultStatistics.IsValidScore(score, fullScore));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ComputesEachSubjectAndTotalRankingOnceForTheExam()
|
||||
{
|
||||
var exam = Exam("rank_percent", 50);
|
||||
var registrations = new[] { Registration("r1"), Registration("r2"), Registration("r3") };
|
||||
var results = new[]
|
||||
{
|
||||
Result("r1-s1", "r1", "s1", 90), Result("r1-s2", "r1", "s2", 80),
|
||||
Result("r2-s1", "r2", "s1", 90), Result("r2-s2", "r2", "s2", 70),
|
||||
Result("r3-s1", "r3", "s1", 60), Result("r3-s2", "r3", "s2", 70)
|
||||
};
|
||||
|
||||
var calculation = AdminResultStatistics.Calculate(exam, registrations, results, "2026-07-23T00:00:00.000Z");
|
||||
|
||||
Assert.Equal(6, calculation.Metrics.Count);
|
||||
Assert.Equal(1, calculation.Metrics.Single(item => item.ResultId == "r1-s1").Rank);
|
||||
Assert.Equal(1, calculation.Metrics.Single(item => item.ResultId == "r2-s1").Rank);
|
||||
Assert.Equal(3, calculation.Metrics.Single(item => item.ResultId == "r3-s1").Rank);
|
||||
Assert.Equal(2, calculation.Metrics.Single(item => item.ResultId == "r2-s2").Rank);
|
||||
Assert.Equal(2, calculation.Metrics.Single(item => item.ResultId == "r3-s2").Rank);
|
||||
|
||||
var summaries = calculation.Summaries.ToDictionary(item => item.RegistrationId);
|
||||
Assert.Equal(170, summaries["r1"].TotalScore);
|
||||
Assert.Equal(1, summaries["r1"].Rank);
|
||||
Assert.True(summaries["r1"].Qualified);
|
||||
Assert.Equal(2, summaries["r2"].Rank);
|
||||
Assert.True(summaries["r2"].Qualified);
|
||||
Assert.Equal(3, summaries["r3"].Rank);
|
||||
Assert.False(summaries["r3"].Qualified);
|
||||
Assert.All(summaries.Values, item => Assert.Equal(3, item.CohortSize));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_PersistsIncompleteAndDraftStatesWithoutIncludingDraftInPublishedSummary()
|
||||
{
|
||||
var exam = Exam("subject_scores", 0);
|
||||
var registrations = new[] { Registration("r1"), Registration("r2") };
|
||||
var results = new[]
|
||||
{
|
||||
Result("r1-s1", "r1", "s1", 95),
|
||||
Result("r1-s2", "r1", "s2", 88, published: false),
|
||||
Result("r2-s1", "r2", "s1", 80),
|
||||
Result("r2-s2", "r2", "s2", 75)
|
||||
};
|
||||
|
||||
var calculation = AdminResultStatistics.Calculate(exam, registrations, results, "2026-07-23T00:00:00.000Z");
|
||||
var draft = calculation.Metrics.Single(item => item.ResultId == "r1-s2");
|
||||
var summaries = calculation.Summaries.ToDictionary(item => item.RegistrationId);
|
||||
|
||||
Assert.Equal("待发布", draft.Grade);
|
||||
Assert.Equal(2, draft.CohortSize);
|
||||
Assert.False(summaries["r1"].Complete);
|
||||
Assert.Null(summaries["r1"].Qualified);
|
||||
Assert.Equal(1, summaries["r1"].PublishedSubjectCount);
|
||||
Assert.True(summaries["r2"].Complete);
|
||||
}
|
||||
|
||||
private static OperationalExam Exam(string passPolicy, double passValue)
|
||||
{
|
||||
var subjects = new[]
|
||||
{
|
||||
new JsonObject { ["id"] = "s1", ["name"] = "语文", ["fullScore"] = 100, ["passRule"] = "fixed_score", ["passValue"] = 60 },
|
||||
new JsonObject { ["id"] = "s2", ["name"] = "数学", ["fullScore"] = 100, ["passRule"] = "fixed_score", ["passValue"] = 60 }
|
||||
};
|
||||
return new("exam-1", new JsonObject
|
||||
{
|
||||
["id"] = "exam-1",
|
||||
["passPolicy"] = passPolicy,
|
||||
["passValue"] = passValue
|
||||
}, subjects);
|
||||
}
|
||||
|
||||
private static OperationalRegistration Registration(string id) => new(
|
||||
id,
|
||||
$"user-{id}",
|
||||
"exam-1",
|
||||
"approved",
|
||||
"paid",
|
||||
null,
|
||||
"2026-07-23T00:00:00.000Z",
|
||||
new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["subjectIds"] = new JsonArray("s1", "s2")
|
||||
});
|
||||
|
||||
private static AdminWorkflowResult Result(
|
||||
string id,
|
||||
string registrationId,
|
||||
string subjectId,
|
||||
double score,
|
||||
bool published = true) => new(
|
||||
id,
|
||||
registrationId,
|
||||
subjectId,
|
||||
score,
|
||||
published ? "" : "待发布",
|
||||
published,
|
||||
"2026-07-23T00:00:00.000Z",
|
||||
published ? "2026-07-23T00:00:00.000Z" : null);
|
||||
}
|
||||
Reference in New Issue
Block a user