成绩提交时按考试一次性计算并存储单科名次、排名百分比、等级、合格状态、总分排名及整场合格状态。
新增 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
|
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
Reference in new issue
Block a user