feat: improve attendance scheduling and API validation

This commit is contained in:
2026-07-14 23:12:14 +08:00
parent c75a08affe
commit e45da7f998
33 changed files with 869 additions and 297 deletions

View File

@@ -20,6 +20,14 @@ const createService = () => {
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const attendanceService = {
getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => {
if (targetSchedule.endTime > targetSchedule.startTime) {
return { startDate: lessonDate, endDate: lessonDate };
}
const next = new Date(`${lessonDate}T00:00:00.000Z`);
next.setUTCDate(next.getUTCDate() + 1);
return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) };
}),
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({

View File

@@ -110,9 +110,12 @@ export class AttendanceSettlementService {
schedule.teacherId,
schedule.classId,
);
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
schedule,
lessonDate,
);
const imported = await this.importService.importFromDingTalk({
startDate: lessonDate,
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
...importRange,
userIds,
autoMatch: true,
userId: schedule.teacherId,

View File

@@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => {
assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(),
getTeacherClassDingUserIds: jest.fn(),
getLessonAttendanceImportDateRange: jest.fn().mockImplementation(
(_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }),
),
batchCreate: jest.fn(),
generateFromSchedules: jest.fn(),
findAttendanceRecord: jest.fn(),

View File

@@ -13,6 +13,7 @@ import {
Res,
BadRequestException,
ForbiddenException,
ParseIntPipe,
} from '@nestjs/common';
import { Observable, filter } from 'rxjs';
import type { Request as ExpressRequest, Response } from 'express';
@@ -27,6 +28,7 @@ import {
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
AttendanceAlertsQueryDto,
UpdateAttendanceRecordDto,
GenerateFromSchedulesDto,
LessonAttendanceQueryDto,
@@ -93,11 +95,11 @@ export class AttendanceController {
@Get('attendance-lessons/schedules/:scheduleId')
@RequirePermission('attendance:view')
async getLessonAttendance(
@Param('scheduleId') scheduleId: string,
@Param('scheduleId', ParseIntPipe) scheduleId: number,
@Query() query: LessonAttendanceQueryDto,
@Request() req: { user: RequestUser },
) {
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
const result = await this.service.getLessonAttendance(scheduleId, query.date);
await this.assertClassAccess(req, result.schedule.classId!);
return result;
}
@@ -105,26 +107,29 @@ export class AttendanceController {
@Post('attendance-lessons/schedules/:scheduleId/pull')
@RequirePermission('attendance:create')
async pullLessonAttendance(
@Param('scheduleId') scheduleId: string,
@Param('scheduleId', ParseIntPipe) scheduleId: number,
@Body() dto: StartLessonAttendanceDto,
@Request() req: { user: RequestUser },
) {
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
await this.assertClassAccess(req, schedule.schedule.classId!);
const importClassIds = await this.service.getTeacherClassDingUserIds(
req.user.id,
schedule.schedule.classId!,
this.canManageAllAttendance(req),
);
const importRange = this.service.getLessonAttendanceImportDateRange(
schedule.schedule,
dto.date,
);
const importResult = await this.importService.importFromDingTalk({
startDate: dto.date,
endDate: dto.date,
...importRange,
userIds: importClassIds,
autoMatch: true,
userId: req.user.id,
});
const result = await this.service.createLessonAttendanceFromDingTalk(
+scheduleId,
scheduleId,
dto.date,
req.user.id,
);
@@ -143,18 +148,18 @@ export class AttendanceController {
@Post('attendance-lessons/:sessionId/complete')
@RequirePermission('attendance:create')
async completeLessonAttendance(
@Param('sessionId') sessionId: string,
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req: { user: RequestUser },
) {
const session = await this.service.findAttendanceSession(+sessionId);
const session = await this.service.findAttendanceSession(sessionId);
await this.assertClassAccess(req, session.classId);
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考勤管理',
action: '完成课程点名',
targetId: +sessionId,
targetId: sessionId,
targetType: 'attendanceSession',
detail: `班级${session.classId} 日期${session.lessonDate}`,
});
@@ -278,23 +283,23 @@ export class AttendanceController {
@Put('attendance-records/:id')
@RequirePermission('attendance:edit', 'attendance:self-edit')
async update(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateAttendanceRecordDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id);
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);
const result = await this.service.update(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤管理',
action: '编辑考勤记录',
targetId: +id,
targetId: id,
targetType: 'attendanceRecord',
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
ipAddress,
@@ -306,20 +311,20 @@ export class AttendanceController {
// ── Delete a single attendance record ──
@Delete('attendance-records/:id')
@RequirePermission('attendance:edit', 'attendance:self-edit')
async remove(@Param('id') id: string, @Request() req: any) {
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id);
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);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤管理',
action: '删除考勤记录',
targetId: +id,
targetId: id,
targetType: 'attendanceRecord',
detail: `删除考勤记录 ${id}`,
ipAddress,
@@ -369,18 +374,18 @@ export class AttendanceController {
@Post('ding-attendance-raw/:id/match')
@RequirePermission('attendance:edit')
async matchDingRecord(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: MatchDingRecordDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.matchDingRecord(+id, dto);
const result = await this.service.matchDingRecord(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤管理',
action: '匹配考勤记录',
targetId: +id,
targetId: id,
targetType: 'dingAttendanceRaw',
detail: `匹配到学生 ${dto.studentId}`,
ipAddress,
@@ -458,12 +463,11 @@ export class AttendanceController {
@RequirePermission('attendance:view')
async getAlerts(
@Request() req: { user: RequestUser },
@Query('days') days?: string,
@Query('threshold') threshold?: string,
@Query() query: AttendanceAlertsQueryDto,
) {
return this.service.getAlerts(
days ? +days : 14,
threshold ? +threshold : 3,
query.days ?? 14,
query.threshold ?? 3,
await this.getAccessibleClassIds(req),
);
}

View File

@@ -59,6 +59,7 @@ const endedSchedule = {
subject: '\u6570\u5B66',
status: 'active',
scheduleType: 'INTERNAL',
attendanceAdvanceMinutes: 30,
};
describe('AttendanceService \u2014 DingTalk course attendance', () => {
@@ -186,6 +187,40 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
]);
});
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
{ 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') },
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
expect.objectContaining({ studentId: 3, status: 'absent' }),
]);
});
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' });
});
it('creates local attendance after the lesson starts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();

View File

@@ -175,24 +175,45 @@ export class AttendanceService {
return { schedule, session, records };
}
private getLessonAttendanceWindow(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { start: number; end: number; dateFrom: string; dateTo: string } {
const startMinuteOfDay = this.toMinutes(schedule.startTime);
const endMinuteOfDay = this.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 ? this.shiftDate(lessonDate, -1) : lessonDate,
dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
};
}
getLessonAttendanceImportDateRange(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { startDate: string; endDate: string } {
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
return { startDate: window.dateFrom, endDate: window.dateTo };
}
private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[],
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
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 window = this.getLessonAttendanceWindow(schedule, lessonDate);
return records.filter((record) => {
// 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
return time && time.getTime() >= window.start && time.getTime() <= window.end;
});
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
}
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
@@ -291,7 +312,7 @@ export class AttendanceService {
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 rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
@@ -312,9 +333,8 @@ export class AttendanceService {
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate,
schedule.startTime,
schedule.endTime,
);
record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata(
@@ -333,9 +353,8 @@ export class AttendanceService {
if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
schedule.startTime,
schedule.endTime,
);
updatedRecords.push(
recordRepo.create({
@@ -377,7 +396,7 @@ export class AttendanceService {
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 rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
@@ -421,9 +440,8 @@ export class AttendanceService {
const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
schedule.startTime,
schedule.endTime,
);
return recordRepo.create({
studentId: classStudent.studentId,
@@ -460,6 +478,7 @@ export class AttendanceService {
private async fetchDingTalkRawByStudent(
classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({
@@ -467,9 +486,10 @@ export class AttendanceService {
});
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: lessonDate,
attendanceDate: Between(window.dateFrom, window.dateTo),
matchedStudentId: In(studentIds),
},
});
@@ -651,6 +671,17 @@ export class AttendanceService {
});
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + 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 mapScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';

View File

@@ -7,6 +7,9 @@ import {
IsIn,
ValidateNested,
IsNotEmpty,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
@@ -44,6 +47,7 @@ export class AttendanceRecordItem {
export class BatchCreateAttendanceDto {
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => AttendanceRecordItem)
records: AttendanceRecordItem[];
@@ -96,11 +100,14 @@ export class QueryDingRawDto {
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(200)
@Type(() => Number)
pageSize?: number;
}
@@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto {
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(200)
@Type(() => Number)
pageSize?: number;
}
@@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto {
remark?: string;
}
export class AttendanceAlertsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
days?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
threshold?: number;
}
export class AttendanceReportQueryDto {
@IsOptional()
@IsInt()