Implement comprehensive academic statistics and grade analysis workflows
This commit is contained in:
1 parent
370a82d4d4
commit
f677cf8dca
24 files changed
+8703
-42
No files matched your search
@@ -557,7 +557,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
|||||||
.Select(x => new ClassroomListItem(
|
.Select(x => new ClassroomListItem(
|
||||||
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
|
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
|
||||||
x.Building.Name, x.Building.Campus!.Name, x.Capacity,
|
x.Building.Name, x.Building.Campus!.Name, x.Capacity,
|
||||||
x.Floor, x.RoomType, x.TeachingVenueNature, x.Equipment,
|
x.Floor, x.RoomType, x.TeachingVenueNature, x.Equipment, x.AvailabilityStatus,
|
||||||
x.IsEnabled, x.SortOrder))
|
x.IsEnabled, x.SortOrder))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return Ok(new PagedResult<ClassroomListItem>(
|
return Ok(new PagedResult<ClassroomListItem>(
|
||||||
@@ -582,6 +582,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
|||||||
x.RoomType,
|
x.RoomType,
|
||||||
x.TeachingVenueNature,
|
x.TeachingVenueNature,
|
||||||
x.Equipment,
|
x.Equipment,
|
||||||
|
x.AvailabilityStatus,
|
||||||
x.IsEnabled,
|
x.IsEnabled,
|
||||||
x.SortOrder))
|
x.SortOrder))
|
||||||
.ToListAsync(token),
|
.ToListAsync(token),
|
||||||
@@ -887,5 +888,6 @@ public sealed record ClassroomListItem(
|
|||||||
string RoomType,
|
string RoomType,
|
||||||
TeachingVenueNature TeachingVenueNature,
|
TeachingVenueNature TeachingVenueNature,
|
||||||
string? Equipment,
|
string? Equipment,
|
||||||
|
ClassroomAvailabilityStatus AvailabilityStatus,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
int SortOrder);
|
int SortOrder);
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Facilities;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using QRCoder;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/classroom-repairs")]
|
||||||
|
public sealed class ClassroomRepairsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ClassroomRepairNotificationService notificationService) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string Managers = SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.MaintenanceAdmin;
|
||||||
|
private const string Workers = Managers + "," + SystemRoles.MaintenanceStaff;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("report/{classroomId:guid}")]
|
||||||
|
public async Task<ActionResult> GetReportOptions(Guid classroomId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var room = await db.Classrooms.AsNoTracking().Where(x => x.Id == classroomId && x.IsEnabled)
|
||||||
|
.Select(x => new { x.Id, x.Code, x.Name, BuildingName = x.Building!.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (room is null) return NotFound();
|
||||||
|
var equipment = await db.ClassroomEquipment.AsNoTracking().Where(x => x.ClassroomId == classroomId)
|
||||||
|
.OrderBy(x => x.Category).ThenBy(x => x.Name)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Category, x.IsAvailable, x.Notes })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { classroom = room, equipment, categories = Enum.GetValues<ClassroomEquipmentCategory>() });
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("report/{classroomId:guid}/qr")]
|
||||||
|
public async Task<IActionResult> GetReportQrCode(
|
||||||
|
Guid classroomId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.Classrooms.AsNoTracking().AnyAsync(
|
||||||
|
x => x.Id == classroomId && x.IsEnabled, cancellationToken))
|
||||||
|
return NotFound();
|
||||||
|
var reportUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}" +
|
||||||
|
$"/classroom-repairs/report/{classroomId}";
|
||||||
|
using var generator = new QRCodeGenerator();
|
||||||
|
using var qrData = generator.CreateQrCode(reportUrl, QRCodeGenerator.ECCLevel.Q);
|
||||||
|
var svg = new SvgQRCode(qrData).GetGraphic(8);
|
||||||
|
return Content(svg, "image/svg+xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("report/{classroomId:guid}")]
|
||||||
|
public async Task<ActionResult> Create(
|
||||||
|
Guid classroomId,
|
||||||
|
ClassroomRepairCreateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var classroom = await db.Classrooms.FirstOrDefaultAsync(x => x.Id == classroomId && x.IsEnabled, cancellationToken);
|
||||||
|
if (classroom is null) return NotFound();
|
||||||
|
ClassroomEquipment? equipment = null;
|
||||||
|
if (request.ClassroomEquipmentId.HasValue)
|
||||||
|
{
|
||||||
|
equipment = await db.ClassroomEquipment.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == request.ClassroomEquipmentId && x.ClassroomId == classroomId, cancellationToken);
|
||||||
|
if (equipment is null) return ValidationProblem("所选设备不属于该教室。");
|
||||||
|
}
|
||||||
|
var ticket = new ClassroomRepairTicket
|
||||||
|
{
|
||||||
|
ClassroomId = classroomId,
|
||||||
|
ClassroomEquipmentId = equipment?.Id,
|
||||||
|
Category = request.Category,
|
||||||
|
FaultItem = request.FaultItem.Trim(),
|
||||||
|
Description = request.Description.Trim(),
|
||||||
|
ReporterName = request.ReporterName.Trim(),
|
||||||
|
ReporterContact = request.ReporterContact.Trim(),
|
||||||
|
ReporterUserId = CurrentUserId()
|
||||||
|
};
|
||||||
|
classroom.AvailabilityStatus = ClassroomAvailabilityStatus.UnderRepair;
|
||||||
|
if (equipment is not null) equipment.IsAvailable = false;
|
||||||
|
db.ClassroomRepairTickets.Add(ticket);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToRoleAsync(db, SystemRoles.MaintenanceAdmin,
|
||||||
|
"新增教室维修工单", $"{classroom.Name}:{ticket.FaultItem},请尽快分派维修员。",
|
||||||
|
linkUrl: "/classroom-repairs", cancellationToken: cancellationToken,
|
||||||
|
category: NotificationCategory.Schedule);
|
||||||
|
await notificationService.NotifyInitialTeachersAsync(ticket, cancellationToken);
|
||||||
|
return Created($"api/classroom-repairs/{ticket.Id}", new { ticket.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = Workers)]
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult> List(
|
||||||
|
ClassroomRepairTicketStatus? status,
|
||||||
|
string? keyword,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = db.ClassroomRepairTickets.AsNoTracking();
|
||||||
|
if (!User.IsInRole(SystemRoles.SuperAdmin) && !User.IsInRole(SystemRoles.AcademicAdmin) &&
|
||||||
|
!User.IsInRole(SystemRoles.MaintenanceAdmin))
|
||||||
|
query = query.Where(x => x.AssignedToUserId == CurrentUserId());
|
||||||
|
if (status.HasValue) query = query.Where(x => x.Status == status.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
query = query.Where(x => x.FaultItem.Contains(keyword) || x.Description.Contains(keyword) ||
|
||||||
|
x.Classroom!.Name.Contains(keyword) || x.Classroom.Building!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var result = await query.OrderByDescending(x => x.Status != ClassroomRepairTicketStatus.Completed)
|
||||||
|
.ThenByDescending(x => x.CreatedAt).Select(x => new
|
||||||
|
{
|
||||||
|
x.Id, x.ClassroomId, ClassroomName = x.Classroom!.Name, BuildingName = x.Classroom.Building!.Name,
|
||||||
|
x.ClassroomEquipmentId, EquipmentName = x.ClassroomEquipment != null ? x.ClassroomEquipment.Name : null,
|
||||||
|
x.Category, x.FaultItem, x.Description, x.ReporterName, x.ReporterContact, x.Status,
|
||||||
|
x.AssignedToUserId, AssigneeName = x.AssignedToUser != null ? x.AssignedToUser.DisplayName : null,
|
||||||
|
x.AssignedAt, x.StartedAt, x.CompletedAt, x.RepairResult, x.CreatedAt
|
||||||
|
}).ToListAsync(cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[HttpGet("workers")]
|
||||||
|
public async Task<ActionResult> GetWorkers(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await (from user in db.Users.AsNoTracking()
|
||||||
|
join userRole in db.UserRoles on user.Id equals userRole.UserId
|
||||||
|
join role in db.Roles on userRole.RoleId equals role.Id
|
||||||
|
where user.IsEnabled && role.Name == SystemRoles.MaintenanceStaff
|
||||||
|
orderby user.DisplayName
|
||||||
|
select new { user.Id, user.DisplayName, user.UserName, user.StaffNumber })
|
||||||
|
.Distinct().ToListAsync(cancellationToken));
|
||||||
|
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[HttpPut("{id:guid}/assign")]
|
||||||
|
public async Task<ActionResult> Assign(Guid id, ClassroomRepairAssignRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ticket = await db.ClassroomRepairTickets.FindAsync([id], cancellationToken);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
if (ticket.Status == ClassroomRepairTicketStatus.Completed) return Conflict("已完成工单不能重新分派。");
|
||||||
|
var workerExists = await (from userRole in db.UserRoles
|
||||||
|
join role in db.Roles on userRole.RoleId equals role.Id
|
||||||
|
join user in db.Users on userRole.UserId equals user.Id
|
||||||
|
where userRole.UserId == request.AssignedToUserId && user.IsEnabled &&
|
||||||
|
role.Name == SystemRoles.MaintenanceStaff
|
||||||
|
select user.Id).AnyAsync(cancellationToken);
|
||||||
|
if (!workerExists) return ValidationProblem("所选人员不是启用的维修员。");
|
||||||
|
ticket.AssignedToUserId = request.AssignedToUserId;
|
||||||
|
ticket.AssignedByUserId = CurrentUserId();
|
||||||
|
ticket.AssignedAt = DateTime.UtcNow;
|
||||||
|
ticket.Status = ClassroomRepairTicketStatus.Assigned;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotificationService.SendAsync(db, request.AssignedToUserId, "维修工单已分派",
|
||||||
|
$"工单“{ticket.FaultItem}”已分派给您,请及时处理。", "/classroom-repairs", cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = Workers)]
|
||||||
|
[HttpPut("{id:guid}/start")]
|
||||||
|
public async Task<ActionResult> Start(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ticket = await FindWorkerTicketAsync(id, cancellationToken);
|
||||||
|
if (ticket is null) return Forbid();
|
||||||
|
if (ticket.Status == ClassroomRepairTicketStatus.Completed) return Conflict("工单已完成。");
|
||||||
|
if (!ticket.AssignedToUserId.HasValue) return Conflict("工单尚未分派维修员。");
|
||||||
|
ticket.Status = ClassroomRepairTicketStatus.InProgress;
|
||||||
|
ticket.StartedAt ??= DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = Workers)]
|
||||||
|
[HttpPut("{id:guid}/complete")]
|
||||||
|
public async Task<ActionResult> Complete(Guid id, ClassroomRepairCompleteRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ticket = await FindWorkerTicketAsync(id, cancellationToken);
|
||||||
|
if (ticket is null) return Forbid();
|
||||||
|
if (ticket.Status == ClassroomRepairTicketStatus.Completed) return Conflict("工单已完成。");
|
||||||
|
ticket.Status = ClassroomRepairTicketStatus.Completed;
|
||||||
|
ticket.CompletedAt = DateTime.UtcNow;
|
||||||
|
ticket.RepairResult = request.RepairResult.Trim();
|
||||||
|
if (ticket.ClassroomEquipmentId.HasValue)
|
||||||
|
{
|
||||||
|
var equipment = await db.ClassroomEquipment.FindAsync([ticket.ClassroomEquipmentId.Value], cancellationToken);
|
||||||
|
if (equipment is not null) equipment.IsAvailable = true;
|
||||||
|
}
|
||||||
|
var hasOpenTicket = await db.ClassroomRepairTickets.AnyAsync(x =>
|
||||||
|
x.ClassroomId == ticket.ClassroomId && x.Id != ticket.Id &&
|
||||||
|
x.Status != ClassroomRepairTicketStatus.Completed, cancellationToken);
|
||||||
|
if (!hasOpenTicket)
|
||||||
|
{
|
||||||
|
var classroom = await db.Classrooms.FindAsync([ticket.ClassroomId], cancellationToken);
|
||||||
|
if (classroom is not null) classroom.AvailabilityStatus = ClassroomAvailabilityStatus.Normal;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[HttpGet("classrooms")]
|
||||||
|
public async Task<ActionResult> GetClassrooms(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Building!.Campus!.SortOrder).ThenBy(x => x.Building!.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.Select(x => new { x.Id, x.Code, x.Name, BuildingName = x.Building!.Name, x.AvailabilityStatus })
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[HttpGet("classrooms/{classroomId:guid}/equipment")]
|
||||||
|
public async Task<ActionResult> GetEquipment(Guid classroomId, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await db.ClassroomEquipment.AsNoTracking().Where(x => x.ClassroomId == classroomId)
|
||||||
|
.OrderBy(x => x.Category).ThenBy(x => x.Name).ToListAsync(cancellationToken));
|
||||||
|
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[HttpPost("classrooms/{classroomId:guid}/equipment")]
|
||||||
|
public async Task<ActionResult> CreateEquipment(Guid classroomId, ClassroomEquipmentRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.Classrooms.AnyAsync(x => x.Id == classroomId, cancellationToken)) return NotFound();
|
||||||
|
var entity = new ClassroomEquipment { ClassroomId = classroomId, Name = request.Name.Trim(), Category = request.Category, Notes = request.Notes?.Trim() };
|
||||||
|
db.ClassroomEquipment.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Created($"api/classroom-repairs/classrooms/{classroomId}/equipment/{entity.Id}", entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClassroomRepairTicket?> FindWorkerTicketAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ticket = await db.ClassroomRepairTickets.FindAsync([id], cancellationToken);
|
||||||
|
if (ticket is null) return null;
|
||||||
|
var isManager = User.IsInRole(SystemRoles.SuperAdmin) || User.IsInRole(SystemRoles.AcademicAdmin) || User.IsInRole(SystemRoles.MaintenanceAdmin);
|
||||||
|
return isManager || ticket.AssignedToUserId == CurrentUserId() ? ticket : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid? CurrentUserId() => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ClassroomRepairCreateRequest(
|
||||||
|
Guid? ClassroomEquipmentId, ClassroomEquipmentCategory Category,
|
||||||
|
[Required, MaxLength(80)] string FaultItem, [Required, MaxLength(2000)] string Description,
|
||||||
|
[Required, MaxLength(50)] string ReporterName, [Required, MaxLength(100)] string ReporterContact);
|
||||||
|
public sealed record ClassroomRepairAssignRequest(Guid AssignedToUserId);
|
||||||
|
public sealed record ClassroomRepairCompleteRequest([Required, MaxLength(2000)] string RepairResult);
|
||||||
|
public sealed record ClassroomEquipmentRequest([Required, MaxLength(80)] string Name,
|
||||||
|
ClassroomEquipmentCategory Category, [MaxLength(500)] string? Notes);
|
||||||
@@ -142,7 +142,7 @@ public sealed class CourseAdjustmentsController(
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var classrooms = await db.Classrooms.AsNoTracking()
|
var classrooms = await db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled && x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal)
|
||||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||||
.ThenBy(x => x.Building!.SortOrder)
|
.ThenBy(x => x.Building!.SortOrder)
|
||||||
.ThenBy(x => x.SortOrder)
|
.ThenBy(x => x.SortOrder)
|
||||||
@@ -614,9 +614,10 @@ public sealed class CourseAdjustmentsController(
|
|||||||
|
|
||||||
if (request.ClassroomId.HasValue &&
|
if (request.ClassroomId.HasValue &&
|
||||||
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
||||||
x.Id == request.ClassroomId && x.IsEnabled,
|
x.Id == request.ClassroomId && x.IsEnabled &&
|
||||||
|
x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal,
|
||||||
cancellationToken))
|
cancellationToken))
|
||||||
return ValidationProblem("目标教室不存在或已停用。");
|
return ValidationProblem("目标教室不存在、已停用或正在维修。");
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 3 of 24 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user