Files
gongxue-base/apps/server/src/attendance/attendance.service.ts

1172 lines
42 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,
DingAttendanceRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
ClassTeacher,
ScheduleType,
StudentDingMapping,
} from '../entities';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
UpdateAttendanceRecordDto,
GenerateAttendanceFromSchedulesDto,
GenerateFromSchedulesDto,
} 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>,
private dataSource: DataSource,
) {}
private sessionMutex = new SessionMutex();
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('只能访问自己任教班级的考勤');
}
/** 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,
): 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 = 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 };
}
private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
endTime: string,
): DingAttendanceRaw[] {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
const windowStart = start - 3 * 60 * 60 * 1000;
const windowEnd = end + 3 * 60 * 60 * 1000;
const timed = records.filter((record) => {
const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
});
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
}
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 today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute;
const currentMinute = now.getHours() * 60 + now.getMinutes();
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 };
}
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!, lessonDate);
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
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) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
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) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
updatedRecords.push(
recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: existing.id,
attendanceDate: lessonDate,
session: this.mapScheduleTimeToSession(schedule.startTime),
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: saved };
});
}
// 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!, lessonDate);
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['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: existingRecords };
}
}
throw err;
}
const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: this.mapScheduleTimeToSession(schedule.startTime),
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: saved };
});
}
private async fetchDingTalkRawByStudent(
classId: number,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: lessonDate,
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 };
}
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 };
}),
);
}
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: 'active' },
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 = this.mapScheduleTimeToSession(sched.startTime);
for (const cs of classStudents) {
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 mapScheduleTimeToSession(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';
}
// ── 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 });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 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 });
}
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 presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
return { total, present, late, absent, leave, 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 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, 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 = await this.classRepo.find({ where });
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${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;
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.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');
return qb.getMany();
}
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;
}
}