跳转到内容

KEC 排课算法与教材内聚策略完整说明

主代码:server/src/services/arrange/auto-arrange.js 优化服务:server/src/services/arrange/optimize.js 配置:server/src/constants/index.jsTEXTBOOK_COHESIONTABU_SEARCH) 版本:v1.0.0(正式发布版,2026-07-30 版本基线重置) 分析对象:server/src/services/arrange/ 目录(auto-arrange.jsoptimize.jsqueries.jsbatch.jsvalidate.jstabu-search.js


一、代码结构

排课逻辑已从单文件 teaching-arrange.service.js 重构为 server/src/services/arrange/ 目录,按职责拆分:

文件职责主要导出/函数
arrange/queries.js数据查询与匹配函数getClassesWithCoursegetTeachersForCourseisTextbookMatchisCollegeEligibleisLevelEligibleparseSemester
arrange/validate.js课时设置校验validateHourSettings
arrange/auto-arrange.js排课主算法、评分、置换回溯、结果构建、统计导出autoArrangebatchLockscalcMatchScoreisTeacherEligiblecalcAllMatchRatesdiagnoseFailureselectBestTeachertrySwapOne内部函数buildTeacherConstraintstrySwapUnassignedbuildResult
arrange/tabu-search.js禁忌搜索优化层(可选)tabuOptimize(Insert/Shift/Swap 邻域、禁忌表、aspiration criterion)
arrange/optimize.js排课优化服务(跨课程全局优化,可选)导出runOptimizeScheduleapplyOptimizeResult内部calculateMetrics(α/β 惩罚)、meetsMinimumThresholdbuildTeacherConstraints
arrange/batch.js批量排课batchAutoArrange(从 auto-arrange.js 导入 batchLocks
teaching-arrange.service.js入口转发仅 re-export,无业务逻辑

1.1 并发锁

范围用途
arrangeLockscourseId:semesterStr防止同一课程在同一学期被并发排课
batchLockssemesterStr防止批量排课与单课程排课并发;批量进行中,同 semester 的单课程 autoArrange 直接拒绝(批量内部调用通过 options.skipBatchLockCheck=true 绕过)

上述锁均为进程内存级别,仅适用于单进程部署(如 PM2 fork 模式)。多实例部署需改用 Redis 分布式锁或数据库行级锁。


二、核心概念

2.1 排课目标

为指定课程在指定学期内,将教师分配到所有需要该课程的班级,同时满足:

  • 教师的学院偏好和层次偏好
  • 教材匹配度与教材内聚度
  • 教师课时容量约束
  • 教材数量硬上限
  • 保护手动排课记录不被覆盖

v2 算法严格遵循以下设计原则:

  1. 教师拿教材的方式:所有教师先拿完第一本教材,再拿第二本
  2. 学院优先:优先拿完一个学院的班级,再拿其他学院
  3. 意向约束严格:指定了意向学院或意向层次的教师,必须严格按照指定的类型来优先拿取教材
  4. 无指定按容量:未指定任何意向的教师,按课时容量去拿
  5. 手动排课追踪:手动排课的教材和课时需要计入教师状态

2.2 与旧算法(v1)的区别

维度旧算法(v1)新算法(v2)
教材分配顺序教师主动选教材组按教材组顺序,所有教师先拿完第一本
学院内聚排序时优先同学院同教材组内严格按学院排序
意向约束评分权重体现严格过滤,不匹配直接排除
有/无指定教师混合处理分阶段处理,有指定优先
手动排课追踪仅追踪教材追踪教材+学院+课时

2.3 学期格式

学期使用 "YYYY-YYYY-N" 格式,例如 "2025-2026-1" 表示 2025-2026 学年第一学期。

学期序号计算由 semester.service.jscalcClassSemester 完成:

年级 = 学年起始年 - 入学年份 + 1
当前学期序号 = (年级 - 1) × 2 + 学期索引

三、核心数据结构

3.1 教师约束对象(buildTeacherConstraints 输出)

在原始教师数据上扩展以下字段:

字段类型说明
standardHours / maxHoursnumber来自 hourSettings 的标准 / 满载课时
effectiveTotalnumber已排总周课时(扣除本课程已自动排部分 + 批量前序虚拟课时)
courseExistingHoursnumber本课程已排周课时
standardCap / fullCapnumber当前可继续分配的标准 / 满载容量上限(已扣除 effectiveTotal,并受 defaultWeeklyHours 天花板约束)
teacherHourCapnumber | null教师自定义课时上限(defaultWeeklyHours - effectiveTotal
assignedHoursnumber本轮已分配周课时(初始为 0,分配时累加)
inherentTextbookIdsnumber[]排课前固有的教材快照,运行时累加不污染匹配判断
assignedTextbookIdsSet<number>本轮(含手动排课)已分配教材集合,动态更新
assignedCollegeIdsSet<number>本轮已分配学院集合,用于学院内聚奖励
textbookIdsnumber[]教材列表(动态累加,含固有 + 本轮新增)

3.2 班级对象

getClassesWithCourse 返回的班级上挂载 textbookIds: number[](由 textbooks 映射而来),供评分与分组使用。

每条候选班级携带:classIdclassNamecollegeIdmajorIdtrainingLevelIdgradeweeklyHoursweeksCounttotalHourstextbooks(数组)等。


四、班级筛选逻辑

4.1 班级纳入条件(getClassesWithCourse

一个班级被纳入某课程的排课候选,必须同时满足:

  • 未离校、学制内(由 getActiveClassFilter 统一生成查询条件)
  • 该班级关联的培养方案中包含目标课程
  • 该课程在当前学期有课时安排(plan_course_semesters 记录)

4.2 培养方案匹配优先级

使用 findBestMatchPlan(来自 plan.service.js)选定唯一最佳方案,三级优先级:

优先级条件说明
1class.custom_plan_id === plan.id班级绑定了自定义培养方案
2class.major_id === plan.major_id按专业匹配
3class.training_level_id === plan.training_level_id按培养层次匹配(兜底)

五、教师筛选逻辑

5.1 教师纳入条件(getTeachersForCourse

条件说明
状态活跃status = 'active'
课程关联通过 teacher_courses 关联表与目标课程关联

5.2 教师字段

字段来源用途
personnelTypeteachers.personnel_type确定课时容量档次(full_time / part_time / external)
defaultWeeklyHoursteachers.default_weekly_hours教师总周课时上限(跨所有课程),可选
schedulingCollegeIdsteacher_scheduling_colleges学院意向
schedulingLevelIdsteacher_scheduling_levels层次意向
textbookIds / inherentTextbookIds实际排课或培养方案推导固有教材快照
totalWeeklyHours本学期所有排课课时总和整体工作量
courseHours本课程本学期已排课时本课程课时统计
assignedTextbookIds初始化为空 Set运行时累加的教材集合
assignedCollegeIds初始化为空 Set运行时累加的学院集合

5.3 教材 ID 推导(两级推导策略)

  1. 实际排课推导(优先):跨课程查询教师在该学期的全部排课记录,追溯每个班级的最佳匹配培养方案,提取 plan_textbooks 中的教材 ID。
  2. 培养方案推导(兜底):对无排课记录的教师,根据 TEXTBOOK_COHESION.FALLBACK_EMPTY 决定:
    • FALLBACK_EMPTY = true(当前默认):教材为空集合(收紧策略,避免 isTextbookMatch 对新教师全通过)
    • FALLBACK_EMPTY = false:取所有关联培养方案中该课程教材 ID 的并集(保守超集)

5.4 固有教材快照固化

getTeachersForCourse 返回的教师对象包含:

  • textbookIds:初始等于 inherentTextbookIds,运行时会被 recordAssignment 累加
  • inherentTextbookIds:固化的快照,运行时不变
  • assignedTextbookIdsnew Set(),本轮运行时累加

buildTeacherConstraints(auto-arrange.js)再次固化快照:

javascript
inherentTextbookIds: [...(t.textbookIds || [])],

确保 isTextbookMatch 在整个排课过程中读取的是入场时的教材集合。


六、教材匹配核心:isTextbookMatch

位置arrange/queries.js

javascript
export function isTextbookMatch(teacher, cls) {
  const inherentIds = teacher.inherentTextbookIds ?? teacher.textbookIds;
  if (!cls.textbookIds?.length) return false; // 班级无教材,不匹配
  if (!inherentIds?.length) return true; // 教师无固有教材约束,能教任何教材
  return inherentIds.some((tid) => cls.textbookIds.includes(tid));
}

实际行为

条件返回值说明
班级无教材(cls.textbookIds 为空)false班级无教材,不需要匹配
教师无固有教材约束(inherentIds 为空)true教师能教任何教材
二者均有取交集inherentIdscls.textbookIds 有交集即匹配

关键设计:始终使用教师固有教材快照inherentTextbookIds),不受本次分配累加污染。教师无固有教材约束时,对任何有教材的班级都返回 true

与旧文档"修复 1"的差异

旧文档(v1.0)预期 isTextbookMatch 对新教师返回 false。实际实现相反:当 FALLBACK_EMPTY=true 时,inherentTextbookIds=[]isTextbookMatch 返回 true,新教师不会被教材匹配阶段屏蔽。这一设计让新教师具备教材匹配资格,由 v2 五阶段算法的"先拿第一本教材"策略自然形成教材归属。


七、约束条件

7.1 默认课时设置(DEFAULT_HOUR_SETTINGS

人员类型标准课时 (standard)最大课时 (max)
专职 (full_time)1620
兼职 (part_time)1216
外聘 (external)1216

存储位置

  • 全局默认:system_settingsteaching_hour_settings
  • 按课程自定义:键 teaching_hour_settings_{course_id}

7.2 排课模式

模式使用上限行为
标准模式 (standard)standard保守,教师总课时不超过标准值
全量模式 (full)max宽松,允许教师课时达到最大值

7.3 容量计算(buildTeacherConstraints

javascript
autoHoursForCourse = autoHoursMap.get(t.id) || 0;
extraHours = extraTeacherHours?.get(t.id) || 0;
effectiveTotal = t.totalWeeklyHours - autoHoursForCourse + extraHours;

teacherHourCap =
  t.defaultWeeklyHours != null
    ? Math.max(0, t.defaultWeeklyHours - effectiveTotal)
    : null;

standardCap =
  teacherHourCap != null
    ? Math.min(teacherHourCap, Math.max(0, setting.standard - effectiveTotal))
    : Math.max(0, setting.standard - effectiveTotal);

fullCap =
  teacherHourCap != null
    ? Math.min(teacherHourCap, Math.max(0, setting.max - effectiveTotal))
    : Math.max(0, setting.max - effectiveTotal);

关键点

  • effectiveTotal 包含全学期所有课程的总课时,不只是本课程
  • 本课程的自动排课课时被减去,因为这些记录将被删除并重新分配
  • 手动排课和其他课程的排课保持不变并计入容量
  • defaultWeeklyHours 是教师总周课时上限,会同时收紧 standardCapfullCap

7.4 资格检查(isTeacherEligible

教师被分配一个班级前,必须通过以下硬约束:

约束公式
容量约束t.assignedHours + cls.weeklyHours ≤ (standardCap 或 fullCap)
学院意向教师有 schedulingCollegeIds 时,必须包含 cls.collegeId
层次意向教师有 schedulingLevelIds 时,cls.trainingLevelId 必须在其中
教材硬上限t.assignedTextbookIds.size + 新增教材数 ≤ MAX_TEXTBOOKS_PER_TEACHER

7.5 课时设置校验(validateHourSettings

full_time / part_time / external 三种类型逐项校验:

  • 必须存在 standardmax
  • 均为有效数字
  • standard ≥ 1
  • 1 ≤ max ≤ 40
  • standard ≤ max

任一项不满足即抛错。


八、算法流程(v2,5 阶段)

8.1 前置处理

  1. 加载教师候选池(getTeachersForCourse
  2. 加载班级候选池(getClassesWithCourse
  3. 加载本课程本学期的手动排课记录,提取 manualClassIds
  4. 从候选池中排除手动排课的班级(但手动排课的教材和课时仍计入教师上下文)
  5. 校验周课时合法性:weeklyHours ≤ 0 的班级直接进入未分配列表(原因:课时配置异常(周课时为0或负数)),不参与排课
  6. 构建 teacherConstraints(含 effectiveTotalstandardCapfullCapteacherHourCap
  7. 追踪手动排课的教材与学院到教师的 assignedTextbookIds / assignedCollegeIds
  8. 容量可行性预检:若 总班级课时 > 总教师容量,添加警告(非错误)
  9. 按教材对班级分组textbookGroups):同教材组内按学院排序;每组维护可变可用班级池 groupAvailable

8.2 教材分组预处理

javascript
const textbookGroups = new Map();
for (const cls of validClassesToAssign) {
  const key =
    cls.textbookIds && cls.textbookIds.length > 0
      ? cls.textbookIds.slice().sort().join(",")
      : "__no_textbook__";
  if (!textbookGroups.has(key)) textbookGroups.set(key, []);
  textbookGroups.get(key).push(cls);
}

// 每组内按学院排序(保证同教材内优先拿完一个学院)
for (const [key, group] of textbookGroups) {
  group.sort((a, b) => {
    if (a.collegeId !== b.collegeId) return a.collegeId - b.collegeId;
    return a.classId - b.classId;
  });
}

8.3 手动排课教材追踪

手动排课的班级虽然不参与自动排课,但教师已分配的教材和课时需要计入:

javascript
for (const ma of manualAssignments) {
  const teacher = teacherConstraints.find((t) => t.id === ma.teacher_id);
  if (!teacher) continue;
  const cls = allClassMap.get(ma.class_id);
  if (!cls) continue;
  for (const tid of (cls.textbooks || []).map((tb) => tb.id)) {
    teacher.assignedTextbookIds.add(tid);
    if (!teacher.textbookIds.includes(tid)) teacher.textbookIds.push(tid);
  }
  teacher.assignedCollegeIds.add(cls.collegeId);
}

8.4 五阶段定义

阶段处理对象筛选条件说明
阶段 1有指定意向的教师schedulingCollegeIdsschedulingLevelIds 非空;本轮已分配教材的教师必须包含当前教材组的教材;0 本教师可拿任意教材严格按意向分配,拿第一本教材
阶段 2无指定意向的教师无意向约束;本轮已分配教材的教师必须包含当前教材组的教材;0 本教师可拿任意教材教师视角选组,复用 takeGroupsForTeacher(strictPref=false)
阶段 3所有教师已持有此教材组的教师追加同教材班级(不增加教材数)
阶段 4所有教师未持有此教材组、有剩余容量、且新增后不超 MAX_TEXTBOOKS_PER_TEACHER教师视角选组,复用 takeGroupsForTeacher(吸收原阶段4逻辑)
阶段 5兜底剩余班级用 assignRound 放宽约束处理综合评分分配

8.5 阶段内通用逻辑

  • 按教材组遍历 groupAvailable
  • 筛选符合条件的教师,按剩余容量降序排序
  • 教师通过 takeClassesForTeacher 从可用班级中拿取,直到课时满或无匹配班级
  • takeClassesForTeacher 内部按"教师已分配的学院优先 → 学院 ID → 班级 ID"排序班级
  • 教材上限追踪:假设拿取后的教材集合不能超过 MAX_TEXTBOOKS_PER_TEACHER
  • 拿取后调用 recordAssignment 更新状态

8.6 意向匹配(isPrefMatch

  • 有学院意向的教师,只能拿匹配学院的班级
  • 有层次意向的教师,只能拿匹配层次的班级
  • 阶段 1、3、4 使用严格意向检查;阶段 2 关闭严格检查(教师本身无意向)

语义边界isPrefMatch 仅检查学院意向和层次意向,不含教材上限检查与容量约束。 教材上限由 takeClassesForTeacher 内部的 useTbLimit 检查兜底(基于 projectedTextbooks 投影集合), 容量约束由 takeClassesForTeacherremainingCap 检查兜底。 isTeacherEligible 是更完整的约束检查(含教材+容量),供 assignRound 兜底使用。

8.7 兜底分配(assignRound,阶段 5)

  • 按候选教师数量升序排序班级(少候选优先),同分时按教材签名排序
  • 对每个班级,筛选 isTeacherEligible 通过的教师,且教材硬上限检查:已达上限的教师只能接已持有教材的班级
  • calcMatchScore 评分 + loadRate 负载均衡,调用 selectBestTeacher 选最优教师
  • 无候选则归入 unassigned

8.8 置换回溯(trySwapUnassigned

阶段 5 后对未分配班级尝试置换:

  • 单轮置换(不递归),复杂度 O(U × T × A)
  • 对未分配班级 U:遍历所有教师 T(含已满的),找 T 当前某班级 V 能被其他教师 T'' 接管,且 T 腾出容量后能容纳 U
  • 置换前校验:
    • T 对 U 的学院/层次资格
    • T'' 对 V 的学院/层次资格
    • T 置换后教材数 ≤ MAX_TEXTBOOKS_PER_TEACHER
    • T'' 接管 V 后教材数 ≤ MAX_TEXTBOOKS_PER_TEACHER
  • weeklyHours ≤ 0 的班级不参与置换

8.9 教材亲和级联

教师被分配一个班级后,其 assignedTextbookIdstextbookIds 会扩展包含该班级的教材 ID,形成级联的教材亲和效应——后续阶段评分时同教材教师会获得更高分。

8.10 持久化(非预览模式)

采用全量替换策略,在单个数据库事务中完成:

  1. 删除本课程本学期的所有自动排课记录(is_auto = true
  2. 重新聚合各教师当前学期实际总课时(已扣除本课程旧自动安排)
  3. 容量二次校验:超载的分配降级跳过,归入 unassigned,原因:并发排课导致教师容量已满,已跳过
  4. 教材上限二次校验:违规的不写入 DB
  5. 批量插入通过校验的分配记录

8.11 事务内二次校验

非预览模式写入数据库前,对每位教师做容量与教材上限的二次校验:

  • baselineassignedTextbookIds ∩ inherentTextbookIds(入场前已存在的教材)
  • written:本次事务已通过校验的新增教材
  • projectedbaseline ∪ written ∪ 当前班级教材
  • projected.size > MAX_TEXTBOOKS_PER_TEACHER,该分配降级跳过,归入 unassigned

九、评分机制

9.1 calcMatchScore 加权评分

评分项权重/分值来源触发条件
学院意向匹配+COLLEGE_WEIGHT(5)constants教师上课学院包含班级学院
学院内聚奖励+3硬编码教师已接过该学院班级(assignedCollegeIds
层次意向匹配+LEVEL_WEIGHT(5)constants教师培养层次包含班级层次
已分配教材匹配+ASSIGNED_WEIGHT(10)constants本轮已分配的教材与班级教材有交集
固有教材匹配+INHERENT_WEIGHT(4)constants教师固有教材与班级教材有交集(isTextbookMatch
新增教材惩罚-PENALTY_PER_NEW × N(10 × N)constants接此班需新增 N 本教材
0 本教材奖励+ZERO_TEXTBOOK_BONUS(30)constants教师尚未持有任何教材
同教材追加奖励+TEXTBOOK_COUNT_BONUS_1_SAME(10)constants教师已持有 1 本教材且此班级教材无新增
新增教材强惩罚-TEXTBOOK_COUNT_PENALTY_1_NEW(300)constants教师已达 MAX_TEXTBOOKS_PER_TEACHER 且需新增教材,或 1 本教师接新教材

9.2 教材数量分级奖惩(二轮优化)

TEXTBOOK_COHESION.ENABLED = true 时启用,配合 MAX_TEXTBOOKS_PER_TEACHER = 2

教师已有教材数班级是否新增教材结果
0 本任意score += 30(ZERO_TEXTBOOK_BONUS)
1 本不新增(同教材)score += 10(TEXTBOOK_COUNT_BONUS_1_SAME)
1 本新增return score - 300(软性强惩罚,兜底无其他候选时仍可分配)
MAX_TEXTBOOKS_PER_TEACHER(即 ≥2)新增return score - 300(软性强惩罚;另在 assignRound 候选过滤中被硬排除)
≥2(不可达分支,仅当调高上限时生效)score -= TEXTBOOK_COUNT_PENALTY_2(20)
≥3(不可达分支)score -= TEXTBOOK_COUNT_PENALTY_3PLUS(150)

注:同教材奖励与新增教材惩罚均引用 constants 配置(TEXTBOOK_COUNT_BONUS_1_SAME=10TEXTBOOK_COUNT_PENALTY_1_NEW=300),早期版本的硬编码 +10 / -10000 已在 P1-4 修复中移除。-300 为软性强惩罚(≈ 理论最大正分 57 的 5.3 倍),非实质禁止:兜底阶段无其他候选时仍可分配。

9.3 教师选择(selectBestTeacher

F5 修复:原阈值分段比较器存在非传递性(a>b, b>c, c>a)导致 Array.prototype.sort 结果与 V8 引擎实现相关。 改为严格弱序比较器(strict weak ordering),通过分档 + 确定性兜底消除歧义:

javascript
const st = WORKLOAD_BALANCE.SCORE_THRESHOLD; // 1
const lt = WORKLOAD_BALANCE.LOAD_RATE_THRESHOLD; // 0.2
const sorted = [...candidates].sort((a, b) => {
  // 1. 评分分档降序(同档内差异 < st 视为等价)
  const scoreBucketA = Math.floor(a.score / st);
  const scoreBucketB = Math.floor(b.score / st);
  if (scoreBucketA !== scoreBucketB) return scoreBucketB - scoreBucketA;
  // 2. 负载率分档升序(同档内差异 < lt 视为等价,低负载优先)
  const loadBucketA = Math.floor(a.loadRate / lt);
  const loadBucketB = Math.floor(b.loadRate / lt);
  if (loadBucketA !== loadBucketB) return loadBucketA - loadBucketB;
  // 3. 确定性兜底:原始评分降序 → 负载率升序 → 教师 ID 升序
  if (b.score !== a.score) return b.score - a.score;
  if (a.loadRate !== b.loadRate) return a.loadRate - b.loadRate;
  return (a.teacher?.id ?? 0) - (b.teacher?.id ?? 0);
});
return sorted[0];

比较顺序:scoreBucket → loadBucket → score → loadRate → teacherId, 前 4 级无法决断时用教师 ID 升序兜底,保证排序结果完全确定。

9.4 负载率计算

loadRate = (effectiveTotal + assignedHours) / max(1, maxCap + effectiveTotal)

十、手动排课与自动排课的交互

10.1 核心原则

手动排课记录神圣不可侵犯——自动排课永远不会覆盖手动排课。

10.2 保护机制

  1. 加载本课程本学期所有手动排课记录(is_auto = false
  2. 提取手动排课的班级 ID 集合
  3. 从自动排课候选池中排除这些班级
  4. 手动排课的课时仍计入教师容量(通过 totalWeeklyHours
  5. 手动排课的教材与学院仍计入教师的 assignedTextbookIds / assignedCollegeIds

10.3 手动排课操作

  • 使用 upsert,复合键 (class_id, course_id, semester)
  • 显式设置 is_auto = false
  • 如果班级之前有自动排课记录,手动排课会替换它

10.4 重置操作

  • 仅删除 is_auto = true 的记录
  • 手动排课记录不受影响

十一、批量排课(batchAutoArrange

11.1 范围

所有在培养方案中出现过的课程(即有 plan_courses 记录且至少有一条 plan_course_semesters 记录的课程)。

11.2 课程排序

按"供需比"降序排序,优先处理"可选教师少、需求大"的课程:

supplyCapacity = teacherCount × defaultStandard
supplyDemandRatio = demand / supplyCapacity   (teacherCount=0 时为 MAX_SAFE_INTEGER)

11.3 执行模型

顺序执行,非并行。课程逐个处理,每门课程的排课结果会影响后续课程的教师容量(通过 totalWeeklyHours)。

11.4 跨课程累计

预览模式下:

  • virtualTeacherHours:累计每位教师在前序课程中的虚拟分配课时,传入 extraTeacherHours 参数
  • globalTextbookMap:累计每位教师在前序课程中的教材集合,传入 globalTextbookMap 参数,用于初始化 assignedTextbookIds

非预览模式下,跨课程数据从 DB 实际读取(getTeachersForCourse 已查询全部课程的排课记录)。

11.5 超时保护

  • BATCH_TIMEOUT_MS = 5 * 60 * 1000(5 分钟)
  • 每门课程排课前检查是否超时
  • 超时后停止处理剩余课程,标记 timeoutReached: true,未处理课程数记入 skippedCourses(数值)

11.6 错误隔离

单门课程的排课失败不会中断批量流程。错误被捕获并记录在结果中(error 字段),继续处理剩余课程。


十二、预览模式

说明:预览现仅作为算法内部 dry-run 机制使用(批量排课补漏轮 F8 落库前评估、单元测试驱动算法主流程),前端排课预览入口与 API 层 preview 请求参数已移除。

12.1 行为

当内部选项 options.preview = true 时:

  • 算法完整执行(所有计算、匹配、评分、置换)
  • 跳过数据库事务——不删除、不插入
  • 返回结果包含 preview: true
  • 不创建审计日志
  • 附带 classTextbookMap,供批量排课跨课程累计教材

12.2 统计信息

预览模式下结果包含 statistics 字段:

  • teacherWorkload:每位教师的总课时、新增课时、容量、负载率、班级数
  • collegeMatchRate / textbookMatchRate / levelMatchRate:匹配率
  • textbookCohesionRate:教材内聚度(每位教师 1 - (教材数 - 1) / 班级数,clamp [0,1],取平均)
  • avgTextbookPerTeacher:教师平均教材数
  • scatteredTeacherCount:教材数 ≥ SCATTERED_THRESHOLD(3)的教师数
  • involvedTeacherCount:涉及的教师数

12.3 内聚度计算公式

每位教师的内聚度:

cohesion = max(0, 1 - (教材数 - 1) / 班级数)
  • 教材数 = 1 或 班级数 = 0 → cohesion = 1(最内聚)
  • 教材数 = 班级数 → cohesion = 0(最分散)

整体 textbookCohesionRate = 所有教师 cohesion 平均值 × 100。


十三、诊断机制

13.1 诊断函数(diagnoseFailure

对每个未分配的班级,按顺序检查并返回首个匹配的原因:

诊断原因触发条件
没有可教此课程的教师该课程无关联教师
所有候选教师课时容量已满所有教师的 assignedHours + cls.weeklyHours > cap
所有候选教师总周课时已达上限所有教师触及 defaultWeeklyHours 总周课时上限
所有候选教师教材上限已满所有教师已达 MAX_TEXTBOOKS_PER_TEACHER 且无法接纳新教材
有资格的教师课时容量已满通过意向筛选的教师,其容量全部已满
无匹配的教师(学院/层次偏好筛选后无候选)上述均不满足时的兜底诊断

诊断同时返回 details,包含前 5 位教师的具体数据(姓名、已排课时、上限等)。

13.2 其他未分配原因

原因触发场景
课时配置异常(周课时为0或负数)weeklyHours 为 0 或负数,前置处理阶段直接归入未分配
并发排课导致教师容量已满,已跳过事务内二次校验超载,降级跳过

13.3 提前退出消息

消息条件
该课程没有可用教师getTeachersForCourse 返回空数组
当前学期没有开设该课程的班级getClassesWithCourse 返回空数组
学期 {semesterStr} 批量排课进行中,请稍后再试单课程排课遇到 batchLocks
该课程正在排课中,请稍后重试单课程排课遇到 arrangeLocks

十四、结果结构

14.1 单课程排课结果(buildResult

javascript
{
  assigned: [...],           // 排课记录数组
  unassigned: [{             // 未分配班级数组
    classId, className, weeklyHours, reason, details
  }],
  totalClasses,              // 需排课的班级总数(不含手动)
  manualCount,               // 已有手动排课的班级数
  autoCount,                 // 本次自动排课的班级数
  unassignedCount,           // 未分配班级数
  preview,                   // 是否为预览模式
  warnings,                  // 容量警告数组
  statistics?,               // 预览模式下的统计信息
  classTextbookMap?,         // 预览模式下的班级教材映射
  message?                   // 错误/提前退出消息
}

14.2 排课记录结构

javascript
{
  teacher_id: Number, teacher_name: String,
  class_id: Number, class_name: String,
  course_id: Number, semester: String,
  weekly_hours: Number, is_auto: true
}

14.3 批量排课结果

javascript
{
  semester, mode, preview,
  courseResults: [...],
  summary: {
    totalCourses, successCount, errorCount,
    totalAssigned, totalUnassigned, totalWarnings,
    timeoutReached, skippedCourses?  // number,超时跳过的课程数量
  }
}

十五、排课流程图

┌─────────────────────────────────────────────────────────────┐
│                     自动排课开始                              │
└────────────────────┬────────────────────────────────────────┘


        ┌────────────────────────┐
        │  加载班级和教师候选池    │
        │  排除手动排课的班级      │
        │  追踪手动排课教材/学院   │
        │  过滤无效课时班级        │
        │  按教材分组排序          │
        └────────────┬───────────┘


        ┌────────────────────────┐
        │   容量可行性预检        │
        │  (需求 > 容量则警告)    │
        └────────────┬───────────┘


        ┌────────────────────────┐
        │   构建教师约束           │
        │  effectiveTotal/        │
        │  standardCap/fullCap/   │
        │  teacherHourCap         │
        └────────────┬───────────┘

    ┌────────────────┼────────────────────┐
    │                │                    │
    ▼                ▼                    ▼
┌────────┐    ┌────────────┐    ┌──────────────┐
│ 阶段 1  │    │  阶段 2     │    │  阶段 3       │
│ 有意向   │───▶│  无意向     │───▶│  同教材追加   │
│ 教师首选 │    │  教师首选   │    │  (不增教材)   │
└────────┘    └────────────┘    └──────┬───────┘


                              ┌────────────┐
                              │  阶段 4     │
                              │  第二本教材  │
                              └──────┬─────┘


                              ┌────────────┐
                              │  阶段 5     │
                              │  兜底分配   │
                              │ (assignRound)│
                              └──────┬─────┘


                              ┌────────────┐
                              │  置换回溯   │
                              │  提升分配率 │
                              └──────┬─────┘


                    ┌──────────────────────────┐
                    │ 后置优化层(禁忌搜索)   │
                    │ 可选,独立于 phase 1-5   │
                    │ (tabuOptimize /          │
                    │  runOptimizeSchedule)    │
                    └──────────────────────────┘


                    ┌───────────────────────────┐
                    │    诊断未分配班级原因       │
                    │    生成排课结果             │
                    └──────────────┬────────────┘

                          ┌────────┴────────┐
                          │                 │
                          ▼                 ▼
                   ┌────────────┐   ┌────────────┐
                   │ 预览模式    │   │ 正式模式    │
                   │ (不写入)    │   │ (事务写入)  │
                   │ + 统计信息  │   │ + 容量二次校验│
                   └────────────┘   └────────────┘

十六、配置参数(constants/index.js

16.1 课时与模式

常量说明
DEFAULT_HOUR_SETTINGS.full_time{standard: 16, max: 20}专职默认课时
DEFAULT_HOUR_SETTINGS.part_time{standard: 12, max: 16}兼职默认课时
DEFAULT_HOUR_SETTINGS.external{standard: 12, max: 16}外聘默认课时
ARRANGE_MODE.STANDARD'standard'标准模式
ARRANGE_MODE.FULL'full'全量模式

16.2 工作量平衡(WORKLOAD_BALANCE

常量说明
SCORE_THRESHOLD1分数差异阈值,超过则按分数排序
LOAD_RATE_THRESHOLD0.2负载率差异阈值,超过则按负载率排序

16.3 教材内聚优化(TEXTBOOK_COHESION

javascript
export const TEXTBOOK_COHESION = {
  ENABLED: true, // 总开关
  COLLEGE_WEIGHT: 5, // 学院匹配权重
  LEVEL_WEIGHT: 5, // 层次匹配权重
  ASSIGNED_WEIGHT: 10, // 本轮已用教材权重
  INHERENT_WEIGHT: 4, // 固有教材权重
  PENALTY_PER_NEW: 10, // 新增教材每本扣分
  ZERO_TEXTBOOK_BONUS: 30, // 0本教师加分
  TEXTBOOK_COUNT_PENALTY_1_NEW: 300, // 1本教师接不同教材强力惩罚
  TEXTBOOK_COUNT_BONUS_1_SAME: 10, // 1本教师接同类加分
  TEXTBOOK_COUNT_PENALTY_2: 20, // 不可达:MAX_TEXTBOOKS_PER_TEACHER=2
  TEXTBOOK_COUNT_PENALTY_3PLUS: 150, // 不可达:同上
  MAX_TEXTBOOKS_PER_TEACHER: 2, // 硬上限
  // F14 修复:移除 COHESION_PHASE_ENABLED / PHASE0_ENABLED(v2 重写后无生产代码引用)
  FALLBACK_EMPTY: true, // 无排课记录教师教材为空集合
  SCATTERED_THRESHOLD: 3, // 教材数 >= 此值视为"分散"
};

16.4 配置与实际生效情况

配置项实际值是否生效说明
ENABLEDtrue总开关
COLLEGE_WEIGHT5calcMatchScore 学院匹配
LEVEL_WEIGHT5calcMatchScore 层次匹配
ASSIGNED_WEIGHT10calcMatchScore 本轮已用教材
INHERENT_WEIGHT4calcMatchScore 固有教材
PENALTY_PER_NEW10calcMatchScore 新增教材惩罚
ZERO_TEXTBOOK_BONUS30calcMatchScore 0本教师加分
TEXTBOOK_COUNT_PENALTY_1_NEW300calcMatchScore 1本教师接新教材强惩罚
TEXTBOOK_COUNT_BONUS_1_SAME10calcMatchScore 1本教师接同教材奖励
TEXTBOOK_COUNT_PENALTY_220maxTb=2 时不可达
TEXTBOOK_COUNT_PENALTY_3PLUS150maxTb=2 时不可达
MAX_TEXTBOOKS_PER_TEACHER2多处硬上限校验
FALLBACK_EMPTYtrue兜底教材推导
SCATTERED_THRESHOLD3内聚度统计

注:TEXTBOOK_COUNT_PENALTY_2TEXTBOOK_COUNT_PENALTY_3PLUS 仅在 MAX_TEXTBOOKS_PER_TEACHER ≥ 3 时可达,当前默认值为 2,调整这两项不会影响实际评分。

16.5 批量排课

常量说明
BATCH_TIMEOUT_MS5 * 60 * 1000(5 分钟)批量排课超时上限

十七、排课优化服务(optimize.js

17.1 概述

server/src/services/arrange/optimize.js 提供跨课程全局优化服务,作为五阶段贪心排课(phase 1-5)的独立后置优化层。 与单课程禁忌搜索(tabu-search.jstabuOptimize)的区别:optimize.js 在学期维度上聚合所有课程的自动排课记录, 逐课程调用 tabuOptimize 并在课程间同步教师状态,实现跨课程负载均衡与教材内聚优化。

导出 API

  • runOptimizeSchedule(semesterId, mode, options):预览模式运行全局优化,返回 before/after 指标与变更详情
  • applyOptimizeResult(semesterId, changes, userId):将变更应用到 teaching_assignments 表,并写入审计日志

17.2 runOptimizeSchedule 流程

  1. 加载当前排课:查询 teaching_assignmentsis_locked=falseis_auto=true
  2. 按课程分组:构建 courseMap(courseId → assignments / classIds / teacherIds)
  3. 批量加载班级与教材:通过 plan_coursesplan_course_semestersplan_textbooks 关联, N+1 → 批量查询(原实现按课程循环内逐班 findUnique,现为 findMany + Map 查找)
  4. 构建全局 teacherConstraintsbuildTeacherConstraintsauto-arrange.js 字段对齐 (含 inherentTextbookIds 快照、schedulingCollegeIds / schedulingLevelIdscourses 授课资格)
  5. 计算 before 指标calculateMetrics 返回 score / loadVariance / textbookCohesionRate
  6. 逐课程运行 tabuOptimize:每门课程创建独立教师约束副本,防止 writeback 污染共享状态; 容量修正:将 standardCap / fullCap 重算为「排除本课后」的可用容量,与 auto-arrange.jseffectiveTotal 思路对齐
  7. 跨课程状态回写(L549-567):每门课程优化后,将 courseTeacherConstraints 的增量状态同步回 teacherConstraints
    • assignedTextbookIds:直接替换为优化后的值
    • assignedCollegeIds:只增不减(保守策略,与 tabu-search writeback 一致)
  8. 构建变更详情:对比 original 与 optimized 的 teacher_id 差异; teacherNameMap 优化(L600):预构建 teacherId → name 的 Map,避免 O(T) 线性查找
  9. 阈值判定meetsMinimumThreshold 决定是否值得应用变更
  10. 应用变更applyOptimizeResult 在事务中 updateMany 按唯一键 (class_id, course_id, semester, teacher_id) 定位

17.3 calculateMetrics 与 α/β 惩罚

score = totalMatchScore − α × underAssignmentGap − β × loadVariance × 100
来源
totalMatchScore对每条分配调用 calcMatchScore 求和(proxy 教师对象)
underAssignmentPenalty每位教师 max(0, cap − assignedHours) × α
loadVariancePenaltyβ × loadVariance × 100(量级与 computeObjective 对齐)

P1 修复:与 tabu-search.jscomputeObjective 对齐,避免 UI 显示与算法结果矛盾。

17.4 meetsMinimumThreshold 阈值逻辑

P2 修复:原 && 关系导致 2 个班级的有效 Swap 被丢弃,改为加权判定:

javascript
function meetsMinimumThreshold(before, after) {
  const changesCount = after.changesCount || 0;
  // P2 修复:>0 改为 !== 0,避免 before.score 为负数时(含 α/β 惩罚)
  //         负分→正分的巨大改进被误判为 0%
  const scoreImprovement = before.score !== 0
    ? ((after.score - before.score) / Math.abs(before.score)) * 100
    : 0;
  // && 改为 ||:scoreImprovement > 5% 或 (changesCount >= 3 且 scoreImprovement > 2%)
  return scoreImprovement > 5 || (changesCount >= 3 && scoreImprovement > 2);
}

两处关键修复:

  1. &&||:放宽阈值,避免小规模有效变更被整体丢弃
  2. > 0!== 0:新目标函数含 α/β 惩罚项,before.score 可能为负数; 用 > 0 守卫会让负分→正分的巨大改进被误判为 0% 改进而被丢弃

17.5 已知限制

  • 跨课程公平性缺失:逐课程串行优化,先优化课程的状态会影响后续课程的教师可用容量(与 auto-arrange.js 单课程独立排课同款限制)
  • 不处理合班runOptimizeSchedule 不展开 combination_id,合班变更需在上层处理

十八、API 接口

方法路径说明权限
GET/classes获取课程班级列表所有用户
GET/teachers获取课程教师列表所有用户
GET/statistics学期排课统计所有用户
GET/hour-settings获取课时设置所有用户
POST/assign手动排课admin+
POST/auto-arrange单课程自动排课admin+
POST/batch-auto-arrange批量自动排课admin+
POST/reset重置自动排课admin+
PUT/hour-settings保存课时设置admin+
DELETE/assignments/:id删除排课记录admin+
PATCH/assignments/:id/lock锁定/解锁单条排课admin+
POST/lock-batch批量锁定/解锁admin+
POST/optimize-schedule排课优化(试算)admin+
POST/apply-optimize应用优化结果admin+

十九、已知限制

19.1 贪心算法无回溯

一旦教师被分配给某班级,不会被撤回以寻求全局更优解(置换回溯仅单轮,不递归)。结果是局部最优,非全局最优。

19.2 跨课程公平性缺失

每门课程独立排课。在批量排课中,先处理的课程可能占用大量教师容量,导致后续课程的教师选择受限。批量排课通过"供需比降序"排序缓解此问题,但无法完全消除。

19.3 教材数量分级奖惩部分未启用

TEXTBOOK_COUNT_PENALTY_2TEXTBOOK_COUNT_PENALTY_3PLUS 仅在 MAX_TEXTBOOKS_PER_TEACHER ≥ 3 时生效。当前默认值为 2,这两个分支不可达。

19.4 defaultWeeklyHours 语义

字段名"默认周课时"具有误导性,实际作用是教师总周课时上限(跨所有课程,含手动排课与其他课程)。UI 中已重命名为"自定义课时"。

19.5 并发锁为进程级别

arrangeLocksbatchLocks 均为进程内存级别,仅适用于单进程部署。多实例部署需改用分布式锁。

19.6 批量排课无教材分组预处理

batch.js 仅按课程供需比排序,不做教材分组。教材分组完全由 autoArrange 内部完成。


二十、测试验证

20.1 验证场景

场景1:有指定意向的教师

  • 前提:教师A 指定意向学院=职教,意向层次=本科;班级1-5 职教学院本科教材X;班级6-10 普教学院本科教材X
  • 预期:教师A 只分配到班级1-5,不会分配到班级6-10

场景2:无指定意向的教师

  • 前提:教师B 无指定意向;班级1-5 教材X;班级6-10 教材Y
  • 预期:教师B 先拿完教材X的班级(1-5),如果还有容量再拿教材Y的班级(6-10)

场景3:教材内聚

  • 前提:教师C 已持有教材X;班级1-5 教材X;班级6-10 教材Y
  • 预期:阶段3中,教师C 优先追加教材X的班级(1-5),只有教材X分配完后才在阶段4拿教材Y

场景4:学院内聚

  • 前提:教师D 已分配职教学院班级;班级1-3 职教学院教材X;班级4-6 普教学院教材X
  • 预期:教师D 优先拿职教学院的班级(1-3),只有在职教学院班级分配完后才拿普教学院的班级

20.2 验证步骤

  1. 启动开发环境
  2. 进入"教学安排"页面
  3. 选择课程,使用"标准模式"或"全量模式"排课
  4. 检查排课结果:
    • 有指定意向的教师是否严格符合意向
    • 教师的教材数是否尽量保持在 1-2 本
    • 同教材的班级是否尽量分配给同一教师
    • 同学院的班级是否尽量分配给同一教师

20.3 日志查看

排课过程中会输出详细日志:

[新分配算法v2] 共 3 个教材组,开始分配...
  教材组 1,2: 10 个班级
  教材组 3: 8 个班级
  教材组 __no_textbook__: 5 个班级
[阶段1] 有指定意向的教师拿第一本教材
  [阶段1] 教材组 1,2: 剩余 2 个班级
[阶段2] 无指定意向的教师拿第一本教材
[阶段3] 所有教师追加同教材班级
[阶段4] 所有教师拿第二本教材
[新分配算法v2] 完成,总分配 23,未分配 0

二十一、常见问题

Q1:为什么有指定意向的教师没有被分配到任何班级?

可能原因:该教师的意向学院/层次没有对应的班级;课时容量已满;不能教此课程的教材。

解决方法:检查教师的意向设置、对应学院/层次的班级、课时容量。

Q2:为什么教师拿到了超过 2 本教材?

可能原因MAX_TEXTBOOKS_PER_TEACHER 设置为 0(不限制);兜底阶段(阶段5)放宽了约束。

解决方法:确认 MAX_TEXTBOOKS_PER_TEACHER 设置为 2;检查日志中兜底阶段的分配记录。

Q3:为什么有些班级没有被分配?

可能原因:没有可教此课程的教师;所有教师的课时容量已满;所有教师的意向/教材都不匹配该班级。

解决方法:检查未分配班级的诊断原因;增加教师数量或提高课时容量;调整教师意向设置。

Q4:手动排课的班级会影响自动排课吗?

手动排课的班级不会被自动排课覆盖。但手动排课的教材和课时计入教师状态,避免教师因手动排课而拿到过多教材或超负荷。


二十二、性能优化建议

22.1 大数据量场景

当班级数量超过 100 时,建议:

  1. 分批排课:按学院或层次分批排课,减少单次处理的班级数量
  2. 锁定关键分配:对满意的排课结果及时锁定,重新排课时不会被覆盖
  3. 单课程排课:对于重要课程,单独排课而非批量排课

22.2 日志优化

生产环境中,建议降低日志级别:

javascript
// 开发环境:保留所有日志
logger.info("[阶段1] 有指定意向的教师拿第一本教材");

// 生产环境:只保留关键日志
logger.debug("[阶段1] 有指定意向的教师拿第一本教材");

二十三、禁忌搜索优化层(v2.21.0 新增)

23.1 概述

v2.21.0 新增了可选的禁忌搜索优化层,作为五阶段贪心算法的后续优化。贪心算法快速生成初始解,禁忌搜索在此基础上通过邻域搜索迭代优化,提升排课质量。

默认关闭,可通过系统设置页面动态启用(system_settings 表 key=tabu_search_enabled),也可通过常量 TABU_SEARCH.ENABLED 静态开启。

23.2 算法流程

五阶段贪心(构造初始解)

置换回溯 trySwapUnassigned(尝试补救未分配班级)

禁忌搜索 tabuOptimize(迭代优化,可选)

输出最终结果

23.3 邻域移动算子

移动类型操作说明
Insert将未分配班级分配给某教师减少未分配惩罚
Shift将某教师的班级移给另一教师释放源教师容量,改善目标教师匹配
Swap两个教师交换各自的一个班级双向改善,需检查双方约束

每次移动后检查硬约束(容量上限、教材上限 MAX_TEXTBOOKS_PER_TEACHER、学院/层次意向),不可行的移动直接跳过。

23.4 核心机制

  • 禁忌表:记录最近 N 轮被移动的 (classId, teacherId) 对,防止局部震荡。默认 tenure=10
  • Aspiration Criterion:被禁忌的移动如果能产生优于历史最优的解,则忽略禁忌
  • 教材引用计数refCountMap 跟踪教材被引用次数,Swap 移动正确维护引用计数
  • 学院集合维护:Swap 评估时保存/恢复学院集合,防止累积污染
  • 教材 writeback 增量保护:搜索结束后仅写回增量变化,不替换整个 assignedTextbookIds

23.5 配置参数

参数默认值说明
ENABLEDfalse静态开关,优先级高于系统设置
MAX_ITERATIONS500最大迭代次数
TABU_TENURE10禁忌期限(轮数)
NO_IMPROVEMENT_LIMIT80连续无改进轮数上限
SINGLE_COURSE_TIMEOUT_MS15000单课程优化超时(毫秒)
UNASSIGNED_PENALTY500未分配班级惩罚分
UNDER_ASSIGNMENT_PENALTY5欠分配课时惩罚(α 系数)
LOAD_VARIANCE_WEIGHT2负载方差惩罚权重(β 系数)
RANDOM_SEED42固定种子(mulberry32 PRNG)

目标函数computeObjective / calculateMetrics):

score = totalMatchScore − α × underAssignmentGap − β × loadVariance × 100
  • α = UNDER_ASSIGNMENT_PENALTY(5):每位教师低于 cap 的课时缺口 × α
  • β = LOAD_VARIANCE_WEIGHT(2):教师间负载方差的惩罚权重,促进工作量均衡

伪随机数采用 mulberry32 算法,种子由 RANDOM_SEED(42)固定,保证同输入结果可复现; RANDOM_SEED = 0 时退化为 Math.random()

配置位于 server/src/constants/index.jsTABU_SEARCH 对象。

23.6 错误处理

禁忌搜索异常不会影响排课结果。所有禁忌搜索逻辑被 try/catch 包裹,异常时自动跳过,返回贪心初始解。日志中会记录异常信息。

23.7 前端管理

在系统设置页面新增了"排课禁忌搜索优化"开关(SchedulingConfig.vue 组件),使用 el-switch 控件,独立保存,支持脏状态跟踪。


二十四、未来优化方向

24.1 更高级的全局优化

v2.21.0 已实现禁忌搜索作为局部搜索优化层。未来可以考虑:

  1. 模拟退火:以一定概率接受劣解,避免陷入局部最优
  2. 遗传算法:适合多目标优化,但实现复杂、调参多

24.2 跨课程均衡

当前算法是单课程独立排课。未来可以考虑:

  1. 教师工作量均衡:跨课程考虑教师的总工作量
  2. 教材分布均衡:避免某教师在同一学期教过多不同教材的课程

24.3 用户偏好学习

通过学习历史排课数据,自动调整:

  1. 教师偏好:自动学习教师的实际授课偏好
  2. 教材亲和度:根据教学效果调整教材匹配权重

二十五、关键代码位置索引

功能文件行号(约)
教材匹配判断 isTextbookMatcharrange/queries.js12-21
兜底教材推导arrange/queries.js261-408
兜底赋值(FALLBACK_EMPTY)arrange/queries.js401-408
固有教材快照固化arrange/auto-arrange.js~199
评分函数 calcMatchScorearrange/auto-arrange.js~35-128
资格校验 isTeacherEligiblearrange/auto-arrange.js~130-157
构建教师约束 buildTeacherConstraintsarrange/auto-arrange.js~159
内聚度统计 calcAllMatchRatesarrange/auto-arrange.js~326-381
置换回溯 trySwapUnassignedarrange/auto-arrange.js~458-503
置换单次 trySwapOnearrange/auto-arrange.js~510-652
候选教师排序 selectBestTeacherarrange/auto-arrange.js~306
assignRoundarrange/auto-arrange.js~834-905
手动排课教材追踪arrange/auto-arrange.js~940-959
recordAssignmentarrange/auto-arrange.js~982-1001
takeClassesForTeacherarrange/auto-arrange.js~1004-1044
教材分组预处理arrange/auto-arrange.js~1046-1074
v2 阶段 1(有意向教师拿第一本)arrange/auto-arrange.js~1077-1123
v2 阶段 2(无意向教师拿第一本)arrange/auto-arrange.js~1127-1166
v2 阶段 3(追加同教材班级)arrange/auto-arrange.js~1170-1201
v2 阶段 4(拿第二本教材)arrange/auto-arrange.js~1204-1250
v2 阶段 5(兜底 assignRound)arrange/auto-arrange.js~1254-1265
排课主入口 autoArrangearrange/auto-arrange.js~664
事务内二次校验arrange/auto-arrange.js~1356-1404
禁忌搜索主入口 tabuOptimizearrange/tabu-search.js~1-30
Insert 邻域移动arrange/tabu-search.js~200-280
Shift 邻域移动arrange/tabu-search.js~280-380
Swap 邻域移动arrange/tabu-search.js~380-520
教材引用计数 refCountMaparrange/tabu-search.js~80-120
Aspiration Criterionarrange/tabu-search.js各邻域内
TABU_SEARCH 配置constants/index.js~115-125
批量排课 batchAutoArrangearrange/batch.js14-182
排课优化 runOptimizeSchedulearrange/optimize.js241-665
优化指标 calculateMetricsarrange/optimize.js33-143
改进阈值 meetsMinimumThresholdarrange/optimize.js21-28
跨课程状态回写arrange/optimize.js549-567
N+1 → 批量查询arrange/optimize.js333-394
teacherNameMap 优化arrange/optimize.js600
配置 TEXTBOOK_COHESIONconstants/index.js92-114

二十六、固有班级延续(v1.7.0 新增)

26.1 概述

"固有班级延续"是一个可选的软优先策略:排课下学期时,优先将教师分配到他上学期教过的班级(即"固有班级")。开关为系统设置项 inherent_class_enabled(默认关闭),由系统设置页"排课配置"卡片控制。

设计原则:纯软性优先,不构成硬约束。容量、资格(学院/层次)、教材惩罚、手动锁定等既有规则全部不变,延续偏好只影响同等条件下的排序与评分。

26.2 快照构建

  • 上学期推算:semester.service.jsgetPreviousSemester(春季 N=2 → 本学年秋季 N=1;秋季 N=1 → 上一学年春季,年份越界返回 null)。
  • 快照来源:查询上学期该课程的 teaching_assignments(teacher_id → class_id 集合),构建 Map:courseId → Map(teacherId → Set(classId)),挂到教师约束对象的 inherentClassIds 字段(每教师独立副本,防止补排多轮间共享 Set 污染)。
  • 批量排课在入口处一次性预加载上学期全部排课记录(单条 SQL),按课程切分后透传给各次 autoArrange 调用,避免逐课程重复查库;单课程排课自行查询。
  • 快照缺失或查询失败时自动降级为普通排课,不影响主流程。

26.3 生效点位

  1. 评分calcMatchScore):教师命中固有班级时 +INHERENT_CLASS.CONTINUITY_WEIGHT(=8)。高于学院/层次匹配权重(各 5),但远低于教材强惩罚(-300),即教材内聚目标仍占主导。
  2. 拿班顺序takeClassesForTeacher):固有班级排序前置,先于学院优先规则,再进入既有的 matchHours 最大化逻辑。
  3. 禁忌搜索buildScoringProxy 通过展开运算符透传教师字段,inherentClassIds 自动参与评分,无需额外改动。
  4. 排课优化optimize.js):按课程注入 inherentClassIds 到课程级约束副本;前后指标评估使用不含快照的全局约束,保证阈值口径一致。

26.4 开关语义

  • 开关在每次排课调用入口读取一次,同一次批量排课内不重复读取,避免中途改开关导致批次内口径不一致。
  • 静态常量 INHERENT_CLASS.ENABLED 默认 falsesystem_settings.inherent_class_enabled 为动态开关,DB 查询失败时保持关闭。
  • 即使开关打开,若目标课程上学期无排课记录(首学期开设、上学期未排等),该课程也自然走普通排课。

26.5 结果统计

开关生效且存在快照时,排课结果附带 inherentContinuity

  • candidateCount:本次已分配班级中,上学期存在任教记录的数量;
  • continuedCount:其中仍分配给原任课教师的数量;
  • continuityRate:延续率百分比(candidateCount 为 0 时为 null)。

26.6 持久化标记(is_inherent,v1.8.0 新增)

teaching_assignments.is_inherent 布尔列记录"该条安排是延续上学期的教师-班级关系":

  • 写入:排课结束时(tabu 优化/置换回溯之后)统一按快照计算最终标记,随自动排课记录一起持久化;预览结果同样计算并返回(场景一弹窗展示)。
  • 失效语义:手动安排/更换教师覆盖延续记录时清为 false;排课优化置换教师后按快照重算(新教师上学期教过该班才保持);自动重排会先删除旧自动安排再重写,标记随新结果重新计算。
  • 展示:教学安排班级表教师标签旁紫色 RefreshRight 图标(tooltip 说明);结果弹窗延续率统计块;课程预览卡"延续率"统计项(延续班级数 / 已安排班级数);课程概览接口返回 inherentCount
  • 关闭开关:不产生任何 true 标记,历史标记不受影响(只读展示,随下次重排自然清除)。

文档版本:v1.8.0 | 最后更新:2026-08-12