成绩提交时按考试一次性计算并存储单科名次、排名百分比、等级、合格状态、总分排名及整场合格状态。
新增 result_statistics、registration_result_summaries 两张统计表,现有数据库首次启动时自动回填。 SQL 查询首先通过 examId 限定范围。 科目成绩只排序一次,并使用 registration/subject 字典组织数据。 汇总接口与成绩明细接口已拆分,明细、成绩录入名单和特征分名单均改为服务端分页。 所有成绩写入口统一限制为:0—满分,且只能是整数或 .5,包括手工录入、批量录入、Excel 导入和复议改分。 前端也增加了相同的即时校验。 修复其他考试卡片显示 undefined 的问题。
This commit is contained in:
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
|
||||
{
|
||||
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)
|
||||
|
||||
Loaded 3 of 20 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user