fix: UI improvements and DingTalk attendance refresh for completed sessions
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:
2026-07-22 17:50:59 +08:00
parent d33c37b4b0
commit 90cc321221
14 changed files with 581 additions and 546 deletions

View File

@@ -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 {

View File

@@ -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
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
}),

View File

@@ -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();
});
});

View 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 };
}
}