迁移范围: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+ 函数。
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
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"],
|
||
})
|