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:
152
src/modules/attendance/warning-compute.ts
Normal file
152
src/modules/attendance/warning-compute.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
AttendanceWarning,
|
||||
AttendanceWarningSeverity,
|
||||
AttendanceWarningSummary,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* L-3:预警阈值默认值(当班级未配置规则时使用)。
|
||||
*/
|
||||
export const DEFAULT_ATTENDANCE_RATE_THRESHOLD = 90
|
||||
export const DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD = 3
|
||||
|
||||
/**
|
||||
* 出勤率预警的严重等级划分:
|
||||
* - high:低于阈值 10 个百分点以上
|
||||
* - medium:低于阈值 5-10 个百分点
|
||||
* - low:低于阈值 5 个百分点以内
|
||||
*/
|
||||
const rateSeverity = (current: number, threshold: number): AttendanceWarningSeverity => {
|
||||
const diff = threshold - current
|
||||
if (diff >= 10) return "high"
|
||||
if (diff >= 5) return "medium"
|
||||
return "low"
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续缺勤预警的严重等级划分:
|
||||
* - high:连续缺勤 ≥ 阈值 + 2
|
||||
* - medium:连续缺勤 ≥ 阈值 + 1
|
||||
* - low:连续缺勤 = 阈值
|
||||
*/
|
||||
const absenceSeverity = (current: number, threshold: number): AttendanceWarningSeverity => {
|
||||
if (current >= threshold + 2) return "high"
|
||||
if (current >= threshold + 1) return "medium"
|
||||
return "low"
|
||||
}
|
||||
|
||||
/**
|
||||
* 从按日期升序的状态列表中计算最长连续缺勤段。
|
||||
* 缺勤定义为 status === "absent"(不含 late/early_leave 等其他异常状态)。
|
||||
* 返回 { maxStreak, lastStreakDates }:最长连续段及其日期列表。
|
||||
*/
|
||||
export const computeConsecutiveAbsence = (
|
||||
records: { date: string; status: string }[]
|
||||
): { maxStreak: number; lastStreakDates: string[] } => {
|
||||
if (records.length === 0) return { maxStreak: 0, lastStreakDates: [] }
|
||||
|
||||
// 按日期升序排序
|
||||
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date))
|
||||
let maxStreak = 0
|
||||
let currentStreak = 0
|
||||
let currentStreakStart = 0
|
||||
let lastMaxStart = 0
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (sorted[i].status === "absent") {
|
||||
if (currentStreak === 0) currentStreakStart = i
|
||||
currentStreak += 1
|
||||
if (currentStreak > maxStreak) {
|
||||
maxStreak = currentStreak
|
||||
lastMaxStart = currentStreakStart
|
||||
}
|
||||
} else {
|
||||
currentStreak = 0
|
||||
}
|
||||
}
|
||||
|
||||
const lastStreakDates = maxStreak > 0
|
||||
? sorted.slice(lastMaxStart, lastMaxStart + maxStreak).map((r) => r.date)
|
||||
: []
|
||||
|
||||
return { maxStreak, lastStreakDates }
|
||||
}
|
||||
|
||||
/**
|
||||
* L-3:根据学生出勤率统计和阈值生成预警列表(纯函数,便于测试)。
|
||||
*
|
||||
* @param studentStats 学生列表,每项含 studentId/studentName/total/present 和按日期升序的记录
|
||||
* @param attendanceRateThreshold 出勤率阈值(百分比)
|
||||
* @param consecutiveAbsenceThreshold 连续缺勤阈值(次)
|
||||
*/
|
||||
export const computeAttendanceWarnings = (
|
||||
studentStats: {
|
||||
studentId: string
|
||||
studentName: string
|
||||
total: number
|
||||
present: number
|
||||
records: { date: string; status: string }[]
|
||||
}[],
|
||||
attendanceRateThreshold: number,
|
||||
consecutiveAbsenceThreshold: number
|
||||
): AttendanceWarning[] => {
|
||||
const warnings: AttendanceWarning[] = []
|
||||
|
||||
for (const s of studentStats) {
|
||||
// 跳过无记录的学生(避免除零,且无记录不触发预警)
|
||||
if (s.total === 0) continue
|
||||
|
||||
const presentRate = Math.round((s.present / s.total) * 10000) / 100
|
||||
|
||||
// 出勤率低于阈值
|
||||
if (presentRate < attendanceRateThreshold) {
|
||||
warnings.push({
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName,
|
||||
type: "low_attendance_rate",
|
||||
severity: rateSeverity(presentRate, attendanceRateThreshold),
|
||||
currentValue: presentRate,
|
||||
threshold: attendanceRateThreshold,
|
||||
relatedDates: [],
|
||||
})
|
||||
}
|
||||
|
||||
// 连续缺勤达到阈值
|
||||
const { maxStreak, lastStreakDates } = computeConsecutiveAbsence(s.records)
|
||||
if (maxStreak >= consecutiveAbsenceThreshold) {
|
||||
warnings.push({
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName,
|
||||
type: "consecutive_absence",
|
||||
severity: absenceSeverity(maxStreak, consecutiveAbsenceThreshold),
|
||||
currentValue: maxStreak,
|
||||
threshold: consecutiveAbsenceThreshold,
|
||||
relatedDates: lastStreakDates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 严重等级排序:high → medium → low
|
||||
const severityOrder: Record<AttendanceWarningSeverity, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
}
|
||||
warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造空的预警汇总(当班级无数据或无规则时使用)。
|
||||
*/
|
||||
export const createEmptyWarningSummary = (
|
||||
classId: string,
|
||||
className: string
|
||||
): AttendanceWarningSummary => ({
|
||||
classId,
|
||||
className,
|
||||
attendanceRateThreshold: DEFAULT_ATTENDANCE_RATE_THRESHOLD,
|
||||
consecutiveAbsenceThreshold: DEFAULT_CONSECUTIVE_ABSENCE_THRESHOLD,
|
||||
warnings: [],
|
||||
})
|
||||
Reference in New Issue
Block a user