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:
@@ -125,4 +125,101 @@ describe('AttendanceImportService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('auto-matches previously imported duplicate records', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
|
||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||
|
||||
const result = await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||
expect(result.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('scopes SSE progress events to the importing user', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-1',
|
||||
userName: '李四',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T09:00:00.000Z',
|
||||
checkId: 'check-2',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
|
||||
const events: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = service.progress$.subscribe((event) => {
|
||||
events.push({ phase: event.phase, userId: event.userId });
|
||||
});
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
userId: 42,
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
for (const event of events) {
|
||||
expect(event.userId).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits userId undefined when import has no HTTP user', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-2',
|
||||
userName: '王五',
|
||||
workDate: '2026-07-02',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-02T10:00:00.000Z',
|
||||
checkId: 'check-3',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
|
||||
const events: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = service.progress$.subscribe((event) => {
|
||||
events.push({ phase: event.phase, userId: event.userId });
|
||||
});
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-02',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-2'],
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
for (const event of events) {
|
||||
expect(event.userId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export class AttendanceImportService {
|
||||
/** RxJS Subject emitting live progress during import */
|
||||
private progressSubject = new Subject<ImportProgressEvent>();
|
||||
private isRunning = false;
|
||||
/** ID of the user who triggered the current import (for SSE scoping) */
|
||||
private importingUserId?: number;
|
||||
constructor(
|
||||
@InjectRepository(DingAttendanceRaw)
|
||||
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@@ -60,7 +62,6 @@ export class AttendanceImportService {
|
||||
* 1. Fetch attendance results from DingTalk (paginated)
|
||||
* 2. Parse and validate each record
|
||||
* 3. Deduplicate by `dingId` (unique in DB)
|
||||
* 4. Batch-save to `ding_attendance_raw`
|
||||
* 5. Optionally auto-match to students by name
|
||||
*/
|
||||
async importFromDingTalk(params: {
|
||||
@@ -68,6 +69,8 @@ export class AttendanceImportService {
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
autoMatch?: boolean;
|
||||
/** ID of the HTTP user triggering the import (for SSE event scoping) */
|
||||
userId?: number;
|
||||
}): Promise<ImportResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('An import is already in progress');
|
||||
@@ -75,6 +78,7 @@ export class AttendanceImportService {
|
||||
|
||||
const startedAt = Date.now();
|
||||
this.isRunning = true;
|
||||
this.importingUserId = params.userId;
|
||||
|
||||
// Safety timeout: auto-reset isRunning after 30 minutes in case of
|
||||
// an unhandled exception that bypasses the finally block (extremely rare).
|
||||
@@ -106,6 +110,7 @@ export class AttendanceImportService {
|
||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
||||
|
||||
if (newRecords.length === 0) {
|
||||
if (params.autoMatch) matched = await this.autoMatchUnmatched();
|
||||
this.emit('complete', imported + skipped, total, 'Nothing new to import');
|
||||
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
|
||||
}
|
||||
@@ -147,6 +152,7 @@ export class AttendanceImportService {
|
||||
} finally {
|
||||
clearTimeout(safetyTimer);
|
||||
this.isRunning = false;
|
||||
this.importingUserId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +318,6 @@ export class AttendanceImportService {
|
||||
message: string,
|
||||
error?: string,
|
||||
): void {
|
||||
this.progressSubject.next({ phase, current, total, message, error });
|
||||
this.progressSubject.next({ phase, current, total, message, error, userId: this.importingUserId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
@@ -11,6 +12,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn(),
|
||||
progress$: undefined as unknown,
|
||||
};
|
||||
const logService = {
|
||||
log: jest.fn(),
|
||||
@@ -52,6 +54,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
endDate: '2026-07-10',
|
||||
userIds: ['ding-today'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -64,12 +67,12 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
);
|
||||
|
||||
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,12 +147,223 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
userId: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceController — write data scope', () => {
|
||||
const attendanceService = {
|
||||
assertClassAccess: jest.fn(),
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
getTeacherClassDingUserIds: jest.fn(),
|
||||
batchCreate: jest.fn(),
|
||||
generateFromSchedules: jest.fn(),
|
||||
findAttendanceRecord: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
getLessonAttendance: jest.fn(),
|
||||
createLessonAttendanceFromDingTalk: jest.fn(),
|
||||
findAttendanceSession: jest.fn(),
|
||||
completeLessonAttendance: jest.fn(),
|
||||
};
|
||||
const importService = { importFromDingTalk: jest.fn() };
|
||||
const logService = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const authzService = { can: jest.fn().mockReturnValue(false) };
|
||||
const req = {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false, roles: ['teacher'] },
|
||||
headers: {},
|
||||
};
|
||||
let controller: AttendanceController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
authzService.can.mockReturnValue(false);
|
||||
controller = new AttendanceController(
|
||||
attendanceService as unknown as AttendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService as unknown as OperationLogsService,
|
||||
authzService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks every distinct class in a manual attendance batch', async () => {
|
||||
attendanceService.batchCreate.mockResolvedValue({ count: 2, records: [] });
|
||||
const dto = {
|
||||
records: [
|
||||
{
|
||||
studentId: 1,
|
||||
classId: 8,
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
status: 'present',
|
||||
},
|
||||
{
|
||||
studentId: 2,
|
||||
classId: 9,
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
status: 'present',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await controller.batchCreate(dto, req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
|
||||
});
|
||||
|
||||
it('rejects classless manual attendance records for a class-scoped teacher', async () => {
|
||||
attendanceService.batchCreate.mockResolvedValue({ count: 1, records: [] });
|
||||
const dto = {
|
||||
records: [
|
||||
{
|
||||
studentId: 1,
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
status: 'present',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(controller.batchCreate(dto, req as never)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks the schedule class before starting and completing lesson attendance', async () => {
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
matched: 1,
|
||||
errors: [],
|
||||
duration: 10,
|
||||
});
|
||||
attendanceService.getLessonAttendance.mockResolvedValue({
|
||||
schedule: { id: 4, classId: 8 },
|
||||
session: null,
|
||||
records: [],
|
||||
});
|
||||
attendanceService.createLessonAttendanceFromDingTalk.mockResolvedValue({
|
||||
schedule: { id: 4, classId: 8 },
|
||||
session: { id: 90, classId: 8 },
|
||||
records: [],
|
||||
});
|
||||
attendanceService.findAttendanceSession.mockResolvedValue({ id: 90, classId: 8 });
|
||||
attendanceService.completeLessonAttendance.mockResolvedValue({
|
||||
session: { id: 90, classId: 8, status: 'completed' },
|
||||
records: [],
|
||||
});
|
||||
|
||||
await controller.pullLessonAttendance('4', { date: '2026-07-11' }, req as never);
|
||||
await controller.completeLessonAttendance('90', req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
|
||||
});
|
||||
|
||||
it('checks class access before generating attendance from schedules', async () => {
|
||||
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
|
||||
|
||||
await controller.generateFromSchedules({ classId: 8 }, req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
});
|
||||
|
||||
it('checks the attendance record owning class before update and delete', async () => {
|
||||
attendanceService.findAttendanceRecord.mockResolvedValue({ id: 4, classId: 8 });
|
||||
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
|
||||
attendanceService.remove.mockResolvedValue({ deleted: true });
|
||||
|
||||
await controller.update('4', { status: 'late' }, req as never);
|
||||
await controller.remove('4', req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceController — SSE progress scoping', () => {
|
||||
let progressSubject: Subject<{ phase: string; userId?: number }>;
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn(),
|
||||
get progress$() { return progressSubject.asObservable(); },
|
||||
};
|
||||
const attendanceService = {} as unknown as AttendanceService;
|
||||
const logService = {} as unknown as OperationLogsService;
|
||||
const authzService = {} as never;
|
||||
|
||||
let controller: AttendanceController;
|
||||
|
||||
beforeEach(() => {
|
||||
progressSubject = new Subject<{ phase: string; userId?: number }>();
|
||||
controller = new AttendanceController(
|
||||
attendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService,
|
||||
authzService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
progressSubject.complete();
|
||||
});
|
||||
|
||||
it('delivers events matching the requesting user id', () => {
|
||||
const received: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = controller.importProgressStream({
|
||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||
} as never).subscribe({
|
||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||
});
|
||||
|
||||
progressSubject.next({ phase: 'fetching', userId: 42 });
|
||||
progressSubject.next({ phase: 'complete', userId: 42 });
|
||||
|
||||
sub.unsubscribe();
|
||||
expect(received).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('excludes events from a different user', () => {
|
||||
const received: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = controller.importProgressStream({
|
||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||
} as never).subscribe({
|
||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||
});
|
||||
|
||||
progressSubject.next({ phase: 'fetching', userId: 99 }); // different user
|
||||
progressSubject.next({ phase: 'complete', userId: 42 });
|
||||
|
||||
sub.unsubscribe();
|
||||
// Only the matching event should arrive
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].userId).toBe(42);
|
||||
});
|
||||
|
||||
it('excludes events with undefined userId (non-HTTP callers)', () => {
|
||||
const received: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = controller.importProgressStream({
|
||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||
} as never).subscribe({
|
||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||
});
|
||||
progressSubject.next({ phase: 'fetching' }); // no userId
|
||||
progressSubject.next({ phase: 'complete', userId: 42 });
|
||||
|
||||
sub.unsubscribe();
|
||||
// Only the event with matching userId should arrive
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].userId).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, filter } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
AttendanceReportQueryDto,
|
||||
UpdateAttendanceRecordDto,
|
||||
GenerateFromSchedulesDto,
|
||||
LessonAttendanceQueryDto,
|
||||
StartLessonAttendanceDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -88,11 +90,94 @@ export class AttendanceController {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
@Get('attendance-lessons/schedules/:scheduleId')
|
||||
@RequirePermission('attendance:view')
|
||||
async getLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query() query: LessonAttendanceQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
|
||||
await this.assertClassAccess(req, result.schedule.classId!);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
||||
@RequirePermission('attendance:create')
|
||||
async pullLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Body() dto: StartLessonAttendanceDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
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 importResult = await this.importService.importFromDingTalk({
|
||||
startDate: dto.date,
|
||||
endDate: dto.date,
|
||||
userIds: importClassIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||
+scheduleId,
|
||||
dto.date,
|
||||
req.user.id,
|
||||
);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: schedule.session ? '查看已拉取课程考勤' : '拉取钉钉课程考勤',
|
||||
targetId: result.session.id,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `排课${scheduleId} 日期${dto.date},钉钉新增${importResult.imported}条,匹配${importResult.matched}条`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-lessons/:sessionId/complete')
|
||||
@RequirePermission('attendance:create')
|
||||
async completeLessonAttendance(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const session = await this.service.findAttendanceSession(+sessionId);
|
||||
await this.assertClassAccess(req, session.classId);
|
||||
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,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req);
|
||||
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
||||
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
|
||||
}
|
||||
const classIds = [
|
||||
...new Set(
|
||||
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
|
||||
),
|
||||
];
|
||||
for (const classId of classIds) {
|
||||
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
const result = await this.service.batchCreate(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -111,6 +196,7 @@ export class AttendanceController {
|
||||
@RequirePermission('attendance:create')
|
||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.assertClassAccess(req, dto.classId);
|
||||
const result = await this.service.generateFromSchedules(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -190,13 +276,18 @@ export class AttendanceController {
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@Put('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAttendanceRecordDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
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);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -214,9 +305,14 @@ export class AttendanceController {
|
||||
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
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);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -421,12 +517,12 @@ export class AttendanceController {
|
||||
|
||||
const startDate = dto.start ?? this.getTodayDateOnly();
|
||||
const endDate = dto.end ?? startDate;
|
||||
|
||||
const result = await this.importService.importFromDingTalk({
|
||||
startDate,
|
||||
endDate,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
await this.logService.log({
|
||||
@@ -452,17 +548,22 @@ export class AttendanceController {
|
||||
*/
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@RequirePermission('attendance:view')
|
||||
importProgressStream(): Observable<SseEvent> {
|
||||
importProgressStream(@Request() req: { user: RequestUser }): Observable<SseEvent> {
|
||||
const userId = req.user.id;
|
||||
return new Observable<SseEvent>((subscriber) => {
|
||||
const subscription = this.importService.progress$.subscribe({
|
||||
next: (event) => {
|
||||
subscriber.next({ data: JSON.stringify(event) });
|
||||
if (event.phase === 'complete' || event.phase === 'error') {
|
||||
subscriber.complete();
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => subscriber.error(err),
|
||||
});
|
||||
const subscription = this.importService.progress$
|
||||
.pipe(
|
||||
filter((event) => event.userId === userId),
|
||||
)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
subscriber.next({ data: JSON.stringify(event) });
|
||||
if (event.phase === 'complete' || event.phase === 'error') {
|
||||
subscriber.complete();
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => subscriber.error(err),
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
492
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal file
492
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
|
||||
const createService = () => {
|
||||
const attendanceRepo = {
|
||||
create: jest.fn((value: Record<string, unknown>) => value),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(async (records: unknown) => records),
|
||||
remove: jest.fn(async (record: unknown) => record),
|
||||
count: jest.fn(),
|
||||
};
|
||||
const dingRawRepo = { find: jest.fn() };
|
||||
const scheduleRepo = { findOne: jest.fn() };
|
||||
const classStudentRepo = { find: jest.fn() };
|
||||
const sessionRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
|
||||
save: jest.fn(async (value: unknown) => value),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(
|
||||
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
|
||||
const managerGetRepo = jest.fn((entity: { name: string }) => {
|
||||
if (entity.name === AttendanceSession.name) return sessionRepo;
|
||||
if (entity.name === AttendanceRecord.name) return attendanceRepo;
|
||||
return {};
|
||||
});
|
||||
return cb({ getRepository: managerGetRepo });
|
||||
},
|
||||
),
|
||||
};
|
||||
const service = new AttendanceService(
|
||||
attendanceRepo as never,
|
||||
dingRawRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
sessionRepo as never,
|
||||
dataSource as unknown as DataSource,
|
||||
);
|
||||
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, dataSource };
|
||||
};
|
||||
|
||||
const endedSchedule = {
|
||||
id: 4,
|
||||
classId: 8,
|
||||
weekDay: 6,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '\u6570\u5B66',
|
||||
status: 'active',
|
||||
scheduleType: 'INTERNAL',
|
||||
};
|
||||
|
||||
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
it('creates one course session from DingTalk results after the lesson', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
{ studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
|
||||
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(sessionRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
}),
|
||||
);
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 3, status: 'absent', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||
]);
|
||||
expect(result.records).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('returns student relations after the first pull', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ student: { id: 1, name: '张三' } }),
|
||||
]);
|
||||
expect(result.records[0].student.name).toBe('张三');
|
||||
});
|
||||
|
||||
it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Late',
|
||||
checkInTime: new Date('2026-07-11T02:00:00+08:00'),
|
||||
},
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Normal',
|
||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates local attendance after the lesson starts', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
const now = new Date();
|
||||
const weekDay = now.getDay() === 0 ? 7 : now.getDay();
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
...endedSchedule,
|
||||
weekDay,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
const today = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalled();
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a completed session as-is without refreshing', async () => {
|
||||
const { service, attendanceRepo, scheduleRepo, sessionRepo } = createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'completed',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([{ id: 1, attendanceSessionId: 90, status: 'present' }]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(sessionRepo.save).not.toHaveBeenCalled();
|
||||
expect(attendanceRepo.save).not.toHaveBeenCalled();
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes an in_progress session from latest DingTalk data', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
||||
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(sessionRepo.create).not.toHaveBeenCalled();
|
||||
expect(attendanceRepo.save).toHaveBeenCalled();
|
||||
|
||||
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
|
||||
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late' }),
|
||||
]),
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
it('restores students missing from an existing empty session', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
|
||||
expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
|
||||
]);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
it('preserves manually corrected records when refreshing an in_progress session', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
|
||||
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
|
||||
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'leave' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late' }),
|
||||
]),
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects completion when pending records exist', async () => {
|
||||
const { service, sessionRepo, attendanceRepo } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.count.mockResolvedValue(1);
|
||||
|
||||
await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('completes a pulled attendance session after teacher review', async () => {
|
||||
const { service, sessionRepo, attendanceRepo } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.count.mockResolvedValue(0);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 1, status: 'present' },
|
||||
{ id: 2, status: 'absent' },
|
||||
]);
|
||||
|
||||
const result = await service.completeLessonAttendance(90, 21);
|
||||
|
||||
expect(sessionRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
|
||||
);
|
||||
expect(result.session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('throws when querying a missing schedule attendance session', async () => {
|
||||
const { service, scheduleRepo } = createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update() marks record source as manual so refresh preserves the correction', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
// Initial state: dingtalk-sourced record
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
studentId: 1,
|
||||
attendanceSessionId: 90,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
|
||||
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
]);
|
||||
|
||||
// Step 1: update the record to absent via generic update()
|
||||
const updated = await service.update(101, { status: 'absent' });
|
||||
expect(updated.source).toBe('manual');
|
||||
|
||||
// Step 2: refresh in_progress session — manual record status must stay absent
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
|
||||
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
|
||||
][0] as Array<{ studentId: number; status: string }>;
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'present' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers from concurrent unique constraint on first session creation', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne
|
||||
.mockResolvedValueOnce(null) // first check: no existing session
|
||||
.mockResolvedValueOnce({
|
||||
// recovery: the winning session
|
||||
id: 77,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
// Simulate unique constraint on save
|
||||
sessionRepo.save.mockRejectedValueOnce(
|
||||
Object.assign(new Error('UNIQUE constraint failed'), {
|
||||
code: 'SQLITE_CONSTRAINT',
|
||||
errno: undefined,
|
||||
}),
|
||||
);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(result.session.id).toBe(77);
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('completeLessonAttendance runs inside a transaction', async () => {
|
||||
const { service, sessionRepo, attendanceRepo, dataSource } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.count.mockResolvedValue(0);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 1, status: 'present' },
|
||||
]);
|
||||
|
||||
await service.completeLessonAttendance(90, 21);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update() throws when the parent session is completed', async () => {
|
||||
const { service, attendanceRepo, sessionRepo } = createService();
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
attendanceSessionId: 90,
|
||||
status: 'present',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('remove() throws when the parent session is completed', async () => {
|
||||
const { service, attendanceRepo, sessionRepo } = createService();
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
attendanceSessionId: 90,
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update() allows modification when the parent session is in_progress', async () => {
|
||||
const { service, attendanceRepo, sessionRepo } = createService();
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
attendanceSessionId: 90,
|
||||
status: 'present',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
const result = await service.update(101, { status: 'absent' });
|
||||
expect(result.source).toBe('manual');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
@@ -9,7 +9,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
@@ -53,6 +55,8 @@ describe('AttendanceService — batchCreate', () => {
|
||||
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
|
||||
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
|
||||
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
|
||||
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -130,6 +134,8 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -203,6 +209,8 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -219,3 +227,205 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
expect(qb.take).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── Session serialization tests ──
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('AttendanceService — session serialization', () => {
|
||||
const sessionId = 100;
|
||||
const recordId = 200;
|
||||
const sessionInProgress = { id: sessionId, status: 'in_progress' };
|
||||
const sessionCompleted = { id: sessionId, status: 'completed' };
|
||||
const record: Record<string, unknown> = {
|
||||
id: recordId,
|
||||
attendanceSessionId: sessionId,
|
||||
studentId: 1,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
};
|
||||
|
||||
function makeService(
|
||||
dataSourceMock: { transaction: jest.Mock },
|
||||
attendanceRepoOverrides?: Record<string, jest.Mock>,
|
||||
) {
|
||||
const defaultAttendanceRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(record),
|
||||
find: jest.fn().mockResolvedValue([record]),
|
||||
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides };
|
||||
|
||||
return new AttendanceService(
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
dataSourceMock as never,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTxManager(sessionStatus: string) {
|
||||
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
|
||||
|
||||
const sessionRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(session),
|
||||
save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)),
|
||||
};
|
||||
|
||||
const recordRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(record),
|
||||
find: jest.fn().mockResolvedValue([record]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === AttendanceSession) return sessionRepo;
|
||||
if (entity === AttendanceRecord) return recordRepo;
|
||||
throw new Error('Unexpected entity');
|
||||
}),
|
||||
sessionRepo,
|
||||
recordRepo,
|
||||
};
|
||||
}
|
||||
|
||||
it('complete holds lock; queued update is rejected after session becomes completed', async () => {
|
||||
// For this test, the manager has a completed session
|
||||
// The complete callback is stalled, update queues and then finds completed
|
||||
const manager = makeTxManager('completed');
|
||||
const completeStall = deferred<unknown>();
|
||||
|
||||
const txMock = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => completeStall.promise)
|
||||
.mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager));
|
||||
|
||||
const svc = makeService({ transaction: txMock });
|
||||
|
||||
const completeP = svc.completeLessonAttendance(sessionId, 1);
|
||||
const updateP = svc.update(recordId, { status: 'absent' });
|
||||
|
||||
completeStall.resolve({ session: sessionCompleted, records: [record] });
|
||||
|
||||
await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] });
|
||||
await expect(updateP).rejects.toThrow(BadRequestException);
|
||||
|
||||
expect(manager.sessionRepo.findOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update holds lock; complete reads current state after update finishes', async () => {
|
||||
const manager = makeTxManager('in_progress');
|
||||
const updateStall = deferred<unknown>();
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
let transactionsRun = 0;
|
||||
|
||||
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => {
|
||||
transactionsRun++;
|
||||
if (transactionsRun === 1) {
|
||||
// update: stall
|
||||
callOrder.push('update-tx-started');
|
||||
return updateStall.promise.then((v) => {
|
||||
callOrder.push('update-tx-resolved');
|
||||
return v;
|
||||
});
|
||||
}
|
||||
// complete: actually run the callback
|
||||
callOrder.push('complete-tx-started');
|
||||
return cb(manager);
|
||||
});
|
||||
|
||||
const svc = makeService({ transaction: txMock });
|
||||
|
||||
// Start update but DON'T await — it will stall
|
||||
const updateP = svc.update(recordId, { status: 'absent' });
|
||||
|
||||
// Give update time to enter the mutex and transaction
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Start complete while update is stalled
|
||||
const completeP = svc.completeLessonAttendance(sessionId, 1);
|
||||
|
||||
// Another tick
|
||||
await Promise.resolve();
|
||||
|
||||
// Resolve update's stalled transaction
|
||||
updateStall.resolve(record);
|
||||
|
||||
const updateResult = await updateP;
|
||||
expect(updateResult).toEqual(record);
|
||||
|
||||
const completeResult = await completeP;
|
||||
|
||||
// Verify the callback was actually run correctly
|
||||
expect(callOrder).toContain('update-tx-started');
|
||||
expect(callOrder).toContain('complete-tx-started');
|
||||
|
||||
expect(completeResult).toHaveProperty('session');
|
||||
expect(completeResult).toHaveProperty('records');
|
||||
});
|
||||
|
||||
it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => {
|
||||
const noSessionRecord = { ...record, attendanceSessionId: null };
|
||||
|
||||
const overrides = {
|
||||
findOne: jest.fn().mockResolvedValue(noSessionRecord),
|
||||
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
|
||||
};
|
||||
|
||||
const txMock = jest.fn();
|
||||
const svc = makeService({ transaction: txMock }, overrides);
|
||||
|
||||
const result = await svc.update(recordId, { status: 'absent' });
|
||||
expect(result).toEqual(noSessionRecord);
|
||||
expect(txMock).not.toHaveBeenCalled();
|
||||
|
||||
const removeResult = await svc.remove(recordId);
|
||||
expect(removeResult).toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
it('complete is idempotent: returns current state when session already completed', async () => {
|
||||
const manager = makeTxManager('completed');
|
||||
|
||||
const txMock = jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
|
||||
|
||||
const svc = makeService({ transaction: txMock });
|
||||
|
||||
const result = await svc.completeLessonAttendance(sessionId, 1);
|
||||
|
||||
expect(result).toHaveProperty('session');
|
||||
expect(result).toHaveProperty('records');
|
||||
expect(manager.recordRepo.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,5 +55,33 @@ describe('DingTalkService — attendance records', () => {
|
||||
).rejects.toThrow('userIds');
|
||||
expect(global.fetch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps DingTalk work dates in China local time', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
recordresult: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 'ding-1',
|
||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||
sourceType: 'USER',
|
||||
checkType: 'OnDuty',
|
||||
timeResult: 'Normal',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const [record] = await service.fetchAttendanceResults({
|
||||
startDate: '2026-07-12',
|
||||
endDate: '2026-07-12',
|
||||
userIds: ['ding-1'],
|
||||
});
|
||||
|
||||
expect(record.workDate).toBe('2026-07-12');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ export class QueryAttendanceRecordsDto {
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
scheduleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
@@ -204,3 +209,15 @@ export class GenerateFromSchedulesDto {
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export class LessonAttendanceQueryDto {
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
export class StartLessonAttendanceDto {
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface ImportProgressEvent {
|
||||
message: string;
|
||||
/** Error message (only when phase === 'error') */
|
||||
error?: string;
|
||||
/** ID of the user who triggered the import (undefined for non-HTTP callers) */
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user