Files
NextEdu/src/modules/grades/lib/report-card.ts
SpecialX 9ce8d6d3fd refactor(data-access): 补全剩余 37 个 data-access 文件迁移至 cacheFn
迁移范围:lesson-preparation 11 文件、attendance 3 文件、settings 4 文件、classes-invitations、跨模块接口 4 文件、adaptive-practice/ai/onboarding/invitation-codes/search/standards/scheduling/leave-requests/audit。修复 3 个 cacheFn 块位置错误。同步架构文档迁移范围至 83 文件 250+ 函数。
2026-07-06 15:12:30 +08:00

385 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { sum: number; count: number }>()
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<ReportCardData | null> => {
// 校验 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<string, { id: string; name: string }>()
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<string, string>()),
getUserNamesByIds(recorderIds),
])
const subjectNameById = new Map<string, string>()
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<string, GradeRecordListItem[]>()
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"],
})