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