forked from wangziqi/gongxue-base
575 lines
20 KiB
TypeScript
575 lines
20 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Sse,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
Res,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
ParseIntPipe,
|
|
} from '@nestjs/common';
|
|
import { Observable, filter } 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,
|
|
AttendanceAlertsQueryDto,
|
|
UpdateAttendanceRecordDto,
|
|
GenerateFromSchedulesDto,
|
|
LessonAttendanceQueryDto,
|
|
StartLessonAttendanceDto,
|
|
} 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';
|
|
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
|
import type { AuthenticatedUser } from '../authorization';
|
|
|
|
/** 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;
|
|
roles: string[];
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller()
|
|
export class AttendanceController {
|
|
constructor(
|
|
private readonly service: AttendanceService,
|
|
private readonly importService: AttendanceImportService,
|
|
private readonly logService: OperationLogsService,
|
|
private readonly authz: AuthorizationService,
|
|
) {}
|
|
|
|
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(req: { user: RequestUser }): boolean {
|
|
return (
|
|
this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||
|
|
// Legacy: class:edit grants broad attendance access for teacher scoping
|
|
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
|
);
|
|
}
|
|
|
|
private getAccessibleClassIds(req: { user: RequestUser }) {
|
|
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
|
|
}
|
|
|
|
private assertClassAccess(req: { user: RequestUser }, classId: number) {
|
|
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
|
}
|
|
|
|
@Get('attendance-lessons/schedules/:scheduleId')
|
|
@RequirePermission('attendance:view')
|
|
async getLessonAttendance(
|
|
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
|
@Query() query: LessonAttendanceQueryDto,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
const result = await this.service.getLessonAttendance(scheduleId, query.date);
|
|
await this.assertClassAccess(req, result.schedule.classId!);
|
|
return result;
|
|
}
|
|
|
|
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
|
@RequirePermission('attendance:create')
|
|
async pullLessonAttendance(
|
|
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
|
@Body() dto: StartLessonAttendanceDto,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
|
|
await this.assertClassAccess(req, schedule.schedule.classId!);
|
|
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
|
req.user.id,
|
|
schedule.schedule.classId!,
|
|
this.canManageAllAttendance(req),
|
|
);
|
|
const importRange = this.service.getLessonAttendanceImportDateRange(
|
|
schedule.schedule,
|
|
dto.date,
|
|
);
|
|
const importResult = await this.importService.importFromDingTalk({
|
|
...importRange,
|
|
userIds: importClassIds,
|
|
autoMatch: true,
|
|
userId: req.user.id,
|
|
});
|
|
const result = await this.service.createLessonAttendanceFromDingTalk(
|
|
scheduleId,
|
|
dto.date,
|
|
req.user.id,
|
|
);
|
|
await this.logService.log({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
module: '考勤管理',
|
|
action: schedule.session ? '查看已拉取课程考勤' : '拉取钉钉课程考勤',
|
|
targetId: result.session.id,
|
|
targetType: 'attendanceSession',
|
|
detail: `排课${scheduleId} 日期${dto.date},钉钉新增${importResult.imported}条,匹配${importResult.matched}条`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('attendance-lessons/:sessionId/complete')
|
|
@RequirePermission('attendance:create')
|
|
async completeLessonAttendance(
|
|
@Param('sessionId', ParseIntPipe) sessionId: number,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
const session = await this.service.findAttendanceSession(sessionId);
|
|
await this.assertClassAccess(req, session.classId);
|
|
const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
|
|
await this.logService.log({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
module: '考勤管理',
|
|
action: '完成课程点名',
|
|
targetId: sessionId,
|
|
targetType: 'attendanceSession',
|
|
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ── 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 canManageAll = this.canManageAllAttendance(req);
|
|
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
|
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
|
|
}
|
|
const classIds = [
|
|
...new Set(
|
|
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
|
|
),
|
|
];
|
|
for (const classId of classIds) {
|
|
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
|
}
|
|
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);
|
|
await this.assertClassAccess(req, dto.classId);
|
|
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, query.classId);
|
|
const classIds = await this.getAccessibleClassIds(req);
|
|
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, query.classId);
|
|
return this.service.findAll(query, await this.getAccessibleClassIds(req));
|
|
}
|
|
|
|
// ── Update a single attendance record ──
|
|
@Put('attendance-records/:id')
|
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
|
async update(
|
|
@Param('id', ParseIntPipe) id: number,
|
|
@Body() dto: UpdateAttendanceRecordDto,
|
|
@Request() req: any,
|
|
) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const existing = await this.service.findAttendanceRecord(id);
|
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
|
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
|
}
|
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
|
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', 'attendance:self-edit')
|
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const existing = await this.service.findAttendanceRecord(id);
|
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
|
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
|
}
|
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
|
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));
|
|
}
|
|
|
|
// ── 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, query.classId);
|
|
return this.service.getSummary(query, await this.getAccessibleClassIds(req));
|
|
}
|
|
|
|
// ── Attendance calendar ──
|
|
@Get('attendance-records/calendar')
|
|
@RequirePermission('attendance:view')
|
|
async getCalendar(
|
|
@Query() query: AttendanceCalendarQueryDto,
|
|
@Request() req: { user: RequestUser },
|
|
) {
|
|
await this.assertClassAccess(req, 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, query.classId);
|
|
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req));
|
|
}
|
|
|
|
// ── Match a dingtalk record to a student ──
|
|
@Post('ding-attendance-raw/:id/match')
|
|
@RequirePermission('attendance:edit')
|
|
async matchDingRecord(
|
|
@Param('id', ParseIntPipe) id: number,
|
|
@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, query.classId);
|
|
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
|
|
|
|
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() query: AttendanceAlertsQueryDto,
|
|
) {
|
|
return this.service.getAlerts(
|
|
query.days ?? 14,
|
|
query.threshold ?? 3,
|
|
await this.getAccessibleClassIds(req),
|
|
);
|
|
}
|
|
|
|
@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));
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
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,
|
|
userId: req.user.id,
|
|
});
|
|
|
|
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(@Request() req: { user: RequestUser }): Observable<SseEvent> {
|
|
const userId = req.user.id;
|
|
return new Observable<SseEvent>((subscriber) => {
|
|
const subscription = this.importService.progress$
|
|
.pipe(
|
|
filter((event) => event.userId === userId),
|
|
)
|
|
.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();
|
|
});
|
|
}
|
|
}
|