Files
gongxue-base/apps/server/src/attendance/attendance.service.ts
wangziqi 42d3f0e27f feat: DingTalk attendance import + integration config + expense types + UI polish
Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
2026-07-09 09:11:56 +08:00

604 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities';
import { CampusScope } from '../common/campus-scope';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
UpdateAttendanceRecordDto,
GenerateAttendanceFromSchedulesDto,
GenerateFromSchedulesDto,
} from './dto/attendance.dto';
@Injectable()
export class AttendanceService {
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(UserDingMapping)
private userDingMappingRepo: Repository<UserDingMapping>,
private readonly scope: CampusScope,
) {}
// ── Batch create attendance records ──
async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) {
throw new BadRequestException('records array must not be empty');
}
// Batch-load student departmentIds
const ids = [...new Set(dto.records.map((r) => r.studentId))];
const students = await this.studentRepo.find({ where: { id: In(ids) } });
const deptMap = new Map(students.map((s) => [s.id, s.departmentId]));
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',
});
entity.departmentId = deptMap.get(r.studentId)!;
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: 'active' },
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 = this.mapScheduleTimeToSession(sched.startTime);
for (const cs of classStudents) {
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',
});
entity.departmentId = cs.student?.departmentId ?? cls.departmentId ?? undefined;
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 mapScheduleTimeToSession(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';
}
// ── Attendance summary ──
async getSummary(query: AttendanceSummaryQueryDto) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
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 presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
return { total, present, late, absent, leave, presentRate };
}
// ── Attendance calendar ──
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 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);
// Fetch attendance records for the week
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());
}
// ── List attendance records with filters ──
async findAll(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
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, total, page, pageSize };
}
// ── Get distinct classes with attendance records ──
async getClasses() {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
const rows = await qb
.orderBy('ar.classId', 'ASC')
.getRawMany();
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
if (classIds.length === 0) return [];
const where = await this.scope.filter({ id: In(classIds) });
const classes = await this.classRepo.find({ where });
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` }));
}
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto) {
const where: any = {};
if (query.matchStatus) {
where.matchStatus = query.matchStatus;
}
return this.dingRawRepo.find({
where,
relations: ['matchedStudent'],
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
});
}
// ── 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 = '已匹配';
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: '未处理' },
});
if (unmatched.length === 0) return { matched: 0, total: 0 };
// Build dingUserId → userId map from the mapping table
const mappings = await this.userDingMappingRepo.find();
const dingToUserId = new Map<string, number>();
for (const m of mappings) {
dingToUserId.set(m.dingUserId, m.userId);
}
// Build userId → studentId map (only students linked to a user)
const students = await this.studentRepo.find({
where: { userId: In([...dingToUserId.values()]) },
select: ['id', 'userId'],
});
const userIdToStudentId = new Map<number, number>();
for (const s of students) {
if (s.userId != null) userIdToStudentId.set(s.userId, s.id);
}
let matched = 0;
for (const record of unmatched) {
const userId = dingToUserId.get(record.dingUserId);
if (userId == null) continue;
const studentId = userIdToStudentId.get(userId);
if (studentId == null) continue;
record.matchedStudentId = studentId;
record.matchStatus = '已匹配';
await this.dingRawRepo.save(record);
matched++;
}
return { matched, total: unmatched.length };
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
}) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
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');
return qb.getMany();
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds && !scopeIds.includes(record.departmentId)) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
if (dto.status !== undefined) {
record.status = dto.status;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
}
return this.attendanceRepo.save(record);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds && !scopeIds.includes(record.departmentId)) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
await this.attendanceRepo.remove(record);
return { deleted: true };
}
// ── Class-based attendance report ──
async getReport(query: AttendanceReportQueryDto) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
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 });
}
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) {
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) {
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');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
}
const records = await qb
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
.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 as any)?.name || '';
const className = (r.class as any)?.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;
}
}