1046 lines
42 KiB
C#
1046 lines
42 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Data;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
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,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tasks = AccessibleTeachingTasks().AsNoTracking()
|
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
|
if (academicTermId.HasValue)
|
|
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
|
|
|
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,
|
|
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)
|
|
})
|
|
.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
|
|
})
|
|
.ToListAsync(cancellationToken),
|
|
Periods = periodItems
|
|
});
|
|
}
|
|
|
|
[HttpGet("management")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> GetManagementProjects(
|
|
Guid? academicTermId,
|
|
ExperimentArrangementMode? arrangementMode,
|
|
ExperimentProjectStatus? status,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
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);
|
|
|
|
return Ok(await source
|
|
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
|
|
.ThenBy(x => x.StartDate)
|
|
.ThenBy(x => x.Code)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TeachingTaskId,
|
|
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),
|
|
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));
|
|
}
|
|
|
|
[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.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),
|
|
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 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,
|
|
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 });
|
|
}
|
|
|
|
[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 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.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();
|
|
}
|
|
|
|
[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("只有草稿实验项目可以发布。");
|
|
if (!project.Sessions.Any(x =>
|
|
x.Status == ExperimentSessionStatus.Scheduled))
|
|
return ConflictProblem("请至少安排一个有效实验场次后再发布。");
|
|
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);
|
|
|
|
var userIds = await TeachingTaskRosterQuery
|
|
.ForTask(db, project.TeachingTaskId)
|
|
.Where(x => x.UserId.HasValue)
|
|
.Select(x => x.UserId!.Value)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken);
|
|
if (userIds.Count > 0)
|
|
{
|
|
var mode = project.ArrangementMode ==
|
|
ExperimentArrangementMode.Centralized
|
|
? "集中安排"
|
|
: "自行预约";
|
|
await NotificationService.SendToUserIdsAsync(
|
|
db,
|
|
userIds,
|
|
"实验项目已发布",
|
|
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
|
|
"/experiments",
|
|
cancellationToken,
|
|
NotificationCategory.Schedule);
|
|
}
|
|
return NoContent();
|
|
}
|
|
|
|
[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("已关闭实验项目不能再增加场次。");
|
|
|
|
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 });
|
|
}
|
|
|
|
[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 (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 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 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);
|
|
|
|
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 ExperimentPeriodOption(
|
|
Guid AcademicTermId,
|
|
int PeriodNumber,
|
|
string Name,
|
|
string StartsAt,
|
|
string EndsAt);
|