320 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|