forked from wangziqi/gongxue-base
1559 lines
58 KiB
TypeScript
1559 lines
58 KiB
TypeScript
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';
|
||
|
||
interface AgentAttendanceSummaryRow {
|
||
date: string;
|
||
classId: string | number;
|
||
className: string;
|
||
status: string;
|
||
count: string | number;
|
||
}
|
||
|
||
/** 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('只能访问自己任教班级的考勤');
|
||
}
|
||
|
||
async agentGetAttendanceSummary(
|
||
userId: number,
|
||
canManageAll: boolean,
|
||
query: { classId?: number; dateFrom?: string; dateTo?: string; limit?: number },
|
||
) {
|
||
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
|
||
if (accessibleClassIds?.length === 0) return [];
|
||
if (query.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) return [];
|
||
|
||
const qb = this.attendanceRepo
|
||
.createQueryBuilder('attendance')
|
||
.leftJoin('attendance.class', 'class')
|
||
.select('attendance.attendanceDate', 'date')
|
||
.addSelect('attendance.classId', 'classId')
|
||
.addSelect('class.name', 'className')
|
||
.addSelect('attendance.status', 'status')
|
||
.addSelect('COUNT(attendance.id)', 'count')
|
||
.where('attendance.classId IS NOT NULL');
|
||
if (query.classId) qb.andWhere('attendance.classId = :classId', { classId: query.classId });
|
||
else if (accessibleClassIds) qb.andWhere('attendance.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||
if (query.dateFrom) qb.andWhere('attendance.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||
if (query.dateTo) qb.andWhere('attendance.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||
const rows = await qb
|
||
.groupBy('attendance.attendanceDate')
|
||
.addGroupBy('attendance.classId')
|
||
.addGroupBy('class.name')
|
||
.addGroupBy('attendance.status')
|
||
.orderBy('attendance.attendanceDate', 'DESC')
|
||
.addOrderBy('class.name', 'ASC')
|
||
.limit(query.limit ?? 30)
|
||
.getRawMany<AgentAttendanceSummaryRow>();
|
||
return rows.map((row) => ({ ...row, classId: Number(row.classId), count: Number(row.count || 0) }));
|
||
}
|
||
|
||
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 !== 'in_progress' &&
|
||
existing.status !== 'completed' &&
|
||
!(finalize && existing.status === 'settling')
|
||
) {
|
||
throw new BadRequestException('课程考勤正在结算');
|
||
}
|
||
|
||
// Refresh latest DingTalk data even after automatic settlement; late-arriving punches
|
||
// may legitimately change a DingTalk-generated absence to present.
|
||
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 effectiveFinalize = finalize || existing.status === 'completed';
|
||
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, effectiveFinalize);
|
||
Object.assign(record, this.getLessonPunchMetadata(
|
||
raw,
|
||
lessonDate,
|
||
schedule.startTime,
|
||
));
|
||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||
? null
|
||
: effectiveFinalize
|
||
? '课程截止仍未打卡'
|
||
: '未获取到钉钉打卡结果';
|
||
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, effectiveFinalize),
|
||
source: 'dingtalk',
|
||
...this.getLessonPunchMetadata(
|
||
raw,
|
||
lessonDate,
|
||
schedule.startTime,
|
||
),
|
||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||
? undefined
|
||
: effectiveFinalize
|
||
? '课程截止仍未打卡'
|
||
: '未获取到钉钉打卡结果',
|
||
}),
|
||
);
|
||
}
|
||
|
||
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 (Monday–Sunday)
|
||
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;
|
||
}
|
||
}
|