feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:56:10 +08:00
parent d260df864c
commit 7dd5c44406
23 changed files with 3344 additions and 2 deletions

View File

@@ -0,0 +1,201 @@
import { describe, it, expect } from "vitest";
import { isTimeOverlap, detectConflict } from "./schedule-conflict.js";
import type { ScheduleSlot } from "./schedule-conflict.js";
// 构造时间辅助函数基于固定日期UTC
function at(hour: number, minute: number = 0): Date {
return new Date(
`2026-07-13T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00Z`,
);
}
// 构造排课槽位的工厂函数
function makeSlot(
overrides: Partial<ScheduleSlot> & { id: string },
): ScheduleSlot {
return {
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
...overrides,
};
}
describe("schedule-conflict", () => {
describe("isTimeOverlap", () => {
it("完全重叠返回 true", () => {
expect(isTimeOverlap(at(10), at(11), at(10), at(11))).toBe(true);
});
it("部分重叠返回 true", () => {
expect(isTimeOverlap(at(10), at(11), at(10, 30), at(11, 30))).toBe(true);
});
it("包含关系返回 true", () => {
expect(isTimeOverlap(at(9), at(12), at(10), at(11))).toBe(true);
});
it("不重叠返回 false", () => {
expect(isTimeOverlap(at(8), at(9), at(10), at(11))).toBe(false);
});
it("刚好接续endA == startB返回 false", () => {
expect(isTimeOverlap(at(10), at(11), at(11), at(12))).toBe(false);
});
it("刚好接续反向endB == startA返回 false", () => {
expect(isTimeOverlap(at(11), at(12), at(10), at(11))).toBe(false);
});
});
describe("detectConflict", () => {
it("无现有排课时不冲突", () => {
const newSlot = makeSlot({ id: "new1" });
expect(detectConflict(newSlot, [])).toEqual({
hasConflict: false,
conflictType: null,
});
});
it("同一教师同一时间段冲突conflictType 为 teacher", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t1",
classId: "c-other",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(true);
expect(result.conflictType).toBe("teacher");
expect(result.conflictingSlot).toBe(existing);
});
it("同一班级同一时间段冲突conflictType 为 class", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t-other",
classId: "c1",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(true);
expect(result.conflictType).toBe("class");
expect(result.conflictingSlot).toBe(existing);
});
it("不同教师不同班级不冲突(即使时间重叠)", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t2",
classId: "c2",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(false);
expect(result.conflictType).toBe(null);
});
it("不同教师不冲突", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t2",
classId: "c2",
});
expect(detectConflict(newSlot, [existing]).hasConflict).toBe(false);
});
it("不同班级不冲突", () => {
const existing = makeSlot({
id: "e1",
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(12),
});
const newSlot = makeSlot({
id: "new1",
teacherId: "t2",
classId: "c2",
startTime: at(10, 30),
endTime: at(11, 30),
});
expect(detectConflict(newSlot, [existing]).hasConflict).toBe(false);
});
it("边界情况:刚好接续不冲突", () => {
const existing = makeSlot({
id: "e1",
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
});
const newSlot = makeSlot({
id: "new1",
teacherId: "t1",
classId: "c1",
startTime: at(11), // 接续,不重叠
endTime: at(12),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(false);
});
it("excludeId 排除自身(更新场景)", () => {
const existing = makeSlot({
id: "e1",
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
});
const newSlot = makeSlot({
id: "e1", // 同一 id更新场景
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
});
// 不排除时会冲突
expect(detectConflict(newSlot, [existing]).hasConflict).toBe(true);
// 排除自身后不冲突
expect(detectConflict(newSlot, [existing], "e1").hasConflict).toBe(false);
});
it("多个现有排课时跳过无冲突项并返回首个冲突", () => {
const nonConflicting = makeSlot({
id: "e1",
teacherId: "t-other",
classId: "c-other",
startTime: at(10, 30),
endTime: at(11, 30),
});
const conflicting = makeSlot({
id: "e2",
teacherId: "t1",
classId: "c1",
startTime: at(10, 30),
endTime: at(11, 30),
});
const newSlot = makeSlot({
id: "new1",
teacherId: "t1",
classId: "c1",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [nonConflicting, conflicting]);
expect(result.hasConflict).toBe(true);
expect(result.conflictType).toBe("teacher");
expect(result.conflictingSlot).toBe(conflicting);
});
});
});

View File

@@ -1,6 +1,10 @@
import { randomUUID } from "node:crypto";
import { eq, inArray } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { schedulingRepository } from "./scheduling.repository.js";
import { courses, schedules } from "./scheduling.schema.js";
import { attendance } from "../attendance/attendance.schema.js";
import { detectConflict, type ScheduleSlot } from "./schedule-conflict.js";
import {
NotFoundError,
@@ -10,6 +14,18 @@ import {
} from "../shared/errors/application-error.js";
import type { Course, Schedule } from "./scheduling.schema.js";
export interface ScheduleSlotInfo {
id: string;
courseId: string;
courseName: string;
teacherId: string;
classId: string;
roomId: string;
startTime: string;
endTime: string;
subjectId: string;
}
export interface CreateCourseInput {
classId: string;
subjectId: string;
@@ -161,4 +177,89 @@ export class SchedulingService {
async listSchedulesByClass(classId: string): Promise<Schedule[]> {
return schedulingRepository.findSchedulesByClassId(classId);
}
// --------------------------------------------------------------------------
// P3.13 新增:按学生查询课表(通过考勤记录反查 class_id再查该班所有排课
// --------------------------------------------------------------------------
async getScheduleByStudent(
studentId: string,
weekStart?: string,
): Promise<ScheduleSlotInfo[]> {
if (!studentId) {
throw new ValidationError("studentId is required");
}
// 1. 从考勤记录中查找该学生关联的 schedule_id
const attendanceRows = await db
.select({ scheduleId: attendance.scheduleId })
.from(attendance)
.where(eq(attendance.studentId, studentId));
if (attendanceRows.length === 0) {
return [];
}
const scheduleIds = attendanceRows.map((r) => r.scheduleId);
// 2. 查找这些 schedule提取 class_id
const studentSchedules = await db
.select({ classId: schedules.classId })
.from(schedules)
.where(inArray(schedules.id, scheduleIds));
const classIds = [...new Set(studentSchedules.map((s) => s.classId))];
if (classIds.length === 0) {
return [];
}
// 3. 查找这些 class 的所有排课(含未来排课)
let allSchedules = await db
.select()
.from(schedules)
.where(inArray(schedules.classId, classIds));
// 4. 可选:按 week_start 过滤(该周起始日之后 7 天内)
if (weekStart) {
const weekStartDate = new Date(weekStart);
if (!Number.isNaN(weekStartDate.getTime())) {
const weekEndDate = new Date(weekStartDate);
weekEndDate.setDate(weekEndDate.getDate() + 7);
allSchedules = allSchedules.filter((s) => {
const start = new Date(s.startTime);
return start >= weekStartDate && start < weekEndDate;
});
}
}
if (allSchedules.length === 0) {
return [];
}
// 5. 关联课程表获取 course_name 和 subject_id
const courseIds = [...new Set(allSchedules.map((s) => s.courseId))];
const courseRows = await db
.select()
.from(courses)
.where(inArray(courses.id, courseIds));
const courseMap = new Map(courseRows.map((c) => [c.id, c]));
// 6. 构造返回结果
return allSchedules.map((s) => {
const course = courseMap.get(s.courseId);
return {
id: s.id,
courseId: s.courseId,
courseName: course?.name ?? "",
teacherId: s.teacherId,
classId: s.classId,
roomId: s.roomId ?? "",
startTime:
s.startTime instanceof Date ? s.startTime.toISOString() : s.startTime,
endTime:
s.endTime instanceof Date ? s.endTime.toISOString() : s.endTime,
subjectId: course?.subjectId ?? "",
};
});
}
}