import { prisma } from '../../lib/db.js'; import { logger } from '../../lib/logger.js'; import { weekParityFromDiv } from '@classhelper/shared'; import type { ReportScheduleEntry, ReportTimeLayoutItem } from './integrations.schemas.js'; /** * 插件上报的课表 / 节次时间 → 写入本服务。 * * 设计要点: * 1) **复用导入模块的口径**:与 `modules/imports/class-plan.service.ts`、 * `time-layout.service.ts` 完全一致 —— 缺失的科目自动补建课程(任课老师取班主任)、 * merge 按 `星期 + 开始时间 + 单双周` 去重、replace 先清空再写入。 * 这样"插件实时上报"和"手动导入 JSON"落到库里的结果是一样的,不会出现两套语义。 * 2) **幂等**:插件每次上报(启动、课表变化、定时)都会带全量课表, * 因此 merge 必须是"存在则更新、不存在则新增",重复上报不会产生重复行。 * 3) 整个写入放在一个事务里:解析/校验失败的批次不会留下半截数据。 */ export interface ScheduleWriteResult { applied: boolean; created: number; updated: number; /** 被清理掉的旧同步行(老师已在 ClassIsland 里删除的课) */ removed: number; courses: string[]; weekStart: number; weekEnd: number; } /** 单双周口径:优先用插件显式给的 weekParity,否则从 WeekCountDiv/Total 推导 */ function resolveParity(entry: ReportScheduleEntry): 'ALL' | 'ODD' | 'EVEN' { if (entry.weekParity === 'ALL' || entry.weekParity === 'ODD' || entry.weekParity === 'EVEN') { return entry.weekParity; } const { weekCountDiv = 0, weekCountDivTotal = 0 } = entry; return weekParityFromDiv(weekCountDiv, weekCountDivTotal); } /** * 写入课表。classId 由**设备令牌**决定(不接受插件指定),因此插件无法越权写别的班级。 */ export async function applyReportedSchedule( classId: string, mode: 'replace' | 'merge', entries: ReportScheduleEntry[], ): Promise { const empty: ScheduleWriteResult = { applied: false, created: 0, updated: 0, removed: 0, courses: [], weekStart: 1, weekEnd: 20, }; if (entries.length === 0) return empty; const classRecord = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, teacherId: true, termWeeks: true }, }); if (!classRecord) { logger.warn(`ClassIsland 上报:班级不存在,已丢弃本批课表(classId=${classId})`); return empty; } // 起止周次:与导入模块保持一致(weekStart 由班级进度决定,weekEnd 取班级教学周数) const weekStart = 1; const weekEnd = classRecord.termWeeks > 0 ? classRecord.termWeeks : 20; const result = await prisma.$transaction(async (tx) => { // 1) 补齐缺失的课程(任课老师先用班主任占位,老师可在课表管理里改) const existingCourses = await tx.course.findMany({ where: { classId }, select: { id: true, name: true }, }); const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id])); const createdCourses: string[] = []; for (const name of new Set(entries.map((item) => item.subject))) { if (courseIdByName.has(name)) continue; const created = await tx.course.create({ data: { classId, name, teacherId: classRecord.teacherId }, select: { id: true, name: true }, }); courseIdByName.set(created.name, created.id); createdCourses.push(created.name); } // 2) replace:先清空该班课表(与导入模块的 replace 语义一致) if (mode === 'replace') { await tx.schedule.deleteMany({ where: { classId } }); } // 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」 let created = 0; let updated = 0; /** 本轮上报覆盖到的行 id:不在其中的 classisland 来源行会被清理(见第 4 步) */ const keptIds = new Set(); for (const entry of entries) { const courseId = courseIdByName.get(entry.subject); if (!courseId) continue; const weekParity = resolveParity(entry); const existing = await tx.schedule.findFirst({ where: { classId, dayOfWeek: entry.dayOfWeek, startTime: entry.startTime, weekParity }, select: { id: true }, }); if (existing) { await tx.schedule.update({ where: { id: existing.id }, data: { courseId, endTime: entry.endTime, // 不覆盖 weekStart / weekEnd:插件上报契约里没有周次范围, // 硬写"整学期"会把老师在 Web 端为选修/短期课设的「第 1~10 周」悄悄抹掉。 // 只在新建时给默认值(见下面 create)。 // // 同样**不要**把 source 改成 'classisland':merge 的去重键是「星期 + 开始时间 + 单双周」, // 老师在 Web 端手排的同一时间槽会被这里"收养"。一旦收养时改写来源, // 后续那次"清理插件侧已删除的课"就会把这行老师手排的课一起删掉 // (真机上发生过:种子课表 93 行被删成 91 行,`verify:e2e` 的上课状态用例随之失败)。 // 因此来源只在 create 时确定,update 永远保持原值。 }, }); keptIds.add(existing.id); updated += 1; } else { await tx.schedule.create({ data: { classId, courseId, dayOfWeek: entry.dayOfWeek, startTime: entry.startTime, endTime: entry.endTime, location: null, weekStart, weekEnd, weekParity, source: 'classisland', }, }); created += 1; } } // 4) 清理"老师已在 ClassIsland 里删掉"的行。 // // merge 模式原先只 upsert、从不删除:老师在 ClassIsland 删掉一节,或把 8:00 的课挪到 8:05, // 服务端那行会永远留着(幽灵课 / 同一天两节重复课)。但也不能照 replace 那样整表清空 —— // 那会连老师在 Web 端手排的课一起删。 // 因此只清理 `source='classisland'`(即由上报同步产生)且本轮未再出现的行。 let removed = 0; if (mode === 'merge') { const synced = await tx.schedule.findMany({ where: { classId, source: 'classisland' }, select: { id: true, dayOfWeek: true, startTime: true, weekParity: true }, }); const stale = synced.filter( (row) => !keptIds.has(row.id) && !entries.some( (entry) => entry.dayOfWeek === row.dayOfWeek && entry.startTime === row.startTime && resolveParity(entry) === row.weekParity, ), ); if (stale.length > 0) { await tx.schedule.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } }); removed = stale.length; } } return { created, updated, removed, createdCourses, allCourses: [...courseIdByName.keys()] }; }); logger.info( `ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` + `清理=${result.removed} 补建科目=[${result.createdCourses.join(',')}]`, ); return { applied: true, created: result.created, updated: result.updated, removed: result.removed, courses: result.allCourses, weekStart, weekEnd, }; } /** * 写入节次时间表(TimeLayout)。 * 与 `modules/imports/time-layout.service.ts` 的口径保持一致: * 同一班级同名配置整体替换;没有则新建。 * 注意:`action`(ClassIsland TimeType=3,行动)在本系统里没有对应节次类型, * 写入时降级为 `divider`(不占课时,只做视觉分隔),避免类型校验失败。 */ export async function applyReportedTimeLayout( classId: string, name: string, items: ReportTimeLayoutItem[], ): Promise<{ applied: boolean; count: number }> { if (items.length === 0) return { applied: false, count: 0 }; const normalized = items.map((item, index) => ({ index: item.index && item.index > 0 ? item.index : index + 1, name: item.name?.trim() || `第 ${index + 1} 节`, startTime: item.startTime, endTime: item.endTime, type: item.type === 'action' ? 'divider' : (item.type ?? 'class'), skipped: item.skipped === true, })); const current = await prisma.timeLayout.findFirst({ where: { classId, name }, orderBy: { updatedAt: 'desc' }, select: { id: true }, }); if (current) { await prisma.timeLayout.update({ where: { id: current.id }, data: { items: JSON.stringify(normalized), source: 'classisland' }, }); } else { await prisma.timeLayout.create({ data: { classId, name, source: 'classisland', items: JSON.stringify(normalized) }, }); } logger.info(`ClassIsland 上报节次时间表:班级=${classId} 名称=${name} 共 ${normalized.length} 节`); return { applied: true, count: normalized.length }; } /** * 把本服务的课表转换成 ClassIsland 的档案口径(供插件拉回去镜像)。 * * 关键换算: * - 星期:本系统 1=周一…7=周日 → ClassIsland 0=周日、1=周一…6=周六; * - 节次:ClassIsland 的 Classes[i] 对应 Layouts 里第 i 个 TimeType=0(上课)的点, * 因此这里必须**按节次顺序**生成 layouts,并把每个 Schedule 归到"开始时间最接近且不晚于它"的节次上; * - 单双周:ALL→(0,0);ODD→(1,2);EVEN→(2,2),与导入模块的解析互逆。 */ export interface MirrorClassPlan { entries: { weekDay: number; weekCountDiv: number; weekCountDivTotal: number; subject: string; teacherName: string | null; /** * 该课目的起止时间(HH:mm)。插件靠它重建 ClassIsland 的时间表: * `ClassPlan.Classes[i]` 必须与时间表里第 i 个上课点一一对应,缺了时间就整份错位。 */ startTime: string; endTime: string; timeLayoutId: string; }[]; timeLayouts: { id: string; name: string; layouts: { startTime: string; endTime: string; timeType: number }[]; }[]; profileName: string; termStartDate: string; } /** 本系统 dayOfWeek(1=周一…7=周日)→ ClassIsland WeekDay(0=周日…6=周六) */ export function toClassIslandWeekDay(dayOfWeek: number): number { return dayOfWeek % 7; }