import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository, In } from 'typeorm'; import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities'; import { Classroom } from '../entities/classroom.entity'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; import dayjs from '../common/dayjs'; interface AgentClassRow { id: string | number; name: string; code: string; studentCount: string | number; } @Injectable() export class ClassesQueriesService { constructor( @InjectRepository(Class) private readonly classRepo: Repository, @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository, private readonly dataSource: DataSource, ) {} async agentSearchClasses( accessibleClassIds: number[] | undefined, query: { keyword?: string; status?: string; limit?: number }, ) { if (accessibleClassIds?.length === 0) return []; const qb = this.classRepo .createQueryBuilder('class') .leftJoin( ClassStudent, 'classStudent', 'classStudent.classId = class.id AND classStudent.status = :activeStudent', { activeStudent: 'active' }, ) .select('class.id', 'id'); const classSelects = [ ['class.name', 'name'], ['class.code', 'code'], ['class.classType', 'classType'], ['class.status', 'status'], ['class.startDate', 'startDate'], ['class.endDate', 'endDate'], ['COUNT(classStudent.id)', 'studentCount'], ] as const; for (const [column, alias] of classSelects) { qb.addSelect(column, alias); } qb.where('class.isArchived = :isArchived', { isArchived: false }); if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); if (query.status) qb.andWhere('class.status = :status', { status: query.status }); const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); } async batchImportStudents( classId: number, users: Array<{ dingUserId: string; name: string; mobile?: string }>, ): Promise<{ imported: number; skipped: number; conflicts: number }> { if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; return this.dataSource.transaction(async (manager) => { const classEntity = await manager.findOne(Class, { where: { id: classId } }); if (!classEntity) throw new NotFoundException('班级不存在'); const synced = await syncDingTalkStudents(manager, users); const studentIds = [...new Set(synced.studentIds.values())]; if (studentIds.length === 0) { return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; } const existingClassStudents = await manager.find(ClassStudent, { where: { classId, studentId: In(studentIds) }, }); const existingByStudentId = new Map( existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), ); const today = dayjs().utc().format('YYYY-MM-DD'); let skipped = 0; const memberships = studentIds.flatMap((studentId) => { const existing = existingByStudentId.get(studentId); if (existing?.status === 'active') { skipped++; return []; } if (existing) { existing.status = 'active'; existing.joinDate = today; existing.leaveDate = null; return [existing]; } return [ manager.create(ClassStudent, { classId, studentId, status: 'active', joinDate: today, }), ]; }); if (memberships.length > 0) await manager.save(ClassStudent, memberships); return { imported: memberships.length, skipped, conflicts: synced.conflicts.length, }; }); } async getSchedule(classId: number, query: QueryClassScheduleDto) { const qb = this.scheduleRepo .createQueryBuilder('cs') .leftJoinAndSelect('cs.classroom', 'classroom') .where('cs.classId = :classId', { classId }); if (query.startDate) { qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); } if (query.endDate) { qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); } const schedules = await qb .orderBy('cs.weekDay', 'ASC') .addOrderBy('cs.startTime', 'ASC') .getMany(); return schedules.map((s) => ({ ...s, classroomName: (s.classroom as Classroom | undefined)?.name || null, })); } async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { const qb = this.attendanceRepo .createQueryBuilder('ar') .where('ar.classId = :classId', { classId }); if (query.startDate) { qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate }); } if (query.endDate) { qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate }); } 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; return { total, present, late, absent, leave, presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0, absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0, lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0, leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0, }; } }