体测系统

This commit is contained in:
biss committed 2026-09-29 10:24:14 +08:00
1 parent 33b875b21a
commit c74c1e3914
19 files changed
+10507 -7

No files matched your search

@@ -146,7 +146,7 @@ public sealed class AcademicCalendarController(AppDbContext db, HolidayCnClient
return null; return null;
} }
private static readonly HashSet<string> KnownRoles = [SystemRoles.SuperAdmin, SystemRoles.AcademicAdmin, SystemRoles.CollegeAdmin, SystemRoles.Leader, SystemRoles.Counselor, SystemRoles.Teacher, SystemRoles.Student, SystemRoles.MaintenanceAdmin, SystemRoles.MaintenanceStaff]; private static readonly HashSet<string> KnownRoles = [SystemRoles.SuperAdmin, SystemRoles.AcademicAdmin, SystemRoles.CollegeAdmin, SystemRoles.Leader, SystemRoles.Counselor, SystemRoles.Teacher, SystemRoles.Student, SystemRoles.MaintenanceAdmin, SystemRoles.MaintenanceStaff, SystemRoles.PhysicalEducationAdmin];
private static bool IsForRoles(string? audienceRoles, HashSet<string> roles) => string.IsNullOrWhiteSpace(audienceRoles) || audienceRoles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Any(roles.Contains); private static bool IsForRoles(string? audienceRoles, HashSet<string> roles) => string.IsNullOrWhiteSpace(audienceRoles) || audienceRoles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Any(roles.Contains);
private static string? SerializeRoles(IReadOnlyCollection<string> roles) => roles.Count == 0 ? null : string.Join(',', roles.Distinct().Order()); private static string? SerializeRoles(IReadOnlyCollection<string> roles) => roles.Count == 0 ? null : string.Join(',', roles.Distinct().Order());
private static object ToResponse(AcademicCalendarEvent item) => new { item.Id, item.AcademicTermId, AcademicTermName = item.AcademicTerm?.Name, item.Title, item.Kind, item.StartsOn, item.EndsOn, item.ReminderDays, AudienceRoles = string.IsNullOrWhiteSpace(item.AudienceRoles) ? Array.Empty<string>() : item.AudienceRoles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), item.Description, item.IsEnabled, item.Source, item.SourceYear, item.IsOffDay }; private static object ToResponse(AcademicCalendarEvent item) => new { item.Id, item.AcademicTermId, AcademicTermName = item.AcademicTerm?.Name, item.Title, item.Kind, item.StartsOn, item.EndsOn, item.ReminderDays, AudienceRoles = string.IsNullOrWhiteSpace(item.AudienceRoles) ? Array.Empty<string>() : item.AudienceRoles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), item.Description, item.IsEnabled, item.Source, item.SourceYear, item.IsOffDay };
@@ -0,0 +1,472 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.RegularExpressions;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Academic;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Files;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/physical-fitness")]
public sealed class PhysicalFitnessController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.PhysicalEducationAdmin;
private const string Viewers = Managers + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor;
private static readonly string[] ImportHeaders =
[
"学号", "状态", "年级", "身高(cm)", "体重(kg)", "肺活量(ml)", "50米跑(秒)",
"坐位体前屈(cm)", "立定跳远(cm)", "引体向上/仰卧起坐(次)", "1000米/800米跑(秒)", "特殊情况原因", "备注"
];
[HttpGet("batches")]
[Authorize(Roles = Viewers)]
public async Task<ActionResult> GetBatches(
string? keyword = null,
PhysicalTestBatchStatus? status = null,
string? cursor = null,
int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 10, 100);
if (!CursorToken.TryDecode<PhysicalTestBatchCursor>(cursor, out var after))
return BadRequest("无效的游标。");
var source = db.PhysicalTestBatches.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x => x.Name.Contains(keyword) || x.AcademicYear.Contains(keyword));
}
if (status.HasValue) source = source.Where(x => x.Status == status.Value);
var total = await source.CountAsync(ct);
if (after is not null)
{
source = source.Where(x => x.TestDate < after.TestDate ||
x.TestDate == after.TestDate && x.Id.CompareTo(after.Id) < 0);
}
var accessibleStudents = ApplyStudentScope(db.Students.AsNoTracking());
var rows = await source.OrderByDescending(x => x.TestDate).ThenByDescending(x => x.Id)
.Take(pageSize + 1)
.Select(x => new
{
x.Id, x.Name, x.AcademicYear, x.TestDate, x.StandardVersion, x.Status,
x.PublicationCount, x.PublishedAt,
RecordCount = x.Records.Count(r => accessibleStudents.Any(s => s.Id == r.StudentId)),
NormalCount = x.Records.Count(r => r.Status == PhysicalTestRecordStatus.Normal && accessibleStudents.Any(s => s.Id == r.StudentId)),
ExemptCount = x.Records.Count(r => r.Status == PhysicalTestRecordStatus.Exempt && accessibleStudents.Any(s => s.Id == r.StudentId))
}).ToListAsync(ct);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).Cast<object>().ToList();
var last = rows.Take(pageSize).LastOrDefault();
return Ok(new CursorPagedResult<object>(items,
hasMore && last is not null ? CursorToken.Encode(new PhysicalTestBatchCursor(last.TestDate, last.Id)) : null,
hasMore, pageSize, total));
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(CreatePhysicalTestBatchRequest request, CancellationToken ct)
{
var name = Normalize(request.Name);
var academicYear = Normalize(request.AcademicYear);
if (name is null || name.Length > 150) return ValidationProblem("批次名称不能为空且不能超过 150 个字符。");
if (!TryParseAcademicYear(academicYear, out _)) return ValidationProblem("学年格式应为 2026-2027,且结束年份应比开始年份大 1。 ");
if (await db.PhysicalTestBatches.AnyAsync(x => x.AcademicYear == academicYear && x.Name == name, ct))
return ConflictProblem("该学年已存在同名体测批次。");
var batch = new PhysicalTestBatch
{
Name = name,
AcademicYear = academicYear!,
TestDate = request.TestDate,
StandardVersion = PhysicalFitnessScoringService.StandardVersion
};
db.PhysicalTestBatches.Add(batch);
await db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(GetRecords), new { id = batch.Id }, new { batch.Id });
}
[HttpGet("batches/{id:guid}/records")]
[Authorize(Roles = Viewers)]
public async Task<ActionResult> GetRecords(
Guid id,
string? keyword = null,
PhysicalTestRecordStatus? status = null,
string? cursor = null,
int pageSize = 30,
CancellationToken ct = default)
{
var batch = await db.PhysicalTestBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
pageSize = Math.Clamp(pageSize, 10, 100);
if (!CursorToken.TryDecode<PhysicalTestRecordCursor>(cursor, out var after)) return BadRequest("无效的游标。");
var students = ApplyStudentScope(db.Students.AsNoTracking());
var source = from record in db.PhysicalTestRecords.AsNoTracking()
join student in students on record.StudentId equals student.Id
where record.PhysicalTestBatchId == id
select new { record, student };
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x => x.student.StudentNumber.Contains(keyword) || x.student.Name.Contains(keyword));
}
if (status.HasValue) source = source.Where(x => x.record.Status == status.Value);
var total = await source.CountAsync(ct);
if (after is not null)
source = source.Where(x => string.Compare(x.student.StudentNumber, after.StudentNumber) > 0 ||
x.student.StudentNumber == after.StudentNumber && x.record.Id.CompareTo(after.Id) > 0);
var rows = await source.OrderBy(x => x.student.StudentNumber).ThenBy(x => x.record.Id)
.Take(pageSize + 1)
.Select(x => new
{
x.record.Id, x.record.StudentId, x.student.StudentNumber, x.student.Name,
Gender = x.student.Gender,
CollegeName = x.student.AdministrativeClass!.Major!.College!.Name,
ClassName = x.student.AdministrativeClass.Name,
x.record.GradeLevel, x.record.Status, x.record.SpecialReason,
x.record.StandardScore, x.record.BonusScore, x.record.TotalScore,
x.record.Grade, x.record.CalculatedAt, x.record.Notes,
Items = x.record.Items.OrderBy(item => item.ItemCode).Select(item => new
{
item.ItemCode, item.Value, item.Unit, item.Score,
item.BonusScore, item.Weight, item.WeightedScore
}).ToList()
}).ToListAsync(ct);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).Cast<object>().ToList();
var last = rows.Take(pageSize).LastOrDefault();
return Ok(new
{
Batch = new { batch.Id, batch.Name, batch.AcademicYear, batch.TestDate, batch.StandardVersion, batch.Status, batch.PublicationCount, batch.PublishedAt },
Page = new CursorPagedResult<object>(items,
hasMore && last is not null ? CursorToken.Encode(new PhysicalTestRecordCursor(last.StudentNumber, last.Id)) : null,
hasMore, pageSize, total)
});
}
[HttpGet("students/lookup")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> LookupStudent([Required] string studentNumber, CancellationToken ct)
{
studentNumber = studentNumber.Trim();
var student = await ApplyStudentScope(db.Students.AsNoTracking())
.Where(x => x.StudentNumber == studentNumber)
.Select(x => new
{
x.Id, x.StudentNumber, x.Name, x.Gender, x.EnrollmentYear,
CollegeName = x.AdministrativeClass!.Major!.College!.Name,
ClassName = x.AdministrativeClass.Name
}).FirstOrDefaultAsync(ct);
return student is null ? NotFound() : Ok(student);
}
[HttpPut("batches/{id:guid}/records")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpsertRecords(Guid id, UpsertPhysicalTestRecordsRequest request, CancellationToken ct)
{
var batch = await db.PhysicalTestBatches.FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
if (request.Records.Count == 0) return ValidationProblem("请至少提交一条体测记录。");
if (request.Records.Count > 2000) return ValidationProblem("单次最多保存 2000 条体测记录。");
var error = await UpsertRecordsAsync(batch, request.Records, ct);
return error ?? Ok(new { updated = request.Records.Count });
}
[HttpDelete("records/{recordId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteRecord(Guid recordId, CancellationToken ct)
{
var record = await db.PhysicalTestRecords.FirstOrDefaultAsync(x => x.Id == recordId, ct);
if (record is null) return NotFound();
var batch = await db.PhysicalTestBatches.FindAsync([record.PhysicalTestBatchId], ct);
if (batch is null) return NotFound();
db.PhysicalTestRecords.Remove(record);
batch.Status = PhysicalTestBatchStatus.Draft;
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("batches/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Template(Guid id, CancellationToken ct)
{
var batch = await db.PhysicalTestBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
var bytes = ExcelWorkbookHelper.Create("体测数据", ImportHeaders, [],
[
"状态可填:正常、缺测、缓测、免测;留空按正常处理。",
"正常记录必须填写全部测试数值;男生力量项目为引体向上、耐力项目为 1000 米,女生分别为 1 分钟仰卧起坐和 800 米。",
"耐力跑统一填写总秒数,例如 3 分 42 秒填写 222。年级留空时按学年与入学年份自动计算。",
$"本批次按照《{batch.StandardVersion}》自动计分。"
]);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Template("体测数据", ".xlsx", batch.AcademicYear));
}
[HttpPost("batches/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
{
var batch = await db.PhysicalTestBatches.FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
IReadOnlyList<ExcelRow> rows;
try { rows = await ExcelWorkbookHelper.ReadAsync(file, ImportHeaders, ct); }
catch (InvalidDataException exception) { return ValidationProblem(exception.Message); }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的体测数据。");
var errors = new List<string>();
var records = new List<PhysicalTestRecordRequest>();
foreach (var row in rows)
{
var status = ParseStatus(row["状态"], row.RowNumber, errors);
var gradeLevel = ParseOptionalInt(row["年级"], "年级", row.RowNumber, errors);
records.Add(new PhysicalTestRecordRequest(
row["学号"], status, gradeLevel,
ParseOptionalDecimal(row["身高(cm)"], "身高", row.RowNumber, errors),
ParseOptionalDecimal(row["体重(kg)"], "体重", row.RowNumber, errors),
ParseOptionalDecimal(row["肺活量(ml)"], "肺活量", row.RowNumber, errors),
ParseOptionalDecimal(row["50米跑(秒)"], "50 米跑", row.RowNumber, errors),
ParseOptionalDecimal(row["坐位体前屈(cm)"], "坐位体前屈", row.RowNumber, errors),
ParseOptionalDecimal(row["立定跳远(cm)"], "立定跳远", row.RowNumber, errors),
ParseOptionalDecimal(row["引体向上/仰卧起坐(次)"], "力量项目", row.RowNumber, errors),
ParseOptionalDecimal(row["1000米/800米跑(秒)"], "耐力跑", row.RowNumber, errors),
Normalize(row["特殊情况原因"]), Normalize(row["备注"])));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var error = await UpsertRecordsAsync(batch, records, ct);
return error ?? Ok(new { updated = records.Count });
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
{
var batch = await db.PhysicalTestBatches.Include(x => x.Records).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound();
if (batch.Records.Count == 0) return ConflictProblem("没有体测记录,不能发布。");
if (batch.Records.Any(x => x.Status == PhysicalTestRecordStatus.Normal && !x.TotalScore.HasValue))
return ConflictProblem("存在尚未完成计分的正常体测记录,不能发布。");
batch.Status = PhysicalTestBatchStatus.Published;
batch.PublicationCount++;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Ok(new { batch.PublicationCount, batch.PublishedAt });
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Mine(CancellationToken ct)
{
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId)
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
var records = await db.PhysicalTestRecords.AsNoTracking()
.Where(x => x.StudentId == studentId && x.PhysicalTestBatch!.Status == PhysicalTestBatchStatus.Published)
.OrderByDescending(x => x.PhysicalTestBatch!.TestDate)
.Select(x => new
{
x.Id, x.PhysicalTestBatchId, BatchName = x.PhysicalTestBatch!.Name,
x.PhysicalTestBatch.AcademicYear, x.PhysicalTestBatch.TestDate,
x.PhysicalTestBatch.StandardVersion, x.Status, x.GradeLevel,
x.SpecialReason, x.StandardScore, x.BonusScore, x.TotalScore, x.Grade, x.Notes,
Items = x.Items.OrderBy(item => item.ItemCode).Select(item => new
{
item.ItemCode, item.Value, item.Unit, item.Score,
item.BonusScore, item.Weight, item.WeightedScore
}).ToList()
}).ToListAsync(ct);
return Ok(new { Records = records });
}
private async Task<ActionResult?> UpsertRecordsAsync(
PhysicalTestBatch batch,
IReadOnlyList<PhysicalTestRecordRequest> inputs,
CancellationToken ct)
{
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
if (numbers.Any(string.IsNullOrWhiteSpace)) return ValidationProblem("学号不能为空。");
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count())
return ValidationProblem("本次提交中学号不能重复。");
var students = await ApplyStudentScope(db.Students)
.Where(x => numbers.Contains(x.StudentNumber))
.ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, ct);
if (students.Count != numbers.Count) return ValidationProblem("存在不存在或无权访问的学号,请检查学生档案。");
if (!TryParseAcademicYear(batch.AcademicYear, out var academicYearStart))
return ConflictProblem("批次学年格式无效,无法计算学生年级。");
var existing = await db.PhysicalTestRecords
.Where(x => x.PhysicalTestBatchId == batch.Id && students.Values.Select(s => s.Id).Contains(x.StudentId))
.ToDictionaryAsync(x => x.StudentId, ct);
var prepared = new List<(PhysicalTestRecordRequest Input, Student Student, int GradeLevel, PhysicalFitnessScoreResult? Score)>();
foreach (var input in inputs)
{
var student = students[input.StudentNumber.Trim()];
var gradeLevel = input.GradeLevel ?? academicYearStart - student.EnrollmentYear + 1;
if (gradeLevel is < 1 or > 4) return ValidationProblem($"学号 {student.StudentNumber} 的年级必须在 1 到 4 之间。");
if (!Enum.IsDefined(input.Status)) return ValidationProblem($"学号 {student.StudentNumber} 的体测状态无效。");
if (Normalize(input.Notes)?.Length > 500 || Normalize(input.SpecialReason)?.Length > 500)
return ValidationProblem($"学号 {student.StudentNumber} 的原因或备注不能超过 500 个字符。");
PhysicalFitnessScoreResult? score = null;
if (input.Status == PhysicalTestRecordStatus.Normal)
{
if (input.HeightCm is null || input.WeightKg is null || input.VitalCapacityMl is null ||
input.Sprint50MSeconds is null || input.SitAndReachCm is null || input.StandingLongJumpCm is null ||
input.StrengthCount is null || input.EnduranceRunSeconds is null)
return ValidationProblem($"学号 {student.StudentNumber} 的正常体测记录必须填写全部测试项目。");
try
{
score = PhysicalFitnessScoringService.Calculate(student.Gender, gradeLevel,
new PhysicalFitnessMeasurements(input.HeightCm.Value, input.WeightKg.Value,
input.VitalCapacityMl.Value, input.Sprint50MSeconds.Value,
input.SitAndReachCm.Value, input.StandingLongJumpCm.Value,
input.StrengthCount.Value, input.EnduranceRunSeconds.Value));
}
catch (ArgumentException exception)
{
return ValidationProblem($"学号 {student.StudentNumber}:{exception.Message}");
}
}
else if (input.Status is PhysicalTestRecordStatus.Deferred or PhysicalTestRecordStatus.Exempt &&
string.IsNullOrWhiteSpace(input.SpecialReason))
{
return ValidationProblem($"学号 {student.StudentNumber} 的缓测或免测记录必须填写原因。");
}
prepared.Add((input, student, gradeLevel, score));
}
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
{
var existingRecordIds = existing.Values.Select(x => x.Id).ToList();
if (existingRecordIds.Count > 0)
{
await db.PhysicalTestItemResults
.Where(x => existingRecordIds.Contains(x.PhysicalTestRecordId))
.ExecuteDeleteAsync(ct);
var trackedOldItems = db.ChangeTracker.Entries<PhysicalTestItemResult>()
.Where(x => existingRecordIds.Contains(x.Entity.PhysicalTestRecordId))
.ToList();
foreach (var record in existing.Values) record.Items.Clear();
foreach (var entry in trackedOldItems) entry.State = EntityState.Detached;
}
foreach (var item in prepared)
{
if (!existing.TryGetValue(item.Student.Id, out var record))
{
record = new PhysicalTestRecord { PhysicalTestBatchId = batch.Id, StudentId = item.Student.Id };
db.PhysicalTestRecords.Add(record);
}
record.GradeLevel = item.GradeLevel;
record.Status = item.Input.Status;
record.SpecialReason = Normalize(item.Input.SpecialReason);
record.Notes = Normalize(item.Input.Notes);
record.StandardScore = item.Score?.StandardScore;
record.BonusScore = item.Score?.BonusScore;
record.TotalScore = item.Score?.TotalScore;
record.Grade = item.Score?.Grade;
record.CalculatedAt = item.Score is null ? null : DateTime.UtcNow;
if (item.Score is not null)
{
foreach (var scoreItem in item.Score.Items)
{
db.PhysicalTestItemResults.Add(new PhysicalTestItemResult
{
PhysicalTestRecordId = record.Id,
ItemCode = scoreItem.ItemCode,
Value = scoreItem.Value,
Unit = scoreItem.Unit,
Score = scoreItem.Score,
BonusScore = scoreItem.BonusScore,
Weight = scoreItem.Weight,
WeightedScore = scoreItem.WeightedScore
});
}
}
}
batch.Status = PhysicalTestBatchStatus.Draft;
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return null;
}, ct);
}
private IQueryable<Student> ApplyStudentScope(IQueryable<Student> source)
{
var current = scope.Current;
if (current.Scope == DataScope.All) return source;
if (current.Scope == DataScope.College)
return source.Where(x => x.AdministrativeClass!.Major!.CollegeId == current.RestrictedCollegeId);
if (current.Scope == DataScope.Class)
return source.Where(x => x.AdministrativeClass!.CounselorUserId == current.UserId);
return source.Where(x => x.UserId == current.UserId);
}
private static bool TryParseAcademicYear(string? value, out int start)
{
start = 0;
var match = Regex.Match(value ?? "", @"^(\d{4})-(\d{4})$");
return match.Success && int.TryParse(match.Groups[1].Value, out start) &&
int.TryParse(match.Groups[2].Value, out var end) && end == start + 1;
}
private static PhysicalTestRecordStatus ParseStatus(string value, int row, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value) || value.Trim() == "正常") return PhysicalTestRecordStatus.Normal;
if (value.Trim() == "缺测") return PhysicalTestRecordStatus.Missing;
if (value.Trim() == "缓测") return PhysicalTestRecordStatus.Deferred;
if (value.Trim() == "免测") return PhysicalTestRecordStatus.Exempt;
errors.Add($"第 {row} 行:状态必须是正常、缺测、缓测或免测。");
return PhysicalTestRecordStatus.Normal;
}
private static decimal? ParseOptionalDecimal(string value, string field, int row, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var result)) return result;
errors.Add($"第 {row} 行:{field}必须填写数字。");
return null;
}
private static int? ParseOptionalInt(string value, string field, int row, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) return result;
errors.Add($"第 {row} 行:{field}必须填写整数。");
return null;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
return ValidationProblem(ModelState);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
}
public sealed record CreatePhysicalTestBatchRequest([Required] string Name, [Required] string AcademicYear, DateOnly TestDate);
public sealed record UpsertPhysicalTestRecordsRequest(List<PhysicalTestRecordRequest> Records);
public sealed record PhysicalTestRecordRequest(
[Required] string StudentNumber,
PhysicalTestRecordStatus Status,
int? GradeLevel,
decimal? HeightCm,
decimal? WeightKg,
decimal? VitalCapacityMl,
decimal? Sprint50MSeconds,
decimal? SitAndReachCm,
decimal? StandingLongJumpCm,
decimal? StrengthCount,
decimal? EnduranceRunSeconds,
string? SpecialReason,
string? Notes);
public sealed record PhysicalTestBatchCursor(DateOnly TestDate, Guid Id);
public sealed record PhysicalTestRecordCursor(string StudentNumber, Guid Id);
@@ -0,0 +1,81 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class PhysicalTestBatch : EntityBase
{
public required string Name { get; set; }
public required string AcademicYear { get; set; }
public DateOnly TestDate { get; set; }
public required string StandardVersion { get; set; }
public PhysicalTestBatchStatus Status { get; set; } = PhysicalTestBatchStatus.Draft;
public int PublicationCount { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<PhysicalTestRecord> Records { get; set; } = [];
}
public sealed class PhysicalTestRecord : EntityBase
{
public Guid PhysicalTestBatchId { get; set; }
public PhysicalTestBatch? PhysicalTestBatch { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int GradeLevel { get; set; }
public PhysicalTestRecordStatus Status { get; set; } = PhysicalTestRecordStatus.Normal;
public string? SpecialReason { get; set; }
public decimal? StandardScore { get; set; }
public decimal? BonusScore { get; set; }
public decimal? TotalScore { get; set; }
public PhysicalTestGrade? Grade { get; set; }
public DateTime? CalculatedAt { get; set; }
public string? Notes { get; set; }
public ICollection<PhysicalTestItemResult> Items { get; set; } = [];
}
public sealed class PhysicalTestItemResult : EntityBase
{
public Guid PhysicalTestRecordId { get; set; }
public PhysicalTestRecord? PhysicalTestRecord { get; set; }
public PhysicalTestItemCode ItemCode { get; set; }
public decimal Value { get; set; }
public required string Unit { get; set; }
public decimal? Score { get; set; }
public decimal BonusScore { get; set; }
public decimal Weight { get; set; }
public decimal? WeightedScore { get; set; }
}
public enum PhysicalTestBatchStatus
{
Draft = 1,
Published = 2
}
public enum PhysicalTestRecordStatus
{
Normal = 1,
Missing = 2,
Deferred = 3,
Exempt = 4
}
public enum PhysicalTestGrade
{
Fail = 1,
Pass = 2,
Good = 3,
Excellent = 4
}
public enum PhysicalTestItemCode
{
Height = 1,
Weight = 2,
Bmi = 3,
VitalCapacity = 4,
Sprint50M = 5,
SitAndReach = 6,
StandingLongJump = 7,
Strength = 8,
EnduranceRun = 9
}
Loaded 3 of 19 files, more files were not shown because too many files have changed in this diff. Show more