forked from wangziqi/gongxue-base
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Sse,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
Res,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
import { Observable } from 'rxjs';
|
|
import type { Request as ExpressRequest, Response } from 'express';
|
|
import { AttendanceService } from './attendance.service';
|
|
import { AttendanceImportService } from './attendance-import.service';
|
|
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
|
|
import {
|
|
BatchCreateAttendanceDto,
|
|
AttendanceSummaryQueryDto,
|
|
AttendanceCalendarQueryDto,
|
|
QueryAttendanceRecordsDto,
|
|
QueryDingRawDto,
|
|
MatchDingRecordDto,
|
|
AttendanceReportQueryDto,
|
|
UpdateAttendanceRecordDto,
|
|
GenerateFromSchedulesDto,
|
|
} from './dto/attendance.dto';
|
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|
import { extractRequestInfo } from '../common/request-utils';
|
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
|
import * as ExcelJS from 'exceljs';
|
|
|
|
/** SSE event shape for @Sse() decorator */
|
|
interface SseEvent {
|
|
data: string | Record<string, unknown>;
|
|
id?: string;
|
|
type?: string;
|
|
retry?: number;
|
|
}
|
|
/** Minimal request user shape for type safety */
|
|
interface RequestUser {
|
|
id: number;
|
|
username: string;
|
|
permissions?: string[];
|
|
isSuperAdmin?: boolean;
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller()
|
|
export class AttendanceController {
|
|
constructor(
|
|
private readonly service: AttendanceService,
|
|
private readonly importService: AttendanceImportService,
|
|
private readonly logService: OperationLogsService,
|
|
) {}
|
|
|
|
private getTodayDateOnly(): string {
|
|
const today = new Date();
|
|
const year = today.getFullYear();
|
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
const day = String(today.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
private canManageAllAttendance(user: RequestUser): boolean {
|
|
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
|
|
}
|
|
|
|
private getAccessibleClassIds(user: RequestUser) {
|
|
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
|
|
}
|
|
|
|
private assertClassAccess(user: RequestUser, classId: number) {
|
|
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
|
|
}
|
|
|
|
// ── Batch create attendance records ──
|
|
@Post('attendance-records/batch')
|
|
@RequirePermission('attendance:create')
|
|
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.batchCreate(dto);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '考勤管理',
|
|
action: '批量录入考勤',
|
|
detail: `共 ${result.count} 条`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ── Generate attendance records from schedules (with optional date range) ──
|
|
@Post('attendance-records/generate-from-schedules')
|
|
@RequirePermission('attendance:create')
|
|
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.generateFromSchedules(dto);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '考勤管理',
|
|
action: '按课表生成考勤',
|
|
detail: `班级 ${dto.classId}, 共 ${result.count} 条`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ── Export attendance records ──
|
|
@Get('attendance-records/export')
|
|
@RequirePermission('attendance:export')
|
|
async exportRecords(
|
|
@Query() query: QueryAttendanceRecordsDto,
|
|
@Res() res: Response,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
|
const classIds = await this.getAccessibleClassIds(req.user);
|
|
const records = await this.service.findAllForExport(query, classIds);
|
|
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('考勤统计报表');
|
|
ws.columns = [
|
|
{ header: '姓名', key: 'studentName', width: 15 },
|
|
{ header: '班级', key: 'className', width: 20 },
|
|
{ header: '日期', key: 'attendanceDate', width: 15 },
|
|
{ header: '时段', key: 'session', width: 15 },
|
|
{ header: '状态', key: 'status', width: 10 },
|
|
{ header: '来源', key: 'source', width: 10 },
|
|
{ header: '备注', key: 'remark', width: 30 },
|
|
{ header: '打卡时间', key: 'createdAt', width: 20 },
|
|
];
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
|
|
for (const record of records) {
|
|
ws.addRow({
|
|
studentName: record.student?.name || '',
|
|
className: record.class?.name || '',
|
|
attendanceDate: record.attendanceDate || '',
|
|
session: record.session || '',
|
|
status: record.status || '',
|
|
source: record.source || '',
|
|
remark: record.remark || '',
|
|
createdAt: record.createdAt
|
|
? record.createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
|
: '',
|
|
});
|
|
}
|
|
|
|
const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
|
|
|
|
res.setHeader(
|
|
'Content-Type',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
);
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,
|
|
);
|
|
await workbook.xlsx.write(res);
|
|
res.end();
|
|
}
|
|
|
|
// ── List attendance records with filters ──
|
|
@Get('attendance-records')
|
|
@RequirePermission('attendance:view')
|
|
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
|
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
|
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
|
|
}
|
|
|
|
// ── Update a single attendance record ──
|
|
@Put('attendance-records/:id')
|
|
@RequirePermission('attendance:edit')
|
|
async update(
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateAttendanceRecordDto,
|
|
@Request() req: any,
|
|
) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.update(+id, dto);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '考勤管理',
|
|
action: '编辑考勤记录',
|
|
targetId: +id,
|
|
targetType: 'attendanceRecord',
|
|
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ── Delete a single attendance record ──
|
|
@Delete('attendance-records/:id')
|
|
@RequirePermission('attendance:edit')
|
|
async remove(@Param('id') id: string, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.remove(+id);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '考勤管理',
|
|
action: '删除考勤记录',
|
|
targetId: +id,
|
|
targetType: 'attendanceRecord',
|
|
detail: `删除考勤记录 ${id}`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ── Get distinct classes with attendance records ──
|
|
@Get('attendance-records/classes')
|
|
@RequirePermission('attendance:view')
|
|
async getClasses(@Request() req: { user: RequestUser }) {
|
|
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
|
|
}
|
|
|
|
// ── Attendance summary ──
|
|
@Get('attendance-records/summary')
|
|
@RequirePermission('attendance:view')
|
|
async getSummary(
|
|
@Query() query: AttendanceSummaryQueryDto,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
|
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
|
|
}
|
|
|
|
// ── Attendance calendar ──
|
|
@Get('attendance-records/calendar')
|
|
@RequirePermission('attendance:view')
|
|
async getCalendar(
|
|
@Query() query: AttendanceCalendarQueryDto,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
await this.assertClassAccess(req.user, query.classId);
|
|
return this.service.getCalendar(query);
|
|
}
|
|
|
|
// ── DingAttendance raw records ──
|
|
@Get('ding-attendance-raw')
|
|
@RequirePermission('attendance:view')
|
|
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
|
|
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
|
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
|
|
}
|
|
|
|
// ── Match a dingtalk record to a student ──
|
|
@Post('ding-attendance-raw/:id/match')
|
|
@RequirePermission('attendance:edit')
|
|
async matchDingRecord(
|
|
@Param('id') id: string,
|
|
@Body() dto: MatchDingRecordDto,
|
|
@Request() req: any,
|
|
) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.matchDingRecord(+id, dto);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '考勤管理',
|
|
action: '匹配考勤记录',
|
|
targetId: +id,
|
|
targetType: 'dingAttendanceRaw',
|
|
detail: `匹配到学生 ${dto.studentId}`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
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,
|
|
) {
|
|
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
|
const reportData = await this.service.getReport(
|
|
query,
|
|
await this.getAccessibleClassIds(req.user),
|
|
);
|
|
|
|
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')
|
|
async getAlerts(
|
|
@Request() req: { user: RequestUser },
|
|
@Query('days') days?: string,
|
|
@Query('threshold') threshold?: string,
|
|
) {
|
|
return this.service.getAlerts(
|
|
days ? +days : 14,
|
|
threshold ? +threshold : 3,
|
|
await this.getAccessibleClassIds(req.user),
|
|
);
|
|
}
|
|
|
|
@Post('ding-attendance-raw/auto-match')
|
|
@RequirePermission('attendance:edit')
|
|
async autoMatch() {
|
|
return this.service.autoMatchDingRecords();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// DingTalk attendance import with SSE streaming progress
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
@Get('attendance-records/import/dingtalk/classes')
|
|
@RequirePermission('attendance:create')
|
|
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
|
|
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
|
|
}
|
|
|
|
/**
|
|
* Trigger DingTalk attendance import.
|
|
* Mirrors `dws attendance check result` pipeline:
|
|
* fetch → parse → deduplicate → save → auto-match.
|
|
*/
|
|
@Post('attendance-records/import/dingtalk')
|
|
@RequirePermission('attendance:create')
|
|
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const canManageAll = this.canManageAllAttendance(req.user);
|
|
let userIds: string[];
|
|
|
|
if (dto.users) {
|
|
if (!canManageAll) {
|
|
throw new ForbiddenException('仅管理员可指定钉钉用户范围');
|
|
}
|
|
userIds = dto.users
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
} else {
|
|
if (!dto.classId) {
|
|
throw new BadRequestException('请选择要拉取考勤的班级');
|
|
}
|
|
userIds = await this.service.getTeacherClassDingUserIds(
|
|
req.user.id,
|
|
dto.classId,
|
|
canManageAll,
|
|
);
|
|
}
|
|
|
|
const startDate = dto.start ?? this.getTodayDateOnly();
|
|
const endDate = dto.end ?? startDate;
|
|
|
|
const result = await this.importService.importFromDingTalk({
|
|
startDate,
|
|
endDate,
|
|
userIds,
|
|
autoMatch: true,
|
|
});
|
|
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '考勤管理',
|
|
action: '钉钉考勤导入',
|
|
detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* SSE stream for live import progress.
|
|
* Connect before triggering the import to receive real-time progress events.
|
|
*
|
|
* NOTE: @RequirePermission works with @Sse() in NestJS because guards
|
|
* execute in the standard request pipeline before the SSE handler is invoked.
|
|
* If this ever breaks after a NestJS upgrade, verify guard execution order.
|
|
*/
|
|
@Sse('attendance-records/import/dingtalk/stream')
|
|
@RequirePermission('attendance:view')
|
|
importProgressStream(): Observable<SseEvent> {
|
|
return new Observable<SseEvent>((subscriber) => {
|
|
const subscription = this.importService.progress$.subscribe({
|
|
next: (event) => {
|
|
subscriber.next({ data: JSON.stringify(event) });
|
|
if (event.phase === 'complete' || event.phase === 'error') {
|
|
subscriber.complete();
|
|
}
|
|
},
|
|
error: (err: unknown) => subscriber.error(err),
|
|
});
|
|
return () => subscription.unsubscribe();
|
|
});
|
|
}
|
|
}
|