feat: integrate CampusScope.filter() into all business services (12 services + 12 modules)

This commit is contained in:
2026-07-05 23:58:51 +08:00
parent eeae06fe30
commit cd6364268d
40 changed files with 949 additions and 172 deletions

View File

@@ -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();
}
}

View File

@@ -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],

View File

@@ -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;
}
}

View File

@@ -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;
}