教室预约
This commit is contained in:
@@ -0,0 +1,708 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/classroom-reservations")]
|
||||||
|
public sealed class ClassroomReservationsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope scope,
|
||||||
|
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("options")]
|
||||||
|
public async Task<ActionResult> GetOptions(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||||
|
var terms = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(term => term.IsEnabled && !term.IsArchived)
|
||||||
|
.OrderByDescending(term => term.IsCurrent)
|
||||||
|
.ThenByDescending(term => term.StartDate)
|
||||||
|
.Select(term => new
|
||||||
|
{
|
||||||
|
term.Id,
|
||||||
|
term.Name,
|
||||||
|
term.StartDate,
|
||||||
|
term.EndDate,
|
||||||
|
term.IsCurrent,
|
||||||
|
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||||
|
plan.AcademicTermId == term.Id &&
|
||||||
|
plan.Status == SchedulePlanStatus.Published)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var selectedTermId = academicTermId
|
||||||
|
?? terms.FirstOrDefault(term =>
|
||||||
|
term.IsCurrent && term.HasPublishedTimetable)?.Id
|
||||||
|
?? terms.FirstOrDefault(term => term.HasPublishedTimetable)?.Id
|
||||||
|
?? terms.FirstOrDefault()?.Id;
|
||||||
|
var campuses = await db.Campuses.AsNoTracking()
|
||||||
|
.Where(campus => campus.IsEnabled)
|
||||||
|
.OrderBy(campus => campus.SortOrder)
|
||||||
|
.ThenBy(campus => campus.Code)
|
||||||
|
.Select(campus => new { campus.Id, campus.Code, campus.Name })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var buildings = await db.Buildings.AsNoTracking()
|
||||||
|
.Where(building =>
|
||||||
|
building.IsEnabled &&
|
||||||
|
building.Campus!.IsEnabled)
|
||||||
|
.OrderBy(building => building.Campus!.SortOrder)
|
||||||
|
.ThenBy(building => building.SortOrder)
|
||||||
|
.ThenBy(building => building.Code)
|
||||||
|
.Select(building => new
|
||||||
|
{
|
||||||
|
building.Id,
|
||||||
|
building.Code,
|
||||||
|
building.Name,
|
||||||
|
building.CampusId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var classrooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(classroom =>
|
||||||
|
classroom.IsEnabled &&
|
||||||
|
classroom.Building!.IsEnabled &&
|
||||||
|
classroom.Building.Campus!.IsEnabled)
|
||||||
|
.OrderBy(classroom => classroom.Building!.Campus!.SortOrder)
|
||||||
|
.ThenBy(classroom => classroom.Building!.SortOrder)
|
||||||
|
.ThenBy(classroom => classroom.Code)
|
||||||
|
.Select(classroom => new
|
||||||
|
{
|
||||||
|
classroom.Id,
|
||||||
|
classroom.Code,
|
||||||
|
classroom.Name,
|
||||||
|
classroom.BuildingId,
|
||||||
|
classroom.Capacity,
|
||||||
|
classroom.RoomType
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var timeSlots = await LoadTimeSlotsAsync(
|
||||||
|
selectedTermId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Applicant = applicant.Context is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
applicant.Context.User.Id,
|
||||||
|
applicant.Context.User.DisplayName,
|
||||||
|
CollegeId = applicant.Context.CollegeId,
|
||||||
|
CollegeName = applicant.Context.CollegeName
|
||||||
|
},
|
||||||
|
ApplicantProblem = applicant.Error,
|
||||||
|
Terms = terms,
|
||||||
|
SelectedTermId = selectedTermId,
|
||||||
|
Campuses = campuses,
|
||||||
|
Buildings = buildings,
|
||||||
|
Classrooms = classrooms,
|
||||||
|
TimeSlots = timeSlots
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("availability")]
|
||||||
|
public async Task<ActionResult> GetAvailability(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
[Range(1, 30)] int startPeriod,
|
||||||
|
[Range(1, 6)] int periodCount,
|
||||||
|
Guid? campusId,
|
||||||
|
Guid? buildingId,
|
||||||
|
[Range(1, 10000)] int? attendeeCount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await ValidateSlotAsync(
|
||||||
|
academicTermId,
|
||||||
|
reservationDate,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (validation.Error is not null) return validation.Error;
|
||||||
|
|
||||||
|
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||||
|
validation.Term!,
|
||||||
|
reservationDate,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
|
var rooms = db.Classrooms.AsNoTracking()
|
||||||
|
.Where(classroom =>
|
||||||
|
classroom.IsEnabled &&
|
||||||
|
classroom.Building!.IsEnabled &&
|
||||||
|
classroom.Building.Campus!.IsEnabled)
|
||||||
|
.WhereNotIn(occupiedIds, classroom => classroom.Id);
|
||||||
|
if (campusId.HasValue)
|
||||||
|
rooms = rooms.Where(room =>
|
||||||
|
room.Building!.CampusId == campusId.Value);
|
||||||
|
if (buildingId.HasValue)
|
||||||
|
rooms = rooms.Where(room => room.BuildingId == buildingId.Value);
|
||||||
|
if (attendeeCount.HasValue)
|
||||||
|
rooms = rooms.Where(room => room.Capacity >= attendeeCount.Value);
|
||||||
|
|
||||||
|
var items = await rooms
|
||||||
|
.OrderBy(room => room.Building!.Campus!.SortOrder)
|
||||||
|
.ThenBy(room => room.Building!.SortOrder)
|
||||||
|
.ThenBy(room => room.Code)
|
||||||
|
.Select(room => new
|
||||||
|
{
|
||||||
|
room.Id,
|
||||||
|
room.Code,
|
||||||
|
room.Name,
|
||||||
|
room.Capacity,
|
||||||
|
room.RoomType,
|
||||||
|
room.BuildingId,
|
||||||
|
BuildingName = room.Building!.Name,
|
||||||
|
CampusId = room.Building.CampusId,
|
||||||
|
CampusName = room.Building.Campus!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { Items = items });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine")]
|
||||||
|
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetMine(
|
||||||
|
ClassroomReservationStatus? status,
|
||||||
|
[Range(1, int.MaxValue)] int page = 1,
|
||||||
|
[Range(1, 100)] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = db.ClassroomReservations.AsNoTracking()
|
||||||
|
.Where(reservation =>
|
||||||
|
reservation.ApplicantUserId == scope.Current.UserId);
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(reservation => reservation.Status == status.Value);
|
||||||
|
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("review")]
|
||||||
|
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||||
|
public async Task<ActionResult<PagedResult<ClassroomReservationDto>>> GetReviewQueue(
|
||||||
|
ClassroomReservationStatus? status,
|
||||||
|
[Range(1, int.MaxValue)] int page = 1,
|
||||||
|
[Range(1, 100)] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var collegeError = ReviewCollegeError();
|
||||||
|
if (collegeError is not null) return collegeError;
|
||||||
|
var collegeId = scope.Current.CollegeId!.Value;
|
||||||
|
var query = db.ClassroomReservations.AsNoTracking()
|
||||||
|
.Where(reservation => reservation.ApplicantCollegeId == collegeId);
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(reservation => reservation.Status == status.Value);
|
||||||
|
else
|
||||||
|
query = query.Where(reservation =>
|
||||||
|
reservation.Status == ClassroomReservationStatus.Submitted);
|
||||||
|
return Ok(await BuildPageAsync(query, page, pageSize, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult> Create(
|
||||||
|
CreateClassroomReservationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Purpose))
|
||||||
|
return ValidationProblem("请填写借用用途。");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ContactPhone))
|
||||||
|
return ValidationProblem("请填写联系电话。");
|
||||||
|
var applicant = await ResolveApplicantAsync(cancellationToken);
|
||||||
|
if (applicant.Context is null)
|
||||||
|
return ConflictProblem(
|
||||||
|
applicant.Error ?? "当前账号未配置申请人学院,无法提交申请。");
|
||||||
|
|
||||||
|
var validation = await ValidateSlotAsync(
|
||||||
|
request.AcademicTermId,
|
||||||
|
request.ReservationDate,
|
||||||
|
request.StartPeriod,
|
||||||
|
request.PeriodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (validation.Error is not null) return validation.Error;
|
||||||
|
|
||||||
|
var classroom = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(room =>
|
||||||
|
room.Id == request.ClassroomId &&
|
||||||
|
room.IsEnabled &&
|
||||||
|
room.Building!.IsEnabled &&
|
||||||
|
room.Building.Campus!.IsEnabled)
|
||||||
|
.Select(room => new
|
||||||
|
{
|
||||||
|
room.Id,
|
||||||
|
room.Name,
|
||||||
|
room.Code,
|
||||||
|
room.Capacity,
|
||||||
|
BuildingName = room.Building!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (classroom is null) return NotFound();
|
||||||
|
if (request.AttendeeCount > classroom.Capacity)
|
||||||
|
return ValidationProblem(
|
||||||
|
$"申请人数超过教室容量({classroom.Capacity} 人)。");
|
||||||
|
|
||||||
|
var occupiedIds = await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||||
|
validation.Term!,
|
||||||
|
request.ReservationDate,
|
||||||
|
request.StartPeriod,
|
||||||
|
request.PeriodCount,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
|
if (occupiedIds.Contains(classroom.Id))
|
||||||
|
return ConflictProblem("所选教室在该时段已有课程、考试或已批准预约。");
|
||||||
|
|
||||||
|
var duplicate = await db.ClassroomReservations.AsNoTracking()
|
||||||
|
.AnyAsync(reservation =>
|
||||||
|
reservation.ApplicantUserId == applicant.Context.User.Id &&
|
||||||
|
reservation.ClassroomId == classroom.Id &&
|
||||||
|
reservation.ReservationDate == request.ReservationDate &&
|
||||||
|
reservation.Status == ClassroomReservationStatus.Submitted &&
|
||||||
|
reservation.StartPeriod <
|
||||||
|
request.StartPeriod + request.PeriodCount &&
|
||||||
|
request.StartPeriod <
|
||||||
|
reservation.StartPeriod + reservation.PeriodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return ConflictProblem("您已提交过同一教室、重叠时段的申请。");
|
||||||
|
|
||||||
|
var reservation = new ClassroomReservation
|
||||||
|
{
|
||||||
|
ApplicantUserId = applicant.Context.User.Id,
|
||||||
|
ApplicantName = applicant.Context.User.DisplayName,
|
||||||
|
ApplicantCollegeId = applicant.Context.CollegeId,
|
||||||
|
AcademicTermId = request.AcademicTermId,
|
||||||
|
ClassroomId = request.ClassroomId,
|
||||||
|
ReservationDate = request.ReservationDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
AttendeeCount = request.AttendeeCount,
|
||||||
|
Purpose = request.Purpose.Trim(),
|
||||||
|
ContactPhone = request.ContactPhone.Trim(),
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
};
|
||||||
|
db.ClassroomReservations.Add(reservation);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToRoleAsync(
|
||||||
|
db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
"教室借用申请待审核",
|
||||||
|
$"{reservation.ApplicantName} 申请于 {reservation.ReservationDate:yyyy-MM-dd} " +
|
||||||
|
$"借用 {classroom.BuildingName} {classroom.Name}。",
|
||||||
|
reservation.ApplicantCollegeId,
|
||||||
|
"/classroom-reservations",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Approval);
|
||||||
|
return Created("", new { reservation.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/cancel")]
|
||||||
|
public async Task<ActionResult> Cancel(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var reservation = await db.ClassroomReservations
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == id &&
|
||||||
|
item.ApplicantUserId == scope.Current.UserId,
|
||||||
|
cancellationToken);
|
||||||
|
if (reservation is null) return NotFound();
|
||||||
|
if (reservation.Status is not (
|
||||||
|
ClassroomReservationStatus.Submitted or
|
||||||
|
ClassroomReservationStatus.Approved))
|
||||||
|
return ConflictProblem("当前状态不能取消。");
|
||||||
|
if (reservation.ReservationDate < TodayInChina())
|
||||||
|
return ConflictProblem("已过期的预约不能取消。");
|
||||||
|
|
||||||
|
var wasApproved =
|
||||||
|
reservation.Status == ClassroomReservationStatus.Approved;
|
||||||
|
reservation.Status = ClassroomReservationStatus.Cancelled;
|
||||||
|
reservation.CancelledAt = DateTime.UtcNow;
|
||||||
|
reservation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (wasApproved)
|
||||||
|
{
|
||||||
|
await NotificationService.SendToRoleAsync(
|
||||||
|
db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
"已批准教室预约被申请人取消",
|
||||||
|
$"{reservation.ApplicantName} 已取消 " +
|
||||||
|
$"{reservation.ReservationDate:yyyy-MM-dd} 的教室预约。",
|
||||||
|
reservation.ApplicantCollegeId,
|
||||||
|
"/classroom-reservations",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Approval);
|
||||||
|
}
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||||
|
public Task<ActionResult> Approve(
|
||||||
|
Guid id,
|
||||||
|
ReviewClassroomReservationRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
ReviewAsync(id, true, request.Comment, cancellationToken);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
[Authorize(Roles = SystemRoles.CollegeAdmin)]
|
||||||
|
public Task<ActionResult> Reject(
|
||||||
|
Guid id,
|
||||||
|
ReviewClassroomReservationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
|
return Task.FromResult<ActionResult>(
|
||||||
|
ValidationProblem("驳回时必须填写审核意见。"));
|
||||||
|
return ReviewAsync(id, false, request.Comment, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> ReviewAsync(
|
||||||
|
Guid id,
|
||||||
|
bool approve,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var collegeError = ReviewCollegeError();
|
||||||
|
if (collegeError is not null) return collegeError;
|
||||||
|
var reviewerCollegeId = scope.Current.CollegeId!.Value;
|
||||||
|
|
||||||
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
|
async transaction =>
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var reservation = await db.ClassroomReservations
|
||||||
|
.Include(item => item.AcademicTerm)
|
||||||
|
.Include(item => item.Classroom)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||||
|
if (reservation is null) return NotFound();
|
||||||
|
if (reservation.ApplicantCollegeId != reviewerCollegeId)
|
||||||
|
return Forbid();
|
||||||
|
if (reservation.Status != ClassroomReservationStatus.Submitted)
|
||||||
|
return ConflictProblem("该申请已处理,不能重复审核。");
|
||||||
|
|
||||||
|
if (approve)
|
||||||
|
{
|
||||||
|
if (reservation.ReservationDate < TodayInChina())
|
||||||
|
return ConflictProblem("预约日期已过,不能批准。");
|
||||||
|
var validation = await ValidateSlotAsync(
|
||||||
|
reservation.AcademicTermId,
|
||||||
|
reservation.ReservationDate,
|
||||||
|
reservation.StartPeriod,
|
||||||
|
reservation.PeriodCount,
|
||||||
|
cancellationToken);
|
||||||
|
if (validation.Error is not null) return validation.Error;
|
||||||
|
if (reservation.AttendeeCount > reservation.Classroom!.Capacity)
|
||||||
|
return ConflictProblem("申请人数已超过教室当前容量,不能批准。");
|
||||||
|
var occupiedIds =
|
||||||
|
await availabilityService.GetOccupiedClassroomIdsAsync(
|
||||||
|
validation.Term!,
|
||||||
|
reservation.ReservationDate,
|
||||||
|
reservation.StartPeriod,
|
||||||
|
reservation.PeriodCount,
|
||||||
|
reservation.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (occupiedIds.Contains(reservation.ClassroomId))
|
||||||
|
return ConflictProblem(
|
||||||
|
"教室在该时段已被课程、考试或其他已批准预约占用。");
|
||||||
|
reservation.Status = ClassroomReservationStatus.Approved;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reservation.Status = ClassroomReservationStatus.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
reservation.ReviewedByUserId = scope.Current.UserId;
|
||||||
|
reservation.ReviewedAt = DateTime.UtcNow;
|
||||||
|
reservation.ReviewComment = Normalize(comment);
|
||||||
|
reservation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await NotificationService.SendAsync(
|
||||||
|
db,
|
||||||
|
reservation.ApplicantUserId,
|
||||||
|
approve ? "教室借用申请已通过" : "教室借用申请已驳回",
|
||||||
|
approve
|
||||||
|
? $"{reservation.ReservationDate:yyyy-MM-dd} 的教室借用申请已通过。"
|
||||||
|
: reservation.ReviewComment ?? "审核未通过。",
|
||||||
|
"/classroom-reservations",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Approval);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PagedResult<ClassroomReservationDto>> BuildPageAsync(
|
||||||
|
IQueryable<ClassroomReservation> query,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(reservation =>
|
||||||
|
reservation.Status == ClassroomReservationStatus.Submitted)
|
||||||
|
.ThenByDescending(reservation => reservation.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(reservation => new ClassroomReservationDto(
|
||||||
|
reservation.Id,
|
||||||
|
reservation.ApplicantUserId,
|
||||||
|
reservation.ApplicantName,
|
||||||
|
reservation.ApplicantCollegeId,
|
||||||
|
reservation.ApplicantCollege!.Name,
|
||||||
|
reservation.AcademicTermId,
|
||||||
|
reservation.AcademicTerm!.Name,
|
||||||
|
reservation.ClassroomId,
|
||||||
|
reservation.Classroom!.Code,
|
||||||
|
reservation.Classroom.Name,
|
||||||
|
reservation.Classroom.Building!.Name,
|
||||||
|
reservation.Classroom.Building.Campus!.Name,
|
||||||
|
reservation.ReservationDate,
|
||||||
|
reservation.StartPeriod,
|
||||||
|
reservation.PeriodCount,
|
||||||
|
reservation.AttendeeCount,
|
||||||
|
reservation.Purpose,
|
||||||
|
reservation.ContactPhone,
|
||||||
|
reservation.Notes,
|
||||||
|
reservation.Status,
|
||||||
|
reservation.ReviewComment,
|
||||||
|
reservation.ReviewedAt,
|
||||||
|
reservation.CancelledAt,
|
||||||
|
reservation.CreatedAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return new PagedResult<ClassroomReservationDto>(
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(
|
||||||
|
AcademicTerm? Term,
|
||||||
|
ActionResult? Error)> ValidateSlotAsync(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == academicTermId &&
|
||||||
|
item.IsEnabled &&
|
||||||
|
!item.IsArchived,
|
||||||
|
cancellationToken);
|
||||||
|
if (term is null) return (null, NotFound());
|
||||||
|
if (reservationDate < TodayInChina())
|
||||||
|
return (null, ValidationProblem("预约日期不能早于今天。"));
|
||||||
|
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||||
|
return (null, ValidationProblem("预约日期不在所选学期范围内。"));
|
||||||
|
var hasPublishedTimetable = await db.SchedulePlans.AsNoTracking()
|
||||||
|
.AnyAsync(plan =>
|
||||||
|
plan.AcademicTermId == term.Id &&
|
||||||
|
plan.Status == SchedulePlanStatus.Published,
|
||||||
|
cancellationToken);
|
||||||
|
if (!hasPublishedTimetable)
|
||||||
|
return (null, ConflictProblem(
|
||||||
|
"所选学期的正式课表尚未发布,暂不能提交教室预约。"));
|
||||||
|
|
||||||
|
var requestedPeriods = Enumerable.Range(startPeriod, periodCount).ToArray();
|
||||||
|
var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.CountAsync(slot =>
|
||||||
|
slot.AcademicTermId == academicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
if (configuredPeriodCount == 0)
|
||||||
|
{
|
||||||
|
if (requestedPeriods.Any(period => period > 12))
|
||||||
|
return (null, ValidationProblem("预约范围包含不存在的节次。"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var enabledPeriods = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(slot =>
|
||||||
|
slot.AcademicTermId == academicTermId &&
|
||||||
|
slot.IsEnabled)
|
||||||
|
.WhereIn(requestedPeriods, slot => slot.PeriodNumber)
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
if (enabledPeriods != requestedPeriods.Length)
|
||||||
|
return (null, ValidationProblem("预约范围包含不存在或未启用的节次。"));
|
||||||
|
}
|
||||||
|
return (term, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ClassroomReservationTimeSlotDto>> LoadTimeSlotsAsync(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = academicTermId.HasValue
|
||||||
|
? await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(slot =>
|
||||||
|
slot.AcademicTermId == academicTermId.Value &&
|
||||||
|
slot.IsEnabled)
|
||||||
|
.OrderBy(slot => slot.PeriodNumber)
|
||||||
|
.Select(slot => new ClassroomReservationTimeSlotDto(
|
||||||
|
slot.PeriodNumber,
|
||||||
|
slot.Name,
|
||||||
|
slot.StartsAt.ToString("HH:mm"),
|
||||||
|
slot.EndsAt.ToString("HH:mm")))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [];
|
||||||
|
return result.Count > 0
|
||||||
|
? result
|
||||||
|
: Enumerable.Range(1, 12)
|
||||||
|
.Select(period => new ClassroomReservationTimeSlotDto(
|
||||||
|
period,
|
||||||
|
$"第 {period} 节",
|
||||||
|
"",
|
||||||
|
""))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApplicantResolution> ResolveApplicantAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (scope.Current.UserId == Guid.Empty)
|
||||||
|
return new(null, "无法识别当前登录账号。");
|
||||||
|
var user = await db.Users.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item =>
|
||||||
|
item.Id == scope.Current.UserId &&
|
||||||
|
item.IsEnabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (user is null) return new(null, "当前账号不存在或已停用。");
|
||||||
|
|
||||||
|
var linkedCollegeIds = new List<Guid>();
|
||||||
|
if (user.CollegeId.HasValue) linkedCollegeIds.Add(user.CollegeId.Value);
|
||||||
|
var teacherCollegeId = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(teacher => teacher.UserId == user.Id)
|
||||||
|
.Select(teacher => (Guid?)teacher.CollegeId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (teacherCollegeId.HasValue)
|
||||||
|
linkedCollegeIds.Add(teacherCollegeId.Value);
|
||||||
|
var studentCollegeId = await db.Students.AsNoTracking()
|
||||||
|
.Where(student => student.UserId == user.Id)
|
||||||
|
.Select(student =>
|
||||||
|
(Guid?)student.AdministrativeClass!.Major!.CollegeId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (studentCollegeId.HasValue)
|
||||||
|
linkedCollegeIds.Add(studentCollegeId.Value);
|
||||||
|
|
||||||
|
var distinctIds = linkedCollegeIds.Distinct().ToArray();
|
||||||
|
if (distinctIds.Length == 0)
|
||||||
|
return new(null, "当前账号未配置所在学院,请联系账号管理员补充学院信息。");
|
||||||
|
if (distinctIds.Length > 1)
|
||||||
|
return new(null, "账号与人员档案的学院信息不一致,请联系管理员修正后再申请。");
|
||||||
|
var college = await db.Colleges.AsNoTracking()
|
||||||
|
.Where(item => item.Id == distinctIds[0] && item.IsEnabled)
|
||||||
|
.Select(item => new { item.Id, item.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return college is null
|
||||||
|
? new(null, "当前账号所属学院不存在或已停用。")
|
||||||
|
: new(
|
||||||
|
new ApplicantContext(user, college.Id, college.Name),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult? ReviewCollegeError()
|
||||||
|
{
|
||||||
|
if (!scope.Current.IsInRole(SystemRoles.CollegeAdmin))
|
||||||
|
return Forbid();
|
||||||
|
return scope.Current.CollegeId.HasValue
|
||||||
|
? null
|
||||||
|
: ConflictProblem("学院管理员账号未配置所属学院,无法审核。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
|
Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "无法完成教室预约",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static DateOnly TodayInChina()
|
||||||
|
{
|
||||||
|
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var zone = TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
return DateOnly.FromDateTime(
|
||||||
|
TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone).DateTime);
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ApplicantContext(
|
||||||
|
ApplicationUser User,
|
||||||
|
Guid CollegeId,
|
||||||
|
string CollegeName);
|
||||||
|
|
||||||
|
private sealed record ApplicantResolution(
|
||||||
|
ApplicantContext? Context,
|
||||||
|
string? Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateClassroomReservationRequest(
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid ClassroomId,
|
||||||
|
DateOnly ReservationDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
[Range(1, 10000)] int AttendeeCount,
|
||||||
|
[Required, StringLength(200)] string Purpose,
|
||||||
|
[Required, StringLength(30)] string ContactPhone,
|
||||||
|
[StringLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record ReviewClassroomReservationRequest(
|
||||||
|
[StringLength(500)] string? Comment);
|
||||||
|
|
||||||
|
public sealed record ClassroomReservationTimeSlotDto(
|
||||||
|
int PeriodNumber,
|
||||||
|
string Name,
|
||||||
|
string StartTime,
|
||||||
|
string EndTime);
|
||||||
|
|
||||||
|
public sealed record ClassroomReservationDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApplicantUserId,
|
||||||
|
string ApplicantName,
|
||||||
|
Guid ApplicantCollegeId,
|
||||||
|
string ApplicantCollegeName,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
string AcademicTermName,
|
||||||
|
Guid ClassroomId,
|
||||||
|
string ClassroomCode,
|
||||||
|
string ClassroomName,
|
||||||
|
string BuildingName,
|
||||||
|
string CampusName,
|
||||||
|
DateOnly ReservationDate,
|
||||||
|
int StartPeriod,
|
||||||
|
int PeriodCount,
|
||||||
|
int AttendeeCount,
|
||||||
|
string Purpose,
|
||||||
|
string ContactPhone,
|
||||||
|
string? Notes,
|
||||||
|
ClassroomReservationStatus Status,
|
||||||
|
string? ReviewComment,
|
||||||
|
DateTime? ReviewedAt,
|
||||||
|
DateTime? CancelledAt,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -209,7 +209,9 @@ public sealed class TimetableManagementController(
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
public sealed class FreeClassroomsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("free-classrooms/options")]
|
[HttpGet("free-classrooms/options")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var term = await db.AcademicTerms.AsNoTracking()
|
var term = await db.AcademicTerms.AsNoTracking()
|
||||||
.Where(x => x.Id == academicTermId && x.IsEnabled)
|
.FirstOrDefaultAsync(
|
||||||
.Select(x => new { x.Id, x.Name })
|
x => x.Id == academicTermId && x.IsEnabled,
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
cancellationToken);
|
||||||
if (term is null) return NotFound();
|
if (term is null) return NotFound();
|
||||||
var plan = await db.SchedulePlans.AsNoTracking()
|
var plan = await db.SchedulePlans.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
|||||||
if (activePeriodCount != requestedPeriods.Length)
|
if (activePeriodCount != requestedPeriods.Length)
|
||||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||||
|
|
||||||
var candidates = await db.ScheduleEntries.AsNoTracking()
|
var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
|
||||||
.Where(x =>
|
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||||
x.SchedulePlanId == plan.Id &&
|
return ValidationProblem("所选周次和星期不在学期日期范围内。");
|
||||||
x.ClassroomId.HasValue &&
|
var occupiedIds =
|
||||||
x.DayOfWeek == dayOfWeek &&
|
await reservationAvailability.GetOccupiedClassroomIdsAsync(
|
||||||
x.StartWeek <= week &&
|
term,
|
||||||
x.EndWeek >= week &&
|
reservationDate,
|
||||||
x.StartPeriod < startPeriod + periodCount &&
|
|
||||||
startPeriod < x.StartPeriod + x.PeriodCount)
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.ClassroomId,
|
|
||||||
x.WeekPattern,
|
|
||||||
x.StartPeriod,
|
|
||||||
x.PeriodCount
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
var occupiedIds = candidates
|
|
||||||
.Where(x =>
|
|
||||||
FreeClassroomRules.MatchesWeek(x.WeekPattern, week) &&
|
|
||||||
FreeClassroomRules.PeriodsOverlap(
|
|
||||||
startPeriod,
|
startPeriod,
|
||||||
periodCount,
|
periodCount,
|
||||||
x.StartPeriod,
|
null,
|
||||||
x.PeriodCount))
|
cancellationToken);
|
||||||
.Select(x => x.ClassroomId.GetValueOrDefault())
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
var rooms = db.Classrooms.AsNoTracking()
|
var rooms = db.Classrooms.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -397,6 +383,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateOnly ResolveReservationDate(
|
||||||
|
AcademicTerm term,
|
||||||
|
int week,
|
||||||
|
int dayOfWeek)
|
||||||
|
{
|
||||||
|
var startDay = (int)term.StartDate.DayOfWeek;
|
||||||
|
var daysSinceMonday = (startDay + 6) % 7;
|
||||||
|
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
|
||||||
|
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record FreeClassroomTimeSlotDto(
|
public sealed record FreeClassroomTimeSlotDto(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ClassroomReservation : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ApplicantUserId { get; set; }
|
||||||
|
public ApplicationUser? ApplicantUser { get; set; }
|
||||||
|
public required string ApplicantName { get; set; }
|
||||||
|
public Guid ApplicantCollegeId { get; set; }
|
||||||
|
public College? ApplicantCollege { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public AcademicTerm? AcademicTerm { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly ReservationDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public int AttendeeCount { get; set; }
|
||||||
|
public required string Purpose { get; set; }
|
||||||
|
public required string ContactPhone { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public ClassroomReservationStatus Status { get; set; } =
|
||||||
|
ClassroomReservationStatus.Submitted;
|
||||||
|
public Guid? ReviewedByUserId { get; set; }
|
||||||
|
public ApplicationUser? ReviewedByUser { get; set; }
|
||||||
|
public DateTime? ReviewedAt { get; set; }
|
||||||
|
public string? ReviewComment { get; set; }
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ClassroomReservationStatus
|
||||||
|
{
|
||||||
|
Submitted = 1,
|
||||||
|
Approved = 2,
|
||||||
|
Rejected = 3,
|
||||||
|
Cancelled = 4
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<AutomaticScheduleJob>();
|
Set<AutomaticScheduleJob>();
|
||||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||||
Set<SchedulePublishJob>();
|
Set<SchedulePublishJob>();
|
||||||
|
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||||
|
Set<ClassroomReservation>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||||
@@ -472,6 +474,49 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ClassroomReservation>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.ApplicantName).HasMaxLength(50);
|
||||||
|
entity.Property(x => x.Purpose).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ContactPhone).HasMaxLength(30);
|
||||||
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new { x.ApplicantUserId, x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ApplicantCollegeId,
|
||||||
|
x.Status,
|
||||||
|
x.ReservationDate
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ClassroomId,
|
||||||
|
x.ReservationDate,
|
||||||
|
x.Status,
|
||||||
|
x.StartPeriod
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ApplicantUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApplicantUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ApplicantCollege)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApplicantCollegeId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.AcademicTerm)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.AcademicTermId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Classroom)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ReviewedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ReviewedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSelectionRound>(entity =>
|
builder.Entity<CourseSelectionRound>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260726_30_official_documents";
|
"20260726_30_official_documents";
|
||||||
private const string UnifiedMessageCenterMigration =
|
private const string UnifiedMessageCenterMigration =
|
||||||
"20260726_31_unified_message_center";
|
"20260726_31_unified_message_center";
|
||||||
|
private const string ClassroomReservationsMigration =
|
||||||
|
"20260726_32_classroom_reservations";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -406,6 +408,19 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
UnifiedMessageCenterMigration,
|
UnifiedMessageCenterMigration,
|
||||||
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
|
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var classroomReservationsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ClassroomReservations'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ClassroomReservationsMigration,
|
||||||
|
classroomReservationsExist ? [] : ClassroomReservationStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1917,4 +1932,76 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
|
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
|
||||||
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
|
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ClassroomReservationStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ClassroomReservations" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ClassroomReservations" PRIMARY KEY,
|
||||||
|
"ApplicantUserId" TEXT NOT NULL,
|
||||||
|
"ApplicantName" TEXT NOT NULL,
|
||||||
|
"ApplicantCollegeId" TEXT NOT NULL,
|
||||||
|
"AcademicTermId" TEXT NOT NULL,
|
||||||
|
"ClassroomId" TEXT NOT NULL,
|
||||||
|
"ReservationDate" TEXT NOT NULL,
|
||||||
|
"StartPeriod" INTEGER NOT NULL,
|
||||||
|
"PeriodCount" INTEGER NOT NULL,
|
||||||
|
"AttendeeCount" INTEGER NOT NULL,
|
||||||
|
"Purpose" TEXT NOT NULL,
|
||||||
|
"ContactPhone" TEXT NOT NULL,
|
||||||
|
"Notes" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"ReviewedByUserId" TEXT NULL,
|
||||||
|
"ReviewedAt" TEXT NULL,
|
||||||
|
"ReviewComment" TEXT NULL,
|
||||||
|
"CancelledAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ApplicantUserId"
|
||||||
|
FOREIGN KEY ("ApplicantUserId") REFERENCES "AspNetUsers" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_Colleges_ApplicantCollegeId"
|
||||||
|
FOREIGN KEY ("ApplicantCollegeId") REFERENCES "Colleges" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_AcademicTerms_AcademicTermId"
|
||||||
|
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_Classrooms_ClassroomId"
|
||||||
|
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId"
|
||||||
|
FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id")
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt"
|
||||||
|
ON "ClassroomReservations" ("ApplicantUserId", "Status", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationDate"
|
||||||
|
ON "ClassroomReservations" (
|
||||||
|
"ApplicantCollegeId",
|
||||||
|
"Status",
|
||||||
|
"ReservationDate"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_StartPeriod"
|
||||||
|
ON "ClassroomReservations" (
|
||||||
|
"ClassroomId",
|
||||||
|
"ReservationDate",
|
||||||
|
"Status",
|
||||||
|
"StartPeriod"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_AcademicTermId"
|
||||||
|
ON "ClassroomReservations" ("AcademicTermId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ClassroomReservations_ReviewedByUserId"
|
||||||
|
ON "ClassroomReservations" ("ReviewedByUserId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+4790
File diff suppressed because it is too large
Load Diff
+108
@@ -0,0 +1,108 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ClassroomReservations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClassroomReservations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ApplicantName = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||||
|
ApplicantCollegeId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ReservationDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AttendeeCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||||
|
ContactPhone = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClassroomReservations", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_AcademicTerms_AcademicTermId",
|
||||||
|
column: x => x.AcademicTermId,
|
||||||
|
principalTable: "AcademicTerms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_AspNetUsers_ApplicantUserId",
|
||||||
|
column: x => x.ApplicantUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_AspNetUsers_ReviewedByUserId",
|
||||||
|
column: x => x.ReviewedByUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_Classrooms_ClassroomId",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClassroomReservations_Colleges_ApplicantCollegeId",
|
||||||
|
column: x => x.ApplicantCollegeId,
|
||||||
|
principalTable: "Colleges",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_AcademicTermId",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
column: "AcademicTermId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ApplicantCollegeId_Status_ReservationD~",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
columns: new[] { "ApplicantCollegeId", "Status", "ReservationDate" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ApplicantUserId_Status_CreatedAt",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
columns: new[] { "ApplicantUserId", "Status", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ClassroomId_ReservationDate_Status_Sta~",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
columns: new[] { "ClassroomId", "ReservationDate", "Status", "StartPeriod" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClassroomReservations_ReviewedByUserId",
|
||||||
|
table: "ClassroomReservations",
|
||||||
|
column: "ReviewedByUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClassroomReservations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -465,6 +465,92 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("Classrooms");
|
b.ToTable("Classrooms");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("AcademicTermId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApplicantCollegeId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ApplicantName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApplicantUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("AttendeeCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ClassroomId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactPhone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("varchar(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("PeriodCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Purpose")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReservationDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("ReviewComment")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("varchar(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReviewedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReviewedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("StartPeriod")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AcademicTermId");
|
||||||
|
|
||||||
|
b.HasIndex("ReviewedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate");
|
||||||
|
|
||||||
|
b.HasIndex("ApplicantUserId", "Status", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod");
|
||||||
|
|
||||||
|
b.ToTable("ClassroomReservations");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -3522,6 +3608,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Building");
|
b.Navigation("Building");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AcademicTermId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApplicantCollegeId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApplicantUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClassroomId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReviewedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("AcademicTerm");
|
||||||
|
|
||||||
|
b.Navigation("ApplicantCollege");
|
||||||
|
|
||||||
|
b.Navigation("ApplicantUser");
|
||||||
|
|
||||||
|
b.Navigation("Classroom");
|
||||||
|
|
||||||
|
b.Navigation("ReviewedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
|
||||||
|
public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<HashSet<Guid>> GetOccupiedClassroomIdsAsync(
|
||||||
|
AcademicTerm term,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
Guid? excludedReservationId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var occupiedIds = new HashSet<Guid>();
|
||||||
|
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
|
||||||
|
|
||||||
|
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(entry =>
|
||||||
|
entry.ClassroomId.HasValue &&
|
||||||
|
entry.SchedulePlan!.AcademicTermId == term.Id &&
|
||||||
|
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||||
|
entry.DayOfWeek == dayOfWeek &&
|
||||||
|
entry.StartWeek <= week &&
|
||||||
|
entry.EndWeek >= week &&
|
||||||
|
entry.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < entry.StartPeriod + entry.PeriodCount)
|
||||||
|
.Select(entry => new
|
||||||
|
{
|
||||||
|
entry.ClassroomId,
|
||||||
|
entry.WeekPattern,
|
||||||
|
entry.StartPeriod,
|
||||||
|
entry.PeriodCount
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var entry in scheduleEntries.Where(entry =>
|
||||||
|
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
|
||||||
|
FreeClassroomRules.PeriodsOverlap(
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
entry.StartPeriod,
|
||||||
|
entry.PeriodCount)))
|
||||||
|
{
|
||||||
|
occupiedIds.Add(entry.ClassroomId!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var examRoomIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(session =>
|
||||||
|
session.ClassroomId.HasValue &&
|
||||||
|
session.ExamPlan!.AcademicTermId == term.Id &&
|
||||||
|
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||||
|
session.ExamDate == reservationDate &&
|
||||||
|
session.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||||
|
.Select(session => session.ClassroomId!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(examRoomIds);
|
||||||
|
|
||||||
|
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(session =>
|
||||||
|
session.ClassroomId.HasValue &&
|
||||||
|
session.MakeupExamPlan!.AcademicTermId == term.Id &&
|
||||||
|
session.MakeupExamPlan.Status == MakeupExamPlanStatus.Published &&
|
||||||
|
session.ExamDate == reservationDate &&
|
||||||
|
session.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||||
|
.Select(session => session.ClassroomId!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(makeupExamRoomIds);
|
||||||
|
|
||||||
|
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
||||||
|
.Where(reservation =>
|
||||||
|
reservation.AcademicTermId == term.Id &&
|
||||||
|
reservation.Status == ClassroomReservationStatus.Approved &&
|
||||||
|
reservation.ReservationDate == reservationDate &&
|
||||||
|
reservation.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod <
|
||||||
|
reservation.StartPeriod + reservation.PeriodCount);
|
||||||
|
if (excludedReservationId.HasValue)
|
||||||
|
reservationQuery = reservationQuery.Where(reservation =>
|
||||||
|
reservation.Id != excludedReservationId.Value);
|
||||||
|
var reservationRoomIds = await reservationQuery
|
||||||
|
.Select(reservation => reservation.ClassroomId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(reservationRoomIds);
|
||||||
|
return occupiedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (int Week, int DayOfWeek) ResolveTeachingWeek(
|
||||||
|
AcademicTerm term,
|
||||||
|
DateOnly date)
|
||||||
|
{
|
||||||
|
var termStartDay = (int)term.StartDate.DayOfWeek;
|
||||||
|
var daysSinceMonday = (termStartDay + 6) % 7;
|
||||||
|
var firstWeekMonday = term.StartDate.AddDays(-daysSinceMonday);
|
||||||
|
var week = (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
|
||||||
|
var dayOfWeek = ((int)date.DayOfWeek + 6) % 7 + 1;
|
||||||
|
return (week, dayOfWeek);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,6 +216,7 @@ builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
|||||||
builder.Services.AddScoped<TimetableDataService>();
|
builder.Services.AddScoped<TimetableDataService>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
builder.Services.AddScoped<PersonalCalendarService>();
|
builder.Services.AddScoped<PersonalCalendarService>();
|
||||||
|
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ClassroomReservationsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_UsesApplicantsCollegeAndRejectsPublishedClassConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ReservationFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ApplicantScope);
|
||||||
|
var conflictedRequest = fixture.Request(
|
||||||
|
fixture.ScheduledClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
1,
|
||||||
|
2);
|
||||||
|
|
||||||
|
var conflicted = await controller.Create(
|
||||||
|
conflictedRequest,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(conflicted);
|
||||||
|
Assert.Empty(fixture.Db.ClassroomReservations);
|
||||||
|
|
||||||
|
var created = await controller.Create(
|
||||||
|
fixture.Request(
|
||||||
|
fixture.AvailableClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
1,
|
||||||
|
2),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(created);
|
||||||
|
var reservation = Assert.Single(fixture.Db.ClassroomReservations);
|
||||||
|
Assert.Equal(fixture.Applicant.Id, reservation.ApplicantUserId);
|
||||||
|
Assert.Equal(fixture.CollegeA.Id, reservation.ApplicantCollegeId);
|
||||||
|
Assert.Equal(ClassroomReservationStatus.Submitted, reservation.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Review_OnlyAllowsApplicantCollegeAndApprovesAvailableRoom()
|
||||||
|
{
|
||||||
|
await using var fixture = await ReservationFixture.CreateAsync();
|
||||||
|
var reservation = fixture.AddSubmittedReservation(
|
||||||
|
fixture.AvailableClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
3,
|
||||||
|
2);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var wrongCollege = await fixture.Controller(fixture.CollegeBReviewerScope)
|
||||||
|
.Approve(
|
||||||
|
reservation.Id,
|
||||||
|
new ReviewClassroomReservationRequest(""),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ForbidResult>(wrongCollege);
|
||||||
|
Assert.Equal(
|
||||||
|
ClassroomReservationStatus.Submitted,
|
||||||
|
reservation.Status);
|
||||||
|
|
||||||
|
var approved = await fixture.Controller(fixture.CollegeAReviewerScope)
|
||||||
|
.Approve(
|
||||||
|
reservation.Id,
|
||||||
|
new ReviewClassroomReservationRequest("同意"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(approved);
|
||||||
|
var stored = await fixture.Db.ClassroomReservations
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleAsync(item => item.Id == reservation.Id);
|
||||||
|
Assert.Equal(ClassroomReservationStatus.Approved, stored.Status);
|
||||||
|
Assert.Equal(fixture.CollegeAReviewer.Id, stored.ReviewedByUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_RechecksApprovedReservationConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ReservationFixture.CreateAsync();
|
||||||
|
fixture.Db.ClassroomReservations.Add(new ClassroomReservation
|
||||||
|
{
|
||||||
|
ApplicantUserId = fixture.Applicant.Id,
|
||||||
|
ApplicantName = fixture.Applicant.DisplayName,
|
||||||
|
ApplicantCollegeId = fixture.CollegeA.Id,
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
ClassroomId = fixture.AvailableClassroom.Id,
|
||||||
|
ReservationDate = fixture.ReservationDate,
|
||||||
|
StartPeriod = 5,
|
||||||
|
PeriodCount = 2,
|
||||||
|
AttendeeCount = 20,
|
||||||
|
Purpose = "已批准活动",
|
||||||
|
ContactPhone = "13800000000",
|
||||||
|
Status = ClassroomReservationStatus.Approved
|
||||||
|
});
|
||||||
|
var competing = fixture.AddSubmittedReservation(
|
||||||
|
fixture.AvailableClassroom.Id,
|
||||||
|
fixture.ReservationDate,
|
||||||
|
6,
|
||||||
|
2);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller(fixture.CollegeAReviewerScope)
|
||||||
|
.Approve(
|
||||||
|
competing.Id,
|
||||||
|
new ReviewClassroomReservationRequest(null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
var stored = await fixture.Db.ClassroomReservations
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleAsync(item => item.Id == competing.Id);
|
||||||
|
Assert.Equal(ClassroomReservationStatus.Submitted, stored.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReservationFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ReservationFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
College collegeA,
|
||||||
|
ApplicationUser applicant,
|
||||||
|
ApplicationUser collegeAReviewer,
|
||||||
|
AcademicTerm term,
|
||||||
|
Classroom scheduledClassroom,
|
||||||
|
Classroom availableClassroom,
|
||||||
|
DateOnly reservationDate,
|
||||||
|
ICurrentUserDataScope applicantScope,
|
||||||
|
ICurrentUserDataScope collegeAReviewerScope,
|
||||||
|
ICurrentUserDataScope collegeBReviewerScope)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
CollegeA = collegeA;
|
||||||
|
Applicant = applicant;
|
||||||
|
CollegeAReviewer = collegeAReviewer;
|
||||||
|
Term = term;
|
||||||
|
ScheduledClassroom = scheduledClassroom;
|
||||||
|
AvailableClassroom = availableClassroom;
|
||||||
|
ReservationDate = reservationDate;
|
||||||
|
ApplicantScope = applicantScope;
|
||||||
|
CollegeAReviewerScope = collegeAReviewerScope;
|
||||||
|
CollegeBReviewerScope = collegeBReviewerScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public College CollegeA { get; }
|
||||||
|
public ApplicationUser Applicant { get; }
|
||||||
|
public ApplicationUser CollegeAReviewer { get; }
|
||||||
|
public AcademicTerm Term { get; }
|
||||||
|
public Classroom ScheduledClassroom { get; }
|
||||||
|
public Classroom AvailableClassroom { get; }
|
||||||
|
public DateOnly ReservationDate { get; }
|
||||||
|
public ICurrentUserDataScope ApplicantScope { get; }
|
||||||
|
public ICurrentUserDataScope CollegeAReviewerScope { get; }
|
||||||
|
public ICurrentUserDataScope CollegeBReviewerScope { get; }
|
||||||
|
|
||||||
|
public static async Task<ReservationFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "A",
|
||||||
|
Name = "明德楼",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var scheduledClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A101",
|
||||||
|
Name = "A101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 60
|
||||||
|
};
|
||||||
|
var availableClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A102",
|
||||||
|
Name = "A102",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 80
|
||||||
|
};
|
||||||
|
var collegeA = new College
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机学院"
|
||||||
|
};
|
||||||
|
var collegeB = new College
|
||||||
|
{
|
||||||
|
Code = "EE",
|
||||||
|
Name = "电子工程学院"
|
||||||
|
};
|
||||||
|
var applicant = User("student", "测试申请人", collegeA.Id);
|
||||||
|
var collegeAReviewer = User(
|
||||||
|
"college-a",
|
||||||
|
"计算机学院审核人",
|
||||||
|
collegeA.Id);
|
||||||
|
var collegeBReviewer = User(
|
||||||
|
"college-b",
|
||||||
|
"电子工程学院审核人",
|
||||||
|
collegeB.Id);
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2099-1",
|
||||||
|
Name = "2099—2100 学年第一学期",
|
||||||
|
AcademicYear = "2099-2100",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2099, 9, 1),
|
||||||
|
EndDate = new DateOnly(2100, 1, 31),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = collegeA.Id,
|
||||||
|
Credits = 3,
|
||||||
|
TotalHours = 48,
|
||||||
|
LectureHours = 32,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var teachingTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2099-1-CS101-01",
|
||||||
|
Name = "程序设计基础教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var reservationDate = term.StartDate;
|
||||||
|
var (_, dayOfWeek) =
|
||||||
|
ClassroomReservationAvailabilityService.ResolveTeachingWeek(
|
||||||
|
term,
|
||||||
|
reservationDate);
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "v1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = teachingTask.Id,
|
||||||
|
ClassroomId = scheduledClassroom.Id,
|
||||||
|
DayOfWeek = dayOfWeek,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 18,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
scheduledClassroom,
|
||||||
|
availableClassroom,
|
||||||
|
collegeA,
|
||||||
|
collegeB,
|
||||||
|
applicant,
|
||||||
|
collegeAReviewer,
|
||||||
|
collegeBReviewer,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
teachingTask,
|
||||||
|
plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ReservationFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
collegeA,
|
||||||
|
applicant,
|
||||||
|
collegeAReviewer,
|
||||||
|
term,
|
||||||
|
scheduledClassroom,
|
||||||
|
availableClassroom,
|
||||||
|
reservationDate,
|
||||||
|
Scope(applicant, SystemRoles.Student),
|
||||||
|
Scope(collegeAReviewer, SystemRoles.CollegeAdmin),
|
||||||
|
Scope(collegeBReviewer, SystemRoles.CollegeAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassroomReservationsController Controller(
|
||||||
|
ICurrentUserDataScope currentScope) =>
|
||||||
|
new(
|
||||||
|
Db,
|
||||||
|
currentScope,
|
||||||
|
new ClassroomReservationAvailabilityService(Db));
|
||||||
|
|
||||||
|
public CreateClassroomReservationRequest Request(
|
||||||
|
Guid classroomId,
|
||||||
|
DateOnly date,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount) =>
|
||||||
|
new(
|
||||||
|
Term.Id,
|
||||||
|
classroomId,
|
||||||
|
date,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
30,
|
||||||
|
"学院学术活动",
|
||||||
|
"13800000000",
|
||||||
|
null);
|
||||||
|
|
||||||
|
public ClassroomReservation AddSubmittedReservation(
|
||||||
|
Guid classroomId,
|
||||||
|
DateOnly date,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount)
|
||||||
|
{
|
||||||
|
var reservation = new ClassroomReservation
|
||||||
|
{
|
||||||
|
ApplicantUserId = Applicant.Id,
|
||||||
|
ApplicantName = Applicant.DisplayName,
|
||||||
|
ApplicantCollegeId = CollegeA.Id,
|
||||||
|
AcademicTermId = Term.Id,
|
||||||
|
ClassroomId = classroomId,
|
||||||
|
ReservationDate = date,
|
||||||
|
StartPeriod = startPeriod,
|
||||||
|
PeriodCount = periodCount,
|
||||||
|
AttendeeCount = 20,
|
||||||
|
Purpose = "学院交流活动",
|
||||||
|
ContactPhone = "13800000000"
|
||||||
|
};
|
||||||
|
Db.ClassroomReservations.Add(reservation);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationUser User(
|
||||||
|
string userName,
|
||||||
|
string displayName,
|
||||||
|
Guid collegeId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = userName,
|
||||||
|
NormalizedUserName = userName.ToUpperInvariant(),
|
||||||
|
DisplayName = displayName,
|
||||||
|
CollegeId = collegeId,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ICurrentUserDataScope Scope(
|
||||||
|
ApplicationUser user,
|
||||||
|
string role) =>
|
||||||
|
new FixedScope(new CurrentUserScope(
|
||||||
|
user.Id,
|
||||||
|
user.DisplayName,
|
||||||
|
user.CollegeId,
|
||||||
|
role == SystemRoles.CollegeAdmin
|
||||||
|
? DataScope.College
|
||||||
|
: DataScope.Self,
|
||||||
|
new HashSet<string>([role])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedScope(CurrentUserScope current)
|
||||||
|
: ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
),
|
),
|
||||||
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
||||||
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
||||||
|
{
|
||||||
|
path: '/classroom-reservations',
|
||||||
|
label: hasAnyRole(['CollegeAdmin']) ? '教室借用审核' : '教室借用',
|
||||||
|
},
|
||||||
...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }),
|
...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }),
|
||||||
...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }),
|
...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ const router = createRouter({
|
|||||||
component: () => import('../views/FreeClassroomsView.vue'),
|
component: () => import('../views/FreeClassroomsView.vue'),
|
||||||
meta: { roles: ['Student'] },
|
meta: { roles: ['Student'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'classroom-reservations',
|
||||||
|
name: 'classroom-reservations',
|
||||||
|
component: () => import('../views/ClassroomReservationsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'course-selections',
|
path: 'course-selections',
|
||||||
name: 'course-selections',
|
name: 'course-selections',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user