forked from wangziqi/gongxue-base
- 替换散落的 toISOString().slice(0,10) / split('T')[0] / replace('T',' ')(UTC 语义用 dayjs(...).utc() 保持完全一致)
- Asia/Shanghai 固定时区日期(Intl.DateTimeFormat en-CA)改用 dayjs().utcOffset(8)
- 月份边界 first/last、daysInMonth、nextMonth、shiftDate/addDays 等改 dayjs 简洁实现
- 涉及 attendance/classes/classrooms/room/dashboard/bills/expenses/occupancies/sync/ai-chat/rental 等 28 个文件
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { AttendanceRecord, AttendanceDevice } from '../entities';
|
|
import type { AttendanceReportQueryDto } from './dto/attendance.dto';
|
|
import { attachAttendanceDeviceMappings } from './attendance-device';
|
|
import dayjs from '../common/dayjs';
|
|
|
|
@Injectable()
|
|
export class AttendanceReportService {
|
|
constructor(
|
|
@InjectRepository(AttendanceRecord)
|
|
private attendanceRepo: Repository<AttendanceRecord>,
|
|
@InjectRepository(AttendanceDevice)
|
|
private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
|
) {}
|
|
|
|
// ── Export all attendance records with filters (no pagination) ──
|
|
async findAllForExport(
|
|
query: {
|
|
classId?: number;
|
|
scheduleId?: number;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
session?: string;
|
|
status?: string;
|
|
source?: string;
|
|
},
|
|
accessibleClassIds?: number[],
|
|
) {
|
|
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 [];
|
|
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');
|
|
|
|
const records = await qb.getMany();
|
|
return attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo);
|
|
}
|
|
|
|
// ── Class-based attendance report ──
|
|
async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
|
|
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
|
|
|
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 });
|
|
} else if (accessibleClassIds) {
|
|
if (accessibleClassIds.length === 0) return [];
|
|
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 });
|
|
}
|
|
|
|
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 as Array<{
|
|
classId?: number;
|
|
className?: string | null;
|
|
status?: string;
|
|
count: string;
|
|
}>) {
|
|
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, accessibleClassIds?: number[]) {
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() - days);
|
|
const cutoffStr = dayjs(cutoff).utc().format('YYYY-MM-DD');
|
|
|
|
const qb = this.attendanceRepo
|
|
.createQueryBuilder('a')
|
|
.leftJoinAndSelect('a.student', 'student')
|
|
.leftJoinAndSelect('a.class', 'class');
|
|
|
|
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
|
|
'a.status IN (:...statuses)',
|
|
{ statuses: ['absent', 'late'] },
|
|
);
|
|
if (accessibleClassIds) {
|
|
if (accessibleClassIds.length === 0) return [];
|
|
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
|
}
|
|
const records = await qb
|
|
.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?.name || '';
|
|
const className = r.class?.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;
|
|
}
|
|
}
|