Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs
T
2026-09-07 19:34:03 +08:00

621 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Academic;
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(Roles = ReadRoles)]
[Route("api/personnel")]
public sealed class PersonnelExcelController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor + "," +
SystemRoles.Teacher + "," +
SystemRoles.Student;
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ExportRoles =
WriteRoles + "," + SystemRoles.Counselor;
private static readonly string[] TeacherHeaders =
[
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注"
];
private static readonly string[] StudentHeaders =
[
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
];
private static readonly string[] StudentCurrentCourseHeaders =
[
"学号", "姓名", "学院", "专业", "行政班", "正在修读课程", "学分", "总学分"
];
[HttpGet("{kind}/template")]
[Authorize(Roles = WriteRoles)]
public IActionResult DownloadTemplate(string kind)
{
if (!TryGetHeaders(kind, out var headers)) return NotFound();
var bytes = ExcelWorkbookHelper.Create(
"档案导入",
headers,
[],
[
"请勿修改第一行列名;每行填写一位人员。",
kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
? "工号是唯一标识;学院编码必须已存在。"
: "学号是唯一标识;行政班编码必须已存在。",
"编号已存在时更新档案,不存在时新增档案。",
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
"整批数据会先校验,任一行有误时均不会写入。"
]);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Template(RegistryName(kind), ".xlsx"));
}
[HttpGet("{kind}/export")]
[Authorize(Roles = ExportRoles)]
public async Task<IActionResult> Export(
string kind,
[FromQuery] PersonnelQuery query,
CancellationToken cancellationToken)
{
if (!TryGetHeaders(kind, out var headers)) return NotFound();
IReadOnlyList<IReadOnlyList<object?>> rows;
if (kind.Equals("teachers", StringComparison.OrdinalIgnoreCase))
{
var source = ApplyTeacherFilters(
ApplyTeacherScope(db.Teachers.AsNoTracking()),
query);
rows = (await source.Include(x => x.College).OrderBy(x => x.TeacherNumber)
.ToListAsync(cancellationToken))
.Select(x => Row(
x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code,
x.Title, TeacherStatusName(x.Status), x.HireDate,
x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes))
.ToList();
}
else
{
var source = ApplyStudentFilters(
ApplyStudentScope(db.Students.AsNoTracking()),
query);
rows = (await source.Include(x => x.AdministrativeClass)
.OrderBy(x => x.StudentNumber).ToListAsync(cancellationToken))
.Select(x => Row(
x.StudentNumber, x.Name, GenderName(x.Gender),
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
StudentStatusName(x.Status), x.DateOfBirth, x.EnglishName,
x.IdCardNumber, x.Nationality, x.Ethnicity, x.PoliticalStatus,
x.NativePlace, x.HouseholdAddress, x.CurrentAddress, x.PostalCode,
x.Phone, x.Email, x.Qq, x.WeChat, x.EmergencyContactName,
x.EmergencyContactRelationship, x.EmergencyContactPhone,
x.SpecialTags, x.SpecialNeeds, x.Biography, x.Notes))
.ToList();
}
var bytes = ExcelWorkbookHelper.Create(RegistryName(kind), headers, rows);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Export(RegistryName(kind), ".xlsx"));
}
[HttpGet("students/current-courses/export")]
[Authorize(Roles = ExportRoles)]
public async Task<IActionResult> ExportStudentCurrentCourses(
[FromQuery] PersonnelQuery query,
[FromQuery] Guid[]? studentIds,
CancellationToken cancellationToken)
{
var source = ApplyStudentFilters(
ApplyStudentScope(db.Students.AsNoTracking()),
query);
var selectedIds = studentIds?.Distinct().ToArray() ?? [];
if (selectedIds.Length > 0)
source = source.WhereIn(selectedIds, x => x.Id);
var students = await source.OrderBy(x => x.StudentNumber)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
x.AdministrativeClassId,
ClassName = x.AdministrativeClass!.Name,
MajorName = x.AdministrativeClass.Major!.Name,
CollegeName = x.AdministrativeClass.Major.College!.Name
})
.ToListAsync(cancellationToken);
var summaries = await StudentCurrentCourseQuery.GetAsync(
db,
students.Select(x => new CurrentCourseStudent(x.Id, x.AdministrativeClassId))
.ToList(),
cancellationToken);
var rows = students.Select(student =>
{
var summary = summaries[student.Id];
return Row(
student.StudentNumber,
student.Name,
student.CollegeName,
student.MajorName,
student.ClassName,
string.Join(";", summary.Courses.Select(course =>
$"{course.CourseCode} {course.CourseName}")),
string.Join(";", summary.Courses.Select(course =>
$"{course.Credits:0.##}")),
summary.TotalCredits);
}).ToList();
var bytes = ExcelWorkbookHelper.Create(
"学生正在修读课程",
StudentCurrentCourseHeaders,
rows);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Export("学生正在修读课程", ".xlsx"));
}
[HttpPost("{kind}/import")]
[Authorize(Roles = WriteRoles)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult<ExcelImportResult>> Import(
string kind,
IFormFile file,
CancellationToken cancellationToken)
{
if (!TryGetHeaders(kind, out var headers)) return NotFound();
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
return await db.ExecuteInRetriableTransactionAsync<
ActionResult<ExcelImportResult>>(
async transaction =>
{
var errors = new List<string>();
try
{
var result = kind.Equals(
"teachers",
StringComparison.OrdinalIgnoreCase)
? await ImportTeachersAsync(rows, errors, cancellationToken)
: await ImportStudentsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Analytics,
cancellationToken);
return Ok(result);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编号或无效关联,未写入任何档案。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
}
private async Task<ExcelImportResult> ImportTeachersAsync(
IReadOnlyList<ExcelRow> rows,
List<string> errors,
CancellationToken cancellationToken)
{
var existing = await db.Teachers.ToDictionaryAsync(x => x.TeacherNumber,
StringComparer.OrdinalIgnoreCase, cancellationToken);
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(x => x.Code,
StringComparer.OrdinalIgnoreCase, cancellationToken);
var created = 0;
var updated = 0;
foreach (var row in rows)
{
var number = Required(row, "工号", errors);
var name = Required(row, "姓名", errors);
var collegeCode = Required(row, "学院编码", errors);
var gender = ParseGender(row, errors);
var status = ParseTeacherStatus(row, errors);
var hireDate = ParseDate(row, "入职日期", false, errors);
var isExternal = ParseTeacherType(row, errors);
if (number is null || name is null || collegeCode is null ||
gender is null || status is null || isExternal is null) continue;
if (!colleges.TryGetValue(collegeCode, out var college))
{
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
continue;
}
if (!CanAccessCollege(college.Id))
{
errors.Add($"第 {row.RowNumber} 行:无权维护学院“{college.Name}”的教师。");
continue;
}
var entity = existing.GetValueOrDefault(number);
if (entity is not null && !CanAccessCollege(entity.CollegeId))
{
errors.Add($"第 {row.RowNumber} 行:工号“{number}”已属于无权维护的学院。");
continue;
}
if (entity is null)
{
entity = new Teacher
{
TeacherNumber = number,
Name = name,
CollegeId = college.Id
};
db.Teachers.Add(entity);
existing[number] = entity;
created++;
}
else updated++;
if (entity.IsArchived)
{
errors.Add($"第 {row.RowNumber} 行:记录已归档,请先恢复后再导入更新。");
continue;
}
entity.Name = name;
entity.Gender = gender.Value;
entity.CollegeId = college.Id;
entity.Title = Optional(row, "职称");
entity.Status = status.Value;
entity.HireDate = hireDate;
entity.IsExternal = isExternal.Value;
entity.Phone = Optional(row, "联系电话");
entity.Email = Optional(row, "电子邮箱");
entity.Notes = Optional(row, "备注");
}
return new(created, updated, rows.Count);
}
private async Task<ExcelImportResult> ImportStudentsAsync(
IReadOnlyList<ExcelRow> rows,
List<string> errors,
CancellationToken cancellationToken)
{
var existing = await db.Students.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase,
cancellationToken);
var classes = await db.AdministrativeClasses.AsNoTracking().Include(x => x.Major)
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase,
cancellationToken);
var created = 0;
var updated = 0;
foreach (var row in rows)
{
var number = Required(row, "学号", errors);
var name = Required(row, "姓名", errors);
var classCode = Required(row, "行政班编码", errors);
var gender = ParseGender(row, errors);
var enrollmentYear = ParseInt(row, "入学年级", 2000, 2200, errors);
var enrollmentDate = ParseDate(row, "入学日期", true, errors);
var status = ParseStudentStatus(row, errors);
var dateOfBirth = ParseDate(row, "出生日期", false, errors);
if (number is null || name is null || classCode is null || gender is null ||
enrollmentYear is null || enrollmentDate is null || status is null) continue;
if (!classes.TryGetValue(classCode, out var targetClass))
{
errors.Add($"第 {row.RowNumber} 行:行政班编码“{classCode}”不存在。");
continue;
}
var collegeId = targetClass.Major!.CollegeId;
if (!CanAccessCollege(collegeId))
{
errors.Add($"第 {row.RowNumber} 行:无权维护行政班“{targetClass.Name}”的学生。");
continue;
}
var entity = existing.GetValueOrDefault(number);
if (entity is not null &&
!CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId))
{
errors.Add($"第 {row.RowNumber} 行:学号“{number}”已属于无权维护的学院。");
continue;
}
if (entity is null)
{
entity = new Student
{
StudentNumber = number,
Name = name,
AdministrativeClassId = targetClass.Id,
EnrollmentDate = enrollmentDate.Value
};
db.Students.Add(entity);
existing[number] = entity;
created++;
}
else updated++;
if (entity.IsArchived)
{
errors.Add($"第 {row.RowNumber} 行:记录已归档,请先恢复后再导入更新。");
continue;
}
entity.Name = name;
entity.Gender = gender.Value;
entity.AdministrativeClassId = targetClass.Id;
entity.EnrollmentYear = enrollmentYear.Value;
entity.EnrollmentDate = enrollmentDate.Value;
entity.Status = status.Value;
entity.DateOfBirth = dateOfBirth;
entity.EnglishName = Optional(row, "英文姓名");
entity.IdCardNumber = Optional(row, "证件号码");
entity.Nationality = Optional(row, "国籍");
entity.Ethnicity = Optional(row, "民族");
entity.PoliticalStatus = Optional(row, "政治面貌");
entity.NativePlace = Optional(row, "籍贯");
entity.HouseholdAddress = Optional(row, "户籍地址");
entity.CurrentAddress = Optional(row, "现居住地址");
entity.PostalCode = Optional(row, "邮政编码");
entity.Phone = Optional(row, "联系电话");
entity.Email = Optional(row, "电子邮箱");
entity.Qq = Optional(row, "QQ");
entity.WeChat = Optional(row, "微信");
entity.EmergencyContactName = Optional(row, "紧急联系人");
entity.EmergencyContactRelationship = Optional(row, "与本人关系");
entity.EmergencyContactPhone = Optional(row, "紧急联系电话");
entity.SpecialTags = Optional(row, "特殊标记");
entity.SpecialNeeds = Optional(row, "特殊情况说明");
entity.Biography = Optional(row, "个人简介");
entity.Notes = Optional(row, "备注");
}
return new(created, updated, rows.Count);
}
private IQueryable<Teacher> ApplyTeacherFilters(
IQueryable<Teacher> source,
PersonnelQuery query)
{
if (query.IsArchived.HasValue) source = source.Where(x => x.IsArchived == query.IsArchived.Value);
if (query.CollegeId.HasValue)
source = source.Where(x => x.CollegeId == query.CollegeId);
if (query.TeacherStatus.HasValue)
source = source.Where(x => x.Status == query.TeacherStatus);
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.Trim();
source = source.Where(x =>
x.TeacherNumber.Contains(keyword) || x.Name.Contains(keyword) ||
(x.Title != null && x.Title.Contains(keyword)));
}
return source;
}
private IQueryable<Student> ApplyStudentFilters(
IQueryable<Student> source,
PersonnelQuery query)
{
if (query.IsArchived.HasValue) source = source.Where(x => x.IsArchived == query.IsArchived.Value);
if (query.CollegeId.HasValue)
source = source.Where(x =>
x.AdministrativeClass!.Major!.CollegeId == query.CollegeId);
if (query.MajorId.HasValue)
source = source.Where(x => x.AdministrativeClass!.MajorId == query.MajorId);
if (query.ClassId.HasValue)
source = source.Where(x => x.AdministrativeClassId == query.ClassId);
if (query.EnrollmentYear.HasValue)
source = source.Where(x => x.EnrollmentYear == query.EnrollmentYear);
if (query.StudentStatus.HasValue)
source = source.Where(x => x.Status == query.StudentStatus);
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.Trim();
source = source.Where(x =>
x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword));
}
return source;
}
private IQueryable<Teacher> ApplyTeacherScope(IQueryable<Teacher> source)
{
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.CollegeId == scope.RestrictedCollegeId);
if (scope.Scope == DataScope.Class)
return source.Where(teacher => db.TeachingTasks.Any(task =>
task.Teachers.Any(item => item.TeacherId == teacher.Id) &&
task.Classes.Any(item =>
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
var userId = scope.UserId;
return scope.IsInRole(SystemRoles.Teacher)
? source.Where(x => x.UserId == userId)
: source.Where(_ => false);
}
private IQueryable<Student> ApplyStudentScope(IQueryable<Student> source)
{
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.AdministrativeClass!.Major!.CollegeId == scope.RestrictedCollegeId);
if (scope.Scope == DataScope.Class)
return source.Where(x => x.AdministrativeClass!.CounselorUserId == scope.UserId);
var userId = scope.UserId;
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
var isStudent = scope.IsInRole(SystemRoles.Student);
return source.Where(student =>
isStudent && student.UserId == userId ||
isTeacher && db.TeachingTasks.Any(task =>
task.Teachers.Any(item => item.Teacher!.UserId == userId) &&
task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)));
}
private bool CanAccessCollege(Guid collegeId) =>
currentUserDataScope.Current.CanAccessCollege(collegeId);
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50))
ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static bool TryGetHeaders(string kind, out string[] headers)
{
if (kind.Equals("teachers", StringComparison.OrdinalIgnoreCase))
{
headers = TeacherHeaders;
return true;
}
if (kind.Equals("students", StringComparison.OrdinalIgnoreCase))
{
headers = StudentHeaders;
return true;
}
headers = [];
return false;
}
private static string? Required(ExcelRow row, string header, List<string> errors)
{
var value = Optional(row, header);
if (value is not null) return value;
errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。");
return null;
}
private static string? Optional(ExcelRow row, string header) =>
string.IsNullOrWhiteSpace(row[header]) ? null : row[header].Trim();
private static int? ParseInt(
ExcelRow row, string header, int minimum, int maximum, List<string> errors)
{
if (int.TryParse(row[header], out var value) && value >= minimum && value <= maximum)
return value;
errors.Add($"第 {row.RowNumber} 行:“{header}”必须是 {minimum} 至 {maximum} 的整数。");
return null;
}
private static DateOnly? ParseDate(
ExcelRow row, string header, bool required, List<string> errors)
{
var value = row[header];
if (string.IsNullOrWhiteSpace(value) && !required) return null;
if (DateOnly.TryParse(value, out var date)) return date;
errors.Add($"第 {row.RowNumber} 行:“{header}”必须是有效日期(如 2026-09-01)。");
return null;
}
private static Gender? ParseGender(ExcelRow row, List<string> errors)
{
var value = row["性别"];
if (string.IsNullOrWhiteSpace(value) || value is "未设置" or "未知" ||
value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) return Gender.Unknown;
if (value == "男" || value.Equals("Male", StringComparison.OrdinalIgnoreCase))
return Gender.Male;
if (value == "女" || value.Equals("Female", StringComparison.OrdinalIgnoreCase))
return Gender.Female;
errors.Add($"第 {row.RowNumber} 行:“性别”请填写男、女或未设置。");
return null;
}
private static TeacherStatus? ParseTeacherStatus(ExcelRow row, List<string> errors)
{
var value = row["任职状态"];
if (value == "在职" || value.Equals("Active", StringComparison.OrdinalIgnoreCase))
return TeacherStatus.Active;
if (value == "离岗" || value.Equals("OnLeave", StringComparison.OrdinalIgnoreCase))
return TeacherStatus.OnLeave;
if (value == "退休" || value.Equals("Retired", StringComparison.OrdinalIgnoreCase))
return TeacherStatus.Retired;
if (value == "离职" || value.Equals("Departed", StringComparison.OrdinalIgnoreCase))
return TeacherStatus.Departed;
errors.Add($"第 {row.RowNumber} 行:“任职状态”请填写在职、离岗、退休或离职。");
return null;
}
private static StudentStatus? ParseStudentStatus(ExcelRow row, List<string> errors)
{
var value = row["学籍状态"];
if (value == "在籍" || value.Equals("Active", StringComparison.OrdinalIgnoreCase))
return StudentStatus.Active;
if (value == "休学" || value.Equals("Suspended", StringComparison.OrdinalIgnoreCase))
return StudentStatus.Suspended;
if (value == "毕业" || value.Equals("Graduated", StringComparison.OrdinalIgnoreCase))
return StudentStatus.Graduated;
if (value == "退学" || value.Equals("Withdrawn", StringComparison.OrdinalIgnoreCase))
return StudentStatus.Withdrawn;
errors.Add($"第 {row.RowNumber} 行:“学籍状态”请填写在籍、休学、毕业或退学。");
return null;
}
private static bool? ParseTeacherType(ExcelRow row, List<string> errors)
{
var value = row["教师类别"];
if (value == "校内" || value == "否" || value == "0") return false;
if (value == "外聘" || value == "是" || value == "1") return true;
errors.Add($"第 {row.RowNumber} 行:“教师类别”请填写校内或外聘。");
return null;
}
private static IReadOnlyList<object?> Row(params object?[] values) => values;
private static string RegistryName(string kind) =>
kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) ? "教师档案" : "学生档案";
private static string GenderName(Gender value) => value switch
{
Gender.Male => "男",
Gender.Female => "女",
_ => "未设置"
};
private static string TeacherStatusName(TeacherStatus value) => value switch
{
TeacherStatus.Active => "在职",
TeacherStatus.OnLeave => "离岗",
TeacherStatus.Retired => "退休",
_ => "离职"
};
private static string StudentStatusName(StudentStatus value) => value switch
{
StudentStatus.Active => "在籍",
StudentStatus.Suspended => "休学",
StudentStatus.Graduated => "毕业",
_ => "退学"
};
}