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
This commit is contained in:
SpecialX
2026-07-03 10:24:16 +08:00
parent 7567f317e1
commit 048fc1c386
25 changed files with 3220 additions and 256 deletions

View File

@@ -1,15 +1,33 @@
import "server-only"
import { and, asc, count, desc, eq, gte, lte, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { attendanceRecords, classes, users } from "@/shared/db/schema"
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"
@@ -20,6 +38,7 @@ const EMPTY_STATS: AttendanceStats = {
late: 0,
earlyLeave: 0,
excused: 0,
schoolActivity: 0,
presentRate: 0,
lateRate: 0,
}
@@ -39,6 +58,7 @@ export const computeStats = (rows: { status: string }[]): AttendanceStats => {
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
@@ -55,6 +75,7 @@ const statsFromAggregate = (row: {
late: number
earlyLeave: number
excused: number
schoolActivity: number
}): AttendanceStats => {
const total = Number(row.total ?? 0)
const present = Number(row.present ?? 0)
@@ -66,6 +87,7 @@ const statsFromAggregate = (row: {
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,
}
@@ -84,16 +106,15 @@ export async function getStudentAttendanceSummary(
endDate?: string,
recentLimit: number = DEFAULT_RECENT_LIMIT
): Promise<StudentAttendanceSummary | null> {
const [student] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, studentId))
.limit(1)
// 委托 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, "开始日期")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "结束日期")))
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 聚合,避免拉全量记录
@@ -105,6 +126,7 @@ export async function getStudentAttendanceSummary(
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)
@@ -116,30 +138,36 @@ export async function getStudentAttendanceSummary(
late: 0,
earlyLeave: 0,
excused: 0,
schoolActivity: 0,
})
// 最近记录使用 LIMIT 分页,避免拉全量
// 最近记录:仅查询 attendanceRecords 表className 通过 data-access 委托查询
const rows = await db
.select({
record: attendanceRecords,
className: classes.name,
})
.from(attendanceRecords)
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
.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: student.name ?? "Unknown",
studentName,
classId: r.record.classId,
className: r.className ?? "Unknown",
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(),
@@ -147,7 +175,7 @@ export async function getStudentAttendanceSummary(
return {
studentId,
studentName: student.name ?? "Unknown",
studentName,
stats,
recentRecords,
}
@@ -158,39 +186,65 @@ export async function getClassAttendanceStats(
startDate?: string,
endDate?: string
): Promise<ClassAttendanceSummary | null> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return 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, "开始日期")))
if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "结束日期")))
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,
studentName: users.name,
})
.from(attendanceRecords)
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
.where(and(...conditions))
.orderBy(asc(users.name))
.where(where)
.orderBy(asc(attendanceRecords.studentId))
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
// 批量解析 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: r.studentName ?? "Unknown",
studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
classId: r.record.classId,
className: classRow.name,
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(),
@@ -198,9 +252,215 @@ export async function getClassAttendanceStats(
return {
classId,
className: classRow.name,
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,
}
}