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
716 lines
25 KiB
TypeScript
716 lines
25 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,
|
||
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();
|
||
});
|
||
}
|
||
}
|