体测系统
This commit is contained in:
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
Reference in new issue
Block a user