Files
gongxue-base/apps/server/src/attendance/attendance.service.ts
wangziqi 00e9c5e45a fix: 修复多处边界条件问题
- rooms: parseRoomNumber 未知格式返回默认 capacity=4,防止 undefined 绕过入住容量检查
- rooms: 修复 parseInt() || undefined 导致楼层 0 被吞掉
- rooms: batchImport 中 capacity 使用 ?? 代替 ||,显式 0 不被覆盖
- occupancies: 所有 capacity 比较加 ?? 0 防守兜底,fail closed
- schedules: assertValidScheduleRange 增加 startTime > endTime 校验
- attendance: 时段重叠检查改为按 startTime 排序后再比较,消除漏检
- attendance: 移除 getScheduleOptionsForAttendance 中不可靠的 raw[index] fallback
- expenses: 个人附加费批量导入增加 assertPositiveAmount 校验
- expenses: 水电费导入增加 periodEnd >= periodStart 校验
2026-07-20 14:45:53 +08:00

1519 lines
56 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 { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm';
import {
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
AttendancePeriodConfig,
DingAttendanceRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
ClassTeacher,
TeacherRoleType,
ScheduleType,
StudentDingMapping,
} from '../entities';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
UpdateAttendanceRecordDto,
GenerateAttendanceFromSchedulesDto,
GenerateFromSchedulesDto,
SaveAttendancePeriodConfigsDto,
} from './dto/attendance.dto';
/** Keyed mutex serializing operations on the same attendance session. */
class SessionMutex {
private queueTails = new Map<number, Promise<void>>();
async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
let release!: () => void;
const newTail = new Promise<void>((resolve) => { release = resolve; });
this.queueTails.set(sessionId, newTail);
await tail;
try {
return await fn();
} finally {
release();
if (this.queueTails.get(sessionId) === newTail) {
this.queueTails.delete(sessionId);
}
}
}
}
@Injectable()
export class AttendanceService {
constructor(
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw)
private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(Class)
private classRepo: Repository<Class>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(ClassSchedule)
private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent)
private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendanceDevice)
private attendanceDeviceRepo: Repository<AttendanceDevice>,
@InjectRepository(AttendancePeriodConfig)
private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
private dataSource: DataSource,
) {}
private sessionMutex = new SessionMutex();
private readonly defaultAttendancePeriods = [
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
] as const;
private formatDeviceDetail(device: AttendanceDevice): string {
const classroomName = device.classroom?.name;
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
}
private async attachAttendanceDeviceMappings<T extends AttendanceRecord>(
records: T[],
classroomId?: number | null,
): Promise<T[]> {
if (records.length === 0) return records;
const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])];
const devicesBySn = new Map<string, AttendanceDevice>();
if (sns.length > 0) {
const devices = await this.attendanceDeviceRepo.find({
where: { deviceSn: In(sns) },
relations: ['classroom'],
});
for (const device of devices) devicesBySn.set(device.deviceSn, device);
}
const classroomIds = [...new Set([
...records.map((record) => record.classId).filter((id): id is number => id != null),
...(classroomId != null ? [classroomId] : []),
])];
const devicesByClassroom = new Map<number, AttendanceDevice>();
if (classroomIds.length > 0) {
const devices = await this.attendanceDeviceRepo.find({
where: { classroomId: In(classroomIds), status: 'active' },
relations: ['classroom'],
order: { id: 'ASC' },
});
for (const device of devices) {
if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
}
}
for (const record of records) {
const sn = record.punchDeviceId?.trim();
const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
if (mappedBySn) {
record.punchDeviceName = this.formatDeviceDetail(mappedBySn);
record.punchDeviceId = mappedBySn.deviceSn;
continue;
}
const source = (record.punchSource || '').trim().toUpperCase();
const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
(value) => source === value || source.includes(value),
);
const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
const mappedByClassroom = fallbackClassroomId ? devicesByClassroom.get(fallbackClassroomId) : undefined;
if (isMachine && mappedByClassroom && !record.punchDeviceName) {
record.punchDeviceName = this.formatDeviceDetail(mappedByClassroom);
record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
}
}
return records;
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
}
private isClassStudentActiveOnDate(classStudent: Pick<ClassStudent, 'joinDate' | 'leaveDate' | 'status'>, lessonDate: string): boolean {
const status = classStudent.status ?? 'active';
if (!['active', 'left'].includes(status)) return false;
if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false;
if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false;
return true;
}
private async getClassStudentsForLesson(
classId: number,
lessonDate: string,
relations: string[] = [],
): Promise<ClassStudent[]> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: In(['active', 'left']) },
relations,
});
return classStudents.filter((classStudent) =>
this.isClassStudentActiveOnDate(classStudent, lessonDate),
);
}
/** List classes the current user may select for DingTalk attendance import. */
async getImportableClasses(userId: number, isSuperAdmin = false) {
if (isSuperAdmin) {
const classes = await this.classRepo.find({
where: { isArchived: false },
order: { name: 'ASC' },
});
return classes.map((item) => ({ classId: item.id, className: item.name }));
}
const assignments = await this.classTeacherRepo.find({
where: { userId },
relations: ['class'],
});
const classes = new Map<number, string>();
for (const assignment of assignments) {
if (assignment.class && !assignment.class.isArchived) {
classes.set(assignment.classId, assignment.class.name);
}
}
return [...classes.entries()]
.map(([classId, className]) => ({ classId, className }))
.sort((left, right) => left.className.localeCompare(right.className, 'zh-CN'));
}
/** Resolve the DingTalk users a teacher may import for one assigned class. */
async getTeacherClassDingUserIds(
userId: number,
classId: number,
isSuperAdmin = false,
lessonDate?: string,
): Promise<string[]> {
if (!isSuperAdmin) {
const assignment = await this.classTeacherRepo.findOne({
where: { userId, classId },
});
if (!assignment) {
throw new BadRequestException('只能拉取自己任教班级的考勤记录');
}
} else {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
}
const classStudents = lessonDate
? await this.getClassStudentsForLesson(classId, lessonDate)
: await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) {
throw new BadRequestException('该班级暂无在读学生');
}
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
if (userIds.length === 0) {
throw new BadRequestException('该班级学生尚未同步钉钉账号');
}
return userIds.sort();
}
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('排课记录不存在');
if (schedule.scheduleType !== ScheduleType.INTERNAL || schedule.status !== 'active') {
throw new BadRequestException('该排课不能进行课程考勤');
}
if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
throw new BadRequestException('所选日期不在排课有效期内');
}
const date = new Date(`${lessonDate}T00:00:00`);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
return schedule;
}
async getLessonAttendance(scheduleId: number, lessonDate: string) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const session = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
const records = session
? await this.attendanceRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
})
: [];
return { schedule, session, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
}
private getLessonAttendanceWindow(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { start: number; end: number; dateFrom: string; dateTo: string } {
const startMinuteOfDay = this.toMinutes(schedule.startTime);
const endMinuteOfDay = this.toMinutes(schedule.endTime);
const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
const overnight = endMinuteOfDay <= startMinuteOfDay;
if (overnight) lessonEnd += 24 * 60 * 60 * 1000;
return {
start: lessonStart - advanceMinutes * 60 * 1000,
end: lessonEnd,
dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate,
dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
};
}
getLessonAttendanceImportDateRange(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { startDate: string; endDate: string } {
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
return { startDate: window.dateFrom, endDate: window.dateTo };
}
private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[],
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): DingAttendanceRaw[] {
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
return records.filter((record) => {
// 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= window.start && time.getTime() <= window.end;
});
}
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime);
if (hasPunch) return 'present';
return finalize ? 'absent' : 'pending';
}
private getLessonPunchMetadata(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
const punches = records
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
if (punches.length === 0) {
return {
punchTime: null,
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
};
}
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
punches.sort(
(left, right) =>
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
);
const primary = punches[0];
const metadataRecord = [...punches]
.filter(({ record }) =>
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
)
.sort(
(left, right) =>
Math.abs(left.time.getTime() - primary.time.getTime()) -
Math.abs(right.time.getTime() - primary.time.getTime()),
)[0]?.record;
const source =
metadataRecord?.punchSource ||
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
? metadataRecord.attendanceType
: primary.record.punchSource);
return {
punchTime: primary.time,
punchSource: source || null,
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
};
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
userId: number,
finalize = false,
) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date();
const courseClock = this.getCourseClock(now);
const today = courseClock.date;
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute;
const currentMinute = courseClock.minutes;
if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤');
}
}
const existing = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
if (existing.status === 'completed') {
const records = await this.attendanceRepo.find({
where: { attendanceSessionId: existing.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
}
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
throw new BadRequestException('课程考勤正在结算');
}
// Refresh in_progress session from latest DingTalk data
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
const updatedRecords = existingRecords.map((record) => {
record.student = studentsById.get(record.studentId)!;
// Preserve manual corrections only while the lesson is still in progress.
if (!finalize && record.source !== 'dingtalk') return record;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate,
);
record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果';
return record;
});
for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
);
updatedRecords.push(
recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: existing.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
}),
);
}
const saved = await recordRepo.save(updatedRecords);
if (finalize) {
existing.status = 'completed';
existing.completedBy = userId;
existing.completedAt = new Date();
await sessionRepo.save(existing);
}
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
});
}
// First pull: create session and records atomically
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
let session: AttendanceSession;
try {
session = await sessionRepo.save(
sessionRepo.create({
scheduleId,
classId: schedule.classId!,
lessonDate,
status: 'in_progress',
startedBy: userId,
startedAt: new Date(),
}),
);
} catch (err: unknown) {
const code = (err as Record<string, unknown>).code;
const errno = (err as Record<string, unknown>).errno;
// MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
const existing = await sessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
session = existing;
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session, records: await this.attachAttendanceDeviceMappings(existingRecords, schedule.classId) };
}
}
throw err;
}
const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
});
});
const saved = await recordRepo.save(records);
if (finalize) {
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
session = await sessionRepo.save(session);
}
return { schedule, session, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
});
}
private async fetchDingTalkRawByStudent(
classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: Between(window.dateFrom, window.dateTo),
matchedStudentId: In(studentIds),
},
});
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
for (const raw of rawRecords) {
if (raw.matchedStudentId == null) continue;
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
arr.push(raw);
rawByStudent.set(raw.matchedStudentId, arr);
}
return rawByStudent;
}
async completeLessonAttendance(sessionId: number, userId: number) {
return this.sessionMutex.runExclusive(sessionId, () =>
this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const session = await sessionRepo.findOne({ where: { id: sessionId } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
// Re-check under lock: if already completed, return current state idempotently
if (session.status === 'completed') {
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
}
const pendingRecords = await recordRepo.count({
where: { attendanceSessionId: sessionId, status: 'pending' },
});
if (pendingRecords > 0) {
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
}
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
const savedSession = await sessionRepo.save(session);
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session: savedSession, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
}),
);
}
async findAttendanceSession(id: number) {
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
return session;
}
// ── Batch create attendance records ──
async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) {
throw new BadRequestException('records array must not be empty');
}
const entities = dto.records.map((r) => {
const entity = this.attendanceRepo.create({
studentId: r.studentId,
classId: r.classId ?? undefined,
attendanceDate: r.attendanceDate,
session: r.session,
status: r.status,
remark: r.remark,
source: r.source || 'manual',
});
return entity;
});
const saved = await this.attendanceRepo.save(entities);
return { count: saved.length, records: saved };
}
// ── Generate attendance records from class schedules ──
async generateAttendanceFromSchedules(dto: GenerateAttendanceFromSchedulesDto) {
const { classId, dateFrom, dateTo } = dto;
if (dateFrom > dateTo) {
throw new BadRequestException('dateFrom must not be later than dateTo');
}
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) {
throw new NotFoundException(`Class ${classId} not found`);
}
const schedules = await this.scheduleRepo.find({
where: {
classId,
scheduleType: ScheduleType.INTERNAL,
status: 'active',
startDate: LessThanOrEqual(dateTo),
endDate: MoreThanOrEqual(dateFrom),
},
});
const classStudents = await this.classStudentRepo.find({
where: { classId, status: In(['active', 'left']) },
relations: ['student'],
});
if (schedules.length === 0 || classStudents.length === 0) {
return { count: 0, records: [] };
}
const existingRecords = await this.attendanceRepo.find({
where: { classId, attendanceDate: Between(dateFrom, dateTo) },
});
const existingKeys = new Set(
existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`),
);
const entities: AttendanceRecord[] = [];
const end = new Date(dateTo);
for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().slice(0, 10);
const weekDay = d.getDay() === 0 ? 7 : d.getDay();
for (const sched of schedules) {
if (sched.weekDay !== weekDay) continue;
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
const session = await this.mapScheduleTimeToSession(sched.startTime);
const classStudentsForDate = classStudents.filter((cs) =>
this.isClassStudentActiveOnDate(cs, dateStr),
);
for (const cs of classStudentsForDate) {
const key = `${cs.studentId}|${dateStr}|${session}`;
if (existingKeys.has(key)) continue;
const entity = this.attendanceRepo.create({
studentId: cs.studentId,
classId,
attendanceDate: dateStr,
session,
status: 'pending',
source: 'schedule',
});
entities.push(entity);
existingKeys.add(key);
}
}
}
const saved = await this.attendanceRepo.save(entities);
return { count: saved.length, records: saved };
}
// ── Generate attendance records from schedules (optional date range, defaults to current week) ──
async generateFromSchedules(
dto: GenerateFromSchedulesDto,
): Promise<{ count: number; records: AttendanceRecord[] }> {
const { classId, startDate, endDate } = dto;
// Default to current week (MondaySunday)
const now = new Date();
const dayOfWeek = now.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(now);
monday.setDate(now.getDate() + mondayOffset);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
const dateFrom = startDate ?? monday.toISOString().slice(0, 10);
const dateTo = endDate ?? sunday.toISOString().slice(0, 10);
return this.generateAttendanceFromSchedules({
classId,
dateFrom,
dateTo,
});
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private getCourseClock(date: Date): { date: string; minutes: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
minutes: Number(parts.hour) * 60 + Number(parts.minute),
};
}
private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T00:00:00.000Z`);
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted.toISOString().slice(0, 10);
}
private async ensureAttendancePeriodConfigs() {
const count = await this.attendancePeriodConfigRepo.count();
if (count === 0) {
await this.attendancePeriodConfigRepo.save(
this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({
...period,
enabled: true,
})),
);
}
return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } });
}
async getAttendancePeriodConfigs() {
return this.ensureAttendancePeriodConfigs();
}
async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
const parsedDate = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
const qb = this.scheduleRepo
.createQueryBuilder('schedule')
.where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
.andWhere('schedule.status = :status', { status: 'active' })
.andWhere('schedule.classId IS NOT NULL')
.andWhere('schedule.weekDay = :weekDay', { weekDay })
.andWhere('schedule.startDate <= :date', { date })
.andWhere('schedule.endDate >= :date', { date });
if (classId) {
qb.andWhere('schedule.classId = :classId', { classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
if (!session) return schedules;
const matchedSchedules: ClassSchedule[] = [];
for (const schedule of schedules) {
if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
matchedSchedules.push(schedule);
}
}
return matchedSchedules;
}
async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) {
const seen = new Set<string>();
const normalized = dto.periods.map((period, index) => {
const periodKey = period.periodKey.trim();
const label = period.label.trim();
if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空');
if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`);
seen.add(periodKey);
if (this.toMinutes(period.endTime) <= this.toMinutes(period.startTime)) {
throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
}
return {
periodKey,
label,
startTime: period.startTime,
endTime: period.endTime,
sortOrder: period.sortOrder ?? index + 1,
enabled: period.enabled ?? true,
};
}).sort((left, right) => left.sortOrder - right.sortOrder);
// 按开始时间排序后再检查重叠,避免 sortOrder 与时间顺序不一致时漏检
const sortedByTime = [...normalized].sort(
(left, right) => this.toMinutes(left.startTime) - this.toMinutes(right.startTime),
);
for (let index = 1; index < sortedByTime.length; index += 1) {
const previous = sortedByTime[index - 1];
const current = sortedByTime[index];
if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) {
throw new BadRequestException(`${previous.label}${current.label} 时间段不能重叠`);
}
}
await this.attendancePeriodConfigRepo.clear();
await this.attendancePeriodConfigRepo.save(
normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
);
return this.getAttendancePeriodConfigs();
}
async resetAttendancePeriodConfigs() {
await this.attendancePeriodConfigRepo.clear();
return this.ensureAttendancePeriodConfigs();
}
private mapLessonScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';
if (hour < 12) return 'morning';
if (hour < 17) return 'afternoon';
if (hour < 20) return 'evening_study';
return 'night_check';
}
private async mapScheduleTimeToSession(startTime: string): Promise<string> {
const startMinutes = this.toMinutes(startTime);
const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
const matched = periods.find((period) => {
const periodStart = this.toMinutes(period.startTime);
const periodEnd = this.toMinutes(period.endTime);
return startMinutes >= periodStart && startMinutes < periodEnd;
});
if (matched) return matched.periodKey;
throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`);
}
// ── Attendance summary ──
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (!query.classId && accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
const pending = rows.filter((r) => r.status === 'pending').length;
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
return { total, present, late, absent, leave, pending, presentRate };
}
// ── Attendance calendar ──
async getCalendar(query: AttendanceCalendarQueryDto) {
const { classId, weekStart } = query;
if (!weekStart) {
// Default to the Monday of the current week
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? -6 : 1 - day; // Monday offset
const monday = new Date(now);
monday.setDate(now.getDate() + diff);
const mondayStr = monday.toISOString().slice(0, 10);
return this.buildCalendar(classId, mondayStr);
}
return this.buildCalendar(classId, weekStart);
}
private getWeekDayForDate(date: string): number {
const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
return day === 0 ? 7 : day;
}
async getScheduleOptionsForAttendance(classId: number, date: string) {
const weekDay = this.getWeekDayForDate(date);
const { entities, raw } = await this.scheduleRepo
.createQueryBuilder('cs')
.leftJoin('cs.teacher', 'teacher')
.addSelect('cs.id', 'scheduleIdForTeacherMap')
.addSelect('teacher.username', 'teacherUsername')
.addSelect('teacher.name', 'teacherName')
.where('cs.classId = :classId', { classId })
.andWhere('cs.weekDay = :weekDay', { weekDay })
.andWhere('cs.startDate <= :date', { date })
.andWhere('cs.endDate >= :date', { date })
.andWhere('cs.status = :status', { status: 'active' })
.orderBy('cs.startTime', 'ASC')
.addOrderBy('cs.subject', 'ASC')
.getRawAndEntities();
const teacherByScheduleId = new Map(
raw.map((row) => [
Number(row.scheduleIdForTeacherMap),
{
teacherName: row.teacherName || null,
teacherUsername: row.teacherUsername || null,
},
]),
);
return entities.map((schedule) => {
const teacher = teacherByScheduleId.get(schedule.id) ?? {
teacherName: null,
teacherUsername: null,
};
return { ...schedule, ...teacher };
});
}
private async buildCalendar(classId: number, weekStart: string) {
// Compute weekEnd (Sunday = weekStart + 6 days)
const start = new Date(weekStart);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const endStr = end.toISOString().slice(0, 10);
// Fetch attendance records for the week
const records = await this.attendanceRepo.find({
where: {
classId,
attendanceDate: Between(weekStart, endStr),
},
relations: ['student'],
order: { attendanceDate: 'ASC', session: 'ASC' },
});
// Group by studentId
const studentMap = new Map<
number,
{
studentId: number;
studentName: string;
days: Array<{ date: string; session: string; status: string }>;
}
>();
for (const r of records) {
if (!studentMap.has(r.studentId)) {
studentMap.set(r.studentId, {
studentId: r.studentId,
studentName: r.student?.name ?? `Student#${r.studentId}`,
days: [],
});
}
studentMap.get(r.studentId)!.days.push({
date: r.attendanceDate,
session: r.session,
status: r.status,
});
}
return Array.from(studentMap.values());
}
// ── List attendance records with filters ──
async findAll(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
},
accessibleClassIds?: number[],
) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
if (query.status) {
qb.andWhere('ar.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('ar.source = :source', { source: query.source });
}
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list: await this.attachAttendanceDeviceMappings(list), total, page, pageSize };
}
// ── Get distinct classes with attendance records ──
async getClasses(accessibleClassIds?: number[]) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const rows = accessibleClassIds
? accessibleClassIds.map((classId) => ({ classId }))
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
if (classIds.length === 0) return [];
const where = { id: In(classIds) };
const [classes, teachers] = await Promise.all([
this.classRepo.find({ where }),
this.classTeacherRepo.find({
where: {
classId: In(classIds),
roleType: In([
TeacherRoleType.HEAD_TEACHER,
TeacherRoleType.LIFE_TEACHER,
TeacherRoleType.SUBJECT_TEACHER,
]),
},
relations: ['user'],
order: { roleType: 'ASC', id: 'ASC' },
}),
]);
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
const teacherMap = new Map<
number,
Array<{
userId: number;
username: string | null;
name: string | null;
roleType: string;
subject: string | null;
}>
>();
for (const teacher of teachers) {
const user = teacher.user as { username?: string | null; name?: string | null } | undefined;
const items = teacherMap.get(teacher.classId) ?? [];
items.push({
userId: teacher.userId,
username: user?.username || null,
name: user?.name || null,
roleType: teacher.roleType,
subject: teacher.subject || null,
});
teacherMap.set(teacher.classId, items);
}
return classIds.map((id) => ({
classId: id,
className: nameMap.get(id) || `班级${id}`,
teachers: teacherMap.get(id) ?? [],
}));
}
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.dingRawRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
if (query.matchStatus) {
qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
if (scopedClassIds) {
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
const classStudents = await this.classStudentRepo.find({
where: { classId: In(scopedClassIds), status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const dingUserIds = [
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
];
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
}
qb.orderBy('ar.attendanceDate', 'DESC')
.addOrderBy('ar.checkInTime', 'ASC')
.skip((page - 1) * pageSize)
.take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize };
}
// ── Match a dingtalk record to a student ──
async matchDingRecord(id: number, dto: MatchDingRecordDto) {
const record = await this.dingRawRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`DingAttendanceRaw ${id} not found`);
}
record.matchedStudentId = dto.studentId;
record.matchStatus = 'matched';
return this.dingRawRepo.save(record);
}
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
const unmatched = await this.dingRawRepo.find({
where: { matchStatus: 'unmatched' },
});
if (unmatched.length === 0) return { matched: 0, total: 0 };
// Build dingUserId → studentId map from the mapping table
const mappings = await this.studentDingMappingRepo.find();
const dingToStudentId = new Map<string, number>();
for (const m of mappings) {
dingToStudentId.set(m.dingUserId, m.studentId);
}
let matched = 0;
for (const record of unmatched) {
const studentId = dingToStudentId.get(record.dingUserId);
if (studentId == null) continue;
record.matchedStudentId = studentId;
record.matchStatus = 'matched';
await this.dingRawRepo.save(record);
matched++;
}
return { matched, total: unmatched.length };
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
},
accessibleClassIds?: number[],
) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
if (query.status) {
qb.andWhere('ar.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('ar.source = :source', { source: query.source });
}
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
const records = await qb.getMany();
return this.attachAttendanceDeviceMappings(records);
}
async findAttendanceRecord(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
return record;
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
record.source = 'manual';
}
return this.attendanceRepo.save(record);
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;
freshRecord.source = 'manual';
}
return recordRepo.save(freshRecord);
}),
);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
await this.attendanceRepo.remove(record);
return { deleted: true };
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
await recordRepo.remove(freshRecord);
return { deleted: true };
}),
);
}
// ── Class-based attendance report ──
async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoin('ar.class', 'class')
.select('class.id', 'classId')
.addSelect('class.name', 'className')
.addSelect('ar.status', 'status')
.addSelect('COUNT(*)', 'count');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status');
const rawRows = await qb.getRawMany();
// Aggregate by class
const classMap = new Map<
number,
{
classId: number;
className: string;
present: number;
absent: number;
late: number;
leave: number;
}
>();
for (const row of rawRows) {
if (!row.classId) continue;
if (!classMap.has(row.classId)) {
classMap.set(row.classId, {
classId: row.classId,
className: row.className || `班级#${row.classId}`,
present: 0,
absent: 0,
late: 0,
leave: 0,
});
}
const entry = classMap.get(row.classId)!;
const count = parseInt(row.count, 10);
if (row.status === 'present') entry.present += count;
else if (row.status === 'absent') entry.absent += count;
else if (row.status === 'late') entry.late += count;
else if (row.status === 'leave') entry.leave += count;
}
return Array.from(classMap.values()).map((entry) => {
const total = entry.present + entry.absent + entry.late + entry.leave;
return {
...entry,
total,
presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0',
absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0',
lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0',
leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0',
};
});
}
// ── Attendance alerts: detect consecutive absences/late ──
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
const qb = this.attendanceRepo
.createQueryBuilder('a')
.leftJoinAndSelect('a.student', 'student')
.leftJoinAndSelect('a.class', 'class');
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
'a.status IN (:...statuses)',
{ statuses: ['absent', 'late'] },
);
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
const records = await qb
.orderBy('a.studentId', 'ASC')
.addOrderBy('a.attendanceDate', 'DESC')
.getMany();
const alerts: Array<{
studentId: number;
studentName: string;
className: string;
type: string;
count: number;
lastDate: string;
}> = [];
let current: (typeof alerts)[0] | null = null;
for (const r of records) {
const name = (r.student as any)?.name || '';
const className = (r.class as any)?.name || '';
const status = r.status === 'absent' ? '缺勤' : '迟到';
if (current && current.studentId === r.studentId && current.type === status) {
current.count++;
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
} else {
if (current && current.count >= threshold) alerts.push({ ...current });
current = {
studentId: r.studentId,
studentName: name,
className,
type: status,
count: 1,
lastDate: r.attendanceDate,
};
}
}
if (current && current.count >= threshold) alerts.push(current);
return alerts;
}
}