Files
NextEdu/src/modules/attendance/data-access.ts
SpecialX b090a815ae feat(modules-edu): update ai, adaptive-practice, announcements, attendance, audit, auth
- ai: update chat-panel, child-summary, error-book-analysis, grading-assist,

  lesson-content-generator, markdown-renderer, question-variant-generator,

  study-path, usage-dashboard, use-ai-chat-stream, use-position-persistence

- adaptive-practice: update practice-result-view, practice-session-view,

  practice-starter, data-access-analytics, lib/type-guards

- announcements: update announcement-card, detail, form, data-access

- attendance: update attendance-record-list, attendance-rules-form,

  attendance-sheet, data-access

- audit: update actions, audit-log-export-button, audit-retention-settings,

  data-access

- auth: update login-form, register-form, data-access
2026-07-07 16:18:29 +08:00

525 lines
18 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, isNull, lte, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
attendanceRecords,
attendanceRules,
} from "@/shared/db/schema"
import { getClassActiveStudentsWithInfo, getClassNamesByIds } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { safeParseDate } from "@/shared/lib/action-utils"
import { cacheFn } from "@/shared/lib/cache"
import type { DataScope } from "@/shared/types/permissions"
import type {
AttendanceListItem,
AttendancePeriod,
AttendanceQueryParams,
AttendanceRule,
AttendanceStats,
PaginatedAttendanceResult,
} from "./types"
import type {
AttendanceRuleInput,
BatchRecordAttendanceInput,
RecordAttendanceInput,
UpdateAttendanceInput,
} from "./schema"
const buildScopeFilter = (scope: DataScope): SQL | null => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0
? inArray(attendanceRecords.classId, scope.classIds)
: sql`1=0`
}
if (scope.type === "grade_managed") return sql`1=0`
if (scope.type === "class_members") return null
if (scope.type === "children") {
return scope.childrenIds.length > 0
? inArray(attendanceRecords.studentId, scope.childrenIds)
: sql`1=0`
}
if (scope.type === "owned") return eq(attendanceRecords.studentId, scope.userId)
return sql`1=0`
}
const serializeDate = (d: Date | string | null): string =>
d ? new Date(d).toISOString().slice(0, 10) : ""
/**
* L-6 节次考勤:构建 period 过滤条件。
* - period = "full_day":匹配 period IS NULL OR period = 'full_day'(向后兼容)
* - period = 其他值:精确匹配
* - period 未传:返回 null不按节次过滤
*/
function buildPeriodFilter(period?: AttendancePeriod): SQL | null {
if (!period) return null
if (period === "full_day") {
// Drizzle ORM 的 or() 返回 SQL | undefined用 ?? null 统一为 SQL | null
const result = or(isNull(attendanceRecords.period), eq(attendanceRecords.period, "full_day"))
return result ?? null
}
return eq(attendanceRecords.period, period)
}
const mapListItem = (
r: typeof attendanceRecords.$inferSelect,
studentName: string | null,
className: string | null,
recorderName: string
): AttendanceListItem => ({
id: r.id,
studentId: r.studentId,
studentName: studentName ?? "Unknown",
classId: r.classId,
className: className ?? "Unknown",
scheduleId: r.scheduleId ?? null,
date: serializeDate(r.date),
status: r.status,
// L-6null 视为 full_day向后兼容对外暴露统一为 "full_day"
period: r.period ?? "full_day",
remark: r.remark ?? null,
reason: r.reason ?? null,
recordedBy: r.recordedBy,
recorderName,
createdAt: r.createdAt.toISOString(),
})
const resolveRecorderNames = async (
rows: { record: typeof attendanceRecords.$inferSelect }[]
): Promise<Map<string, string>> => {
const ids = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
if (ids.length === 0) return new Map()
// 委托 users data-access 查询,避免跨模块直查 users 表
const userMap = await getUserNamesByIds(ids)
const result = new Map<string, string>()
for (const [id, user] of userMap) {
result.set(id, user.name ?? "Unknown")
}
return result
}
export async function getAttendanceRecordsRaw(
params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<PaginatedAttendanceResult> {
const page = Math.max(1, params.page ?? 1)
const pageSize = Math.max(1, Math.min(100, params.pageSize ?? 20))
const conditions: SQL[] = []
const scopeFilter = buildScopeFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId))
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "date")))
if (params.startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(params.startDate, "startDate")))
if (params.endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(params.endDate, "endDate")))
if (params.status) conditions.push(eq(attendanceRecords.status, params.status))
// L-6按节次过滤
const periodFilter = buildPeriodFilter(params.period)
if (periodFilter) conditions.push(periodFilter)
const where = conditions.length > 0 ? and(...conditions) : undefined
const [totalRow] = await db.select({ c: count() }).from(attendanceRecords).where(where)
const total = Number(totalRow?.c ?? 0)
const totalPages = Math.max(1, Math.ceil(total / pageSize))
// 仅查询 attendanceRecords 表,姓名通过 data-access 委托查询(避免跨模块 JOIN
const rows = await db
.select({
record: attendanceRecords,
})
.from(attendanceRecords)
.where(where)
.orderBy(desc(attendanceRecords.date), desc(attendanceRecords.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize)
// 批量解析 studentName / className / recorderName委托 users/classes data-access
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
const classIds = Array.from(new Set(rows.map((r) => r.record.classId)))
const [studentNameMap, classNameMap, recorderMap] = await Promise.all([
getUserNamesByIds(studentIds),
getClassNamesByIds(classIds),
resolveRecorderNames(rows),
])
return {
items: rows.map((r) =>
mapListItem(
r.record,
studentNameMap.get(r.record.studentId)?.name ?? null,
classNameMap.get(r.record.classId) ?? null,
recorderMap.get(r.record.recordedBy) ?? "Unknown"
)
),
total,
page,
pageSize,
totalPages,
}
}
export const getAttendanceRecords = cacheFn(getAttendanceRecordsRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "records"],
})
export async function getClassAttendanceForDateRaw(
classId: string,
date: string,
/** L-6按节次过滤未传则返回当日所有节次记录 */
period?: AttendancePeriod
): Promise<AttendanceListItem[]> {
const conditions: SQL[] = [
eq(attendanceRecords.classId, classId),
eq(attendanceRecords.date, new Date(date)),
]
const periodFilter = buildPeriodFilter(period)
if (periodFilter) conditions.push(periodFilter)
const rows = await db
.select({
record: attendanceRecords,
})
.from(attendanceRecords)
.where(and(...conditions))
.orderBy(asc(attendanceRecords.studentId))
// 批量解析姓名(委托 users/classes data-access
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
const [studentNameMap, classNameMap] = await Promise.all([
getUserNamesByIds(studentIds),
getClassNamesByIds([classId]),
])
const className = classNameMap.get(classId) ?? null
return rows.map((r) =>
mapListItem(
r.record,
studentNameMap.get(r.record.studentId)?.name ?? null,
className,
"Unknown"
)
)
}
export const getClassAttendanceForDate = cacheFn(getClassAttendanceForDateRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "by-date"],
})
export async function createAttendanceRecord(
data: RecordAttendanceInput,
recordedBy: string
): Promise<string> {
const id = createId()
await db.insert(attendanceRecords).values({
id,
studentId: data.studentId,
classId: data.classId,
scheduleId: data.scheduleId ?? null,
date: safeParseDate(data.date, "date"),
status: data.status,
// L-6节次考勤schema 默认 full_day
period: data.period,
remark: data.remark ?? null,
reason: data.reason ?? null,
recordedBy,
})
return id
}
export async function batchCreateAttendanceRecords(
data: BatchRecordAttendanceInput,
recordedBy: string
): Promise<number> {
if (data.records.length === 0) return 0
const rows = data.records.map((r) => ({
id: createId(),
studentId: r.studentId,
classId: r.classId,
scheduleId: r.scheduleId ?? null,
date: safeParseDate(r.date, "date"),
status: r.status,
// L-6节次考勤schema 默认 full_day
period: r.period,
remark: r.remark ?? null,
reason: r.reason ?? null,
recordedBy,
}))
await db.insert(attendanceRecords).values(rows)
return rows.length
}
export async function updateAttendanceRecord(
id: string,
data: UpdateAttendanceInput
): Promise<void> {
const update: Partial<typeof attendanceRecords.$inferSelect> = { updatedAt: new Date() }
if (data.status !== undefined) update.status = data.status
// L-6支持更新节次
if (data.period !== undefined) update.period = data.period
if (data.remark !== undefined) update.remark = data.remark
if (data.reason !== undefined) update.reason = data.reason || null
if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId
await db.update(attendanceRecords).set(update).where(eq(attendanceRecords.id, id))
}
export async function deleteAttendanceRecord(id: string): Promise<void> {
await db.delete(attendanceRecords).where(eq(attendanceRecords.id, id))
}
/**
* 获取考勤记录的 classId用于资源归属校验
* 返回 null 表示记录不存在。
*/
export async function getAttendanceRecordClassId(id: string): Promise<string | null> {
const [row] = await db
.select({ classId: attendanceRecords.classId })
.from(attendanceRecords)
.where(eq(attendanceRecords.id, id))
.limit(1)
return row?.classId ?? null
}
export async function getClassStudentsForAttendanceRaw(
classId: string
): Promise<Array<{ id: string; name: string; email: string }>> {
// 通过 classes data-access 获取班级学生,避免跨模块直查 classEnrollments 表
return getClassActiveStudentsWithInfo(classId)
}
export const getClassStudentsForAttendance = cacheFn(getClassStudentsForAttendanceRaw, {
tags: ["attendance:students"],
ttl: 60,
keyParts: ["attendance", "students", "for-attendance"],
})
export async function getAttendanceRulesRaw(classId?: string): Promise<AttendanceRule[]> {
const conditions: SQL[] = []
if (classId) {
const classCondition = or(eq(attendanceRules.classId, classId), sql`${attendanceRules.classId} IS NULL`)
if (classCondition) conditions.push(classCondition)
}
const rows = await db
.select({
id: attendanceRules.id,
classId: attendanceRules.classId,
lateThresholdMinutes: attendanceRules.lateThresholdMinutes,
earlyLeaveThresholdMinutes: attendanceRules.earlyLeaveThresholdMinutes,
enableAutoMark: attendanceRules.enableAutoMark,
attendanceRateThreshold: attendanceRules.attendanceRateThreshold,
consecutiveAbsenceThreshold: attendanceRules.consecutiveAbsenceThreshold,
createdAt: attendanceRules.createdAt,
updatedAt: attendanceRules.updatedAt,
})
.from(attendanceRules)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(attendanceRules.createdAt))
return rows.map((r) => ({
id: r.id,
classId: r.classId ?? null,
lateThresholdMinutes: r.lateThresholdMinutes ?? null,
earlyLeaveThresholdMinutes: r.earlyLeaveThresholdMinutes ?? null,
enableAutoMark: r.enableAutoMark ?? null,
attendanceRateThreshold: r.attendanceRateThreshold ?? null,
consecutiveAbsenceThreshold: r.consecutiveAbsenceThreshold ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}))
}
export const getAttendanceRules = cacheFn(getAttendanceRulesRaw, {
tags: ["attendance"],
ttl: 300,
keyParts: ["attendance", "rules"],
})
export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> {
const [existing] = await db
.select({ id: attendanceRules.id })
.from(attendanceRules)
.where(eq(attendanceRules.classId, data.classId))
.limit(1)
if (existing) {
await db
.update(attendanceRules)
.set({
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
enableAutoMark: data.enableAutoMark ?? false,
attendanceRateThreshold: data.attendanceRateThreshold ?? 90,
consecutiveAbsenceThreshold: data.consecutiveAbsenceThreshold ?? 3,
updatedAt: new Date(),
})
.where(eq(attendanceRules.id, existing.id))
return existing.id
}
const id = createId()
await db.insert(attendanceRules).values({
id,
classId: data.classId,
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
enableAutoMark: data.enableAutoMark ?? false,
attendanceRateThreshold: data.attendanceRateThreshold ?? 90,
consecutiveAbsenceThreshold: data.consecutiveAbsenceThreshold ?? 3,
})
return id
}
/**
* 获取考勤总览统计。
* 返回统一的 `AttendanceStats` 类型P2-5 修复:消除数据结构分裂)。
*/
export async function getAttendanceStatsRaw(params: {
scope: DataScope
currentUserId: string
classId?: string
date?: string
}): Promise<AttendanceStats> {
// 直接使用 SQL 聚合查询避免分页截断导致统计失真P0 修复)
const conditions: SQL[] = []
const scopeFilter = buildScopeFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "date")))
const where = conditions.length > 0 ? and(...conditions) : undefined
const [row] = 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 total = Number(row?.total ?? 0)
const present = Number(row?.present ?? 0)
const absent = Number(row?.absent ?? 0)
const late = Number(row?.late ?? 0)
const earlyLeave = Number(row?.earlyLeave ?? 0)
const excused = Number(row?.excused ?? 0)
const schoolActivity = Number(row?.schoolActivity ?? 0)
return {
total,
present,
absent,
late,
earlyLeave,
excused,
schoolActivity,
presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0,
lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0,
}
}
export const getAttendanceStats = cacheFn(getAttendanceStatsRaw, {
tags: ["attendance"],
ttl: 60,
keyParts: ["attendance", "stats"],
})
/**
* L-5将请假申请同步为考勤记录status='excused')。
*
* 同步策略:
* - 遍历 [startDate, endDate] 区间每一天
* - 查询当天是否已有该学生的考勤记录
* - 无记录 → 插入 excused 记录recordedBy = reviewerIdreason 注明请假类型)
* - 已存在非 excused 记录 → 跳过(不覆盖教师手动录入)
* - 已存在 excused 记录 → 跳过(幂等)
*
* @returns 实际插入的记录数
*/
export async function syncExcusedFromLeaveRequest(params: {
studentId: string
classId: string
startDate: string
endDate: string
reason: string
reviewerId: string
}): Promise<number> {
const { studentId, classId, startDate, endDate, reason, reviewerId } = params
const start = new Date(startDate)
const end = new Date(endDate)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) {
return 0
}
// 构造日期区间(含两端,按天递增)
const dates: Date[] = []
const cursor = new Date(start)
cursor.setUTCHours(0, 0, 0, 0)
const endBoundary = new Date(end)
endBoundary.setUTCHours(0, 0, 0, 0)
while (cursor <= endBoundary) {
dates.push(new Date(cursor))
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
// 一次性查询该学生区间内所有现有考勤记录
const existing = await db
.select({
date: attendanceRecords.date,
status: attendanceRecords.status,
})
.from(attendanceRecords)
.where(
and(
eq(attendanceRecords.studentId, studentId),
eq(attendanceRecords.classId, classId),
gte(attendanceRecords.date, start),
lte(attendanceRecords.date, end),
),
)
const existingDateKeys = new Set(
existing.map((r) => new Date(r.date).toISOString().slice(0, 10)),
)
// 仅插入不存在的日期
const toInsert = dates
.filter((d) => !existingDateKeys.has(d.toISOString().slice(0, 10)))
.map((d) => ({
id: createId(),
studentId,
classId,
scheduleId: null,
date: d,
status: "excused" as const,
// L-6请假同步为全日 excused 记录
period: "full_day" as const,
remark: null,
reason,
recordedBy: reviewerId,
}))
if (toInsert.length === 0) return 0
await db.insert(attendanceRecords).values(toInsert)
return toInsert.length
}