import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like, Not, In, FindOptionsWhere } from 'typeorm'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Organization } from '../entities/organization.entity'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; @Injectable() export class StudentsService { constructor( @InjectRepository(Student) private repo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } async findAll( query?: { name?: string; status?: string; includeArchived?: boolean; organizationId?: number | string; }, accessibleClassIds?: number[], ) { const where: FindOptionsWhere = {}; if (query?.name) where.name = Like(`%${query.name}%`); if (query?.organizationId) where.organizationId = Number(query.organizationId); if (query?.status) { where.status = query.status; } else if (!query?.includeArchived) { where.status = Not(In(['archived', 'staff'])); } if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; const classStudents = await this.classStudentRepo.find({ where: { classId: In(accessibleClassIds), status: 'active' }, }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) return []; where.id = In(studentIds); } return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] }); } async findOne(id: number) { const student = await this.repo.findOne({ where: { id }, relations: ['occupancies', 'occupancies.room'], }); if (!student) throw new NotFoundException('学生不存在'); return student; } async create(dto: CreateStudentDto) { await this.assertActiveOrganization(dto.organizationId); return this.repo.save(this.repo.create(dto)); } async update(id: number, dto: UpdateStudentDto) { await this.findOne(id); if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId); await this.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } async remove(id: number) { const student = await this.findOne(id); if (student.status === 'archived') { throw new BadRequestException('该学生已归档'); } await this.repo.update(id, { status: 'archived' }); return { message: '已归档(数据已保留,可随时恢复)' }; } async batchRemove(ids: number[]) { if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生'); const students = await this.repo.find({ where: { id: In(ids) } }); const skipped: string[] = []; const targetIds: number[] = []; for (const s of students) { if (s.status === 'archived') skipped.push(s.name); else targetIds.push(s.id); } let affected = 0; if (targetIds.length > 0) { const result = await this.repo .createQueryBuilder() .update() .set({ status: 'archived' }) .where('id IN (:...ids)', { ids: targetIds }) .execute(); affected = result.affected || 0; } const message = skipped.length > 0 ? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` : `已批量归档 ${affected} 人(数据已保留,可随时恢复)`; return { message, archived: affected, skipped: skipped.length }; } async restore(id: number) { const student = await this.findOne(id); if (student.status !== 'archived') { throw new BadRequestException('该学生未被归档'); } await this.repo.update(id, { status: 'active' }); return { message: '已恢复' }; } async batchImport( rows: { name: string; phone?: string; idNumber?: string; gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; organization?: string; supervisor?: string; organizationId?: number; }[], ) { let imported = 0; let skipped = 0; for (const row of rows) { if (!row.name || !row.name.trim()) { skipped++; continue; } const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); if (exists) { skipped++; continue; } await this.repo.save( this.repo.create({ name: row.name.trim(), phone: row.phone?.trim() || undefined, idNumber: row.idNumber?.trim() || undefined, gender: row.gender || undefined, ethnicity: row.ethnicity || undefined, emergencyContact: row.emergencyContact || undefined, emergencyPhone: row.emergencyPhone || undefined, supervisor: row.supervisor || undefined, organizationId: row.organizationId || (await this.getHostOrganizationId()), }), ); imported++; } return { message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`, imported, skipped, }; } async matchImport( rows: { name: string; phone?: string; idNumber?: string; gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; organization?: string; supervisor?: string; organizationId?: number; }[], ) { let matched = 0; let skipped = 0; for (const row of rows) { if (!row.name || !row.name.trim()) { skipped++; continue; } // Match by phone first, then idNumber let student = row.phone?.trim() ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) : null; if (!student && row.idNumber?.trim()) { student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); } if (!student) { skipped++; continue; } // Update matched student with non-empty imported fields const updates: Partial< Pick< Student, | 'name' | 'phone' | 'idNumber' | 'gender' | 'ethnicity' | 'emergencyContact' | 'emergencyPhone' | 'supervisor' | 'organizationId' > > = {}; if (row.name?.trim()) updates.name = row.name.trim(); if (row.phone?.trim()) updates.phone = row.phone.trim(); if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); if (row.gender) updates.gender = row.gender; if (row.ethnicity) updates.ethnicity = row.ethnicity; if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; if (row.supervisor) updates.supervisor = row.supervisor; if (row.organizationId) updates.organizationId = row.organizationId; await this.repo.update(student.id, updates as Partial); matched++; } return { message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`, matched, skipped, }; } private async assertActiveOrganization(id: number) { const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } }); if (!organization) throw new BadRequestException('所属机构不存在或已归档'); } private async getHostOrganizationId() { const organization = await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' }, }); if (!organization) throw new BadRequestException('尚未配置本机构'); return organization.id; } async compareClasses(studentId: number) { const student = await this.repo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); const enrollments = await this.classStudentRepo.find({ where: { studentId }, relations: ['class'], }); if (enrollments.length === 0) { return { student, enrollments: [] }; } const classIds = enrollments.map((e) => e.classId); const attendanceRecords = await this.attendanceRepo.find({ where: { studentId, classId: In(classIds) }, }); const attendanceByClass = new Map(); for (const r of attendanceRecords) { const list = attendanceByClass.get(r.classId) || []; list.push(r); attendanceByClass.set(r.classId, list); } const comparison = enrollments.map((e) => { const records = attendanceByClass.get(e.classId) || []; const total = records.length; const present = records.filter((r) => r.status === 'present' || r.status === '正常').length; const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length; const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length; const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length; return { classId: e.classId, className: e.class?.name || '', classType: e.class?.classType || '', startDate: e.class?.startDate || '', endDate: e.class?.endDate || '', joinDate: e.joinDate, leaveDate: e.leaveDate, status: e.status, attendanceStats: { total, present, absent, late, leave, rate: total > 0 ? Math.round((present / total) * 100) : 0, }, }; }); return { student, enrollments: comparison }; } }