fix: UI improvements and DingTalk attendance refresh for completed sessions
All checks were successful
CI / check (pull_request) Successful in 1m33s
All checks were successful
CI / check (pull_request) Successful in 1m33s
- DatePicker editors open immediately on double-click - Student archive drawer hides empty studentNo parentheses - Student add/edit form uses 2-column grid layout - Table horizontal scrollbars show only on hover/focus - Remove fake 近7日趋势 stats from attendance student detail - Allow DingTalk refresh to update completed attendance sessions (late-arriving punches can now change absent → present) - Switch dev command from concurrently to turbo run dev - Remove concurrently/wait-on dependencies
This commit is contained in:
@@ -252,8 +252,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a completed session as-is without refreshing', async () => {
|
||||
const { service, attendanceRepo, scheduleRepo, sessionRepo } = createService();
|
||||
it('refreshes a completed session when DingTalk punches arrive late', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
@@ -262,13 +263,27 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'completed',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([{ id: 1, attendanceSessionId: 90, status: 'present' }]);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present', remark: null }),
|
||||
]);
|
||||
expect(sessionRepo.save).not.toHaveBeenCalled();
|
||||
expect(attendanceRepo.save).not.toHaveBeenCalled();
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('refreshes an in_progress session from latest DingTalk data', async () => {
|
||||
@@ -616,13 +631,15 @@ 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 } = 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',
|
||||
});
|
||||
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: [] });
|
||||
} finally {
|
||||
|
||||
@@ -391,23 +391,21 @@ export class AttendanceService {
|
||||
});
|
||||
|
||||
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: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
|
||||
}
|
||||
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
|
||||
if (
|
||||
existing.status !== 'in_progress' &&
|
||||
existing.status !== 'completed' &&
|
||||
!(finalize && existing.status === 'settling')
|
||||
) {
|
||||
throw new BadRequestException('课程考勤正在结算');
|
||||
}
|
||||
|
||||
// Refresh in_progress session from latest DingTalk data
|
||||
// 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' },
|
||||
@@ -433,7 +431,7 @@ export class AttendanceService {
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||
record.status = this.mapDingTalkStatus(raw, effectiveFinalize);
|
||||
Object.assign(record, this.getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
@@ -441,7 +439,7 @@ export class AttendanceService {
|
||||
));
|
||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? null
|
||||
: finalize
|
||||
: effectiveFinalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果';
|
||||
return record;
|
||||
@@ -462,7 +460,7 @@ export class AttendanceService {
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: lessonSessionKey,
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
status: this.mapDingTalkStatus(raw, effectiveFinalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
raw,
|
||||
@@ -471,7 +469,7 @@ export class AttendanceService {
|
||||
),
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
: effectiveFinalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果',
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { LessonAttendanceSyncService } from './lesson-attendance-sync.service';
|
||||
|
||||
const lesson = {
|
||||
schedule: {
|
||||
id: 4,
|
||||
classId: 8,
|
||||
startTime: '22:00',
|
||||
endTime: '01:00',
|
||||
attendanceAdvanceMinutes: 30,
|
||||
},
|
||||
session: null,
|
||||
records: [],
|
||||
};
|
||||
|
||||
const createService = () => {
|
||||
const attendanceService = {
|
||||
getLessonAttendance: jest.fn().mockResolvedValue(lesson),
|
||||
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1', 'ding-2']),
|
||||
getLessonAttendanceImportDateRange: jest.fn().mockReturnValue({
|
||||
startDate: '2026-07-11',
|
||||
endDate: '2026-07-12',
|
||||
}),
|
||||
createLessonAttendanceFromDingTalk: jest.fn().mockResolvedValue({
|
||||
...lesson,
|
||||
session: { id: 90, status: 'in_progress' },
|
||||
}),
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
imported: 3,
|
||||
skipped: 0,
|
||||
matched: 2,
|
||||
errors: [],
|
||||
duration: 10,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
service: new LessonAttendanceSyncService(attendanceService as never, importService as never),
|
||||
attendanceService,
|
||||
importService,
|
||||
};
|
||||
};
|
||||
|
||||
describe('LessonAttendanceSyncService', () => {
|
||||
it('owns the complete refresh recipe and returns attendance with import statistics', async () => {
|
||||
const { service, attendanceService, importService } = createService();
|
||||
|
||||
const result = await service.syncLesson({
|
||||
scheduleId: 4,
|
||||
lessonDate: '2026-07-11',
|
||||
actorId: 21,
|
||||
canManageAll: false,
|
||||
mode: 'refresh',
|
||||
});
|
||||
|
||||
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(
|
||||
21,
|
||||
8,
|
||||
false,
|
||||
'2026-07-11',
|
||||
);
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-11',
|
||||
endDate: '2026-07-12',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
4,
|
||||
'2026-07-11',
|
||||
21,
|
||||
false,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
attendance: { session: { id: 90 } },
|
||||
imported: 3,
|
||||
matched: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('finalizes only after a complete import', async () => {
|
||||
const { service, attendanceService } = createService();
|
||||
|
||||
await service.syncLesson({
|
||||
scheduleId: 4,
|
||||
lessonDate: '2026-07-11',
|
||||
actorId: 21,
|
||||
canManageAll: true,
|
||||
mode: 'finalize',
|
||||
});
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
4,
|
||||
'2026-07-11',
|
||||
21,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects partial imports before changing lesson attendance', async () => {
|
||||
const { service, attendanceService, importService } = createService();
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
imported: 1,
|
||||
matched: 1,
|
||||
errors: ['one batch failed'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.syncLesson({
|
||||
scheduleId: 4,
|
||||
lessonDate: '2026-07-11',
|
||||
actorId: 21,
|
||||
canManageAll: false,
|
||||
mode: 'refresh',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
apps/server/src/attendance/lesson-attendance-sync.service.ts
Normal file
53
apps/server/src/attendance/lesson-attendance-sync.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
export type LessonAttendanceSyncMode = 'refresh' | 'finalize';
|
||||
|
||||
export interface LessonAttendanceSyncOptions {
|
||||
scheduleId: number;
|
||||
lessonDate: string;
|
||||
actorId: number;
|
||||
canManageAll: boolean;
|
||||
mode: LessonAttendanceSyncMode;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LessonAttendanceSyncService {
|
||||
constructor(
|
||||
private readonly attendanceService: AttendanceService,
|
||||
private readonly importService: AttendanceImportService,
|
||||
) {}
|
||||
|
||||
async syncLesson(options: LessonAttendanceSyncOptions) {
|
||||
const { scheduleId, lessonDate, actorId, canManageAll, mode } = options;
|
||||
const { schedule } = await this.attendanceService.getLessonAttendance(scheduleId, lessonDate);
|
||||
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
|
||||
actorId,
|
||||
schedule.classId!,
|
||||
canManageAll,
|
||||
lessonDate,
|
||||
);
|
||||
const dateRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const imported = await this.importService.importFromDingTalk({
|
||||
...dateRange,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
userId: actorId,
|
||||
});
|
||||
if (!imported.success || imported.errors.length > 0) {
|
||||
throw new BadRequestException(imported.errors.join('; ') || '钉钉考勤拉取失败');
|
||||
}
|
||||
|
||||
const attendance = await this.attendanceService.createLessonAttendanceFromDingTalk(
|
||||
scheduleId,
|
||||
lessonDate,
|
||||
actorId,
|
||||
mode === 'finalize',
|
||||
);
|
||||
return { attendance, imported: imported.imported, matched: imported.matched };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user