generateFromSchedules / getCalendar 原来用本地周一零点转 UTC 日期, 在上海时区会得到前一天的日期,导致默认范围变成周日~周日(8 天)。 改为 dayjs().utcOffset(8) 按中国日期计算,与 getTodayDateOnly 语义一致。 新增测试锁定:默认周 = 本周一~本周日,周日晚上仍归本周,显式传参原样透传。
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
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<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 = 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<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} 未匹配到考勤时段,请先配置考勤时段`);
|
||
}
|
||
|
||
}
|