修正其他考试成绩替换流程
This commit is contained in:
@@ -74,7 +74,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (batch is null) return NotFound();
|
if (batch is null) return NotFound();
|
||||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||||
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
return result is null ? Ok(new { updated = request.Results.Count }) : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("batches/{id:guid}/template")]
|
[HttpGet("batches/{id:guid}/template")]
|
||||||
@@ -153,7 +153,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||||
foreach (var item in inputs)
|
foreach (var item in inputs)
|
||||||
{
|
{
|
||||||
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed, item.Notes);
|
||||||
if (error is not null) return ValidationProblem(error);
|
if (error is not null) return ValidationProblem(error);
|
||||||
}
|
}
|
||||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||||
@@ -166,7 +166,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
batch.Status = OtherExamBatchStatus.Draft;
|
batch.Status = OtherExamBatchStatus.Draft;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
batch.Results = inputs.Select(x =>
|
var replacementResults = inputs.Select(x =>
|
||||||
{
|
{
|
||||||
var student = students[x.StudentNumber.Trim()];
|
var student = students[x.StudentNumber.Trim()];
|
||||||
return new OtherExamResult
|
return new OtherExamResult
|
||||||
@@ -180,6 +180,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
Notes = Normalize(x.Notes)
|
Notes = Normalize(x.Notes)
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
db.OtherExamResults.AddRange(replacementResults);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
return null;
|
return null;
|
||||||
@@ -209,13 +210,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass, string? notes)
|
||||||
|
{
|
||||||
|
if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。";
|
||||||
|
return b.MetricKind switch
|
||||||
{
|
{
|
||||||
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
||||||
|
OtherExamMetricKind.Score when decimal.Round(score.Value, 2) != score.Value => "分数最多保留两位小数。",
|
||||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||||
|
OtherExamMetricKind.Level when Normalize(level)!.Length > 50 => "等级不能超过 50 个字符。",
|
||||||
|
OtherExamMetricKind.Level when !(b.LevelOptions ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Contains(Normalize(level)!, StringComparer.Ordinal) => "等级必须从该考试场次配置的等级选项中选择。",
|
||||||
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
}
|
||||||
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
||||||
{
|
{
|
||||||
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class OtherExamsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" };
|
||||||
|
var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 };
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "202600001",
|
||||||
|
Name = "测试学生",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1),
|
||||||
|
Status = StudentStatus.Active
|
||||||
|
};
|
||||||
|
var batch = new OtherExamBatch
|
||||||
|
{
|
||||||
|
ExamCode = "CET4",
|
||||||
|
Name = "大学英语四级",
|
||||||
|
ExamDate = new DateOnly(2026, 6, 1),
|
||||||
|
MetricKind = OtherExamMetricKind.Score,
|
||||||
|
MaxScore = 710
|
||||||
|
};
|
||||||
|
var oldResult = new OtherExamResult
|
||||||
|
{
|
||||||
|
OtherExamBatchId = batch.Id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
Score = 500,
|
||||||
|
AttemptNumber = 1
|
||||||
|
};
|
||||||
|
db.AddRange(college, major, administrativeClass, student, batch, oldResult);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new OtherExamsController(db, new TestDataScope(college.Id));
|
||||||
|
var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([
|
||||||
|
new OtherExamResultRequest(student.StudentNumber, 610.5m, null, null, "已复核")
|
||||||
|
]), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var saved = await db.OtherExamResults.SingleAsync(x => x.OtherExamBatchId == batch.Id);
|
||||||
|
Assert.Equal(610.5m, saved.Score);
|
||||||
|
Assert.Equal("已复核", saved.Notes);
|
||||||
|
Assert.Equal(1, saved.AttemptNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplaceResults_RejectsOverlongNotesBeforeDatabaseSave()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" };
|
||||||
|
var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 };
|
||||||
|
var student = new Student { StudentNumber = "202600001", Name = "测试学生", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1), Status = StudentStatus.Active };
|
||||||
|
var batch = new OtherExamBatch { ExamCode = "CET4", Name = "大学英语四级", ExamDate = new DateOnly(2026, 6, 1), MetricKind = OtherExamMetricKind.Score, MaxScore = 710 };
|
||||||
|
db.AddRange(college, major, administrativeClass, student, batch);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new OtherExamsController(db, new TestDataScope(college.Id));
|
||||||
|
var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([
|
||||||
|
new OtherExamResultRequest(student.StudentNumber, 610, null, null, new string('注', 501))
|
||||||
|
]), CancellationToken.None);
|
||||||
|
|
||||||
|
var validation = Assert.IsType<ObjectResult>(result);
|
||||||
|
Assert.Contains("备注不能超过 500 个字符", Assert.IsType<ValidationProblemDetails>(validation.Value).Detail);
|
||||||
|
Assert.Empty(await db.OtherExamResults.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
collegeId,
|
||||||
|
DataScope.College,
|
||||||
|
new HashSet<string>([SystemRoles.CollegeAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user