518 lines
22 KiB
C#
518 lines
22 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Auth;
|
|
using Jiaowu.Api.Infrastructure.Excel;
|
|
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,
|
|
PersonnelAccountService personnelAccountService) : 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 static readonly string[] TeacherHeaders =
|
|
[
|
|
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
|
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注", "初始密码"
|
|
];
|
|
|
|
private static readonly string[] StudentHeaders =
|
|
[
|
|
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
|
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注", "初始密码"
|
|
];
|
|
|
|
[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)
|
|
? "工号是唯一标识;学院编码必须已存在。"
|
|
: "学号是唯一标识;行政班编码必须已存在。",
|
|
"编号已存在时更新档案,不存在时新增档案。",
|
|
"新增人员或补建账号时必须填写至少 8 位初始密码;已有关联账号时可留空。",
|
|
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
|
|
"整批数据会先校验,任一行有误时均不会写入。"
|
|
]);
|
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
|
$"{RegistryName(kind)}导入模板.xlsx");
|
|
}
|
|
|
|
[HttpGet("{kind}/export")]
|
|
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, null))
|
|
.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.Phone, x.Email, x.Notes, null))
|
|
.ToList();
|
|
}
|
|
|
|
var bytes = ExcelWorkbookHelper.Create(RegistryName(kind), headers, rows);
|
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
|
$"{RegistryName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.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 中没有可导入的数据。");
|
|
|
|
var errors = new List<string>();
|
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
|
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);
|
|
return Ok(result);
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return Conflict(new ProblemDetails
|
|
{
|
|
Title = "导入失败",
|
|
Detail = "存在重复编号或无效关联,未写入任何档案。",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
}
|
|
|
|
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++;
|
|
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, "备注");
|
|
var account = await personnelAccountService.EnsureTeacherAccountAsync(
|
|
entity,
|
|
Optional(row, "初始密码"),
|
|
cancellationToken);
|
|
if (!account.Success)
|
|
errors.Add($"第 {row.RowNumber} 行:{account.Error}");
|
|
}
|
|
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++;
|
|
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.Phone = Optional(row, "联系电话");
|
|
entity.Email = Optional(row, "电子邮箱");
|
|
entity.Notes = Optional(row, "备注");
|
|
var account = await personnelAccountService.EnsureStudentAccountAsync(
|
|
entity,
|
|
Optional(row, "初始密码"),
|
|
cancellationToken);
|
|
if (!account.Success)
|
|
errors.Add($"第 {row.RowNumber} 行:{account.Error}");
|
|
}
|
|
return new(created, updated, rows.Count);
|
|
}
|
|
|
|
private IQueryable<Teacher> ApplyTeacherFilters(
|
|
IQueryable<Teacher> source,
|
|
PersonnelQuery query)
|
|
{
|
|
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.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 => "毕业",
|
|
_ => "退学"
|
|
};
|
|
}
|