forked from wangziqi/gongxue-base
feat: integrate CampusScope.filter() into all business services (12 services + 12 modules)
This commit is contained in:
@@ -5,13 +5,15 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Between } from 'typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class } from '../entities';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class, Student } from '../entities';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import {
|
||||
BatchCreateAttendanceDto,
|
||||
AttendanceSummaryQueryDto,
|
||||
AttendanceCalendarQueryDto,
|
||||
QueryDingRawDto,
|
||||
MatchDingRecordDto,
|
||||
AttendanceReportQueryDto,
|
||||
} from './dto/attendance.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -23,6 +25,9 @@ export class AttendanceService {
|
||||
private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(Class)
|
||||
private classRepo: Repository<Class>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@@ -50,7 +55,10 @@ export class AttendanceService {
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
@@ -151,11 +159,14 @@ export class AttendanceService {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('ar')
|
||||
.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
@@ -184,17 +195,25 @@ export class AttendanceService {
|
||||
|
||||
// ── Get distinct classes with attendance records ──
|
||||
async getClasses() {
|
||||
const rows = await this.attendanceRepo
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('ar')
|
||||
.select('DISTINCT ar.classId', 'classId')
|
||||
.where('ar.classId IS NOT NULL')
|
||||
.where('ar.classId IS NOT NULL');
|
||||
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
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 classes = await this.classRepo.find({ where: { id: In(classIds) } });
|
||||
const where = await this.scope.filter({ 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}` }));
|
||||
}
|
||||
@@ -207,7 +226,7 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
return this.dingRawRepo.find({
|
||||
where,
|
||||
where: await this.scope.filter(where),
|
||||
relations: ['matchedStudent'],
|
||||
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
|
||||
});
|
||||
@@ -225,6 +244,28 @@ export class AttendanceService {
|
||||
return this.dingRawRepo.save(record);
|
||||
}
|
||||
|
||||
// ── Auto-match unmatched dingtalk records by phone/idCard/name ──
|
||||
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
|
||||
const unmatched = await this.dingRawRepo.find({
|
||||
where: { matchStatus: '未处理' },
|
||||
});
|
||||
|
||||
if (unmatched.length === 0) return { matched: 0, total: 0 };
|
||||
|
||||
let matched = 0;
|
||||
for (const record of unmatched) {
|
||||
const student = await this.studentRepo.findOne({ where: { name: record.userName } });
|
||||
if (student) {
|
||||
record.matchStatus = '已匹配';
|
||||
record.matchedStudentId = student.id;
|
||||
await this.dingRawRepo.save(record);
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, total: unmatched.length };
|
||||
}
|
||||
|
||||
// ── Export all attendance records with filters (no pagination) ──
|
||||
async findAllForExport(query: {
|
||||
classId?: number;
|
||||
@@ -234,11 +275,14 @@ export class AttendanceService {
|
||||
status?: string;
|
||||
source?: string;
|
||||
}) {
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('ar')
|
||||
.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
@@ -262,4 +306,117 @@ export class AttendanceService {
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Class-based attendance report ──
|
||||
async getReport(query: AttendanceReportQueryDto) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
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 scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user