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

348 lines
12 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm';
import {
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
AttendancePeriodConfig,
DingAttendanceRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
ClassTeacher,
StudentDingMapping,
} from '../entities';
import { AttendanceQueryService } from './attendance-query.service';
import { AttendanceLessonService } from './attendance-lesson.service';
import { AttendanceGenerationService } from './attendance-generation.service';
import { SessionMutex } from './attendance-mutex';
interface AgentAttendanceSummaryRow {
date: string;
classId: string | number;
className: string;
status: string;
count: string | number;
}
/** Keyed mutex serializing operations on the same attendance session. */
@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(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendanceDevice)
private attendanceDeviceRepo: Repository<AttendanceDevice>,
@InjectRepository(AttendancePeriodConfig)
private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
private dataSource: DataSource,
) {}
private sessionMutex = new SessionMutex();
private queryService?: AttendanceQueryService;
private lessonService?: AttendanceLessonService;
private generationService?: AttendanceGenerationService;
private get lessons(): AttendanceLessonService {
if (!this.lessonService) {
this.lessonService = new AttendanceLessonService(
this.attendanceRepo,
this.dingRawRepo,
this.classRepo,
this.studentRepo,
this.scheduleRepo,
this.classStudentRepo,
this.studentDingMappingRepo,
this.classTeacherRepo,
this.attendanceSessionRepo,
this.attendanceDeviceRepo,
this.dataSource,
);
}
return this.lessonService;
}
private get generation(): AttendanceGenerationService {
if (!this.generationService) {
this.generationService = new AttendanceGenerationService(
this.attendanceRepo,
this.scheduleRepo,
this.classRepo,
this.classStudentRepo,
this.attendanceSessionRepo,
this.attendancePeriodConfigRepo,
this.dataSource,
);
}
return this.generationService;
}
private get queries(): AttendanceQueryService {
if (!this.queryService) {
this.queryService = new AttendanceQueryService(
this.attendanceRepo,
this.dingRawRepo,
this.classRepo,
this.scheduleRepo,
this.classStudentRepo,
this.studentDingMappingRepo,
this.classTeacherRepo,
this.attendanceDeviceRepo,
this.dataSource,
);
}
return this.queryService;
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
}
async agentGetAttendanceSummary(
userId: number,
canManageAll: boolean,
query: { classId?: number; dateFrom?: string; dateTo?: string; limit?: number },
) {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
if (accessibleClassIds?.length === 0) return [];
if (query.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) return [];
const qb = this.attendanceRepo
.createQueryBuilder('attendance')
.leftJoin('attendance.class', 'class')
.select('attendance.attendanceDate', 'date')
.addSelect('attendance.classId', 'classId')
.addSelect('class.name', 'className')
.addSelect('attendance.status', 'status')
.addSelect('COUNT(attendance.id)', 'count')
.where('attendance.classId IS NOT NULL');
if (query.classId) qb.andWhere('attendance.classId = :classId', { classId: query.classId });
else if (accessibleClassIds) qb.andWhere('attendance.classId IN (:...accessibleClassIds)', { accessibleClassIds });
if (query.dateFrom) qb.andWhere('attendance.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
if (query.dateTo) qb.andWhere('attendance.attendanceDate <= :dateTo', { dateTo: query.dateTo });
const rows = await qb
.groupBy('attendance.attendanceDate')
.addGroupBy('attendance.classId')
.addGroupBy('class.name')
.addGroupBy('attendance.status')
.orderBy('attendance.attendanceDate', 'DESC')
.addOrderBy('class.name', 'ASC')
.limit(query.limit ?? 30)
.getRawMany<AgentAttendanceSummaryRow>();
return rows.map((row) => ({ ...row, classId: Number(row.classId), count: Number(row.count || 0) }));
}
async getImportableClasses(userId: number, isSuperAdmin = false) {
if (isSuperAdmin) {
const classes = await this.classRepo.find({
where: { isArchived: false },
order: { name: 'ASC' },
});
return classes.map((item) => ({ classId: item.id, className: item.name }));
}
const assignments = await this.classTeacherRepo.find({
where: { userId },
relations: ['class'],
});
const classes = new Map<number, string>();
for (const assignment of assignments) {
if (assignment.class && !assignment.class.isArchived) {
classes.set(assignment.classId, assignment.class.name);
}
}
return [...classes.entries()]
.map(([classId, className]) => ({ classId, className }))
.sort((left, right) => left.className.localeCompare(right.className, 'zh-CN'));
}
/** Resolve the DingTalk users a teacher may import for one assigned class. */
async getTeacherClassDingUserIds(
userId: number,
classId: number,
isSuperAdmin = false,
lessonDate?: string,
): Promise<string[]> {
if (!isSuperAdmin) {
const assignment = await this.classTeacherRepo.findOne({
where: { userId, classId },
});
if (!assignment) {
throw new BadRequestException('只能拉取自己任教班级的考勤记录');
}
} else {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
}
const classStudents = lessonDate
? await this.lessons.getClassStudentsForLesson(classId, lessonDate)
: await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) {
throw new BadRequestException('该班级暂无在读学生');
}
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
if (userIds.length === 0) {
throw new BadRequestException('该班级学生尚未同步钉钉账号');
}
return userIds.sort();
}
async getSummary(...args: Parameters<AttendanceQueryService['getSummary']>) {
return this.queries.getSummary(...args);
}
async getCalendar(...args: Parameters<AttendanceQueryService['getCalendar']>) {
return this.queries.getCalendar(...args);
}
async getScheduleOptionsForAttendance(
...args: Parameters<AttendanceQueryService['getScheduleOptionsForAttendance']>
) {
return this.queries.getScheduleOptionsForAttendance(...args);
}
async findAll(...args: Parameters<AttendanceQueryService['findAll']>) {
return this.queries.findAll(...args);
}
async getClasses(...args: Parameters<AttendanceQueryService['getClasses']>) {
return this.queries.getClasses(...args);
}
async getDingRaw(...args: Parameters<AttendanceQueryService['getDingRaw']>) {
return this.queries.getDingRaw(...args);
}
async matchDingRecord(...args: Parameters<AttendanceQueryService['matchDingRecord']>) {
return this.queries.matchDingRecord(...args);
}
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
return this.queries.autoMatchDingRecords();
}
async findAllForExport(...args: Parameters<AttendanceQueryService['findAllForExport']>) {
return this.queries.findAllForExport(...args);
}
async findAttendanceRecord(...args: Parameters<AttendanceQueryService['findAttendanceRecord']>) {
return this.queries.findAttendanceRecord(...args);
}
async update(...args: Parameters<AttendanceQueryService['update']>) {
return this.queries.update(...args);
}
async remove(...args: Parameters<AttendanceQueryService['remove']>) {
return this.queries.remove(...args);
}
async getReport(...args: Parameters<AttendanceQueryService['getReport']>) {
return this.queries.getReport(...args);
}
async getAlerts(...args: Parameters<AttendanceQueryService['getAlerts']>) {
return this.queries.getAlerts(...args);
}
async getLessonAttendance(...args: Parameters<AttendanceLessonService['getLessonAttendance']>) {
return this.lessons.getLessonAttendance(...args);
}
getLessonAttendanceImportDateRange(
...args: Parameters<AttendanceLessonService['getLessonAttendanceImportDateRange']>
) {
return this.lessons.getLessonAttendanceImportDateRange(...args);
}
async createLessonAttendanceFromDingTalk(
...args: Parameters<AttendanceLessonService['createLessonAttendanceFromDingTalk']>
) {
return this.lessons.createLessonAttendanceFromDingTalk(...args);
}
async completeLessonAttendance(
...args: Parameters<AttendanceLessonService['completeLessonAttendance']>
) {
return this.lessons.completeLessonAttendance(...args);
}
async findAttendanceSession(
...args: Parameters<AttendanceLessonService['findAttendanceSession']>
) {
return this.lessons.findAttendanceSession(...args);
}
async getRefreshableSchedules(
...args: Parameters<AttendanceGenerationService['getRefreshableSchedules']>
) {
return this.generation.getRefreshableSchedules(...args);
}
async batchCreate(...args: Parameters<AttendanceGenerationService['batchCreate']>) {
return this.generation.batchCreate(...args);
}
async generateFromSchedules(
...args: Parameters<AttendanceGenerationService['generateFromSchedules']>
) {
return this.generation.generateFromSchedules(...args);
}
async getAttendancePeriodConfigs(
...args: Parameters<AttendanceGenerationService['getAttendancePeriodConfigs']>
) {
return this.generation.getAttendancePeriodConfigs(...args);
}
async saveAttendancePeriodConfigs(
...args: Parameters<AttendanceGenerationService['saveAttendancePeriodConfigs']>
) {
return this.generation.saveAttendancePeriodConfigs(...args);
}
async resetAttendancePeriodConfigs(
...args: Parameters<AttendanceGenerationService['resetAttendancePeriodConfigs']>
) {
return this.generation.resetAttendancePeriodConfigs(...args);
}
}