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

新增 result_statistics、registration_result_summaries 两张统计表,现有数据库首次启动时自动回填。
SQL 查询首先通过 examId 限定范围。
科目成绩只排序一次,并使用 registration/subject 字典组织数据。
汇总接口与成绩明细接口已拆分,明细、成绩录入名单和特征分名单均改为服务端分页。
所有成绩写入口统一限制为:0—满分,且只能是整数或 .5,包括手工录入、批量录入、Excel 导入和复议改分。
前端也增加了相同的即时校验。
修复其他考试卡片显示 undefined 的问题。
This commit is contained in:
biss committed 2026-07-23 19:21:56 +08:00
1 parent b139834396
commit cde754621a
20 files changed
+1365 -489

No files matched your search

@@ -4,7 +4,13 @@ namespace Eis.Application.Administration;
public interface IAdminResultService public interface IAdminResultService
{ {
Task<AdminEndpointResult> GetAsync(string sessionToken, string examId, CancellationToken cancellationToken); Task<AdminEndpointResult> GetSummaryAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAsync(
string sessionToken, string examId, int page, int pageSize, string query, string status,
CancellationToken cancellationToken);
Task<AdminEndpointResult> GetRosterAsync(
string sessionToken, string examId, string subjectId, bool featureMode,
int page, int pageSize, string query, string status, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
@@ -673,7 +673,8 @@ internal sealed partial class AdminAdmissionService(
await admissionRepository.LoadAsync(cancellationToken), await admissionRepository.LoadAsync(cancellationToken),
await operationalLoader.LoadAsync(cancellationToken), await operationalLoader.LoadAsync(cancellationToken),
await directoryLoader.LoadAsync(cancellationToken), await directoryLoader.LoadAsync(cancellationToken),
await resultLoader.LoadAsync(cancellationToken)); await resultLoader.LoadAsync(cancellationToken),
await resultLoader.LoadSummariesAsync(cancellationToken));
private static IEnumerable<CandidateAdmissionRecord> Records(AdmissionData data, string kind, string? examId = null) => private static IEnumerable<CandidateAdmissionRecord> Records(AdmissionData data, string kind, string? examId = null) =>
data.Admission.Records.Where(item => item.Kind == kind && (examId is null || item.ExamId == examId)); data.Admission.Records.Where(item => item.Kind == kind && (examId is null || item.ExamId == examId));
@@ -985,10 +986,8 @@ internal sealed partial class AdminAdmissionService(
var registration = data.Operational.Registrations.FirstOrDefault(item => var registration = data.Operational.Registrations.FirstOrDefault(item =>
item.ExamId == examId && item.UserId == userId && item.Status == "approved"); item.ExamId == examId && item.UserId == userId && item.Status == "approved");
if (registration is null) return null; if (registration is null) return null;
var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToArray(); var summary = data.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id);
var results = data.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); return summary is { Complete: true } ? summary.TotalScore : null;
if (subjectIds.Length == 0 || subjectIds.Any(id => results.All(result => result.SubjectId != id))) return null;
return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero);
} }
private static List<CandidateAdmissionRecord> AssignNoticeNumbers(AdmissionData data, IReadOnlyList<CandidateAdmissionRecord> placements) private static List<CandidateAdmissionRecord> AssignNoticeNumbers(AdmissionData data, IReadOnlyList<CandidateAdmissionRecord> placements)
@@ -1340,7 +1339,8 @@ internal sealed partial class AdminAdmissionService(
CandidateAdmissionSnapshot Admission, CandidateAdmissionSnapshot Admission,
AdminOperationalSnapshot Operational, AdminOperationalSnapshot Operational,
AdminAccountBatchSnapshot Directory, AdminAccountBatchSnapshot Directory,
IReadOnlyList<AdminWorkflowResult> Results); IReadOnlyList<AdminWorkflowResult> Results,
IReadOnlyList<AdminRegistrationResultSummary> ResultSummaries);
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{ {
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
@@ -11,7 +11,19 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) => public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) =>
SaveManyAsync([entry], cancellationToken); SaveManyAsync([entry], cancellationToken);
public Task SaveAsync(
ResultWrite entry,
AdminResultCalculation calculation,
CancellationToken cancellationToken) =>
SaveManyAsync([entry], [calculation], cancellationToken);
public async Task SaveManyAsync(IReadOnlyList<ResultWrite> entries, CancellationToken cancellationToken) public async Task SaveManyAsync(IReadOnlyList<ResultWrite> entries, CancellationToken cancellationToken)
=> await SaveManyAsync(entries, [], cancellationToken);
public async Task SaveManyAsync(
IReadOnlyList<ResultWrite> entries,
IReadOnlyList<AdminResultCalculation> calculations,
CancellationToken cancellationToken)
{ {
await TransactionAsync(async (connection, transaction) => await TransactionAsync(async (connection, transaction) =>
{ {
@@ -46,9 +58,43 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
cancellationToken); cancellationToken);
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken); await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
} }
foreach (var calculation in calculations)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
}, cancellationToken); }, cancellationToken);
} }
public async Task ReplaceStatisticsAsync(
IReadOnlyList<AdminResultCalculation> calculations,
CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
foreach (var calculation in calculations)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
}, cancellationToken);
}
public async Task<bool> NeedsStatisticsBackfillAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText =
"""
SELECT
(SELECT COUNT(*) FROM results result
LEFT JOIN result_statistics statistic ON statistic.result_id = result.id
WHERE statistic.result_id IS NULL) +
(SELECT COUNT(*) FROM registrations registration
LEFT JOIN registration_result_summaries summary ON summary.registration_id = registration.id
WHERE registration.status = 'approved' AND summary.registration_id IS NULL)
""";
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken)) > 0;
}
public async Task SaveFeatureScoresAsync( public async Task SaveFeatureScoresAsync(
IReadOnlyList<FeatureScoreWrite> entries, IReadOnlyList<FeatureScoreWrite> entries,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -72,6 +118,7 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
AccountWorkflowInstance instance, AccountWorkflowInstance instance,
AccountWorkflowAction action, AccountWorkflowAction action,
AdminWorkflowResult? result, AdminWorkflowResult? result,
AdminResultCalculation? calculation,
AdminAuditEntry audit, AdminAuditEntry audit,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -130,10 +177,108 @@ internal sealed class AdminResultRepository(IRelationalConnectionFactory connect
], ],
cancellationToken); cancellationToken);
} }
if (calculation is not null)
{
await ReplaceStatisticsAsync(connection, transaction, calculation, cancellationToken);
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken); await InsertAuditAsync(connection, transaction, audit, cancellationToken);
}, cancellationToken); }, cancellationToken);
} }
private static async Task ReplaceStatisticsAsync(
DbConnection connection,
DbTransaction transaction,
AdminResultCalculation calculation,
CancellationToken cancellationToken)
{
await ExecuteAsync(
connection,
transaction,
"""
DELETE FROM result_statistics
WHERE result_id IN (
SELECT result.id FROM results result
JOIN registrations registration ON registration.id = result.registration_id
WHERE registration.exam_id = @examId
)
""",
[new("@examId", calculation.ExamId)],
cancellationToken);
await ExecuteAsync(
connection,
transaction,
"DELETE FROM registration_result_summaries WHERE exam_id = @examId",
[new("@examId", calculation.ExamId)],
cancellationToken);
foreach (var metric in calculation.Metrics)
{
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO result_statistics (
result_id, subject_rank, cohort_size, rank_percent, grade, qualified,
pass_score, cutoff_rank, calculated_at
) VALUES (
@resultId, @rank, @cohortSize, @rankPercent, @grade, @qualified,
@passScore, @cutoffRank, @calculatedAt
)
""",
[
new("@resultId", metric.ResultId),
new("@rank", metric.Rank),
new("@cohortSize", metric.CohortSize),
new("@rankPercent", metric.RankPercent),
new("@grade", metric.Grade),
new("@qualified", metric.Qualified is null ? null : metric.Qualified.Value ? 1 : 0),
new("@passScore", metric.PassScore),
new("@cutoffRank", metric.CutoffRank),
new("@calculatedAt", metric.CalculatedAt)
],
cancellationToken);
await ExecuteAsync(
connection,
transaction,
"UPDATE results SET grade = @grade WHERE id = @resultId",
[new("@grade", metric.Grade), new("@resultId", metric.ResultId)],
cancellationToken);
}
foreach (var summary in calculation.Summaries)
{
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO registration_result_summaries (
registration_id, exam_id, published_subject_count, subject_count, complete,
total_score, full_score, score_ratio, total_rank, cohort_size, rank_percent,
qualified, cutoff_rank, calculated_at
) VALUES (
@registrationId, @examId, @publishedSubjectCount, @subjectCount, @complete,
@totalScore, @fullScore, @scoreRatio, @rank, @cohortSize, @rankPercent,
@qualified, @cutoffRank, @calculatedAt
)
""",
[
new("@registrationId", summary.RegistrationId),
new("@examId", summary.ExamId),
new("@publishedSubjectCount", summary.PublishedSubjectCount),
new("@subjectCount", summary.SubjectCount),
new("@complete", summary.Complete ? 1 : 0),
new("@totalScore", summary.TotalScore),
new("@fullScore", summary.FullScore),
new("@scoreRatio", summary.ScoreRatio),
new("@rank", summary.Rank),
new("@cohortSize", summary.CohortSize),
new("@rankPercent", summary.RankPercent),
new("@qualified", summary.Qualified is null ? null : summary.Qualified.Value ? 1 : 0),
new("@cutoffRank", summary.CutoffRank),
new("@calculatedAt", summary.CalculatedAt)
],
cancellationToken);
}
}
private async Task TransactionAsync( private async Task TransactionAsync(
Func<DbConnection, DbTransaction, Task> operation, Func<DbConnection, DbTransaction, Task> operation,
CancellationToken cancellationToken) CancellationToken cancellationToken)
Loaded 3 of 20 files, more files were not shown because too many files have changed in this diff. Show more