Files
gongxue-base/apps/server/src/attendance/attendance.controller.ts
wangziqi 375c7ec60b feat: refine admin forms, attendance and finance workflows
Squash merge PR #23.

Included changes:
- complete occupancy check-in required fields/default payload
- improve responsive admin management pages
- fix attendance edge cases and attendance period config
- refine wallet/finance-related workflow handling

Checks:
- npm run typecheck -w apps/admin
- npm run typecheck -w apps/server
2026-07-18 12:54:10 +00:00

716 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
AttendanceScheduleOptionsQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
AttendanceAlertsQueryDto,
UpdateAttendanceRecordDto,
GenerateFromSchedulesDto,
LessonAttendanceQueryDto,
StartLessonAttendanceDto,
SaveAttendancePeriodConfigsDto,
RefreshDingTalkAttendanceDto,
} 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-period-configs')
@RequirePermission('attendance:view')
getAttendancePeriodConfigs() {
return this.service.getAttendancePeriodConfigs();
}
@Put('attendance-period-configs')
@RequirePermission('attendance:edit')
async saveAttendancePeriodConfigs(
@Body() dto: SaveAttendancePeriodConfigsDto,
@Request() req: { user: RequestUser },
) {
const result = await this.service.saveAttendancePeriodConfigs(dto);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考勤管理',
action: '保存考勤时段配置',
targetType: 'attendancePeriodConfig',
detail: dto.periods.map((item) => `${item.label}:${item.startTime}-${item.endTime}`).join(''),
});
return result;
}
@Post('attendance-period-configs/reset')
@RequirePermission('attendance:edit')
async resetAttendancePeriodConfigs(@Request() req: { user: RequestUser }) {
const result = await this.service.resetAttendancePeriodConfigs();
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考勤管理',
action: '重置考勤时段配置',
targetType: 'attendancePeriodConfig',
});
return result;
}
@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),
dto.date,
);
const importRange = this.service.getLessonAttendanceImportDateRange(
schedule.schedule,
dto.date,
);
const importResult = await this.importService.importFromDingTalk({
...importRange,
userIds: importClassIds,
autoMatch: true,
userId: req.user.id,
});
if (!importResult.success || importResult.errors.length > 0) {
throw new BadRequestException(importResult.errors.join('; ') || '钉钉考勤拉取失败');
}
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;
}
@Get('attendance-records/dingtalk-sync-status')
@RequirePermission('attendance:view')
async getDingTalkSyncStatus() {
const latest = await this.logService.findLatestDingTalkAttendancePull();
return {
lastPulledAt: latest?.createdAt ?? null,
action: latest?.action ?? null,
username: latest?.username ?? null,
detail: latest?.detail ?? null,
};
}
@Post('attendance-records/refresh-dingtalk')
@RequirePermission('attendance:create')
async refreshDingTalkAttendance(
@Body() dto: RefreshDingTalkAttendanceDto,
@Request() req: { user: RequestUser },
) {
if (dto.date > this.getTodayDateOnly()) {
throw new BadRequestException('不能查看或刷新未来日期的考勤');
}
if (dto.classId) await this.assertClassAccess(req, dto.classId);
const schedules = await this.service.getRefreshableSchedules(
dto.date,
dto.classId,
dto.session,
await this.getAccessibleClassIds(req),
);
let refreshed = 0;
let imported = 0;
let matched = 0;
const errors: string[] = [];
for (const schedule of schedules) {
try {
const importClassIds = await this.service.getTeacherClassDingUserIds(
req.user.id,
schedule.classId!,
this.canManageAllAttendance(req),
dto.date,
);
const importRange = this.service.getLessonAttendanceImportDateRange(schedule, dto.date);
const importResult = await this.importService.importFromDingTalk({
...importRange,
userIds: importClassIds,
autoMatch: true,
userId: req.user.id,
});
if (!importResult.success || importResult.errors.length > 0) {
errors.push(...importResult.errors);
continue;
}
await this.service.createLessonAttendanceFromDingTalk(schedule.id, dto.date, req.user.id);
refreshed += 1;
imported += importResult.imported;
matched += importResult.matched;
} catch (error: unknown) {
errors.push((error as { message?: string })?.message || `排课 ${schedule.id} 刷新失败`);
}
}
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考勤管理',
action: '刷新钉钉考勤',
targetType: 'attendanceRecord',
detail: `日期${dto.date},排课${schedules.length}节,刷新${refreshed}节,钉钉新增${imported}条,匹配${matched}${errors.length ? `,错误${errors.length}` : ''}`,
status: errors.length > 0 && refreshed === 0 ? 'failure' : 'success',
});
if (schedules.length === 0) {
return { refreshed, imported, matched, errors: ['当前条件下没有可刷新的课程'] };
}
return { refreshed, imported, matched, errors };
}
// ── 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: 'punchDevice', width: 30 },
{ header: '打卡时间', key: 'punchTime', width: 20 },
{ 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 || '',
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
punchTime: record.punchTime
? record.punchTime.toISOString().replace('T', ' ').substring(0, 19)
: '',
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();
}
@Get('attendance-records/schedules')
@RequirePermission('attendance:view')
async getAttendanceScheduleOptions(
@Query() query: AttendanceScheduleOptionsQueryDto,
@Request() req: { user: RequestUser },
) {
await this.assertClassAccess(req, query.classId);
return this.service.getScheduleOptionsForAttendance(query.classId, query.date);
}
// ── 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,
dto.start,
);
}
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();
});
}
}