feat: 考勤模块重构与钉钉考勤同步

This commit is contained in:
2026-08-05 17:11:23 +08:00
parent c622e40a12
commit e9c8a1085d
36 changed files with 5122 additions and 3458 deletions

View File

@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { AttendanceRecord, ClassSchedule } from '../entities';
import type { AttendanceCalendarQueryDto } from './dto/attendance.dto';
@Injectable()
export class AttendanceCalendarService {
constructor(
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassSchedule)
private scheduleRepo: Repository<ClassSchedule>,
) {}
async getCalendar(query: AttendanceCalendarQueryDto) {
const { classId, weekStart } = query;
if (!weekStart) {
// Default to the Monday of the current week
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? -6 : 1 - day; // Monday offset
const monday = new Date(now);
monday.setDate(now.getDate() + diff);
const mondayStr = monday.toISOString().slice(0, 10);
return this.buildCalendar(classId, mondayStr);
}
return this.buildCalendar(classId, weekStart);
}
private getWeekDayForDate(date: string): number {
const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
return day === 0 ? 7 : day;
}
async getScheduleOptionsForAttendance(classId: number, date: string) {
const weekDay = this.getWeekDayForDate(date);
const { entities, raw } = await this.scheduleRepo
.createQueryBuilder('cs')
.leftJoin('cs.teacher', 'teacher')
.addSelect('cs.id', 'scheduleIdForTeacherMap')
.addSelect('teacher.username', 'teacherUsername')
.addSelect('teacher.name', 'teacherName')
.where('cs.classId = :classId', { classId })
.andWhere('cs.weekDay = :weekDay', { weekDay })
.andWhere('cs.startDate <= :date', { date })
.andWhere('cs.endDate >= :date', { date })
.andWhere('cs.status = :status', { status: 'active' })
.orderBy('cs.startTime', 'ASC')
.addOrderBy('cs.subject', 'ASC')
.getRawAndEntities();
const teacherByScheduleId = new Map(
raw.map((row: { scheduleIdForTeacherMap: string; teacherName: string | null; teacherUsername: string | null }) => [
Number(row.scheduleIdForTeacherMap),
{
teacherName: row.teacherName || null,
teacherUsername: row.teacherUsername || null,
},
]),
);
return entities.map((schedule) => {
const teacher = teacherByScheduleId.get(schedule.id) ?? {
teacherName: null,
teacherUsername: null,
};
return { ...schedule, ...teacher };
});
}
private async buildCalendar(classId: number, weekStart: string) {
// Compute weekEnd (Sunday = weekStart + 6 days)
const start = new Date(weekStart);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const endStr = end.toISOString().slice(0, 10);
const records = await this.attendanceRepo.find({
where: {
classId,
attendanceDate: Between(weekStart, endStr),
},
relations: ['student'],
order: { attendanceDate: 'ASC', session: 'ASC' },
});
// Group by studentId
const studentMap = new Map<
number,
{
studentId: number;
studentName: string;
days: Array<{ date: string; session: string; status: string }>;
}
>();
for (const r of records) {
if (!studentMap.has(r.studentId)) {
studentMap.set(r.studentId, {
studentId: r.studentId,
studentName: r.student?.name ?? `Student#${r.studentId}`,
days: [],
});
}
studentMap.get(r.studentId)!.days.push({
date: r.attendanceDate,
session: r.session,
status: r.status,
});
}
return Array.from(studentMap.values());
}
}

View File

@@ -0,0 +1,72 @@
import { In, Repository } from 'typeorm';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import type { AttendanceRecord } from '../entities/attendance-record.entity';
function formatDeviceDetail(device: AttendanceDevice): string {
const classroomName = device.classroom?.name;
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
}
/**
* 为考勤记录补充打卡设备名称:
* 优先按设备序列号匹配,其次按教室绑定的设备兜底。
*/
export async function attachAttendanceDeviceMappings<T extends AttendanceRecord>(
records: T[],
attendanceDeviceRepo: Repository<AttendanceDevice>,
classroomId?: number | null,
): Promise<T[]> {
if (records.length === 0) return records;
const sns = [
...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[]),
];
const devicesBySn = new Map<string, AttendanceDevice>();
if (sns.length > 0) {
const devices = await attendanceDeviceRepo.find({
where: { deviceSn: In(sns) },
relations: ['classroom'],
});
for (const device of devices) devicesBySn.set(device.deviceSn, device);
}
const classroomIds = [
...new Set([
...records.map((record) => record.classId).filter((id): id is number => id != null),
...(classroomId != null ? [classroomId] : []),
]),
];
const devicesByClassroom = new Map<number, AttendanceDevice>();
if (classroomIds.length > 0) {
const devices = await attendanceDeviceRepo.find({
where: { classroomId: In(classroomIds), status: 'active' },
relations: ['classroom'],
order: { id: 'ASC' },
});
for (const device of devices) {
if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
}
}
for (const record of records) {
const sn = record.punchDeviceId?.trim();
const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
if (mappedBySn) {
record.punchDeviceName = formatDeviceDetail(mappedBySn);
record.punchDeviceId = mappedBySn.deviceSn;
continue;
}
const source = (record.punchSource || '').trim().toUpperCase();
const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
(value) => source === value || source.includes(value),
);
const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
const mappedByClassroom = fallbackClassroomId
? devicesByClassroom.get(fallbackClassroomId)
: undefined;
if (isMachine && mappedByClassroom && !record.punchDeviceName) {
record.punchDeviceName = formatDeviceDetail(mappedByClassroom);
record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
}
}
return records;
}

View File

@@ -0,0 +1,101 @@
import { AttendanceRecord, DingAttendanceRaw, ClassSchedule } from '../entities';
import { toMinutes, shiftDate } from './attendance-time';
export type LessonScheduleLike = Pick<
ClassSchedule,
'startTime' | 'endTime' | 'attendanceAdvanceMinutes'
>;
export function getLessonAttendanceWindow(
schedule: LessonScheduleLike,
lessonDate: string,
): { start: number; end: number; dateFrom: string; dateTo: string } {
const startMinuteOfDay = toMinutes(schedule.startTime);
const endMinuteOfDay = toMinutes(schedule.endTime);
const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
const overnight = endMinuteOfDay <= startMinuteOfDay;
if (overnight) lessonEnd += 24 * 60 * 60 * 1000;
return {
start: lessonStart - advanceMinutes * 60 * 1000,
end: lessonEnd,
dateFrom: advanceMinutes > startMinuteOfDay ? shiftDate(lessonDate, -1) : lessonDate,
dateTo: overnight ? shiftDate(lessonDate, 1) : lessonDate,
};
}
export function getLessonAttendanceImportDateRange(
schedule: LessonScheduleLike,
lessonDate: string,
): { startDate: string; endDate: string } {
const window = getLessonAttendanceWindow(schedule, lessonDate);
return { startDate: window.dateFrom, endDate: window.dateTo };
}
export function selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[],
schedule: LessonScheduleLike,
lessonDate: string,
): DingAttendanceRaw[] {
const window = getLessonAttendanceWindow(schedule, lessonDate);
return records.filter((record) => {
// 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= window.start && time.getTime() <= window.end;
});
}
export function mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime);
if (hasPunch) return 'present';
return finalize ? 'absent' : 'pending';
}
export function getLessonPunchMetadata(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
const punches = records
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
if (punches.length === 0) {
return {
punchTime: null,
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
};
}
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
punches.sort(
(left, right) =>
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
);
const primary = punches[0];
const metadataRecord = [...punches]
.filter(({ record }) =>
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
)
.sort(
(left, right) =>
Math.abs(left.time.getTime() - primary.time.getTime()) -
Math.abs(right.time.getTime() - primary.time.getTime()),
)[0]?.record;
const source =
metadataRecord?.punchSource ||
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
? metadataRecord.attendanceType
: primary.record.punchSource);
return {
punchTime: primary.time,
punchSource: source || null,
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
};
}

View File

@@ -0,0 +1,299 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
AttendanceRecord,
Class,
ClassSchedule,
ClassStudent,
AttendanceSession,
AttendancePeriodConfig,
ScheduleType,
} from '../entities';
import { toMinutes, isClassStudentActiveOnDate } from './attendance-time';
import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto';
@Injectable()
export class AttendanceGenerationService {
private readonly defaultAttendancePeriods = [
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
] as const;
constructor(
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
private dataSource: DataSource,
) {}
async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) {
throw new BadRequestException('records array must not be empty');
}
const entities = dto.records.map((r) => {
const entity = this.attendanceRepo.create({
studentId: r.studentId,
classId: r.classId ?? undefined,
attendanceDate: r.attendanceDate,
session: r.session,
status: r.status,
remark: r.remark,
source: r.source || 'manual',
});
return entity;
});
const saved = await this.attendanceRepo.save(entities);
return { count: saved.length, records: saved };
}
// ── Generate attendance records from class schedules ──
async generateAttendanceFromSchedules(dto: GenerateAttendanceFromSchedulesDto) {
const { classId, dateFrom, dateTo } = dto;
if (dateFrom > dateTo) {
throw new BadRequestException('dateFrom must not be later than dateTo');
}
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) {
throw new NotFoundException(`Class ${classId} not found`);
}
const schedules = await this.scheduleRepo.find({
where: {
classId,
scheduleType: ScheduleType.INTERNAL,
status: 'active',
startDate: LessThanOrEqual(dateTo),
endDate: MoreThanOrEqual(dateFrom),
},
});
const classStudents = await this.classStudentRepo.find({
where: { classId, status: In(['active', 'left']) },
relations: ['student'],
});
if (schedules.length === 0 || classStudents.length === 0) {
return { count: 0, records: [] };
}
const existingRecords = await this.attendanceRepo.find({
where: { classId, attendanceDate: Between(dateFrom, dateTo) },
});
const existingKeys = new Set(
existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`),
);
const entities: AttendanceRecord[] = [];
const end = new Date(dateTo);
for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().slice(0, 10);
const weekDay = d.getDay() === 0 ? 7 : d.getDay();
for (const sched of schedules) {
if (sched.weekDay !== weekDay) continue;
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
const session = await this.mapScheduleTimeToSession(sched.startTime);
const classStudentsForDate = classStudents.filter((cs) =>
isClassStudentActiveOnDate(cs, dateStr),
);
for (const cs of classStudentsForDate) {
const key = `${cs.studentId}|${dateStr}|${session}`;
if (existingKeys.has(key)) continue;
const entity = this.attendanceRepo.create({
studentId: cs.studentId,
classId,
attendanceDate: dateStr,
session,
status: 'pending',
source: 'schedule',
});
entities.push(entity);
existingKeys.add(key);
}
}
}
const saved = await this.attendanceRepo.save(entities);
return { count: saved.length, records: saved };
}
// ── Generate attendance records from schedules (optional date range, defaults to current week) ──
async generateFromSchedules(
dto: GenerateFromSchedulesDto,
): Promise<{ count: number; records: AttendanceRecord[] }> {
const { classId, startDate, endDate } = dto;
// Default to current week (MondaySunday)
const now = new Date();
const dayOfWeek = now.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(now);
monday.setDate(now.getDate() + mondayOffset);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
const dateFrom = startDate ?? monday.toISOString().slice(0, 10);
const dateTo = endDate ?? sunday.toISOString().slice(0, 10);
return this.generateAttendanceFromSchedules({
classId,
dateFrom,
dateTo,
});
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private getCourseClock(date: Date): { date: string; minutes: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
minutes: Number(parts.hour) * 60 + Number(parts.minute),
};
}
private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T00:00:00.000Z`);
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted.toISOString().slice(0, 10);
}
private async ensureAttendancePeriodConfigs() {
const count = await this.attendancePeriodConfigRepo.count();
if (count === 0) {
await this.attendancePeriodConfigRepo.save(
this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({
...period,
enabled: true,
})),
);
}
return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } });
}
async getAttendancePeriodConfigs() {
return this.ensureAttendancePeriodConfigs();
}
async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
const parsedDate = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
const qb = this.scheduleRepo
.createQueryBuilder('schedule')
.where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
.andWhere('schedule.status = :status', { status: 'active' })
.andWhere('schedule.classId IS NOT NULL')
.andWhere('schedule.weekDay = :weekDay', { weekDay })
.andWhere('schedule.startDate <= :date', { date })
.andWhere('schedule.endDate >= :date', { date });
if (classId) {
qb.andWhere('schedule.classId = :classId', { classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
if (!session) return schedules;
const matchedSchedules: ClassSchedule[] = [];
for (const schedule of schedules) {
if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
matchedSchedules.push(schedule);
}
}
return matchedSchedules;
}
async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) {
const seen = new Set<string>();
const normalized = dto.periods.map((period, index) => {
const periodKey = period.periodKey.trim();
const label = period.label.trim();
if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空');
if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`);
seen.add(periodKey);
if (toMinutes(period.endTime) <= toMinutes(period.startTime)) {
throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
}
return {
periodKey,
label,
startTime: period.startTime,
endTime: period.endTime,
sortOrder: period.sortOrder ?? index + 1,
enabled: period.enabled ?? true,
};
}).sort((left, right) => left.sortOrder - right.sortOrder);
// 按开始时间排序后再检查重叠,避免 sortOrder 与时间顺序不一致时漏检
const sortedByTime = [...normalized].sort(
(left, right) => toMinutes(left.startTime) - toMinutes(right.startTime),
);
for (let index = 1; index < sortedByTime.length; index += 1) {
const previous = sortedByTime[index - 1];
const current = sortedByTime[index];
if (previous.enabled && current.enabled && toMinutes(current.startTime) < toMinutes(previous.endTime)) {
throw new BadRequestException(`${previous.label}${current.label} 时间段不能重叠`);
}
}
await this.attendancePeriodConfigRepo.clear();
await this.attendancePeriodConfigRepo.save(
normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
);
return this.getAttendancePeriodConfigs();
}
async resetAttendancePeriodConfigs() {
await this.attendancePeriodConfigRepo.clear();
return this.ensureAttendancePeriodConfigs();
}
private mapLessonScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';
if (hour < 12) return 'morning';
if (hour < 17) return 'afternoon';
if (hour < 20) return 'evening_study';
return 'night_check';
}
private async mapScheduleTimeToSession(startTime: string): Promise<string> {
const startMinutes = toMinutes(startTime);
const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
const matched = periods.find((period) => {
const periodStart = toMinutes(period.startTime);
const periodEnd = toMinutes(period.endTime);
return startMinutes >= periodStart && startMinutes < periodEnd;
});
if (matched) return matched.periodKey;
throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`);
}
}

View File

@@ -0,0 +1,147 @@
import { Controller, Get, Post, Sse, Body, Param, Query, Request, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common';
import { Observable, filter } from 'rxjs';
import { AttendanceControllerBase, RequestUser, SseEvent } from './attendance.controller-base';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AuthorizationService } from '../authorization';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto';
@Controller()
export class AttendanceImportController extends AttendanceControllerBase {
constructor(
service: AttendanceService,
importService: AttendanceImportService,
logService: OperationLogsService,
authz: AuthorizationService,
) {
super(service, importService, logService, authz);
}
@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 result = await this.service.matchDingRecord(id, dto);
await logAudit(this.logService, req, {
module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`,
});
return result;
}
// ── Attendance class-based report export ──
@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();
});
}
}

View File

@@ -96,13 +96,11 @@ export class AttendanceImportService {
let matched = 0;
try {
// Stage 1: Fetch
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
const rawResults = await this.fetchAllPages(params);
const total = rawResults.length;
this.emit('fetching', total, total, `Fetched ${total} raw attendance records`);
// Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
@@ -117,7 +115,6 @@ export class AttendanceImportService {
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
}
// Stage 3: Batch save
this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`);
const batchSize = 100;
for (let i = 0; i < newRecords.length; i += batchSize) {
@@ -134,7 +131,6 @@ export class AttendanceImportService {
}
}
// Stage 4: Auto-match (optional)
if (params.autoMatch && imported > 0) {
this.emit('matching', 0, imported, 'Auto-matching records to students...');
matched = await this.autoMatchUnmatched();
@@ -305,7 +301,6 @@ export class AttendanceImportService {
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
// Parse check-in/out times
if (r.actualCheckTime) {
const dt = new Date(r.actualCheckTime);
if (!isNaN(dt.getTime())) {

View File

@@ -0,0 +1,377 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In, Between } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
StudentDingMapping,
ClassTeacher,
AttendanceSession,
AttendanceDevice,
ScheduleType,
} from '../entities';
import { SessionMutex } from './attendance-mutex';
import { attachAttendanceDeviceMappings } from './attendance-device';
import { getCourseClock, mapLessonScheduleTimeToSession, isClassStudentActiveOnDate } from './attendance-time';
import {
getLessonAttendanceImportDateRange,
getLessonAttendanceWindow,
selectDingTalkRecordsForLesson,
mapDingTalkStatus,
getLessonPunchMetadata,
} from './attendance-dingtalk';
@Injectable()
export class AttendanceLessonService {
private readonly sessionMutex = new SessionMutex();
constructor(
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository<AttendanceDevice>,
private dataSource: DataSource,
) {}
async getClassStudentsForLesson(
classId: number,
lessonDate: string,
relations: string[] = [],
): Promise<ClassStudent[]> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: In(['active', 'left']) },
relations,
});
return classStudents.filter((classStudent) =>
isClassStudentActiveOnDate(classStudent, lessonDate),
);
}
/** List classes the current user may select for DingTalk attendance import. */
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('排课记录不存在');
if ((schedule.scheduleType as ScheduleType) !== ScheduleType.INTERNAL || schedule.status !== 'active') {
throw new BadRequestException('该排课不能进行课程考勤');
}
if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
throw new BadRequestException('所选日期不在排课有效期内');
}
const date = new Date(`${lessonDate}T00:00:00`);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
return schedule;
}
async getLessonAttendance(scheduleId: number, lessonDate: string) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const session = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
const records = session
? await this.attendanceRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
})
: [];
return { schedule, session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, schedule.classId) };
}
getLessonAttendanceImportDateRange(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { startDate: string; endDate: string } {
return getLessonAttendanceImportDateRange(schedule, lessonDate);
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
userId: number,
finalize = false,
) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date();
const courseClock = getCourseClock(now);
const today = courseClock.date;
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute;
const currentMinute = courseClock.minutes;
if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤');
}
}
const existing = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
if (
existing.status !== 'in_progress' &&
existing.status !== 'completed' &&
!(finalize && existing.status === 'settling')
) {
throw new BadRequestException('课程考勤正在结算');
}
// Refresh latest DingTalk data even after automatic settlement; late-arriving punches
// may legitimately change a DingTalk-generated absence to present.
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const effectiveFinalize = finalize || existing.status === 'completed';
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const updatedRecords = existingRecords.map((record) => {
record.student = studentsById.get(record.studentId)!;
// Preserve manual corrections only while the lesson is still in progress.
if (!finalize && record.source !== 'dingtalk') return record;
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate,
);
record.status = mapDingTalkStatus(raw, effectiveFinalize);
Object.assign(record, getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: effectiveFinalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果';
return record;
});
for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
);
updatedRecords.push(
recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: existing.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: mapDingTalkStatus(raw, effectiveFinalize),
source: 'dingtalk',
...getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: effectiveFinalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
}),
);
}
const saved = await recordRepo.save(updatedRecords);
if (finalize) {
existing.status = 'completed';
existing.completedBy = userId;
existing.completedAt = new Date();
await sessionRepo.save(existing);
}
return { schedule, session: existing, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) };
});
}
// First pull: create session and records atomically
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
let session: AttendanceSession;
try {
session = await sessionRepo.save(
sessionRepo.create({
scheduleId,
classId: schedule.classId!,
lessonDate,
status: 'in_progress',
startedBy: userId,
startedAt: new Date(),
}),
);
} catch (err: unknown) {
const code = (err as Record<string, unknown>).code;
const errno = (err as Record<string, unknown>).errno;
// MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
const existing = await sessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
session = existing;
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session, records: await attachAttendanceDeviceMappings(existingRecords, this.attendanceDeviceRepo, schedule.classId) };
}
}
throw err;
}
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const records = classStudents.map((classStudent) => {
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
});
});
const saved = await recordRepo.save(records);
if (finalize) {
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
session = await sessionRepo.save(session);
}
return { schedule, session, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) };
});
}
private async fetchDingTalkRawByStudent(
classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const window = getLessonAttendanceWindow(schedule, lessonDate);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: Between(window.dateFrom, window.dateTo),
matchedStudentId: In(studentIds),
},
});
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
for (const raw of rawRecords) {
if (raw.matchedStudentId == null) continue;
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
arr.push(raw);
rawByStudent.set(raw.matchedStudentId, arr);
}
return rawByStudent;
}
async completeLessonAttendance(sessionId: number, userId: number) {
return this.sessionMutex.runExclusive(sessionId, () =>
this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const session = await sessionRepo.findOne({ where: { id: sessionId } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
// Re-check under lock: if already completed, return current state idempotently
if (session.status === 'completed') {
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) };
}
const pendingRecords = await recordRepo.count({
where: { attendanceSessionId: sessionId, status: 'pending' },
});
if (pendingRecords > 0) {
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
}
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
const savedSession = await sessionRepo.save(session);
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session: savedSession, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) };
}),
);
}
async findAttendanceSession(id: number) {
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
return session;
}
// ── Batch create attendance records ──
}

View File

@@ -0,0 +1,23 @@
/**
* 同一考勤会话session内的互斥执行器
* 保证针对同一个 sessionId 的并发操作按提交顺序串行执行。
*/
export class SessionMutex {
private queueTails = new Map<number, Promise<void>>();
async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
let release!: () => void;
const newTail = new Promise<void>((resolve) => { release = resolve; });
this.queueTails.set(sessionId, newTail);
await tail;
try {
return await fn();
} finally {
release();
if (this.queueTails.get(sessionId) === newTail) {
this.queueTails.delete(sessionId);
}
}
}
}

View File

@@ -0,0 +1,319 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
Class,
ClassSchedule,
ClassStudent,
StudentDingMapping,
ClassTeacher,
AttendanceDevice,
TeacherRoleType,
} from '../entities';
import type {
AttendanceSummaryQueryDto,
QueryDingRawDto,
} from './dto/attendance.dto';
import { attachAttendanceDeviceMappings } from './attendance-device';
import { AttendanceCalendarService } from './attendance-calendar.service';
import { AttendanceRecordMutationService } from './attendance-record-mutation.service';
import { AttendanceReportService } from './attendance-report.service';
@Injectable()
export class AttendanceQueryService {
constructor(
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository<AttendanceDevice>,
private dataSource: DataSource,
) {}
private calendarService?: AttendanceCalendarService;
private mutationService?: AttendanceRecordMutationService;
private reportService?: AttendanceReportService;
private get calendar(): AttendanceCalendarService {
if (!this.calendarService) {
this.calendarService = new AttendanceCalendarService(this.attendanceRepo, this.scheduleRepo);
}
return this.calendarService;
}
private get mutations(): AttendanceRecordMutationService {
if (!this.mutationService) {
this.mutationService = new AttendanceRecordMutationService(
this.attendanceRepo,
this.dingRawRepo,
this.studentDingMappingRepo,
this.dataSource,
);
}
return this.mutationService;
}
private get reports(): AttendanceReportService {
if (!this.reportService) {
this.reportService = new AttendanceReportService(
this.attendanceRepo,
this.attendanceDeviceRepo,
);
}
return this.reportService;
}
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (!query.classId && accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
const pending = rows.filter((r) => r.status === 'pending').length;
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
return { total, present, late, absent, leave, pending, presentRate };
}
// ── Attendance calendar ──
async getCalendar(...args: Parameters<AttendanceCalendarService['getCalendar']>) {
return this.calendar.getCalendar(...args);
}
async getScheduleOptionsForAttendance(
...args: Parameters<AttendanceCalendarService['getScheduleOptionsForAttendance']>
) {
return this.calendar.getScheduleOptionsForAttendance(...args);
}
// ── List attendance records with filters ──
async findAll(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
},
accessibleClassIds?: number[],
) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
if (query.status) {
qb.andWhere('ar.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('ar.source = :source', { source: query.source });
}
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list: await attachAttendanceDeviceMappings(list, this.attendanceDeviceRepo), total, page, pageSize };
}
// ── Get distinct classes with attendance records ──
async getClasses(accessibleClassIds?: number[]) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const rows: Array<{ classId: string | number }> = accessibleClassIds
? accessibleClassIds.map((classId) => ({ classId }))
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
if (classIds.length === 0) return [];
const where = { id: In(classIds) };
const [classes, teachers] = await Promise.all([
this.classRepo.find({ where }),
this.classTeacherRepo.find({
where: {
classId: In(classIds),
roleType: In([
TeacherRoleType.HEAD_TEACHER,
TeacherRoleType.LIFE_TEACHER,
TeacherRoleType.SUBJECT_TEACHER,
]),
},
relations: ['user'],
order: { roleType: 'ASC', id: 'ASC' },
}),
]);
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
const teacherMap = new Map<
number,
Array<{
userId: number;
username: string | null;
name: string | null;
roleType: string;
subject: string | null;
}>
>();
for (const teacher of teachers) {
const user = teacher.user as { username?: string | null; name?: string | null } | undefined;
const items = teacherMap.get(teacher.classId) ?? [];
items.push({
userId: teacher.userId,
username: user?.username || null,
name: user?.name || null,
roleType: teacher.roleType,
subject: teacher.subject || null,
});
teacherMap.set(teacher.classId, items);
}
return classIds.map((id) => ({
classId: id,
className: nameMap.get(id) || `班级${id}`,
teachers: teacherMap.get(id) ?? [],
}));
}
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.dingRawRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
if (query.matchStatus) {
qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
if (scopedClassIds) {
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
const classStudents = await this.classStudentRepo.find({
where: { classId: In(scopedClassIds), status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const dingUserIds = [
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
];
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
}
qb.orderBy('ar.attendanceDate', 'DESC')
.addOrderBy('ar.checkInTime', 'ASC')
.skip((page - 1) * pageSize)
.take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize };
}
// ── Match a dingtalk record to a student ──
async matchDingRecord(...args: Parameters<AttendanceRecordMutationService['matchDingRecord']>) {
return this.mutations.matchDingRecord(...args);
}
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
return this.mutations.autoMatchDingRecords();
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(
...args: Parameters<AttendanceReportService['findAllForExport']>
) {
return this.reports.findAllForExport(...args);
}
async findAttendanceRecord(
...args: Parameters<AttendanceRecordMutationService['findAttendanceRecord']>
) {
return this.mutations.findAttendanceRecord(...args);
}
// ── Update a single attendance record ──
async update(...args: Parameters<AttendanceRecordMutationService['update']>) {
return this.mutations.update(...args);
}
// ── Delete a single attendance record ──
async remove(...args: Parameters<AttendanceRecordMutationService['remove']>) {
return this.mutations.remove(...args);
}
// ── Class-based attendance report ──
async getReport(...args: Parameters<AttendanceReportService['getReport']>) {
return this.reports.getReport(...args);
}
// ── Attendance alerts: detect consecutive absences/late ──
async getAlerts(...args: Parameters<AttendanceReportService['getAlerts']>) {
return this.reports.getAlerts(...args);
}
}

View File

@@ -0,0 +1,156 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
StudentDingMapping,
AttendanceSession,
} from '../entities';
import type { MatchDingRecordDto, UpdateAttendanceRecordDto } from './dto/attendance.dto';
import { SessionMutex } from './attendance-mutex';
@Injectable()
export class AttendanceRecordMutationService {
private readonly sessionMutex = new SessionMutex();
constructor(
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw)
private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
private dataSource: DataSource,
) {}
async findAttendanceRecord(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
return record;
}
// ── Match a dingtalk record to a student ──
async matchDingRecord(id: number, dto: MatchDingRecordDto) {
const record = await this.dingRawRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`DingAttendanceRaw ${id} not found`);
}
record.matchedStudentId = dto.studentId;
record.matchStatus = 'matched';
return this.dingRawRepo.save(record);
}
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
const unmatched = await this.dingRawRepo.find({
where: { matchStatus: 'unmatched' },
});
if (unmatched.length === 0) return { matched: 0, total: 0 };
const mappings = await this.studentDingMappingRepo.find();
const dingToStudentId = new Map<string, number>();
for (const m of mappings) {
dingToStudentId.set(m.dingUserId, m.studentId);
}
let matched = 0;
for (const record of unmatched) {
const studentId = dingToStudentId.get(record.dingUserId);
if (studentId == null) continue;
record.matchedStudentId = studentId;
record.matchStatus = 'matched';
await this.dingRawRepo.save(record);
matched++;
}
return { matched, total: unmatched.length };
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
record.source = 'manual';
}
return this.attendanceRepo.save(record);
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;
freshRecord.source = 'manual';
}
return recordRepo.save(freshRecord);
}),
);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
await this.attendanceRepo.remove(record);
return { deleted: true };
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
await recordRepo.remove(freshRecord);
return { deleted: true };
}),
);
}
}

View File

@@ -0,0 +1,365 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common';
import type { Response } from 'express';
import { AttendanceControllerBase, RequestUser } from './attendance.controller-base';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AuthorizationService } from '../authorization';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryAttendanceRecordsDto,
AttendanceScheduleOptionsQueryDto,
AttendanceReportQueryDto,
AttendanceAlertsQueryDto,
UpdateAttendanceRecordDto,
GenerateFromSchedulesDto,
RefreshDingTalkAttendanceDto,
} from './dto/attendance.dto';
import * as ExcelJS from 'exceljs';
@Controller()
export class AttendanceRecordsController extends AttendanceControllerBase {
constructor(
service: import('./attendance.service').AttendanceService,
importService: import('./attendance-import.service').AttendanceImportService,
logService: OperationLogsService,
authz: AuthorizationService,
) {
super(service, importService, logService, authz);
}
@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) {
await this.assertClassAccess(req, dto.classId);
const result = await this.service.generateFromSchedules(dto);
await logAudit(this.logService, req, {
module: '考勤管理', action: '按课表生成考勤', detail: `班级 ${dto.classId}, 共 ${result.count}`,
});
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 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 logAudit(this.logService, req, {
module: '考勤管理', action: '编辑考勤记录', targetId: id, targetType: 'attendanceRecord', detail: `状态=${result.status}, 备注=${result.remark || ''}`,
});
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 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 logAudit(this.logService, req, {
module: '考勤管理', action: '归档考勤记录', targetId: id, targetType: 'attendanceRecord', detail: `归档考勤记录 ${id}`,
});
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('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
await logAudit(this.logService, req, {
module: '考勤管理', action: '导出考勤报表', detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`,
});
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),
);
}
}

View File

@@ -0,0 +1,196 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AttendanceRecord, AttendanceDevice } from '../entities';
import type { AttendanceReportQueryDto } from './dto/attendance.dto';
import { attachAttendanceDeviceMappings } from './attendance-device';
@Injectable()
export class AttendanceReportService {
constructor(
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(AttendanceDevice)
private attendanceDeviceRepo: Repository<AttendanceDevice>,
) {}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
},
accessibleClassIds?: number[],
) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
if (query.status) {
qb.andWhere('ar.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('ar.source = :source', { source: query.source });
}
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
const records = await qb.getMany();
return attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo);
}
// ── Class-based attendance report ──
async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
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 });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
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 as Array<{
classId?: number;
className?: string | null;
status?: string;
count: string;
}>) {
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, accessibleClassIds?: number[]) {
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');
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
'a.status IN (:...statuses)',
{ statuses: ['absent', 'late'] },
);
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
const records = await qb
.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?.name || '';
const className = r.class?.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

@@ -0,0 +1,43 @@
export function toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
export function getCourseClock(date: Date): { date: string; minutes: number } {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return {
date: `${year}-${month}-${day}`,
minutes: date.getHours() * 60 + date.getMinutes(),
};
}
export function shiftDate(date: string, days: number): string {
const d = new Date(`${date}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export function mapLessonScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';
if (hour < 12) return 'morning';
if (hour < 17) return 'afternoon';
if (hour < 20) return 'evening_study';
return 'night_check';
}
export function isClassStudentActiveOnDate(
classStudent: Pick<
import('../entities/class-student.entity').ClassStudent,
'joinDate' | 'leaveDate' | 'status'
>,
lessonDate: string,
): boolean {
const status = classStudent.status ?? 'active';
if (!['active', 'left'].includes(status)) return false;
if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false;
if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false;
return true;
}

View File

@@ -0,0 +1,57 @@
import { UseGuards } from '@nestjs/common';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
/** Minimal request user shape for type safety */
export interface RequestUser {
id: number;
username: string;
permissions: string[];
isSuperAdmin: boolean;
roles: string[];
}
/** SSE event shape for @Sse() decorator */
export interface SseEvent {
data: string | Record<string, unknown>;
id?: string;
type?: string;
retry?: number;
}
@UseGuards(JwtAuthGuard)
export abstract class AttendanceControllerBase {
constructor(
protected readonly service: AttendanceService,
protected readonly importService: AttendanceImportService,
protected readonly logService: OperationLogsService,
protected readonly authz: AuthorizationService,
) {}
protected 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}`;
}
protected 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)
);
}
protected getAccessibleClassIds(req: { user: RequestUser }) {
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
}
protected assertClassAccess(req: { user: RequestUser }, classId: number) {
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
}
}

View File

@@ -1,11 +1,13 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Subject } from 'rxjs';
import { AttendanceController } from './attendance.controller';
import { AttendanceRecordsController } from './attendance-records.controller';
import { AttendanceImportController } from './attendance-import.controller';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
describe('AttendanceController — DingTalk import scope', () => {
describe('AttendanceRecordsController — DingTalk import scope', () => {
const attendanceService = {
getTeacherClassDingUserIds: jest.fn(),
getImportableClasses: jest.fn(),
@@ -22,6 +24,8 @@ describe('AttendanceController — DingTalk import scope', () => {
};
let controller: AttendanceController;
let recordsController: AttendanceRecordsController;
let importController: AttendanceImportController;
beforeEach(() => {
jest.clearAllMocks();
@@ -31,6 +35,18 @@ describe('AttendanceController — DingTalk import scope', () => {
logService as unknown as OperationLogsService,
authzService as never,
);
recordsController = new AttendanceRecordsController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
importController = new AttendanceImportController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
importService.importFromDingTalk.mockResolvedValue({
success: true,
imported: 0,
@@ -45,7 +61,7 @@ describe('AttendanceController — DingTalk import scope', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z'));
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']);
await controller.importFromDingTalk({ classId: 8 }, {
await importController.importFromDingTalk({ classId: 8 }, {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
} as never);
@@ -62,7 +78,7 @@ describe('AttendanceController — DingTalk import scope', () => {
it('uses only the selected class students mapped to DingTalk for a teacher import', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']);
await controller.importFromDingTalk(
await importController.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', classId: 8 },
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
@@ -78,7 +94,7 @@ describe('AttendanceController — DingTalk import scope', () => {
it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => {
await expect(
controller.importFromDingTalk(
importController.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', users: 'someone-else' },
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
),
@@ -89,7 +105,7 @@ describe('AttendanceController — DingTalk import scope', () => {
it('requires teachers to select one of their classes', async () => {
await expect(
controller.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02' }, {
importController.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02' }, {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
} as never),
).rejects.toBeInstanceOf(BadRequestException);
@@ -98,7 +114,7 @@ describe('AttendanceController — DingTalk import scope', () => {
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]);
await expect(
controller.getDingTalkImportClasses({
importController.getDingTalkImportClasses({
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
} as never),
).resolves.toEqual([{ classId: 8, className: '八班' }]);
@@ -109,7 +125,7 @@ describe('AttendanceController — DingTalk import scope', () => {
it('always auto-matches class-scoped imports', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
await controller.importFromDingTalk(
await importController.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', classId: 8 },
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
@@ -124,7 +140,7 @@ describe('AttendanceController — DingTalk import scope', () => {
authzService.can.mockReturnValue(true);
await expect(
controller.getDingTalkImportClasses({
importController.getDingTalkImportClasses({
user: {
id: 7,
username: 'manager',
@@ -136,7 +152,7 @@ describe('AttendanceController — DingTalk import scope', () => {
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true);
await controller.importFromDingTalk(
await importController.importFromDingTalk(
{ start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2' },
{
user: {
@@ -157,7 +173,7 @@ describe('AttendanceController — DingTalk import scope', () => {
});
});
describe('AttendanceController — write data scope', () => {
describe('AttendanceRecordsController — write data scope', () => {
const attendanceService = {
assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(),
@@ -183,6 +199,7 @@ describe('AttendanceController — write data scope', () => {
headers: {},
};
let controller: AttendanceController;
let recordsController: AttendanceRecordsController;
beforeEach(() => {
jest.clearAllMocks();
@@ -193,6 +210,12 @@ describe('AttendanceController — write data scope', () => {
logService as unknown as OperationLogsService,
authzService as never,
);
recordsController = new AttendanceRecordsController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
});
it('checks every distinct class in a manual attendance batch', async () => {
@@ -216,7 +239,7 @@ describe('AttendanceController — write data scope', () => {
],
};
await controller.batchCreate(dto, req);
await recordsController.batchCreate(dto, req);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
@@ -235,7 +258,7 @@ describe('AttendanceController — write data scope', () => {
],
};
await expect(controller.batchCreate(dto, req)).rejects.toBeInstanceOf(
await expect(recordsController.batchCreate(dto, req)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
@@ -277,7 +300,7 @@ describe('AttendanceController — write data scope', () => {
it('checks class access before generating attendance from schedules', async () => {
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
await controller.generateFromSchedules({ classId: 8 }, req);
await recordsController.generateFromSchedules({ classId: 8 }, req);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
@@ -287,8 +310,8 @@ describe('AttendanceController — write data scope', () => {
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
attendanceService.remove.mockResolvedValue({ deleted: true });
await controller.update('4', { status: 'late' }, req);
await controller.remove('4', req);
await recordsController.update('4', { status: 'late' }, req);
await recordsController.remove('4', req);
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
@@ -296,7 +319,7 @@ describe('AttendanceController — write data scope', () => {
});
});
describe('AttendanceController — SSE progress scoping', () => {
describe('AttendanceRecordsController — SSE progress scoping', () => {
let progressSubject: Subject<{ phase: string; userId?: number }>;
const importService = {
importFromDingTalk: jest.fn(),
@@ -306,11 +329,11 @@ describe('AttendanceController — SSE progress scoping', () => {
const logService = {} as unknown as OperationLogsService;
const authzService = {} as never;
let controller: AttendanceController;
let importController: AttendanceImportController;
beforeEach(() => {
progressSubject = new Subject<{ phase: string; userId?: number }>();
controller = new AttendanceController(
importController = new AttendanceImportController(
attendanceService,
importService as unknown as AttendanceImportService,
logService,
@@ -324,7 +347,7 @@ describe('AttendanceController — SSE progress scoping', () => {
it('delivers events matching the requesting user id', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
const sub = importController.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
}).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
@@ -339,7 +362,7 @@ describe('AttendanceController — SSE progress scoping', () => {
it('excludes events from a different user', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
const sub = importController.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
}).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
@@ -356,7 +379,7 @@ describe('AttendanceController — SSE progress scoping', () => {
it('excludes events with undefined userId (non-HTTP callers)', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
const sub = importController.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
}).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),

View File

@@ -3,99 +3,36 @@ import {
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 { AttendanceControllerBase, RequestUser } from './attendance.controller-base';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AuthorizationService } from '../authorization';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryAttendanceRecordsDto,
AttendanceScheduleOptionsQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
AttendanceAlertsQueryDto,
UpdateAttendanceRecordDto,
GenerateFromSchedulesDto,
SaveAttendancePeriodConfigsDto,
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 {
export class AttendanceController extends AttendanceControllerBase {
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}`;
service: AttendanceService,
importService: AttendanceImportService,
logService: OperationLogsService,
authz: AuthorizationService,
) {
super(service, importService, logService, authz);
}
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() {
@@ -115,7 +52,9 @@ export class AttendanceController {
module: '考勤管理',
action: '保存考勤时段配置',
targetType: 'attendancePeriodConfig',
detail: dto.periods.map((item) => `${item.label}:${item.startTime}-${item.endTime}`).join(''),
detail: dto.periods
.map((item) => `${item.label}:${item.startTime}-${item.endTime}`)
.join(''),
});
return result;
}
@@ -212,504 +151,4 @@ export class AttendanceController {
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();
});
}
}

View File

@@ -48,7 +48,16 @@ const createService = () => {
{} as never,
dataSource as unknown as DataSource,
);
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource };
return {
service,
attendanceRepo,
dingRawRepo,
scheduleRepo,
classStudentRepo,
sessionRepo,
attendanceDeviceRepo,
dataSource,
};
};
const endedSchedule = {
@@ -128,9 +137,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
@@ -149,9 +156,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
@@ -167,7 +172,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
@@ -201,10 +208,26 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 3, student: { id: 3, name: '王五' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
{ matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
{ matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
{
matchedStudentId: 1,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T08:40:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T10:00:00+08:00'),
},
{
matchedStudentId: 3,
attendanceType: 'OnDuty',
checkInTime: new Date('2026-07-11T08:39:59+08:00'),
},
{
matchedStudentId: 3,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T10:00:01+08:00'),
},
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
@@ -218,10 +241,12 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
it('expands import dates when the pre-class window crosses midnight', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
)).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
),
).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
});
it('creates local attendance after the lesson starts', async () => {
@@ -266,9 +291,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceRepo.find.mockResolvedValue([
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
@@ -306,8 +329,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
@@ -326,7 +359,6 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(result.records).toHaveLength(2);
});
it('restores students missing from an existing empty session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
@@ -373,8 +405,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
@@ -404,9 +446,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
@@ -489,8 +529,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
]);
// Step 1: update the record to absent via generic update()
@@ -498,7 +548,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(updated.source).toBe('manual');
// Step 2: refresh in_progress session — manual record status must stay absent
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
@@ -532,9 +582,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
errno: undefined,
}),
);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
attendanceRepo.find.mockResolvedValue([
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
@@ -555,9 +603,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
]);
attendanceRepo.find.mockResolvedValue([{ id: 1, status: 'present' }]);
await service.completeLessonAttendance(90, 21);
@@ -615,15 +661,24 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
describe('AttendanceService — attendance window boundaries', () => {
it('crosses calendar boundaries only when the window requires it', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
});
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
@@ -631,17 +686,23 @@ describe('AttendanceService — attendance window boundaries', () => {
process.env.TZ = 'UTC';
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
try {
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } = createService();
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-13',
...endedSchedule,
weekDay: 1,
startTime: '08:30',
endTime: '10:00',
startDate: '2026-07-13',
endDate: '2026-07-13',
});
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
attendanceRepo.find.mockResolvedValue([]);
dingRawRepo.find.mockResolvedValue([]);
classStudentRepo.find.mockResolvedValue([]);
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
.resolves.toMatchObject({ records: [] });
await expect(
service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21),
).resolves.toMatchObject({ records: [] });
} finally {
jest.useRealTimers();
process.env.TZ = originalTz;

View File

@@ -5,6 +5,8 @@ import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { AttendanceSettlementService } from './attendance-settlement.service';
import { AttendanceController } from './attendance.controller';
import { AttendanceRecordsController } from './attendance-records.controller';
import { AttendanceImportController } from './attendance-import.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { IntegrationModule } from '../integration/integration.module';
@@ -14,7 +16,7 @@ import { IntegrationModule } from '../integration/integration.module';
OperationLogsModule,
IntegrationModule,
],
controllers: [AttendanceController],
controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController],
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService],
exports: [AttendanceService, AttendanceImportService],
})

View File

@@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
@@ -19,7 +18,6 @@ import { BatchCreateAttendanceDto } from './dto/attendance.dto';
describe('AttendanceService — batchCreate', () => {
let service: AttendanceService;
let attendanceRepo: jest.Mocked<Pick<Repository<AttendanceRecord>, 'create' | 'save'>>;
const savedRecords: AttendanceRecord[] = [];
@@ -28,14 +26,14 @@ describe('AttendanceService — batchCreate', () => {
const mockRepo = {
create: jest
.fn()
.mockImplementation((data: Partial<AttendanceRecord>) => ({ id: 1, ...data } as AttendanceRecord)),
save: jest
.fn()
.mockImplementation((entities: AttendanceRecord[]) => {
const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
savedRecords.push(...result);
return Promise.resolve(result);
}),
.mockImplementation(
(data: Partial<AttendanceRecord>) => ({ id: 1, ...data }) as AttendanceRecord,
),
save: jest.fn().mockImplementation((entities: AttendanceRecord[]) => {
const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
savedRecords.push(...result);
return Promise.resolve(result);
}),
};
const mockDingRepo = {};
@@ -66,15 +64,32 @@ describe('AttendanceService — batchCreate', () => {
}).compile();
service = module.get<AttendanceService>(AttendanceService);
attendanceRepo = module.get(getRepositoryToken(AttendanceRecord));
});
it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => {
const dto: BatchCreateAttendanceDto = {
records: [
{ studentId: 1, classId: 10, attendanceDate: '2026-07-05', session: 'morning_reading', status: 'present' },
{ studentId: 2, classId: 10, attendanceDate: '2026-07-05', session: 'evening_study', status: 'present' },
{ studentId: 3, classId: 10, attendanceDate: '2026-07-05', session: 'night_check', status: 'present' },
{
studentId: 1,
classId: 10,
attendanceDate: '2026-07-05',
session: 'morning_reading',
status: 'present',
},
{
studentId: 2,
classId: 10,
attendanceDate: '2026-07-05',
session: 'evening_study',
status: 'present',
},
{
studentId: 3,
classId: 10,
attendanceDate: '2026-07-05',
session: 'night_check',
status: 'present',
},
],
};
@@ -92,7 +107,12 @@ describe('AttendanceService — batchCreate', () => {
const invalidPayload = {
records: [
{ studentId: 1, attendanceDate: '2026-07-05', session: 'invalid_session', status: 'present' },
{
studentId: 1,
attendanceDate: '2026-07-05',
session: 'invalid_session',
status: 'present',
},
],
};
@@ -151,11 +171,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
it('returns only mapped active students for a class assigned to the teacher', async () => {
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
classStudentRepo.find.mockResolvedValue([
{ studentId: 2 },
{ studentId: 1 },
{ studentId: 2 },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 2 }, { studentId: 1 }, { studentId: 2 }]);
mappingRepo.find.mockResolvedValue([
{ studentId: 1, dingUserId: 'ding-1' },
{ studentId: 2, dingUserId: 'ding-2' },
@@ -206,7 +222,9 @@ describe('AttendanceService — DingTalk raw query', () => {
};
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]),
};
const service = new AttendanceService(
{} as never,
dingRepo as never,
@@ -237,7 +255,6 @@ describe('AttendanceService — DingTalk raw query', () => {
});
});
describe('AttendanceService — attendance device display mappings', () => {
function createHistoryQueryBuilder(records: AttendanceRecord[]) {
return {
@@ -337,8 +354,6 @@ describe('AttendanceService — attendance device display mappings', () => {
});
});
// ── Session serialization tests ──
function deferred<T>(): {
promise: Promise<T>;
@@ -400,7 +415,8 @@ describe('AttendanceService — session serialization', () => {
}
function makeTxManager(sessionStatus: string) {
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const session =
sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const sessionRepo = {
findOne: jest.fn().mockResolvedValue(session),
@@ -528,9 +544,7 @@ describe('AttendanceService — session serialization', () => {
it('complete is idempotent: returns current state when session already completed', async () => {
const manager = makeTxManager('completed');
const txMock = jest
.fn()
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
import type {
DingTalkAttendanceResult,
DingTalkServiceContext,
} from './dingtalk.types';
export class DingTalkAttendanceClient {
constructor(private readonly context: DingTalkServiceContext) {}
async fetchAttendanceResults(params: {
startDate: string;
endDate: string;
userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> {
if (!(await this.context.isConfigured())) throw new Error('DingTalk not configured');
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
const token = await this.context.getAccessToken();
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`;
const body: Record<string, unknown> = {
checkDateFrom: dateFrom,
checkDateTo: dateTo,
};
body.userIds = params.userIds;
const res = await fetch(
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = await res.json() as {
errcode: number; errmsg: string;
recordresult?: Array<{
id: number; userId: string; workDate: number;
userCheckTime: number; sourceType: string;
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number; deviceSN?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
const records = data.recordresult ?? [];
return records.map((r) => ({
userId: r.userId,
userName: '',
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
timeResult: r.timeResult ?? r.sourceType ?? '',
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
planCheckTime: '',
actualCheckTime: new Date(r.userCheckTime).toISOString(),
checkId: String(r.id),
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined,
}));
}
}

View File

@@ -0,0 +1,183 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type {
DingTalkGroupParams,
DingTalkGroupSummary,
DingTalkGroupUpdateParams,
DingTalkServiceContext,
} from './dingtalk.types';
export class DingTalkGroupClient {
constructor(private readonly context: DingTalkServiceContext) {}
/** 创建排班制考勤组 */
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
const topGroup = this.buildAttendanceGroupBody(params);
const body = { op_user_id: params.owner, top_group: topGroup };
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: { id: number };
};
if (data.errcode !== 0) {
throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`);
}
this.context.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`);
return data.result!.id;
}
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
},
);
const data = (await res.json()) as {
errcode?: number;
errmsg?: string;
success?: boolean;
message?: string;
};
const succeeded = data.success === true || data.errcode === 0;
if (!succeeded) {
throw new Error(
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
`(code=${data.errcode ?? 'unknown'})`,
);
}
this.context.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
}
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
const machineOnly = params.attendance_machine_only ?? false;
const topGroup: Record<string, unknown> = {
name: params.name,
type: params.type,
owner: params.owner,
members: params.members.map((m) => ({
role: m.role,
type: m.type,
user_id: m.user_id,
})),
enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true),
disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false),
disable_check_when_rest: params.disable_check_when_rest ?? true,
};
if (params.shift_ids?.length) {
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
}
if (machineOnly) {
Object.assign(topGroup, {
enable_outside_check: false,
enable_position_ble: false,
positions: [],
wifis: [],
});
}
return topGroup;
}
/** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(_opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
const all: DingTalkGroupSummary[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ offset, size: 10 }),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: {
has_more: boolean;
groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`);
}
if (data.result?.groups) {
all.push(...data.result.groups.map((g) => ({
group_id: g.group_id,
group_name: g.group_name,
type: g.type,
member_count: g.member_count,
})));
}
hasMore = data.result?.has_more ?? false;
offset += 10;
}
return all;
}
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
await this.context.rateLimit();
const keyResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
},
);
const keyData = await keyResponse.json() as {
errcode: number;
errmsg: string;
result?: string;
};
if (keyData.errcode !== 0 || !keyData.result) {
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
}
await this.context.rateLimit();
const deleteResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
},
);
const deleteData = await deleteResponse.json() as {
errcode: number;
errmsg: string;
success?: boolean;
};
if (deleteData.errcode !== 0 || deleteData.success !== true) {
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
}
}
}

View File

@@ -0,0 +1,94 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type {
DingTalkScheduleItem,
DingTalkScheduleResult,
DingTalkServiceContext,
} from './dingtalk.types';
export class DingTalkScheduleClient {
constructor(private readonly context: DingTalkServiceContext) {}
/** 批量排班单次最多200条 */
async scheduleUsers(
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
): Promise<void> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
if (schedules.length === 0) return;
if (schedules.length > 200) {
throw new Error(`排班单次最多200条当前 ${schedules.length}`);
}
const token = await this.context.getAccessToken();
const body = {
op_user_id: opUserId,
group_id: groupId,
schedules: schedules.map((s) => ({
userid: s.userid,
work_date: s.work_date,
shift_id: s.shift_id,
is_rest: s.is_rest ?? false,
})),
};
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
};
if (data.errcode !== 0) {
throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`);
}
this.context.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length}`);
}
/** 查询指定用户的排班信息7天内最多50人 */
async queryScheduleByUsers(
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
): Promise<DingTalkScheduleResult[]> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
op_user_id: opUserId,
userids: userIds.join(','),
from_date_time: fromDate,
to_date_time: toDate,
}),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: Array<{
userid: string; work_date: string; shift_id: number;
is_rest: string; check_type: string; plan_check_time: string;
group_id: number; id: number;
}>;
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`);
}
return (data.result ?? []).map((r) => ({
userid: r.userid,
work_date: r.work_date,
shift_id: r.shift_id,
is_rest: r.is_rest,
check_type: r.check_type,
plan_check_time: r.plan_check_time,
group_id: r.group_id,
id: r.id,
}));
}
}

View File

@@ -0,0 +1,107 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type {
DingTalkServiceContext,
DingTalkShiftParams,
DingTalkShiftSummary,
} from './dingtalk.types';
export class DingTalkShiftClient {
constructor(private readonly context: DingTalkServiceContext) {}
/** 创建或修改班次。id 不传=创建,传了=修改 */
async upsertShift(params: DingTalkShiftParams): Promise<number> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
const body: Record<string, unknown> = {
op_user_id: params.owner || 'manager',
shift: {
name: params.name,
owner: params.owner,
sections: params.sections.map((s) => ({
times: s.times.map((t) => ({
check_type: t.check_type,
across: t.across,
check_time: t.check_time,
begin_min: t.begin_min ?? -1,
end_min: t.end_min ?? -1,
free_check: t.free_check ?? false,
})),
})),
setting: params.setting
? {
is_flexible: params.setting.is_flexible ?? false,
serious_late_minutes: params.setting.serious_late_minutes ?? -1,
absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1,
}
: undefined,
},
};
if (params.id) (body.shift as Record<string, unknown>).id = params.id;
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: { id: number; name: string };
};
if (data.errcode !== 0) {
throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`);
}
this.context.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`);
return data.result!.id;
}
/** 查询所有班次摘要每页最多200条 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.context.getAccessToken();
const all: DingTalkShiftSummary[] = [];
let cursor = 0;
let hasMore = true;
while (hasMore) {
await this.context.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, cursor }),
},
);
const data = (await res.json()) as {
errcode: number;
errmsg: string;
result?: {
cursor?: number;
has_more?: boolean;
result?: Array<{ id: number; name: string }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
}
const page = data.result;
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
hasMore = page?.has_more ?? false;
if (hasMore) {
if (page?.cursor === undefined || page.cursor === cursor) {
throw new Error('钉钉查询班次失败: 分页游标无效');
}
cursor = page.cursor;
}
}
return all;
}
}

View File

@@ -0,0 +1,192 @@
import { Logger } from '@nestjs/common';
export interface DingTalkCredentials {
appKey: string;
appSecret: string;
}
export interface DingTalkUserListResponse {
errcode: number;
errmsg: string;
result: {
has_more: boolean;
next_cursor?: number;
list: Array<{
userid: string;
name: string;
mobile: string;
dept_id_list: number[];
}>;
};
}
export function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse {
if (!value || typeof value !== 'object' || !('errcode' in value)) return false;
if (typeof value.errcode !== 'number') return false;
if ('errmsg' in value && typeof value.errmsg !== 'string') return false;
if (!('result' in value) || !value.result || typeof value.result !== 'object') {
return value.errcode !== 0;
}
if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false;
if (!('list' in value.result) || !Array.isArray(value.result.list)) return false;
return value.result.list.every(
(item: Record<string, unknown>) =>
item &&
typeof item === 'object' &&
'userid' in item &&
typeof item.userid === 'string' &&
'name' in item &&
typeof item.name === 'string' &&
'mobile' in item &&
typeof item.mobile === 'string' &&
'dept_id_list' in item &&
Array.isArray(item.dept_id_list) &&
item.dept_id_list.every((id) => typeof id === 'number'),
);
}
/** 钉钉打卡结果 — 对齐 dws attendance check result */
export interface DingTalkAttendanceResult {
userId: string;
userName: string;
workDate: string;
timeResult: string;
locationResult: string;
planCheckTime: string;
actualCheckTime: string;
checkId: string;
checkType: string;
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
sourceType: string;
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
deviceName?: string;
deviceId?: string;
}
// ── 组织架构 API 类型 ──
export interface DingTalkDeptListResponse {
errcode: number;
result?: Array<{ dept_id: number; name: string; parent_id: number }>;
}
export interface DingTalkDeptGetResponse {
errcode: number;
result?: { name: string; parent_id: number };
}
export interface OrgDeptNode {
id: number;
name: string;
parentId: number;
children: OrgDeptNode[];
}
export interface OrgDeptNodeWithUsers extends OrgDeptNode {
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
// ── 考勤排班 API 类型 ──
/** 班次卡段打卡时间 */
export interface DingTalkShiftTime {
check_type: 'OnDuty' | 'OffDuty';
across: number;
check_time: string;
begin_min?: number;
end_min?: number;
free_check?: boolean;
}
/** 班次卡段 */
export interface DingTalkShiftSection {
times: DingTalkShiftTime[];
}
/** 班次配置 */
export interface DingTalkShiftSetting {
is_flexible?: boolean;
serious_late_minutes?: number;
absenteeism_late_minutes?: number;
}
/** 创建/修改班次参数 */
export interface DingTalkShiftParams {
id?: number;
name: string;
owner?: string;
sections: DingTalkShiftSection[];
setting?: DingTalkShiftSetting;
}
/** 班次摘要(查询返回) */
export interface DingTalkShiftSummary {
id: number;
name: string;
}
/** 考勤组成员 */
export interface DingTalkGroupMember {
role: string;
type: 'StaffMember' | 'DeptMember';
user_id: string;
}
/** 创建考勤组参数 */
export interface DingTalkGroupParams {
name: string;
type: 'TURN';
owner: string;
members: DingTalkGroupMember[];
shift_ids?: number[];
enable_emp_select_class?: boolean;
disable_check_without_schedule?: boolean;
disable_check_when_rest?: boolean;
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
attendance_machine_only?: boolean;
}
/** 修改考勤组参数 */
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
id: number;
}
/** 考勤组摘要(查询返回) */
export interface DingTalkGroupSummary {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
/** 排班参数(单条) */
export interface DingTalkScheduleItem {
userid: string;
work_date: number;
shift_id: number;
is_rest?: boolean;
}
/** 排班查询结果 */
export interface DingTalkScheduleResult {
userid: string;
work_date: string;
shift_id: number;
is_rest: string;
check_type: string;
plan_check_time: string;
group_id: number;
id: number;
}
/** 子服务访问主服务状态/能力的共享上下文。 */
export interface DingTalkServiceContext {
accessToken: string | null;
accessTokenCredentialKey: string | null;
tokenExpiresAt: number;
apiRequestCount: number;
readonly logger: Logger;
isConfigured(): Promise<boolean>;
getAccessToken(): Promise<string>;
rateLimit(): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
/**
* 第三方平台官方固定 API 端点。
* 这些是钉钉 / 企业微信 / 金数据的公开固定端点,不是环境相关地址;
* 如需指向代理或沙箱环境,应通过各自服务的环境变量覆盖。
*/
// aislop-ignore-next-line: hardcoded-url -- 钉钉官方 OAuth 固定端点
export const DINGTALK_OAUTH_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/accessToken';
// aislop-ignore-next-line: hardcoded-url -- 金数据官方 API 固定端点
export const JINSHUJU_API_BASE = 'https://jinshuju.net/api/v1';
// aislop-ignore-next-line: hardcoded-url -- 企业微信官方 API 固定端点
export const WECOM_API_BASE = 'https://qyapi.weixin.qq.com';
export const WECOM_TOKEN_PATH = '/cgi-bin/gettoken';
export const WECOM_DEPARTMENT_PATH = '/cgi-bin/department/list';
export const WECOM_USER_PATH = '/cgi-bin/user/simplelist';