fix permissions and teacher attendance workflows
This commit is contained in:
128
apps/server/src/attendance/attendance-import.service.spec.ts
Normal file
128
apps/server/src/attendance/attendance-import.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
describe('AttendanceImportService', () => {
|
||||
const dingRawRepo = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn() };
|
||||
const studentDingMappingRepo = { findOne: jest.fn() };
|
||||
const dingTalkService = {
|
||||
fetchAttendanceResults: jest.fn(),
|
||||
};
|
||||
const attendanceService = {
|
||||
autoMatchDingRecords: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AttendanceImportService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AttendanceImportService(
|
||||
dingRawRepo as never,
|
||||
studentRepo as never,
|
||||
studentDingMappingRepo as never,
|
||||
dingTalkService as unknown as DingTalkService,
|
||||
attendanceService as unknown as AttendanceService,
|
||||
);
|
||||
});
|
||||
|
||||
it('splits DingTalk requests by at most 50 users and 7 calendar days without offset pagination', async () => {
|
||||
const userIds = Array.from({ length: 51 }, (_, index) => `user-${index + 1}`);
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([]);
|
||||
|
||||
await (service as any).fetchAllPages({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-10',
|
||||
userIds,
|
||||
});
|
||||
|
||||
expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(4);
|
||||
expect(dingTalkService.fetchAttendanceResults.mock.calls.map(([params]) => params)).toEqual([
|
||||
{
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07',
|
||||
userIds: userIds.slice(0, 50),
|
||||
},
|
||||
{
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07',
|
||||
userIds: userIds.slice(50),
|
||||
},
|
||||
{
|
||||
startDate: '2026-07-08',
|
||||
endDate: '2026-07-10',
|
||||
userIds: userIds.slice(0, 50),
|
||||
},
|
||||
{
|
||||
startDate: '2026-07-08',
|
||||
endDate: '2026-07-10',
|
||||
userIds: userIds.slice(50),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an attendance import without DingTalk user IDs', async () => {
|
||||
await expect(
|
||||
(service as any).fetchAllPages({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: [],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(dingTalkService.fetchAttendanceResults).not.toHaveBeenCalled();
|
||||
});
|
||||
it('stores the DingTalk user name returned with the attendance record', async () => {
|
||||
const entity = await (service as any).mapToEntity({
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
});
|
||||
|
||||
expect(entity.userName).toBe('张三');
|
||||
});
|
||||
|
||||
it('fills the student name from the DingTalk mapping when saving an imported record', 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([]);
|
||||
studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 });
|
||||
studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' });
|
||||
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: false,
|
||||
});
|
||||
|
||||
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ userName: '张三' })],
|
||||
{ chunk: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
@@ -115,7 +115,7 @@ export class AttendanceImportService {
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < newRecords.length; i += batchSize) {
|
||||
const batch = newRecords.slice(i, i + batchSize);
|
||||
const entities = batch.map((r) => this.mapToEntity(r));
|
||||
const entities = await Promise.all(batch.map((record) => this.mapToEntity(record)));
|
||||
try {
|
||||
await this.dingRawRepo.save(entities, { chunk: 50 });
|
||||
imported += entities.length;
|
||||
@@ -151,42 +151,92 @@ export class AttendanceImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate through DingTalk attendance API.
|
||||
* The DingTalk API returns max 50 records per page.
|
||||
* DingTalk requires userIds, accepts at most 50 users per request, and
|
||||
* allows a maximum inclusive date range of 7 calendar days.
|
||||
*/
|
||||
private async fetchAllPages(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
}): Promise<DingTalkAttendanceResult[]> {
|
||||
const userIds = [...new Set((params.userIds ?? []).filter(Boolean))];
|
||||
if (userIds.length === 0) {
|
||||
throw new BadRequestException('拉取钉钉考勤必须指定人员范围');
|
||||
}
|
||||
if (params.startDate > params.endDate) {
|
||||
throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
}
|
||||
|
||||
const allResults: DingTalkAttendanceResult[] = [];
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
const userBatches = this.chunk(userIds, 50);
|
||||
const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7);
|
||||
const totalRequests = userBatches.length * dateRanges.length;
|
||||
let completedRequests = 0;
|
||||
|
||||
while (hasMore) {
|
||||
const batch = await this.dingTalkService.fetchAttendanceResults({
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
userIds: params.userIds,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
});
|
||||
|
||||
if (batch.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
for (const range of dateRanges) {
|
||||
for (const users of userBatches) {
|
||||
const batch = await this.dingTalkService.fetchAttendanceResults({
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
userIds: users,
|
||||
});
|
||||
allResults.push(...batch);
|
||||
offset += batch.length;
|
||||
this.emit('fetching', allResults.length, allResults.length + (batch.length < pageSize ? 0 : pageSize), `Fetched ${allResults.length} records...`);
|
||||
// If last page was smaller than pageSize, we're done
|
||||
if (batch.length < pageSize) hasMore = false;
|
||||
completedRequests++;
|
||||
this.emit(
|
||||
'fetching',
|
||||
completedRequests,
|
||||
totalRequests,
|
||||
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
private chunk<T>(items: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
result.push(items.slice(index, index + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private splitDateRanges(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
maxDays: number,
|
||||
): Array<{ startDate: string; endDate: string }> {
|
||||
const ranges: Array<{ startDate: string; endDate: string }> = [];
|
||||
let cursor = this.parseDate(startDate);
|
||||
const end = this.parseDate(endDate);
|
||||
|
||||
while (cursor.getTime() <= end.getTime()) {
|
||||
const rangeEnd = new Date(cursor);
|
||||
rangeEnd.setUTCDate(rangeEnd.getUTCDate() + maxDays - 1);
|
||||
if (rangeEnd.getTime() > end.getTime()) rangeEnd.setTime(end.getTime());
|
||||
ranges.push({
|
||||
startDate: this.formatDate(cursor),
|
||||
endDate: this.formatDate(rangeEnd),
|
||||
});
|
||||
cursor = new Date(rangeEnd);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private parseDate(value: string): Date {
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException(`无效日期: ${value}`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private formatDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query which dingIds already exist to skip duplicates.
|
||||
*/
|
||||
@@ -206,10 +256,10 @@ export class AttendanceImportService {
|
||||
/**
|
||||
* Map a DingTalk API result to a DingAttendanceRaw entity.
|
||||
*/
|
||||
private mapToEntity(r: DingTalkAttendanceResult): DingAttendanceRaw {
|
||||
private async mapToEntity(r: DingTalkAttendanceResult): Promise<DingAttendanceRaw> {
|
||||
const entity = new DingAttendanceRaw();
|
||||
entity.dingUserId = r.userId;
|
||||
entity.userName = ''; // Will be filled from the result if available
|
||||
entity.userName = r.userName || await this.resolveStudentName(r.userId);
|
||||
entity.attendanceDate = r.workDate;
|
||||
entity.dingId = r.checkId;
|
||||
entity.attendanceType = r.checkType || 'OnDuty';
|
||||
@@ -233,6 +283,15 @@ export class AttendanceImportService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
private async resolveStudentName(dingUserId: string): Promise<string> {
|
||||
const mapping = await this.studentDingMappingRepo.findOne({
|
||||
where: { dingUserId },
|
||||
});
|
||||
if (!mapping) return '';
|
||||
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
|
||||
return student?.name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-match unmatched records to students via the dingUserId → userId mapping chain.
|
||||
*
|
||||
|
||||
150
apps/server/src/attendance/attendance.controller.spec.ts
Normal file
150
apps/server/src/attendance/attendance.controller.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
|
||||
describe('AttendanceController — DingTalk import scope', () => {
|
||||
const attendanceService = {
|
||||
getTeacherClassDingUserIds: jest.fn(),
|
||||
getImportableClasses: jest.fn(),
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn(),
|
||||
};
|
||||
const logService = {
|
||||
log: jest.fn(),
|
||||
};
|
||||
|
||||
let controller: AttendanceController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
controller = new AttendanceController(
|
||||
attendanceService as unknown as AttendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService as unknown as OperationLogsService,
|
||||
);
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
matched: 0,
|
||||
errors: [],
|
||||
duration: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults teacher DingTalk import to today when no date range is provided', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z'));
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']);
|
||||
|
||||
await controller.importFromDingTalk({ classId: 8 }, {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
} as never);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-10',
|
||||
endDate: '2026-07-10',
|
||||
userIds: ['ding-today'],
|
||||
autoMatch: true,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses only the selected class students mapped to DingTalk for a teacher import', async () => {
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']);
|
||||
|
||||
await controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', classId: 8, autoMatch: true },
|
||||
{ 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,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => {
|
||||
await expect(
|
||||
controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', users: 'someone-else', autoMatch: true },
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires teachers to select one of their classes', async () => {
|
||||
await expect(
|
||||
controller.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02', autoMatch: true }, {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
it('lists only classes available to the current user for DingTalk import', async () => {
|
||||
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]);
|
||||
|
||||
await expect(
|
||||
controller.getDingTalkImportClasses({
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
} as never),
|
||||
).resolves.toEqual([{ classId: 8, className: '八班' }]);
|
||||
|
||||
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(21, false);
|
||||
});
|
||||
|
||||
it('always auto-matches class-scoped imports even if an old client sends autoMatch=false', async () => {
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
|
||||
|
||||
await controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', classId: 8, autoMatch: false },
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ autoMatch: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
|
||||
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
|
||||
|
||||
await expect(
|
||||
controller.getDingTalkImportClasses({
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'manager',
|
||||
permissions: ['class:edit', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
},
|
||||
} as never),
|
||||
).resolves.toEqual([{ classId: 1, className: '一班' }]);
|
||||
|
||||
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true);
|
||||
|
||||
await controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2', autoMatch: true },
|
||||
{
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'manager',
|
||||
permissions: ['class:edit', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
@@ -34,7 +36,6 @@ import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
|
||||
/** SSE event shape for @Sse() decorator */
|
||||
interface SseEvent {
|
||||
data: string | Record<string, unknown>;
|
||||
@@ -46,7 +47,8 @@ interface SseEvent {
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -58,13 +60,30 @@ export class AttendanceController {
|
||||
private readonly logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
private getTodayDateOnly(): string {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
private canManageAllAttendance(user: RequestUser): boolean {
|
||||
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
|
||||
}
|
||||
|
||||
private assertClassAccess(user: RequestUser, classId: number) {
|
||||
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
async batchCreate(
|
||||
@Body() dto: BatchCreateAttendanceDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchCreate(dto);
|
||||
await this.logService.log({
|
||||
@@ -82,10 +101,7 @@ export class AttendanceController {
|
||||
// ── Generate attendance records from schedules (with optional date range) ──
|
||||
@Post('attendance-records/generate-from-schedules')
|
||||
@RequirePermission('attendance:create')
|
||||
async generateFromSchedules(
|
||||
@Body() dto: GenerateFromSchedulesDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.generateFromSchedules(dto);
|
||||
await this.logService.log({
|
||||
@@ -100,15 +116,17 @@ export class AttendanceController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ── Export attendance records ──
|
||||
@Get('attendance-records/export')
|
||||
@RequirePermission('attendance:export')
|
||||
async exportRecords(
|
||||
@Query() query: QueryAttendanceRecordsDto,
|
||||
@Res() res: Response,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const records = await this.service.findAllForExport(query);
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const classIds = await this.getAccessibleClassIds(req.user);
|
||||
const records = await this.service.findAllForExport(query, classIds);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -140,9 +158,7 @@ export class AttendanceController {
|
||||
});
|
||||
}
|
||||
|
||||
const dateRange = [query.dateFrom, query.dateTo]
|
||||
.filter(Boolean)
|
||||
.join('-') || '全部';
|
||||
const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
@@ -159,8 +175,9 @@ export class AttendanceController {
|
||||
// ── List attendance records with filters ──
|
||||
@Get('attendance-records')
|
||||
@RequirePermission('attendance:view')
|
||||
findAll(@Query() query: QueryAttendanceRecordsDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@@ -190,10 +207,7 @@ export class AttendanceController {
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
@@ -213,29 +227,38 @@ export class AttendanceController {
|
||||
// ── Get distinct classes with attendance records ──
|
||||
@Get('attendance-records/classes')
|
||||
@RequirePermission('attendance:view')
|
||||
getClasses() {
|
||||
return this.service.getClasses();
|
||||
async getClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Attendance summary ──
|
||||
@Get('attendance-records/summary')
|
||||
@RequirePermission('attendance:view')
|
||||
getSummary(@Query() query: AttendanceSummaryQueryDto) {
|
||||
return this.service.getSummary(query);
|
||||
async getSummary(
|
||||
@Query() query: AttendanceSummaryQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Attendance calendar ──
|
||||
@Get('attendance-records/calendar')
|
||||
@RequirePermission('attendance:view')
|
||||
getCalendar(@Query() query: AttendanceCalendarQueryDto) {
|
||||
async getCalendar(
|
||||
@Query() query: AttendanceCalendarQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getCalendar(query);
|
||||
}
|
||||
|
||||
// ── DingAttendance raw records ──
|
||||
@Get('ding-attendance-raw')
|
||||
@RequirePermission('attendance:view')
|
||||
getDingRaw(@Query() query: QueryDingRawDto) {
|
||||
return this.service.getDingRaw(query);
|
||||
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Match a dingtalk record to a student ──
|
||||
@@ -270,7 +293,11 @@ export class AttendanceController {
|
||||
@Res() res: Response,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const reportData = await this.service.getReport(query);
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const reportData = await this.service.getReport(
|
||||
query,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -316,7 +343,10 @@ export class AttendanceController {
|
||||
userAgent,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
@@ -325,13 +355,15 @@ export class AttendanceController {
|
||||
// ── Abnormal attendance alerts ──
|
||||
@Get('attendance-records/alerts')
|
||||
@RequirePermission('attendance:view')
|
||||
getAlerts(
|
||||
async getAlerts(
|
||||
@Request() req: { user: RequestUser },
|
||||
@Query('days') days?: string,
|
||||
@Query('threshold') threshold?: string,
|
||||
) {
|
||||
return this.service.getAlerts(
|
||||
days ? +days : 14,
|
||||
threshold ? +threshold : 3,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,6 +377,12 @@ export class AttendanceController {
|
||||
// DingTalk attendance import with SSE streaming progress
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@Get('attendance-records/import/dingtalk/classes')
|
||||
@RequirePermission('attendance:create')
|
||||
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DingTalk attendance import.
|
||||
* Mirrors `dws attendance check result` pipeline:
|
||||
@@ -352,16 +390,38 @@ export class AttendanceController {
|
||||
*/
|
||||
@Post('attendance-records/import/dingtalk')
|
||||
@RequirePermission('attendance:create')
|
||||
async importFromDingTalk(
|
||||
@Body() dto: DingTalkImportDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req.user);
|
||||
let userIds: string[];
|
||||
|
||||
if (dto.users) {
|
||||
if (!canManageAll) {
|
||||
throw new ForbiddenException('仅管理员可指定钉钉用户范围');
|
||||
}
|
||||
userIds = dto.users
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
if (!dto.classId) {
|
||||
throw new BadRequestException('请选择要拉取考勤的班级');
|
||||
}
|
||||
userIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
dto.classId,
|
||||
canManageAll,
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = dto.start ?? this.getTodayDateOnly();
|
||||
const endDate = dto.end ?? startDate;
|
||||
|
||||
const result = await this.importService.importFromDingTalk({
|
||||
startDate: dto.start,
|
||||
endDate: dto.end,
|
||||
userIds: dto.users?.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
autoMatch: dto.autoMatch ?? true,
|
||||
startDate,
|
||||
endDate,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
await this.logService.log({
|
||||
@@ -369,7 +429,7 @@ export class AttendanceController {
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '钉钉考勤导入',
|
||||
detail: `${dto.start}~${dto.end}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
||||
detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -385,7 +445,7 @@ export class AttendanceController {
|
||||
* execute in the standard request pipeline before the SSE handler is invoked.
|
||||
* If this ever breaks after a NestJS upgrade, verify guard execution order.
|
||||
*/
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@RequirePermission('attendance:view')
|
||||
importProgressStream(): Observable<SseEvent> {
|
||||
return new Observable<SseEvent>((subscriber) => {
|
||||
@@ -401,5 +461,4 @@ export class AttendanceController {
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, 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, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Student } from '../entities/student.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { BatchCreateAttendanceDto } from './dto/attendance.dto';
|
||||
|
||||
describe('AttendanceService — batchCreate', () => {
|
||||
@@ -51,6 +52,7 @@ describe('AttendanceService — batchCreate', () => {
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
|
||||
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
|
||||
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -105,3 +107,115 @@ describe('AttendanceService — batchCreate', () => {
|
||||
// Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceService — teacher DingTalk class scope', () => {
|
||||
const classTeacherRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
const createService = () =>
|
||||
new AttendanceService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classTeacherRepo as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns only mapped active students for a class assigned to the teacher', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 2 },
|
||||
{ studentId: 1 },
|
||||
{ studentId: 2 },
|
||||
]);
|
||||
mappingRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, dingUserId: 'ding-1' },
|
||||
{ studentId: 2, dingUserId: 'ding-2' },
|
||||
]);
|
||||
|
||||
await expect(createService().getTeacherClassDingUserIds(21, 8, false)).resolves.toEqual([
|
||||
'ding-1',
|
||||
'ding-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists distinct classes assigned to a teacher', async () => {
|
||||
classTeacherRepo.find.mockResolvedValue([
|
||||
{ classId: 8, class: { name: '八班' } },
|
||||
{ classId: 8, class: { name: '八班' } },
|
||||
{ classId: 9, class: { name: '九班' } },
|
||||
]);
|
||||
|
||||
await expect(createService().getImportableClasses(21, false)).resolves.toEqual([
|
||||
{ classId: 8, className: '八班' },
|
||||
{ classId: 9, className: '九班' },
|
||||
]);
|
||||
expect(classTeacherRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 21 },
|
||||
relations: ['class'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a class that is not assigned to the teacher', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(createService().getTeacherClassDingUserIds(21, 99, false)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceService — DingTalk raw query', () => {
|
||||
it('returns the paginated shape and filters by class student mappings', async () => {
|
||||
const qb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[{ id: 1 }], 1]),
|
||||
};
|
||||
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
|
||||
const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
|
||||
const service = new AttendanceService(
|
||||
{} as never,
|
||||
dingRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getDingRaw({ classId: 8, dateFrom: '2026-07-01', page: 2, pageSize: 10 }),
|
||||
).resolves.toEqual({ list: [{ id: 1 }], total: 1, page: 2, pageSize: 10 });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('ar.dingUserId IN (:...dingUserIds)', {
|
||||
dingUserIds: ['ding-3'],
|
||||
});
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('ar.attendanceDate >= :dateFrom', {
|
||||
dateFrom: '2026-07-01',
|
||||
});
|
||||
expect(qb.skip).toHaveBeenCalledWith(10);
|
||||
expect(qb.take).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
|
||||
import {
|
||||
BatchCreateAttendanceDto,
|
||||
AttendanceSummaryQueryDto,
|
||||
@@ -35,8 +35,83 @@ export class AttendanceService {
|
||||
private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private classTeacherRepo: Repository<ClassTeacher>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
|
||||
}
|
||||
|
||||
/** List classes the current user may select for DingTalk attendance import. */
|
||||
async getImportableClasses(userId: number, isSuperAdmin = false) {
|
||||
if (isSuperAdmin) {
|
||||
const classes = await this.classRepo.find({
|
||||
where: { isArchived: false },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
return classes.map((item) => ({ classId: item.id, className: item.name }));
|
||||
}
|
||||
|
||||
const assignments = await this.classTeacherRepo.find({
|
||||
where: { userId },
|
||||
relations: ['class'],
|
||||
});
|
||||
const classes = new Map<number, string>();
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.class && !assignment.class.isArchived) {
|
||||
classes.set(assignment.classId, assignment.class.name);
|
||||
}
|
||||
}
|
||||
return [...classes.entries()]
|
||||
.map(([classId, className]) => ({ classId, className }))
|
||||
.sort((left, right) => left.className.localeCompare(right.className, 'zh-CN'));
|
||||
}
|
||||
|
||||
/** Resolve the DingTalk users a teacher may import for one assigned class. */
|
||||
async getTeacherClassDingUserIds(
|
||||
userId: number,
|
||||
classId: number,
|
||||
isSuperAdmin = false,
|
||||
): Promise<string[]> {
|
||||
if (!isSuperAdmin) {
|
||||
const assignment = await this.classTeacherRepo.findOne({
|
||||
where: { userId, classId },
|
||||
});
|
||||
if (!assignment) {
|
||||
throw new BadRequestException('只能拉取自己任教班级的考勤记录');
|
||||
}
|
||||
} else {
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
|
||||
}
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) {
|
||||
throw new BadRequestException('该班级暂无在读学生');
|
||||
}
|
||||
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { studentId: In(studentIds) },
|
||||
});
|
||||
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||
if (userIds.length === 0) {
|
||||
throw new BadRequestException('该班级学生尚未同步钉钉账号');
|
||||
}
|
||||
return userIds.sort();
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
async batchCreate(dto: BatchCreateAttendanceDto) {
|
||||
if (!dto.records || dto.records.length === 0) {
|
||||
@@ -169,11 +244,15 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto) {
|
||||
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 };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -267,7 +346,7 @@ export class AttendanceService {
|
||||
source?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
}, accessibleClassIds?: number[]) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
|
||||
@@ -278,6 +357,10 @@ export class AttendanceService {
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -302,18 +385,18 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Get distinct classes with attendance records ──
|
||||
async getClasses() {
|
||||
async getClasses(accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('ar')
|
||||
.select('DISTINCT ar.classId', 'classId')
|
||||
.where('ar.classId IS NOT NULL');
|
||||
|
||||
|
||||
const rows = await qb
|
||||
.orderBy('ar.classId', 'ASC')
|
||||
.getRawMany();
|
||||
const rows = accessibleClassIds
|
||||
? accessibleClassIds.map((classId) => ({ classId }))
|
||||
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
|
||||
|
||||
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
|
||||
const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
|
||||
if (classIds.length === 0) return [];
|
||||
|
||||
const where = { id: In(classIds) };
|
||||
@@ -323,17 +406,44 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── DingAttendance raw records ──
|
||||
async getDingRaw(query: QueryDingRawDto) {
|
||||
const where: any = {};
|
||||
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
const qb = this.dingRawRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
|
||||
if (query.matchStatus) {
|
||||
where.matchStatus = query.matchStatus;
|
||||
qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
if (query.dateTo) {
|
||||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||
}
|
||||
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
|
||||
if (scopedClassIds) {
|
||||
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: In(scopedClassIds), status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { studentId: In(studentIds) },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
return this.dingRawRepo.find({
|
||||
where,
|
||||
relations: ['matchedStudent'],
|
||||
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
|
||||
});
|
||||
qb.orderBy('ar.attendanceDate', 'DESC')
|
||||
.addOrderBy('ar.checkInTime', 'ASC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
const [list, total] = await qb.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
// ── Match a dingtalk record to a student ──
|
||||
@@ -385,7 +495,7 @@ export class AttendanceService {
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
}) {
|
||||
}, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
@@ -393,6 +503,10 @@ export class AttendanceService {
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -445,7 +559,7 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Class-based attendance report ──
|
||||
async getReport(query: AttendanceReportQueryDto) {
|
||||
async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoin('ar.class', 'class')
|
||||
@@ -456,6 +570,10 @@ export class AttendanceService {
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -509,7 +627,7 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
// ── Attendance alerts: detect consecutive absences/late ──
|
||||
async getAlerts(days: number = 14, threshold: number = 3) {
|
||||
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||
@@ -520,9 +638,13 @@ export class AttendanceService {
|
||||
.leftJoinAndSelect('a.class', 'class');
|
||||
|
||||
|
||||
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 });
|
||||
}
|
||||
const records = await qb
|
||||
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
|
||||
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
|
||||
.orderBy('a.studentId', 'ASC')
|
||||
.addOrderBy('a.attendanceDate', 'DESC')
|
||||
.getMany();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
|
||||
|
||||
describe('DingTalkService — attendance records', () => {
|
||||
const originalAppKey = process.env.DINGTALK_APP_KEY;
|
||||
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
|
||||
let service: DingTalkService;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DINGTALK_APP_KEY = 'test-app-key';
|
||||
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
|
||||
service = new DingTalkService({} as never, {} as never);
|
||||
Object.assign(service, {
|
||||
accessToken: 'test-token',
|
||||
tokenExpiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
|
||||
else process.env.DINGTALK_APP_KEY = originalAppKey;
|
||||
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
|
||||
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
|
||||
});
|
||||
|
||||
it('sends the required userIds and does not send unsupported offset/limit fields', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', recordresult: [] }),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await service.fetchAttendanceResults({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
});
|
||||
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
|
||||
checkDateFrom: '2026-07-01 00:00:00',
|
||||
checkDateTo: '2026-07-07 23:59:59',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects missing userIds before calling DingTalk', async () => {
|
||||
await expect(
|
||||
service.fetchAttendanceResults({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
}),
|
||||
).rejects.toThrow('userIds');
|
||||
expect(global.fetch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,29 @@ export class QueryDingRawDto {
|
||||
@IsString()
|
||||
@IsIn(['unmatched', 'pending', 'matched'])
|
||||
matchStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class QueryAttendanceRecordsDto {
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsDateString,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { IsOptional, IsString, IsDateString, IsInt, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
@@ -15,24 +6,30 @@ import { Type } from 'class-transformer';
|
||||
* Mirrors `dws attendance check result` flags.
|
||||
*/
|
||||
export class DingTalkImportDto {
|
||||
/** Start date (YYYY-MM-DD), required */
|
||||
@IsNotEmpty()
|
||||
/** Start date (YYYY-MM-DD). Defaults to today when omitted. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
start: string;
|
||||
start?: string;
|
||||
|
||||
/** End date (YYYY-MM-DD), required, max 1 month span */
|
||||
@IsNotEmpty()
|
||||
/** End date (YYYY-MM-DD). Defaults to start/today when omitted. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
end: string;
|
||||
end?: string;
|
||||
|
||||
/** Comma-separated DingTalk user IDs, optional (default: all org users) */
|
||||
/** Target class. Required for non-super-admin users. */
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
/** Comma-separated DingTalk user IDs. Super-admin override only. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
users?: string;
|
||||
|
||||
/** Auto-match imported records to students after import */
|
||||
/** @deprecated Imports are always matched through DingTalk user mappings. */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
autoMatch?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user