1578 lines
67 KiB
C#
1578 lines
67 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using System.Data;
|
||
using System.Text.Json;
|
||
using Jiaowu.Api.Domain.Academic;
|
||
using Jiaowu.Api.Domain.Identity;
|
||
using Jiaowu.Api.Domain.System;
|
||
using Jiaowu.Api.Infrastructure.Auth;
|
||
using Jiaowu.Api.Infrastructure.Experiments;
|
||
using Jiaowu.Api.Infrastructure.Persistence;
|
||
using Jiaowu.Api.Infrastructure.Teaching;
|
||
using Jiaowu.Api.Infrastructure.Timetables;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace Jiaowu.Api.Controllers;
|
||
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/experiments")]
|
||
public sealed class ExperimentsController(
|
||
AppDbContext db,
|
||
ICurrentUserDataScope currentUserDataScope,
|
||
ClassroomReservationAvailabilityService classroomAvailability) : ControllerBase
|
||
{
|
||
private const string Managers =
|
||
SystemRoles.SuperAdmin + "," +
|
||
SystemRoles.AcademicAdmin + "," +
|
||
SystemRoles.CollegeAdmin + "," +
|
||
SystemRoles.Teacher;
|
||
|
||
[HttpGet("options")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> GetOptions(
|
||
Guid? academicTermId,
|
||
Guid? offeringCollegeId,
|
||
string? courseKeyword,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||
if (academicTermId.HasValue)
|
||
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
||
if (offeringCollegeId.HasValue)
|
||
tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
|
||
var normalizedCourseKeyword = Normalize(courseKeyword);
|
||
if (normalizedCourseKeyword is not null)
|
||
tasks = tasks.Where(x =>
|
||
x.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||
x.Course.Name.Contains(normalizedCourseKeyword));
|
||
|
||
var periods = db.ScheduleTimeSlots.AsNoTracking()
|
||
.Where(x => x.IsEnabled);
|
||
if (academicTermId.HasValue)
|
||
periods = periods.Where(x => x.AcademicTermId == academicTermId);
|
||
var periodItems = await periods
|
||
.OrderBy(x => x.PeriodNumber)
|
||
.Select(x => new ExperimentPeriodOption(
|
||
x.AcademicTermId,
|
||
x.PeriodNumber,
|
||
x.Name,
|
||
x.StartsAt.ToString("HH:mm"),
|
||
x.EndsAt.ToString("HH:mm")))
|
||
.ToListAsync(cancellationToken);
|
||
if (academicTermId.HasValue && periodItems.Count == 0)
|
||
{
|
||
periodItems = Enumerable.Range(1, 12)
|
||
.Select(period => new ExperimentPeriodOption(
|
||
academicTermId.Value,
|
||
period,
|
||
$"第 {period} 节",
|
||
"",
|
||
""))
|
||
.ToList();
|
||
}
|
||
|
||
return Ok(new
|
||
{
|
||
Tasks = await tasks
|
||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||
.ThenBy(x => x.Course!.Code)
|
||
.ThenBy(x => x.TaskNumber)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.TaskNumber,
|
||
x.Name,
|
||
x.AcademicTermId,
|
||
x.CourseId,
|
||
TermName = x.AcademicTerm!.Name,
|
||
TermStartDate = x.AcademicTerm.StartDate,
|
||
TermEndDate = x.AcademicTerm.EndDate,
|
||
CourseCode = x.Course!.Code,
|
||
CourseName = x.Course.Name,
|
||
CollegeName = x.Course.College!.Name,
|
||
TeacherNames = x.Teachers
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.ThenBy(item => item.Teacher!.TeacherNumber)
|
||
.Select(item => item.Teacher!.Name),
|
||
ClassNames = x.Classes
|
||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||
.Select(item => item.AdministrativeClass!.Name)
|
||
})
|
||
.Take(200)
|
||
.ToListAsync(cancellationToken),
|
||
Colleges = await AccessibleTeachingTasks().AsNoTracking()
|
||
.Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
|
||
.Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
|
||
.Distinct()
|
||
.OrderBy(x => x.CollegeName)
|
||
.ToListAsync(cancellationToken),
|
||
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||
x.Kind == ScheduleEntryKind.Experiment &&
|
||
x.ClassroomId.HasValue &&
|
||
AccessibleTeachingTasks().Select(task => task.Id)
|
||
.Contains(x.TeachingTaskId))
|
||
.Where(x => !academicTermId.HasValue ||
|
||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
||
.Where(x => !offeringCollegeId.HasValue ||
|
||
x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
|
||
.Where(x => normalizedCourseKeyword == null ||
|
||
x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||
x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
|
||
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||
.ThenBy(x => x.DayOfWeek)
|
||
.ThenBy(x => x.StartPeriod)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.TeachingTaskId,
|
||
TaskNumber = x.TeachingTask!.TaskNumber,
|
||
CourseCode = x.TeachingTask.Course!.Code,
|
||
CourseName = x.TeachingTask.Course.Name,
|
||
x.DayOfWeek,
|
||
x.StartPeriod,
|
||
x.PeriodCount,
|
||
x.StartWeek,
|
||
x.EndWeek,
|
||
x.WeekPattern,
|
||
ClassNames = x.TeachingTask!.Classes
|
||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||
.Select(item => item.AdministrativeClass!.Name),
|
||
ClassroomName = x.Classroom!.Name,
|
||
BuildingName = x.Classroom.Building!.Name,
|
||
CampusName = x.Classroom.Building.Campus!.Name
|
||
})
|
||
.Take(500)
|
||
.ToListAsync(cancellationToken),
|
||
Classrooms = await db.Classrooms.AsNoTracking()
|
||
.Where(x => x.IsEnabled)
|
||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||
.ThenBy(x => x.Building!.SortOrder)
|
||
.ThenBy(x => x.SortOrder)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.Name,
|
||
BuildingName = x.Building!.Name,
|
||
CampusName = x.Building.Campus!.Name,
|
||
x.Capacity
|
||
,x.TeachingVenueNature
|
||
})
|
||
.ToListAsync(cancellationToken),
|
||
Periods = periodItems
|
||
});
|
||
}
|
||
|
||
[HttpGet("management")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> GetManagementProjects(
|
||
Guid? academicTermId,
|
||
ExperimentArrangementMode? arrangementMode,
|
||
ExperimentProjectStatus? status,
|
||
Guid? offeringCollegeId,
|
||
string? courseKeyword,
|
||
string? classKeyword,
|
||
string? teacherKeyword,
|
||
string? taskKeyword,
|
||
CancellationToken cancellationToken,
|
||
int page = 1,
|
||
int pageSize = 20)
|
||
{
|
||
var source = ScopedProjects().AsNoTracking();
|
||
if (academicTermId.HasValue)
|
||
source = source.Where(x =>
|
||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||
if (arrangementMode.HasValue)
|
||
source = source.Where(x =>
|
||
x.ArrangementMode == arrangementMode);
|
||
if (status.HasValue)
|
||
source = source.Where(x => x.Status == status);
|
||
|
||
var normalizedCourseKeyword = Normalize(courseKeyword);
|
||
var normalizedClassKeyword = Normalize(classKeyword);
|
||
var normalizedTeacherKeyword = Normalize(teacherKeyword);
|
||
var normalizedTaskKeyword = Normalize(taskKeyword);
|
||
var taskSource = AccessibleTeachingTasks().AsNoTracking()
|
||
.Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
|
||
if (academicTermId.HasValue)
|
||
taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
|
||
if (offeringCollegeId.HasValue)
|
||
taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
|
||
if (normalizedCourseKeyword is not null)
|
||
taskSource = taskSource.Where(x =>
|
||
x.Course!.Code.Contains(normalizedCourseKeyword) ||
|
||
x.Course.Name.Contains(normalizedCourseKeyword));
|
||
if (normalizedClassKeyword is not null)
|
||
taskSource = taskSource.Where(x => x.Classes.Any(item =>
|
||
item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
|
||
item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
|
||
if (normalizedTeacherKeyword is not null)
|
||
taskSource = taskSource.Where(x => x.Teachers.Any(item =>
|
||
item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
|
||
item.Teacher.Name.Contains(normalizedTeacherKeyword)));
|
||
if (normalizedTaskKeyword is not null)
|
||
taskSource = taskSource.Where(x =>
|
||
x.TaskNumber.Contains(normalizedTaskKeyword) ||
|
||
x.Name.Contains(normalizedTaskKeyword));
|
||
|
||
page = Math.Max(1, page);
|
||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||
var total = await taskSource.CountAsync(cancellationToken);
|
||
var taskIds = await taskSource
|
||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||
.ThenBy(x => x.Course!.Code)
|
||
.ThenBy(x => x.TaskNumber)
|
||
.Skip((page - 1) * pageSize)
|
||
.Take(pageSize)
|
||
.Select(x => x.Id)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
var items = await source
|
||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
||
.ThenBy(x => x.StartDate)
|
||
.ThenBy(x => x.Code)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.TeachingTaskId,
|
||
x.ScheduleEntryId,
|
||
x.ScheduleWeek,
|
||
x.Code,
|
||
x.Name,
|
||
x.ArrangementMode,
|
||
x.Description,
|
||
x.Requirements,
|
||
x.StartDate,
|
||
x.EndDate,
|
||
x.Status,
|
||
x.PublishedAt,
|
||
x.ClosedAt,
|
||
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||
TaskNumber = x.TeachingTask.TaskNumber,
|
||
CourseCode = x.TeachingTask.Course!.Code,
|
||
CourseName = x.TeachingTask.Course.Name,
|
||
CollegeName = x.TeachingTask.Course.College!.Name,
|
||
TeacherNames = x.TeachingTask.Teachers
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.Select(item => item.Teacher!.Name),
|
||
ClassNames = x.TeachingTask.Classes
|
||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||
.Select(item => item.AdministrativeClass!.Name),
|
||
ScheduleEntry = x.ScheduleEntryId == null ? null : new
|
||
{
|
||
x.ScheduleEntry!.DayOfWeek,
|
||
x.ScheduleEntry.StartPeriod,
|
||
x.ScheduleEntry.PeriodCount,
|
||
x.ScheduleEntry.StartWeek,
|
||
x.ScheduleEntry.EndWeek,
|
||
x.ScheduleEntry.WeekPattern,
|
||
ProjectWeek = x.ScheduleWeek,
|
||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||
},
|
||
Sessions = x.Sessions
|
||
.OrderBy(item => item.SessionDate)
|
||
.ThenBy(item => item.StartPeriod)
|
||
.Select(item => new
|
||
{
|
||
item.Id,
|
||
item.SessionDate,
|
||
item.StartPeriod,
|
||
item.PeriodCount,
|
||
item.Capacity,
|
||
item.ReservedCount,
|
||
item.Notes,
|
||
item.Status,
|
||
item.ClassroomId,
|
||
ClassroomName = item.Classroom!.Name,
|
||
BuildingName = item.Classroom.Building!.Name,
|
||
CampusName = item.Classroom.Building.Campus!.Name
|
||
})
|
||
})
|
||
.ToListAsync(cancellationToken);
|
||
|
||
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||
}
|
||
|
||
[HttpGet("student")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetStudentProjects(
|
||
Guid? academicTermId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var student = await CurrentStudentAsync(cancellationToken);
|
||
if (student is null)
|
||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||
|
||
var taskIds = TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id);
|
||
var source = db.ExperimentProjects.AsNoTracking()
|
||
.Where(x =>
|
||
taskIds.Contains(x.TeachingTaskId) &&
|
||
(x.Status == ExperimentProjectStatus.Published ||
|
||
x.Status == ExperimentProjectStatus.Closed));
|
||
if (academicTermId.HasValue)
|
||
source = source.Where(x =>
|
||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||
|
||
return Ok(await source
|
||
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
||
.ThenBy(x => x.EndDate)
|
||
.ThenBy(x => x.Code)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.Code,
|
||
x.Name,
|
||
x.ArrangementMode,
|
||
x.ScheduleEntryId,
|
||
x.ScheduleWeek,
|
||
x.Description,
|
||
x.Requirements,
|
||
x.StartDate,
|
||
x.EndDate,
|
||
x.Status,
|
||
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||
TaskNumber = x.TeachingTask.TaskNumber,
|
||
CourseCode = x.TeachingTask.Course!.Code,
|
||
CourseName = x.TeachingTask.Course.Name,
|
||
TeacherNames = x.TeachingTask.Teachers
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.Select(item => item.Teacher!.Name),
|
||
ScheduleEntry = x.ScheduleEntryId == null ? null : new
|
||
{
|
||
x.ScheduleEntry!.DayOfWeek,
|
||
x.ScheduleEntry.StartPeriod,
|
||
x.ScheduleEntry.PeriodCount,
|
||
x.ScheduleEntry.StartWeek,
|
||
x.ScheduleEntry.EndWeek,
|
||
x.ScheduleEntry.WeekPattern,
|
||
ProjectWeek = x.ScheduleWeek,
|
||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||
},
|
||
Sessions = x.Sessions
|
||
.Where(item => item.Status == ExperimentSessionStatus.Scheduled)
|
||
.OrderBy(item => item.SessionDate)
|
||
.ThenBy(item => item.StartPeriod)
|
||
.Select(item => new
|
||
{
|
||
item.Id,
|
||
item.SessionDate,
|
||
item.StartPeriod,
|
||
item.PeriodCount,
|
||
item.Capacity,
|
||
item.ReservedCount,
|
||
RemainingCount = item.Capacity - item.ReservedCount,
|
||
item.Notes,
|
||
ClassroomName = item.Classroom!.Name,
|
||
BuildingName = item.Classroom.Building!.Name,
|
||
CampusName = item.Classroom.Building.Campus!.Name
|
||
}),
|
||
MyBooking = x.Bookings
|
||
.Where(item =>
|
||
item.StudentId == student.Id &&
|
||
item.Status == ExperimentBookingStatus.Booked)
|
||
.Select(item => new
|
||
{
|
||
item.Id,
|
||
item.ExperimentSessionId,
|
||
item.BookedAt
|
||
})
|
||
.FirstOrDefault()
|
||
})
|
||
.ToListAsync(cancellationToken));
|
||
}
|
||
|
||
[HttpPost]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> CreateProject(
|
||
ExperimentProjectRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var task = await AccessibleTeachingTasks().AsNoTracking()
|
||
.Include(x => x.AcademicTerm)
|
||
.FirstOrDefaultAsync(x =>
|
||
x.Id == request.TeachingTaskId &&
|
||
x.Status == TeachingTaskStatus.Published,
|
||
cancellationToken);
|
||
if (task is null)
|
||
return ValidationProblem("教学任务不存在、未发布或不在当前管理范围内。");
|
||
|
||
var problem = ValidateProjectRequest(request, task.AcademicTerm!);
|
||
if (problem is not null) return ValidationProblem(problem);
|
||
var scheduleEntry = await ValidateScheduleEntryAsync(
|
||
request, task.Id, cancellationToken);
|
||
if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.Problem);
|
||
|
||
var code = request.Code.Trim();
|
||
if (await db.ExperimentProjects.AnyAsync(x =>
|
||
x.TeachingTaskId == request.TeachingTaskId &&
|
||
x.Code == code,
|
||
cancellationToken))
|
||
return ConflictProblem("该教学任务下已存在相同实验项目编码。");
|
||
|
||
var project = new ExperimentProject
|
||
{
|
||
TeachingTaskId = request.TeachingTaskId,
|
||
ScheduleEntryId = scheduleEntry.Entry?.Id,
|
||
Code = code,
|
||
Name = request.Name.Trim(),
|
||
ArrangementMode = request.ArrangementMode,
|
||
Description = Normalize(request.Description),
|
||
Requirements = Normalize(request.Requirements),
|
||
StartDate = request.StartDate,
|
||
EndDate = request.EndDate
|
||
};
|
||
db.ExperimentProjects.Add(project);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return Created(string.Empty, new { project.Id });
|
||
}
|
||
|
||
[HttpPost("batch")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> CreateProjects(
|
||
ExperimentProjectBatchRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var taskIds = request.TeachingTaskIds
|
||
.Where(x => x != Guid.Empty)
|
||
.Distinct()
|
||
.ToList();
|
||
if (taskIds.Count == 0)
|
||
return ValidationProblem("请至少选择一个教学任务。");
|
||
if (taskIds.Count > 100)
|
||
return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");
|
||
|
||
var tasks = await AccessibleTeachingTasks().AsNoTracking()
|
||
.Include(x => x.AcademicTerm)
|
||
.WhereIn(taskIds, x => x.Id)
|
||
.Where(x => x.Status == TeachingTaskStatus.Published)
|
||
.OrderBy(x => x.TaskNumber)
|
||
.ToListAsync(cancellationToken);
|
||
if (tasks.Count != taskIds.Count)
|
||
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||
|
||
var first = tasks[0];
|
||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
|
||
x.AcademicTermId != first.AcademicTermId ||
|
||
x.CourseId != first.CourseId))
|
||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||
|
||
var code = request.Code.Trim();
|
||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||
{
|
||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||
.Where(x => x.Code == code)
|
||
.Select(x => x.TeachingTask!.TaskNumber)
|
||
.OrderBy(x => x)
|
||
.ToListAsync(cancellationToken);
|
||
if (conflictingTaskNumbers.Count > 0)
|
||
return ConflictProblem(
|
||
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
||
}
|
||
|
||
var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
|
||
? await db.ScheduleEntries.AsNoTracking()
|
||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||
x.Kind == ScheduleEntryKind.Experiment &&
|
||
x.ClassroomId.HasValue &&
|
||
taskIds.Contains(x.TeachingTaskId))
|
||
.OrderBy(x => x.TeachingTaskId)
|
||
.ThenBy(x => x.DayOfWeek)
|
||
.ThenBy(x => x.StartPeriod)
|
||
.ToListAsync(cancellationToken)
|
||
: [];
|
||
if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
|
||
tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
|
||
return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。");
|
||
|
||
var scheduledOccurrences = scheduledEntries
|
||
.SelectMany(entry => Enumerable.Range(
|
||
entry.StartWeek,
|
||
entry.EndWeek - entry.StartWeek + 1)
|
||
.Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
|
||
.Select(week => (Entry: entry, Week: week)))
|
||
.ToList();
|
||
|
||
var legacyProjects = scheduledEntries.Count > 0
|
||
? await db.ExperimentProjects
|
||
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
|
||
x.ScheduleWeek == null &&
|
||
taskIds.Contains(x.TeachingTaskId))
|
||
.ToListAsync(cancellationToken)
|
||
: [];
|
||
if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||
return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。");
|
||
|
||
if (scheduledOccurrences.Count > 0)
|
||
{
|
||
var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
|
||
var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
|
||
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
|
||
x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
|
||
.Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
|
||
.ToListAsync(cancellationToken);
|
||
var existingKeys = existingOccurrences
|
||
.Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
|
||
.ToHashSet();
|
||
if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
|
||
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
|
||
}
|
||
|
||
var projects = new List<ExperimentProject>(tasks.Count);
|
||
foreach (var task in tasks)
|
||
{
|
||
var item = request.ForTeachingTask(task.Id);
|
||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||
if (problem is not null) return ValidationProblem(problem);
|
||
|
||
IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
|
||
request.ArrangementMode == ExperimentArrangementMode.Centralized
|
||
? scheduledOccurrences
|
||
.Where(item => item.Entry.TeachingTaskId == task.Id)
|
||
.Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
|
||
: [(null, null)];
|
||
foreach (var occurrence in taskOccurrences)
|
||
{
|
||
var scheduleEntry = occurrence.Entry;
|
||
var legacy = scheduleEntry is null ? null : legacyProjects
|
||
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
|
||
if (legacy is not null)
|
||
{
|
||
var firstWeek = scheduledOccurrences
|
||
.Where(item => item.Entry.Id == scheduleEntry!.Id)
|
||
.Min(item => item.Week);
|
||
if (occurrence.Week == firstWeek)
|
||
{
|
||
legacy.ScheduleWeek = occurrence.Week;
|
||
continue;
|
||
}
|
||
}
|
||
projects.Add(new ExperimentProject
|
||
{
|
||
TeachingTaskId = task.Id,
|
||
ScheduleEntryId = occurrence.Entry?.Id,
|
||
ScheduleWeek = occurrence.Week,
|
||
Code = code,
|
||
Name = request.Name.Trim(),
|
||
ArrangementMode = request.ArrangementMode,
|
||
Description = Normalize(request.Description),
|
||
Requirements = Normalize(request.Requirements),
|
||
StartDate = request.StartDate,
|
||
EndDate = request.EndDate
|
||
});
|
||
}
|
||
}
|
||
|
||
db.ExperimentProjects.AddRange(projects);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return Created(string.Empty, new
|
||
{
|
||
Count = projects.Count,
|
||
ProjectIds = projects.Select(x => x.Id)
|
||
});
|
||
}
|
||
|
||
[HttpPut("{id:guid}")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> UpdateProject(
|
||
Guid id,
|
||
ExperimentProjectRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var project = await ScopedProjects()
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.AcademicTerm)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (project is null) return NotFound();
|
||
if (project.Status != ExperimentProjectStatus.Draft)
|
||
return ConflictProblem("只有草稿实验项目可以修改。");
|
||
if (project.TeachingTaskId != request.TeachingTaskId)
|
||
return ValidationProblem("创建后不能更换实验项目所属教学任务。");
|
||
|
||
var problem = ValidateProjectRequest(
|
||
request,
|
||
project.TeachingTask!.AcademicTerm!);
|
||
if (problem is not null) return ValidationProblem(problem);
|
||
var scheduleEntry = await ValidateScheduleEntryAsync(
|
||
request, project.TeachingTaskId, cancellationToken);
|
||
if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.Problem);
|
||
|
||
var code = request.Code.Trim();
|
||
if (await db.ExperimentProjects.AnyAsync(x =>
|
||
x.Id != id &&
|
||
x.TeachingTaskId == request.TeachingTaskId &&
|
||
x.Code == code,
|
||
cancellationToken))
|
||
return ConflictProblem("该教学任务下已存在相同实验项目编码。");
|
||
|
||
project.Code = code;
|
||
project.Name = request.Name.Trim();
|
||
project.ArrangementMode = request.ArrangementMode;
|
||
project.ScheduleEntryId = scheduleEntry.Entry?.Id;
|
||
project.Description = Normalize(request.Description);
|
||
project.Requirements = Normalize(request.Requirements);
|
||
project.StartDate = request.StartDate;
|
||
project.EndDate = request.EndDate;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpDelete("{id:guid}")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> DeleteProject(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var project = await ScopedProjects()
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (project is null) return NotFound();
|
||
if (project.Status != ExperimentProjectStatus.Draft)
|
||
return ConflictProblem("只有草稿实验项目可以删除。");
|
||
|
||
db.ExperimentProjects.Remove(project);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpDelete("batch")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> DeleteProjects(
|
||
ExperimentProjectBulkRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||
var projects = await ScopedProjects()
|
||
.Where(x => ids.Contains(x.Id))
|
||
.ToListAsync(cancellationToken);
|
||
if (projects.Count != ids.Count) return NotFound();
|
||
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
|
||
return ConflictProblem("批量删除只能包含草稿实验项目。");
|
||
|
||
db.ExperimentProjects.RemoveRange(projects);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("{id:guid}/publish")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> PublishProject(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var project = await ScopedProjects()
|
||
.Include(x => x.Sessions)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Course)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (project is null) return NotFound();
|
||
if (project.Status != ExperimentProjectStatus.Draft)
|
||
return ConflictProblem("只有草稿实验项目可以发布。");
|
||
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x =>
|
||
x.Status == ExperimentSessionStatus.Scheduled)
|
||
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
|
||
if (!hasSchedule)
|
||
return ConflictProblem(project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||
? "请先绑定已发布课表中的实验课后再发布。"
|
||
: "请至少安排一个有效实验场次后再发布。");
|
||
if (project.Sessions.Any(x =>
|
||
x.Status == ExperimentSessionStatus.Scheduled &&
|
||
(x.SessionDate < project.StartDate ||
|
||
x.SessionDate > project.EndDate)))
|
||
return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
|
||
|
||
project.Status = ExperimentProjectStatus.Published;
|
||
project.PublishedAt = DateTime.UtcNow;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
await NotifyProjectPublishedAsync(project, cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("batch/publish")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> PublishProjects(
|
||
ExperimentProjectBulkRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||
if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
|
||
return NotFound();
|
||
var userId = currentUserDataScope.Current.UserId;
|
||
var job = new ExamPublishJob
|
||
{
|
||
Kind = ExamPublishJobKind.ExperimentProjects,
|
||
PlanId = ids[0],
|
||
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||
ProjectIdsJson = JsonSerializer.Serialize(ids),
|
||
CurrentStep = "等待后台校验"
|
||
};
|
||
db.ExamPublishJobs.Add(job);
|
||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||
BackgroundJobKind.ExamPublish, job.Id));
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
|
||
}
|
||
|
||
[HttpGet("batch/publish-jobs/{jobId:guid}")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> GetPublishJob(Guid jobId, CancellationToken cancellationToken)
|
||
{
|
||
var job = await db.ExamPublishJobs.AsNoTracking().FirstOrDefaultAsync(x =>
|
||
x.Id == jobId && x.Kind == ExamPublishJobKind.ExperimentProjects,
|
||
cancellationToken);
|
||
if (job is null) return NotFound();
|
||
return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt });
|
||
}
|
||
|
||
[HttpPost("{id:guid}/close")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> CloseProject(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var project = await ScopedProjects()
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (project is null) return NotFound();
|
||
if (project.Status != ExperimentProjectStatus.Published)
|
||
return ConflictProblem("只有已发布实验项目可以关闭。");
|
||
project.Status = ExperimentProjectStatus.Closed;
|
||
project.ClosedAt = DateTime.UtcNow;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("{projectId:guid}/sessions")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> CreateSession(
|
||
Guid projectId,
|
||
ExperimentSessionRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var project = await ScopedProjects()
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.AcademicTerm)
|
||
.FirstOrDefaultAsync(x => x.Id == projectId, cancellationToken);
|
||
if (project is null) return NotFound();
|
||
if (project.Status == ExperimentProjectStatus.Closed)
|
||
return ConflictProblem("已关闭实验项目不能再增加场次。");
|
||
if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
|
||
return ConflictProblem("集中安排的实验项目直接使用已发布课表中的实验课,不能在此重复排时派地点。");
|
||
|
||
var problem = await ValidateSessionAsync(
|
||
project,
|
||
request,
|
||
cancellationToken);
|
||
if (problem is not null) return ConflictProblem(problem);
|
||
|
||
var session = new ExperimentSession
|
||
{
|
||
ExperimentProjectId = project.Id,
|
||
ClassroomId = request.ClassroomId,
|
||
SessionDate = request.SessionDate,
|
||
StartPeriod = request.StartPeriod,
|
||
PeriodCount = request.PeriodCount,
|
||
Capacity = await ResolveSessionCapacityAsync(
|
||
project,
|
||
request.Capacity,
|
||
cancellationToken),
|
||
Notes = Normalize(request.Notes)
|
||
};
|
||
db.ExperimentSessions.Add(session);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
|
||
if (project.Status == ExperimentProjectStatus.Published)
|
||
{
|
||
var userIds = await RosterUserIdsAsync(
|
||
project.TeachingTaskId,
|
||
cancellationToken);
|
||
if (userIds.Count > 0)
|
||
{
|
||
await NotificationService.SendToUserIdsAsync(
|
||
db,
|
||
userIds,
|
||
"新增实验场次",
|
||
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
|
||
"/experiments",
|
||
cancellationToken,
|
||
NotificationCategory.Schedule);
|
||
}
|
||
}
|
||
return Created(string.Empty, new { session.Id });
|
||
}
|
||
|
||
[HttpPost("sessions/batch")]
|
||
[Authorize(Roles = Managers)]
|
||
public Task<ActionResult> CreateSessions(
|
||
ExperimentSessionBatchRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (request.Items.Count == 0)
|
||
return Task.FromResult<ActionResult>(
|
||
ValidationProblem("请至少添加一条实验排课。"));
|
||
if (request.Items.Count > 100)
|
||
return Task.FromResult<ActionResult>(
|
||
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
||
request.Items.Count)
|
||
return Task.FromResult<ActionResult>(
|
||
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
||
|
||
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||
async transaction =>
|
||
{
|
||
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
|
||
var projects = await ScopedProjects()
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.AcademicTerm)
|
||
.WhereIn(projectIds, x => x.Id)
|
||
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
||
if (projects.Count != projectIds.Count)
|
||
return ValidationProblem(
|
||
"部分实验项目不存在或不在当前管理范围内。");
|
||
|
||
var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
|
||
request.Items.Count);
|
||
foreach (var item in request.Items)
|
||
{
|
||
var project = projects[item.ProjectId];
|
||
if (project.Status == ExperimentProjectStatus.Closed)
|
||
return ConflictProblem(
|
||
$"实验项目“{project.Name}”已关闭,不能再增加场次。");
|
||
if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
|
||
return ConflictProblem(
|
||
$"实验项目“{project.Name}”为集中安排,请直接使用已发布课表中的实验课。");
|
||
|
||
var sessionRequest = item.ToSessionRequest();
|
||
var problem = await ValidateSessionAsync(
|
||
project,
|
||
sessionRequest,
|
||
cancellationToken);
|
||
if (problem is not null)
|
||
return ConflictProblem(
|
||
$"实验项目“{project.Name}”:{problem}");
|
||
|
||
var session = new ExperimentSession
|
||
{
|
||
ExperimentProjectId = project.Id,
|
||
ClassroomId = item.ClassroomId,
|
||
SessionDate = item.SessionDate,
|
||
StartPeriod = item.StartPeriod,
|
||
PeriodCount = item.PeriodCount,
|
||
Capacity = await ResolveSessionCapacityAsync(
|
||
project,
|
||
item.Capacity,
|
||
cancellationToken),
|
||
Notes = Normalize(item.Notes)
|
||
};
|
||
db.ExperimentSessions.Add(session);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
createdSessions.Add((project, session));
|
||
}
|
||
|
||
foreach (var (project, session) in createdSessions.Where(x =>
|
||
x.Project.Status == ExperimentProjectStatus.Published))
|
||
{
|
||
var userIds = await RosterUserIdsAsync(
|
||
project.TeachingTaskId,
|
||
cancellationToken);
|
||
if (userIds.Count == 0) continue;
|
||
await NotificationService.SendToUserIdsAsync(
|
||
db,
|
||
userIds,
|
||
"新增实验场次",
|
||
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
|
||
"/experiments",
|
||
cancellationToken,
|
||
NotificationCategory.Schedule);
|
||
}
|
||
|
||
await transaction.CommitAsync(cancellationToken);
|
||
return Created(string.Empty, new
|
||
{
|
||
Count = createdSessions.Count,
|
||
SessionIds = createdSessions.Select(x => x.Session.Id)
|
||
});
|
||
},
|
||
cancellationToken,
|
||
IsolationLevel.Serializable);
|
||
}
|
||
|
||
[HttpDelete("sessions/{id:guid}")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> CancelSession(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var session = await db.ExperimentSessions
|
||
.Include(x => x.ExperimentProject)
|
||
.ThenInclude(x => x!.TeachingTask)
|
||
.ThenInclude(x => x!.Course)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (session is null ||
|
||
!await ScopedProjects().AnyAsync(
|
||
x => x.Id == session.ExperimentProjectId,
|
||
cancellationToken))
|
||
return NotFound();
|
||
if (session.Status == ExperimentSessionStatus.Cancelled)
|
||
return NoContent();
|
||
|
||
if (session.ExperimentProject!.Status == ExperimentProjectStatus.Draft)
|
||
{
|
||
db.ExperimentSessions.Remove(session);
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
var affectedUserIds =
|
||
session.ExperimentProject.ArrangementMode ==
|
||
ExperimentArrangementMode.Centralized
|
||
? await RosterUserIdsAsync(
|
||
session.ExperimentProject.TeachingTaskId,
|
||
cancellationToken)
|
||
: await db.ExperimentBookings
|
||
.Where(x =>
|
||
x.ExperimentSessionId == id &&
|
||
x.Status == ExperimentBookingStatus.Booked &&
|
||
x.Student!.UserId.HasValue)
|
||
.Select(x => x.Student!.UserId!.Value)
|
||
.Distinct()
|
||
.ToListAsync(cancellationToken);
|
||
var bookings = await db.ExperimentBookings
|
||
.Where(x =>
|
||
x.ExperimentSessionId == id &&
|
||
x.Status == ExperimentBookingStatus.Booked)
|
||
.ToListAsync(cancellationToken);
|
||
foreach (var booking in bookings)
|
||
{
|
||
booking.Status = ExperimentBookingStatus.Cancelled;
|
||
booking.CancelledAt = DateTime.UtcNow;
|
||
}
|
||
session.Status = ExperimentSessionStatus.Cancelled;
|
||
session.CancelledAt = DateTime.UtcNow;
|
||
session.ReservedCount = 0;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
|
||
if (affectedUserIds.Count > 0)
|
||
{
|
||
await NotificationService.SendToUserIdsAsync(
|
||
db,
|
||
affectedUserIds,
|
||
"实验场次已取消",
|
||
$"“{session.ExperimentProject.Name}”原定于 {session.SessionDate:yyyy-MM-dd} 的实验场次已取消,请重新查看安排。",
|
||
"/experiments",
|
||
cancellationToken,
|
||
NotificationCategory.Schedule);
|
||
}
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpGet("sessions/{id:guid}/participants")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> GetParticipants(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var session = await db.ExperimentSessions.AsNoTracking()
|
||
.Include(x => x.ExperimentProject)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (session is null ||
|
||
!await ScopedProjects().AnyAsync(
|
||
x => x.Id == session.ExperimentProjectId,
|
||
cancellationToken))
|
||
return NotFound();
|
||
|
||
if (session.ExperimentProject!.ArrangementMode ==
|
||
ExperimentArrangementMode.Centralized)
|
||
{
|
||
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||
db,
|
||
[session.ExperimentProject.TeachingTaskId],
|
||
cancellationToken);
|
||
return Ok(roster.Select(x => new
|
||
{
|
||
x.StudentId,
|
||
x.StudentNumber,
|
||
x.Name,
|
||
x.ClassName,
|
||
ParticipationType = "Centralized"
|
||
}));
|
||
}
|
||
|
||
return Ok(await db.ExperimentBookings.AsNoTracking()
|
||
.Where(x =>
|
||
x.ExperimentSessionId == id &&
|
||
x.Status == ExperimentBookingStatus.Booked)
|
||
.OrderBy(x => x.Student!.StudentNumber)
|
||
.Select(x => new
|
||
{
|
||
x.StudentId,
|
||
x.Student!.StudentNumber,
|
||
x.Student.Name,
|
||
ClassName = x.Student.AdministrativeClass!.Name,
|
||
ParticipationType = "Booked",
|
||
x.BookedAt
|
||
})
|
||
.ToListAsync(cancellationToken));
|
||
}
|
||
|
||
[HttpPost("sessions/{id:guid}/book")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> Book(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||
async transaction =>
|
||
{
|
||
db.ChangeTracker.Clear();
|
||
var student = await CurrentStudentAsync(cancellationToken);
|
||
if (student is null)
|
||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||
|
||
var session = await db.ExperimentSessions
|
||
.Include(x => x.ExperimentProject)
|
||
.ThenInclude(x => x!.TeachingTask)
|
||
.ThenInclude(x => x!.AcademicTerm)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (session is null ||
|
||
session.Status != ExperimentSessionStatus.Scheduled ||
|
||
session.ExperimentProject!.Status !=
|
||
ExperimentProjectStatus.Published)
|
||
return NotFound();
|
||
if (session.ExperimentProject.ArrangementMode !=
|
||
ExperimentArrangementMode.SelfScheduled)
|
||
return ConflictProblem("集中安排实验无需学生预约。");
|
||
if (!await TeachingTaskRosterQuery
|
||
.TaskIdsForStudent(db, student.Id)
|
||
.ContainsAsync(
|
||
session.ExperimentProject.TeachingTaskId,
|
||
cancellationToken))
|
||
return Forbid();
|
||
if (session.SessionDate <
|
||
DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)))
|
||
return ConflictProblem("该实验场次已经结束,不能预约。");
|
||
|
||
var existing = await db.ExperimentBookings
|
||
.FirstOrDefaultAsync(x =>
|
||
x.ExperimentProjectId ==
|
||
session.ExperimentProjectId &&
|
||
x.StudentId == student.Id,
|
||
cancellationToken);
|
||
if (existing?.Status == ExperimentBookingStatus.Booked)
|
||
{
|
||
return existing.ExperimentSessionId == session.Id
|
||
? NoContent()
|
||
: ConflictProblem("该实验项目已有预约,请先取消原预约。");
|
||
}
|
||
|
||
var scheduleProblem = await StudentConflictAsync(
|
||
student.Id,
|
||
session,
|
||
cancellationToken);
|
||
if (scheduleProblem is not null)
|
||
return ConflictProblem(scheduleProblem);
|
||
|
||
var reservedCount = await db.ExperimentBookings
|
||
.CountAsync(x =>
|
||
x.ExperimentSessionId == session.Id &&
|
||
x.Status == ExperimentBookingStatus.Booked,
|
||
cancellationToken);
|
||
if (reservedCount >= session.Capacity)
|
||
return ConflictProblem("该实验场次名额已满,请选择其他时间。");
|
||
|
||
if (existing is null)
|
||
{
|
||
db.ExperimentBookings.Add(new ExperimentBooking
|
||
{
|
||
ExperimentProjectId = session.ExperimentProjectId,
|
||
ExperimentSessionId = session.Id,
|
||
StudentId = student.Id
|
||
});
|
||
}
|
||
else
|
||
{
|
||
existing.ExperimentSessionId = session.Id;
|
||
existing.Status = ExperimentBookingStatus.Booked;
|
||
existing.BookedAt = DateTime.UtcNow;
|
||
existing.CancelledAt = null;
|
||
}
|
||
session.ReservedCount = reservedCount + 1;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
await transaction.CommitAsync(cancellationToken);
|
||
return NoContent();
|
||
},
|
||
cancellationToken,
|
||
IsolationLevel.Serializable);
|
||
}
|
||
|
||
[HttpDelete("bookings/{id:guid}")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> CancelBooking(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||
async transaction =>
|
||
{
|
||
db.ChangeTracker.Clear();
|
||
var student = await CurrentStudentAsync(cancellationToken);
|
||
if (student is null)
|
||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||
|
||
var booking = await db.ExperimentBookings
|
||
.Include(x => x.ExperimentSession)
|
||
.FirstOrDefaultAsync(x =>
|
||
x.Id == id &&
|
||
x.StudentId == student.Id,
|
||
cancellationToken);
|
||
if (booking is null) return NotFound();
|
||
if (booking.Status == ExperimentBookingStatus.Cancelled)
|
||
return NoContent();
|
||
|
||
booking.Status = ExperimentBookingStatus.Cancelled;
|
||
booking.CancelledAt = DateTime.UtcNow;
|
||
booking.ExperimentSession!.ReservedCount = Math.Max(
|
||
0,
|
||
await db.ExperimentBookings.CountAsync(x =>
|
||
x.ExperimentSessionId ==
|
||
booking.ExperimentSessionId &&
|
||
x.Status == ExperimentBookingStatus.Booked &&
|
||
x.Id != booking.Id,
|
||
cancellationToken));
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
await transaction.CommitAsync(cancellationToken);
|
||
return NoContent();
|
||
},
|
||
cancellationToken,
|
||
IsolationLevel.Serializable);
|
||
}
|
||
|
||
private async Task<string?> ValidateSessionAsync(
|
||
ExperimentProject project,
|
||
ExperimentSessionRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (request.SessionDate < project.StartDate ||
|
||
request.SessionDate > project.EndDate)
|
||
return "实验场次日期必须在项目开放日期范围内。";
|
||
|
||
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
|
||
.CountAsync(x =>
|
||
x.AcademicTermId ==
|
||
project.TeachingTask!.AcademicTermId &&
|
||
x.IsEnabled,
|
||
cancellationToken);
|
||
var validPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
|
||
.CountAsync(x =>
|
||
x.AcademicTermId ==
|
||
project.TeachingTask!.AcademicTermId &&
|
||
x.IsEnabled &&
|
||
x.PeriodNumber >= request.StartPeriod &&
|
||
x.PeriodNumber <
|
||
request.StartPeriod + request.PeriodCount,
|
||
cancellationToken);
|
||
var usesValidDefaultPeriods =
|
||
configuredPeriodCount == 0 &&
|
||
request.StartPeriod + request.PeriodCount - 1 <= 12;
|
||
if (!usesValidDefaultPeriods &&
|
||
validPeriodCount != request.PeriodCount)
|
||
return "所选实验节次不在该学期启用节次范围内。";
|
||
|
||
var classroom = await db.Classrooms.AsNoTracking()
|
||
.FirstOrDefaultAsync(x =>
|
||
x.Id == request.ClassroomId && x.IsEnabled,
|
||
cancellationToken);
|
||
if (classroom is null) return "实验教室不存在或已停用。";
|
||
if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
||
return "所选场地未标注实验教学性质。";
|
||
if (request.Capacity > classroom.Capacity)
|
||
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
|
||
if (project.ArrangementMode ==
|
||
ExperimentArrangementMode.Centralized)
|
||
{
|
||
var rosterCount = await TeachingTaskRosterQuery
|
||
.ForTask(db, project.TeachingTaskId)
|
||
.CountAsync(cancellationToken);
|
||
if (rosterCount > classroom.Capacity)
|
||
return $"集中实验应到 {rosterCount} 人,超过教室容量 {classroom.Capacity} 人。";
|
||
}
|
||
|
||
var occupiedRooms =
|
||
await classroomAvailability.GetOccupiedClassroomIdsAsync(
|
||
project.TeachingTask!.AcademicTerm!,
|
||
request.SessionDate,
|
||
request.StartPeriod,
|
||
request.PeriodCount,
|
||
null,
|
||
cancellationToken);
|
||
if (occupiedRooms.Contains(request.ClassroomId))
|
||
return "所选教室与已发布课程、考试或已批准借用安排冲突。";
|
||
|
||
var experimentRoomConflict = await db.ExperimentSessions.AsNoTracking()
|
||
.AnyAsync(x =>
|
||
x.ClassroomId == request.ClassroomId &&
|
||
x.SessionDate == request.SessionDate &&
|
||
x.Status == ExperimentSessionStatus.Scheduled &&
|
||
x.StartPeriod <
|
||
request.StartPeriod + request.PeriodCount &&
|
||
request.StartPeriod < x.StartPeriod + x.PeriodCount,
|
||
cancellationToken);
|
||
if (experimentRoomConflict)
|
||
return "所选教室与其他实验场次冲突。";
|
||
|
||
if (project.ArrangementMode ==
|
||
ExperimentArrangementMode.Centralized)
|
||
{
|
||
return await CentralizedTaskConflictAsync(
|
||
project.TeachingTaskId,
|
||
project.TeachingTask.AcademicTerm!,
|
||
request.SessionDate,
|
||
request.StartPeriod,
|
||
request.PeriodCount,
|
||
cancellationToken);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private async Task<string?> CentralizedTaskConflictAsync(
|
||
Guid teachingTaskId,
|
||
AcademicTerm term,
|
||
DateOnly date,
|
||
int startPeriod,
|
||
int periodCount,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var teacherIds = await db.TeachingTaskTeachers.AsNoTracking()
|
||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||
.Select(x => x.TeacherId)
|
||
.ToListAsync(cancellationToken);
|
||
var classIds = await db.TeachingTaskClasses.AsNoTracking()
|
||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||
.Select(x => x.AdministrativeClassId)
|
||
.ToListAsync(cancellationToken);
|
||
var (week, dayOfWeek) =
|
||
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
|
||
term,
|
||
date);
|
||
|
||
var relatedTaskIds = (await CentralizedExperimentConflictQuery
|
||
.TaskIdsForTeachers(db, teacherIds)
|
||
.ToListAsync(cancellationToken))
|
||
.Concat(await CentralizedExperimentConflictQuery
|
||
.TaskIdsForClasses(db, classIds)
|
||
.ToListAsync(cancellationToken))
|
||
.Distinct()
|
||
.ToArray();
|
||
|
||
var scheduleConflicts = await CentralizedExperimentConflictQuery
|
||
.ScheduleEntries(
|
||
db,
|
||
relatedTaskIds,
|
||
term.Id,
|
||
dayOfWeek,
|
||
week,
|
||
startPeriod,
|
||
periodCount)
|
||
.Select(x => new { x.TeachingTaskId, x.WeekPattern })
|
||
.ToListAsync(cancellationToken);
|
||
if (scheduleConflicts.Any(x =>
|
||
FreeClassroomRules.MatchesWeek(x.WeekPattern, week)))
|
||
return "集中实验与相关教师或行政班的已发布课表冲突。";
|
||
|
||
var experimentConflict = await CentralizedExperimentConflictQuery
|
||
.ExperimentSessions(
|
||
db,
|
||
relatedTaskIds,
|
||
date,
|
||
startPeriod,
|
||
periodCount)
|
||
.AnyAsync(cancellationToken);
|
||
return experimentConflict
|
||
? "集中实验与相关教师或行政班的其他实验安排冲突。"
|
||
: null;
|
||
}
|
||
|
||
private async Task<string?> StudentConflictAsync(
|
||
Guid studentId,
|
||
ExperimentSession target,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var project = target.ExperimentProject!;
|
||
var term = project.TeachingTask!.AcademicTerm!;
|
||
var taskIds = await TeachingTaskRosterQuery
|
||
.TaskIdsForStudent(db, studentId)
|
||
.ToListAsync(cancellationToken);
|
||
var (week, dayOfWeek) =
|
||
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
|
||
term,
|
||
target.SessionDate);
|
||
|
||
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||
.Where(x =>
|
||
taskIds.Contains(x.TeachingTaskId) &&
|
||
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||
x.SchedulePlan.AcademicTermId == term.Id &&
|
||
x.DayOfWeek == dayOfWeek &&
|
||
x.StartWeek <= week &&
|
||
x.EndWeek >= week &&
|
||
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
|
||
target.StartPeriod < x.StartPeriod + x.PeriodCount)
|
||
.Select(x => x.WeekPattern)
|
||
.ToListAsync(cancellationToken);
|
||
if (scheduleEntries.Any(pattern =>
|
||
FreeClassroomRules.MatchesWeek(pattern, week)))
|
||
return "该时间与您的已发布课表冲突。";
|
||
|
||
var bookingConflict = await db.ExperimentBookings.AsNoTracking()
|
||
.AnyAsync(x =>
|
||
x.StudentId == studentId &&
|
||
x.Status == ExperimentBookingStatus.Booked &&
|
||
x.ExperimentSessionId != target.Id &&
|
||
x.ExperimentSession!.Status ==
|
||
ExperimentSessionStatus.Scheduled &&
|
||
x.ExperimentSession.SessionDate == target.SessionDate &&
|
||
x.ExperimentSession.StartPeriod <
|
||
target.StartPeriod + target.PeriodCount &&
|
||
target.StartPeriod <
|
||
x.ExperimentSession.StartPeriod +
|
||
x.ExperimentSession.PeriodCount,
|
||
cancellationToken);
|
||
if (bookingConflict) return "该时间与您已预约的其他实验冲突。";
|
||
|
||
var centralizedConflict = await db.ExperimentSessions.AsNoTracking()
|
||
.AnyAsync(x =>
|
||
x.Id != target.Id &&
|
||
x.Status == ExperimentSessionStatus.Scheduled &&
|
||
x.SessionDate == target.SessionDate &&
|
||
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
|
||
target.StartPeriod < x.StartPeriod + x.PeriodCount &&
|
||
x.ExperimentProject!.Status ==
|
||
ExperimentProjectStatus.Published &&
|
||
x.ExperimentProject.ArrangementMode ==
|
||
ExperimentArrangementMode.Centralized &&
|
||
taskIds.Contains(x.ExperimentProject.TeachingTaskId),
|
||
cancellationToken);
|
||
return centralizedConflict
|
||
? "该时间与您的集中实验安排冲突。"
|
||
: null;
|
||
}
|
||
|
||
private IQueryable<TeachingTask> AccessibleTeachingTasks()
|
||
{
|
||
var source = db.TeachingTasks.AsQueryable();
|
||
var scope = currentUserDataScope.Current;
|
||
if (scope.Scope == DataScope.All) return source;
|
||
if (scope.Scope == DataScope.College)
|
||
return source.Where(x =>
|
||
x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||
if (scope.IsInRole(SystemRoles.Teacher))
|
||
return source.Where(x =>
|
||
x.Teachers.Any(item =>
|
||
item.Teacher!.UserId == scope.UserId));
|
||
return source.Where(_ => false);
|
||
}
|
||
|
||
private IQueryable<ExperimentProject> ScopedProjects()
|
||
{
|
||
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
||
return db.ExperimentProjects.Where(x =>
|
||
taskIds.Contains(x.TeachingTaskId));
|
||
}
|
||
|
||
private Task<Student?> CurrentStudentAsync(
|
||
CancellationToken cancellationToken) =>
|
||
db.Students.FirstOrDefaultAsync(x =>
|
||
x.UserId == currentUserDataScope.Current.UserId &&
|
||
x.Status == StudentStatus.Active,
|
||
cancellationToken);
|
||
|
||
private async Task<int> ResolveSessionCapacityAsync(
|
||
ExperimentProject project,
|
||
int requestedCapacity,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (project.ArrangementMode !=
|
||
ExperimentArrangementMode.Centralized)
|
||
return requestedCapacity;
|
||
|
||
var rosterCount = await TeachingTaskRosterQuery
|
||
.ForTask(db, project.TeachingTaskId)
|
||
.CountAsync(cancellationToken);
|
||
return rosterCount > 0 ? rosterCount : requestedCapacity;
|
||
}
|
||
|
||
private Task<List<Guid>> RosterUserIdsAsync(
|
||
Guid teachingTaskId,
|
||
CancellationToken cancellationToken) =>
|
||
TeachingTaskRosterQuery.ForTask(db, teachingTaskId)
|
||
.Where(x => x.UserId.HasValue)
|
||
.Select(x => x.UserId!.Value)
|
||
.Distinct()
|
||
.ToListAsync(cancellationToken);
|
||
|
||
private async Task NotifyProjectPublishedAsync(
|
||
ExperimentProject project,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
|
||
if (userIds.Count == 0) return;
|
||
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||
? "集中安排"
|
||
: "自行预约";
|
||
await NotificationService.SendToUserIdsAsync(
|
||
db,
|
||
userIds,
|
||
"实验项目已发布",
|
||
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
||
"/experiments",
|
||
cancellationToken,
|
||
NotificationCategory.Schedule);
|
||
}
|
||
|
||
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
|
||
{
|
||
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||
return ids is { Count: > 0 and <= 100 } ? ids : null;
|
||
}
|
||
|
||
private static string? ValidateProjectRequest(
|
||
ExperimentProjectRequest request,
|
||
AcademicTerm term)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(request.Code))
|
||
return "请填写实验项目编码。";
|
||
if (string.IsNullOrWhiteSpace(request.Name))
|
||
return "请填写实验项目名称。";
|
||
if (!Enum.IsDefined(request.ArrangementMode))
|
||
return "实验安排方式无效。";
|
||
if (request.StartDate > request.EndDate)
|
||
return "项目开始日期不能晚于结束日期。";
|
||
if (request.StartDate < term.StartDate ||
|
||
request.EndDate > term.EndDate)
|
||
return "实验项目日期必须在所属学期起止日期内。";
|
||
return null;
|
||
}
|
||
|
||
private async Task<(ScheduleEntry? Entry, string? Problem)> ValidateScheduleEntryAsync(
|
||
ExperimentProjectRequest request,
|
||
Guid teachingTaskId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||
return request.ScheduleEntryId.HasValue
|
||
? (null, "自行安排的实验项目不能绑定课表实验课。")
|
||
: (null, null);
|
||
if (!request.ScheduleEntryId.HasValue)
|
||
return (null, "集中安排的实验项目必须绑定已发布课表中的实验课。");
|
||
|
||
var entry = await db.ScheduleEntries
|
||
.Include(x => x.SchedulePlan)
|
||
.FirstOrDefaultAsync(x => x.Id == request.ScheduleEntryId, cancellationToken);
|
||
if (entry is null || entry.SchedulePlan!.Status != SchedulePlanStatus.Published ||
|
||
entry.Kind != ScheduleEntryKind.Experiment || !entry.ClassroomId.HasValue ||
|
||
entry.TeachingTaskId != teachingTaskId)
|
||
return (null, "只能绑定本教学任务已发布、已安排实验室的实验课。");
|
||
if (!await AccessibleTeachingTasks().AnyAsync(x => x.Id == teachingTaskId, cancellationToken))
|
||
return (null, "该教学任务不在当前管理范围内。");
|
||
return (entry, null);
|
||
}
|
||
|
||
private static string? Normalize(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private ActionResult ConflictProblem(string detail) =>
|
||
Conflict(new ProblemDetails
|
||
{
|
||
Title = "无法完成操作",
|
||
Detail = detail,
|
||
Status = StatusCodes.Status409Conflict
|
||
});
|
||
}
|
||
|
||
public sealed record ExperimentProjectRequest(
|
||
Guid TeachingTaskId,
|
||
[Required, MaxLength(40)] string Code,
|
||
[Required, MaxLength(120)] string Name,
|
||
ExperimentArrangementMode ArrangementMode,
|
||
[MaxLength(1000)] string? Description,
|
||
[MaxLength(1000)] string? Requirements,
|
||
DateOnly StartDate,
|
||
DateOnly EndDate,
|
||
Guid? ScheduleEntryId = null);
|
||
|
||
public sealed record ExperimentProjectBatchRequest(
|
||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||
[Required, MaxLength(40)] string Code,
|
||
[Required, MaxLength(120)] string Name,
|
||
ExperimentArrangementMode ArrangementMode,
|
||
[MaxLength(1000)] string? Description,
|
||
[MaxLength(1000)] string? Requirements,
|
||
DateOnly StartDate,
|
||
DateOnly EndDate)
|
||
{
|
||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||
new(
|
||
teachingTaskId,
|
||
Code,
|
||
Name,
|
||
ArrangementMode,
|
||
Description,
|
||
Requirements,
|
||
StartDate,
|
||
EndDate);
|
||
}
|
||
|
||
public sealed record ExperimentProjectBulkRequest(
|
||
[Required] IReadOnlyList<Guid> ProjectIds);
|
||
|
||
public sealed record ExperimentSessionRequest(
|
||
Guid ClassroomId,
|
||
DateOnly SessionDate,
|
||
[Range(1, 30)] int StartPeriod,
|
||
[Range(1, 30)] int PeriodCount,
|
||
[Range(1, 10000)] int Capacity,
|
||
[MaxLength(500)] string? Notes);
|
||
|
||
public sealed record ExperimentSessionBatchRequest(
|
||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||
|
||
public sealed record ExperimentSessionBatchItem(
|
||
Guid ProjectId,
|
||
Guid ClassroomId,
|
||
DateOnly SessionDate,
|
||
[Range(1, 30)] int StartPeriod,
|
||
[Range(1, 30)] int PeriodCount,
|
||
[Range(1, 10000)] int Capacity,
|
||
[MaxLength(500)] string? Notes)
|
||
{
|
||
public ExperimentSessionRequest ToSessionRequest() =>
|
||
new(
|
||
ClassroomId,
|
||
SessionDate,
|
||
StartPeriod,
|
||
PeriodCount,
|
||
Capacity,
|
||
Notes);
|
||
}
|
||
|
||
public sealed record ExperimentPeriodOption(
|
||
Guid AcademicTermId,
|
||
int PeriodNumber,
|
||
string Name,
|
||
string StartsAt,
|
||
string EndsAt);
|