教室预约
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]
|
||||
[Route("api/timetables")]
|
||||
public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
public sealed class FreeClassroomsController(
|
||||
AppDbContext db,
|
||||
ClassroomReservationAvailabilityService reservationAvailability) : ControllerBase
|
||||
{
|
||||
[HttpGet("free-classrooms/options")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
@@ -297,9 +299,9 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var term = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.Id == academicTermId && x.IsEnabled)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == academicTermId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (term is null) return NotFound();
|
||||
var plan = await db.SchedulePlans.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -329,33 +331,17 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
if (activePeriodCount != requestedPeriods.Length)
|
||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||
|
||||
var candidates = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlanId == plan.Id &&
|
||||
x.ClassroomId.HasValue &&
|
||||
x.DayOfWeek == dayOfWeek &&
|
||||
x.StartWeek <= week &&
|
||||
x.EndWeek >= week &&
|
||||
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,
|
||||
periodCount,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount))
|
||||
.Select(x => x.ClassroomId.GetValueOrDefault())
|
||||
.ToHashSet();
|
||||
var reservationDate = ResolveReservationDate(term, week, dayOfWeek);
|
||||
if (reservationDate < term.StartDate || reservationDate > term.EndDate)
|
||||
return ValidationProblem("所选周次和星期不在学期日期范围内。");
|
||||
var occupiedIds =
|
||||
await reservationAvailability.GetOccupiedClassroomIdsAsync(
|
||||
term,
|
||||
reservationDate,
|
||||
startPeriod,
|
||||
periodCount,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
var rooms = db.Classrooms.AsNoTracking()
|
||||
.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(
|
||||
|
||||
@@ -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>();
|
||||
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||
Set<SchedulePublishJob>();
|
||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
Set<CourseSelectionRound>();
|
||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||
@@ -472,6 +474,49 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -56,6 +56,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260726_30_official_documents";
|
||||
private const string UnifiedMessageCenterMigration =
|
||||
"20260726_31_unified_message_center";
|
||||
private const string ClassroomReservationsMigration =
|
||||
"20260726_32_classroom_reservations";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -406,6 +408,19 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
UnifiedMessageCenterMigration,
|
||||
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
|
||||
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(
|
||||
@@ -1917,4 +1932,76 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
|
||||
"""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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3522,6 +3608,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
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<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<PersonalCalendarService>();
|
||||
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
|
||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||
|
||||
Reference in New Issue
Block a user