Files
gongxue-base/apps/server/src/attendance/attendance-query.service.ts

320 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
Class,
ClassSchedule,
ClassStudent,
StudentDingMapping,
ClassTeacher,
AttendanceDevice,
TeacherRoleType,
} from '../entities';
import type {
AttendanceSummaryQueryDto,
QueryDingRawDto,
} from './dto/attendance.dto';
import { attachAttendanceDeviceMappings } from './attendance-device';
import { AttendanceCalendarService } from './attendance-calendar.service';
import { AttendanceRecordMutationService } from './attendance-record-mutation.service';
import { AttendanceReportService } from './attendance-report.service';
@Injectable()
export class AttendanceQueryService {
constructor(
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository<AttendanceDevice>,
private dataSource: DataSource,
) {}
private calendarService?: AttendanceCalendarService;
private mutationService?: AttendanceRecordMutationService;
private reportService?: AttendanceReportService;
private get calendar(): AttendanceCalendarService {
if (!this.calendarService) {
this.calendarService = new AttendanceCalendarService(this.attendanceRepo, this.scheduleRepo);
}
return this.calendarService;
}
private get mutations(): AttendanceRecordMutationService {
if (!this.mutationService) {
this.mutationService = new AttendanceRecordMutationService(
this.attendanceRepo,
this.dingRawRepo,
this.studentDingMappingRepo,
this.dataSource,
);
}
return this.mutationService;
}
private get reports(): AttendanceReportService {
if (!this.reportService) {
this.reportService = new AttendanceReportService(
this.attendanceRepo,
this.attendanceDeviceRepo,
);
}
return this.reportService;
}
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (!query.classId && accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
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 });
}
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 pending = rows.filter((r) => r.status === 'pending').length;
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
return { total, present, late, absent, leave, pending, presentRate };
}
// ── Attendance calendar ──
async getCalendar(...args: Parameters<AttendanceCalendarService['getCalendar']>) {
return this.calendar.getCalendar(...args);
}
async getScheduleOptionsForAttendance(
...args: Parameters<AttendanceCalendarService['getScheduleOptionsForAttendance']>
) {
return this.calendar.getScheduleOptionsForAttendance(...args);
}
// ── List attendance records with filters ──
async findAll(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
},
accessibleClassIds?: number[],
) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
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: await attachAttendanceDeviceMappings(list, this.attendanceDeviceRepo), total, page, pageSize };
}
// ── Get distinct classes with attendance records ──
async getClasses(accessibleClassIds?: number[]) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const rows: Array<{ classId: string | number }> = accessibleClassIds
? accessibleClassIds.map((classId) => ({ classId }))
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
if (classIds.length === 0) return [];
const where = { id: In(classIds) };
const [classes, teachers] = await Promise.all([
this.classRepo.find({ where }),
this.classTeacherRepo.find({
where: {
classId: In(classIds),
roleType: In([
TeacherRoleType.HEAD_TEACHER,
TeacherRoleType.LIFE_TEACHER,
TeacherRoleType.SUBJECT_TEACHER,
]),
},
relations: ['user'],
order: { roleType: 'ASC', id: 'ASC' },
}),
]);
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
const teacherMap = new Map<
number,
Array<{
userId: number;
username: string | null;
name: string | null;
roleType: string;
subject: string | null;
}>
>();
for (const teacher of teachers) {
const user = teacher.user as { username?: string | null; name?: string | null } | undefined;
const items = teacherMap.get(teacher.classId) ?? [];
items.push({
userId: teacher.userId,
username: user?.username || null,
name: user?.name || null,
roleType: teacher.roleType,
subject: teacher.subject || null,
});
teacherMap.set(teacher.classId, items);
}
return classIds.map((id) => ({
classId: id,
className: nameMap.get(id) || `班级${id}`,
teachers: teacherMap.get(id) ?? [],
}));
}
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
const qb = this.dingRawRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
if (query.matchStatus) {
qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
if (scopedClassIds) {
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
const classStudents = await this.classStudentRepo.find({
where: { classId: In(scopedClassIds), status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const dingUserIds = [
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
];
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
}
qb.orderBy('ar.attendanceDate', 'DESC')
.addOrderBy('ar.checkInTime', 'ASC')
.skip((page - 1) * pageSize)
.take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize };
}
// ── Match a dingtalk record to a student ──
async matchDingRecord(...args: Parameters<AttendanceRecordMutationService['matchDingRecord']>) {
return this.mutations.matchDingRecord(...args);
}
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
return this.mutations.autoMatchDingRecords();
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(
...args: Parameters<AttendanceReportService['findAllForExport']>
) {
return this.reports.findAllForExport(...args);
}
async findAttendanceRecord(
...args: Parameters<AttendanceRecordMutationService['findAttendanceRecord']>
) {
return this.mutations.findAttendanceRecord(...args);
}
// ── Update a single attendance record ──
async update(...args: Parameters<AttendanceRecordMutationService['update']>) {
return this.mutations.update(...args);
}
// ── Delete a single attendance record ──
async remove(...args: Parameters<AttendanceRecordMutationService['remove']>) {
return this.mutations.remove(...args);
}
// ── Class-based attendance report ──
async getReport(...args: Parameters<AttendanceReportService['getReport']>) {
return this.reports.getReport(...args);
}
// ── Attendance alerts: detect consecutive absences/late ──
async getAlerts(...args: Parameters<AttendanceReportService['getAlerts']>) {
return this.reports.getAlerts(...args);
}
}