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(
|
||||
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
|
||||
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))
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<ClassroomListItem>(
|
||||
@@ -582,6 +582,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
x.RoomType,
|
||||
x.TeachingVenueNature,
|
||||
x.Equipment,
|
||||
x.AvailabilityStatus,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
@@ -887,5 +888,6 @@ public sealed record ClassroomListItem(
|
||||
string RoomType,
|
||||
TeachingVenueNature TeachingVenueNature,
|
||||
string? Equipment,
|
||||
ClassroomAvailabilityStatus AvailabilityStatus,
|
||||
bool IsEnabled,
|
||||
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();
|
||||
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.Where(x => x.IsEnabled && x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal)
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Building!.SortOrder)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
@@ -614,9 +614,10 @@ public sealed class CourseAdjustmentsController(
|
||||
|
||||
if (request.ClassroomId.HasValue &&
|
||||
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
|
||||
x.Id == request.ClassroomId && x.IsEnabled,
|
||||
x.Id == request.ClassroomId && x.IsEnabled &&
|
||||
x.AvailabilityStatus == ClassroomAvailabilityStatus.Normal,
|
||||
cancellationToken))
|
||||
return ValidationProblem("目标教室不存在或已停用。");
|
||||
return ValidationProblem("目标教室不存在、已停用或正在维修。");
|
||||
|
||||
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