- 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
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
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-6:null 视为 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 = reviewerId,reason 注明请假类型)
|
||
* - 已存在非 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
|
||
}
|