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 { // 委托 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`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`, absent: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`, late: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`, earlyLeave: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`, excused: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`, schoolActivity: sql`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 { // 委托 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`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`, absent: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`, late: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`, earlyLeave: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`, excused: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`, schoolActivity: sql`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 { 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() 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 { 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 { 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`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`, late: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`, absent: sql`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, } }