Files
NextEdu/src/modules/attendance/data-access-stats.ts
SpecialX 048fc1c386 feat(attendance): add correlation, trend, warnings, report print, and services
- Add attendance-grade-correlation-card and data-access-correlation, correlation-compute

- Add attendance-trend-chart and trend-compute for trend analysis

- Add attendance-warnings-card and warning-compute for attendance warnings

- Add attendance-report-print for printable reports

- Add class-comparison-card for class attendance comparison

- Add notifications and services directory
2026-07-03 10:24:16 +08:00

467 lines
16 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 { and, asc, count, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { attendanceRecords } from "@/shared/db/schema"
import { getClassNameById, getClassNamesByIds, getClassesByGradeId } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getGradeNameById } from "@/modules/school/data-access"
import { safeParseDate } from "@/shared/lib/action-utils"
import { getAttendanceRules } from "./data-access"
import {
computeAttendanceWarnings,
createEmptyWarningSummary,
DEFAULT_ATTENDANCE_RATE_THRESHOLD,
DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD,
} from "./warning-compute"
import {
computeTrendPoints,
type TrendGranularity,
} from "./trend-compute"
import type {
AttendanceListItem,
AttendanceStats,
AttendanceTrendSummary,
AttendanceWarningSummary,
ClassAttendanceSummary,
ClassComparisonSummary,
ClassComparisonItem,
StudentAttendanceSummary,
} from "./types"
const EMPTY_STATS: AttendanceStats = {
total: 0,
present: 0,
absent: 0,
late: 0,
earlyLeave: 0,
excused: 0,
schoolActivity: 0,
presentRate: 0,
lateRate: 0,
}
/** 最近记录的默认截取数量(避免一次拉全量) */
const DEFAULT_RECENT_LIMIT = 20
/**
* 根据考勤记录行计算统计(纯函数,便于测试)。
*/
export const computeStats = (rows: { status: string }[]): AttendanceStats => {
if (rows.length === 0) return EMPTY_STATS
const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length }
for (const r of rows) {
if (r.status === "present") stats.present += 1
else if (r.status === "absent") stats.absent += 1
else if (r.status === "late") stats.late += 1
else if (r.status === "early_leave") stats.earlyLeave += 1
else if (r.status === "excused") stats.excused += 1
else if (r.status === "school_activity") stats.schoolActivity += 1
}
stats.presentRate = Math.round((stats.present / stats.total) * 10000) / 100
stats.lateRate = Math.round((stats.late / stats.total) * 10000) / 100
return stats
}
/**
* 将 SQL 聚合行转换为 AttendanceStats避免拉全量记录计算统计
*/
const statsFromAggregate = (row: {
total: number
present: number
absent: number
late: number
earlyLeave: number
excused: number
schoolActivity: number
}): AttendanceStats => {
const total = Number(row.total ?? 0)
const present = Number(row.present ?? 0)
const late = Number(row.late ?? 0)
return {
total,
present,
absent: Number(row.absent ?? 0),
late,
earlyLeave: Number(row.earlyLeave ?? 0),
excused: Number(row.excused ?? 0),
schoolActivity: Number(row.schoolActivity ?? 0),
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
}
}
const serializeDate = (d: Date | string | null): string =>
d ? new Date(d).toISOString().slice(0, 10) : ""
/**
* 获取学生考勤汇总。
* 优化:统计使用 SQL 聚合查询(避免拉全量记录),最近记录使用 LIMIT 分页。
*/
export async function getStudentAttendanceSummary(
studentId: string,
startDate?: string,
endDate?: string,
recentLimit: number = DEFAULT_RECENT_LIMIT
): Promise<StudentAttendanceSummary | null> {
// 委托 users data-access 查询学生姓名,避免跨模块直查 users 表
const userMap = await getUserNamesByIds([studentId])
const student = userMap.get(studentId)
if (!student) return null
const studentName = student.name ?? "Unknown"
const conditions = [eq(attendanceRecords.studentId, studentId)]
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
const where = and(...conditions)
// 统计使用 SQL 聚合,避免拉全量记录
const [statsRow] = await db
.select({
total: count(),
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
excused: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
schoolActivity: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'school_activity' THEN 1 ELSE 0 END), 0)`,
})
.from(attendanceRecords)
.where(where)
const stats = statsFromAggregate(statsRow ?? {
total: 0,
present: 0,
absent: 0,
late: 0,
earlyLeave: 0,
excused: 0,
schoolActivity: 0,
})
// 最近记录:仅查询 attendanceRecords 表className 通过 data-access 委托查询
const rows = await db
.select({
record: attendanceRecords,
})
.from(attendanceRecords)
.where(where)
.orderBy(desc(attendanceRecords.date))
.limit(recentLimit)
// 批量解析 className委托 classes data-access
const classIds = Array.from(new Set(rows.map((r) => r.record.classId)))
const classNameMap = await getClassNamesByIds(classIds)
const recentRecords: AttendanceListItem[] = rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName,
classId: r.record.classId,
className: classNameMap.get(r.record.classId) ?? "Unknown",
scheduleId: r.record.scheduleId ?? null,
date: serializeDate(r.record.date),
status: r.record.status,
// L-6节次考勤null 视为 full_day
period: r.record.period ?? "full_day",
remark: r.record.remark ?? null,
reason: r.record.reason ?? null,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
createdAt: r.record.createdAt.toISOString(),
}))
return {
studentId,
studentName,
stats,
recentRecords,
}
}
export async function getClassAttendanceStats(
classId: string,
startDate?: string,
endDate?: string
): Promise<ClassAttendanceSummary | null> {
// 委托 classes data-access 查询班级名称,避免跨模块直查 classes 表
const className = await getClassNameById(classId)
if (!className) return null
const conditions = [eq(attendanceRecords.classId, classId)]
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
const where = and(...conditions)
// 统计使用 SQL 聚合P1-11 修复:避免全量查询 + 内存 computeStats
const [statsRow] = await db
.select({
total: count(),
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
excused: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
schoolActivity: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'school_activity' THEN 1 ELSE 0 END), 0)`,
})
.from(attendanceRecords)
.where(where)
const stats = statsFromAggregate(statsRow ?? {
total: 0,
present: 0,
absent: 0,
late: 0,
earlyLeave: 0,
excused: 0,
schoolActivity: 0,
})
// 学生明细记录:仅查询 attendanceRecords 表studentName 通过 data-access 委托查询
const rows = await db
.select({
record: attendanceRecords,
})
.from(attendanceRecords)
.where(where)
.orderBy(asc(attendanceRecords.studentId))
// 批量解析 studentName委托 users data-access
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
const studentNameMap = await getUserNamesByIds(studentIds)
const studentRecords: AttendanceListItem[] = rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
classId: r.record.classId,
className,
scheduleId: r.record.scheduleId ?? null,
date: serializeDate(r.record.date),
status: r.record.status,
// L-6节次考勤null 视为 full_day
period: r.record.period ?? "full_day",
remark: r.record.remark ?? null,
reason: r.record.reason ?? null,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
createdAt: r.record.createdAt.toISOString(),
}))
return {
classId,
className,
date: startDate ?? endDate ?? new Date().toISOString().slice(0, 10),
stats,
studentRecords,
}
}
/**
* L-3获取班级所有学生的出勤率预警汇总。
* 按学生聚合统计 + 连续缺勤段,调用纯函数 computeAttendanceWarnings 计算预警。
*/
export async function getClassAttendanceWarnings(
classId: string,
startDate?: string,
endDate?: string
): Promise<AttendanceWarningSummary | null> {
const className = await getClassNameById(classId)
if (!className) return null
// 读取班级规则(取第一条匹配的规则或使用默认值)
const rules = await getAttendanceRules(classId)
const classRule = rules.find((r) => r.classId === classId)
const attendanceRateThreshold = classRule?.attendanceRateThreshold ?? DEFAULT_ATTENDANCE_RATE_THRESHOLD
const consecutiveAbsenceThreshold = classRule?.consecutiveAbsenceThreshold ?? DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD
// 拉取该班级所有考勤记录,按 studentId 聚合
const conditions = [eq(attendanceRecords.classId, classId)]
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
const where = and(...conditions)
const rows = await db
.select({
studentId: attendanceRecords.studentId,
date: attendanceRecords.date,
status: attendanceRecords.status,
})
.from(attendanceRecords)
.where(where)
.orderBy(asc(attendanceRecords.studentId), asc(attendanceRecords.date))
if (rows.length === 0) {
return createEmptyWarningSummary(classId, className)
}
// 按 studentId 聚合
const studentMap = new Map<string, { date: string; status: string }[]>()
for (const r of rows) {
const list = studentMap.get(r.studentId) ?? []
list.push({ date: serializeDate(r.date), status: r.status })
studentMap.set(r.studentId, list)
}
// 批量查询学生姓名(委托 users data-access
const studentIds = Array.from(studentMap.keys())
const studentNameMap = await getUserNamesByIds(studentIds)
const studentStats = studentIds.map((id) => {
const records = studentMap.get(id) ?? []
const total = records.length
const present = records.filter((r) => r.status === "present").length
return {
studentId: id,
studentName: studentNameMap.get(id)?.name ?? "Unknown",
total,
present,
records,
}
})
const warnings = computeAttendanceWarnings(
studentStats,
attendanceRateThreshold,
consecutiveAbsenceThreshold
)
return {
classId,
className,
attendanceRateThreshold,
consecutiveAbsenceThreshold,
warnings,
}
}
/**
* L-4获取班级考勤趋势按日/周/月聚合)。
* 使用纯函数 computeTrendPoints 计算趋势点,便于测试。
*/
export async function getAttendanceTrend(
classId: string,
granularity: TrendGranularity,
startDate?: string,
endDate?: string
): Promise<AttendanceTrendSummary | null> {
const className = await getClassNameById(classId)
if (!className) return null
const conditions = [eq(attendanceRecords.classId, classId)]
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
const where = and(...conditions)
const rows = await db
.select({
date: attendanceRecords.date,
status: attendanceRecords.status,
})
.from(attendanceRecords)
.where(where)
.orderBy(asc(attendanceRecords.date))
const records = rows.map((r) => ({
date: serializeDate(r.date),
status: r.status,
}))
const points = computeTrendPoints(records, granularity)
return {
classId,
className,
granularity,
startDate: startDate ?? (points[0]?.date ?? new Date().toISOString().slice(0, 10)),
endDate: endDate ?? (points[points.length - 1]?.date ?? new Date().toISOString().slice(0, 10)),
points,
}
}
/**
* L-7获取同年级所有班级的出勤率对比。
* 通过 SQL 聚合按 classId 分组统计,避免拉全量记录。
*/
export async function getClassComparison(
gradeId: string,
startDate?: string,
endDate?: string
): Promise<ClassComparisonSummary | null> {
const gradeName = await getGradeNameById(gradeId)
if (!gradeName) return null
// 获取年级所有班级
const gradeClasses = await getClassesByGradeId(gradeId)
if (gradeClasses.length === 0) {
return {
gradeId,
gradeName,
startDate: startDate ?? new Date().toISOString().slice(0, 10),
endDate: endDate ?? new Date().toISOString().slice(0, 10),
items: [],
averagePresentRate: 0,
}
}
const classIds = gradeClasses.map((c) => c.id)
const classNameMap = new Map(gradeClasses.map((c) => [c.id, c.name]))
// 构建时间范围条件
const conditions = [inArray(attendanceRecords.classId, classIds)]
if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "startDate")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "endDate")))
const where = and(...conditions)
// SQL 聚合:按 classId 分组统计
const rows = await db
.select({
classId: attendanceRecords.classId,
total: count(),
present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
})
.from(attendanceRecords)
.where(where)
.groupBy(attendanceRecords.classId)
// 构建对比项含无记录的班级total=0
const items: ClassComparisonItem[] = classIds.map((cid) => {
const row = rows.find((r) => r.classId === cid)
const total = Number(row?.total ?? 0)
const present = Number(row?.present ?? 0)
const late = Number(row?.late ?? 0)
const absent = Number(row?.absent ?? 0)
return {
classId: cid,
className: classNameMap.get(cid) ?? "Unknown",
total,
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
absentRate: total > 0 ? Math.round((absent / total) * 10000) / 100 : 0,
}
})
// 按出勤率降序排序
items.sort((a, b) => b.presentRate - a.presentRate)
// 计算年级平均出勤率(加权平均,按 total 加权)
const totalRecords = items.reduce((sum, i) => sum + i.total, 0)
const totalPresent = items.reduce((sum, i) => sum + Math.round(i.presentRate * i.total) / 100, 0)
const averagePresentRate = totalRecords > 0
? Math.round((totalPresent / totalRecords) * 10000) / 100
: 0
return {
gradeId,
gradeName,
startDate: startDate ?? new Date().toISOString().slice(0, 10),
endDate: endDate ?? new Date().toISOString().slice(0, 10),
items,
averagePresentRate,
}
}