- grades: split analytics into class/overview/student/trend files - messaging: split into bulk/core/group/reports/templates - school: split into classrooms/departments/grades/schools/semesters/subjects - Add lib/type-guards.ts for attendance, classes, elective, exams, invitation-codes, leave-requests, notifications, scheduling, school, settings, standards, textbooks - Add lesson-preparation/lib/status-mappers.ts - Add parent/actions.ts - Add shared/lib/date-utils.ts
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
import "server-only"
|
||
|
||
/**
|
||
* 年级数据访问层
|
||
*
|
||
* 审计 G3-007 / S-01 修复:从 data-access.ts 拆分。
|
||
*
|
||
* 职责:
|
||
* - getGrades / getGradesForStaff: 年级列表查询(带缓存,含年级主任/教学主任信息)
|
||
* - getGradeOptions / getGradeNameById: 跨模块只读接口
|
||
* - isGradeHead / isGradeManager / findGradeIdByHeadAndName: 跨模块权限校验
|
||
* - createGrade / updateGrade / deleteGrade: 年级 CRUD
|
||
* - promoteGrades: 年级升级(order +1 + 名称升级)
|
||
* - getGradeOverviewStats: 年级概览统计
|
||
*
|
||
* 变更历史:
|
||
* - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出
|
||
* - G3-012 / F-09: promoteGrades 改用 db.transaction 包裹循环 UPDATE
|
||
* - S-03: 抽取 fetchGradesWithHeads helper,消除 3 处重复代码
|
||
*/
|
||
|
||
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
|
||
|
||
import { cacheFn } from "@/shared/lib/cache"
|
||
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
|
||
import { db } from "@/shared/db"
|
||
import { grades, schools, users } from "@/shared/db/schema"
|
||
import type {
|
||
GradeInsertData,
|
||
GradeListItem,
|
||
GradeUpdateData,
|
||
StaffOption,
|
||
} from "./types"
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 共享 helper(S-03 修复:消除 getGrades / getGradesForStaff / getGradesForUser 三处重复)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface GradeRowWithHeadIds {
|
||
id: string
|
||
name: string
|
||
order: number | null
|
||
schoolId: string
|
||
schoolName: string
|
||
gradeHeadId: string | null
|
||
teachingHeadId: string | null
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
/**
|
||
* 按 where 条件查询年级(含学校信息),并 JOIN 出年级主任/教学主任的用户信息。
|
||
* 三个查询函数(getGrades / getGradesForStaff / getGradesByIds)
|
||
* 共享此 helper,消除"查询年级 + 批量查询主任用户 + 映射"的重复代码(S-03 修复)。
|
||
*/
|
||
async function fetchGradesWithHeads(
|
||
where?: SQL
|
||
): Promise<GradeListItem[]> {
|
||
const rows = await db
|
||
.select({
|
||
id: grades.id,
|
||
name: grades.name,
|
||
order: grades.order,
|
||
schoolId: schools.id,
|
||
schoolName: schools.name,
|
||
gradeHeadId: grades.gradeHeadId,
|
||
teachingHeadId: grades.teachingHeadId,
|
||
createdAt: grades.createdAt,
|
||
updatedAt: grades.updatedAt,
|
||
})
|
||
.from(grades)
|
||
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||
.where(where)
|
||
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
|
||
|
||
return mapGradesWithHeads(rows)
|
||
}
|
||
|
||
/** 将已查询的年级行映射为 GradeListItem[](含批量查询主任用户信息) */
|
||
async function mapGradesWithHeads(
|
||
rows: GradeRowWithHeadIds[]
|
||
): Promise<GradeListItem[]> {
|
||
const headIds = Array.from(
|
||
new Set(
|
||
rows
|
||
.flatMap((r) => [r.gradeHeadId, r.teachingHeadId])
|
||
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
||
)
|
||
)
|
||
|
||
const heads = headIds.length
|
||
? await db
|
||
.select({ id: users.id, name: users.name, email: users.email })
|
||
.from(users)
|
||
.where(inArray(users.id, headIds))
|
||
: []
|
||
|
||
const headById = new Map<string, StaffOption>()
|
||
for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email })
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
school: { id: r.schoolId, name: r.schoolName },
|
||
name: r.name,
|
||
order: Number(r.order ?? 0),
|
||
gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null,
|
||
teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null,
|
||
createdAt: toIso(r.createdAt),
|
||
updatedAt: toIso(r.updatedAt),
|
||
}))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 只读查询
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const getGradesRaw = async (): Promise<GradeListItem[]> => {
|
||
return await fetchGradesWithHeads()
|
||
}
|
||
export const getGrades = cacheFn(getGradesRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "getGrades"],
|
||
})
|
||
|
||
export const getGradesForStaffRaw = async (staffId: string): Promise<GradeListItem[]> => {
|
||
const id = staffId.trim()
|
||
if (!id) return []
|
||
|
||
return await fetchGradesWithHeads(
|
||
or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id))
|
||
)
|
||
}
|
||
export const getGradesForStaff = cacheFn(getGradesForStaffRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "getGradesForStaff"],
|
||
})
|
||
|
||
/**
|
||
* 按 ID 列表批量获取年级(含学校信息 + 年级主任/教学主任)。
|
||
* 供 lib/school-permissions.ts 的 teacher 角色分支使用,避免重复实现 fetchGradesWithHeads 逻辑(S-03 修复)。
|
||
*/
|
||
export const getGradesByIdsRaw = async (gradeIds: string[]): Promise<GradeListItem[]> => {
|
||
const ids = Array.from(new Set(gradeIds.filter((id) => id.trim().length > 0)))
|
||
if (ids.length === 0) return []
|
||
|
||
return await fetchGradesWithHeads(inArray(grades.id, ids))
|
||
}
|
||
export const getGradesByIds = cacheFn(getGradesByIdsRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "getGradesByIds"],
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 跨模块只读接口
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type GradeOption = {
|
||
id: string
|
||
name: string
|
||
schoolId: string
|
||
schoolName: string
|
||
order: number
|
||
}
|
||
|
||
export const getGradeOptionsRaw = async (): Promise<GradeOption[]> => {
|
||
const rows = await db
|
||
.select({
|
||
id: grades.id,
|
||
name: grades.name,
|
||
order: grades.order,
|
||
schoolId: schools.id,
|
||
schoolName: schools.name,
|
||
})
|
||
.from(grades)
|
||
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
schoolId: r.schoolId,
|
||
schoolName: r.schoolName,
|
||
order: Number(r.order ?? 0),
|
||
}))
|
||
}
|
||
export const getGradeOptions = cacheFn(getGradeOptionsRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "getGradeOptions"],
|
||
})
|
||
|
||
/**
|
||
* 按 ID 获取单个年级名称。
|
||
* 供跨模块调用使用,避免为单个年级拉取全量年级选项。
|
||
*/
|
||
export const getGradeNameByIdRaw = async (
|
||
gradeId: string
|
||
): Promise<string | null> => {
|
||
const id = gradeId.trim()
|
||
if (!id) return null
|
||
const [row] = await db
|
||
.select({ name: grades.name })
|
||
.from(grades)
|
||
.where(eq(grades.id, id))
|
||
.limit(1)
|
||
return row?.name ?? null
|
||
}
|
||
export const getGradeNameById = cacheFn(getGradeNameByIdRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "getGradeNameById"],
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 跨模块权限校验接口
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 校验用户是否为指定年级的年级主任。
|
||
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
|
||
*/
|
||
export const isGradeHeadRaw = async (
|
||
gradeId: string,
|
||
userId: string
|
||
): Promise<boolean> => {
|
||
const trimmedGradeId = gradeId.trim()
|
||
const trimmedUserId = userId.trim()
|
||
if (!trimmedGradeId || !trimmedUserId) return false
|
||
|
||
const [row] = await db
|
||
.select({ id: grades.id })
|
||
.from(grades)
|
||
.where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId)))
|
||
.limit(1)
|
||
return Boolean(row)
|
||
}
|
||
export const isGradeHead = cacheFn(isGradeHeadRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "isGradeHead"],
|
||
})
|
||
|
||
/**
|
||
* 校验用户是否为指定年级的年级主任或教学主任。
|
||
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
|
||
*/
|
||
export const isGradeManagerRaw = async (
|
||
gradeId: string,
|
||
userId: string
|
||
): Promise<boolean> => {
|
||
const trimmedGradeId = gradeId.trim()
|
||
const trimmedUserId = userId.trim()
|
||
if (!trimmedGradeId || !trimmedUserId) return false
|
||
|
||
const [row] = await db
|
||
.select({ id: grades.id })
|
||
.from(grades)
|
||
.where(
|
||
and(
|
||
eq(grades.id, trimmedGradeId),
|
||
or(eq(grades.gradeHeadId, trimmedUserId), eq(grades.teachingHeadId, trimmedUserId))
|
||
)
|
||
)
|
||
.limit(1)
|
||
return Boolean(row)
|
||
}
|
||
export const isGradeManager = cacheFn(isGradeManagerRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "isGradeManager"],
|
||
})
|
||
|
||
/**
|
||
* 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。
|
||
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
|
||
*/
|
||
export const findGradeIdByHeadAndNameRaw = async (
|
||
userId: string,
|
||
gradeName: string
|
||
): Promise<string | null> => {
|
||
const trimmedUserId = userId.trim()
|
||
const normalizedGradeName = gradeName.trim().toLowerCase()
|
||
if (!trimmedUserId || !normalizedGradeName) return null
|
||
|
||
const [row] = await db
|
||
.select({ id: grades.id })
|
||
.from(grades)
|
||
.where(
|
||
and(
|
||
eq(grades.gradeHeadId, trimmedUserId),
|
||
sql`LOWER(${grades.name}) = ${normalizedGradeName}`
|
||
)
|
||
)
|
||
.limit(1)
|
||
return row?.id ?? null
|
||
}
|
||
export const findGradeIdByHeadAndName = cacheFn(findGradeIdByHeadAndNameRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "findGradeIdByHeadAndName"],
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mutations — DB write operations (called only from actions.ts)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function createGrade(data: GradeInsertData): Promise<void> {
|
||
await db.insert(grades).values({
|
||
id: data.id,
|
||
schoolId: data.schoolId,
|
||
name: data.name,
|
||
order: data.order,
|
||
gradeHeadId: data.gradeHeadId,
|
||
teachingHeadId: data.teachingHeadId,
|
||
})
|
||
}
|
||
|
||
export async function updateGrade(
|
||
id: string,
|
||
data: GradeUpdateData
|
||
): Promise<void> {
|
||
await db
|
||
.update(grades)
|
||
.set({
|
||
schoolId: data.schoolId,
|
||
name: data.name,
|
||
order: data.order,
|
||
gradeHeadId: data.gradeHeadId,
|
||
teachingHeadId: data.teachingHeadId,
|
||
})
|
||
.where(eq(grades.id, id))
|
||
}
|
||
|
||
export async function deleteGrade(id: string): Promise<void> {
|
||
await db.delete(grades).where(eq(grades.id, id))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 年级升级
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 将年级名称升级:一年级→二年级,1年级→2年级,Grade 1→Grade 2
|
||
* 无法识别的名称保持不变。
|
||
*/
|
||
function promoteGradeName(name: string): string {
|
||
// 中文数字映射
|
||
const chineseNums = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"]
|
||
for (let i = 0; i < chineseNums.length - 1; i++) {
|
||
if (name.includes(chineseNums[i]) && !name.includes(chineseNums[i + 1])) {
|
||
return name.replace(chineseNums[i], chineseNums[i + 1])
|
||
}
|
||
}
|
||
|
||
// 阿拉伯数字
|
||
const match = name.match(/(\d+)/)
|
||
if (match) {
|
||
const num = parseInt(match[1], 10)
|
||
if (num > 0 && num < 13) {
|
||
return name.replace(match[1], String(num + 1))
|
||
}
|
||
}
|
||
|
||
return name
|
||
}
|
||
|
||
/**
|
||
* 年级升级:将指定学校下的所有年级 order +1,并更新年级名称(如果符合数字模式)。
|
||
* 例如:一年级 → 二年级,order 1 → 2
|
||
*
|
||
* G3-012 / F-09 修复:使用 db.transaction 包裹循环 UPDATE,保证原子性。
|
||
* 任意一条更新失败将回滚整个升级操作,避免出现部分年级已升级、部分未升级的不一致状态。
|
||
*
|
||
* 注意:此函数只更新年级本身的 order 和 name,不迁移班级数据。
|
||
* 班级升级应由 classes 模块的独立 Action 处理。
|
||
*/
|
||
export async function promoteGrades(schoolId: string): Promise<{ promoted: number }> {
|
||
return await db.transaction(async (tx) => {
|
||
const rows = await tx
|
||
.select({ id: grades.id, name: grades.name, order: grades.order })
|
||
.from(grades)
|
||
.where(eq(grades.schoolId, schoolId))
|
||
.orderBy(desc(grades.order)) // 从高到低升级,避免唯一约束冲突
|
||
|
||
let promoted = 0
|
||
for (const row of rows) {
|
||
const newOrder = (row.order ?? 0) + 1
|
||
const newName = promoteGradeName(row.name)
|
||
await tx
|
||
.update(grades)
|
||
.set({ order: newOrder, name: newName })
|
||
.where(eq(grades.id, row.id))
|
||
promoted += 1
|
||
}
|
||
|
||
return { promoted }
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 年级概览统计
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 年级概览统计:每个年级的班级数、学生数、教师数。
|
||
* 用于年级管理页面的卡片视图,让管理员一目了然看到各年级规模。
|
||
*/
|
||
export interface GradeOverviewStats {
|
||
gradeId: string
|
||
classCount: number
|
||
studentCount: number
|
||
teacherCount: number
|
||
}
|
||
|
||
export const getGradeOverviewStatsRaw = async (): Promise<GradeOverviewStats[]> => {
|
||
// 动态导入 classes 模块避免循环依赖
|
||
const { getAdminClasses } = await import("@/modules/classes/data-access")
|
||
const allClasses = await getAdminClasses()
|
||
|
||
// 按年级分组统计班级数和学生数
|
||
const statsByGrade = new Map<string, { classCount: number; studentCount: number; teacherIds: Set<string> }>()
|
||
|
||
for (const cls of allClasses) {
|
||
const gradeId = cls.gradeId
|
||
if (typeof gradeId !== "string" || gradeId.length === 0) continue
|
||
|
||
const existing = statsByGrade.get(gradeId) ?? { classCount: 0, studentCount: 0, teacherIds: new Set<string>() }
|
||
existing.classCount += 1
|
||
existing.studentCount += cls.studentCount ?? 0
|
||
// 班主任算一个教师
|
||
if (cls.teacher?.id) existing.teacherIds.add(cls.teacher.id)
|
||
// 学科教师也算
|
||
for (const st of cls.subjectTeachers ?? []) {
|
||
if (st.teacher?.id) existing.teacherIds.add(st.teacher.id)
|
||
}
|
||
statsByGrade.set(gradeId, existing)
|
||
}
|
||
|
||
return Array.from(statsByGrade.entries()).map(([gradeId, s]) => ({
|
||
gradeId,
|
||
classCount: s.classCount,
|
||
studentCount: s.studentCount,
|
||
teacherCount: s.teacherIds.size,
|
||
}))
|
||
}
|
||
export const getGradeOverviewStats = cacheFn(getGradeOverviewStatsRaw, {
|
||
tags: ["school"],
|
||
ttl: 300,
|
||
keyParts: ["school", "getGradeOverviewStats"],
|
||
})
|