自动排课完成后逐条显示“需人工处理”的任务和原因,并提供“检查排课约束”入口;刷新页面后仍可读取最近一次结果。
批量排课约束新增校区、教学楼、多教室统一指定,也支持清除原有限定。 课表新增“总视图”,同一时段、不同周次课程会并列显示。 周视图按教学周过滤,支持上一周、下一周、周次下拉和回到本周。 日视图按周次过滤;连续两节课程会跨两行完整占格。
This commit is contained in:
1 parent
db91988264
commit
e4a12931ea
8 files changed
+647
-46
No files matched your search
@@ -245,10 +245,13 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
if (!request.SchedulingMode.HasValue &&
|
if (!request.SchedulingMode.HasValue &&
|
||||||
!request.RequiresClassroom.HasValue &&
|
!request.RequiresClassroom.HasValue &&
|
||||||
request.AllowedDayOfWeeks is null &&
|
request.AllowedDayOfWeeks is null &&
|
||||||
|
!request.UpdateClassroomScope &&
|
||||||
!request.UpdatePeriodRange &&
|
!request.UpdatePeriodRange &&
|
||||||
!request.EarliestPeriod.HasValue &&
|
!request.EarliestPeriod.HasValue &&
|
||||||
!request.LatestPeriod.HasValue)
|
!request.LatestPeriod.HasValue)
|
||||||
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
return ValidationProblem("请至少选择一项需要批量修改的设置。");
|
||||||
|
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
|
||||||
|
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
|
||||||
|
|
||||||
var tasks = await db.TeachingTasks
|
var tasks = await db.TeachingTasks
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -261,6 +264,48 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||||
await HasScheduleEntriesAsync(taskIds, cancellationToken))
|
await HasScheduleEntriesAsync(taskIds, cancellationToken))
|
||||||
return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。");
|
return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。");
|
||||||
|
if (request.UpdateClassroomScope && tasks.Any(task =>
|
||||||
|
(request.SchedulingMode ?? task.SchedulingMode) ==
|
||||||
|
TeachingTaskSchedulingMode.Flexible))
|
||||||
|
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
|
||||||
|
|
||||||
|
Building? building = null;
|
||||||
|
List<Classroom> allowedRooms = [];
|
||||||
|
if (request.UpdateClassroomScope)
|
||||||
|
{
|
||||||
|
if (request.RequiredBuildingId.HasValue)
|
||||||
|
{
|
||||||
|
building = await db.Buildings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == request.RequiredBuildingId && x.IsEnabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (building is null)
|
||||||
|
return ValidationProblem("指定教学楼不存在或已停用。");
|
||||||
|
if (request.RequiredCampusId.HasValue &&
|
||||||
|
building.CampusId != request.RequiredCampusId)
|
||||||
|
return ValidationProblem("指定教学楼不属于所选校区。");
|
||||||
|
}
|
||||||
|
if (request.RequiredCampusId.HasValue &&
|
||||||
|
!await db.Campuses.AnyAsync(
|
||||||
|
x => x.Id == request.RequiredCampusId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("指定校区不存在或已停用。");
|
||||||
|
|
||||||
|
var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? [];
|
||||||
|
allowedRooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => roomIds.Contains(x.Id) && x.IsEnabled)
|
||||||
|
.Include(x => x.Building)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (allowedRooms.Count != roomIds.Length)
|
||||||
|
return ValidationProblem("部分指定教室不存在或已停用。");
|
||||||
|
if (building is not null &&
|
||||||
|
allowedRooms.Any(x => x.BuildingId != building.Id))
|
||||||
|
return ValidationProblem("指定教室必须位于所选教学楼。");
|
||||||
|
if (request.RequiredCampusId.HasValue &&
|
||||||
|
allowedRooms.Any(x =>
|
||||||
|
x.Building!.CampusId != request.RequiredCampusId))
|
||||||
|
return ValidationProblem("指定教室必须位于所选校区。");
|
||||||
|
}
|
||||||
|
|
||||||
var constraints = await db.TeachingTaskScheduleConstraints
|
var constraints = await db.TeachingTaskScheduleConstraints
|
||||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
@@ -282,6 +327,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
var changesConstraint =
|
var changesConstraint =
|
||||||
request.RequiresClassroom.HasValue ||
|
request.RequiresClassroom.HasValue ||
|
||||||
request.AllowedDayOfWeeks is not null ||
|
request.AllowedDayOfWeeks is not null ||
|
||||||
|
request.UpdateClassroomScope ||
|
||||||
request.UpdatePeriodRange;
|
request.UpdatePeriodRange;
|
||||||
if (!changesConstraint) continue;
|
if (!changesConstraint) continue;
|
||||||
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
|
||||||
@@ -306,6 +352,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
|||||||
? null
|
? null
|
||||||
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
||||||
}
|
}
|
||||||
|
if (request.UpdateClassroomScope)
|
||||||
|
{
|
||||||
|
constraint.RequiresClassroom = true;
|
||||||
|
constraint.RequiredCampusId = request.RequiredCampusId;
|
||||||
|
constraint.RequiredBuildingId = request.RequiredBuildingId;
|
||||||
|
db.TeachingTaskAllowedClassrooms.RemoveRange(
|
||||||
|
constraint.AllowedClassrooms);
|
||||||
|
constraint.AllowedClassrooms = allowedRooms.Select(room =>
|
||||||
|
new TeachingTaskAllowedClassroom
|
||||||
|
{
|
||||||
|
ClassroomId = room.Id
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
if (request.UpdatePeriodRange)
|
if (request.UpdatePeriodRange)
|
||||||
{
|
{
|
||||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||||
@@ -374,6 +433,10 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
|||||||
TeachingTaskSchedulingMode? SchedulingMode,
|
TeachingTaskSchedulingMode? SchedulingMode,
|
||||||
bool? RequiresClassroom,
|
bool? RequiresClassroom,
|
||||||
IReadOnlyList<int>? AllowedDayOfWeeks,
|
IReadOnlyList<int>? AllowedDayOfWeeks,
|
||||||
|
bool UpdateClassroomScope,
|
||||||
|
Guid? RequiredCampusId,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyList<Guid>? AllowedClassroomIds,
|
||||||
bool UpdatePeriodRange,
|
bool UpdatePeriodRange,
|
||||||
[Range(1, 30)] int? EarliestPeriod,
|
[Range(1, 30)] int? EarliestPeriod,
|
||||||
[Range(1, 30)] int? LatestPeriod);
|
[Range(1, 30)] int? LatestPeriod);
|
||||||
@@ -353,12 +353,12 @@ public sealed class SchedulesController(
|
|||||||
|
|
||||||
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
|
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
|
||||||
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
|
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
|
||||||
GetActiveAutomaticScheduleJob(
|
GetLatestAutomaticScheduleJob(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||||
.Where(x => x.ActiveSchedulePlanId == planId)
|
.Where(x => x.SchedulePlanId == planId)
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
return Ok(job is null ? null : ToResponse(job));
|
return Ok(job is null ? null : ToResponse(job));
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ScheduleSettingsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Batch_constraints_assign_same_classroom_scope_to_all_tasks()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "J1",
|
||||||
|
Name = "第一教学楼",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "J1-201",
|
||||||
|
Name = "J1-201",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 80
|
||||||
|
};
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
College = college,
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-F",
|
||||||
|
Name = "2026 秋季",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 7),
|
||||||
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
|
};
|
||||||
|
var firstTask = NewTask("TASK-01", course, term);
|
||||||
|
var secondTask = NewTask("TASK-02", course, term);
|
||||||
|
db.AddRange(
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
|
college,
|
||||||
|
course,
|
||||||
|
term,
|
||||||
|
firstTask,
|
||||||
|
secondTask);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ScheduleSettingsController(db);
|
||||||
|
var result = await controller.SaveConstraintsBatch(
|
||||||
|
new TeachingTaskScheduleConstraintBatchRequest(
|
||||||
|
term.Id,
|
||||||
|
[firstTask.Id, secondTask.Id],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
campus.Id,
|
||||||
|
building.Id,
|
||||||
|
[classroom.Id],
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var constraints = await db.TeachingTaskScheduleConstraints
|
||||||
|
.Include(item => item.AllowedClassrooms)
|
||||||
|
.OrderBy(item => item.TeachingTaskId)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(2, constraints.Count);
|
||||||
|
Assert.All(constraints, constraint =>
|
||||||
|
{
|
||||||
|
Assert.True(constraint.RequiresClassroom);
|
||||||
|
Assert.Equal(campus.Id, constraint.RequiredCampusId);
|
||||||
|
Assert.Equal(building.Id, constraint.RequiredBuildingId);
|
||||||
|
Assert.Equal(
|
||||||
|
classroom.Id,
|
||||||
|
Assert.Single(constraint.AllowedClassrooms).ClassroomId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTask NewTask(
|
||||||
|
string taskNumber,
|
||||||
|
Course course,
|
||||||
|
AcademicTerm term) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
TaskNumber = taskNumber,
|
||||||
|
Name = $"{course.Name}教学班",
|
||||||
|
Course = course,
|
||||||
|
AcademicTerm = term,
|
||||||
|
Capacity = 60,
|
||||||
|
WeeklyHours = 4,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
}
|
||||||
Loaded 3 of 8 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user