feat: integrate CampusScope.filter() into all business services (12 services + 12 modules)
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
QueryAttendanceRecordsDto,
|
||||
QueryDingRawDto,
|
||||
MatchDingRecordDto,
|
||||
AttendanceReportQueryDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -56,7 +57,7 @@ export class AttendanceController {
|
||||
|
||||
// ── Export attendance records ──
|
||||
@Get('attendance-records/export')
|
||||
@RequirePermission('attendance:view')
|
||||
@RequirePermission('attendance:export')
|
||||
async exportRecords(
|
||||
@Query() query: QueryAttendanceRecordsDto,
|
||||
@Res() res: Response,
|
||||
@@ -167,4 +168,83 @@ export class AttendanceController {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attendance class-based report export ──
|
||||
@Get('attendance-records/report')
|
||||
@RequirePermission('attendance:export')
|
||||
async exportReport(
|
||||
@Query() query: AttendanceReportQueryDto,
|
||||
@Res() res: Response,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const reportData = await this.service.getReport(query);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
ws.columns = [
|
||||
{ header: '班级名称', key: 'className', width: 30 },
|
||||
{ header: '总记录数', key: 'total', width: 12 },
|
||||
{ header: '出勤', key: 'present', width: 10 },
|
||||
{ header: '出勤率', key: 'presentRate', width: 10 },
|
||||
{ header: '缺勤', key: 'absent', width: 10 },
|
||||
{ header: '缺勤率', key: 'absentRate', width: 10 },
|
||||
{ header: '迟到', key: 'late', width: 10 },
|
||||
{ header: '迟到率', key: 'lateRate', width: 10 },
|
||||
{ header: '请假', key: 'leave', width: 10 },
|
||||
{ header: '请假率', key: 'leaveRate', width: 10 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
for (const row of reportData) {
|
||||
ws.addRow({
|
||||
className: row.className,
|
||||
total: row.total,
|
||||
present: row.present,
|
||||
presentRate: `${row.presentRate}%`,
|
||||
absent: row.absent,
|
||||
absentRate: `${row.absentRate}%`,
|
||||
late: row.late,
|
||||
lateRate: `${row.lateRate}%`,
|
||||
leave: row.leave,
|
||||
leaveRate: `${row.leaveRate}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '导出考勤报表',
|
||||
detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
}
|
||||
|
||||
// ── Abnormal attendance alerts ──
|
||||
@Get('attendance-records/alerts')
|
||||
@RequirePermission('attendance:view')
|
||||
getAlerts(
|
||||
@Query('days') days?: string,
|
||||
@Query('threshold') threshold?: string,
|
||||
) {
|
||||
return this.service.getAlerts(
|
||||
days ? +days : 14,
|
||||
threshold ? +threshold : 3,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('ding-attendance-raw/auto-match')
|
||||
@RequirePermission('attendance:edit')
|
||||
async autoMatch() {
|
||||
return this.service.autoMatchDingRecords();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { AttendanceRecord, DingAttendanceRaw, Student, Class } from '../entities
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
import { DepartmentsModule } from '../departments/departments.module';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class]),
|
||||
OperationLogsModule,
|
||||
DepartmentsModule,
|
||||
],
|
||||
controllers: [AttendanceController],
|
||||
providers: [AttendanceService],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,3 +125,18 @@ export class MatchDingRecordDto {
|
||||
@IsNotEmpty()
|
||||
studentId: number;
|
||||
}
|
||||
|
||||
export class AttendanceReportQueryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user