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

570 lines
19 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 } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
UpdateAttendanceRecordDto,
GenerateAttendanceFromSchedulesDto,
GenerateFromSchedulesDto,
} from './dto/attendance.dto';
@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(UserDingMapping)
private userDingMappingRepo: Repository<UserDingMapping>,
) {}
// ── Batch create attendance records ──
async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) {
throw new BadRequestException('records array must not be empty');
}
// Batch-load student departmentIds
const ids = [...new Set(dto.records.map((r) => r.studentId))];
const students = await this.studentRepo.find({ where: { id: In(ids) } });
const deptMap = new Map(students.map((s) => [s.id, s.departmentId]));
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',
});
entity.departmentId = deptMap.get(r.studentId)!;
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',
});
entity.departmentId = cs.student?.departmentId ?? cls.departmentId ?? undefined;
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) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
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;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: 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.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
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() {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const rows = await qb
.orderBy('ar.classId', 'ASC')
.getRawMany();
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
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) {
const where: any = {};
if (query.matchStatus) {
where.matchStatus = query.matchStatus;
}
return this.dingRawRepo.find({
where,
relations: ['matchedStudent'],
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
});
}
// ── 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 = '已匹配';
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: '未处理' },
});
if (unmatched.length === 0) return { matched: 0, total: 0 };
// Build dingUserId → userId map from the mapping table
const mappings = await this.userDingMappingRepo.find();
const dingToUserId = new Map<string, number>();
for (const m of mappings) {
dingToUserId.set(m.dingUserId, m.userId);
}
// Build userId → studentId map (only students linked to a user)
const students = await this.studentRepo.find({
where: { userId: In([...dingToUserId.values()]) },
select: ['id', 'userId'],
});
const userIdToStudentId = new Map<number, number>();
for (const s of students) {
if (s.userId != null) userIdToStudentId.set(s.userId, s.id);
}
let matched = 0;
for (const record of unmatched) {
const userId = dingToUserId.get(record.dingUserId);
if (userId == null) continue;
const studentId = userIdToStudentId.get(userId);
if (studentId == null) continue;
record.matchedStudentId = studentId;
record.matchStatus = '已匹配';
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;
}) {
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 });
}
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();
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
if (dto.status !== undefined) {
record.status = dto.status;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
}
return this.attendanceRepo.save(record);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
await this.attendanceRepo.remove(record);
return { deleted: true };
}
// ── Class-based attendance report ──
async getReport(query: AttendanceReportQueryDto) {
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 });
}
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) {
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');
const records = await qb
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
.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;
}
}