709 lines
28 KiB
C#
709 lines
28 KiB
C#
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);
|