forked from wangziqi/gongxue-base
570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
import {
|
||
Injectable,
|
||
NotFoundException,
|
||
BadRequestException,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities';
|
||
import {
|
||
BatchCreateAttendanceDto,
|
||
AttendanceSummaryQueryDto,
|
||
AttendanceCalendarQueryDto,
|
||
QueryDingRawDto,
|
||
MatchDingRecordDto,
|
||
AttendanceReportQueryDto,
|
||
UpdateAttendanceRecordDto,
|
||
GenerateAttendanceFromSchedulesDto,
|
||
GenerateFromSchedulesDto,
|
||
} from './dto/attendance.dto';
|
||
|
||
@Injectable()
|
||
export class AttendanceService {
|
||
constructor(
|
||
@InjectRepository(AttendanceRecord)
|
||
private attendanceRepo: Repository<AttendanceRecord>,
|
||
@InjectRepository(DingAttendanceRaw)
|
||
private dingRawRepo: Repository<DingAttendanceRaw>,
|
||
@InjectRepository(Class)
|
||
private classRepo: Repository<Class>,
|
||
@InjectRepository(Student)
|
||
private studentRepo: Repository<Student>,
|
||
@InjectRepository(ClassSchedule)
|
||
private scheduleRepo: Repository<ClassSchedule>,
|
||
@InjectRepository(ClassStudent)
|
||
private classStudentRepo: Repository<ClassStudent>,
|
||
@InjectRepository(UserDingMapping)
|
||
private userDingMappingRepo: Repository<UserDingMapping>,
|
||
) {}
|
||
|
||
// ── Batch create attendance records ──
|
||
async batchCreate(dto: BatchCreateAttendanceDto) {
|
||
if (!dto.records || dto.records.length === 0) {
|
||
throw new BadRequestException('records array must not be empty');
|
||
}
|
||
|
||
// Batch-load student departmentIds
|
||
const ids = [...new Set(dto.records.map((r) => r.studentId))];
|
||
const students = await this.studentRepo.find({ where: { id: In(ids) } });
|
||
const deptMap = new Map(students.map((s) => [s.id, s.departmentId]));
|
||
|
||
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',
|
||
});
|
||
entity.departmentId = deptMap.get(r.studentId)!;
|
||
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: 'active' },
|
||
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 = this.mapScheduleTimeToSession(sched.startTime);
|
||
for (const cs of classStudents) {
|
||
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',
|
||
});
|
||
entity.departmentId = cs.student?.departmentId ?? cls.departmentId ?? undefined;
|
||
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 mapScheduleTimeToSession(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';
|
||
}
|
||
|
||
// ── Attendance summary ──
|
||
async getSummary(query: AttendanceSummaryQueryDto) {
|
||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||
if (query.classId) {
|
||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||
}
|
||
if (query.dateFrom) {
|
||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||
}
|
||
if (query.dateTo) {
|
||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||
}
|
||
|
||
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;
|
||
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
|
||
|
||
return { total, present, late, absent, leave, presentRate };
|
||
}
|
||
|
||
// ── Attendance calendar ──
|
||
async getCalendar(query: AttendanceCalendarQueryDto) {
|
||
const { classId, weekStart } = query;
|
||
|
||
if (!weekStart) {
|
||
// Default to the Monday of the current week
|
||
const now = new Date();
|
||
const day = now.getDay();
|
||
const diff = day === 0 ? -6 : 1 - day; // Monday offset
|
||
const monday = new Date(now);
|
||
monday.setDate(now.getDate() + diff);
|
||
const mondayStr = monday.toISOString().slice(0, 10);
|
||
|
||
return this.buildCalendar(classId, mondayStr);
|
||
}
|
||
|
||
return this.buildCalendar(classId, weekStart);
|
||
}
|
||
|
||
private async buildCalendar(classId: number, weekStart: string) {
|
||
// Compute weekEnd (Sunday = weekStart + 6 days)
|
||
const start = new Date(weekStart);
|
||
const end = new Date(start);
|
||
end.setDate(start.getDate() + 6);
|
||
const endStr = end.toISOString().slice(0, 10);
|
||
|
||
// Fetch attendance records for the week
|
||
const records = await this.attendanceRepo.find({
|
||
where: {
|
||
classId,
|
||
attendanceDate: Between(weekStart, endStr),
|
||
},
|
||
relations: ['student'],
|
||
order: { attendanceDate: 'ASC', session: 'ASC' },
|
||
});
|
||
|
||
// Group by studentId
|
||
const studentMap = new Map<
|
||
number,
|
||
{
|
||
studentId: number;
|
||
studentName: string;
|
||
days: Array<{ date: string; session: string; status: string }>;
|
||
}
|
||
>();
|
||
|
||
for (const r of records) {
|
||
if (!studentMap.has(r.studentId)) {
|
||
studentMap.set(r.studentId, {
|
||
studentId: r.studentId,
|
||
studentName: r.student?.name ?? `Student#${r.studentId}`,
|
||
days: [],
|
||
});
|
||
}
|
||
studentMap.get(r.studentId)!.days.push({
|
||
date: r.attendanceDate,
|
||
session: r.session,
|
||
status: r.status,
|
||
});
|
||
}
|
||
|
||
return Array.from(studentMap.values());
|
||
}
|
||
|
||
// ── List attendance records with filters ──
|
||
async findAll(query: {
|
||
classId?: number;
|
||
dateFrom?: string;
|
||
dateTo?: string;
|
||
session?: string;
|
||
status?: string;
|
||
source?: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
}) {
|
||
const page = query.page || 1;
|
||
const pageSize = query.pageSize || 20;
|
||
|
||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||
|
||
qb.leftJoinAndSelect('ar.student', 'student')
|
||
.leftJoinAndSelect('ar.class', 'class');
|
||
if (query.classId) {
|
||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||
}
|
||
if (query.dateFrom) {
|
||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||
}
|
||
if (query.dateTo) {
|
||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||
}
|
||
if (query.session) {
|
||
qb.andWhere('ar.session = :session', { session: query.session });
|
||
}
|
||
if (query.status) {
|
||
qb.andWhere('ar.status = :status', { status: query.status });
|
||
}
|
||
if (query.source) {
|
||
qb.andWhere('ar.source = :source', { source: query.source });
|
||
}
|
||
|
||
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
||
qb.skip((page - 1) * pageSize).take(pageSize);
|
||
|
||
const [list, total] = await qb.getManyAndCount();
|
||
return { list, total, page, pageSize };
|
||
}
|
||
|
||
// ── Get distinct classes with attendance records ──
|
||
async getClasses() {
|
||
const qb = this.attendanceRepo
|
||
.createQueryBuilder('ar')
|
||
.select('DISTINCT ar.classId', 'classId')
|
||
.where('ar.classId IS NOT NULL');
|
||
|
||
|
||
const rows = await qb
|
||
.orderBy('ar.classId', 'ASC')
|
||
.getRawMany();
|
||
|
||
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
|
||
if (classIds.length === 0) return [];
|
||
|
||
const where = { id: In(classIds) };
|
||
const classes = await this.classRepo.find({ where });
|
||
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
|
||
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` }));
|
||
}
|
||
|
||
// ── DingAttendance raw records ──
|
||
async getDingRaw(query: QueryDingRawDto) {
|
||
const where: any = {};
|
||
if (query.matchStatus) {
|
||
where.matchStatus = query.matchStatus;
|
||
}
|
||
|
||
return this.dingRawRepo.find({
|
||
where,
|
||
relations: ['matchedStudent'],
|
||
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
|
||
});
|
||
}
|
||
|
||
// ── Match a dingtalk record to a student ──
|
||
async matchDingRecord(id: number, dto: MatchDingRecordDto) {
|
||
const record = await this.dingRawRepo.findOne({ where: { id } });
|
||
if (!record) {
|
||
throw new NotFoundException(`DingAttendanceRaw ${id} not found`);
|
||
}
|
||
|
||
record.matchedStudentId = dto.studentId;
|
||
record.matchStatus = '已匹配';
|
||
return this.dingRawRepo.save(record);
|
||
}
|
||
|
||
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
|
||
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
|
||
const unmatched = await this.dingRawRepo.find({
|
||
where: { matchStatus: '未处理' },
|
||
});
|
||
|
||
if (unmatched.length === 0) return { matched: 0, total: 0 };
|
||
|
||
// Build dingUserId → userId map from the mapping table
|
||
const mappings = await this.userDingMappingRepo.find();
|
||
const dingToUserId = new Map<string, number>();
|
||
for (const m of mappings) {
|
||
dingToUserId.set(m.dingUserId, m.userId);
|
||
}
|
||
|
||
// Build userId → studentId map (only students linked to a user)
|
||
const students = await this.studentRepo.find({
|
||
where: { userId: In([...dingToUserId.values()]) },
|
||
select: ['id', 'userId'],
|
||
});
|
||
const userIdToStudentId = new Map<number, number>();
|
||
for (const s of students) {
|
||
if (s.userId != null) userIdToStudentId.set(s.userId, s.id);
|
||
}
|
||
|
||
let matched = 0;
|
||
for (const record of unmatched) {
|
||
const userId = dingToUserId.get(record.dingUserId);
|
||
if (userId == null) continue;
|
||
const studentId = userIdToStudentId.get(userId);
|
||
if (studentId == null) continue;
|
||
|
||
record.matchedStudentId = studentId;
|
||
record.matchStatus = '已匹配';
|
||
await this.dingRawRepo.save(record);
|
||
matched++;
|
||
}
|
||
|
||
return { matched, total: unmatched.length };
|
||
}
|
||
|
||
// ── Export all attendance records with filters (no pagination) ──
|
||
async findAllForExport(query: {
|
||
classId?: number;
|
||
dateFrom?: string;
|
||
dateTo?: string;
|
||
session?: string;
|
||
status?: string;
|
||
source?: string;
|
||
}) {
|
||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||
|
||
qb.leftJoinAndSelect('ar.student', 'student')
|
||
.leftJoinAndSelect('ar.class', 'class');
|
||
if (query.classId) {
|
||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||
}
|
||
if (query.dateFrom) {
|
||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||
}
|
||
if (query.dateTo) {
|
||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||
}
|
||
if (query.session) {
|
||
qb.andWhere('ar.session = :session', { session: query.session });
|
||
}
|
||
if (query.status) {
|
||
qb.andWhere('ar.status = :status', { status: query.status });
|
||
}
|
||
if (query.source) {
|
||
qb.andWhere('ar.source = :source', { source: query.source });
|
||
}
|
||
|
||
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
||
|
||
return qb.getMany();
|
||
}
|
||
|
||
// ── Update a single attendance record ──
|
||
async update(id: number, dto: UpdateAttendanceRecordDto) {
|
||
const record = await this.attendanceRepo.findOne({ where: { id } });
|
||
if (!record) {
|
||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||
}
|
||
|
||
|
||
if (dto.status !== undefined) {
|
||
record.status = dto.status;
|
||
}
|
||
if (dto.remark !== undefined) {
|
||
record.remark = dto.remark;
|
||
}
|
||
|
||
return this.attendanceRepo.save(record);
|
||
}
|
||
|
||
// ── Delete a single attendance record ──
|
||
async remove(id: number) {
|
||
const record = await this.attendanceRepo.findOne({ where: { id } });
|
||
if (!record) {
|
||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||
}
|
||
|
||
|
||
await this.attendanceRepo.remove(record);
|
||
return { deleted: true };
|
||
}
|
||
|
||
// ── Class-based attendance report ──
|
||
async getReport(query: AttendanceReportQueryDto) {
|
||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||
|
||
qb.leftJoin('ar.class', 'class')
|
||
.select('class.id', 'classId')
|
||
.addSelect('class.name', 'className')
|
||
.addSelect('ar.status', 'status')
|
||
.addSelect('COUNT(*)', 'count');
|
||
if (query.classId) {
|
||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||
}
|
||
if (query.dateFrom) {
|
||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||
}
|
||
if (query.dateTo) {
|
||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||
}
|
||
|
||
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status');
|
||
const rawRows = await qb.getRawMany();
|
||
|
||
// Aggregate by class
|
||
const classMap = new Map<number, {
|
||
classId: number;
|
||
className: string;
|
||
present: number;
|
||
absent: number;
|
||
late: number;
|
||
leave: number;
|
||
}>();
|
||
|
||
for (const row of rawRows) {
|
||
if (!row.classId) continue;
|
||
if (!classMap.has(row.classId)) {
|
||
classMap.set(row.classId, {
|
||
classId: row.classId,
|
||
className: row.className || `班级#${row.classId}`,
|
||
present: 0,
|
||
absent: 0,
|
||
late: 0,
|
||
leave: 0,
|
||
});
|
||
}
|
||
const entry = classMap.get(row.classId)!;
|
||
const count = parseInt(row.count, 10);
|
||
if (row.status === 'present') entry.present += count;
|
||
else if (row.status === 'absent') entry.absent += count;
|
||
else if (row.status === 'late') entry.late += count;
|
||
else if (row.status === 'leave') entry.leave += count;
|
||
}
|
||
|
||
return Array.from(classMap.values()).map((entry) => {
|
||
const total = entry.present + entry.absent + entry.late + entry.leave;
|
||
return {
|
||
...entry,
|
||
total,
|
||
presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0',
|
||
absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0',
|
||
lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0',
|
||
leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0',
|
||
};
|
||
});
|
||
}
|
||
// ── Attendance alerts: detect consecutive absences/late ──
|
||
async getAlerts(days: number = 14, threshold: number = 3) {
|
||
const cutoff = new Date();
|
||
cutoff.setDate(cutoff.getDate() - days);
|
||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||
|
||
const qb = this.attendanceRepo
|
||
.createQueryBuilder('a')
|
||
.leftJoinAndSelect('a.student', 'student')
|
||
.leftJoinAndSelect('a.class', 'class');
|
||
|
||
|
||
const records = await qb
|
||
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
|
||
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
|
||
.orderBy('a.studentId', 'ASC')
|
||
.addOrderBy('a.attendanceDate', 'DESC')
|
||
.getMany();
|
||
|
||
const alerts: Array<{
|
||
studentId: number; studentName: string; className: string;
|
||
type: string; count: number; lastDate: string;
|
||
}> = [];
|
||
|
||
let current: typeof alerts[0] | null = null;
|
||
for (const r of records) {
|
||
const name = (r.student as any)?.name || '';
|
||
const className = (r.class as any)?.name || '';
|
||
const status = r.status === 'absent' ? '缺勤' : '迟到';
|
||
if (current && current.studentId === r.studentId && current.type === status) {
|
||
current.count++;
|
||
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
|
||
} else {
|
||
if (current && current.count >= threshold) alerts.push({ ...current });
|
||
current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate };
|
||
}
|
||
}
|
||
if (current && current.count >= threshold) alerts.push(current);
|
||
return alerts;
|
||
}
|
||
}
|