feat: 考勤模块重构与钉钉考勤同步
This commit is contained in:
299
apps/server/src/attendance/attendance-generation.service.ts
Normal file
299
apps/server/src/attendance/attendance-generation.service.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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 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<AttendanceRecord>,
|
||||
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
@InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
|
||||
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 = 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 = 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)
|
||||
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 toMinutes(time: string): number {
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
private getCourseClock(date: Date): { date: string; minutes: number } {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
|
||||
);
|
||||
return {
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
minutes: Number(parts.hour) * 60 + Number(parts.minute),
|
||||
};
|
||||
}
|
||||
|
||||
private shiftDate(date: string, days: number): string {
|
||||
const shifted = new Date(`${date}T00:00:00.000Z`);
|
||||
shifted.setUTCDate(shifted.getUTCDate() + days);
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<string> {
|
||||
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} 未匹配到考勤时段,请先配置考勤时段`);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user