forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
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, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
|
||||
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm';
|
||||
import {
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
DingAttendanceRaw,
|
||||
Class,
|
||||
Student,
|
||||
ClassSchedule,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ScheduleType,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import {
|
||||
BatchCreateAttendanceDto,
|
||||
AttendanceSummaryQueryDto,
|
||||
@@ -18,6 +25,26 @@ import {
|
||||
GenerateFromSchedulesDto,
|
||||
} from './dto/attendance.dto';
|
||||
|
||||
/** Keyed mutex serializing operations on the same attendance session. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
constructor(
|
||||
@@ -37,8 +64,13 @@ export class AttendanceService {
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private sessionMutex = new SessionMutex();
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
@@ -112,13 +144,315 @@ export class AttendanceService {
|
||||
return userIds.sort();
|
||||
}
|
||||
|
||||
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
|
||||
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||
if (schedule.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 };
|
||||
}
|
||||
|
||||
private selectDingTalkRecordsForLesson(
|
||||
records: DingAttendanceRaw[],
|
||||
lessonDate: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
): DingAttendanceRaw[] {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
|
||||
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
|
||||
const windowStart = start - 3 * 60 * 60 * 1000;
|
||||
const windowEnd = end + 3 * 60 * 60 * 1000;
|
||||
const timed = records.filter((record) => {
|
||||
const time = record.checkInTime ?? record.checkOutTime;
|
||||
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
|
||||
});
|
||||
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
|
||||
}
|
||||
|
||||
private mapDingTalkStatus(records: DingAttendanceRaw[]): string {
|
||||
const results = new Set(records.map((record) => record.timeResult?.toLowerCase()));
|
||||
if (results.has('late') || results.has('seriouslate')) return 'late';
|
||||
if (
|
||||
results.has('notsigned') ||
|
||||
results.has('absenteeism') ||
|
||||
results.has('absent')
|
||||
) {
|
||||
return 'absent';
|
||||
}
|
||||
if (results.has('leave') || results.has('vacation')) return 'leave';
|
||||
if (results.has('normal')) return 'present';
|
||||
return 'pending';
|
||||
}
|
||||
async createLessonAttendanceFromDingTalk(
|
||||
scheduleId: number,
|
||||
lessonDate: string,
|
||||
userId: number,
|
||||
) {
|
||||
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
|
||||
const now = new Date();
|
||||
const today = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
|
||||
if (lessonDate === today) {
|
||||
const [hour, minute] = schedule.startTime.split(':').map(Number);
|
||||
const startMinute = hour * 60 + minute;
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
if (currentMinute < startMinute) {
|
||||
throw new BadRequestException('课程尚未开始,不能拉取考勤');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.attendanceSessionRepo.findOne({
|
||||
where: { scheduleId, lessonDate },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'completed') {
|
||||
const records = await this.attendanceRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { schedule, session: existing, records };
|
||||
}
|
||||
|
||||
// Refresh in_progress session from latest DingTalk data
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
||||
const existingRecords = await recordRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
relations: ['student'],
|
||||
});
|
||||
const studentsById = new Map(
|
||||
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
|
||||
);
|
||||
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
|
||||
|
||||
const updatedRecords = existingRecords.map((record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manually corrected records.
|
||||
if (record.source !== 'dingtalk') return record;
|
||||
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(record.studentId) ?? [],
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
record.status = this.mapDingTalkStatus(raw);
|
||||
record.remark = raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : null;
|
||||
return record;
|
||||
});
|
||||
for (const classStudent of classStudents) {
|
||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
updatedRecords.push(
|
||||
recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: schedule.classId!,
|
||||
scheduleId,
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw),
|
||||
source: 'dingtalk',
|
||||
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const saved = await recordRepo.save(updatedRecords);
|
||||
return { schedule, session: existing, records: saved };
|
||||
});
|
||||
}
|
||||
|
||||
// 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!, lessonDate);
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
relations: ['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: existingRecords };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const records = classStudents.map((classStudent) => {
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: schedule.classId!,
|
||||
scheduleId,
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw),
|
||||
source: 'dingtalk',
|
||||
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
|
||||
});
|
||||
});
|
||||
const saved = await recordRepo.save(records);
|
||||
return { schedule, session, records: saved };
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchDingTalkRawByStudent(
|
||||
classId: number,
|
||||
lessonDate: string,
|
||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
if (classStudents.length === 0) return new Map();
|
||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||
const rawRecords = await this.dingRawRepo.find({
|
||||
where: {
|
||||
attendanceDate: lessonDate,
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findAttendanceSession(id: number) {
|
||||
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
|
||||
if (!session) throw new NotFoundException('课程考勤场次不存在');
|
||||
return session;
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
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,
|
||||
@@ -209,7 +543,9 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Generate attendance records from schedules (optional date range, defaults to current week) ──
|
||||
async generateFromSchedules(dto: GenerateFromSchedulesDto): Promise<{ count: number; records: AttendanceRecord[] }> {
|
||||
async generateFromSchedules(
|
||||
dto: GenerateFromSchedulesDto,
|
||||
): Promise<{ count: number; records: AttendanceRecord[] }> {
|
||||
const { classId, startDate, endDate } = dto;
|
||||
|
||||
// Default to current week (Monday–Sunday)
|
||||
@@ -233,7 +569,6 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private mapScheduleTimeToSession(startTime: string): string {
|
||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||
if (hour < 8) return 'morning_reading';
|
||||
@@ -243,14 +578,15 @@ export class AttendanceService {
|
||||
return 'night_check';
|
||||
}
|
||||
|
||||
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0)
|
||||
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
@@ -337,27 +673,32 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── List attendance records with filters ──
|
||||
async findAll(query: {
|
||||
classId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}, accessibleClassIds?: number[]) {
|
||||
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');
|
||||
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) {
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
@@ -391,7 +732,6 @@ export class AttendanceService {
|
||||
.select('DISTINCT ar.classId', 'classId')
|
||||
.where('ar.classId IS NOT NULL');
|
||||
|
||||
|
||||
const rows = accessibleClassIds
|
||||
? accessibleClassIds.map((classId) => ({ classId }))
|
||||
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
|
||||
@@ -432,7 +772,9 @@ export class AttendanceService {
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { studentId: In(studentIds) },
|
||||
});
|
||||
const dingUserIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||
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 });
|
||||
}
|
||||
@@ -488,22 +830,23 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Export all attendance records with filters (no pagination) ──
|
||||
async findAllForExport(query: {
|
||||
classId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
}, accessibleClassIds?: number[]) {
|
||||
async findAllForExport(
|
||||
query: {
|
||||
classId?: 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');
|
||||
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
@@ -528,34 +871,85 @@ export class AttendanceService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
async update(id: number, dto: UpdateAttendanceRecordDto) {
|
||||
async findAttendanceRecord(id: number) {
|
||||
const record = await this.attendanceRepo.findOne({ where: { id } });
|
||||
if (!record) {
|
||||
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);
|
||||
return 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`);
|
||||
// ── 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';
|
||||
}
|
||||
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);
|
||||
|
||||
await this.attendanceRepo.remove(record);
|
||||
return { deleted: true };
|
||||
// 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';
|
||||
}
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Class-based attendance report ──
|
||||
@@ -569,8 +963,7 @@ export class AttendanceService {
|
||||
.addSelect('COUNT(*)', 'count');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
@@ -585,14 +978,17 @@ export class AttendanceService {
|
||||
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;
|
||||
}>();
|
||||
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;
|
||||
@@ -637,9 +1033,10 @@ export class AttendanceService {
|
||||
.leftJoinAndSelect('a.student', 'student')
|
||||
.leftJoinAndSelect('a.class', 'class');
|
||||
|
||||
|
||||
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
|
||||
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] });
|
||||
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 });
|
||||
@@ -650,11 +1047,15 @@ export class AttendanceService {
|
||||
.getMany();
|
||||
|
||||
const alerts: Array<{
|
||||
studentId: number; studentName: string; className: string;
|
||||
type: string; count: number; lastDate: string;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
type: string;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
}> = [];
|
||||
|
||||
let current: typeof alerts[0] | null = null;
|
||||
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 || '';
|
||||
@@ -664,10 +1065,17 @@ export class AttendanceService {
|
||||
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 };
|
||||
current = {
|
||||
studentId: r.studentId,
|
||||
studentName: name,
|
||||
className,
|
||||
type: status,
|
||||
count: 1,
|
||||
lastDate: r.attendanceDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (current && current.count >= threshold) alerts.push(current);
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user