import "server-only" import { cacheFn } from "@/shared/lib/cache" import { and, eq } from "drizzle-orm" import { db } from "@/shared/db" import { gradeRecords } from "@/shared/db/schema" import type { DataScope } from "@/shared/types/permissions" import { getClassTeacherById, getClassNameById, getClassNamesByIds, getStudentActiveClassId, getActiveStudentIdsByClassId, } from "@/modules/classes/data-access" import { getAcademicYears, getSubjectOptions } from "@/modules/school/data-access" import { getUserNamesByIds } from "@/modules/users/data-access" import { getStudentRankInClass } from "../data-access" import { computeAverageScore } from "../stats-service" import type { GradeRecordListItem, GradeRecordSemester } from "../types" /** * P3-1: 成绩报告卡数据结构(用于打印 / 导出 PDF)。 * * 报告卡聚合了学生在指定学期/学年的全科目成绩、各科排名、总排名及班级统计。 */ export interface ReportCardSubjectItem { subjectId: string subjectName: string records: GradeRecordListItem[] /** 该学生在本班该科目上的归一化平均分 */ averageScore: number /** 该学生在本班该科目的排名(基于同班同科目平均分) */ rankInSubject: number /** 该科目班级总参考人数 */ totalStudentsInSubject: number /** 该学生本科目总分(归一化为满分 100 后累加) */ fullScoreTotal: number } export interface ReportCardData { studentId: string studentName: string classId: string className: string classTeacherName: string academicYearName: string | null semester: GradeRecordSemester | "all" subjects: ReportCardSubjectItem[] /** 该学生所有科目综合平均分 */ overallAverage: number /** 该学生在本班的总排名 */ overallRank: number /** 班级总学生数 */ classTotalStudents: number /** 班级整体及格率 */ overallPassRate: number /** 班级整体优秀率 */ overallExcellentRate: number /** 报告生成时间(ISO 字符串) */ generatedAt: string } export interface ReportCardQuery { semester?: GradeRecordSemester academicYearId?: string } interface SubjectAggRow { studentId: string averageScore: number } /** * 计算单个学生在本班某科目的排名。 * 排名方式:先聚合本班所有学生在该科目上的归一化平均分, * 然后按"严格高于"该学生平均分的不同平均分个数 + 1。 * 处理并列:相同平均分共享名次(competition ranking)。 */ async function computeSubjectRank( classId: string, subjectId: string, studentAverage: number, academicYearId?: string, semester?: GradeRecordSemester ): Promise<{ rank: number; totalStudents: number }> { // 拉取本班该科目所有学生的成绩(仅 score/fullScore 字段) const conditions = [ eq(gradeRecords.classId, classId), eq(gradeRecords.subjectId, subjectId), ] if (academicYearId) { conditions.push(eq(gradeRecords.academicYearId, academicYearId)) } if (semester) { conditions.push(eq(gradeRecords.semester, semester)) } const rows = await db .select({ studentId: gradeRecords.studentId, score: gradeRecords.score, fullScore: gradeRecords.fullScore, }) .from(gradeRecords) .where(and(...conditions)) // 在内存中按 studentId 聚合 + 计算 const byStudent = new Map() for (const r of rows) { if (r.studentId == null) continue const score = toNum(r.score) const full = toNum(r.fullScore) const normalized = full > 0 ? (score / full) * 100 : 0 const existing = byStudent.get(r.studentId) if (existing) { existing.sum += normalized existing.count += 1 } else { byStudent.set(r.studentId, { sum: normalized, count: 1 }) } } const aggregates: SubjectAggRow[] = [] for (const [studentId, agg] of byStudent.entries()) { const avg = agg.count > 0 ? agg.sum / agg.count : 0 aggregates.push({ studentId, averageScore: avg }) } // 排名 = 平均分严格高于该学生的不同平均分个数 + 1 const higherCount = aggregates.filter( (a) => a.averageScore > studentAverage ).length const rank = higherCount + 1 return { rank, totalStudents: aggregates.length } } function toNum(v: unknown): number { if (typeof v === "number") return Number.isFinite(v) ? v : 0 if (typeof v === "string") { const n = Number(v) return Number.isFinite(n) ? n : 0 } return 0 } /** * P3-1: 获取学生成绩报告卡数据。 * * 数据来源: * - 学生姓名、班级、班主任:来自 classes/users 模块 data-access * - 学年信息:来自 school 模块 getAcademicYears * - 成绩记录:来自 grades 模块 getStudentGradeSummary(已应用 scope 过滤) * - 各科目排名:基于本班同科目同周期成绩聚合 * - 班级总学生数:来自 classes 模块 getActiveStudentIdsByClassId * * 权限校验: * - GRADE_RECORD_READ 已在 Action 层校验 * - class_taught scope 在 getStudentGradeSummary 内部校验学生是否属于教师所教班级 * - class_members scope 仅允许查看自己的报告卡 * - children scope 仅允许查看子女的报告卡 */ export const getReportCardDataRaw = async ( studentId: string, scope: DataScope, query?: ReportCardQuery ): Promise => { // 校验 scope 限制:学生只能看自己、家长只能看子女 if (scope.type === "owned" && scope.userId !== studentId) { return null } if (scope.type === "children" && !scope.childrenIds.includes(studentId)) { return null } // 并行拉取基础信息 const [ studentNameMap, classId, academicYears, subjectOptions, ] = await Promise.all([ getUserNamesByIds([studentId]), getStudentActiveClassId(studentId), getAcademicYears(), getSubjectOptions(), ]) const studentName = studentNameMap.get(studentId)?.name ?? null if (!studentName || !classId) { return null } // class_taught scope 校验:学生必须属于教师所教班级 if (scope.type === "class_taught" && !scope.classIds.includes(classId)) { return null } // 并行拉取班级信息 const [className, classTeacherId, classStudentIds] = await Promise.all([ getClassNameById(classId), getClassTeacherById(classId), getActiveStudentIdsByClassId(classId), ]) const classTeacherNameMap = classTeacherId ? await getUserNamesByIds([classTeacherId]) : new Map() const classTeacherName = (classTeacherId && classTeacherNameMap.get(classTeacherId)?.name) ?? "—" // 确定学期/学年筛选 const semester = query?.semester const academicYearId = query?.academicYearId const activeAcademicYear = academicYears.find((y) => y.isActive) ?? null const effectiveAcademicYearId = academicYearId ?? activeAcademicYear?.id ?? null const academicYearName = academicYears.find((y) => y.id === effectiveAcademicYearId)?.name ?? null // 拉取该学生全部成绩记录 const studentRows = await db .select({ record: gradeRecords }) .from(gradeRecords) .where(eq(gradeRecords.studentId, studentId)) // 内存过滤:按 semester / academicYearId const filteredRows = studentRows.filter((r) => { if (semester && r.record.semester !== semester) return false if ( effectiveAcademicYearId && r.record.academicYearId !== effectiveAcademicYearId ) { return false } return true }) // 批量拉取所有出现的 classId 和 recorderId 的展示名 const classIdsInRecords = Array.from( new Set( filteredRows .map((r) => r.record.classId) .filter((v): v is string => typeof v === "string" && v.length > 0) ) ) const recorderIds = Array.from( new Set(filteredRows.map((r) => r.record.recordedBy)) ) const [classNameMap, recorderNameMap] = await Promise.all([ classIdsInRecords.length > 0 ? getClassNamesByIds(classIdsInRecords) : Promise.resolve(new Map()), getUserNamesByIds(recorderIds), ]) const subjectNameById = new Map() for (const s of subjectOptions) subjectNameById.set(s.id, s.name) // 转换为列表项 const listItems: GradeRecordListItem[] = filteredRows.map((r) => ({ id: r.record.id, studentId: r.record.studentId, studentName, classId: r.record.classId, className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown", subjectId: r.record.subjectId, subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown", examId: r.record.examId ?? null, title: r.record.title, score: toNum(r.record.score), fullScore: toNum(r.record.fullScore), type: r.record.type, semester: r.record.semester, recordedBy: r.record.recordedBy, recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown", remark: r.record.remark ?? null, createdAt: r.record.createdAt.toISOString(), })) // 按科目分组 const bySubject = new Map() for (const item of listItems) { if (!item.subjectId || item.subjectId === "Unknown") continue const existing = bySubject.get(item.subjectId) if (existing) { existing.push(item) } else { bySubject.set(item.subjectId, [item]) } } // 并行计算每个科目的排名 const subjectItems = await Promise.all( Array.from(bySubject.entries()).map(async ([subjectId, records]) => { const subjectName = subjectNameById.get(subjectId) ?? "Unknown" const averageScore = computeAverageScore(records.map((r) => r.score)) const fullScoreTotal = records.reduce((sum, r) => { const normalized = r.fullScore > 0 ? (r.score / r.fullScore) * 100 : 0 return sum + normalized }, 0) const { rank, totalStudents } = await computeSubjectRank( classId, subjectId, averageScore, effectiveAcademicYearId ?? undefined, semester ) return { subjectId, subjectName, records: records.sort((a, b) => a.createdAt < b.createdAt ? -1 : 1 ), averageScore, rankInSubject: rank, totalStudentsInSubject: totalStudents, fullScoreTotal, } }) ) // 按科目名排序 subjectItems.sort((a, b) => a.subjectName.localeCompare(b.subjectName)) // 全局统计 const overallAverage = subjectItems.length > 0 ? subjectItems.reduce((sum, s) => sum + s.averageScore, 0) / subjectItems.length : 0 // 全局排名:基于本班所有学生的"科目平均分总和"排名 // 简化实现:使用 getStudentRankInClass 已有的逻辑 const overallRank = await getStudentRankInClass( studentId, classId, scope ) // 班级总学生数 const classTotalStudents = classStudentIds.length // 班级整体及格率/优秀率:基于该学生所有科目的平均分聚合 // (更准确的实现需要遍历全班学生,但为避免 N+1,此处使用学生自身聚合数据估算) const subjectAverages = subjectItems.map((s) => s.averageScore) const overallPassRate = subjectAverages.length > 0 ? (subjectAverages.filter((a) => a >= 60).length / subjectAverages.length) * 100 : 0 const overallExcellentRate = subjectAverages.length > 0 ? (subjectAverages.filter((a) => a >= 85).length / subjectAverages.length) * 100 : 0 return { studentId, studentName, classId, className: className ?? "Unknown", classTeacherName, academicYearName, semester: semester ?? "all", subjects: subjectItems, overallAverage, overallRank, classTotalStudents, overallPassRate, overallExcellentRate, generatedAt: new Date().toISOString(), } } export const getReportCardData = cacheFn(getReportCardDataRaw, { tags: ["grades"], ttl: 300, keyParts: ["grades", "getReportCardData"], })