import { Injectable, NotFoundException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Between } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw } from '../entities'; import { BatchCreateAttendanceDto, AttendanceSummaryQueryDto, AttendanceCalendarQueryDto, QueryDingRawDto, MatchDingRecordDto, } from './dto/attendance.dto'; @Injectable() export class AttendanceService { constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, ) {} // ── 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) => 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', }), ); const saved = await this.attendanceRepo.save(entities); return { count: saved.length, records: saved }; } // ── 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') .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 rows = await this.attendanceRepo .createQueryBuilder('ar') .select('DISTINCT ar.classId', 'classId') .where('ar.classId IS NOT NULL') .orderBy('ar.classId', 'ASC') .getRawMany(); const classIds = rows.map((r) => r.classId).filter(Boolean) as number[]; if (classIds.length === 0) return []; const classes = await this.classRepo.find({ where: { id: In(classIds) } }); 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); } // ── 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') .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(); } }