import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { AttendanceRecord, Class, ClassSchedule, ClassStudent, AttendanceSession, AttendancePeriodConfig, ScheduleType, } from '../entities'; import { toMinutes, isClassStudentActiveOnDate } from './attendance-time'; import dayjs from '../common/dayjs'; import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto'; @Injectable() export class AttendanceGenerationService { 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; constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, @InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository, private dataSource: DataSource, ) {} 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 = dayjs(d).utc().format('YYYY-MM-DD'); 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) => 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), China calendar const now = dayjs().utcOffset(8); const day = now.day(); const monday = now.subtract(day === 0 ? 6 : day - 1, 'day').startOf('day'); const sunday = monday.add(6, 'day').endOf('day'); const dateFrom = startDate ?? monday.format('YYYY-MM-DD'); const dateTo = endDate ?? sunday.format('YYYY-MM-DD'); 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 c = dayjs.utc(date).add(8, 'hour'); return { date: c.format('YYYY-MM-DD'), minutes: c.hour() * 60 + c.minute(), }; } private shiftDate(date: string, days: number): string { return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD'); } 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(); 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 (toMinutes(period.endTime) <= 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) => toMinutes(left.startTime) - 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 && toMinutes(current.startTime) < 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 { const startMinutes = toMinutes(startTime); const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled); const matched = periods.find((period) => { const periodStart = toMinutes(period.startTime); const periodEnd = toMinutes(period.endTime); return startMinutes >= periodStart && startMinutes < periodEnd; }); if (matched) return matched.periodKey; throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`); } }